diff --git a/apps/server/src/github/Errors.ts b/apps/server/src/github/Errors.ts new file mode 100644 index 00000000..b391e661 --- /dev/null +++ b/apps/server/src/github/Errors.ts @@ -0,0 +1,20 @@ +import { Schema } from "effect"; +import { GitHubCliError } from "../git/Errors.ts"; + +/** + * GitHubServiceError - GitHub issue/notification service error. + */ +export class GitHubServiceError extends Schema.TaggedErrorClass()( + "GitHubServiceError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `GitHub service failed in ${this.operation}: ${this.detail}`; + } +} + +export type GitHubIssueServiceError = GitHubServiceError | GitHubCliError; diff --git a/apps/server/src/github/Layers/GitHub.ts b/apps/server/src/github/Layers/GitHub.ts new file mode 100644 index 00000000..a881ca56 --- /dev/null +++ b/apps/server/src/github/Layers/GitHub.ts @@ -0,0 +1,218 @@ +/** + * GitHub Layer - Implementation of the GitHub issue service using the `gh` CLI. + * + * Delegates all GitHub CLI interactions to the existing GitHubCli service. + */ +import { Effect, Layer, Schema } from "effect"; +import type { + GitHubGetIssueResult, + GitHubIssueComment, + GitHubIssueDetail, + GitHubIssueSummary, + GitHubListIssuesResult, + GitHubPostCommentResult, +} from "@okcode/contracts"; +import { GitHubCli } from "../../git/Services/GitHubCli.ts"; +import { GitHubServiceError } from "../Errors.ts"; +import { GitHub, type GitHubShape } from "../Services/GitHub.ts"; + +// ── Raw CLI output schemas ────────────────────────────────────────── + +const RawIssueLabel = Schema.Struct({ + name: Schema.String, + color: Schema.optional(Schema.String), +}); + +const RawIssueAuthor = Schema.Struct({ + login: Schema.String, + avatarUrl: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawIssueSummary = Schema.Struct({ + number: Schema.Number, + title: Schema.String, + state: Schema.String, + labels: Schema.Array(RawIssueLabel), + author: Schema.NullOr(RawIssueAuthor), + url: Schema.String, + updatedAt: Schema.String, +}); + +const RawIssueComment = Schema.Struct({ + id: Schema.String, + body: Schema.String, + author: Schema.NullOr(RawIssueAuthor), + createdAt: Schema.String, + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawIssueDetail = Schema.Struct({ + number: Schema.Number, + title: Schema.String, + state: Schema.String, + body: Schema.String, + labels: Schema.Array(RawIssueLabel), + author: Schema.NullOr(RawIssueAuthor), + assignees: Schema.Array(RawIssueAuthor), + comments: Schema.Array(RawIssueComment), + milestone: Schema.optional(Schema.NullOr(Schema.Struct({ title: Schema.String }))), + url: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, +}); + +// ── Normalization helpers ─────────────────────────────────────────── + +function normalizeAuthor( + raw: Schema.Schema.Type | null, +): GitHubIssueSummary["author"] { + if (!raw) return null; + return { + login: raw.login as any, + avatarUrl: raw.avatarUrl ?? "", + url: raw.url ?? `https://github.com/${raw.login}`, + name: raw.name ?? null, + bio: null, + company: null, + location: null, + }; +} + +function normalizeIssueSummary( + raw: Schema.Schema.Type, +): GitHubIssueSummary { + return { + number: raw.number as any, + title: raw.title as any, + state: raw.state.toLowerCase() === "closed" ? "closed" : ("open" as any), + labels: raw.labels.map((l) => ({ name: l.name as any, color: l.color ?? "" })), + author: normalizeAuthor(raw.author), + url: raw.url, + updatedAt: raw.updatedAt, + }; +} + +function normalizeIssueComment( + raw: Schema.Schema.Type, +): GitHubIssueComment { + return { + id: raw.id as any, + body: raw.body, + author: normalizeAuthor(raw.author), + createdAt: raw.createdAt, + url: raw.url ?? null, + }; +} + +function normalizeIssueDetail(raw: Schema.Schema.Type): GitHubIssueDetail { + return { + number: raw.number as any, + title: raw.title as any, + state: raw.state.toLowerCase() === "closed" ? "closed" : ("open" as any), + body: raw.body, + labels: raw.labels.map((l) => ({ name: l.name as any, color: l.color ?? "" })), + author: normalizeAuthor(raw.author), + assignees: raw.assignees.map((a) => normalizeAuthor(a)!).filter(Boolean) as any, + comments: raw.comments.map(normalizeIssueComment), + milestone: raw.milestone?.title ?? null, + url: raw.url, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + commentsCount: raw.comments.length as any, + }; +} + +// ── Helper: parse JSON from gh CLI output ─────────────────────────── + +function decodeGhJson( + raw: string, + schema: S, + operation: string, + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new GitHubServiceError({ + operation, + detail: error instanceof Error ? `${invalidDetail}: ${error.message}` : invalidDetail, + cause: error, + }), + ), + ); +} + +// ── Layer implementation ──────────────────────────────────────────── + +const makeGitHub = Effect.gen(function* () { + const gitHubCli = yield* GitHubCli; + + const listIssues: GitHubShape["listIssues"] = (input) => + Effect.gen(function* () { + const args: string[] = [ + "issue", + "list", + "--json", + "number,title,state,labels,author,url,updatedAt", + "--limit", + String(input.limit ?? 10), + ]; + if (input.assignee) { + args.push("--assignee", input.assignee); + } + if (input.state) { + args.push("--state", input.state); + } + if (input.labels) { + args.push("--label", input.labels); + } + + const result = yield* gitHubCli.execute({ cwd: input.cwd, args }); + const raw = yield* decodeGhJson( + result.stdout, + Schema.Array(RawIssueSummary), + "listIssues", + "Failed to parse issue list output", + ); + return { issues: raw.map(normalizeIssueSummary) } satisfies GitHubListIssuesResult; + }); + + const getIssue: GitHubShape["getIssue"] = (input) => + Effect.gen(function* () { + const result = yield* gitHubCli.execute({ + cwd: input.cwd, + args: [ + "issue", + "view", + String(input.number), + "--json", + "number,title,state,body,labels,author,assignees,comments,milestone,url,createdAt,updatedAt", + ], + }); + const raw = yield* decodeGhJson( + result.stdout, + RawIssueDetail, + "getIssue", + "Failed to parse issue detail output", + ); + return { issue: normalizeIssueDetail(raw) } satisfies GitHubGetIssueResult; + }); + + const postComment: GitHubShape["postComment"] = (input) => + Effect.gen(function* () { + const result = yield* gitHubCli.execute({ + cwd: input.cwd, + args: ["issue", "comment", String(input.issueNumber), "--body", input.body], + }); + // gh issue comment outputs the comment URL on success + const url = result.stdout.trim() || ""; + return { url } satisfies GitHubPostCommentResult; + }); + + const service = { listIssues, getIssue, postComment } satisfies GitHubShape; + return service; +}); + +export const GitHubLive = Layer.effect(GitHub, makeGitHub); diff --git a/apps/server/src/github/Services/GitHub.ts b/apps/server/src/github/Services/GitHub.ts new file mode 100644 index 00000000..f746e4de --- /dev/null +++ b/apps/server/src/github/Services/GitHub.ts @@ -0,0 +1,52 @@ +/** + * GitHub - Effect service contract for GitHub issue operations. + * + * Provides listing, retrieval, and commenting on GitHub issues + * via the `gh` CLI. + * + * @module GitHub + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type { + GitHubGetIssueInput, + GitHubGetIssueResult, + GitHubListIssuesInput, + GitHubListIssuesResult, + GitHubPostCommentInput, + GitHubPostCommentResult, +} from "@okcode/contracts"; +import type { GitHubIssueServiceError } from "../Errors.ts"; + +/** + * GitHubShape - Service API for GitHub issue operations. + */ +export interface GitHubShape { + /** + * List issues for the current repository, optionally filtered by assignee/state/labels. + */ + readonly listIssues: ( + input: GitHubListIssuesInput, + ) => Effect.Effect; + + /** + * Get a single issue with its full body, comments, and metadata. + */ + readonly getIssue: ( + input: GitHubGetIssueInput, + ) => Effect.Effect; + + /** + * Post a comment on a GitHub issue. + */ + readonly postComment: ( + input: GitHubPostCommentInput, + ) => Effect.Effect; +} + +/** + * GitHub - Service tag for GitHub issue operations. + */ +export class GitHub extends ServiceMap.Service()( + "okcode/github/Services/GitHub", +) {} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index b1886ab3..bc65acf2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -418,6 +418,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + githubRef: event.payload.githubRef ? JSON.stringify(event.payload.githubRef) : null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -440,6 +441,13 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.githubRef !== undefined + ? { + githubRef: event.payload.githubRef + ? JSON.stringify(event.payload.githubRef) + : null, + } + : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 21162f76..03100c47 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -542,25 +542,36 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { deletedAt: row.deletedAt, })); - const threads: Array = threadRows.map((row) => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - model: row.model, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - })); + const threads: Array = threadRows.map((row) => { + let githubRef: OrchestrationThread["githubRef"]; + try { + if (row.githubRef) { + githubRef = JSON.parse(row.githubRef); + } + } catch { + // Ignore invalid JSON — treat as no ref + } + return { + id: row.threadId, + projectId: row.projectId, + title: row.title, + model: row.model, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + ...(githubRef ? { githubRef } : {}), + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + }; + }); const snapshot = { snapshotSequence: computeSnapshotSequence(stateRows), diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0234c4ec..00f23dd1 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -190,6 +190,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + ...(command.githubRef ? { githubRef: command.githubRef } : {}), createdAt: command.createdAt, updatedAt: command.createdAt, }, @@ -264,6 +265,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.model !== undefined ? { model: command.model } : {}), ...(command.branch !== undefined ? { branch: command.branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.githubRef !== undefined ? { githubRef: command.githubRef } : {}), updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index feab1053..a88f32f4 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -257,6 +257,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + ...(payload.githubRef ? { githubRef: payload.githubRef } : {}), latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -298,6 +299,7 @@ export function projectEvent( ...(payload.model !== undefined ? { model: payload.model } : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.githubRef !== undefined ? { githubRef: payload.githubRef } : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 10192697..bae3adba 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -28,6 +28,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + github_ref, latest_turn_id, created_at, updated_at, @@ -42,6 +43,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.githubRef}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -56,6 +58,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + github_ref = excluded.github_ref, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -77,6 +80,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + github_ref AS "githubRef", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -100,6 +104,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + github_ref AS "githubRef", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 4e1c6d40..003477ab 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -29,6 +29,7 @@ import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplemen import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; import Migration0016 from "./Migrations/016_ProjectionThreadsInteractionModeChatCodePlan.ts"; import Migration0017 from "./Migrations/017_EnvironmentVariables.ts"; +import Migration0018 from "./Migrations/018_ProjectionThreadsGithubRef.ts"; import { Effect } from "effect"; /** @@ -59,6 +60,7 @@ const loader = Migrator.fromRecord({ "15_ProjectionTurnsSourceProposedPlan": Migration0015, "16_ProjectionThreadsInteractionModeChatCodePlan": Migration0016, "17_EnvironmentVariables": Migration0017, + "18_ProjectionThreadsGithubRef": Migration0018, }); /** diff --git a/apps/server/src/persistence/Migrations/018_ProjectionThreadsGithubRef.ts b/apps/server/src/persistence/Migrations/018_ProjectionThreadsGithubRef.ts new file mode 100644 index 00000000..b583d8d7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/018_ProjectionThreadsGithubRef.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Add github_ref column (JSON blob) to projection_threads. + yield* sql` + ALTER TABLE projection_threads ADD COLUMN github_ref TEXT DEFAULT NULL + `.pipe(Effect.ignore); +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a9df9b2b..d71ee836 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,6 +14,7 @@ import { ThreadId, TurnId, } from "@okcode/contracts"; +import type { GitHubRef } from "@okcode/contracts"; import { Option, Schema, ServiceMap } from "effect"; import type { Effect } from "effect"; @@ -28,6 +29,7 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + githubRef: Schema.NullOr(Schema.String), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 3036b65c..e68cccc1 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -38,6 +38,7 @@ import { PrReviewProjectionLive } from "./prReview/Layers/PrReviewProjection"; import { WorkflowEngineLive } from "./prReview/Layers/WorkflowEngine"; import { MergeConflictResolverLive } from "./prReview/Layers/MergeConflictResolver"; import { PrReviewLive } from "./prReview/Layers/PrReview"; +import { GitHubLive } from "./github/Layers/GitHub"; import { PtyAdapter } from "./terminal/Services/PTY"; type RuntimePtyAdapterLoader = { @@ -152,11 +153,14 @@ export function makeServerRuntimeServicesLayer() { Layer.provideMerge(MergeConflictResolverLive.pipe(Layer.provideMerge(GitCoreLive))), ); + const githubLayer = GitHubLive.pipe(Layer.provideMerge(GitHubCliLive)); + return Layer.mergeAll( orchestrationReactorLayer, GitCoreLive, gitManagerLayer, prReviewLayer, + githubLayer, terminalLayer, KeybindingsLive, SkillServiceLive, diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 7a54f42b..ce123c25 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -88,6 +88,7 @@ import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@okcode/shared/schemaJson"; import { PrReview } from "./prReview/Services/PrReview.ts"; +import { GitHub } from "./github/Services/GitHub.ts"; import { GitActionExecutionError } from "./git/Errors.ts"; import { EnvironmentVariables } from "./persistence/Services/EnvironmentVariables.ts"; import { SkillService } from "./skills/SkillService.ts"; @@ -290,6 +291,7 @@ export type ServerRuntimeServices = | GitManager | GitCore | PrReview + | GitHub | TerminalManager | Keybindings | SkillService @@ -744,6 +746,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; const prReview = yield* PrReview; + const github = yield* GitHub; const { openInEditor, openInFileManager, revealInFileManager } = yield* Open; const environmentVariables = yield* EnvironmentVariables; const skillService = yield* SkillService; @@ -1300,6 +1303,23 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return result; } + // ── GitHub issue methods ────────────────────────────────────── + + case WS_METHODS.githubListIssues: { + const body = stripRequestTag(request.body); + return yield* github.listIssues(body); + } + + case WS_METHODS.githubGetIssue: { + const body = stripRequestTag(request.body); + return yield* github.getIssue(body); + } + + case WS_METHODS.githubPostComment: { + const body = stripRequestTag(request.body); + return yield* github.postComment(body); + } + case WS_METHODS.gitListBranches: { const body = stripRequestTag(request.body); const snapshot = yield* projectionReadModelQuery.getSnapshot(); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 74de6fae..16f750e5 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -39,6 +39,7 @@ export function buildLocalDraftThread( branch: draftThread.branch, worktreePath: draftThread.worktreePath, worktreeBaseBranch: null, + ...(draftThread.githubRef ? { githubRef: draftThread.githubRef } : {}), turnDiffSummaries: [], activities: [], proposedPlans: [], @@ -94,6 +95,11 @@ export interface PullRequestDialogState { key: number; } +export interface IssueDialogState { + initialReference: string | null; + key: number; +} + export function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a7f0707f..e1efe996 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,6 +3,8 @@ import { DEFAULT_CHAT_FILE_MIME_TYPE, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, + type GitHubIssueDetail, + type GitHubRef, type MessageId, type ProjectScript, type ModelSlug, @@ -173,6 +175,7 @@ import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { IssueThreadDialog } from "./IssueThreadDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; @@ -209,6 +212,7 @@ import { deriveComposerSendState, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, + IssueDialogState, PullRequestDialogState, QueuedMessage, readFileAsDataUrl, @@ -532,6 +536,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); + const [issueDialogState, setIssueDialogState] = useState(null); const [pendingProjectScriptRun, setPendingProjectScriptRun] = useState<{ script: ProjectScript; inputIds: string[]; @@ -714,6 +719,24 @@ export default function ChatView({ threadId }: ChatViewProps) { setPullRequestDialogState(null); }, []); + const openIssueDialog = useCallback( + (reference?: string) => { + if (!isLocalDraftThread) { + return; + } + setIssueDialogState({ + initialReference: reference ?? null, + key: Date.now(), + }); + setComposerHighlightedItemId(null); + }, + [isLocalDraftThread], + ); + + const closeIssueDialog = useCallback(() => { + setIssueDialogState(null); + }, []); + const openOrReuseProjectDraftThread = useCallback( async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { if (!activeProject) { @@ -776,6 +799,68 @@ export default function ChatView({ threadId }: ChatViewProps) { [openOrReuseProjectDraftThread], ); + const handleStartIssueThread = useCallback( + async (input: { issue: GitHubIssueDetail; mode: "local" | "worktree" }) => { + if (!activeProject) { + return; + } + // Extract owner/repo from the issue URL + let owner = ""; + let repo = ""; + try { + const url = new URL(input.issue.url); + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length >= 2) { + owner = parts[0]!; + repo = parts[1]!; + } + } catch { + // Fallback: cannot parse URL + } + + if (!owner || !repo) { + return; + } + + const githubRef = { + kind: "issue" as const, + owner, + repo, + number: input.issue.number, + } satisfies GitHubRef; + + // Always create a fresh thread for an issue + clearProjectDraftThreadId(activeProject.id); + const nextId = newThreadId(); + setProjectDraftThreadId(activeProject.id, nextId, { + createdAt: new Date().toISOString(), + runtimeMode: DEFAULT_RUNTIME_MODE, + envMode: input.mode, + githubRef, + }); + + // Pre-populate the composer with an issue context prompt + const { setPrompt: storeSetPrompt } = useComposerDraftStore.getState(); + const labelsText = + input.issue.labels.length > 0 + ? `Labels: ${input.issue.labels.map((l) => l.name).join(", ")}\n` + : ""; + const bodyPreview = input.issue.body + ? input.issue.body.slice(0, 2000) + (input.issue.body.length > 2000 ? "\n..." : "") + : ""; + storeSetPrompt( + nextId, + `Resolve GitHub issue #${input.issue.number}: ${input.issue.title}\n\n${labelsText}${bodyPreview ? `${bodyPreview}\n\n` : ""}Please analyze this issue and implement a fix.`, + ); + + await navigate({ + to: "/$threadId", + params: { threadId: nextId }, + }); + }, + [activeProject, clearProjectDraftThreadId, navigate, setProjectDraftThreadId], + ); + useEffect(() => { if (!activeThread?.id) return; if (!latestTurnSettled) return; diff --git a/apps/web/src/components/IssueThreadDialog.tsx b/apps/web/src/components/IssueThreadDialog.tsx new file mode 100644 index 00000000..d2cc12ba --- /dev/null +++ b/apps/web/src/components/IssueThreadDialog.tsx @@ -0,0 +1,270 @@ +import type { GitHubIssueDetail } from "@okcode/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { githubGetIssueQueryOptions } from "~/lib/githubReactQuery"; +import { cn } from "~/lib/utils"; +import { parseIssueReferenceParts } from "~/issueReference"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Spinner } from "./ui/spinner"; + +interface IssueThreadDialogProps { + open: boolean; + cwd: string | null; + initialReference: string | null; + onOpenChange: (open: boolean) => void; + onStartThread: (input: { + issue: GitHubIssueDetail; + mode: "local" | "worktree"; + }) => Promise | void; +} + +export function IssueThreadDialog({ + open, + cwd, + initialReference, + onOpenChange, + onStartThread, +}: IssueThreadDialogProps) { + const referenceInputRef = useRef(null); + const [reference, setReference] = useState(initialReference ?? ""); + const [referenceDirty, setReferenceDirty] = useState(false); + const [startingMode, setStartingMode] = useState<"local" | "worktree" | null>(null); + const [debouncedReference, referenceDebouncer] = useDebouncedValue( + reference, + { wait: 450 }, + (debouncerState) => ({ isPending: debouncerState.isPending }), + ); + + useEffect(() => { + if (!open) return; + setReference(initialReference ?? ""); + setReferenceDirty(false); + setStartingMode(null); + }, [initialReference, open]); + + useEffect(() => { + if (!open) return; + const frame = window.requestAnimationFrame(() => { + referenceInputRef.current?.focus(); + referenceInputRef.current?.select(); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, [open]); + + const parsedReference = parseIssueReferenceParts(reference); + const parsedDebouncedReference = parseIssueReferenceParts(debouncedReference); + const parsedNumber = parsedReference ? Number(parsedReference.number) : null; + const parsedDebouncedNumber = parsedDebouncedReference + ? Number(parsedDebouncedReference.number) + : null; + + const resolveIssueQuery = useQuery( + githubGetIssueQueryOptions({ + cwd, + number: open ? parsedDebouncedNumber : null, + }), + ); + + const resolvedIssue: GitHubIssueDetail | null = + parsedNumber !== null && parsedNumber === parsedDebouncedNumber + ? (resolveIssueQuery.data?.issue ?? null) + : null; + + const isResolving = + open && + parsedNumber !== null && + resolvedIssue === null && + (referenceDebouncer.state.isPending || + parsedNumber !== parsedDebouncedNumber || + resolveIssueQuery.isPending || + resolveIssueQuery.isFetching); + + const statusTone = useMemo(() => { + switch (resolvedIssue?.state) { + case "closed": + return "text-violet-600 dark:text-violet-300/90"; + case "open": + return "text-emerald-600 dark:text-emerald-300/90"; + default: + return "text-muted-foreground"; + } + }, [resolvedIssue?.state]); + + const handleConfirm = useCallback( + async (mode: "local" | "worktree") => { + if (!parsedNumber) { + setReferenceDirty(true); + return; + } + if (!resolvedIssue || !cwd) { + return; + } + setStartingMode(mode); + try { + await onStartThread({ issue: resolvedIssue, mode }); + onOpenChange(false); + } finally { + setStartingMode(null); + } + }, + [cwd, onOpenChange, onStartThread, parsedNumber, resolvedIssue], + ); + + const validationMessage = !referenceDirty + ? null + : reference.trim().length === 0 + ? "Paste a GitHub issue URL or enter 123 / #123." + : parsedReference === null + ? "Use a GitHub issue URL, 123, or #123." + : null; + const errorMessage = + validationMessage ?? + (resolvedIssue === null && resolveIssueQuery.isError + ? resolveIssueQuery.error instanceof Error + ? resolveIssueQuery.error.message + : "Failed to resolve issue." + : null); + + return ( + { + if (!startingMode) { + onOpenChange(nextOpen); + } + }} + > + + + Start Thread from Issue + + Resolve a GitHub issue, then create a new coding thread with its context pre-loaded for + the AI agent. + + + + + + {resolvedIssue ? ( +
+
+
+

