Showing the changes to apps/server/src/pullRequest (13 of 172 files). Show all
Unified · Split
| @@ -183,20 +183,37 @@ afterEach(() => { · 182 unmodified lines | |||
| 183 | }); | 183 | }); |
| 184 | 184 | ||
| 185 | layer("GitHubPullRequestCli.layer", (it) => { | 185 | 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", () => |
| 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 | ||
| @@ -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"; |
| 94 | import type { ProviderListCursor } from "./PullRequestProvider.ts"; | 96 | import 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 from | 99 | * 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 read | 497 | * 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 not | 498 | * 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 | github | 1635 | 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 === undefined | 1649 | 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({ |
| @@ -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 | |||
| 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"); | ||
| 45 | }), | 106 | }), |
| 46 | ); | 107 | ); |
| 47 | 108 | ||
| @@ -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( |
| @@ -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. */ | ||
| 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>; | ||
| 106 | } | 134 | } |
| 107 | 135 | ||
| 108 | export interface ProviderChangeRequestPage { | 136 | export 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 }, |
| @@ -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 the | 37 | // 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 | ||
| 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 | |||
| 1566 | it.effect("refuses a diff on a host that cannot produce one", () => | 1808 | it.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 | ||
| 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 | |||
| 3380 | it.effect("reuses an observed merged state for strict settlement reads", () => | 3664 | it.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 | ||
| 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 | |||
| 3627 | it.effect("narrows the rows of a host that ignored the filters it was handed", () => | 3874 | it.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 | |||
| 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 | ); | ||
| @@ -1,3 +1,7 @@ | |||
| 1 | import { | ||
| 2 | canonicalRepositoryKey, | ||
| 3 | sourceControlRepositorySelector, | ||
| 4 | } from "@t3tools/shared/sourceControl"; | ||
| 1 | import * as Cache from "effect/Cache"; | 5 | import * as Cache from "effect/Cache"; |
| 2 | import * as Clock from "effect/Clock"; | 6 | import * as Clock from "effect/Clock"; |
| 3 | import * as Context from "effect/Context"; | 7 | import * 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. */ |
| 243 | interface SupportedProject { | 255 | interface 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 === undefined | 494 | ...(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 | */ | ||
| 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; | ||
| 533 | } | 528 | } |
| 534 | 529 | ||
| 535 | export const make = Effect.gen(function* () { | 530 | export 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 reach | 558 | // 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 keeps | 632 | // 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 part | 633 | // 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's | 691 | 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 mean | 885 | // 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 === null | 999 | continuation === null |
| 951 | ? projects | 1000 | ? 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 repositories | 1046 | * 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.api | 1054 | 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: items | 1212 | 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 reuse | 2266 | // 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 holder | 2337 | // 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 one | 2339 | // 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 | ), |
| @@ -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 | |||
| 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 | }); | ||
| @@ -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. */ | ||
| 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 | } | ||
| @@ -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 | ); | ||
| @@ -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 | ); | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||