diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..dd086f1ce75 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -48,6 +48,7 @@ interface FakeGhScenario { headRepositoryNameWithOwner?: string | null; headRepositoryOwnerLogin?: string | null; }; + issue?: GitHubCli.GitHubIssueSummary; repositoryCloneUrls?: Record; failWith?: GitHubCli.GitHubCliError; } @@ -476,6 +477,23 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }); } + if (args[0] === "issue" && args[1] === "view") { + return Effect.succeed( + fakeGhOutput( + JSON.stringify( + scenario.issue ?? { + number: 84, + title: "Issue", + url: "https://github.com/pingdotgg/codething-mvp/issues/84", + state: "open", + labels: [], + assignees: [], + }, + ) + "\n", + ), + ); + } + if (args[0] === "repo" && args[1] === "view") { const repository = args[2]; if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { @@ -576,6 +594,17 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), ), + getIssue: (input) => + execute({ + cwd: input.cwd, + args: [ + "issue", + "view", + input.reference, + "--json", + "number,title,url,state,labels,assignees", + ], + }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubIssueSummary)), getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, @@ -627,6 +656,13 @@ function resolvePullRequest( return manager.resolvePullRequest(input); } +function resolveIssue( + manager: GitManager.GitManager["Service"], + input: { cwd: string; reference: string }, +) { + return manager.resolveIssue(input); +} + function preparePullRequestThread( manager: GitManager.GitManager["Service"], input: GitPreparePullRequestThreadInput, @@ -2547,6 +2583,38 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("resolves GitHub issues from #number references", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + issue: { + number: 84, + title: "Draft page suggestions", + url: "https://github.com/pingdotgg/codething-mvp/issues/84", + state: "open", + labels: ["enhancement"], + assignees: ["octocat"], + }, + }, + }); + + const result = yield* resolveIssue(manager, { cwd: repoDir, reference: "#84" }); + + expect(result.issue).toEqual({ + number: 84, + title: "Draft page suggestions", + url: "https://github.com/pingdotgg/codething-mvp/issues/84", + state: "open", + labels: ["enhancement"], + assignees: ["octocat"], + }); + expect(ghCalls.some((call) => call.startsWith("issue view 84 "))).toBe(true); + }), + ); + it.effect("prepares pull request threads in local mode by checking out the PR branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f1fb03e7e45..06f066a4723 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -18,6 +18,8 @@ import { GitCommandError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, + GitIssueRefInput, + GitResolveIssueResult, GitPullRequestRefInput, GitResolvePullRequestResult, GitRunStackedActionInput, @@ -41,7 +43,11 @@ import { type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; +import { + GitManagerError, + GitPullRequestMaterializationError, + SourceControlProviderError, +} from "@t3tools/contracts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; @@ -79,6 +85,9 @@ export class GitManager extends Context.Service< readonly resolvePullRequest: ( input: GitPullRequestRefInput, ) => Effect.Effect; + readonly resolveIssue: ( + input: GitIssueRefInput, + ) => Effect.Effect; readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect; @@ -478,6 +487,10 @@ function normalizePullRequestReference(reference: string): string { return hashNumber?.[1] ?? trimmed; } +function normalizeIssueReference(reference: string): string { + return normalizePullRequestReference(reference); +} + function toResolvedPullRequest(pr: { number: number; title: string; @@ -1458,6 +1471,35 @@ export const make = Effect.gen(function* () { return { pullRequest }; }); + const resolveIssue: GitManager["Service"]["resolveIssue"] = Effect.fn("resolveIssue")( + function* (input) { + const provider = yield* sourceControlProvider(input.cwd); + if (!provider.getIssue) { + return yield* new SourceControlProviderError({ + provider: provider.kind, + operation: "getIssue", + cwd: input.cwd, + reference: normalizeIssueReference(input.reference), + detail: "Issue lookup is currently available for GitHub repositories only.", + }); + } + const issue = yield* provider.getIssue({ + cwd: input.cwd, + reference: normalizeIssueReference(input.reference), + }); + return { + issue: { + number: issue.number, + title: issue.title, + url: issue.url, + state: issue.state, + labels: [...issue.labels], + assignees: [...issue.assignees], + }, + }; + }, + ); + const preparePullRequestThread: GitManager["Service"]["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { @@ -1866,6 +1908,7 @@ export const make = Effect.gen(function* () { invalidateRemoteStatus, invalidateStatus, resolvePullRequest, + resolveIssue, preparePullRequestThread, runStackedAction, }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 100b9beadba..8b04da76ab6 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -16,6 +16,8 @@ import { type GitManagerServiceError, type GitPreparePullRequestThreadInput, type GitPreparePullRequestThreadResult, + type GitIssueRefInput, + type GitResolveIssueResult, type GitPullRequestRefInput, type VcsPullResult, type VcsRemoveWorktreeInput, @@ -56,6 +58,9 @@ export class GitWorkflowService extends Context.Service< readonly resolvePullRequest: ( input: GitPullRequestRefInput, ) => Effect.Effect; + readonly resolveIssue: ( + input: GitIssueRefInput, + ) => Effect.Effect; readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect; @@ -285,6 +290,7 @@ export const make = Effect.gen(function* () { "GitWorkflowService.resolvePullRequest", gitManager.resolvePullRequest, ), + resolveIssue: routeGitManager("GitWorkflowService.resolveIssue", gitManager.resolveIssue), preparePullRequestThread: routeGitManager( "GitWorkflowService.preparePullRequestThread", gitManager.preparePullRequestThread, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6122c498145..10a5e7302c0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4900,6 +4900,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { state: "open", }, }), + resolveIssue: () => + Effect.succeed({ + issue: { + number: 2, + title: "Demo issue", + url: "https://example.com/issues/2", + state: "open", + labels: [], + assignees: [], + }, + }), preparePullRequestThread: () => Effect.succeed({ pullRequest: { diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..a441ceaff4f 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -111,6 +111,45 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("parses issue view output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 84, + title: "Draft page suggestions", + url: "https://github.com/pingdotgg/codething-mvp/issues/84", + state: "OPEN", + labels: [{ name: "enhancement" }, { name: "web" }], + assignees: [{ login: "octocat" }], + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getIssue({ cwd: "/repo", reference: "84" }); + + assert.deepStrictEqual(result, { + number: 84, + title: "Draft page suggestions", + url: "https://github.com/pingdotgg/codething-mvp/issues/84", + state: "open", + labels: ["enhancement", "web"], + assignees: ["octocat"], + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["issue", "view", "84", "--json", "number,title,url,state,labels,assignees"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("trims pull request fields decoded from gh json", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..37746fb8e64 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -16,6 +16,7 @@ import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, } from "./gitHubPullRequests.ts"; +import { decodeGitHubIssueJson, type NormalizedGitHubIssueRecord } from "./gitHubIssues.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -64,6 +65,19 @@ export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitHubIssueNotFoundError", + gitHubCliFailureFields, +) { + get detail(): string { + return "Issue not found. Check the issue number or URL and try again."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + export class GitHubCliCommandError extends Schema.TaggedErrorClass()( "GitHubCliCommandError", gitHubCliFailureFields, @@ -122,6 +136,19 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedErrorClass()( + "GitHubIssueDecodeError", + gitHubCliDecodeFields, +) { + get detail(): string { + return "GitHub CLI returned invalid issue JSON."; + } + + override get message(): string { + return `GitHub CLI failed in getIssue: ${this.detail}`; + } +} + export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass()( "GitHubRepositoryDecodeError", gitHubCliDecodeFields, @@ -139,10 +166,12 @@ export const GitHubCliError = Schema.Union([ GitHubCliUnavailableError, GitHubCliAuthenticationError, GitHubPullRequestNotFoundError, + GitHubIssueNotFoundError, GitHubCliCommandError, GitHubPullRequestListDecodeError, GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, + GitHubIssueDecodeError, GitHubRepositoryDecodeError, ]); export type GitHubCliError = typeof GitHubCliError.Type; @@ -190,6 +219,8 @@ export interface GitHubPullRequestSummary { readonly headRepositoryOwnerLogin?: string | null; } +export type GitHubIssueSummary = NormalizedGitHubIssueRecord; + export interface GitHubRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; @@ -216,6 +247,11 @@ export class GitHubCli extends Context.Service< readonly reference: string; }) => Effect.Effect; + readonly getIssue: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { readonly cwd: string; readonly repository: string; @@ -390,6 +426,44 @@ export const make = Effect.gen(function* () { ), ), ), + getIssue: (input) => + execute({ + cwd: input.cwd, + args: [ + "issue", + "view", + input.reference, + "--json", + "number,title,url,state,labels,assignees", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => decodeGitHubIssueJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubIssueDecodeError({ + command: "gh", + cwd: input.cwd, + cause: decoded.failure, + }), + ); + } + return Effect.succeed(decoded.success); + }), + ), + ), + Effect.mapError((error) => + error._tag === "GitHubPullRequestNotFoundError" + ? new GitHubIssueNotFoundError({ + command: "gh", + cwd: input.cwd, + cause: error.cause, + }) + : error, + ), + ), getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a6829566..e60b21acb59 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -68,6 +68,34 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); +it.effect("maps GitHub issues into provider-neutral issue metadata", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getIssue: () => + Effect.succeed({ + number: 84, + title: "Draft page suggestions", + url: "https://github.com/pingdotgg/t3code/issues/84", + state: "open", + labels: ["enhancement"], + assignees: ["octocat"], + }), + }); + + const issue = yield* provider.getIssue!({ cwd: "/repo", reference: "84" }); + + assert.deepStrictEqual(issue, { + provider: "github", + number: 84, + title: "Draft page suggestions", + url: "https://github.com/pingdotgg/t3code/issues/84", + state: "open", + labels: ["enhancement"], + assignees: ["octocat"], + }); + }), +); + it.effect("adds safe request context while retaining GitHub CLI causes", () => Effect.gen(function* () { const cause = new GitHubCli.GitHubPullRequestNotFoundError({ diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..40a06424b54 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -6,6 +6,7 @@ import { SourceControlProviderError, type ChangeRequest, type ChangeRequestState, + type SourceControlIssue, } from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts"; @@ -42,6 +43,18 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } +function toIssue(summary: GitHubCli.GitHubIssueSummary): SourceControlIssue { + return { + provider: "github", + number: summary.number, + title: summary.title, + url: summary.url, + state: summary.state, + labels: [...summary.labels], + assignees: [...summary.assignees], + }; +} + function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -205,6 +218,24 @@ export const make = Effect.gen(function* () { }), ), ), + getIssue: (input) => + github.getIssue(input).pipe( + Effect.map(toIssue), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getIssue", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), createChangeRequest: (input) => github .createPullRequest({ diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa42..041d3e6fe1a 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -6,6 +6,7 @@ import type { SourceControlProviderError, SourceControlProviderInfo, SourceControlProviderKind, + SourceControlIssue, SourceControlRepositoryCloneUrls, SourceControlRepositoryVisibility, } from "@t3tools/contracts"; @@ -96,6 +97,11 @@ export class SourceControlProvider extends Context.Service< readonly context?: SourceControlProviderContext; readonly reference: string; }) => Effect.Effect; + readonly getIssue?: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; readonly createChangeRequest: (input: { readonly cwd: string; readonly context?: SourceControlProviderContext; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e43..8b837bc908f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -169,6 +169,15 @@ function bindProviderContext( ...input, context: input.context ?? context, }), + ...(provider.getIssue + ? { + getIssue: (input: Parameters>[0]) => + provider.getIssue!({ + ...input, + context: input.context ?? context, + }), + } + : {}), createChangeRequest: (input) => provider.createChangeRequest({ ...input, diff --git a/apps/server/src/sourceControl/gitHubIssues.ts b/apps/server/src/sourceControl/gitHubIssues.ts new file mode 100644 index 00000000000..4c5665b47e2 --- /dev/null +++ b/apps/server/src/sourceControl/gitHubIssues.ts @@ -0,0 +1,51 @@ +import * as Cause from "effect/Cause"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +export interface NormalizedGitHubIssueRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly state: "open" | "closed"; + readonly labels: ReadonlyArray; + readonly assignees: ReadonlyArray; +} + +const GitHubIssueSchema = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + state: Schema.String, + labels: Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + }), + ), + assignees: Schema.Array( + Schema.Struct({ + login: TrimmedNonEmptyString, + }), + ), +}); + +const decodeGitHubIssue = decodeJsonResult(GitHubIssueSchema); + +export function decodeGitHubIssueJson( + raw: string, +): Result.Result> { + const result = decodeGitHubIssue(raw); + if (!Result.isSuccess(result)) { + return Result.fail(result.failure); + } + + return Result.succeed({ + number: result.success.number, + title: result.success.title, + url: result.success.url, + state: result.success.state.trim().toUpperCase() === "CLOSED" ? "closed" : "open", + labels: result.success.labels.map((label) => label.name), + assignees: result.success.assignees.map((assignee) => assignee.login), + }); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 08b1770a0a2..a1b43c8e521 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -322,6 +322,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], + [WS_METHODS.gitResolveIssue, AuthOrchestrationOperateScope], [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope], [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope], [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope], @@ -1797,6 +1798,10 @@ const makeWsRpcLayer = ( "rpc.aggregate": "git", }, ), + [WS_METHODS.gitResolveIssue]: (input) => + observeRpcEffect(WS_METHODS.gitResolveIssue, gitWorkflow.resolveIssue(input), { + "rpc.aggregate": "git", + }), [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..18839f731b2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,6 +3,8 @@ import { DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, + type GitResolvedIssue, + type GitResolvedPullRequest, type MessageId, type ModelSelection, type ProjectScript, @@ -150,7 +152,7 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; -import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -160,7 +162,6 @@ import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -204,7 +205,9 @@ import { import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; +import { DraftSuggestedTasks } from "./chat/DraftSuggestedTasks"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; +import { IssueThreadDialog } from "./IssueThreadDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; @@ -260,6 +263,12 @@ import { resolveServerConfigVersionMismatch, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; +import { + buildIssueTriageTask, + buildPullRequestTask, + FIX_FAILING_CHECKS_PROMPT, + REVIEW_CURRENT_CHANGES_PROMPT, +} from "../suggestedTasks"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -1175,13 +1184,6 @@ function ChatViewContent(props: ChatViewProps) { ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const getDraftSessionByLogicalProjectKey = useComposerDraftStore( - (store) => store.getDraftSessionByLogicalProjectKey, - ); - const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); - const setLogicalProjectDraftThreadId = useComposerDraftStore( - (store) => store.setLogicalProjectDraftThreadId, - ); const draftThread = useComposerDraftStore((store) => routeKind === "server" ? store.getDraftSessionByRef(routeThreadRef) @@ -1229,6 +1231,7 @@ function ChatViewContent(props: ChatViewProps) { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); + const [issueDialogOpen, setIssueDialogOpen] = useState(false); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< @@ -1646,94 +1649,6 @@ function ChatViewContent(props: ChatViewProps) { setPullRequestDialogState(null); }, []); - const openOrReuseProjectDraftThread = useCallback( - async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { - if (!activeProject) { - throw new Error("No active project is available for this pull request."); - } - const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKeyFromSettings( - activeProject, - projectGroupingSettings, - ); - const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); - if (storedDraftSession) { - setDraftThreadContext(storedDraftSession.draftId, input); - setLogicalProjectDraftThreadId( - logicalProjectKey, - activeProjectRef, - storedDraftSession.draftId, - { - threadId: storedDraftSession.threadId, - ...input, - }, - ); - if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(storedDraftSession.draftId), - }); - } - return storedDraftSession.threadId; - } - - const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; - if ( - !isServerThread && - activeDraftSession?.logicalProjectKey === logicalProjectKey && - draftId - ) { - setDraftThreadContext(draftId, input); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { - threadId: activeDraftSession.threadId, - createdAt: activeDraftSession.createdAt, - runtimeMode: activeDraftSession.runtimeMode, - interactionMode: activeDraftSession.interactionMode, - ...input, - }); - return activeDraftSession.threadId; - } - - const nextDraftId = newDraftId(); - const nextThreadId = newThreadId(); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { - threadId: nextThreadId, - createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - ...input, - }); - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(nextDraftId), - }); - return nextThreadId; - }, - [ - activeProject, - draftId, - getDraftSession, - getDraftSessionByLogicalProjectKey, - isServerThread, - navigate, - projectGroupingSettings, - routeKind, - setDraftThreadContext, - setLogicalProjectDraftThreadId, - ], - ); - - const handlePreparedPullRequestThread = useCallback( - async (input: { branch: string; worktreePath: string | null }) => { - await openOrReuseProjectDraftThread({ - branch: input.branch, - worktreePath: input.worktreePath, - envMode: input.worktreePath ? "worktree" : "local", - }); - }, - [openOrReuseProjectDraftThread], - ); - useEffect(() => { if (!serverThread?.id) return; const threadUpdatedAt = Date.parse(serverThread.updatedAt); @@ -2415,6 +2330,90 @@ function ChatViewContent(props: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + + const materializeSuggestedThread = async (input: { + title: string; + prompt: string; + branch: string | null; + worktreePath: string | null; + }): Promise => { + if (!activeProject || !activeThread || !draftId || !isLocalDraftThread) { + return false; + } + const sendContext = composerRef.current?.getSendContext(); + if (!sendContext) { + return false; + } + + const createResult = await createThread({ + environmentId, + input: { + threadId: activeThread.id, + projectId: activeProject.id, + title: truncate(input.title), + modelSelection: sendContext.selectedModelSelection, + runtimeMode, + interactionMode, + branch: input.branch, + worktreePath: input.worktreePath, + createdAt: activeThread.createdAt, + }, + }); + if (createResult._tag === "Failure") { + if (!isAtomCommandInterrupted(createResult)) { + const error = squashAtomCommandFailure(createResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create suggested task thread", + description: + error instanceof Error + ? error.message + : "An error occurred while creating the thread.", + }), + ); + } + return false; + } + + setDraftThreadContext(draftId, { + branch: input.branch, + worktreePath: input.worktreePath, + envMode: input.worktreePath ? "worktree" : "local", + }); + setComposerDraftPrompt(draftId, input.prompt); + composerRef.current?.resetCursorState({ cursor: input.prompt.length, prompt: input.prompt }); + scheduleComposerFocus(); + return true; + }; + + const handlePreparedPullRequestThread = async (input: { + branch: string; + worktreePath: string | null; + pullRequest: GitResolvedPullRequest; + }) => { + const task = buildPullRequestTask(input.pullRequest); + return materializeSuggestedThread({ + ...task, + branch: input.branch, + worktreePath: input.worktreePath, + }); + }; + + const handleResolvedIssue = (issue: GitResolvedIssue) => { + const task = buildIssueTriageTask(issue); + return materializeSuggestedThread({ + ...task, + branch: activeThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? null, + }); + }; + + const seedSuggestedPrompt = (prompt: string) => { + setComposerDraftPrompt(composerDraftTarget, prompt); + composerRef.current?.resetCursorState({ cursor: prompt.length, prompt }); + scheduleComposerFocus(); + }; const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { composerRef.current?.addTerminalContext(selection); @@ -3612,6 +3611,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); + setIssueDialogOpen(false); isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; @@ -5346,6 +5346,13 @@ function ChatViewContent(props: ChatViewProps) { activeProjectRef={activeProjectRef} activeProjectTitle={activeProject?.title ?? null} /> + openPullRequestDialog()} + onTriageIssue={() => setIssueDialogOpen(true)} + onReviewChanges={() => seedSuggestedPrompt(REVIEW_CURRENT_CHANGES_PROMPT)} + onFixFailingChecks={() => seedSuggestedPrompt(FIX_FAILING_CHECKS_PROMPT)} + /> @@ -5504,6 +5511,16 @@ function ChatViewContent(props: ChatViewProps) { onPrepared={handlePreparedPullRequestThread} /> ) : null} + + {issueDialogOpen && activeThread ? ( + + ) : null} {/* end chat column */} diff --git a/apps/web/src/components/IssueThreadDialog.tsx b/apps/web/src/components/IssueThreadDialog.tsx new file mode 100644 index 00000000000..9616a489dfa --- /dev/null +++ b/apps/web/src/components/IssueThreadDialog.tsx @@ -0,0 +1,196 @@ +import type { EnvironmentId, GitResolvedIssue } from "@t3tools/contracts"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { CircleDotIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { readCachedIssueResolution, useIssueResolution } from "~/lib/sourceControlActions"; +import { parseIssueReference } from "~/issueReference"; +import { cn } from "~/lib/utils"; +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 { + readonly open: boolean; + readonly environmentId: EnvironmentId; + readonly cwd: string | null; + readonly onOpenChange: (open: boolean) => void; + readonly onResolved: (issue: GitResolvedIssue) => Promise | boolean; +} + +export function IssueThreadDialog({ + open, + environmentId, + cwd, + onOpenChange, + onResolved, +}: IssueThreadDialogProps) { + const inputRef = useRef(null); + const [reference, setReference] = useState(""); + const [referenceDirty, setReferenceDirty] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [debouncedReference, referenceDebouncer] = useDebouncedValue( + reference, + { wait: 450 }, + (state) => ({ isPending: state.isPending }), + ); + + useEffect(() => { + if (!open) return; + const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); + return () => window.cancelAnimationFrame(frame); + }, [open]); + + const parsedReference = parseIssueReference(reference); + const parsedDebouncedReference = parseIssueReference(debouncedReference); + const scope = useMemo(() => ({ environmentId, cwd }), [cwd, environmentId]); + const resolution = useIssueResolution({ + ...scope, + reference: open ? parsedDebouncedReference : null, + }); + const cachedIssue = useMemo( + () => readCachedIssueResolution({ ...scope, reference: parsedReference })?.issue ?? null, + [parsedReference, scope], + ); + const liveIssue = + parsedReference !== null && parsedReference === parsedDebouncedReference + ? (resolution.data?.issue ?? null) + : null; + const issue = liveIssue ?? cachedIssue; + const isResolving = + open && + parsedReference !== null && + issue === null && + (referenceDebouncer.state.isPending || + parsedReference !== parsedDebouncedReference || + resolution.isPending || + resolution.isFetching); + + const handleConfirm = async () => { + if (!parsedReference) { + setReferenceDirty(true); + return; + } + if (!issue || isResolving || isCreating) return; + setIsCreating(true); + const didCreateThread = await onResolved(issue); + setIsCreating(false); + if (didCreateThread) onOpenChange(false); + }; + + 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 ?? + (issue === null && resolution.error + ? typeof resolution.error === "string" + ? resolution.error + : "Unable to resolve this issue." + : null); + + return ( + { + if (!isCreating) onOpenChange(nextOpen); + }} + > + + + + + Triage an issue + + + Resolve a GitHub issue, then create a focused triage thread without sending it yet. + + + + + + {issue ? ( +
+
+
+

{issue.title}

+

+ #{issue.number} + {issue.labels.length > 0 ? ` · ${issue.labels.join(", ")}` : ""} +

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

{errorMessage}

: null} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 4004b4930c2..d549f6d86ee 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,7 +1,7 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, GitResolvedPullRequest, ThreadId } from "@t3tools/contracts"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { useDebouncedValue } from "@tanstack/react-pacer"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { readCachedPullRequestResolution, @@ -33,7 +33,11 @@ interface PullRequestThreadDialogProps { cwd: string | null; initialReference: string | null; onOpenChange: (open: boolean) => void; - onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise | void; + onPrepared: (input: { + branch: string; + worktreePath: string | null; + pullRequest: GitResolvedPullRequest; + }) => Promise | boolean; } export function PullRequestThreadDialog({ @@ -129,44 +133,34 @@ export function PullRequestThreadDialog({ } }, [resolvedPullRequest?.state]); - const handleConfirm = useCallback( - async (mode: "local" | "worktree") => { - if (!parsedReference) { - setReferenceDirty(true); - return; - } - if (!parsedReference || !resolvedPullRequest || !cwd) { - return; - } - setPreparingMode(mode); - const result = await preparePullRequestThreadAction.run({ - reference: parsedReference, - mode, - ...(mode === "worktree" ? { threadId } : {}), - }); - setPreparingMode(null); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - preparePullRequestThreadAction.resetError(); - } - return; + const handleConfirm = async (mode: "local" | "worktree") => { + if (!parsedReference) { + setReferenceDirty(true); + return; + } + if (!parsedReference || !resolvedPullRequest || !cwd) { + return; + } + setPreparingMode(mode); + const result = await preparePullRequestThreadAction.run({ + reference: parsedReference, + mode, + ...(mode === "worktree" ? { threadId } : {}), + }); + setPreparingMode(null); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + preparePullRequestThreadAction.resetError(); } - await onPrepared({ - branch: result.value.branch, - worktreePath: result.value.worktreePath, - }); - onOpenChange(false); - }, - [ - cwd, - onOpenChange, - onPrepared, - parsedReference, - preparePullRequestThreadAction, - resolvedPullRequest, - threadId, - ], - ); + return; + } + const didCreateThread = await onPrepared({ + branch: result.value.branch, + worktreePath: result.value.worktreePath, + pullRequest: result.value.pullRequest, + }); + if (didCreateThread) onOpenChange(false); + }; const validationMessage = !referenceDirty ? null @@ -201,8 +195,8 @@ export function PullRequestThreadDialog({ Checkout {terminology.singular} - Resolve a {sourceControlPresentation.providerName} {terminology.singular}, then create - the draft thread in the main repo or in a dedicated worktree. + Resolve a {sourceControlPresentation.providerName} {terminology.singular}, then open it + in the main repo or in a dedicated worktree. @@ -280,7 +274,7 @@ export function PullRequestThreadDialog({ preparePullRequestThreadAction.isPending } > - {preparingMode === "local" ? "Preparing local..." : "Local"} + {preparingMode === "local" ? "Checking out..." : "Check out here"} diff --git a/apps/web/src/components/chat/DraftSuggestedTasks.tsx b/apps/web/src/components/chat/DraftSuggestedTasks.tsx new file mode 100644 index 00000000000..29520cc7485 --- /dev/null +++ b/apps/web/src/components/chat/DraftSuggestedTasks.tsx @@ -0,0 +1,92 @@ +import { + BugIcon, + GitPullRequestArrowIcon, + ScanSearchIcon, + TestTubeDiagonalIcon, + type LucideIcon, +} from "lucide-react"; + +import { cn } from "~/lib/utils"; + +interface SuggestedTask { + readonly title: string; + readonly description: string; + readonly icon: LucideIcon; + readonly tone: string; + readonly onSelect: () => void; + readonly disabled?: boolean; +} + +interface DraftSuggestedTasksProps { + readonly sourceControlAvailable: boolean; + readonly onCheckoutPullRequest: () => void; + readonly onTriageIssue: () => void; + readonly onReviewChanges: () => void; + readonly onFixFailingChecks: () => void; +} + +export function DraftSuggestedTasks(props: DraftSuggestedTasksProps) { + const tasks: ReadonlyArray = [ + { + title: "Check out a PR", + description: "Open it locally or in a worktree", + icon: GitPullRequestArrowIcon, + tone: "text-sky-500 dark:text-sky-400", + onSelect: props.onCheckoutPullRequest, + disabled: !props.sourceControlAvailable, + }, + { + title: "Triage an issue", + description: "Investigate scope and likely cause", + icon: BugIcon, + tone: "text-amber-500 dark:text-amber-400", + onSelect: props.onTriageIssue, + disabled: !props.sourceControlAvailable, + }, + { + title: "Review current changes", + description: "Inspect the diff and flag risks", + icon: ScanSearchIcon, + tone: "text-violet-500 dark:text-violet-400", + onSelect: props.onReviewChanges, + }, + { + title: "Fix failing checks", + description: "Run focused checks and repair failures", + icon: TestTubeDiagonalIcon, + tone: "text-emerald-500 dark:text-emerald-400", + onSelect: props.onFixFailingChecks, + }, + ]; + + return ( +
+ {tasks.map((task) => { + const Icon = task.icon; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/issueReference.test.ts b/apps/web/src/issueReference.test.ts new file mode 100644 index 00000000000..383cce6fa6f --- /dev/null +++ b/apps/web/src/issueReference.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseIssueReference } from "./issueReference"; + +describe("parseIssueReference", () => { + it("accepts GitHub issue URLs", () => { + expect(parseIssueReference("https://github.com/pingdotgg/t3code/issues/42")).toBe( + "https://github.com/pingdotgg/t3code/issues/42", + ); + }); + + it("accepts numbers with or without a hash", () => { + expect(parseIssueReference("42")).toBe("42"); + expect(parseIssueReference("#42")).toBe("42"); + }); + + it("rejects unrelated input", () => { + expect(parseIssueReference("draft-page")).toBeNull(); + }); +}); diff --git a/apps/web/src/issueReference.ts b/apps/web/src/issueReference.ts new file mode 100644 index 00000000000..992c0fef8eb --- /dev/null +++ b/apps/web/src/issueReference.ts @@ -0,0 +1,17 @@ +const GITHUB_ISSUE_URL_PATTERN = + /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/issues\/(\d+)(?:[/?#].*)?$/i; +const ISSUE_NUMBER_PATTERN = /^#?(\d+)$/; + +export function parseIssueReference(input: string): string | null { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return null; + } + + if (GITHUB_ISSUE_URL_PATTERN.test(trimmed)) { + return trimmed; + } + + const numberMatch = ISSUE_NUMBER_PATTERN.exec(trimmed); + return numberMatch?.[1] ?? null; +} diff --git a/apps/web/src/lib/sourceControlActions.ts b/apps/web/src/lib/sourceControlActions.ts index 2d857c8e4b7..b541325f8f0 100644 --- a/apps/web/src/lib/sourceControlActions.ts +++ b/apps/web/src/lib/sourceControlActions.ts @@ -1,8 +1,10 @@ export { readCachedPullRequestResolution, + readCachedIssueResolution, useGitStackedAction, usePreparePullRequestThreadAction, usePullRequestResolutionState as usePullRequestResolution, + useIssueResolutionState as useIssueResolution, useSourceControlActionRunning, useSourceControlPublishRepositoryAction, useVcsInitAction, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 297ae5717df..17156d2839e 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -12,6 +12,7 @@ import type { EnvironmentId, GitActionProgressEvent, GitResolvePullRequestResult, + GitResolveIssueResult, GitStackedAction, SourceControlCloneProtocol, SourceControlRepositoryVisibility, @@ -388,3 +389,47 @@ export function usePullRequestResolutionState(target: PullRequestResolutionTarge refresh: query.refresh, }; } + +export interface IssueResolutionTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly reference: string | null; +} + +export function readCachedIssueResolution( + target: IssueResolutionTarget, +): GitResolveIssueResult | null { + if (target.environmentId === null || target.cwd === null || target.reference === null) { + return null; + } + return Option.getOrNull( + AsyncResult.value( + appAtomRegistry.get( + gitEnvironment.issueResolution({ + environmentId: target.environmentId, + input: { cwd: target.cwd, reference: target.reference }, + }), + ), + ), + ); +} + +export function useIssueResolutionState(target: IssueResolutionTarget) { + const query = useEnvironmentQuery( + target.environmentId !== null && target.cwd !== null && target.reference !== null + ? gitEnvironment.issueResolution({ + environmentId: target.environmentId, + input: { cwd: target.cwd, reference: target.reference }, + }) + : null, + ); + const cached = readCachedIssueResolution(target); + + return { + data: query.data ?? cached, + error: query.error, + isPending: query.isPending && cached === null, + isFetching: query.isPending, + refresh: query.refresh, + }; +} diff --git a/apps/web/src/suggestedTasks.test.ts b/apps/web/src/suggestedTasks.test.ts new file mode 100644 index 00000000000..5a7cf560e80 --- /dev/null +++ b/apps/web/src/suggestedTasks.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildIssueTriageTask, buildPullRequestTask } from "./suggestedTasks"; + +describe("suggested task builders", () => { + it("builds a PR-specific thread title and editable review prompt", () => { + const task = buildPullRequestTask({ + number: 42, + title: "Improve the draft page", + url: "https://github.com/acme/app/pull/42", + baseBranch: "main", + headBranch: "feature/draft-page", + state: "open", + }); + + expect(task.title).toBe("PR #42 · Improve the draft page"); + expect(task.prompt).toContain("Review PR #42"); + expect(task.prompt).toContain("Do not make changes"); + }); + + it("builds an analysis-only issue triage prompt", () => { + const task = buildIssueTriageTask({ + number: 84, + title: "Sidebar freezes", + url: "https://github.com/acme/app/issues/84", + state: "open", + labels: ["bug"], + assignees: [], + }); + + expect(task.title).toBe("Triage #84 · Sidebar freezes"); + expect(task.prompt).toContain("reproduction steps"); + expect(task.prompt).toContain("Do not implement changes yet"); + }); +}); diff --git a/apps/web/src/suggestedTasks.ts b/apps/web/src/suggestedTasks.ts new file mode 100644 index 00000000000..713a1b7b9ac --- /dev/null +++ b/apps/web/src/suggestedTasks.ts @@ -0,0 +1,27 @@ +import type { GitResolvedIssue, GitResolvedPullRequest } from "@t3tools/contracts"; + +export const REVIEW_CURRENT_CHANGES_PROMPT = + "Review the current changes in this workspace. Inspect the diff, identify correctness, security, performance, and maintainability risks, and recommend focused improvements. Do not make changes until you have summarized what you found."; + +export const FIX_FAILING_CHECKS_PROMPT = + "Find the smallest relevant test, lint, formatting, and type-check commands for this project. Run the focused checks, diagnose any failures, implement fixes, and rerun the affected checks to verify them."; + +export function buildPullRequestTask(input: GitResolvedPullRequest): { + readonly title: string; + readonly prompt: string; +} { + return { + title: `PR #${input.number} · ${input.title}`, + prompt: `Review PR #${input.number}, “${input.title}” (${input.url}). Understand the changes, run the relevant checks, identify correctness or maintainability issues, and suggest fixes. Do not make changes until you have summarized what you found.`, + }; +} + +export function buildIssueTriageTask(input: GitResolvedIssue): { + readonly title: string; + readonly prompt: string; +} { + return { + title: `Triage #${input.number} · ${input.title}`, + prompt: `Triage issue #${input.number}, “${input.title}” (${input.url}). Read the issue and inspect the relevant code. Determine the likely cause, reproduction steps, scope, severity, and a concrete next action. Do not implement changes yet.`, + }; +} diff --git a/packages/client-runtime/src/state/git.ts b/packages/client-runtime/src/state/git.ts index 8a743485b4f..2fd59ffe121 100644 --- a/packages/client-runtime/src/state/git.ts +++ b/packages/client-runtime/src/state/git.ts @@ -13,6 +13,10 @@ export function createGitEnvironmentAtoms( label: "environment-data:git:resolve-pull-request", tag: WS_METHODS.gitResolvePullRequest, }), + issueResolution: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:git:resolve-issue", + tag: WS_METHODS.gitResolveIssue, + }), preparePullRequestThread: createEnvironmentRpcCommand(runtime, { label: "environment-data:git:prepare-pull-request-thread", tag: WS_METHODS.gitPreparePullRequestThread, diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 4ea86670ff8..5721f81f7c1 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -7,6 +7,7 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, + GitResolveIssueResult, } from "./git.ts"; const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); @@ -16,6 +17,7 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); +const decodeResolveIssueResult = Schema.decodeUnknownSync(GitResolveIssueResult); describe("VcsCreateWorktreeInput", () => { it("accepts omitted newRefName for existing-refName worktrees", () => { @@ -73,6 +75,24 @@ describe("GitResolvePullRequestResult", () => { }); }); +describe("GitResolveIssueResult", () => { + it("decodes resolved issue metadata", () => { + const parsed = decodeResolveIssueResult({ + issue: { + number: 84, + title: "Draft page suggestions", + url: "https://github.com/pingdotgg/codething-mvp/issues/84", + state: "open", + labels: ["enhancement", "web"], + assignees: ["octocat"], + }, + }); + + expect(parsed.issue.number).toBe(84); + expect(parsed.issue.labels).toEqual(["enhancement", "web"]); + }); +}); + describe("GitRunStackedActionInput", () => { it("accepts explicit stacked actions and requires a client-provided actionId", () => { const parsed = decodeRunStackedActionInput({ diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index aa5cdf8432b..cdc3d5fc331 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -44,6 +44,7 @@ const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"] const GitPrStepStatus = Schema.Literals(["created", "opened_existing", "skipped_not_requested"]); const VcsStatusChangeRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPullRequestReference = TrimmedNonEmptyStringSchema; +const GitIssueReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); export const GitRunStackedActionToastRunAction = Schema.Struct({ @@ -97,6 +98,16 @@ const GitResolvedPullRequest = Schema.Struct({ }); export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; +export const GitResolvedIssue = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyStringSchema, + url: Schema.String, + state: Schema.Literals(["open", "closed"]), + labels: Schema.Array(TrimmedNonEmptyStringSchema), + assignees: Schema.Array(TrimmedNonEmptyStringSchema), +}); +export type GitResolvedIssue = typeof GitResolvedIssue.Type; + // RPC Inputs export const VcsStatusInput = Schema.Struct({ @@ -148,6 +159,12 @@ export const GitPullRequestRefInput = Schema.Struct({ }); export type GitPullRequestRefInput = typeof GitPullRequestRefInput.Type; +export const GitIssueRefInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + reference: GitIssueReference, +}); +export type GitIssueRefInput = typeof GitIssueRefInput.Type; + export const GitPreparePullRequestThreadInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, reference: GitPullRequestReference, @@ -271,6 +288,11 @@ export const GitResolvePullRequestResult = Schema.Struct({ }); export type GitResolvePullRequestResult = typeof GitResolvePullRequestResult.Type; +export const GitResolveIssueResult = Schema.Struct({ + issue: GitResolvedIssue, +}); +export type GitResolveIssueResult = typeof GitResolveIssueResult.Type; + export const GitPreparePullRequestThreadResult = Schema.Struct({ pullRequest: GitResolvedPullRequest, branch: TrimmedNonEmptyStringSchema, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 6c853ca1631..54b33a866d8 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -15,6 +15,8 @@ import type { GitPreparePullRequestThreadResult, GitPullRequestRefInput, GitResolvePullRequestResult, + GitIssueRefInput, + GitResolveIssueResult, VcsStatusInput, VcsStatusResult, } from "./git.ts"; @@ -1210,6 +1212,7 @@ export interface EnvironmentApi { }; git: { resolvePullRequest: (input: GitPullRequestRefInput) => Promise; + resolveIssue: (input: GitIssueRefInput) => Promise; preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0356aa1807b..22e141fd82d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -34,6 +34,8 @@ import { VcsPullResult, VcsRemoveWorktreeInput, GitResolvePullRequestResult, + GitIssueRefInput, + GitResolveIssueResult, GitRunStackedActionInput, VcsStatusInput, VcsStatusResult, @@ -174,6 +176,7 @@ export const WS_METHODS = { // Git workflow methods gitRunStackedAction: "git.runStackedAction", gitResolvePullRequest: "git.resolvePullRequest", + gitResolveIssue: "git.resolveIssue", gitPreparePullRequestThread: "git.preparePullRequestThread", // Review methods @@ -434,6 +437,12 @@ export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequ error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); +export const WsGitResolveIssueRpc = Rpc.make(WS_METHODS.gitResolveIssue, { + payload: GitIssueRefInput, + success: GitResolveIssueResult, + error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), +}); + export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { payload: GitPreparePullRequestThreadInput, success: GitPreparePullRequestThreadResult, @@ -719,6 +728,7 @@ export const WsRpcGroup = RpcGroup.make( WsVcsRefreshStatusRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, + WsGitResolveIssueRpc, WsGitPreparePullRequestThreadRpc, WsVcsListRefsRpc, WsVcsCreateWorktreeRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161..a7abe3b79e4 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -36,6 +36,20 @@ export const ChangeRequest = Schema.Struct({ }); export type ChangeRequest = typeof ChangeRequest.Type; +export const SourceControlIssueState = Schema.Literals(["open", "closed"]); +export type SourceControlIssueState = typeof SourceControlIssueState.Type; + +export const SourceControlIssue = Schema.Struct({ + provider: SourceControlProviderKind, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: Schema.String, + state: SourceControlIssueState, + labels: Schema.Array(TrimmedNonEmptyString), + assignees: Schema.Array(TrimmedNonEmptyString), +}); +export type SourceControlIssue = typeof SourceControlIssue.Type; + export const SourceControlRepositoryCloneUrls = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString,