diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index dc5beea14d24073351a73ed156184c90391ca22d..fcba4626be2d9e050ffb351e912d2ac812811ca0 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -23,7 +23,7 @@ import { relativeTime } from "../../lib/time"; import { themeColorWithAlpha } from "../../lib/mobileTheme"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; +import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; @@ -42,9 +42,15 @@ export type ThreadListVariant = "compact" | "sidebar"; export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; const SIDEBAR_ROW_RADIUS = 12; -function pullRequestTintColor(state: ThreadPr["state"], colorScheme: "light" | "dark") { +function pullRequestTintColor( + pr: Pick, + colorScheme: "light" | "dark", +) { const dark = colorScheme === "dark"; - switch (state) { + if (pr.state === "open" && pr.isDraft === true) { + return dark ? "#a1a1aa" : "#71717a"; + } + switch (pr.state) { case "open": return dark ? "#34d399" : "#059669"; case "merged": @@ -548,9 +554,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ; export interface ThreadPrPresentation { readonly number: number; readonly state: ThreadPr["state"]; + readonly isDraft: boolean; /** Provider-side last activity, bounding when a terminal state landed. */ readonly updatedAt: string | null; readonly url: string; @@ -27,13 +28,15 @@ export function presentThreadPr( provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): ThreadPrPresentation { const presentation = resolveChangeRequestPresentation(provider); + const isDraft = pr.state === "open" && pr.isDraft === true; return { number: pr.number, state: pr.state, + isDraft, updatedAt: pr.updatedAt ?? null, url: pr.url, label: String(pr.number), - accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`, - textClassName: PR_STATE_TEXT_CLASS[pr.state], + accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`, + textClassName: isDraft ? "text-adaptive-zinc-500-400" : PR_STATE_TEXT_CLASS[pr.state], }; } diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index 1313f202fa1336df1ae7fe54040a1b2be40d6030..ddda3b1acd9648fb79d2f9c265eb4ca5d2b1261c 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -33,4 +33,13 @@ describe("presentThreadPr", () => { accessibilityLabel: "#3774 merge request merged", }); }); + + it("uses gray for draft pull requests", () => { + expect( + presentThreadPr({ ...pullRequest, state: "open", isDraft: true }, undefined), + ).toMatchObject({ + accessibilityLabel: "#3774 pull request draft", + textClassName: "text-adaptive-zinc-500-400", + }); + }); }); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 3e4be02a3c140c11824e1a63aef9300625f4affb..643e1e7a0d5bae630c8b050b2f6f05407284f44c 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -54,6 +54,7 @@ interface FakeGhScenario { baseRefName: string; headRefName: string; state?: "open" | "closed" | "merged"; + isDraft?: boolean; isCrossRepository?: boolean; headRepositoryNameWithOwner?: string | null; headRepositoryOwnerLogin?: string | null; @@ -116,6 +117,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ ? "closed" : "merged" : undefined; + const isDraft = typeof record.isDraft === "boolean" ? record.isDraft : undefined; const isCrossRepository = typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; const headRepositoryNameWithOwner = @@ -138,6 +140,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ baseRefName, headRefName, ...(state ? { state } : {}), + ...(isDraft === true ? { isDraft: true } : {}), ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), @@ -508,7 +511,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), @@ -552,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), @@ -699,7 +702,7 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe( ); it.layer(GitManagerTestLayer)("GitManager", (it) => { - it.effect("status includes PR metadata when branch already has an open PR", () => + it.effect("status includes draft PR metadata when branch already has a draft PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -719,6 +722,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/13", baseRefName: "main", headRefName: "feature/status-open-pr", + isDraft: true, }, ]), ], @@ -737,6 +741,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRef: "main", headRef: "feature/status-open-pr", state: "open", + isDraft: true, updatedAt: null, }); }), @@ -1686,7 +1691,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1752,7 +1757,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index cad328d16b08394b74b33381204dd78f502f6341..2d8af0c9e8bb54a5510f299ba6e3438822645e0f 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -170,6 +170,7 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; + isDraft?: boolean; updatedAt: Option.Option; } @@ -404,6 +405,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } @@ -558,6 +560,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: string; headRef: string; state: "open" | "closed" | "merged"; + isDraft?: boolean; updatedAt: string | null; } { return { @@ -567,6 +570,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, + ...(pr.isDraft === true ? { isDraft: true } : {}), updatedAt: Option.match(pr.updatedAt, { onNone: () => null, onSome: (updatedAt) => DateTime.formatIso(updatedAt), @@ -1518,11 +1522,7 @@ export const make = Effect.gen(function* () { ); if (firstPullRequest) { return { - number: firstPullRequest.number, - title: firstPullRequest.title, - url: firstPullRequest.url, - baseRefName: firstPullRequest.baseRefName, - headRefName: firstPullRequest.headRefName, + ...firstPullRequest, state: "open", updatedAt: Option.none(), } satisfies PullRequestInfo; diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 3dc41896037bbddf25f4884b8e8526822f7aa81b..5d5c2062c08c3b11313c4b6b8c99fdc6cb371b37 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -468,6 +468,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly headBranch: string; readonly baseBranch: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: string; }, GitHubPullRequestCliError @@ -1650,6 +1651,7 @@ export const make = Effect.gen(function* () { headBranch: summary.headRefName, baseBranch: summary.baseRefName, state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt, }), ), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 1459d7cec921982488aa2368ff667d51461a4dc1..22028ced5ddfba9758697c41a9ba60ea98a8845f 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -96,6 +96,8 @@ export interface ProviderChangeRequestSummary { readonly headBranch: string; readonly baseBranch: string; readonly state: PullRequestState; + /** Present when the host says an open pull request is still a draft. */ + readonly isDraft?: boolean; readonly updatedAt: string; } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 88a8ffe32df34a039de17937b284f84a6aaa2574..6a37ed9358487d0ad72dbb0af1d9102c69cfe5ad 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1254,6 +1254,7 @@ export const make = Effect.gen(function* () { title: changeRequest.title, url: changeRequest.url, state: changeRequest.state, + ...(changeRequest.isDraft === true ? { isDraft: true } : {}), headBranch: changeRequest.headBranch, baseBranch: changeRequest.baseBranch, updatedAt: changeRequest.updatedAt, @@ -2278,6 +2279,7 @@ export const make = Effect.gen(function* () { title: detail.title, url: detail.url, state: detail.state, + ...(detail.isDraft === true ? { isDraft: true } : {}), headBranch: detail.headBranch, baseBranch: detail.baseBranch, updatedAt: detail.updatedAt, diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 2f147452f9ecfcdb631fcc93f34362d5aab6880e..8a840c524ebac618df1bb951f2414f0da0557a1c 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -61,6 +61,7 @@ function toChangeRequest(summary: { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: ChangeRequest["updatedAt"]; }): ChangeRequest { return { @@ -71,6 +72,7 @@ function toChangeRequest(summary: { baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state, + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt, isCrossRepository: false, }; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 59fab76e52773d9b8364ee03aed4dce7f4547398..ffa3fb1301c93079f0642aedcc8218fe22de3281 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -17,6 +17,7 @@ function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeR baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state, + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index ea79dc87e7fc88a6dfa8b2a4fe25544e848845a3..3f08e92e2c1e97cc9dc44ffaef440a3d93c91b3c 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -65,6 +65,7 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "OPEN", + isDraft: true, mergedAt: null, updatedAt: "2026-08-24T12:34:56Z", isCrossRepository: true, @@ -92,6 +93,7 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + isDraft: true, updatedAt: "2026-08-24T12:34:56.000Z", isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", @@ -105,7 +107,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", timeoutMs: 30_000, diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index a9b65b30e7061ba2ac0625cb88189b7ec736280b..49b2ea31a08c19857966468a0b62728b035235b1 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -205,6 +205,7 @@ export interface GitHubPullRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -366,7 +367,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -398,7 +399,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 1381271e6bbcdf766f058b7ed9b0702c1f6c4efe..7faa2fe351efc0835cb109cee751955d8e1a8c61 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -150,7 +150,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 738b10498d56f8469d6f4af7e6b9cb4ecf0761d2..1a20b587256a68c376f3df08445b1056690be030 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -30,6 +30,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt === undefined ? Option.none() @@ -153,7 +154,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9a9fc3360247d35161bbde6870b718b7340f2c8d..ab8dfbb5f334425b998cccd4b32cb7b68482c676 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -246,6 +246,7 @@ export interface GitLabMergeRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt?: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2cba12f1b3f73134b527d49a94b6a38f8a6bf18e..2ec1f9b9a228dfe4da304d1196c5f45a573a3890 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -26,6 +26,7 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 8c3c5c4de56b57981d862398d0aa335741832d9d..8ac682399e1d13eb877c5830d0404eb96eefd724 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -14,6 +14,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: Option.Option; } @@ -35,6 +36,7 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ sourceRefName: TrimmedNonEmptyString, targetRefName: TrimmedNonEmptyString, status: Schema.String, + isDraft: Schema.optional(Schema.Boolean), creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), _links: Schema.optional( @@ -168,6 +170,7 @@ function normalizeAzureDevOpsPullRequestRecord( baseRefName: normalizeRefName(raw.targetRefName), headRefName: normalizeRefName(raw.sourceRefName), state: normalizeAzureDevOpsPullRequestState(raw.status), + ...(raw.isDraft === true ? { isDraft: true } : {}), updatedAt: (raw.closedDate ?? Option.none()).pipe( Option.orElse(() => raw.creationDate ?? Option.none()), ), diff --git a/apps/server/src/sourceControl/bitbucketPullRequests.ts b/apps/server/src/sourceControl/bitbucketPullRequests.ts index 6d67477bca703dcc9f5b41307bc777d16fb9baeb..3b07334a80409db06faf79ae89893086b2cd8b37 100644 --- a/apps/server/src/sourceControl/bitbucketPullRequests.ts +++ b/apps/server/src/sourceControl/bitbucketPullRequests.ts @@ -10,6 +10,7 @@ export interface NormalizedBitbucketPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -38,6 +39,7 @@ export const BitbucketPullRequestSchema = Schema.Struct({ id: PositiveInt, title: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), links: Schema.Struct({ html: Schema.Struct({ @@ -98,6 +100,7 @@ export function normalizeBitbucketPullRequestRecord( baseRefName: raw.destination.branch.name, headRefName: raw.source.branch.name, state: normalizeBitbucketPullRequestState(raw.state), + ...(raw.draft === true ? { isDraft: true } : {}), updatedAt: raw.updated_on ?? Option.none(), ...(isCrossRepository ? { isCrossRepository: true } : {}), ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index ded3c0a90b08ed6963aafd152bcffdeb6123e7ce..9e4f282e1c8ae3b38680daceef9f08141ad5658c 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -14,6 +14,7 @@ export interface NormalizedGitHubPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -27,6 +28,7 @@ const GitHubPullRequestSchema = Schema.Struct({ baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), @@ -93,6 +95,7 @@ function normalizeGitHubPullRequestRecord( baseRefName: raw.baseRefName, headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), + ...(raw.isDraft === true ? { isDraft: true } : {}), updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts index afd1eceaeedc468701f7c727378845a0b65d8b28..3b032e245bbcc5900a65c0afe4efc92b87034883 100644 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -14,6 +14,7 @@ export interface NormalizedGitLabMergeRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -41,6 +42,8 @@ const GitLabMergeRequestSchema = Schema.Struct({ source_branch: TrimmedNonEmptyString, target_branch: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + work_in_progress: Schema.optional(Schema.Boolean), updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), @@ -108,6 +111,7 @@ function normalizeGitLabMergeRequestRecord( baseRefName: raw.target_branch, headRefName: raw.source_branch, state: normalizeGitLabMergeRequestState(raw.state), + ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), updatedAt: raw.updated_at ?? Option.none(), ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf14c961473469d70f2ebfaab7e9998f4e1d5333..8b636dc6ed463f45e2bb4086dc2367ba3dfb7d15 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -954,7 +954,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { linkedPullRequestStatus, }); const prStatus = prStatusIndicator(pr, prProvider); - const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; + const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; useEffect(() => { const nextSnapshot = nextThreadChangeRequestSnapshot({ threadBranch: thread.branch, diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 31b94db36d4c29c789c828836a0c0bf7bf2434c8..439756b2466ed2b7dd6535de78352ef7d342c1be 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -10,6 +10,7 @@ import { resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadChangeRequestSnapshotsEqual, threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; @@ -559,6 +560,18 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { }); expect(displayed?.state).toBe("merged"); }); + + it("refreshes a cached snapshot when a pull request becomes ready", () => { + const readyPr = { ...mergedPr, state: "open" as const }; + const draftPr = { ...readyPr, isDraft: true }; + + expect( + threadChangeRequestSnapshotsEqual( + snapshotFor(featureBranch, draftPr), + snapshotFor(featureBranch, readyPr), + ), + ).toBe(false); + }); }); describe("threadChangeRequestSnapshotsAtom", () => { @@ -600,6 +613,17 @@ describe("prStatusIndicator", () => { "text-red-600", ); }); + + it("uses gray and draft wording for draft pull requests", () => { + const draftPr = status().pr; + if (!draftPr) throw new Error("Expected pull request fixture"); + + expect(prStatusIndicator({ ...draftPr, isDraft: true }, undefined)).toMatchObject({ + label: "PR draft", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltipLead: "PR #42 - Draft", + }); + }); }); describe("settledPrHoverColorClass", () => { @@ -610,4 +634,8 @@ describe("settledPrHoverColorClass", () => { ] as const)("restores the %s pull request color on row hover", (state, colorClass) => { expect(settledPrHoverColorClass(state)).toContain(`group-hover/v2-row:${colorClass}`); }); + + it("keeps draft pull requests gray on row hover", () => { + expect(settledPrHoverColorClass("open", true)).toContain("group-hover/v2-row:text-zinc-500"); + }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 1ad0c3139fd8e738563e9bb97adeb6eda75a0cb9..4c830f4a4b8bfd9f0b774a0cb1c1d7e911db4e1a 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -84,9 +84,15 @@ export function useLinkedThreadPullRequest( ); } -export function settledPrHoverColorClass(state: NonNullable["state"]): string { +export function settledPrHoverColorClass( + state: NonNullable["state"], + isDraft = false, +): string { switch (state) { case "open": + if (isDraft) { + return "group-hover/v2-row:text-zinc-500 dark:group-hover/v2-row:text-zinc-400/80"; + } return "group-hover/v2-row:text-emerald-600 dark:group-hover/v2-row:text-emerald-300/90"; case "merged": return "group-hover/v2-row:text-violet-600 dark:group-hover/v2-row:text-violet-300/90"; @@ -99,12 +105,13 @@ export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { - function formatPrState(state: NonNullable["state"]): string { - return state.charAt(0).toUpperCase() + state.slice(1); + function formatPrState(pr: NonNullable): string { + if (pr.state === "open" && pr.isDraft === true) return "Draft"; + return pr.state.charAt(0).toUpperCase() + pr.state.slice(1); } function formatPrStatusLead(pr: NonNullable, changeRequestShortName: string): string { - return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; + return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`; } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); @@ -113,9 +120,12 @@ export function prStatusIndicator( const tooltip = `${tooltipLead}: ${pr.title}`; if (pr.state === "open") { + const isDraft = pr.isDraft === true; return { - label: `${presentation.shortName} open`, - colorClass: "text-emerald-600 dark:text-emerald-300/90", + label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`, + colorClass: isDraft + ? "text-zinc-500 dark:text-zinc-400/80" + : "text-emerald-600 dark:text-emerald-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, @@ -230,6 +240,7 @@ export function threadChangeRequestSnapshotsEqual( left.pr.baseRef === right.pr.baseRef && left.pr.headRef === right.pr.headRef && left.pr.state === right.pr.state && + left.pr.isDraft === right.pr.isDraft && (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 9bfc49848803bbd59933bb777aa426558497971e..70533f06488f45bb7949c36acdcd6a4a5b682589 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -625,7 +625,13 @@ export function PullRequestDetailPanel({ () => resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail ? resolvedCoreDetail - : { ...resolvedCoreDetail, ...sharedSummary }, + : { + ...resolvedCoreDetail, + ...sharedSummary, + // A summary may come from an older server that does not report draft state. Keep the + // detail's required value instead of making the complete detail shape partial. + isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, + }, [resolvedCoreDetail, sharedSummary], ); const activity = activityQuery.data; @@ -1297,7 +1303,7 @@ export function PullRequestDetailPanel({ !conflicting && allowedMergeMethods.length > 1; // The pull request number carries this state in the overview and the right-panel tab mirrors - // it. The conflict action is separate from this state: an open pull request remains green. + // it. Conflicts take the action slot while they need a person, but do not change the PR state. const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index d9fd35e0c8b43c1c18d864f6ee38910e26a9771f..f4eb7958a1d37e6a98ede37e153313570099f16f 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -33,9 +33,9 @@ interface StatePresentation { } /** - * How a pull request's state reads on this page. Open, closed and merged use the same ink as - * the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two - * different things in two places; draft and conflicts are states that badge never shows. + * How a pull request's state reads on this page. Open, closed, merged, and draft use the same + * ink as the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two + * different things in two places. * * Draft outranks conflicts: a draft is not heading for a merge yet, so conflicts only surface * once it is real work. diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 7ebaba0fed036595dd0fd8041c3a75f69441aec6..6144d8507a9ecc055e7d11464ed0b8495e9b7f0d 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -69,6 +69,7 @@ export function pullRequestDetailToVcsStatus( baseRef: detail.baseBranch, headRef: detail.headBranch, state: detail.state, + ...(detail.isDraft === true ? { isDraft: true } : {}), updatedAt: detail.updatedAt, }; } diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index d39be34bf6e9cb654aeb7691c1ebebd87450b12e..4b63b877923f16fc38eca9f06d8b4d1d0d8f2246 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -198,6 +198,8 @@ const VcsStatusChangeRequest = Schema.Struct({ baseRef: TrimmedNonEmptyStringSchema, headRef: TrimmedNonEmptyStringSchema, state: VcsStatusChangeRequestState, + /** Optional for compatibility with older servers and providers. */ + isDraft: Schema.optional(Schema.Boolean), /** * Last provider-side activity (ISO). For a merged/closed change request * this bounds when it reached that state, so clients can tell a PR that diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 04c9d53cf777766e8f81eb9999672d1388d9ea72..f766578bb1a3d63a52059fa2ac6963b99bbadf24 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -634,6 +634,8 @@ export const PullRequestSummary = Schema.Struct({ title: TrimmedNonEmptyString, url: TrimmedNonEmptyString, state: PullRequestState, + /** Present when the host says the open pull request is still a draft. */ + isDraft: Schema.optional(Schema.Boolean), headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, updatedAt: IsoDateTime, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161fcd541865574ea1084bd875f601d0..be3d70aefadda61fdbd27a1e79b8abc2f4e67037 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -29,6 +29,8 @@ export const ChangeRequest = Schema.Struct({ baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, state: ChangeRequestState, + /** Present when the provider can tell that an open change request is still a draft. */ + isDraft: Schema.optional(Schema.Boolean), updatedAt: Schema.Option(Schema.DateTimeUtc), isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f32038763188f1af75cda922ab71ce73a1f345f7..3e40d94dc69ad34888865db11e674b2c1e8b96d7 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2863,7 +2863,7 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function readonly arch: typeof BuildArch.Type; }) { const fs = yield* FileSystem.FileSystem; - yield* Effect.tryPromise({ + const archiveStream = yield* Effect.tryPromise({ try: () => createPackageWithOptions(input.sourceDir, input.asarPath, { dot: true, @@ -2872,6 +2872,21 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function }), catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }), }); + yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const stream = archiveStream as NodeJS.WritableStream & { + readonly writableFinished?: boolean; + }; + if (stream.writableFinished === true) { + resolve(); + return; + } + stream.once("finish", resolve); + stream.once("error", reject); + }), + catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }), + }); const unpackedDirPath = `${input.asarPath}.unpacked`; if (!(yield* fs.exists(unpackedDirPath))) { return yield* new WindowsServerSidecarPackError({