{resolvedIssue.title}

+

+ #{resolvedIssue.number} + {resolvedIssue.author ? ` · ${resolvedIssue.author.login}` : ""} + {resolvedIssue.commentsCount > 0 + ? ` · ${resolvedIssue.commentsCount} comment${resolvedIssue.commentsCount !== 1 ? "s" : ""}` + : ""} +

+
+ + {resolvedIssue.state} + +
+ {resolvedIssue.labels.length > 0 ? ( +
+ {resolvedIssue.labels.map((label) => ( + + {label.color ? ( + + ) : null} + {label.name} + + ))} +
+ ) : null} + {resolvedIssue.body ? ( +

+ {resolvedIssue.body.slice(0, 300)} + {resolvedIssue.body.length > 300 ? "..." : ""} +

+ ) : null} +
+ ) : null} + + {isResolving ? ( +
+ + Resolving issue... +
+ ) : null} + + {errorMessage ?

{errorMessage}

: null} +
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 9a0b047f..0a5b47b6 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -3,6 +3,7 @@ import { type ClaudeCodeEffort, type CodexReasoningEffort, DEFAULT_REASONING_EFFORT_BY_PROVIDER, + type GitHubRef, ProjectId, ProviderInteractionMode, ProviderKind, @@ -171,6 +172,7 @@ export interface DraftThreadState { branch: string | null; worktreePath: string | null; worktreeBaseBranch?: string | null; + githubRef?: GitHubRef | undefined; envMode: DraftThreadEnvMode; } @@ -196,6 +198,7 @@ interface ComposerDraftStoreState { envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + githubRef?: GitHubRef | null; }, ) => void; setDraftThreadContext: ( @@ -209,6 +212,7 @@ interface ComposerDraftStoreState { envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + githubRef?: GitHubRef | null; }, ) => void; setDraftThreadTitle: (threadId: ThreadId, title: string) => void; @@ -1105,6 +1109,10 @@ export const useComposerDraftStore = create()( options?.worktreePath === undefined ? (existingThread?.worktreePath ?? null) : (options.worktreePath ?? null); + const nextGithubRef = + options?.githubRef === undefined + ? existingThread?.githubRef + : (options.githubRef ?? undefined); const nextDraftThread: DraftThreadState = { projectId, createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), @@ -1120,6 +1128,7 @@ export const useComposerDraftStore = create()( ? (existingThread?.branch ?? null) : (options.branch ?? null), worktreePath: nextWorktreePath, + ...(nextGithubRef ? { githubRef: nextGithubRef } : {}), envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), @@ -1134,6 +1143,7 @@ export const useComposerDraftStore = create()( existingThread.interactionMode === nextDraftThread.interactionMode && existingThread.branch === nextDraftThread.branch && existingThread.worktreePath === nextDraftThread.worktreePath && + existingThread.githubRef === nextDraftThread.githubRef && existingThread.envMode === nextDraftThread.envMode; if (hasSameProjectMapping && hasSameDraftThread) { return state; @@ -1182,6 +1192,8 @@ export const useComposerDraftStore = create()( options.worktreePath === undefined ? existing.worktreePath : (options.worktreePath ?? null); + const nextGithubRef = + options.githubRef === undefined ? existing.githubRef : (options.githubRef ?? undefined); const nextDraftThread: DraftThreadState = { projectId: nextProjectId, createdAt: @@ -1194,6 +1206,7 @@ export const useComposerDraftStore = create()( interactionMode: options.interactionMode ?? existing.interactionMode, branch: options.branch === undefined ? existing.branch : (options.branch ?? null), worktreePath: nextWorktreePath, + ...(nextGithubRef ? { githubRef: nextGithubRef } : {}), envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), }; @@ -1205,6 +1218,7 @@ export const useComposerDraftStore = create()( nextDraftThread.interactionMode === existing.interactionMode && nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && + nextDraftThread.githubRef === existing.githubRef && nextDraftThread.envMode === existing.envMode; if (isUnchanged) { return state; diff --git a/apps/web/src/issueReference.ts b/apps/web/src/issueReference.ts new file mode 100644 index 00000000..bbc6da51 --- /dev/null +++ b/apps/web/src/issueReference.ts @@ -0,0 +1,46 @@ +const GITHUB_ISSUE_URL_PATTERN = + /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/issues\/(\d+)(?:[/?#].*)?$/i; +const ISSUE_NUMBER_PATTERN = /^#?(\d+)$/; + +export interface ParsedIssueReference { + kind: "url" | "number"; + reference: string; + number: string; + owner: string | null; + repo: string | null; +} + +export function parseIssueReferenceParts(input: string): ParsedIssueReference | null { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return null; + } + + const urlMatch = GITHUB_ISSUE_URL_PATTERN.exec(trimmed); + if (urlMatch?.[3]) { + return { + kind: "url", + reference: trimmed, + number: urlMatch[3], + owner: urlMatch[1] ?? null, + repo: urlMatch[2] ?? null, + }; + } + + const numberMatch = ISSUE_NUMBER_PATTERN.exec(trimmed); + if (numberMatch?.[1]) { + return { + kind: "number", + reference: trimmed.startsWith("#") ? trimmed : numberMatch[1], + number: numberMatch[1], + owner: null, + repo: null, + }; + } + + return null; +} + +export function parseIssueReference(input: string): string | null { + return parseIssueReferenceParts(input)?.reference ?? null; +} diff --git a/apps/web/src/lib/githubReactQuery.ts b/apps/web/src/lib/githubReactQuery.ts new file mode 100644 index 00000000..93cc7cbc --- /dev/null +++ b/apps/web/src/lib/githubReactQuery.ts @@ -0,0 +1,57 @@ +import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; +import { ensureNativeApi } from "../nativeApi"; + +export const githubQueryKeys = { + all: ["github"] as const, + issues: (cwd: string | null) => ["github", "issues", cwd] as const, + issue: (cwd: string | null, number: number | null) => ["github", "issue", cwd, number] as const, +}; + +export function invalidateGithubIssueQueries(queryClient: QueryClient, cwd: string) { + return Promise.all([queryClient.invalidateQueries({ queryKey: githubQueryKeys.issues(cwd) })]); +} + +export function githubListIssuesQueryOptions(input: { + cwd: string | null; + assignee?: string; + state?: "open" | "closed"; + limit?: number; +}) { + return queryOptions({ + queryKey: githubQueryKeys.issues(input.cwd), + queryFn: async () => { + const api = ensureNativeApi(); + if (!input.cwd) throw new Error("GitHub issues are unavailable."); + return api.github.listIssues({ + cwd: input.cwd, + assignee: input.assignee, + state: input.state, + limit: input.limit, + }); + }, + enabled: input.cwd !== null, + staleTime: 30_000, + }); +} + +export function githubGetIssueQueryOptions(input: { cwd: string | null; number: number | null }) { + return queryOptions({ + queryKey: githubQueryKeys.issue(input.cwd, input.number), + queryFn: async () => { + const api = ensureNativeApi(); + if (!input.cwd || !input.number) throw new Error("GitHub issue is unavailable."); + return api.github.getIssue({ cwd: input.cwd, number: input.number }); + }, + enabled: input.cwd !== null && input.number !== null, + staleTime: 60_000, + }); +} + +export function githubPostCommentMutationOptions(input: { cwd: string; queryClient: QueryClient }) { + return mutationOptions({ + mutationFn: async ( + args: Parameters["github"]["postComment"]>[0], + ) => ensureNativeApi().github.postComment(args), + onSuccess: async () => invalidateGithubIssueQueries(input.queryClient, input.cwd), + }); +} diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 06f3b327..72482c1c 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -310,6 +310,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea branch: thread.branch, worktreePath: thread.worktreePath, worktreeBaseBranch: existing?.worktreeBaseBranch ?? null, + githubRef: thread.githubRef ?? existing?.githubRef, turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ turnId: checkpoint.turnId, completedAt: checkpoint.completedAt, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 2923232b..9415fb80 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -4,6 +4,7 @@ import type { OrchestrationSessionStatus, OrchestrationThreadActivity, ProjectScript as ContractProjectScript, + GitHubRef, ThreadId, ProjectId, TurnId, @@ -116,6 +117,7 @@ export interface Thread { branch: string | null; worktreePath: string | null; worktreeBaseBranch?: string | null; + githubRef?: GitHubRef | undefined; turnDiffSummaries: TurnDiffSummary[]; activities: OrchestrationThreadActivity[]; } diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index c8650eb1..1d15bb29 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -292,6 +292,11 @@ export function createWsNativeApi(): NativeApi { }; }, }, + github: { + listIssues: (input) => transport.request(WS_METHODS.githubListIssues, input), + getIssue: (input) => transport.request(WS_METHODS.githubGetIssue, input), + postComment: (input) => transport.request(WS_METHODS.githubPostComment, input), + }, prReview: { getConfig: (input) => transport.request(WS_METHODS.prReviewGetConfig, input), getDashboard: (input) => transport.request(WS_METHODS.prReviewGetDashboard, input), diff --git a/packages/contracts/src/github.ts b/packages/contracts/src/github.ts new file mode 100644 index 00000000..80a3e150 --- /dev/null +++ b/packages/contracts/src/github.ts @@ -0,0 +1,103 @@ +import { Schema } from "effect"; +import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { GitHubUserPreview } from "./prReview"; + +// ── GitHub Reference ──────────────────────────────────────────────── + +export const GitHubRefKind = Schema.Literals(["issue", "pr"]); +export type GitHubRefKind = typeof GitHubRefKind.Type; + +export const GitHubRef = Schema.Struct({ + kind: GitHubRefKind, + owner: TrimmedNonEmptyString, + repo: TrimmedNonEmptyString, + number: PositiveInt, +}); +export type GitHubRef = typeof GitHubRef.Type; + +// ── Issue Schemas ─────────────────────────────────────────────────── + +export const GitHubIssueLabel = Schema.Struct({ + name: TrimmedNonEmptyString, + color: Schema.String, +}); +export type GitHubIssueLabel = typeof GitHubIssueLabel.Type; + +export const GitHubIssueState = Schema.Literals(["open", "closed"]); +export type GitHubIssueState = typeof GitHubIssueState.Type; + +export const GitHubIssueComment = Schema.Struct({ + id: TrimmedNonEmptyString, + body: Schema.String, + author: Schema.NullOr(GitHubUserPreview), + createdAt: Schema.String, + url: Schema.NullOr(Schema.String), +}); +export type GitHubIssueComment = typeof GitHubIssueComment.Type; + +export const GitHubIssueSummary = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + state: GitHubIssueState, + labels: Schema.Array(GitHubIssueLabel), + author: Schema.NullOr(GitHubUserPreview), + url: Schema.String, + updatedAt: Schema.String, +}); +export type GitHubIssueSummary = typeof GitHubIssueSummary.Type; + +export const GitHubIssueDetail = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + state: GitHubIssueState, + body: Schema.String, + labels: Schema.Array(GitHubIssueLabel), + author: Schema.NullOr(GitHubUserPreview), + assignees: Schema.Array(GitHubUserPreview), + comments: Schema.Array(GitHubIssueComment), + milestone: Schema.NullOr(Schema.String), + url: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, + commentsCount: NonNegativeInt, +}); +export type GitHubIssueDetail = typeof GitHubIssueDetail.Type; + +// ── RPC Input / Result Schemas ────────────────────────────────────── + +export const GitHubListIssuesInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + assignee: Schema.optional(Schema.String), + state: Schema.optional(GitHubIssueState), + labels: Schema.optional(Schema.String), + limit: Schema.optional(PositiveInt), +}); +export type GitHubListIssuesInput = typeof GitHubListIssuesInput.Type; + +export const GitHubListIssuesResult = Schema.Struct({ + issues: Schema.Array(GitHubIssueSummary), +}); +export type GitHubListIssuesResult = typeof GitHubListIssuesResult.Type; + +export const GitHubGetIssueInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + number: PositiveInt, +}); +export type GitHubGetIssueInput = typeof GitHubGetIssueInput.Type; + +export const GitHubGetIssueResult = Schema.Struct({ + issue: GitHubIssueDetail, +}); +export type GitHubGetIssueResult = typeof GitHubGetIssueResult.Type; + +export const GitHubPostCommentInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + issueNumber: PositiveInt, + body: TrimmedNonEmptyString, +}); +export type GitHubPostCommentInput = typeof GitHubPostCommentInput.Type; + +export const GitHubPostCommentResult = Schema.Struct({ + url: Schema.String, +}); +export type GitHubPostCommentResult = typeof GitHubPostCommentResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 537fef6b..ecb9c1a8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -9,6 +9,7 @@ export * from "./ws"; export * from "./keybindings"; export * from "./server"; export * from "./git"; +export * from "./github"; export * from "./prReview"; export * from "./orchestration"; export * from "./editor"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5bb6b7a8..3c803523 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -59,6 +59,14 @@ import type { PrSubmitReviewResult, PrWorkflowStepRunResult, } from "./prReview"; +import type { + GitHubGetIssueInput, + GitHubGetIssueResult, + GitHubListIssuesInput, + GitHubListIssuesResult, + GitHubPostCommentInput, + GitHubPostCommentResult, +} from "./github"; import type { ServerConfig } from "./server"; import type { GlobalEnvironmentVariablesResult, @@ -352,6 +360,11 @@ export interface NativeApi { runStackedAction: (input: GitRunStackedActionInput) => Promise; onActionProgress: (callback: (event: GitActionProgressEvent) => void) => () => void; }; + github: { + listIssues: (input: GitHubListIssuesInput) => Promise; + getIssue: (input: GitHubGetIssueInput) => Promise; + postComment: (input: GitHubPostCommentInput) => Promise; + }; prReview: { getConfig: (input: PrReviewConfigInput) => Promise; getDashboard: (input: PrReviewDashboardInput) => Promise; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4997be0d..cbfd7916 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -14,6 +14,7 @@ import { TrimmedNonEmptyString, TurnId, } from "./baseSchemas"; +import { GitHubRef } from "./github"; export const ORCHESTRATION_WS_METHODS = { getSnapshot: "orchestration.getSnapshot", @@ -327,6 +328,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + githubRef: Schema.optional(GitHubRef), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -387,6 +389,7 @@ const ThreadCreateCommand = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + githubRef: Schema.optional(GitHubRef), createdAt: IsoDateTime, }); @@ -404,6 +407,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + githubRef: Schema.optional(GitHubRef), }); const ThreadRuntimeModeSetCommand = Schema.Struct({ @@ -695,6 +699,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + githubRef: Schema.optional(GitHubRef), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -710,6 +715,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyString), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + githubRef: Schema.optional(GitHubRef), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 541793b0..6768c1df 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -67,6 +67,7 @@ import { import { ProjectFileTreeChangedPayload } from "./project"; import { OpenInEditorInput, OpenPathInput } from "./editor"; import { GeneratePairingLinkInput, RevokeTokenInput, ServerConfigUpdatedPayload } from "./server"; +import { GitHubGetIssueInput, GitHubListIssuesInput, GitHubPostCommentInput } from "./github"; import { SkillListInput, SkillCatalogInput, @@ -112,6 +113,11 @@ export const WS_METHODS = { gitPreparePullRequestThread: "git.preparePullRequestThread", gitListPullRequests: "git.listPullRequests", + // GitHub issue methods + githubListIssues: "github.listIssues", + githubGetIssue: "github.getIssue", + githubPostComment: "github.postComment", + // PR review methods prReviewGetConfig: "prReview.getConfig", prReviewGetDashboard: "prReview.getDashboard", @@ -228,6 +234,11 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.gitPreparePullRequestThread, GitPreparePullRequestThreadInput), tagRequestBody(WS_METHODS.gitListPullRequests, GitListPullRequestsInput), + // GitHub issue methods + tagRequestBody(WS_METHODS.githubListIssues, GitHubListIssuesInput), + tagRequestBody(WS_METHODS.githubGetIssue, GitHubGetIssueInput), + tagRequestBody(WS_METHODS.githubPostComment, GitHubPostCommentInput), + // PR review methods tagRequestBody(WS_METHODS.prReviewGetConfig, PrReviewConfigInput), tagRequestBody(WS_METHODS.prReviewGetDashboard, PrReviewDashboardInput),