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
183});183});
184 184
185layer("GitHubPullRequestCli.layer", (it) => {185layer("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", () =>
187 Effect.gen(function* () {187 Effect.gen(function* () {
188 mockedGetPullRequest.mockReturnValueOnce(188 mockedExecute.mockReturnValueOnce(
189 Effect.succeed({189 Effect.succeed(
190 number: 7,190 output(
191 title: "Reuse the summary",191 // @effect-diagnostics-next-line preferSchemaOverJson:off
192 url: "https://github.com/acme/web/pull/7",192 JSON.stringify({
193 baseRefName: "main",193 number: 7,
194 headRefName: "feat/summary",194 title: "Reuse the summary",
195 state: "merged",195 url: "https://github.com/acme/web/pull/7",
196 closedAt: "2026-08-23T10:00:00Z",196 author: { login: "octocat", name: "Octo Cat" },
197 mergedAt: "2026-08-23T10:00:00Z",197 baseRefName: "main",
198 updatedAt: "2026-08-24T12:34:56.000Z",198 headRefName: "feat/summary",
199 }),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 ),
200 );217 );
201 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;218 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
202 219
@@ -207,23 +224,207 @@ layer("GitHubPullRequestCli.layer", (it) => { · 4 unmodified lines
207 number: 7,224 number: 7,
208 });225 });
209 226
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",
211 number: 7,301 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",
220 });302 });
221 expect(mockedGetPullRequest).toHaveBeenCalledOnce();303
222 expect(mockedGetPullRequest).toHaveBeenCalledWith({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({
223 cwd: "/w",329 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"),
225 });396 });
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");
227 }),428 }),
228 );429 );
229 430
apps/server/src/pullRequest/GitHubPullRequestCli.ts+94−42
@@ -39,6 +39,7 @@ import { · 38 unmodified lines
39 decodePullRequestListJson,39 decodePullRequestListJson,
40 decodePullRequestNodeIdJson,40 decodePullRequestNodeIdJson,
41 decodePullRequestSearchJson,41 decodePullRequestSearchJson,
42 decodePullRequestStacksJson,
42 decodePullRequestStatsJson,43 decodePullRequestStatsJson,
43 decodeReactionSubjectScopeJson,44 decodeReactionSubjectScopeJson,
44 decodeRepositoryAccessJson,45 decodeRepositoryAccessJson,
@@ -84,6 +85,7 @@ import { · 39 unmodified lines
84 type GitHubPullRequestHead,85 type GitHubPullRequestHead,
85 type GitHubPullRequestListItem,86 type GitHubPullRequestListItem,
86 type GitHubPullRequestSearchItem,87 type GitHubPullRequestSearchItem,
88 type GitHubPullRequestStack,
87 type GitHubReviewThreadComments,89 type GitHubReviewThreadComments,
88 type GitHubRepositoryAccess,90 type GitHubRepositoryAccess,
89 type GitHubWorkflowRunApproval,91 type GitHubWorkflowRunApproval,
@@ -91,7 +93,7 @@ import { · 1 unmodified line
91 type GitHubReviewThreadPage,93 type GitHubReviewThreadPage,
92 type GitHubViewerAccess,94 type GitHubViewerAccess,
93} from "./gitHubPullRequestJson.ts";95} from "./gitHubPullRequestJson.ts";
94import type { ProviderListCursor } from "./PullRequestProvider.ts";96import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts";
95 97
96/**98/**
97 * Names the read that produced unusable output, so a failure reports the call it came from99 * 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
460 readonly repository: string;462 readonly repository: string;
461 readonly host: string;463 readonly host: string;
462 readonly number: number;464 readonly number: number;
463 }) => Effect.Effect<465 }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestCliError>;
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 >;
478 466
479 readonly getPullRequestDetail: (input: {467 readonly getPullRequestDetail: (input: {
480 readonly cwd: string;468 readonly cwd: string;
@@ -494,6 +482,17 @@ export class GitHubPullRequestCli extends Context.Service< · 13 unmodified lines
494 readonly isCrossRepository: true;482 readonly isCrossRepository: true;
495 }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestCliError>;483 }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestCliError>;
496 484
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
497 /**496 /**
498 * How far the branch trails its base, and whether this viewer may update it. Its own read497 * How far the branch trails its base, and whether this viewer may update it. Its own read
499 * because the comparison needs the head ref the detail answers with — a fork's branch is not498 * 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
807 viewer: string,806 viewer: string,
808): boolean {807): boolean {
809 if (filters === undefined) return true;808 if (filters === undefined) return true;
810 const labels = item.labels.map((label) => label.name.trim().toLowerCase());809 const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
811 const holds = (label: string) => labels.includes(label.trim().toLowerCase());810 const holds = (label: string) => labels.has(label.trim().toLowerCase());
812 return (811 return (
813 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&812 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
814 (filters.review === undefined ||813 (filters.review === undefined ||
@@ -1630,41 +1629,94 @@ export const make = Effect.gen(function* () { · 815 unmodified lines
1630 ).pipe(Effect.map((results) => results.flat()));1629 ).pipe(Effect.map((results) => results.flat()));
1631 },1630 },
1632 1631
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.
1633 getPullRequestSummary: (input) =>1634 getPullRequestSummary: (input) =>
1634 github1635 github
1635 .getPullRequest({1636 .execute({
1636 cwd: input.cwd,1637 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 ],
1638 })1646 })
1639 .pipe(1647 .pipe(
1640 Effect.flatMap((summary) =>1648 Effect.flatMap((result) => {
1641 summary.updatedAt === undefined1649 const decoded = decodePullRequestDetailJson(result.stdout.trim());
1642 ? Effect.fail(1650 if (!Result.isSuccess(decoded)) {
1643 new GitHubPullRequestUpdatedAtUnavailableError({1651 return Effect.fail(
1644 command: "gh",1652 new GitHubPullRequestReadError({
1645 cwd: input.cwd,1653 command: "gh",
1646 repository: input.repository,1654 cwd: input.cwd,
1647 number: input.number,1655 operation: "getPullRequestSummary",
1648 }),1656 cause: decoded.failure,
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,
1661 }),1657 }),
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 }),
1663 ),1681 ),
1664 1682
1665 getPullRequestDetail,1683 getPullRequestDetail,
1666 listWorkflowRunsRequiringApproval,1684 listWorkflowRunsRequiringApproval,
1667 1685
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
1668 getPullRequestBaseComparison: (input) => {1720 getPullRequestBaseComparison: (input) => {
1669 const { owner, name } = parseRepositorySelector(input.repository);1721 const { owner, name } = parseRepositorySelector(input.repository);
1670 return graphqlRead({1722 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
25 baseBranch: "main",25 baseBranch: "main",
26 state: "open" as const,26 state: "open" as const,
27 updatedAt: "2026-08-24T12:34:56.000Z",27 updatedAt: "2026-08-24T12:34:56.000Z",
28 author: { login: "octocat", name: null, avatarUrl: null },
28 };29 };
29 }),30 }),
30 }),31 }),
@@ -42,6 +43,66 @@ it.effect("uses one narrow read for a linked pull request summary", () => · 11 unmodified lines
42 43
43 expect(summary.state).toBe("open");44 expect(summary.state).toBe("open");
44 expect(summaryReads).toBe(1);45 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
51it.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
80it.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");
45 }),106 }),
46);107);
47 108
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+15−1
@@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = { · 46 unmodified lines
47 },47 },
48 reviewers: { request: true, listCandidates: true },48 reviewers: { request: true, listCandidates: true },
49 edit: { changeRequest: true, comment: true },49 edit: { changeRequest: true, comment: true },
50 stacks: true,
50 labels: true,51 labels: true,
51};52};
52 53
@@ -292,7 +293,20 @@ export const make = Effect.gen(function* () { · 239 unmodified lines
292 .pipe(Effect.mapError(fail("listChangeRequestStats"))),293 .pipe(Effect.mapError(fail("listChangeRequestStats"))),
293 294
294 getChangeRequestSummary: (input) =>295 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"))),
296 310
297 getChangeRequest: (input) =>311 getChangeRequest: (input) =>
298 Effect.all(312 Effect.all(
apps/server/src/pullRequest/PullRequestProvider.ts+36−0
@@ -103,6 +103,34 @@ export interface ProviderChangeRequestSummary { · 102 unmodified lines
103 readonly closedAt?: string | null;103 readonly closedAt?: string | null;
104 readonly mergedAt?: string | null;104 readonly mergedAt?: string | null;
105 readonly updatedAt: string;105 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. */
117export 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 */
128export interface ProviderChangeRequestStack {
129 readonly id: string;
130 readonly number: number;
131 readonly url: string;
132 readonly base: string;
133 readonly layers: ReadonlyArray<ProviderChangeRequestStackLayer>;
106}134}
107 135
108export interface ProviderChangeRequestPage {136export interface ProviderChangeRequestPage {
@@ -331,6 +359,14 @@ export interface PullRequestProviderApi { · 222 unmodified lines
331 input: ProviderRepositoryRef & { readonly number: number },359 input: ProviderRepositoryRef & { readonly number: number },
332 ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>;360 ) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>;
333 361
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
334 /** Comments, line threads, and commits, kept off the critical path for the core detail. */370 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
335 readonly getChangeRequestActivity: (371 readonly getChangeRequestActivity: (
336 input: ProviderRepositoryRef & { readonly number: number },372 input: ProviderRepositoryRef & { readonly number: number },
apps/server/src/pullRequest/PullRequestService.test.ts+323−39
@@ -32,6 +32,7 @@ function project(input: { · 31 unmodified lines
32 readonly repository?: string;32 readonly repository?: string;
33 readonly provider?: string;33 readonly provider?: string;
34 readonly host?: string;34 readonly host?: string;
35 readonly remoteUrl?: string;
35}): OrchestrationProjectShell {36}): OrchestrationProjectShell {
36 // The host defaults from the provider, so a fixture only names one when the point of the37 // The host defaults from the provider, so a fixture only names one when the point of the
37 // test is two hosts of the same kind.38 // test is two hosts of the same kind.
@@ -47,7 +48,7 @@ function project(input: { · 9 unmodified lines
47 locator: {48 locator: {
48 source: "git-remote" as const,49 source: "git-remote" as const,
49 remoteName: "origin",50 remoteName: "origin",
50 remoteUrl: `https://${host}/${input.repository}.git`,51 remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`,
51 },52 },
52 provider: input.provider ?? "github",53 provider: input.provider ?? "github",
53 displayName: input.repository,54 displayName: input.repository,
@@ -470,7 +471,7 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" · 416 unmodified lines
470 471
471 // Keyed by the selector Azure is actually asked with, which is the repository's own name.472 // Keyed by the selector Azure is actually asked with, which is the repository's own name.
472 assert.deepStrictEqual(result.nextCursors, {473 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",
474 });475 });
475 }),476 }),
476);477);
@@ -1563,6 +1564,247 @@ it.effect("refuses a repository that does not belong to the requested project", · 1086 unmodified lines
1563 }),1564 }),
1564);1565);
1565 1566
1567it.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
1612it.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
1640it.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
1680for (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
1748it.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
1783it.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
1566it.effect("refuses a diff on a host that cannot produce one", () =>1808it.effect("refuses a diff on a host that cannot produce one", () =>
1567 Effect.gen(function* () {1809 Effect.gen(function* () {
1568 const service = yield* makeService({1810 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
3377 }),3619 }),
3378);3620);
3379 3621
3622it.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
3380it.effect("reuses an observed merged state for strict settlement reads", () =>3664it.effect("reuses an observed merged state for strict settlement reads", () =>
3381 Effect.gen(function* () {3665 Effect.gen(function* () {
3382 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };3666 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
3587 }),3871 }),
3588);3872);
3589 3873
3590it("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
3605it("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
3615it("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
3627it.effect("narrows the rows of a host that ignored the filters it was handed", () =>3874it.effect("narrows the rows of a host that ignored the filters it was handed", () =>
3628 Effect.gen(function* () {3875 Effect.gen(function* () {
3629 const service = yield* makeService({3876 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
4137 assert.strictEqual(unnamed.viewer, undefined);4384 assert.strictEqual(unnamed.viewer, undefined);
4138 }),4385 }),
4139);4386);
4387
4388it.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 @@
1import {
2 canonicalRepositoryKey,
3 sourceControlRepositorySelector,
4} from "@t3tools/shared/sourceControl";
1import * as Cache from "effect/Cache";5import * as Cache from "effect/Cache";
2import * as Clock from "effect/Clock";6import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";7import * as Context from "effect/Context";
@@ -46,6 +50,7 @@ import { · 42 unmodified lines
46 type PullRequestLabelCandidateList,50 type PullRequestLabelCandidateList,
47 type PullRequestLabelChangeInput,51 type PullRequestLabelChangeInput,
48 type PullRequestSubmitReviewInput,52 type PullRequestSubmitReviewInput,
53 type PullRequestStack,
49 type PullRequestSummary,54 type PullRequestSummary,
50 type PullRequestThreadReplyInput,55 type PullRequestThreadReplyInput,
51 type PullRequestThreadResolutionInput,56 type PullRequestThreadResolutionInput,
@@ -144,6 +149,13 @@ export class PullRequestService extends Context.Service< · 92 unmodified lines
144 input: PullRequestRef,149 input: PullRequestRef,
145 options?: { readonly recoverTransientFailure?: boolean },150 options?: { readonly recoverTransientFailure?: boolean },
146 ) => Effect.Effect<PullRequestSummary, PullRequestError>;151 ) => 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>;
147 readonly subscribeMerges: Effect.Effect<159 readonly subscribeMerges: Effect.Effect<
148 Stream.Stream<PullRequestMergeEvent>,160 Stream.Stream<PullRequestMergeEvent>,
149 never,161 never,
@@ -241,6 +253,7 @@ const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to chang · 91 unmodified lines
241 253
242/** A project this page can read: its remote is on a host with an implementation. */254/** A project this page can read: its remote is on a host with an implementation. */
243interface SupportedProject {255interface SupportedProject {
256 readonly cursorKey: string;
244 readonly project: OrchestrationProjectShell;257 readonly project: OrchestrationProjectShell;
245 readonly api: PullRequestProviderApi;258 readonly api: PullRequestProviderApi;
246 readonly repository: string;259 readonly repository: string;
@@ -453,7 +466,7 @@ function withRateLimitBackoff( · 206 unmodified lines
453 call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,466 call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,
454 ) => wrap(operation, call, true);467 ) => wrap(operation, call, true);
455 468
456 return {469 const wrapped = {
457 kind: api.kind,470 kind: api.kind,
458 capabilities: api.capabilities,471 capabilities: api.capabilities,
459 getViewer: wrap("getViewer", api.getViewer),472 getViewer: wrap("getViewer", api.getViewer),
@@ -474,6 +487,9 @@ function withRateLimitBackoff( · 14 unmodified lines
474 : {487 : {
475 getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary),488 getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary),
476 }),489 }),
490 ...(api.getChangeRequestStack === undefined
491 ? {}
492 : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }),
477 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),493 getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),
478 ...(api.getReviewThreadComments === undefined494 ...(api.getReviewThreadComments === undefined
479 ? {}495 ? {}
@@ -506,30 +522,9 @@ function withRateLimitBackoff( · 26 unmodified lines
506 setReaction: interactive("setReaction", api.setReaction),522 setReaction: interactive("setReaction", api.setReaction),
507 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),523 setThreadResolution: interactive("setThreadResolution", api.setThreadResolution),
508 };524 };
509}525 // Optional provider methods must be forwarded too; returning the interface alone permits omissions.
510 526 return wrapped satisfies PullRequestProviderApi &
511/**527 Record<Exclude<keyof PullRequestProviderApi, keyof typeof wrapped>, never>;
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 */
524export 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;
533}528}
534 529
535export const make = Effect.gen(function* () {530export const make = Effect.gen(function* () {
@@ -554,7 +549,11 @@ export const make = Effect.gen(function* () { · 18 unmodified lines
554 for (const project of projects) {549 for (const project of projects) {
555 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;550 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
556 const identity = project.repositoryIdentity;551 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;
558 const host = pullRequestHostOf(identity, "unknown");557 const host = pullRequestHostOf(identity, "unknown");
559 // A legacy identity has no canonical host until its provider is refined, so it must reach558 // A legacy identity has no canonical host until its provider is refined, so it must reach
560 // the refinement before a host filter can decide whether it belongs in the result.559 // 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
628 if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue;627 if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue;
629 const identity = project.repositoryIdentity;628 const identity = project.repositoryIdentity;
630 let kind = identity?.provider as SourceControlProviderKind | undefined;629 let kind = identity?.provider as SourceControlProviderKind | undefined;
631 const repository = repositoryIdentityOf(project);630 const repository = sourceControlRepositorySelector(project.repositoryIdentity);
632 if (!identity || kind === undefined || repository === null) continue;631 if (!identity || kind === undefined || repository === null) continue;
633 // Worktrees of one repository are separate projects; reading the remote once keeps632 // Worktrees of one repository are separate projects; reading the remote once keeps
634 // the page from repeating every change request per local checkout. The host is part633 // 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
647 if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]);646 if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]);
648 else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot);647 else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot);
649 }648 }
650 const key = listCursorKey(host, repository);649 const key = listCursorKey(
650 host,
651 kind === "azure-devops" ? identity.canonicalKey : repository,
652 );
651 if (seen.has(key)) continue;653 if (seen.has(key)) continue;
652 seen.add(key);654 seen.add(key);
653 if (api === null) {655 if (api === null) {
@@ -657,6 +659,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines
657 continue;659 continue;
658 }660 }
659 supported.push({661 supported.push({
662 cursorKey: key,
660 project,663 project,
661 api: withRateLimitBackoff(api, host, rateLimits),664 api: withRateLimitBackoff(api, host, rateLimits),
662 repository,665 repository,
@@ -667,16 +670,30 @@ export const make = Effect.gen(function* () { · 4 unmodified lines
667 }),670 }),
668 );671 );
669 672
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 */
670 const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> =>680 const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> =>
671 listWorkspaceProjects({ projectId: ref.projectId }).pipe(681 listWorkspaceProjects({ projectId: ref.projectId }).pipe(
672 Effect.flatMap(({ supported }): Effect.Effect<SupportedProject, PullRequestError> => {682 Effect.flatMap(({ supported }): Effect.Effect<SupportedProject, PullRequestError> => {
673 const match = supported[0];683 const own = supported[0];
674 if (!match) {684 const repository = ref.repository.trim();
675 return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" }));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);
676 }690 }
677 // The repository travels through the client, so it is checked against the project's691 if (host === undefined) {
678 // own remote rather than being handed to a provider verbatim.692 if (own === undefined) {
679 if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) {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.
680 return Effect.fail(697 return Effect.fail(
681 new PullRequestOperationError({698 new PullRequestOperationError({
682 operation: "resolveRepository",699 operation: "resolveRepository",
@@ -684,7 +701,39 @@ export const make = Effect.gen(function* () { · 1 unmodified line
684 }),701 }),
685 );702 );
686 }703 }
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 );
688 }),737 }),
689 );738 );
690 739
@@ -829,8 +878,8 @@ export const make = Effect.gen(function* () { · 138 unmodified lines
829 viewer: string,878 viewer: string,
830 ): boolean => {879 ): boolean => {
831 if (filters === undefined) return true;880 if (filters === undefined) return true;
832 const labels = item.labels.map((label) => label.name.trim().toLowerCase());881 const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
833 const holds = (label: string) => labels.includes(label.trim().toLowerCase());882 const holds = (label: string) => labels.has(label.trim().toLowerCase());
834 return (883 return (
835 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&884 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
836 // Judged on the provider row rather than the entry, because the two absences mean885 // 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
949 const selected =998 const selected =
950 continuation === null999 continuation === null
951 ? projects1000 ? projects
952 : projects.filter(({ host, repository }) =>1001 : projects.filter(({ cursorKey }) => continuation.has(cursorKey));
953 continuation.has(listCursorKey(host, repository)),
954 );
955 const readable = selected.filter(({ host }) => viewers[host] !== undefined);1002 const readable = selected.filter(({ host }) => viewers[host] !== undefined);
956 // A host that could not be read still has projects, and they are absent from the list.1003 // A host that could not be read still has projects, and they are absent from the list.
957 // Reporting them keeps "N repositories were unavailable" honest instead of dropping them.1004 // 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
993 1040
994 const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT;1041 const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT;
995 const cursorOf = (project: SupportedProject): ListCursor | undefined =>1042 const cursorOf = (project: SupportedProject): ListCursor | undefined =>
996 continuation?.get(listCursorKey(project.host, project.repository));1043 continuation?.get(project.cursorKey);
997 1044
998 /**1045 /**
999 * One repository asked on its own. What every host without a search across repositories1046 * 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
1002 const readRepository = (project: SupportedProject): Effect.Effect<RepositoryBatch> => {1049 const readRepository = (project: SupportedProject): Effect.Effect<RepositoryBatch> => {
1003 {1050 {
1004 const viewer = viewers[project.host]!;1051 const viewer = viewers[project.host]!;
1005 const key = listCursorKey(project.host, project.repository);1052 const key = project.cursorKey;
1006 const cursor = cursorOf(project);1053 const cursor = cursorOf(project);
1007 return project.api1054 return project.api
1008 .listChangeRequests({1055 .listChangeRequests({
@@ -1161,7 +1208,7 @@ export const make = Effect.gen(function* () { · 152 unmodified lines
1161 !cursorHere.seenAt.includes(item.number),1208 !cursorHere.seenAt.includes(item.number),
1162 );1209 );
1163 return Effect.succeed({1210 return Effect.succeed({
1164 key: listCursorKey(project.host, project.repository),1211 key: project.cursorKey,
1165 entries: items1212 entries: items
1166 .filter((item) => matchesRowFilters(item, input.filters, viewer))1213 .filter((item) => matchesRowFilters(item, input.filters, viewer))
1167 .map((item) => toEntry({ project, item, viewer })),1214 .map((item) => toEntry({ project, item, viewer })),
@@ -1254,17 +1301,67 @@ export const make = Effect.gen(function* () { · 86 unmodified lines
1254 title: changeRequest.title,1301 title: changeRequest.title,
1255 url: changeRequest.url,1302 url: changeRequest.url,
1256 state: changeRequest.state,1303 state: changeRequest.state,
1257 ...(changeRequest.isDraft === true ? { isDraft: true } : {}),
1258 headBranch: changeRequest.headBranch,1304 headBranch: changeRequest.headBranch,
1259 baseBranch: changeRequest.baseBranch,1305 baseBranch: changeRequest.baseBranch,
1260 closedAt: changeRequest.closedAt ?? null,1306 closedAt: changeRequest.closedAt ?? null,
1261 mergedAt: changeRequest.mergedAt ?? null,1307 mergedAt: changeRequest.mergedAt ?? null,
1262 updatedAt: changeRequest.updatedAt,1308 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 }),
1263 })),1329 })),
1264 );1330 );
1265 }),1331 }),
1266 );1332 );
1267 1333
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
1268 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>1365 const detailUncached: PullRequestService["Service"]["detail"] = (input) =>
1269 requireProject(input).pipe(1366 requireProject(input).pipe(
1270 Effect.flatMap((project) =>1367 Effect.flatMap((project) =>
@@ -1507,7 +1604,11 @@ export const make = Effect.gen(function* () { · 236 unmodified lines
1507 })1604 })
1508 .pipe(1605 .pipe(
1509 Effect.mapError(toPullRequestError("runAction")),1606 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 ),
1511 );1612 );
1512 }),1613 }),
1513 );1614 );
@@ -2133,11 +2234,35 @@ export const make = Effect.gen(function* () { · 619 unmodified lines
2133 let turnRefreshEpoch = 0;2234 let turnRefreshEpoch = 0;
2134 const refEpochs = new Map<string, number>();2235 const refEpochs = new Map<string, number>();
2135 const REF_EPOCH_CAPACITY = 2_048;2236 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}`;
2137 const refEpoch = (ref: PullRequestRef) =>2239 const refEpoch = (ref: PullRequestRef) =>
2138 Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);2240 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.
2139 const refCacheKey = (ref: PullRequestRef) =>2243 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 };
2141 // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse2266 // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse
2142 // them; explicit refreshes, mutations, and turns strand old and in-flight results.2267 // them; explicit refreshes, mutations, and turns strand old and in-flight results.
2143 const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]);2268 const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]);
@@ -2181,8 +2306,7 @@ export const make = Effect.gen(function* () { · 37 unmodified lines
2181 2306
2182 const summaryCache = yield* Cache.makeWith(2307 const summaryCache = yield* Cache.makeWith(
2183 (key: string) => {2308 (key: string) => {
2184 const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];2309 return summaryUncached(refOfCacheKey(key));
2185 return summaryUncached({ projectId, repository, number } as PullRequestRef);
2186 },2310 },
2187 {2311 {
2188 capacity: DETAIL_CACHE_CAPACITY,2312 capacity: DETAIL_CACHE_CAPACITY,
@@ -2203,6 +2327,13 @@ export const make = Effect.gen(function* () { · 14 unmodified lines
2203 );2327 );
2204 };2328 };
2205 2329
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
2206 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder2337 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
2207 // of in-flight state: concurrent identical reads coalesce on the key into one host request.2338 // of in-flight state: concurrent identical reads coalesce on the key into one host request.
2208 // The continuation cursors are part of the key, entries sorted so one continuation is one2339 // 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
2283 const detailCache = yield* Cache.makeWith(2414 const detailCache = yield* Cache.makeWith(
2284 (key: string) => {2415 (key: string) => {
2285 const statsKey = statsCacheKey(key);2416 const statsKey = statsCacheKey(key);
2286 const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];2417 return detailUncached(refOfCacheKey(key)).pipe(
2287 return detailUncached({ projectId, repository, number } as PullRequestRef).pipe(
2288 Effect.tap(2418 Effect.tap(
2289 Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) {2419 Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) {
2290 recordStats(2420 recordStats(
@@ -2307,7 +2437,12 @@ export const make = Effect.gen(function* () { · 16 unmodified lines
2307 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),2437 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero),
2308 },2438 },
2309 );2439 );
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,
2311 provider: detail.provider,2446 provider: detail.provider,
2312 projectId: detail.projectId,2447 projectId: detail.projectId,
2313 repository: detail.repository,2448 repository: detail.repository,
@@ -2315,7 +2450,12 @@ export const make = Effect.gen(function* () { · 1 unmodified line
2315 title: detail.title,2450 title: detail.title,
2316 url: detail.url,2451 url: detail.url,
2317 state: detail.state,2452 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,
2319 headBranch: detail.headBranch,2459 headBranch: detail.headBranch,
2320 baseBranch: detail.baseBranch,2460 baseBranch: detail.baseBranch,
2321 closedAt: detail.closedAt,2461 closedAt: detail.closedAt,
@@ -2338,7 +2478,7 @@ export const make = Effect.gen(function* () { · 16 unmodified lines
2338 key,2478 key,
2339 Cache.get(detailCache, key).pipe(2479 Cache.get(detailCache, key).pipe(
2340 Effect.tap((value) => {2480 Effect.tap((value) => {
2341 const summary = summaryFromDetail(value);2481 const summary = summaryFromDetail(value, lastGoodSummary.peek(key));
2342 return shouldReplaceHeldSummary(key, summary)2482 return shouldReplaceHeldSummary(key, summary)
2343 ? lastGoodSummary.record(key, summary)2483 ? lastGoodSummary.record(key, summary)
2344 : Effect.void;2484 : Effect.void;
@@ -2350,8 +2490,7 @@ export const make = Effect.gen(function* () { · 5 unmodified lines
2350 2490
2351 const activityCache = yield* Cache.makeWith(2491 const activityCache = yield* Cache.makeWith(
2352 (key: string) => {2492 (key: string) => {
2353 const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number];2493 return activityUncached(refOfCacheKey(key));
2354 return activityUncached({ projectId, repository, number } as PullRequestRef);
2355 },2494 },
2356 {2495 {
2357 capacity: DETAIL_CACHE_CAPACITY,2496 capacity: DETAIL_CACHE_CAPACITY,
@@ -2365,9 +2504,10 @@ export const make = Effect.gen(function* () { · 7 unmodified lines
2365 2504
2366 const diffCache = yield* Cache.makeWith(2505 const diffCache = yield* Cache.makeWith(
2367 (key: string) => {2506 (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 [
2369 number,2508 number,
2370 string,2509 string,
2510 string | null,
2371 string,2511 string,
2372 number,2512 number,
2373 string | null,2513 string | null,
@@ -2375,6 +2515,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line
2375 ];2515 ];
2376 return diffUncached({2516 return diffUncached({
2377 projectId,2517 projectId,
2518 ...(host === null ? {} : { host }),
2378 repository,2519 repository,
2379 number,2520 number,
2380 ...(cursor === null ? {} : { cursor }),2521 ...(cursor === null ? {} : { cursor }),
@@ -2385,7 +2526,7 @@ export const make = Effect.gen(function* () { · 4 unmodified lines
2385 capacity: DIFF_CACHE_CAPACITY,2526 capacity: DIFF_CACHE_CAPACITY,
2386 timeToLive: (exit, key) => {2527 timeToLive: (exit, key) => {
2387 if (!Exit.isSuccess(exit)) return Duration.zero;2528 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];
2389 return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL;2530 return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL;
2390 },2531 },
2391 },2532 },
@@ -2394,7 +2535,8 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
2394 const key = JSON.stringify([2535 const key = JSON.stringify([
2395 refEpoch(input),2536 refEpoch(input),
2396 input.projectId,2537 input.projectId,
2397 input.repository,2538 input.host?.toLowerCase() ?? null,
2539 input.repository.toLowerCase(),
2398 input.number,2540 input.number,
2399 input.cursor ?? null,2541 input.cursor ?? null,
2400 input.commit ?? null,2542 input.commit ?? null,
@@ -2523,6 +2665,7 @@ export const make = Effect.gen(function* () { · 122 unmodified lines
2523 list,2665 list,
2524 listStats,2666 listStats,
2525 summary,2667 summary,
2668 stack,
2526 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(2669 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
2527 Effect.map((subscription) => Stream.fromSubscription(subscription)),2670 Effect.map((subscription) => Stream.fromSubscription(subscription)),
2528 ),2671 ),
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+79−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
11 decodePullRequestListJson,11 decodePullRequestListJson,
12 decodePullRequestNodeIdJson,12 decodePullRequestNodeIdJson,
13 decodePullRequestSearchJson,13 decodePullRequestSearchJson,
14 decodePullRequestStacksJson,
14 decodeLabelCandidatesJson,15 decodeLabelCandidatesJson,
15 decodeRepositoryAccessJson,16 decodeRepositoryAccessJson,
16 decodeReviewerCandidatesJson,17 decodeReviewerCandidatesJson,
@@ -1500,3 +1501,81 @@ describe("how far a branch trails its base", () => { · 1483 unmodified lines
1500 expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false);1501 expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false);
1501 });1502 });
1502});1503});
1504
1505describe("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
2445 omittedFileStats,2445 omittedFileStats,
2446 });2446 });
2447}2447}
2448
2449/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */
2450const 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 */
2463const 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
2473const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema));
2474
2475export interface GitHubPullRequestStackLayer {
2476 readonly number: number;
2477 readonly headBranch: string;
2478 readonly state: PullRequestState;
2479}
2480
2481export 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 */
2494export 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 @@
1import { assert, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
6import { listLinkedPullRequestThreads } from "./linkedThreads.ts";
7
8it.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 @@
1import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
2import {
3 PullRequestLinkedThreadsResult,
4 PullRequestOperationError,
5 type ThreadPullRequestKey,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Schema from "effect/Schema";
9import * as SqlClient from "effect/unstable/sql/SqlClient";
10
11const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsResult);
12
13export 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 @@
1import { ProjectId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { pullRequestSyncKey } from "./pullRequestSyncKey.ts";
5
6describe("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 @@
1import {
2 pullRequestHostOf,
3 type PullRequestRef,
4 type RepositoryIdentity,
5 type SourceControlProviderKind,
6 type ThreadPullRequestKey,
7} from "@t3tools/contracts";
8import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
9import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
10
11/** Convert checkout-scoped references to the host-level identity used by linked threads. */
12export 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}