authorgravatar for 118054752+extoci@users.noreply.github.comExotic <118054752+extoci@users.noreply.github.com> 2026-09-04 09:17:18+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 02:17:18-04:00
logcaab2fdbac041ac2e851ad4fa3ac4a40a1d4a8f6
tree021bd438f2ef51ef5a54ffea4fd0632acb74f3a5
parent5cc369b7eb882dbea8d5ad21ba688c73a0058748
signature Signed by PGP key B5690EEEBB952194

fix(web): render draft PRs in gray (#9537)


30 files changed, 152 insertions(+), 36 deletions(-)

apps/mobile/src/features/threads/thread-list-items.tsx+10-6
......@@ -23,7 +23,7 @@ import { relativeTime } from "../../lib/time";
2323import { themeColorWithAlpha } from "../../lib/mobileTheme";
2424import { useUniwindTheme } from "../../lib/useUniwindTheme";
2525import type { PendingNewTask } from "../../state/use-pending-new-tasks";
26import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";
26import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr";
2727import type { HomeGroupDisplayAction } from "../home/homeListItems";
2828import { ThreadSwipeable } from "../home/thread-swipe-actions";
2929import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu";
......@@ -42,9 +42,15 @@ export type ThreadListVariant = "compact" | "sidebar";
4242export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;
4343const SIDEBAR_ROW_RADIUS = 12;
4444
45function pullRequestTintColor(state: ThreadPr["state"], colorScheme: "light" | "dark") {
45function pullRequestTintColor(
46 pr: Pick<ThreadPrPresentation, "state" | "isDraft">,
47 colorScheme: "light" | "dark",
48) {
4649 const dark = colorScheme === "dark";
47 switch (state) {
50 if (pr.state === "open" && pr.isDraft === true) {
51 return dark ? "#a1a1aa" : "#71717a";
52 }
53 switch (pr.state) {
4854 case "open":
4955 return dark ? "#34d399" : "#059669";
5056 case "merged":
......@@ -548,9 +554,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
548554 <PullRequestIcon
549555 size={compact ? 13 : 11}
550556 color={
551 selected
552 ? String(selectedForegroundColor)
553 : pullRequestTintColor(pr.state, colorScheme)
557 selected ? String(selectedForegroundColor) : pullRequestTintColor(pr, colorScheme)
554558 }
555559 />
556560 <Text
apps/mobile/src/state/thread-pr-presentation.ts+5-2
......@@ -6,6 +6,7 @@ export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;
66export interface ThreadPrPresentation {
77 readonly number: number;
88 readonly state: ThreadPr["state"];
9 readonly isDraft: boolean;
910 /** Provider-side last activity, bounding when a terminal state landed. */
1011 readonly updatedAt: string | null;
1112 readonly url: string;
......@@ -27,13 +28,15 @@ export function presentThreadPr(
2728 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
2829): ThreadPrPresentation {
2930 const presentation = resolveChangeRequestPresentation(provider);
31 const isDraft = pr.state === "open" && pr.isDraft === true;
3032 return {
3133 number: pr.number,
3234 state: pr.state,
35 isDraft,
3336 updatedAt: pr.updatedAt ?? null,
3437 url: pr.url,
3538 label: String(pr.number),
36 accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`,
37 textClassName: PR_STATE_TEXT_CLASS[pr.state],
39 accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`,
40 textClassName: isDraft ? "text-adaptive-zinc-500-400" : PR_STATE_TEXT_CLASS[pr.state],
3841 };
3942}
apps/mobile/src/state/use-thread-pr.test.ts+9
......@@ -33,4 +33,13 @@ describe("presentThreadPr", () => {
3333 accessibilityLabel: "#3774 merge request merged",
3434 });
3535 });
36
37 it("uses gray for draft pull requests", () => {
38 expect(
39 presentThreadPr({ ...pullRequest, state: "open", isDraft: true }, undefined),
40 ).toMatchObject({
41 accessibilityLabel: "#3774 pull request draft",
42 textClassName: "text-adaptive-zinc-500-400",
43 });
44 });
3645});
apps/server/src/git/GitManager.test.ts+10-5
......@@ -54,6 +54,7 @@ interface FakeGhScenario {
5454 baseRefName: string;
5555 headRefName: string;
5656 state?: "open" | "closed" | "merged";
57 isDraft?: boolean;
5758 isCrossRepository?: boolean;
5859 headRepositoryNameWithOwner?: string | null;
5960 headRepositoryOwnerLogin?: string | null;
......@@ -116,6 +117,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ
116117 ? "closed"
117118 : "merged"
118119 : undefined;
120 const isDraft = typeof record.isDraft === "boolean" ? record.isDraft : undefined;
119121 const isCrossRepository =
120122 typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined;
121123 const headRepositoryNameWithOwner =
......@@ -138,6 +140,7 @@ function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequ
138140 baseRefName,
139141 headRefName,
140142 ...(state ? { state } : {}),
143 ...(isDraft === true ? { isDraft: true } : {}),
141144 ...(isCrossRepository !== undefined ? { isCrossRepository } : {}),
142145 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
143146 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
......@@ -508,7 +511,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
508511 "--limit",
509512 String(input.limit ?? 1),
510513 "--json",
511 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
514 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
512515 ],
513516 }).pipe(
514517 Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
......@@ -552,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
552555 "view",
553556 input.reference,
554557 "--json",
555 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
558 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
556559 ],
557560 }).pipe(
558561 Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary),
......@@ -699,7 +702,7 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe(
699702);
700703
701704it.layer(GitManagerTestLayer)("GitManager", (it) => {
702 it.effect("status includes PR metadata when branch already has an open PR", () =>
705 it.effect("status includes draft PR metadata when branch already has a draft PR", () =>
703706 Effect.gen(function* () {
704707 const repoDir = yield* makeTempDir("t3code-git-manager-");
705708 yield* initRepo(repoDir);
......@@ -719,6 +722,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
719722 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
720723 baseRefName: "main",
721724 headRefName: "feature/status-open-pr",
725 isDraft: true,
722726 },
723727 ]),
724728 ],
......@@ -737,6 +741,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
737741 baseRef: "main",
738742 headRef: "feature/status-open-pr",
739743 state: "open",
744 isDraft: true,
740745 updatedAt: null,
741746 });
742747 }),
......@@ -1686,7 +1691,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
16861691 updatedAt: "2026-03-10T07:00:00.000Z",
16871692 });
16881693 expect(ghCalls).toContain(
1689 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
1694 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
16901695 );
16911696 }),
16921697 20_000,
......@@ -1752,7 +1757,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
17521757 updatedAt: "2026-03-10T07:00:00.000Z",
17531758 });
17541759 expect(ghCalls).toContain(
1755 "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
1760 "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
17561761 );
17571762 }),
17581763 20_000,
apps/server/src/git/GitManager.ts+5-5
......@@ -170,6 +170,7 @@ interface OpenPrInfo {
170170
171171interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
172172 state: "open" | "closed" | "merged";
173 isDraft?: boolean;
173174 updatedAt: Option.Option<DateTime.Utc>;
174175}
175176
......@@ -404,6 +405,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
404405 baseRefName: summary.baseRefName,
405406 headRefName: summary.headRefName,
406407 state: summary.state ?? "open",
408 ...(summary.isDraft === true ? { isDraft: true } : {}),
407409 updatedAt: summary.updatedAt,
408410 ...(summary.isCrossRepository !== undefined
409411 ? { isCrossRepository: summary.isCrossRepository }
......@@ -558,6 +560,7 @@ function toStatusPr(pr: PullRequestInfo): {
558560 baseRef: string;
559561 headRef: string;
560562 state: "open" | "closed" | "merged";
563 isDraft?: boolean;
561564 updatedAt: string | null;
562565} {
563566 return {
......@@ -567,6 +570,7 @@ function toStatusPr(pr: PullRequestInfo): {
567570 baseRef: pr.baseRefName,
568571 headRef: pr.headRefName,
569572 state: pr.state,
573 ...(pr.isDraft === true ? { isDraft: true } : {}),
570574 updatedAt: Option.match(pr.updatedAt, {
571575 onNone: () => null,
572576 onSome: (updatedAt) => DateTime.formatIso(updatedAt),
......@@ -1518,11 +1522,7 @@ export const make = Effect.gen(function* () {
15181522 );
15191523 if (firstPullRequest) {
15201524 return {
1521 number: firstPullRequest.number,
1522 title: firstPullRequest.title,
1523 url: firstPullRequest.url,
1524 baseRefName: firstPullRequest.baseRefName,
1525 headRefName: firstPullRequest.headRefName,
1525 ...firstPullRequest,
15261526 state: "open",
15271527 updatedAt: Option.none(),
15281528 } satisfies PullRequestInfo;
apps/server/src/pullRequest/GitHubPullRequestCli.ts+2
......@@ -468,6 +468,7 @@ export class GitHubPullRequestCli extends Context.Service<
468468 readonly headBranch: string;
469469 readonly baseBranch: string;
470470 readonly state: "open" | "closed" | "merged";
471 readonly isDraft?: boolean;
471472 readonly updatedAt: string;
472473 },
473474 GitHubPullRequestCliError
......@@ -1650,6 +1651,7 @@ export const make = Effect.gen(function* () {
16501651 headBranch: summary.headRefName,
16511652 baseBranch: summary.baseRefName,
16521653 state: summary.state ?? "open",
1654 ...(summary.isDraft === true ? { isDraft: true } : {}),
16531655 updatedAt: summary.updatedAt,
16541656 }),
16551657 ),
apps/server/src/pullRequest/PullRequestProvider.ts+2
......@@ -96,6 +96,8 @@ export interface ProviderChangeRequestSummary {
9696 readonly headBranch: string;
9797 readonly baseBranch: string;
9898 readonly state: PullRequestState;
99 /** Present when the host says an open pull request is still a draft. */
100 readonly isDraft?: boolean;
99101 readonly updatedAt: string;
100102}
101103
apps/server/src/pullRequest/PullRequestService.ts+2
......@@ -1254,6 +1254,7 @@ export const make = Effect.gen(function* () {
12541254 title: changeRequest.title,
12551255 url: changeRequest.url,
12561256 state: changeRequest.state,
1257 ...(changeRequest.isDraft === true ? { isDraft: true } : {}),
12571258 headBranch: changeRequest.headBranch,
12581259 baseBranch: changeRequest.baseBranch,
12591260 updatedAt: changeRequest.updatedAt,
......@@ -2278,6 +2279,7 @@ export const make = Effect.gen(function* () {
22782279 title: detail.title,
22792280 url: detail.url,
22802281 state: detail.state,
2282 ...(detail.isDraft === true ? { isDraft: true } : {}),
22812283 headBranch: detail.headBranch,
22822284 baseBranch: detail.baseBranch,
22832285 updatedAt: detail.updatedAt,
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+2
......@@ -61,6 +61,7 @@ function toChangeRequest(summary: {
6161 readonly baseRefName: string;
6262 readonly headRefName: string;
6363 readonly state: "open" | "closed" | "merged";
64 readonly isDraft?: boolean;
6465 readonly updatedAt: ChangeRequest["updatedAt"];
6566}): ChangeRequest {
6667 return {
......@@ -71,6 +72,7 @@ function toChangeRequest(summary: {
7172 baseRefName: summary.baseRefName,
7273 headRefName: summary.headRefName,
7374 state: summary.state,
75 ...(summary.isDraft === true ? { isDraft: true } : {}),
7476 updatedAt: summary.updatedAt,
7577 isCrossRepository: false,
7678 };
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+1
......@@ -17,6 +17,7 @@ function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeR
1717 baseRefName: summary.baseRefName,
1818 headRefName: summary.headRefName,
1919 state: summary.state,
20 ...(summary.isDraft === true ? { isDraft: true } : {}),
2021 updatedAt: summary.updatedAt ?? Option.none(),
2122 ...(summary.isCrossRepository !== undefined
2223 ? { isCrossRepository: summary.isCrossRepository }
apps/server/src/sourceControl/GitHubCli.test.ts+3-1
......@@ -65,6 +65,7 @@ describe("GitHubCli.layer", () => {
6565 baseRefName: "main",
6666 headRefName: "feature/pr-threads",
6767 state: "OPEN",
68 isDraft: true,
6869 mergedAt: null,
6970 updatedAt: "2026-08-24T12:34:56Z",
7071 isCrossRepository: true,
......@@ -92,6 +93,7 @@ describe("GitHubCli.layer", () => {
9293 baseRefName: "main",
9394 headRefName: "feature/pr-threads",
9495 state: "open",
96 isDraft: true,
9597 updatedAt: "2026-08-24T12:34:56.000Z",
9698 isCrossRepository: true,
9799 headRepositoryNameWithOwner: "octocat/codething-mvp",
......@@ -105,7 +107,7 @@ describe("GitHubCli.layer", () => {
105107 "view",
106108 "#42",
107109 "--json",
108 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
110 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
109111 ],
110112 cwd: "/repo",
111113 timeoutMs: 30_000,
apps/server/src/sourceControl/GitHubCli.ts+3-2
......@@ -205,6 +205,7 @@ export interface GitHubPullRequestSummary {
205205 readonly baseRefName: string;
206206 readonly headRefName: string;
207207 readonly state?: "open" | "closed" | "merged";
208 readonly isDraft?: boolean;
208209 readonly updatedAt?: string;
209210 readonly isCrossRepository?: boolean;
210211 readonly headRepositoryNameWithOwner?: string | null;
......@@ -366,7 +367,7 @@ export const make = Effect.gen(function* () {
366367 "--limit",
367368 String(input.limit ?? 1),
368369 "--json",
369 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
370 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
370371 ],
371372 }).pipe(
372373 Effect.map((result) => result.stdout.trim()),
......@@ -398,7 +399,7 @@ export const make = Effect.gen(function* () {
398399 "view",
399400 input.reference,
400401 "--json",
401 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
402 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
402403 ],
403404 }).pipe(
404405 Effect.map((result) => result.stdout.trim()),
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+1-1
......@@ -150,7 +150,7 @@ it.effect("uses gh json listing for non-open change request state queries", () =
150150 "--limit",
151151 "10",
152152 "--json",
153 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
153 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
154154 ]);
155155 assert.strictEqual(changeRequests[0]?.provider, "github");
156156 assert.strictEqual(changeRequests[0]?.state, "merged");
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+2-1
......@@ -30,6 +30,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
3030 baseRefName: summary.baseRefName,
3131 headRefName: summary.headRefName,
3232 state: summary.state ?? "open",
33 ...(summary.isDraft === true ? { isDraft: true } : {}),
3334 updatedAt:
3435 summary.updatedAt === undefined
3536 ? Option.none()
......@@ -153,7 +154,7 @@ export const make = Effect.gen(function* () {
153154 "--limit",
154155 String(input.limit ?? 20),
155156 "--json",
156 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
157 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
157158 ],
158159 })
159160 .pipe(
apps/server/src/sourceControl/GitLabCli.ts+1
......@@ -246,6 +246,7 @@ export interface GitLabMergeRequestSummary {
246246 readonly baseRefName: string;
247247 readonly headRefName: string;
248248 readonly state?: "open" | "closed" | "merged";
249 readonly isDraft?: boolean;
249250 readonly updatedAt?: Option.Option<DateTime.Utc>;
250251 readonly isCrossRepository?: boolean;
251252 readonly headRepositoryNameWithOwner?: string | null;
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+1
......@@ -26,6 +26,7 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe
2626 baseRefName: summary.baseRefName,
2727 headRefName: summary.headRefName,
2828 state: summary.state ?? "open",
29 ...(summary.isDraft === true ? { isDraft: true } : {}),
2930 updatedAt: summary.updatedAt ?? Option.none(),
3031 ...(summary.isCrossRepository !== undefined
3132 ? { isCrossRepository: summary.isCrossRepository }
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+3
......@@ -14,6 +14,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord {
1414 readonly baseRefName: string;
1515 readonly headRefName: string;
1616 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
1718 readonly updatedAt: Option.Option<DateTime.Utc>;
1819}
1920
......@@ -35,6 +36,7 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({
3536 sourceRefName: TrimmedNonEmptyString,
3637 targetRefName: TrimmedNonEmptyString,
3738 status: Schema.String,
39 isDraft: Schema.optional(Schema.Boolean),
3840 creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
3941 closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
4042 _links: Schema.optional(
......@@ -168,6 +170,7 @@ function normalizeAzureDevOpsPullRequestRecord(
168170 baseRefName: normalizeRefName(raw.targetRefName),
169171 headRefName: normalizeRefName(raw.sourceRefName),
170172 state: normalizeAzureDevOpsPullRequestState(raw.status),
173 ...(raw.isDraft === true ? { isDraft: true } : {}),
171174 updatedAt: (raw.closedDate ?? Option.none()).pipe(
172175 Option.orElse(() => raw.creationDate ?? Option.none()),
173176 ),
apps/server/src/sourceControl/bitbucketPullRequests.ts+3
......@@ -10,6 +10,7 @@ export interface NormalizedBitbucketPullRequestRecord {
1010 readonly baseRefName: string;
1111 readonly headRefName: string;
1212 readonly state: "open" | "closed" | "merged";
13 readonly isDraft?: boolean;
1314 readonly updatedAt: Option.Option<DateTime.Utc>;
1415 readonly isCrossRepository?: boolean;
1516 readonly headRepositoryNameWithOwner?: string | null;
......@@ -38,6 +39,7 @@ export const BitbucketPullRequestSchema = Schema.Struct({
3839 id: PositiveInt,
3940 title: TrimmedNonEmptyString,
4041 state: Schema.optional(Schema.NullOr(Schema.String)),
42 draft: Schema.optional(Schema.Boolean),
4143 updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
4244 links: Schema.Struct({
4345 html: Schema.Struct({
......@@ -98,6 +100,7 @@ export function normalizeBitbucketPullRequestRecord(
98100 baseRefName: raw.destination.branch.name,
99101 headRefName: raw.source.branch.name,
100102 state: normalizeBitbucketPullRequestState(raw.state),
103 ...(raw.draft === true ? { isDraft: true } : {}),
101104 updatedAt: raw.updated_on ?? Option.none(),
102105 ...(isCrossRepository ? { isCrossRepository: true } : {}),
103106 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
apps/server/src/sourceControl/gitHubPullRequests.ts+3
......@@ -14,6 +14,7 @@ export interface NormalizedGitHubPullRequestRecord {
1414 readonly baseRefName: string;
1515 readonly headRefName: string;
1616 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
1718 readonly updatedAt: Option.Option<DateTime.Utc>;
1819 readonly isCrossRepository?: boolean;
1920 readonly headRepositoryNameWithOwner?: string | null;
......@@ -27,6 +28,7 @@ const GitHubPullRequestSchema = Schema.Struct({
2728 baseRefName: TrimmedNonEmptyString,
2829 headRefName: TrimmedNonEmptyString,
2930 state: Schema.optional(Schema.NullOr(Schema.String)),
31 isDraft: Schema.optional(Schema.Boolean),
3032 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
3133 updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
3234 isCrossRepository: Schema.optional(Schema.Boolean),
......@@ -93,6 +95,7 @@ function normalizeGitHubPullRequestRecord(
9395 baseRefName: raw.baseRefName,
9496 headRefName: raw.headRefName,
9597 state: normalizeGitHubPullRequestState(raw),
98 ...(raw.isDraft === true ? { isDraft: true } : {}),
9699 updatedAt: raw.updatedAt ?? Option.none(),
97100 ...(typeof raw.isCrossRepository === "boolean"
98101 ? { isCrossRepository: raw.isCrossRepository }
apps/server/src/sourceControl/gitLabMergeRequests.ts+4
......@@ -14,6 +14,7 @@ export interface NormalizedGitLabMergeRequestRecord {
1414 readonly baseRefName: string;
1515 readonly headRefName: string;
1616 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
1718 readonly updatedAt: Option.Option<DateTime.Utc>;
1819 readonly isCrossRepository?: boolean;
1920 readonly headRepositoryNameWithOwner?: string | null;
......@@ -41,6 +42,8 @@ const GitLabMergeRequestSchema = Schema.Struct({
4142 source_branch: TrimmedNonEmptyString,
4243 target_branch: TrimmedNonEmptyString,
4344 state: Schema.optional(Schema.NullOr(Schema.String)),
45 draft: Schema.optional(Schema.Boolean),
46 work_in_progress: Schema.optional(Schema.Boolean),
4447 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
4548 source_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
4649 target_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
......@@ -108,6 +111,7 @@ function normalizeGitLabMergeRequestRecord(
108111 baseRefName: raw.target_branch,
109112 headRefName: raw.source_branch,
110113 state: normalizeGitLabMergeRequestState(raw.state),
114 ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}),
111115 updatedAt: raw.updated_at ?? Option.none(),
112116 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
113117 ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}),
apps/web/src/components/Sidebar.tsx+1-1
......@@ -954,7 +954,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
954954 linkedPullRequestStatus,
955955 });
956956 const prStatus = prStatusIndicator(pr, prProvider);
957 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
957 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined;
958958 useEffect(() => {
959959 const nextSnapshot = nextThreadChangeRequestSnapshot({
960960 threadBranch: thread.branch,
apps/web/src/components/ThreadStatusIndicators.test.ts+28
......@@ -10,6 +10,7 @@ import {
1010 resolveDisplayedThreadPrProvider,
1111 resolveThreadPr,
1212 settledPrHoverColorClass,
13 threadChangeRequestSnapshotsEqual,
1314 threadChangeRequestSnapshotsAtom,
1415 type ThreadChangeRequestSnapshot,
1516} from "./ThreadStatusIndicators";
......@@ -559,6 +560,18 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
559560 });
560561 expect(displayed?.state).toBe("merged");
561562 });
563
564 it("refreshes a cached snapshot when a pull request becomes ready", () => {
565 const readyPr = { ...mergedPr, state: "open" as const };
566 const draftPr = { ...readyPr, isDraft: true };
567
568 expect(
569 threadChangeRequestSnapshotsEqual(
570 snapshotFor(featureBranch, draftPr),
571 snapshotFor(featureBranch, readyPr),
572 ),
573 ).toBe(false);
574 });
562575});
563576
564577describe("threadChangeRequestSnapshotsAtom", () => {
......@@ -600,6 +613,17 @@ describe("prStatusIndicator", () => {
600613 "text-red-600",
601614 );
602615 });
616
617 it("uses gray and draft wording for draft pull requests", () => {
618 const draftPr = status().pr;
619 if (!draftPr) throw new Error("Expected pull request fixture");
620
621 expect(prStatusIndicator({ ...draftPr, isDraft: true }, undefined)).toMatchObject({
622 label: "PR draft",
623 colorClass: "text-zinc-500 dark:text-zinc-400/80",
624 tooltipLead: "PR #42 - Draft",
625 });
626 });
603627});
604628
605629describe("settledPrHoverColorClass", () => {
......@@ -610,4 +634,8 @@ describe("settledPrHoverColorClass", () => {
610634 ] as const)("restores the %s pull request color on row hover", (state, colorClass) => {
611635 expect(settledPrHoverColorClass(state)).toContain(`group-hover/v2-row:${colorClass}`);
612636 });
637
638 it("keeps draft pull requests gray on row hover", () => {
639 expect(settledPrHoverColorClass("open", true)).toContain("group-hover/v2-row:text-zinc-500");
640 });
613641});
apps/web/src/components/ThreadStatusIndicators.tsx+17-6
......@@ -84,9 +84,15 @@ export function useLinkedThreadPullRequest(
8484 );
8585}
8686
87export function settledPrHoverColorClass(state: NonNullable<ThreadPr>["state"]): string {
87export function settledPrHoverColorClass(
88 state: NonNullable<ThreadPr>["state"],
89 isDraft = false,
90): string {
8891 switch (state) {
8992 case "open":
93 if (isDraft) {
94 return "group-hover/v2-row:text-zinc-500 dark:group-hover/v2-row:text-zinc-400/80";
95 }
9096 return "group-hover/v2-row:text-emerald-600 dark:group-hover/v2-row:text-emerald-300/90";
9197 case "merged":
9298 return "group-hover/v2-row:text-violet-600 dark:group-hover/v2-row:text-violet-300/90";
......@@ -99,12 +105,13 @@ export function prStatusIndicator(
99105 pr: ThreadPr,
100106 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
101107): PrStatusIndicator | null {
102 function formatPrState(state: NonNullable<ThreadPr>["state"]): string {
103 return state.charAt(0).toUpperCase() + state.slice(1);
108 function formatPrState(pr: NonNullable<ThreadPr>): string {
109 if (pr.state === "open" && pr.isDraft === true) return "Draft";
110 return pr.state.charAt(0).toUpperCase() + pr.state.slice(1);
104111 }
105112
106113 function formatPrStatusLead(pr: NonNullable<ThreadPr>, changeRequestShortName: string): string {
107 return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`;
114 return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`;
108115 }
109116 if (!pr) return null;
110117 const presentation = resolveChangeRequestPresentation(provider);
......@@ -113,9 +120,12 @@ export function prStatusIndicator(
113120 const tooltip = `${tooltipLead}: ${pr.title}`;
114121
115122 if (pr.state === "open") {
123 const isDraft = pr.isDraft === true;
116124 return {
117 label: `${presentation.shortName} open`,
118 colorClass: "text-emerald-600 dark:text-emerald-300/90",
125 label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`,
126 colorClass: isDraft
127 ? "text-zinc-500 dark:text-zinc-400/80"
128 : "text-emerald-600 dark:text-emerald-300/90",
119129 tooltip,
120130 tooltipLead,
121131 tooltipTitle: pr.title,
......@@ -230,6 +240,7 @@ export function threadChangeRequestSnapshotsEqual(
230240 left.pr.baseRef === right.pr.baseRef &&
231241 left.pr.headRef === right.pr.headRef &&
232242 left.pr.state === right.pr.state &&
243 left.pr.isDraft === right.pr.isDraft &&
233244 (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) &&
234245 sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) &&
235246 linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest)
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+8-2
......@@ -625,7 +625,13 @@ export function PullRequestDetailPanel({
625625 () =>
626626 resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail
627627 ? resolvedCoreDetail
628 : { ...resolvedCoreDetail, ...sharedSummary },
628 : {
629 ...resolvedCoreDetail,
630 ...sharedSummary,
631 // A summary may come from an older server that does not report draft state. Keep the
632 // detail's required value instead of making the complete detail shape partial.
633 isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft,
634 },
629635 [resolvedCoreDetail, sharedSummary],
630636 );
631637 const activity = activityQuery.data;
......@@ -1297,7 +1303,7 @@ export function PullRequestDetailPanel({
12971303 !conflicting &&
12981304 allowedMergeMethods.length > 1;
12991305 // The pull request number carries this state in the overview and the right-panel tab mirrors
1300 // it. The conflict action is separate from this state: an open pull request remains green.
1306 // it. Conflicts take the action slot while they need a person, but do not change the PR state.
13011307 const statePresentation = detail
13021308 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
13031309 : null;
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+3-3
......@@ -33,9 +33,9 @@ interface StatePresentation {
3333}
3434
3535/**
36 * How a pull request's state reads on this page. Open, closed and merged use the same ink as
37 * the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two
38 * different things in two places; draft and conflicts are states that badge never shows.
36 * How a pull request's state reads on this page. Open, closed, merged, and draft use the same
37 * ink as the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two
38 * different things in two places.
3939 *
4040 * Draft outranks conflicts: a draft is not heading for a merge yet, so conflicts only surface
4141 * once it is real work.
packages/client-runtime/src/state/pullRequests.ts+1
......@@ -69,6 +69,7 @@ export function pullRequestDetailToVcsStatus(
6969 baseRef: detail.baseBranch,
7070 headRef: detail.headBranch,
7171 state: detail.state,
72 ...(detail.isDraft === true ? { isDraft: true } : {}),
7273 updatedAt: detail.updatedAt,
7374 };
7475}
packages/contracts/src/git.ts+2
......@@ -198,6 +198,8 @@ const VcsStatusChangeRequest = Schema.Struct({
198198 baseRef: TrimmedNonEmptyStringSchema,
199199 headRef: TrimmedNonEmptyStringSchema,
200200 state: VcsStatusChangeRequestState,
201 /** Optional for compatibility with older servers and providers. */
202 isDraft: Schema.optional(Schema.Boolean),
201203 /**
202204 * Last provider-side activity (ISO). For a merged/closed change request
203205 * this bounds when it reached that state, so clients can tell a PR that
packages/contracts/src/pullRequest.ts+2
......@@ -634,6 +634,8 @@ export const PullRequestSummary = Schema.Struct({
634634 title: TrimmedNonEmptyString,
635635 url: TrimmedNonEmptyString,
636636 state: PullRequestState,
637 /** Present when the host says the open pull request is still a draft. */
638 isDraft: Schema.optional(Schema.Boolean),
637639 headBranch: TrimmedNonEmptyString,
638640 baseBranch: TrimmedNonEmptyString,
639641 updatedAt: IsoDateTime,
packages/contracts/src/sourceControl.ts+2
......@@ -29,6 +29,8 @@ export const ChangeRequest = Schema.Struct({
2929 baseRefName: TrimmedNonEmptyString,
3030 headRefName: TrimmedNonEmptyString,
3131 state: ChangeRequestState,
32 /** Present when the provider can tell that an open change request is still a draft. */
33 isDraft: Schema.optional(Schema.Boolean),
3234 updatedAt: Schema.Option(Schema.DateTimeUtc),
3335 isCrossRepository: Schema.optional(Schema.Boolean),
3436 headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
scripts/build-desktop-artifact.ts+16-1
......@@ -2863,7 +2863,7 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function
28632863 readonly arch: typeof BuildArch.Type;
28642864}) {
28652865 const fs = yield* FileSystem.FileSystem;
2866 yield* Effect.tryPromise({
2866 const archiveStream = yield* Effect.tryPromise({
28672867 try: () =>
28682868 createPackageWithOptions(input.sourceDir, input.asarPath, {
28692869 dot: true,
......@@ -2872,6 +2872,21 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function
28722872 }),
28732873 catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }),
28742874 });
2875 yield* Effect.tryPromise({
2876 try: () =>
2877 new Promise<void>((resolve, reject) => {
2878 const stream = archiveStream as NodeJS.WritableStream & {
2879 readonly writableFinished?: boolean;
2880 };
2881 if (stream.writableFinished === true) {
2882 resolve();
2883 return;
2884 }
2885 stream.once("finish", resolve);
2886 stream.once("error", reject);
2887 }),
2888 catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }),
2889 });
28752890 const unpackedDirPath = `${input.asarPath}.unpacked`;
28762891 if (!(yield* fs.exists(unpackedDirPath))) {
28772892 return yield* new WindowsServerSidecarPackError({