History

feat(pull-requests): link multiple pull requests to threads (#10839)

Julius Marminge committed 2 months ago

Commit afb84891 parent 383cc40172 files · +12057 −908Browse files

Showing the changes to apps/server/src/pullRequest (13 of 172 files). Show all

Unified · Split

13 files
  1. apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +227 −26
  2. apps/server/src/pullRequest/GitHubPullRequestCli.ts +94 −42
  3. apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +61 −0
  4. apps/server/src/pullRequest/GitHubPullRequestProvider.ts +15 −1
  5. apps/server/src/pullRequest/PullRequestProvider.ts +36 −0
  6. apps/server/src/pullRequest/PullRequestService.test.ts +323 −39
  7. apps/server/src/pullRequest/PullRequestService.ts +202 −59
  8. apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +79 −0
  9. apps/server/src/pullRequest/gitHubPullRequestJson.ts +66 −0
  10. apps/server/src/pullRequest/linkedThreads.test.ts +117 −0
  11. apps/server/src/pullRequest/linkedThreads.ts +39 −0
  12. apps/server/src/pullRequest/pullRequestSyncKey.test.ts +46 −0
  13. apps/server/src/pullRequest/pullRequestSyncKey.ts +44 −0
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+227−26
@@ -183,20 +183,37 @@ afterEach(() => { · 182 unmodified lines
183183 });
184184
185185 layer("GitHubPullRequestCli.layer", (it) => {
186− it.effect("reads linked pull request status through one narrow request", () =>
186+ it.effect("reads linked pull request status with the overview fields in one request", () =>
187187 Effect.gen(function* () {
188− mockedGetPullRequest.mockReturnValueOnce(
189− Effect.succeed({
190− number: 7,
191− title: "Reuse the summary",
192− url: "https://github.com/acme/web/pull/7",
193− baseRefName: "main",
194− headRefName: "feat/summary",
195− state: "merged",
196− closedAt: "2026-08-23T10:00:00Z",
197− mergedAt: "2026-08-23T10:00:00Z",
198− updatedAt: "2026-08-24T12:34:56.000Z",
199− }),
188+ mockedExecute.mockReturnValueOnce(
189+ Effect.succeed(
190+ output(
191+ // @effect-diagnostics-next-line preferSchemaOverJson:off
192+ JSON.stringify({
193+ number: 7,
194+ title: "Reuse the summary",
195+ url: "https://github.com/acme/web/pull/7",
196+ author: { login: "octocat", name: "Octo Cat" },
197+ baseRefName: "main",
198+ headRefName: "feat/summary",
199+ state: "OPEN",
200+ isDraft: false,
201+ mergeable: "MERGEABLE",
202+ reviewDecision: "APPROVED",
203+ additions: 12,
204+ deletions: 3,
205+ changedFiles: 2,
206+ createdAt: "2026-08-20T00:00:00.000Z",
207+ updatedAt: "2026-08-24T12:34:56.000Z",
208+ reviewRequests: [],
209+ labels: [],
210+ statusCheckRollup: [
211+ { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" },
212+ ],
213+ body: "",
214+ }),
215+ ),
216+ ),
200217 );
201218 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
202219
@@ -207,23 +224,207 @@ layer("GitHubPullRequestCli.layer", (it) => { · 4 unmodified lines
207224 number: 7,
208225 });
209226
210− assert.deepStrictEqual(summary, {
227+ assert.deepStrictEqual(
228+ {
229+ number: summary.number,
230+ state: summary.state,
231+ headBranch: summary.headBranch,
232+ isDraft: summary.isDraft,
233+ author: summary.author?.login,
234+ additions: summary.additions,
235+ deletions: summary.deletions,
236+ changedFiles: summary.changedFiles,
237+ reviewDecision: summary.reviewDecision,
238+ checksState: summary.checksState,
239+ mergeability: summary.mergeability,
240+ },
241+ {
242+ number: 7,
243+ state: "open",
244+ headBranch: "feat/summary",
245+ isDraft: false,
246+ author: "octocat",
247+ additions: 12,
248+ deletions: 3,
249+ changedFiles: 2,
250+ reviewDecision: "approved",
251+ checksState: "passing",
252+ mergeability: "mergeable",
253+ },
254+ );
255+ expect(mockedExecute).toHaveBeenCalledOnce();
256+ expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([
257+ "pr",
258+ "view",
259+ "7",
260+ "--repo",
261+ "github.com/acme/web",
262+ "--json",
263+ expect.stringContaining("statusCheckRollup"),
264+ ]);
265+ expect(mockedGetPullRequest).not.toHaveBeenCalled();
266+ }),
267+ );
268+
269+ it.effect("reads the stack a pull request is in through the stacks preview, on its host", () =>
270+ Effect.gen(function* () {
271+ mockedExecute.mockReturnValueOnce(
272+ Effect.succeed(
273+ output(
274+ // @effect-diagnostics-next-line preferSchemaOverJson:off
275+ JSON.stringify([
276+ {
277+ id: 42,
278+ number: 3,
279+ url: "https://api.github.com/repos/acme/web/stacks/3",
280+ base: { ref: "main" },
281+ pull_requests: [
282+ {
283+ number: 6,
284+ head: { ref: "feat/one" },
285+ state: "closed",
286+ merged_at: "2026-09-02",
287+ },
288+ { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null },
289+ ],
290+ },
291+ ]),
292+ ),
293+ ),
294+ );
295+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
296+
297+ const stack = yield* cli.getPullRequestStack({
298+ cwd: "/w",
299+ repository: "acme/web",
300+ host: "ghe.example.com",
211301 number: 7,
212− title: "Reuse the summary",
213− url: "https://github.com/acme/web/pull/7",
214− headBranch: "feat/summary",
215− baseBranch: "main",
216− state: "merged",
217− closedAt: "2026-08-23T10:00:00Z",
218− mergedAt: "2026-08-23T10:00:00Z",
219− updatedAt: "2026-08-24T12:34:56.000Z",
220302 });
221− expect(mockedGetPullRequest).toHaveBeenCalledOnce();
222− expect(mockedGetPullRequest).toHaveBeenCalledWith({
303+
304+ assert.deepStrictEqual(stack, {
305+ id: "42",
306+ number: 3,
307+ url: "https://api.github.com/repos/acme/web/stacks/3",
308+ base: "main",
309+ layers: [
310+ { number: 6, headBranch: "feat/one", state: "merged" },
311+ { number: 7, headBranch: "feat/two", state: "open" },
312+ ],
313+ });
314+ assert.deepStrictEqual(callAt(0).args, [
315+ "api",
316+ "--hostname",
317+ "ghe.example.com",
318+ "repos/acme/web/stacks?pull_request=7",
319+ ]);
320+ }),
321+ );
322+
323+ it.effect("reads an empty stacks listing as not stacked", () =>
324+ Effect.gen(function* () {
325+ mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
326+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
327+
328+ const stack = yield* cli.getPullRequestStack({
223329 cwd: "/w",
224− reference: "https://github.com/acme/web/pull/7",
330+ repository: "acme/web",
331+ host: "github.com",
332+ number: 7,
333+ });
334+
335+ assert.isNull(stack);
336+ }),
337+ );
338+
339+ it.effect("reads a host that refuses the stacks preview as not stacked", () =>
340+ Effect.gen(function* () {
341+ // The CLI classifies a missing preview endpoint as not found.
342+ mockedExecute.mockReturnValueOnce(
343+ Effect.fail(
344+ new GitHubCli.GitHubPullRequestNotFoundError({
345+ command: "gh",
346+ cwd: "/w",
347+ cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"),
348+ }),
349+ ),
350+ );
351+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
352+
353+ const stack = yield* cli.getPullRequestStack({
354+ cwd: "/w",
355+ repository: "acme/web",
356+ host: "github.com",
357+ number: 7,
358+ });
359+
360+ assert.isNull(stack);
361+ }),
362+ );
363+
364+ it.effect("does not read a signed-out gh as an unstacked pull request", () =>
365+ Effect.gen(function* () {
366+ mockedExecute.mockReturnValueOnce(
367+ Effect.fail(
368+ new GitHubCli.GitHubCliAuthenticationError({
369+ command: "gh",
370+ cwd: "/w",
371+ cause: new Error("gh auth login"),
372+ }),
373+ ),
374+ );
375+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
376+
377+ const error = yield* Effect.flip(
378+ cli.getPullRequestStack({
379+ cwd: "/w",
380+ repository: "acme/web",
381+ host: "github.com",
382+ number: 7,
383+ }),
384+ );
385+
386+ assert.strictEqual(error._tag, "GitHubCliAuthenticationError");
387+ }),
388+ );
389+
390+ it.effect("preserves transient stack failures instead of reporting no stack", () =>
391+ Effect.gen(function* () {
392+ const failure = new GitHubCli.GitHubCliCommandError({
393+ command: "gh",
394+ cwd: "/w",
395+ cause: new Error("HTTP 503"),
225396 });
226− expect(mockedExecute).not.toHaveBeenCalled();
397+ mockedExecute.mockReturnValueOnce(Effect.fail(failure));
398+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
399+ const error = yield* Effect.flip(
400+ cli.getPullRequestStack({
401+ cwd: "/w",
402+ repository: "acme/web",
403+ host: "github.com",
404+ number: 7,
405+ }),
406+ );
407+ assert.strictEqual(error, failure);
408+ }),
409+ );
410+
411+ it.effect("reports a stacks answer it cannot read against the stack read", () =>
412+ Effect.gen(function* () {
413+ mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]')));
414+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
415+
416+ const error = yield* Effect.flip(
417+ cli.getPullRequestStack({
418+ cwd: "/w",
419+ repository: "acme/web",
420+ host: "github.com",
421+ number: 7,
422+ }),
423+ );
424+
425+ assert.strictEqual(error._tag, "GitHubPullRequestReadError");
426+ if (error._tag !== "GitHubPullRequestReadError") return;
427+ assert.strictEqual(error.operation, "getPullRequestStack");
227428 }),
228429 );
229430
apps/server/src/pullRequest/GitHubPullRequestCli.ts+94−42
@@ -39,6 +39,7 @@ import { · 38 unmodified lines
3939 decodePullRequestListJson,
4040 decodePullRequestNodeIdJson,
4141 decodePullRequestSearchJson,
42+ decodePullRequestStacksJson,
4243 decodePullRequestStatsJson,
4344 decodeReactionSubjectScopeJson,
4445 decodeRepositoryAccessJson,
@@ -84,6 +85,7 @@ import { · 39 unmodified lines
8485 type GitHubPullRequestHead,
8586 type GitHubPullRequestListItem,
8687 type GitHubPullRequestSearchItem,
88+ type GitHubPullRequestStack,
8789 type GitHubReviewThreadComments,
8890 type GitHubRepositoryAccess,
8991 type GitHubWorkflowRunApproval,
@@ -91,7 +93,7 @@ import { · 1 unmodified line
9193 type GitHubReviewThreadPage,
9294 type GitHubViewerAccess,
9395 } from "./gitHubPullRequestJson.ts";
94−import type { ProviderListCursor } from "./PullRequestProvider.ts";
96+import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts";
9597
9698 /**
9799 * Names the read that produced unusable output, so a failure reports the call it came from
@@ -460,21 +462,7 @@ export class GitHubPullRequestCli extends Context.Service< · 362 unmodified lines
460462 readonly repository: string;
461463 readonly host: string;
462464 readonly number: number;
463− }) => Effect.Effect<
464− {
465− readonly number: number;
466− readonly title: string;
467− readonly url: string;
468− readonly headBranch: string;
469− readonly baseBranch: string;
470− readonly state: "open" | "closed" | "merged";
471− readonly isDraft?: boolean;
472− readonly closedAt?: string | null;
473− readonly mergedAt?: string | null;
474− readonly updatedAt: string;
475− },
476− GitHubPullRequestCliError
477− >;
465+ }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestCliError>;
478466
479467 readonly getPullRequestDetail: (input: {
480468 readonly cwd: string;
@@ -494,6 +482,17 @@ export class GitHubPullRequestCli extends Context.Service< · 13 unmodified lines
494482 readonly isCrossRepository: true;
495483 }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestCliError>;
496484
485+ /**
486+ * The host-native stack this pull request is in, or null when it is in none — which is also
487+ * the answer for a host that refuses the stacks preview altogether.
488+ */
489+ readonly getPullRequestStack: (input: {
490+ readonly cwd: string;
491+ readonly repository: string;
492+ readonly host: string;
493+ readonly number: number;
494+ }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestCliError>;
495+
497496 /**
498497 * How far the branch trails its base, and whether this viewer may update it. Its own read
499498 * because the comparison needs the head ref the detail answers with — a fork's branch is not
@@ -807,8 +806,8 @@ function matchesFilters( · 307 unmodified lines
807806 viewer: string,
808807 ): boolean {
809808 if (filters === undefined) return true;
810− const labels = item.labels.map((label) => label.name.trim().toLowerCase());
811− const holds = (label: string) => labels.includes(label.trim().toLowerCase());
809+ const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
810+ const holds = (label: string) => labels.has(label.trim().toLowerCase());
812811 return (
813812 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
814813 (filters.review === undefined ||
@@ -1630,41 +1629,94 @@ export const make = Effect.gen(function* () { · 815 unmodified lines
16301629 ).pipe(Effect.map((results) => results.flat()));
16311630 },
16321631
1632+ // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands
1633+ // the thread overview its author, diff stat, review decision and checks in the same read.
16331634 getPullRequestSummary: (input) =>
16341635 github
1635− .getPullRequest({
1636+ .execute({
16361637 cwd: input.cwd,
1637− reference: `https://${input.host}/${input.repository}/pull/${input.number}`,
1638+ args: [
1639+ "pr",
1640+ "view",
1641+ String(input.number),
1642+ ...repositoryArgs(input),
1643+ "--json",
1644+ PULL_REQUEST_DETAIL_JSON_FIELDS,
1645+ ],
16381646 })
16391647 .pipe(
1640− Effect.flatMap((summary) =>
1641− summary.updatedAt === undefined
1642− ? Effect.fail(
1643− new GitHubPullRequestUpdatedAtUnavailableError({
1644− command: "gh",
1645− cwd: input.cwd,
1646− repository: input.repository,
1647− number: input.number,
1648− }),
1649− )
1650− : Effect.succeed({
1651− number: summary.number,
1652− title: summary.title,
1653− url: summary.url,
1654− headBranch: summary.headRefName,
1655− baseBranch: summary.baseRefName,
1656− state: summary.state ?? "open",
1657− ...(summary.isDraft === true ? { isDraft: true } : {}),
1658− closedAt: summary.closedAt ?? null,
1659− mergedAt: summary.mergedAt ?? null,
1660− updatedAt: summary.updatedAt,
1648+ Effect.flatMap((result) => {
1649+ const decoded = decodePullRequestDetailJson(result.stdout.trim());
1650+ if (!Result.isSuccess(decoded)) {
1651+ return Effect.fail(
1652+ new GitHubPullRequestReadError({
1653+ command: "gh",
1654+ cwd: input.cwd,
1655+ operation: "getPullRequestSummary",
1656+ cause: decoded.failure,
16611657 }),
1662− ),
1658+ );
1659+ }
1660+ const detail = decoded.success;
1661+ return Effect.succeed({
1662+ number: detail.number,
1663+ title: detail.title,
1664+ url: detail.url,
1665+ headBranch: detail.headBranch,
1666+ baseBranch: detail.baseBranch,
1667+ state: detail.state,
1668+ updatedAt: detail.updatedAt,
1669+ closedAt: detail.closedAt ?? null,
1670+ mergedAt: detail.mergedAt ?? null,
1671+ isDraft: detail.isDraft,
1672+ author: detail.author,
1673+ additions: detail.additions,
1674+ deletions: detail.deletions,
1675+ changedFiles: detail.changedFiles,
1676+ reviewDecision: detail.reviewDecision,
1677+ checksState: detail.checksState,
1678+ mergeability: detail.mergeability,
1679+ });
1680+ }),
16631681 ),
16641682
16651683 getPullRequestDetail,
16661684 listWorkflowRunsRequiringApproval,
16671685
1686+ getPullRequestStack: (input) => {
1687+ const { owner, name } = parseRepositorySelector(input.repository);
1688+ return github
1689+ .execute({
1690+ cwd: input.cwd,
1691+ args: [
1692+ "api",
1693+ "--hostname",
1694+ input.host,
1695+ `repos/${owner}/${name}/stacks?pull_request=${input.number}`,
1696+ ],
1697+ })
1698+ .pipe(
1699+ Effect.flatMap((result) => {
1700+ const decoded = decodePullRequestStacksJson(result.stdout.trim());
1701+ return Result.isSuccess(decoded)
1702+ ? Effect.succeed(decoded.success)
1703+ : Effect.fail(
1704+ new GitHubPullRequestReadError({
1705+ command: "gh",
1706+ cwd: input.cwd,
1707+ operation: "getPullRequestStack",
1708+ cause: decoded.failure,
1709+ }),
1710+ );
1711+ }),
1712+ // Hosts without the stacks preview return 404. Other failures must preserve the
1713+ // previously synced stack and let the caller retry.
1714+ Effect.catchTags({
1715+ GitHubPullRequestNotFoundError: () => Effect.succeed(null),
1716+ }),
1717+ );
1718+ },
1719+
16681720 getPullRequestBaseComparison: (input) => {
16691721 const { owner, name } = parseRepositorySelector(input.repository);
16701722 return graphqlRead({
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+61−0
@@ -25,6 +25,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => · 24 unmodified lines
2525 baseBranch: "main",
2626 state: "open" as const,
2727 updatedAt: "2026-08-24T12:34:56.000Z",
28+ author: { login: "octocat", name: null, avatarUrl: null },
2829 };
2930 }),
3031 }),
@@ -42,6 +43,66 @@ it.effect("uses one narrow read for a linked pull request summary", () => · 11 unmodified lines
4243
4344 expect(summary.state).toBe("open");
4445 expect(summaryReads).toBe(1);
46+ // The author's avatar comes from the login-shaped URL, not a second request.
47+ expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80");
48+ }),
49+);
50+
51+it.effect("declares host-native stacks and passes the one the CLI reads through", () =>
52+ Effect.gen(function* () {
53+ const stack = {
54+ id: "42",
55+ number: 3,
56+ url: "https://github.com/acme/web/stacks/3",
57+ base: "main",
58+ layers: [
59+ { number: 6, headBranch: "feat/one", state: "merged" as const },
60+ { number: 7, headBranch: "feat/two", state: "open" as const },
61+ ],
62+ };
63+ const provider = yield* make.pipe(
64+ Effect.provide(
65+ Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
66+ getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null),
67+ }),
68+ ),
69+ );
70+
71+ expect(provider.capabilities.stacks).toBe(true);
72+ const readStack = provider.getChangeRequestStack;
73+ if (readStack === undefined) return yield* Effect.die("stack read was not implemented");
74+ const ref = { cwd: "/w", repository: "acme/web", host: "github.com" };
75+ expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack);
76+ expect(yield* readStack({ ...ref, number: 8 })).toBeNull();
77+ }),
78+);
79+
80+it.effect("reports a failed stack read against its own operation", () =>
81+ Effect.gen(function* () {
82+ const provider = yield* make.pipe(
83+ Effect.provide(
84+ Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
85+ getPullRequestStack: () =>
86+ Effect.fail(
87+ new GitHubPullRequestCli.GitHubPullRequestReadError({
88+ command: "gh",
89+ cwd: "/w",
90+ operation: "getPullRequestStack",
91+ cause: new Error("unreadable"),
92+ }),
93+ ),
94+ }),
95+ ),
96+ );
97+
98+ const readStack = provider.getChangeRequestStack;
99+ if (readStack === undefined) return yield* Effect.die("stack read was not implemented");
100+ const error = yield* Effect.flip(
101+ readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }),
102+ );
103+
104+ expect(error.operation).toBe("getChangeRequestStack");
105+ expect(error.reason).toBe("failed");
45106 }),
46107 );
47108
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+15−1
@@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = { · 46 unmodified lines
4747 },
4848 reviewers: { request: true, listCandidates: true },
4949 edit: { changeRequest: true, comment: true },
50+ stacks: true,
5051 labels: true,
5152 };
5253
@@ -292,7 +293,20 @@ export const make = Effect.gen(function* () { · 239 unmodified lines
292293 .pipe(Effect.mapError(fail("listChangeRequestStats"))),
293294
294295 getChangeRequestSummary: (input) =>
295− cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))),
296+ cli.getPullRequestSummary(input).pipe(
297+ // `gh pr view` names the author without an avatar; the login-shaped URL every user
298+ // has stands in, without the second request the listing spends on it.
299+ Effect.map((summary) => ({
300+ ...summary,
301+ ...(summary.author === undefined
302+ ? {}
303+ : { author: withAvatar(summary.author, new Map(), input.host) }),
304+ })),
305+ Effect.mapError(fail("getChangeRequestSummary")),
306+ ),
307+
308+ getChangeRequestStack: (input) =>
309+ cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))),
296310
297311 getChangeRequest: (input) =>
298312 Effect.all(
apps/server/src/pullRequest/PullRequestProvider.ts+36−0
@@ -103,6 +103,34 @@ export interface ProviderChangeRequestSummary { · 102 unmodified lines
103103 readonly closedAt?: string | null;
104104 readonly mergedAt?: string | null;
105105 readonly updatedAt: string;
106+ /** Overview fields, present where the host's single read returns them at no extra cost. */
107+ readonly author?: PullRequestActor | null | undefined;
108+ readonly additions?: number | undefined;
109+ readonly deletions?: number | undefined;
110+ readonly changedFiles?: number | undefined;
111+ readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
112+ readonly checksState?: PullRequestChecksState | null | undefined;
113+ readonly mergeability?: PullRequestMergeability | undefined;
114+}
115+
116+/** One layer of a host-native stack, bottom to top order is the array's. */
117+export interface ProviderChangeRequestStackLayer {
118+ readonly number: number;
119+ readonly headBranch: string;
120+ readonly state: PullRequestState;
121+}
122+
123+/**
124+ * A host-native stack: an ordered set of change requests the host itself merges and retargets as
125+ * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay
126+ * ignorant of which host said so.
127+ */
128+export interface ProviderChangeRequestStack {
129+ readonly id: string;
130+ readonly number: number;
131+ readonly url: string;
132+ readonly base: string;
133+ readonly layers: ReadonlyArray<ProviderChangeRequestStackLayer>;
106134 }
107135
108136 export interface ProviderChangeRequestPage {
@@ -331,6 +359,14 @@ export interface PullRequestProviderApi { · 222 unmodified lines
331359 input: ProviderRepositoryRef & { readonly number: number },
332360 ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>;
333361
362+ /**
363+ * The host-native stack a change request belongs to, or null when it is not stacked. Optional
364+ * because most hosts have no such object; the service derives chains from base branches there.
365+ */
366+ readonly getChangeRequestStack?: (
367+ input: ProviderRepositoryRef & { readonly number: number },
368+ ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>;
369+
334370 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
335371 readonly getChangeRequestActivity: (
336372 input: ProviderRepositoryRef & { readonly number: number },
apps/server/src/pullRequest/PullRequestService.test.ts+323−39
@@ -32,6 +32,7 @@ function project(input: { · 31 unmodified lines
3232 readonly repository?: string;
3333 readonly provider?: string;
3434 readonly host?: string;
35+ readonly remoteUrl?: string;
3536 }): OrchestrationProjectShell {
3637 // The host defaults from the provider, so a fixture only names one when the point of the
3738 // test is two hosts of the same kind.
@@ -47,7 +48,7 @@ function project(input: { · 9 unmodified lines
4748 locator: {
4849 source: "git-remote" as const,
4950 remoteName: "origin",
50− remoteUrl: `https://${host}/${input.repository}.git`,
51+ remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`,
5152 },
5253 provider: input.provider ?? "github",
5354 displayName: input.repository,
@@ -470,7 +471,7 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" · 416 unmodified lines
470471
471472 // Keyed by the selector Azure is actually asked with, which is the repository's own name.
472473 assert.deepStrictEqual(result.nextCursors, {
473− "dev.azure.com web": "2026-07-02T00:00:00Z|4|7",
474+ "dev.azure.com dev.azure.com/acme/web": "2026-07-02T00:00:00Z|4|7",
474475 });
475476 }),
476477 );
@@ -1563,6 +1564,247 @@ it.effect("refuses a repository that does not belong to the requested project", · 1086 unmodified lines
15631564 }),
15641565 );
15651566
1567+it.effect("reads a host-native stack through the provider and null where it has none", () =>
1568+ Effect.gen(function* () {
1569+ const service = yield* makeService({
1570+ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1571+ providers: [
1572+ fakeProvider("github", {
1573+ getChangeRequestStack: () =>
1574+ Effect.succeed({
1575+ id: "9",
1576+ number: 3,
1577+ url: "https://github.com/acme/web/stacks/3",
1578+ base: "main",
1579+ layers: [
1580+ { number: 7, headBranch: "a", state: "open" as const },
1581+ { number: 8, headBranch: "b", state: "open" as const },
1582+ ],
1583+ }),
1584+ }),
1585+ ],
1586+ });
1587+
1588+ const stack = yield* service.stack({
1589+ projectId: "p1" as ProjectId,
1590+ repository: "acme/web",
1591+ number: 7,
1592+ });
1593+ assert.deepStrictEqual(
1594+ stack?.layers.map((layer) => layer.number),
1595+ [7, 8],
1596+ );
1597+
1598+ const withoutStacks = yield* makeService({
1599+ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1600+ providers: [fakeProvider("github")],
1601+ });
1602+ assert.isNull(
1603+ yield* withoutStacks.stack({
1604+ projectId: "p1" as ProjectId,
1605+ repository: "acme/web",
1606+ number: 7,
1607+ }),
1608+ );
1609+ }),
1610+);
1611+
1612+it.effect("routes a hosted reference to another repository through a project on that host", () =>
1613+ Effect.gen(function* () {
1614+ const seen: Array<{ cwd: string; repository: string; host: string }> = [];
1615+ const service = yield* makeService({
1616+ projects: [
1617+ project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
1618+ ],
1619+ providers: [
1620+ fakeProvider("github", {
1621+ getChangeRequestSummary: (input) =>
1622+ Effect.sync(() => {
1623+ seen.push({ cwd: input.cwd, repository: input.repository, host: input.host });
1624+ return changeRequest(7, "2026-07-02T00:00:00Z");
1625+ }),
1626+ }),
1627+ ],
1628+ });
1629+
1630+ const summary = yield* service.summary(
1631+ { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 },
1632+ { recoverTransientFailure: false },
1633+ );
1634+
1635+ assert.strictEqual(summary.number, 7);
1636+ assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]);
1637+ }),
1638+);
1639+
1640+it.effect("routes Azure reads and writes through the requested organization's checkout", () =>
1641+ Effect.gen(function* () {
1642+ const seen: string[] = [];
1643+ const service = yield* makeService({
1644+ projects: ["org-a", "org-b"].map((organization) =>
1645+ project({
1646+ id: organization,
1647+ title: organization,
1648+ workspaceRoot: `/${organization}`,
1649+ repository: `${organization}/project/_git/web`,
1650+ provider: "azure-devops",
1651+ host: "dev.azure.com",
1652+ }),
1653+ ),
1654+ providers: [
1655+ fakeProvider("azure-devops", {
1656+ getChangeRequestSummary: (input) =>
1657+ Effect.sync(() => {
1658+ seen.push(`read ${input.cwd} ${input.repository}`);
1659+ return changeRequest(7, "2026-07-02T00:00:00Z");
1660+ }),
1661+ runAction: (input) =>
1662+ Effect.sync(() => {
1663+ seen.push(`write ${input.cwd} ${input.repository}`);
1664+ }),
1665+ }),
1666+ ],
1667+ });
1668+ const reference = {
1669+ projectId: "org-a" as ProjectId,
1670+ host: "dev.azure.com",
1671+ repository: "org-b/project/_git/web",
1672+ number: 7,
1673+ };
1674+ yield* service.summary(reference, { recoverTransientFailure: false });
1675+ yield* service.runAction({ ...reference, action: "merge" });
1676+ assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]);
1677+ }),
1678+);
1679+
1680+for (const checkout of [
1681+ {
1682+ host: "ssh.dev.azure.com",
1683+ repository: "v3/org-b/project/web",
1684+ remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web",
1685+ },
1686+ {
1687+ host: "vs-ssh.visualstudio.com",
1688+ repository: "v3/org-b/project/web",
1689+ remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web",
1690+ },
1691+ {
1692+ host: "org-b.visualstudio.com",
1693+ repository: "DefaultCollection/project/_git/web",
1694+ remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web",
1695+ },
1696+]) {
1697+ it.effect(`routes Azure URL reads and writes through a ${checkout.host} checkout`, () =>
1698+ Effect.gen(function* () {
1699+ const seen: string[] = [];
1700+ const target = project({
1701+ id: "target",
1702+ title: "target",
1703+ workspaceRoot: "/target",
1704+ provider: "azure-devops",
1705+ ...checkout,
1706+ });
1707+ const service = yield* makeService({
1708+ projects: [
1709+ ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) =>
1710+ project({
1711+ id: repository,
1712+ title: repository,
1713+ workspaceRoot: `/${repository}`,
1714+ provider: "azure-devops",
1715+ host: "dev.azure.com",
1716+ repository,
1717+ }),
1718+ ),
1719+ target,
1720+ ],
1721+ providers: [
1722+ fakeProvider("azure-devops", {
1723+ getChangeRequestSummary: (input) =>
1724+ Effect.sync(() => {
1725+ seen.push(`read ${input.cwd} ${input.repository}`);
1726+ return changeRequest(7, "2026-07-02T00:00:00Z");
1727+ }),
1728+ runAction: (input) =>
1729+ Effect.sync(() => {
1730+ seen.push(`write ${input.cwd} ${input.repository}`);
1731+ }),
1732+ }),
1733+ ],
1734+ });
1735+ const reference = {
1736+ projectId: "org-a/project/_git/web" as ProjectId,
1737+ host: "dev.azure.com",
1738+ repository: "org-b/project/_git/web",
1739+ number: 7,
1740+ };
1741+ yield* service.summary(reference, { recoverTransientFailure: false });
1742+ yield* service.runAction({ ...reference, action: "merge" });
1743+ assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]);
1744+ }),
1745+ );
1746+}
1747+
1748+it.effect("refuses Azure cross-organization reads and writes without its checkout", () =>
1749+ Effect.gen(function* () {
1750+ const service = yield* makeService({
1751+ projects: [
1752+ project({
1753+ id: "org-a",
1754+ title: "org-a",
1755+ workspaceRoot: "/org-a",
1756+ repository: "org-a/project/_git/web",
1757+ provider: "azure-devops",
1758+ host: "dev.azure.com",
1759+ }),
1760+ ],
1761+ providers: [
1762+ fakeProvider("azure-devops", {
1763+ getChangeRequestSummary: () => Effect.die("must not read the wrong organization"),
1764+ runAction: () => Effect.die("must not modify the wrong organization"),
1765+ }),
1766+ ],
1767+ });
1768+ const reference = {
1769+ projectId: "org-a" as ProjectId,
1770+ host: "dev.azure.com",
1771+ repository: "org-b/project/_git/web",
1772+ number: 7,
1773+ };
1774+ const readError = yield* Effect.flip(
1775+ service.summary(reference, { recoverTransientFailure: false }),
1776+ );
1777+ const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" }));
1778+ assert.strictEqual(readError._tag, "PullRequestUnavailableError");
1779+ assert.strictEqual(writeError._tag, "PullRequestUnavailableError");
1780+ }),
1781+);
1782+
1783+it.effect("refuses a hosted reference when nothing is checked out from that host", () =>
1784+ Effect.gen(function* () {
1785+ const service = yield* makeService({
1786+ projects: [
1787+ project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }),
1788+ ],
1789+ providers: [fakeProvider("github")],
1790+ });
1791+
1792+ const error = yield* service
1793+ .summary(
1794+ {
1795+ projectId: "frontend" as ProjectId,
1796+ host: "gitlab.com",
1797+ repository: "acme/api",
1798+ number: 7,
1799+ },
1800+ { recoverTransientFailure: false },
1801+ )
1802+ .pipe(Effect.flip);
1803+
1804+ assert.strictEqual(error._tag, "PullRequestUnavailableError");
1805+ }),
1806+);
1807+
15661808 it.effect("refuses a diff on a host that cannot produce one", () =>
15671809 Effect.gen(function* () {
15681810 const service = yield* makeService({
@@ -3377,6 +3619,48 @@ it.effect("does not ask the host again for a linked summary it already holds", ( · 1808 unmodified lines
33773619 }),
33783620 );
33793621
3622+it.effect(
3623+ "opening detail preserves enriched linked summaries and updates draft and diff fields",
3624+ () =>
3625+ Effect.gen(function* () {
3626+ const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3627+ const service = yield* makeService({
3628+ projects: [
3629+ project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
3630+ ],
3631+ providers: [
3632+ fakeProvider("github", {
3633+ getChangeRequestSummary: () =>
3634+ Effect.succeed({
3635+ ...changeRequest(1, "2026-07-02T00:00:00Z"),
3636+ isDraft: true,
3637+ reviewDecision: "approved",
3638+ checksState: "passing",
3639+ }),
3640+ getChangeRequest: () =>
3641+ Effect.succeed({
3642+ ...hostedChangeRequest("body", 14),
3643+ deletions: 3,
3644+ changedFiles: 5,
3645+ mergeability: "conflicting",
3646+ }),
3647+ }),
3648+ ],
3649+ });
3650+ yield* service.summary(reference);
3651+ const detail = yield* service.detail(reference);
3652+ const summary = yield* service.summary(reference);
3653+ assert.strictEqual(summary.isDraft, false);
3654+ assert.deepStrictEqual(summary.author, detail.author);
3655+ assert.strictEqual(summary.additions, 14);
3656+ assert.strictEqual(summary.deletions, 3);
3657+ assert.strictEqual(summary.changedFiles, 5);
3658+ assert.strictEqual(summary.mergeability, "conflicting");
3659+ assert.strictEqual(summary.reviewDecision, "approved");
3660+ assert.strictEqual(summary.checksState, "passing");
3661+ }),
3662+);
3663+
33803664 it.effect("reuses an observed merged state for strict settlement reads", () =>
33813665 Effect.gen(function* () {
33823666 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
@@ -3587,43 +3871,6 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil · 204 unmodified lines
35873871 }),
35883872 );
35893873
3590−it("names an Azure DevOps repository by its own name, not its project path", () => {
3591− // `az repos pr list --repository` takes a name and detects the organisation and project from
3592− // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then
3593− // reads as unavailable on the page.
3594− const selector = PullRequestService.repositoryIdentityOf({
3595− repositoryIdentity: {
3596− provider: "azure-devops",
3597− displayName: "contoso/payments/_git/checkout",
3598− owner: "contoso",
3599− name: "checkout",
3600− },
3601− } as never);
3602− assert.strictEqual(selector, "checkout");
3603−});
3604−
3605−it("falls back to the path's last segment where an Azure identity has no name", () => {
3606− const selector = PullRequestService.repositoryIdentityOf({
3607− repositoryIdentity: {
3608− provider: "azure-devops",
3609− displayName: "contoso/payments/_git/checkout",
3610− },
3611− } as never);
3612− assert.strictEqual(selector, "checkout");
3613−});
3614−
3615−it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => {
3616− const selector = PullRequestService.repositoryIdentityOf({
3617− repositoryIdentity: {
3618− provider: "gitlab",
3619− displayName: "group/subgroup/service",
3620− owner: "group",
3621− name: "service",
3622− },
3623− } as never);
3624− assert.strictEqual(selector, "group/subgroup/service");
3625−});
3626−
36273874 it.effect("narrows the rows of a host that ignored the filters it was handed", () =>
36283875 Effect.gen(function* () {
36293876 const service = yield* makeService({
@@ -4137,3 +4384,40 @@ it.effect("names the signed-in account in the detail, and says nothing where the · 507 unmodified lines
41374384 assert.strictEqual(unnamed.viewer, undefined);
41384385 }),
41394386 );
4387+
4388+it.effect("keeps Azure continuation cursors separate for repositories with the same name", () =>
4389+ Effect.gen(function* () {
4390+ const seen: string[] = [];
4391+ const service = yield* makeService({
4392+ projects: ["org-a", "org-b"].map((organization) =>
4393+ project({
4394+ id: organization,
4395+ title: organization,
4396+ workspaceRoot: `/${organization}`,
4397+ repository: `${organization}/project/_git/web`,
4398+ provider: "azure-devops",
4399+ host: "dev.azure.com",
4400+ }),
4401+ ),
4402+ providers: [
4403+ fakeProvider("azure-devops", {
4404+ listChangeRequests: (input) =>
4405+ Effect.sync(() => {
4406+ seen.push(input.cwd);
4407+ return {
4408+ items: [changeRequest(7, "2026-07-02T00:00:00Z")],
4409+ truncated: true,
4410+ continues: true,
4411+ };
4412+ }),
4413+ }),
4414+ ],
4415+ });
4416+ const first = yield* service.list({ state: "open" });
4417+ assert.lengthOf(Object.keys(first.nextCursors), 2);
4418+ const key = Object.keys(first.nextCursors).find((key) => key.includes("org-b"))!;
4419+ seen.length = 0;
4420+ yield* service.list({ state: "open", cursors: { [key]: first.nextCursors[key]! } });
4421+ assert.deepStrictEqual(seen, ["/org-b"]);
4422+ }),
4423+);
apps/server/src/pullRequest/PullRequestService.ts+202−59
@@ -1,3 +1,7 @@
1+import {
2+ canonicalRepositoryKey,
3+ sourceControlRepositorySelector,
4+} from "@t3tools/shared/sourceControl";
15 import * as Cache from "effect/Cache";
26 import * as Clock from "effect/Clock";
37 import * as Context from "effect/Context";
@@ -46,6 +50,7 @@ import { · 42 unmodified lines
4650 type PullRequestLabelCandidateList,
4751 type PullRequestLabelChangeInput,
4852 type PullRequestSubmitReviewInput,
53+ type PullRequestStack,
4954 type PullRequestSummary,
5055 type PullRequestThreadReplyInput,
5156 type PullRequestThreadResolutionInput,
@@ -144,6 +149,13 @@ export class PullRequestService extends Context.Service< · 92 unmodified lines
144149 input: PullRequestRef,
145150 options?: { readonly recoverTransientFailure?: boolean },
146151 ) => Effect.Effect<PullRequestSummary, PullRequestError>;
152+ /**
153+ * The host-native stack the pull request belongs to, or null when it is not in one or the
154+ * host keeps no such object. Cached like a summary; a stack changes about as often.
155+ */
156+ readonly stack: (
157+ input: PullRequestRef,
158+ ) => Effect.Effect<PullRequestStack | null, PullRequestError>;
147159 readonly subscribeMerges: Effect.Effect<
148160 Stream.Stream<PullRequestMergeEvent>,
149161 never,
@@ -241,6 +253,7 @@ const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to chang · 91 unmodified lines
241253
242254 /** A project this page can read: its remote is on a host with an implementation. */
243255 interface SupportedProject {
256+ readonly cursorKey: string;
244257 readonly project: OrchestrationProjectShell;
245258 readonly api: PullRequestProviderApi;
246259 readonly repository: string;
@@ -453,7 +466,7 @@ function withRateLimitBackoff( · 206 unmodified lines
453466 call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,
454467 ) => wrap(operation, call, true);
455468
456− return {
469+ const wrapped = {
457470 kind: api.kind,
458471 capabilities: api.capabilities,
459472 getViewer: wrap("getViewer", api.getViewer),
@@ -474,6 +487,9 @@ function withRateLimitBackoff( · 14 unmodified lines
474487 : {
475488 getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary),
476489 }),
490+ ...(api.getChangeRequestStack === undefined
491+ ? {}
492+ : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }),
477493 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),
478494 ...(api.getReviewThreadComments === undefined
479495 ? {}
@@ -506,30 +522,9 @@ function withRateLimitBackoff( · 26 unmodified lines
506522 setReaction: interactive("setReaction", api.setReaction),
507523 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),
508524 };
509−}
510−
511−/**
512− * The provider-native repository selector. `displayName` is the full path below the host, which
513− * is what nested GitLab groups need; owner/name is the two-segment fallback for identities
514− * recorded before that field existed.
515− *
516− * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and
517− * takes the organisation and project from the checkout it detects — so the recorded
518− * `org/project/_git/repo` path is refused outright and the whole repository reads as
519− * unavailable. Its name is the last segment, which is what this hands over.
520− *
521− * One function because everything downstream is keyed by what it answers: the rows' own
522− * `repository`, the per-repository cursors, and the detail and diff reads a row leads to.
523− */
524−export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null {
525− const identity = project.repositoryIdentity;
526− if (!identity) return null;
527− if (identity.provider === "azure-devops") {
528− const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git");
529− return identity.name || segments.at(-1) || null;
530− }
531− if (identity.displayName) return identity.displayName;
532− return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null;
525+ // Optional provider methods must be forwarded too; returning the interface alone permits omissions.
526+ return wrapped satisfies PullRequestProviderApi &
527+ Record<Exclude<keyof PullRequestProviderApi, keyof typeof wrapped>, never>;
533528 }
534529
535530 export const make = Effect.gen(function* () {
@@ -554,7 +549,11 @@ export const make = Effect.gen(function* () { · 18 unmodified lines
554549 for (const project of projects) {
555550 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
556551 const identity = project.repositoryIdentity;
557− if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue;
552+ if (
553+ identity?.provider !== "unknown" ||
554+ sourceControlRepositorySelector(project.repositoryIdentity) === null
555+ )
556+ continue;
558557 const host = pullRequestHostOf(identity, "unknown");
559558 // A legacy identity has no canonical host until its provider is refined, so it must reach
560559 // the refinement before a host filter can decide whether it belongs in the result.
@@ -628,7 +627,7 @@ export const make = Effect.gen(function* () { · 67 unmodified lines
628627 if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue;
629628 const identity = project.repositoryIdentity;
630629 let kind = identity?.provider as SourceControlProviderKind | undefined;
631− const repository = repositoryIdentityOf(project);
630+ const repository = sourceControlRepositorySelector(project.repositoryIdentity);
632631 if (!identity || kind === undefined || repository === null) continue;
633632 // Worktrees of one repository are separate projects; reading the remote once keeps
634633 // the page from repeating every change request per local checkout. The host is part
@@ -647,7 +646,10 @@ export const make = Effect.gen(function* () { · 12 unmodified lines
647646 if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]);
648647 else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot);
649648 }
650− const key = listCursorKey(host, repository);
649+ const key = listCursorKey(
650+ host,
651+ kind === "azure-devops" ? identity.canonicalKey : repository,
652+ );
651653 if (seen.has(key)) continue;
652654 seen.add(key);
653655 if (api === null) {
@@ -657,6 +659,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines
657659 continue;
658660 }
659661 supported.push({
662+ cursorKey: key,
660663 project,
661664 api: withRateLimitBackoff(api, host, rateLimits),
662665 repository,
@@ -667,16 +670,30 @@ export const make = Effect.gen(function* () { · 4 unmodified lines
667670 }),
668671 );
669672
673+ /**
674+ * The project whose checkout and credentials serve a reference. The project's own
675+ * repository is the default; a reference that names a `host` may instead point at any
676+ * repository on that host. Prefer its own checkout; providers with explicit repository
677+ * targeting can fall back to another checkout on the host. Azure derives its organization
678+ * from the checkout, so it requires a matching repository.
679+ */
670680 const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> =>
671681 listWorkspaceProjects({ projectId: ref.projectId }).pipe(
672682 Effect.flatMap(({ supported }): Effect.Effect<SupportedProject, PullRequestError> => {
673− const match = supported[0];
674− if (!match) {
675− return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" }));
683+ const own = supported[0];
684+ const repository = ref.repository.trim();
685+ const host = ref.host?.trim().toLowerCase();
686+ if (own !== undefined && own.repository.toLowerCase() === repository.toLowerCase()) {
687+ // Hostless references only ever meant the project's own repository, and a hosted one
688+ // naming it still is; either way the project serves itself.
689+ if (host === undefined || host === own.host) return Effect.succeed(own);
676690 }
677− // The repository travels through the client, so it is checked against the project's
678− // own remote rather than being handed to a provider verbatim.
679− if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) {
691+ if (host === undefined) {
692+ if (own === undefined) {
693+ return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" }));
694+ }
695+ // The repository travels through the client, so it is checked against the project's
696+ // own remote rather than being handed to a provider verbatim.
680697 return Effect.fail(
681698 new PullRequestOperationError({
682699 operation: "resolveRepository",
@@ -684,7 +701,39 @@ export const make = Effect.gen(function* () { · 1 unmodified line
684701 }),
685702 );
686703 }
687− return Effect.succeed(match);
704+ const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase());
705+ // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare
706+ // the complete repository identity before narrowing those checkouts by host.
707+ return listWorkspaceProjects(
708+ repositoryKey.startsWith("dev.azure.com/") ? {} : { host },
709+ ).pipe(
710+ Effect.flatMap(({ supported }) => {
711+ const onHost = supported.filter((candidate) => candidate.host === host);
712+ const route =
713+ supported.find(
714+ (candidate) =>
715+ candidate.api.kind === "azure-devops" &&
716+ candidate.project.repositoryIdentity != null &&
717+ canonicalRepositoryKey(
718+ candidate.project.repositoryIdentity.canonicalKey.toLowerCase(),
719+ ) === repositoryKey,
720+ ) ??
721+ onHost.find(
722+ (candidate) =>
723+ candidate.api.kind !== "azure-devops" &&
724+ candidate.repository.toLowerCase() === repository.toLowerCase(),
725+ ) ??
726+ onHost.find((candidate) => candidate.api.kind !== "azure-devops");
727+ if (route === undefined) {
728+ return Effect.fail(
729+ new PullRequestUnavailableError({ reason: "provider-unsupported" }),
730+ );
731+ }
732+ return Effect.succeed(
733+ route.api.kind === "azure-devops" ? route : { ...route, repository },
734+ );
735+ }),
736+ );
688737 }),
689738 );
690739
@@ -829,8 +878,8 @@ export const make = Effect.gen(function* () { · 138 unmodified lines
829878 viewer: string,
830879 ): boolean => {
831880 if (filters === undefined) return true;
832− const labels = item.labels.map((label) => label.name.trim().toLowerCase());
833− const holds = (label: string) => labels.includes(label.trim().toLowerCase());
881+ const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
882+ const holds = (label: string) => labels.has(label.trim().toLowerCase());
834883 return (
835884 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
836885 // Judged on the provider row rather than the entry, because the two absences mean
@@ -949,9 +998,7 @@ export const make = Effect.gen(function* () { · 112 unmodified lines
949998 const selected =
950999 continuation === null
9511000 ? projects
952− : projects.filter(({ host, repository }) =>
953− continuation.has(listCursorKey(host, repository)),
954− );
1001+ : projects.filter(({ cursorKey }) => continuation.has(cursorKey));
9551002 const readable = selected.filter(({ host }) => viewers[host] !== undefined);
9561003 // A host that could not be read still has projects, and they are absent from the list.
9571004 // Reporting them keeps "N repositories were unavailable" honest instead of dropping them.
@@ -993,7 +1040,7 @@ export const make = Effect.gen(function* () { · 35 unmodified lines
9931040
9941041 const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT;
9951042 const cursorOf = (project: SupportedProject): ListCursor | undefined =>
996− continuation?.get(listCursorKey(project.host, project.repository));
1043+ continuation?.get(project.cursorKey);
9971044
9981045 /**
9991046 * One repository asked on its own. What every host without a search across repositories
@@ -1002,7 +1049,7 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
10021049 const readRepository = (project: SupportedProject): Effect.Effect<RepositoryBatch> => {
10031050 {
10041051 const viewer = viewers[project.host]!;
1005− const key = listCursorKey(project.host, project.repository);
1052+ const key = project.cursorKey;
10061053 const cursor = cursorOf(project);
10071054 return project.api
10081055 .listChangeRequests({
@@ -1161,7 +1208,7 @@ export const make = Effect.gen(function* () { · 152 unmodified lines
11611208 !cursorHere.seenAt.includes(item.number),
11621209 );
11631210 return Effect.succeed({
1164− key: listCursorKey(project.host, project.repository),
1211+ key: project.cursorKey,
11651212 entries: items
11661213 .filter((item) => matchesRowFilters(item, input.filters, viewer))
11671214 .map((item) => toEntry({ project, item, viewer })),
@@ -1254,17 +1301,67 @@ export const make = Effect.gen(function* () { · 86 unmodified lines
12541301 title: changeRequest.title,
12551302 url: changeRequest.url,
12561303 state: changeRequest.state,
1257− ...(changeRequest.isDraft === true ? { isDraft: true } : {}),
12581304 headBranch: changeRequest.headBranch,
12591305 baseBranch: changeRequest.baseBranch,
12601306 closedAt: changeRequest.closedAt ?? null,
12611307 mergedAt: changeRequest.mergedAt ?? null,
12621308 updatedAt: changeRequest.updatedAt,
1309+ ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }),
1310+ ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }),
1311+ ...(changeRequest.additions === undefined
1312+ ? {}
1313+ : { additions: changeRequest.additions }),
1314+ ...(changeRequest.deletions === undefined
1315+ ? {}
1316+ : { deletions: changeRequest.deletions }),
1317+ ...(changeRequest.changedFiles === undefined
1318+ ? {}
1319+ : { changedFiles: changeRequest.changedFiles }),
1320+ ...(changeRequest.reviewDecision === undefined
1321+ ? {}
1322+ : { reviewDecision: changeRequest.reviewDecision }),
1323+ ...(changeRequest.checksState === undefined
1324+ ? {}
1325+ : { checksState: changeRequest.checksState }),
1326+ ...(changeRequest.mergeability === undefined
1327+ ? {}
1328+ : { mergeability: changeRequest.mergeability }),
12631329 })),
12641330 );
12651331 }),
12661332 );
12671333
1334+ const stackUncached: PullRequestService["Service"]["stack"] = (input) =>
1335+ requireProject(input).pipe(
1336+ Effect.flatMap((project) => {
1337+ const read = project.api.getChangeRequestStack;
1338+ if (read === undefined) return Effect.succeed(null);
1339+ return read({
1340+ cwd: project.project.workspaceRoot,
1341+ repository: project.repository,
1342+ host: project.host,
1343+ number: input.number,
1344+ }).pipe(
1345+ Effect.mapError(toPullRequestError("stack")),
1346+ Effect.map((stack): PullRequestStack | null =>
1347+ stack === null
1348+ ? null
1349+ : {
1350+ id: stack.id,
1351+ number: stack.number,
1352+ url: stack.url,
1353+ base: stack.base,
1354+ layers: stack.layers.map((layer) => ({
1355+ number: layer.number,
1356+ headBranch: layer.headBranch,
1357+ state: layer.state,
1358+ })),
1359+ },
1360+ ),
1361+ );
1362+ }),
1363+ );
1364+
12681365 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>
12691366 requireProject(input).pipe(
12701367 Effect.flatMap((project) =>
@@ -1507,7 +1604,11 @@ export const make = Effect.gen(function* () { · 236 unmodified lines
15071604 })
15081605 .pipe(
15091606 Effect.mapError(toPullRequestError("runAction")),
1510− Effect.as(project.repository),
1607+ Effect.as(
1608+ project.api.kind === "azure-devops"
1609+ ? input.repository.trim()
1610+ : project.repository,
1611+ ),
15111612 );
15121613 }),
15131614 );
@@ -2133,11 +2234,35 @@ export const make = Effect.gen(function* () { · 619 unmodified lines
21332234 let turnRefreshEpoch = 0;
21342235 const refEpochs = new Map<string, number>();
21352236 const REF_EPOCH_CAPACITY = 2_048;
2136− const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`;
2237+ const refScope = (ref: PullRequestRef) =>
2238+ `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`;
21372239 const refEpoch = (ref: PullRequestRef) =>
21382240 Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);
2241+ // Keys carry the reference back out of the cache loader, so the slot layout is shared with
2242+ // `refOfCacheKey` rather than read positionally at every loader.
21392243 const refCacheKey = (ref: PullRequestRef) =>
2140− JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]);
2244+ JSON.stringify([
2245+ refEpoch(ref),
2246+ ref.projectId,
2247+ ref.host?.toLowerCase() ?? null,
2248+ ref.repository.toLowerCase(),
2249+ ref.number,
2250+ ]);
2251+ const refOfCacheKey = (key: string): PullRequestRef => {
2252+ const [, projectId, host, repository, number] = JSON.parse(key) as [
2253+ number,
2254+ string,
2255+ string | null,
2256+ string,
2257+ number,
2258+ ];
2259+ return {
2260+ projectId,
2261+ ...(host === null ? {} : { host }),
2262+ repository,
2263+ number,
2264+ } as PullRequestRef;
2265+ };
21412266 // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse
21422267 // them; explicit refreshes, mutations, and turns strand old and in-flight results.
21432268 const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]);
@@ -2181,8 +2306,7 @@ export const make = Effect.gen(function* () { · 37 unmodified lines
21812306
21822307 const summaryCache = yield* Cache.makeWith(
21832308 (key: string) => {
2184− const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];
2185− return summaryUncached({ projectId, repository, number } as PullRequestRef);
2309+ return summaryUncached(refOfCacheKey(key));
21862310 },
21872311 {
21882312 capacity: DETAIL_CACHE_CAPACITY,
@@ -2203,6 +2327,13 @@ export const make = Effect.gen(function* () { · 14 unmodified lines
22032327 );
22042328 };
22052329
2330+ const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), {
2331+ capacity: DETAIL_CACHE_CAPACITY,
2332+ timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero),
2333+ });
2334+ const stack: PullRequestService["Service"]["stack"] = (input) =>
2335+ Cache.get(stackCache, refCacheKey(input));
2336+
22062337 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
22072338 // of in-flight state: concurrent identical reads coalesce on the key into one host request.
22082339 // The continuation cursors are part of the key, entries sorted so one continuation is one
@@ -2283,8 +2414,7 @@ export const make = Effect.gen(function* () { · 74 unmodified lines
22832414 const detailCache = yield* Cache.makeWith(
22842415 (key: string) => {
22852416 const statsKey = statsCacheKey(key);
2286− const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];
2287− return detailUncached({ projectId, repository, number } as PullRequestRef).pipe(
2417+ return detailUncached(refOfCacheKey(key)).pipe(
22882418 Effect.tap(
22892419 Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) {
22902420 recordStats(
@@ -2307,7 +2437,12 @@ export const make = Effect.gen(function* () { · 16 unmodified lines
23072437 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),
23082438 },
23092439 );
2310− const summaryFromDetail = (detail: PullRequestDetail): PullRequestSummary => ({
2440+ const summaryFromDetail = (
2441+ detail: PullRequestDetail,
2442+ previous: PullRequestSummary | undefined,
2443+ ): PullRequestSummary => ({
2444+ // Detail does not carry review/check summaries. Keep the last summary observation.
2445+ ...previous,
23112446 provider: detail.provider,
23122447 projectId: detail.projectId,
23132448 repository: detail.repository,
@@ -2315,7 +2450,12 @@ export const make = Effect.gen(function* () { · 1 unmodified line
23152450 title: detail.title,
23162451 url: detail.url,
23172452 state: detail.state,
2318− ...(detail.isDraft === true ? { isDraft: true } : {}),
2453+ isDraft: detail.isDraft,
2454+ author: detail.author,
2455+ additions: detail.additions,
2456+ deletions: detail.deletions,
2457+ changedFiles: detail.changedFiles,
2458+ mergeability: detail.mergeability,
23192459 headBranch: detail.headBranch,
23202460 baseBranch: detail.baseBranch,
23212461 closedAt: detail.closedAt,
@@ -2338,7 +2478,7 @@ export const make = Effect.gen(function* () { · 16 unmodified lines
23382478 key,
23392479 Cache.get(detailCache, key).pipe(
23402480 Effect.tap((value) => {
2341− const summary = summaryFromDetail(value);
2481+ const summary = summaryFromDetail(value, lastGoodSummary.peek(key));
23422482 return shouldReplaceHeldSummary(key, summary)
23432483 ? lastGoodSummary.record(key, summary)
23442484 : Effect.void;
@@ -2350,8 +2490,7 @@ export const make = Effect.gen(function* () { · 5 unmodified lines
23502490
23512491 const activityCache = yield* Cache.makeWith(
23522492 (key: string) => {
2353− const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];
2354− return activityUncached({ projectId, repository, number } as PullRequestRef);
2493+ return activityUncached(refOfCacheKey(key));
23552494 },
23562495 {
23572496 capacity: DETAIL_CACHE_CAPACITY,
@@ -2365,9 +2504,10 @@ export const make = Effect.gen(function* () { · 7 unmodified lines
23652504
23662505 const diffCache = yield* Cache.makeWith(
23672506 (key: string) => {
2368− const [, projectId, repository, number, cursor, commit] = JSON.parse(key) as [
2507+ const [, projectId, host, repository, number, cursor, commit] = JSON.parse(key) as [
23692508 number,
23702509 string,
2510+ string | null,
23712511 string,
23722512 number,
23732513 string | null,
@@ -2375,6 +2515,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line
23752515 ];
23762516 return diffUncached({
23772517 projectId,
2518+ ...(host === null ? {} : { host }),
23782519 repository,
23792520 number,
23802521 ...(cursor === null ? {} : { cursor }),
@@ -2385,7 +2526,7 @@ export const make = Effect.gen(function* () { · 4 unmodified lines
23852526 capacity: DIFF_CACHE_CAPACITY,
23862527 timeToLive: (exit, key) => {
23872528 if (!Exit.isSuccess(exit)) return Duration.zero;
2388− const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[5];
2529+ const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[6];
23892530 return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL;
23902531 },
23912532 },
@@ -2394,7 +2535,8 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
23942535 const key = JSON.stringify([
23952536 refEpoch(input),
23962537 input.projectId,
2397− input.repository,
2538+ input.host?.toLowerCase() ?? null,
2539+ input.repository.toLowerCase(),
23982540 input.number,
23992541 input.cursor ?? null,
24002542 input.commit ?? null,
@@ -2523,6 +2665,7 @@ export const make = Effect.gen(function* () { · 122 unmodified lines
25232665 list,
25242666 listStats,
25252667 summary,
2668+ stack,
25262669 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
25272670 Effect.map((subscription) => Stream.fromSubscription(subscription)),
25282671 ),
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+79−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 decodePullRequestListJson,
1212 decodePullRequestNodeIdJson,
1313 decodePullRequestSearchJson,
14+ decodePullRequestStacksJson,
1415 decodeLabelCandidatesJson,
1516 decodeRepositoryAccessJson,
1617 decodeReviewerCandidatesJson,
@@ -1500,3 +1501,81 @@ describe("how far a branch trails its base", () => { · 1483 unmodified lines
15001501 expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false);
15011502 });
15021503 });
1504+
1505+describe("host-native stack decoding", () => {
1506+ /** A stack as the preview lists it, bottom to top, with the fields it answers today. */
1507+ function stack(overrides: Record<string, unknown> = {}) {
1508+ return {
1509+ id: 42,
1510+ number: 3,
1511+ node_id: "STK_kwDO",
1512+ url: "https://api.github.com/repos/acme/web/stacks/3",
1513+ base: { ref: "main", sha: "abc" },
1514+ open: true,
1515+ created_at: "2026-09-01T00:00:00Z",
1516+ pull_requests: [
1517+ {
1518+ number: 10,
1519+ head: { ref: "feat/one" },
1520+ state: "closed",
1521+ merged_at: "2026-09-02T00:00:00Z",
1522+ },
1523+ { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null },
1524+ { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null },
1525+ ],
1526+ ...overrides,
1527+ };
1528+ }
1529+
1530+ /** The one stack a listing answered with, which these reads all expect to find. */
1531+ function expectStack(overrides: Record<string, unknown> = {}) {
1532+ const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)])));
1533+ if (decoded === null) throw new Error("expected a stack");
1534+ return decoded;
1535+ }
1536+
1537+ it("reads the first stack, bottom to top, with merged_at outranking state", () => {
1538+ expect(expectStack()).toEqual({
1539+ id: "42",
1540+ number: 3,
1541+ url: "https://api.github.com/repos/acme/web/stacks/3",
1542+ base: "main",
1543+ layers: [
1544+ { number: 10, headBranch: "feat/one", state: "merged" },
1545+ { number: 11, headBranch: "feat/two", state: "open" },
1546+ { number: 12, headBranch: "feat/three", state: "closed" },
1547+ ],
1548+ });
1549+ });
1550+
1551+ it("accepts a base named as a bare branch, which is what the preview started out sending", () => {
1552+ expect(expectStack({ base: "develop" }).base).toBe("develop");
1553+ });
1554+
1555+ it("prefers the page a person opens over the API URL, where the host reports one", () => {
1556+ expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe(
1557+ "https://github.com/acme/web/stacks/3",
1558+ );
1559+ });
1560+
1561+ it("falls back to the node id, then the number, for a stack without an id", () => {
1562+ expect(expectStack({ id: undefined }).id).toBe("STK_kwDO");
1563+ expect(expectStack({ id: null, node_id: null }).id).toBe("3");
1564+ });
1565+
1566+ it("reads an empty listing as not stacked", () => {
1567+ expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull();
1568+ });
1569+
1570+ it("refuses a stack without a number or without its pull requests", () => {
1571+ expect(
1572+ Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))),
1573+ ).toBe(false);
1574+ expect(
1575+ Result.isSuccess(
1576+ decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])),
1577+ ),
1578+ ).toBe(false);
1579+ expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false);
1580+ });
1581+});
apps/server/src/pullRequest/gitHubPullRequestJson.ts+66−0
@@ -2445,3 +2445,69 @@ export function decodePullRequestFilesJson( · 2444 unmodified lines
24452445 omittedFileStats,
24462446 });
24472447 }
2448+
2449+/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */
2450+const RawStackPullRequestSchema = Schema.Struct({
2451+ number: Schema.Int,
2452+ head: Schema.Struct({ ref: Schema.String }),
2453+ state: Schema.optional(Schema.NullOr(Schema.String)),
2454+ merged_at: Schema.optional(Schema.NullOr(Schema.String)),
2455+});
2456+
2457+/**
2458+ * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may
2459+ * still move. Only what a stack is made of is required — where it lives, what it stands on, and
2460+ * its pull requests — and `base` is accepted both as the ref object the preview sends today and
2461+ * as the bare branch name it started out as.
2462+ */
2463+const RawStackSchema = Schema.Struct({
2464+ id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))),
2465+ number: Schema.Int,
2466+ node_id: Schema.optional(Schema.NullOr(Schema.String)),
2467+ url: Schema.String,
2468+ html_url: Schema.optional(Schema.NullOr(Schema.String)),
2469+ base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]),
2470+ pull_requests: Schema.Array(RawStackPullRequestSchema),
2471+});
2472+
2473+const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema));
2474+
2475+export interface GitHubPullRequestStackLayer {
2476+ readonly number: number;
2477+ readonly headBranch: string;
2478+ readonly state: PullRequestState;
2479+}
2480+
2481+export interface GitHubPullRequestStack {
2482+ readonly id: string;
2483+ readonly number: number;
2484+ readonly url: string;
2485+ readonly base: string;
2486+ /** Bottom to top, which is the order GitHub lists them in. */
2487+ readonly layers: ReadonlyArray<GitHubPullRequestStackLayer>;
2488+}
2489+
2490+/**
2491+ * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in
2492+ * at most one stack, so the array is GitHub's way of saying "none" rather than a page.
2493+ */
2494+export function decodePullRequestStacksJson(
2495+ raw: string,
2496+): Result.Result<GitHubPullRequestStack | null, DecodeFailure> {
2497+ const decoded = decodeStacks(raw);
2498+ if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2499+ const stack = decoded.success[0];
2500+ if (stack === undefined) return Result.succeed(null);
2501+ return Result.succeed({
2502+ id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id),
2503+ number: stack.number,
2504+ // The page a person opens where the preview reports one; the API URL is what it always has.
2505+ url: trimmed(stack.html_url) ?? stack.url,
2506+ base: typeof stack.base === "string" ? stack.base : stack.base.ref,
2507+ layers: stack.pull_requests.map((pullRequest) => ({
2508+ number: pullRequest.number,
2509+ headBranch: pullRequest.head.ref,
2510+ state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }),
2511+ })),
2512+ });
2513+}
apps/server/src/pullRequest/linkedThreads.test.ts+117−0
@@ -0,0 +1,117 @@
1+import { assert, expect, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as SqlClient from "effect/unstable/sql/SqlClient";
4+
5+import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
6+import { listLinkedPullRequestThreads } from "./linkedThreads.ts";
7+
8+it.effect(
9+ "finds active and archived threads for exactly one pull request, excluding deleted and dismissed links",
10+ () =>
11+ Effect.gen(function* () {
12+ const sql = yield* SqlClient.SqlClient;
13+ const createdAt = "2026-09-01T00:00:00.000Z";
14+ const archivedAt = "2026-09-03T00:00:00.000Z";
15+ yield* sql`
16+ INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
17+ VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt})
18+ `;
19+ const fixtures = [
20+ {
21+ id: "azure",
22+ host: "dev.azure.com",
23+ repository: "org/project/_git/web",
24+ number: 7,
25+ source: "manual",
26+ },
27+ {
28+ id: "other-org",
29+ host: "dev.azure.com",
30+ repository: "other/project/_git/web",
31+ number: 7,
32+ source: "manual",
33+ },
34+ { id: "active", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
35+ {
36+ id: "archived",
37+ host: "github.com",
38+ repository: "acme/web",
39+ number: 7,
40+ source: "created",
41+ },
42+ { id: "deleted", host: "github.com", repository: "acme/web", number: 7, source: "manual" },
43+ {
44+ id: "dismissed",
45+ host: "github.com",
46+ repository: "acme/web",
47+ number: 7,
48+ source: "stack-dismissed",
49+ },
50+ {
51+ id: "other-host",
52+ host: "github.example.com",
53+ repository: "acme/web",
54+ number: 7,
55+ source: "manual",
56+ },
57+ {
58+ id: "other-repository",
59+ host: "github.com",
60+ repository: "acme/api",
61+ number: 7,
62+ source: "manual",
63+ },
64+ {
65+ id: "other-number",
66+ host: "github.com",
67+ repository: "acme/web",
68+ number: 8,
69+ source: "manual",
70+ },
71+ ];
72+ for (const fixture of fixtures) {
73+ yield* sql`
74+ INSERT INTO projection_threads (
75+ thread_id, project_id, title, model_selection_json, created_at, updated_at, archived_at, deleted_at
76+ ) VALUES (
77+ ${fixture.id}, 'project-1', ${fixture.id}, '{"instanceId":"codex","model":"gpt-5.4"}',
78+ ${createdAt}, ${fixture.id === "archived" ? archivedAt : createdAt},
79+ ${fixture.id === "archived" ? archivedAt : null},
80+ ${fixture.id === "deleted" ? archivedAt : null}
81+ )
82+ `;
83+ yield* sql`
84+ INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at)
85+ VALUES (${fixture.id}, ${fixture.host}, ${fixture.repository}, ${fixture.number},
86+ 'https://github.com/acme/web/pull/7', ${fixture.source}, ${createdAt})
87+ `;
88+ }
89+
90+ expect(
91+ (yield* listLinkedPullRequestThreads({
92+ host: "org.visualstudio.com",
93+ repository: "project/_git/web",
94+ number: 7,
95+ })).threads.map((thread) => thread.id),
96+ ).toEqual(["azure"]);
97+ const result = yield* listLinkedPullRequestThreads({
98+ host: "GitHub.Com",
99+ repository: "ACME/WEB",
100+ number: 7,
101+ });
102+ expect(result).toEqual({
103+ threads: [
104+ { id: "archived", projectId: "project-1", title: "archived", archivedAt },
105+ { id: "active", projectId: "project-1", title: "active", archivedAt: null },
106+ ],
107+ });
108+ assert.deepStrictEqual(
109+ yield* listLinkedPullRequestThreads({
110+ host: "github.com",
111+ repository: "acme/web",
112+ number: 99,
113+ }),
114+ { threads: [] },
115+ );
116+ }).pipe(Effect.provide(SqlitePersistenceMemory)),
117+);
apps/server/src/pullRequest/linkedThreads.ts+39−0
@@ -0,0 +1,39 @@
1+import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
2+import {
3+ PullRequestLinkedThreadsResult,
4+ PullRequestOperationError,
5+ type ThreadPullRequestKey,
6+} from "@t3tools/contracts";
7+import * as Effect from "effect/Effect";
8+import * as Schema from "effect/Schema";
9+import * as SqlClient from "effect/unstable/sql/SqlClient";
10+
11+const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsResult);
12+
13+export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")(
14+ function* (input: ThreadPullRequestKey) {
15+ const key = normalizeThreadPullRequestKey(input);
16+ const sql = yield* SqlClient.SqlClient;
17+ const threads = yield* sql`
18+ SELECT t.thread_id AS id, t.project_id AS "projectId", t.title,
19+ t.archived_at AS "archivedAt"
20+ FROM projection_thread_pull_requests AS link
21+ JOIN projection_threads AS t ON t.thread_id = link.thread_id
22+ WHERE link.host = ${key.host.toLowerCase()}
23+ AND link.repository = ${key.repository.toLowerCase()}
24+ AND link.number = ${key.number}
25+ AND link.source != 'stack-dismissed'
26+ AND t.deleted_at IS NULL
27+ ORDER BY t.updated_at DESC, t.thread_id ASC
28+ `;
29+ return yield* decodeLinkedThreads({ threads });
30+ },
31+ Effect.mapError(
32+ (cause) =>
33+ new PullRequestOperationError({
34+ operation: "linkedThreads",
35+ detail: "Could not load linked threads.",
36+ cause,
37+ }),
38+ ),
39+);
apps/server/src/pullRequest/pullRequestSyncKey.test.ts+46−0
@@ -0,0 +1,46 @@
1+import { ProjectId } from "@t3tools/contracts";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import { pullRequestSyncKey } from "./pullRequestSyncKey.ts";
5+
6+describe("pullRequestSyncKey", () => {
7+ const reference = { projectId: ProjectId.make("project"), repository: "web", number: 7 };
8+ it.each([
9+ "dev.azure.com/org/project/_git/web",
10+ "ssh.dev.azure.com/v3/org/project/web",
11+ "vs-ssh.visualstudio.com/v3/org/project/web",
12+ "org.visualstudio.com/defaultcollection/project/_git/web",
13+ ])("resolves hostless and checkout-host Azure references for %s", (canonicalKey) => {
14+ const identity = {
15+ canonicalKey,
16+ provider: "azure-devops",
17+ name: "web",
18+ displayName: canonicalKey.split("/").slice(1).join("/"),
19+ locator: {
20+ source: "git-remote" as const,
21+ remoteName: "origin",
22+ remoteUrl: `https://${canonicalKey}`,
23+ },
24+ };
25+ const expected = { host: "dev.azure.com", repository: "org/project/_git/web", number: 7 };
26+ expect(pullRequestSyncKey(reference, identity)).toEqual(expected);
27+ expect(
28+ pullRequestSyncKey({ ...reference, host: canonicalKey.split("/")[0]! }, identity),
29+ ).toEqual(expected);
30+ expect(pullRequestSyncKey({ ...reference, repository: "other" }, identity)).toBeNull();
31+ expect(pullRequestSyncKey({ ...reference, host: "unrelated.test" }, identity)).toBeNull();
32+ });
33+ it("normalizes complete hosted aliases without requiring a checkout", () => {
34+ expect(
35+ pullRequestSyncKey({
36+ ...reference,
37+ host: "org.visualstudio.com",
38+ repository: "project/_git/web",
39+ }),
40+ ).toEqual({ host: "dev.azure.com", repository: "org/project/_git/web", number: 7 });
41+ expect(
42+ pullRequestSyncKey({ ...reference, host: "github.com", repository: "acme/web" }),
43+ ).toEqual({ host: "github.com", repository: "acme/web", number: 7 });
44+ expect(pullRequestSyncKey(reference)).toBeNull();
45+ });
46+});
apps/server/src/pullRequest/pullRequestSyncKey.ts+44−0
@@ -0,0 +1,44 @@
1+import {
2+ pullRequestHostOf,
3+ type PullRequestRef,
4+ type RepositoryIdentity,
5+ type SourceControlProviderKind,
6+ type ThreadPullRequestKey,
7+} from "@t3tools/contracts";
8+import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
9+import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
10+
11+/** Convert checkout-scoped references to the host-level identity used by linked threads. */
12+export function pullRequestSyncKey(
13+ reference: PullRequestRef,
14+ identity?: RepositoryIdentity | null,
15+): ThreadPullRequestKey | null {
16+ if (identity?.provider === "azure-devops" && !reference.repository.includes("/")) {
17+ if (
18+ reference.repository.toLowerCase() !==
19+ sourceControlRepositorySelector(identity)?.toLowerCase() ||
20+ (reference.host !== undefined &&
21+ reference.host.toLowerCase() !== pullRequestHostOf(identity, "azure-devops"))
22+ )
23+ return null;
24+ const [host, ...repository] = identity.canonicalKey.split("/");
25+ if (!host || repository.length === 0) return null;
26+ return normalizeThreadPullRequestKey({
27+ host,
28+ repository: repository.join("/"),
29+ number: reference.number,
30+ });
31+ }
32+ const host =
33+ reference.host ??
34+ (identity
35+ ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind)
36+ : undefined);
37+ return host === undefined
38+ ? null
39+ : normalizeThreadPullRequestKey({
40+ host,
41+ repository: reference.repository,
42+ number: reference.number,
43+ });
44+}