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 | 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 | 187 | Effect.gen(function* () { |
| 188 | − mockedGetPullRequest.mockReturnValueOnce( | |
| 189 | − Effect.succeed({ | |
| 190 | − number: 7, | |
| 191 | − title: "Reuse the summary", | |
| 192 | − url: "https://github.com/acme/web/pull/7", | |
| 193 | − baseRefName: "main", | |
| 194 | − headRefName: "feat/summary", | |
| 195 | − state: "merged", | |
| 196 | − closedAt: "2026-08-23T10:00:00Z", | |
| 197 | − mergedAt: "2026-08-23T10:00:00Z", | |
| 198 | − updatedAt: "2026-08-24T12:34:56.000Z", | |
| 199 | − }), | |
| 188 | + mockedExecute.mockReturnValueOnce( | |
| 189 | + Effect.succeed( | |
| 190 | + output( | |
| 191 | + // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 192 | + JSON.stringify({ | |
| 193 | + number: 7, | |
| 194 | + title: "Reuse the summary", | |
| 195 | + url: "https://github.com/acme/web/pull/7", | |
| 196 | + author: { login: "octocat", name: "Octo Cat" }, | |
| 197 | + baseRefName: "main", | |
| 198 | + headRefName: "feat/summary", | |
| 199 | + state: "OPEN", | |
| 200 | + isDraft: false, | |
| 201 | + mergeable: "MERGEABLE", | |
| 202 | + reviewDecision: "APPROVED", | |
| 203 | + additions: 12, | |
| 204 | + deletions: 3, | |
| 205 | + changedFiles: 2, | |
| 206 | + createdAt: "2026-08-20T00:00:00.000Z", | |
| 207 | + updatedAt: "2026-08-24T12:34:56.000Z", | |
| 208 | + reviewRequests: [], | |
| 209 | + labels: [], | |
| 210 | + statusCheckRollup: [ | |
| 211 | + { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }, | |
| 212 | + ], | |
| 213 | + body: "", | |
| 214 | + }), | |
| 215 | + ), | |
| 216 | + ), | |
| 200 | 217 | ); |
| 201 | 218 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 202 | 219 | |
| @@ -207,23 +224,207 @@ layer("GitHubPullRequestCli.layer", (it) => { · 4 unmodified lines | ||
| 207 | 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 | 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(); | |
| 222 | − expect(mockedGetPullRequest).toHaveBeenCalledWith({ | |
| 303 | + | |
| 304 | + assert.deepStrictEqual(stack, { | |
| 305 | + id: "42", | |
| 306 | + number: 3, | |
| 307 | + url: "https://api.github.com/repos/acme/web/stacks/3", | |
| 308 | + base: "main", | |
| 309 | + layers: [ | |
| 310 | + { number: 6, headBranch: "feat/one", state: "merged" }, | |
| 311 | + { number: 7, headBranch: "feat/two", state: "open" }, | |
| 312 | + ], | |
| 313 | + }); | |
| 314 | + assert.deepStrictEqual(callAt(0).args, [ | |
| 315 | + "api", | |
| 316 | + "--hostname", | |
| 317 | + "ghe.example.com", | |
| 318 | + "repos/acme/web/stacks?pull_request=7", | |
| 319 | + ]); | |
| 320 | + }), | |
| 321 | + ); | |
| 322 | + | |
| 323 | + it.effect("reads an empty stacks listing as not stacked", () => | |
| 324 | + Effect.gen(function* () { | |
| 325 | + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | |
| 326 | + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | |
| 327 | + | |
| 328 | + const stack = yield* cli.getPullRequestStack({ | |
| 223 | 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 | 39 | decodePullRequestListJson, |
| 40 | 40 | decodePullRequestNodeIdJson, |
| 41 | 41 | decodePullRequestSearchJson, |
| 42 | + decodePullRequestStacksJson, | |
| 42 | 43 | decodePullRequestStatsJson, |
| 43 | 44 | decodeReactionSubjectScopeJson, |
| 44 | 45 | decodeRepositoryAccessJson, |
| @@ -84,6 +85,7 @@ import { · 39 unmodified lines | ||
| 84 | 85 | type GitHubPullRequestHead, |
| 85 | 86 | type GitHubPullRequestListItem, |
| 86 | 87 | type GitHubPullRequestSearchItem, |
| 88 | + type GitHubPullRequestStack, | |
| 87 | 89 | type GitHubReviewThreadComments, |
| 88 | 90 | type GitHubRepositoryAccess, |
| 89 | 91 | type GitHubWorkflowRunApproval, |
| @@ -91,7 +93,7 @@ import { · 1 unmodified line | ||
| 91 | 93 | type GitHubReviewThreadPage, |
| 92 | 94 | type GitHubViewerAccess, |
| 93 | 95 | } from "./gitHubPullRequestJson.ts"; |
| 94 | −import type { ProviderListCursor } from "./PullRequestProvider.ts"; | |
| 96 | +import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts"; | |
| 95 | 97 | |
| 96 | 98 | /** |
| 97 | 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 | 462 | readonly repository: string; |
| 461 | 463 | readonly host: string; |
| 462 | 464 | readonly number: number; |
| 463 | − }) => Effect.Effect< | |
| 464 | − { | |
| 465 | − readonly number: number; | |
| 466 | − readonly title: string; | |
| 467 | − readonly url: string; | |
| 468 | − readonly headBranch: string; | |
| 469 | − readonly baseBranch: string; | |
| 470 | − readonly state: "open" | "closed" | "merged"; | |
| 471 | − readonly isDraft?: boolean; | |
| 472 | − readonly closedAt?: string | null; | |
| 473 | − readonly mergedAt?: string | null; | |
| 474 | − readonly updatedAt: string; | |
| 475 | − }, | |
| 476 | − GitHubPullRequestCliError | |
| 477 | − >; | |
| 465 | + }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestCliError>; | |
| 478 | 466 | |
| 479 | 467 | readonly getPullRequestDetail: (input: { |
| 480 | 468 | readonly cwd: string; |
| @@ -494,6 +482,17 @@ export class GitHubPullRequestCli extends Context.Service< · 13 unmodified lines | ||
| 494 | 482 | readonly isCrossRepository: true; |
| 495 | 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 | 497 | * How far the branch trails its base, and whether this viewer may update it. Its own read |
| 499 | 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 | 806 | viewer: string, |
| 808 | 807 | ): boolean { |
| 809 | 808 | if (filters === undefined) return true; |
| 810 | − const labels = item.labels.map((label) => label.name.trim().toLowerCase()); | |
| 811 | − const holds = (label: string) => labels.includes(label.trim().toLowerCase()); | |
| 809 | + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); | |
| 810 | + const holds = (label: string) => labels.has(label.trim().toLowerCase()); | |
| 812 | 811 | return ( |
| 813 | 812 | (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && |
| 814 | 813 | (filters.review === undefined || |
| @@ -1630,41 +1629,94 @@ export const make = Effect.gen(function* () { · 815 unmodified lines | ||
| 1630 | 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 | 1634 | getPullRequestSummary: (input) => |
| 1634 | 1635 | github |
| 1635 | − .getPullRequest({ | |
| 1636 | + .execute({ | |
| 1636 | 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 | 1647 | .pipe( |
| 1640 | − Effect.flatMap((summary) => | |
| 1641 | − summary.updatedAt === undefined | |
| 1642 | − ? Effect.fail( | |
| 1643 | − new GitHubPullRequestUpdatedAtUnavailableError({ | |
| 1644 | − command: "gh", | |
| 1645 | − cwd: input.cwd, | |
| 1646 | − repository: input.repository, | |
| 1647 | − number: input.number, | |
| 1648 | − }), | |
| 1649 | − ) | |
| 1650 | − : Effect.succeed({ | |
| 1651 | − number: summary.number, | |
| 1652 | − title: summary.title, | |
| 1653 | − url: summary.url, | |
| 1654 | − headBranch: summary.headRefName, | |
| 1655 | − baseBranch: summary.baseRefName, | |
| 1656 | − state: summary.state ?? "open", | |
| 1657 | − ...(summary.isDraft === true ? { isDraft: true } : {}), | |
| 1658 | − closedAt: summary.closedAt ?? null, | |
| 1659 | − mergedAt: summary.mergedAt ?? null, | |
| 1660 | − updatedAt: summary.updatedAt, | |
| 1648 | + Effect.flatMap((result) => { | |
| 1649 | + const decoded = decodePullRequestDetailJson(result.stdout.trim()); | |
| 1650 | + if (!Result.isSuccess(decoded)) { | |
| 1651 | + return Effect.fail( | |
| 1652 | + new GitHubPullRequestReadError({ | |
| 1653 | + command: "gh", | |
| 1654 | + cwd: input.cwd, | |
| 1655 | + operation: "getPullRequestSummary", | |
| 1656 | + cause: decoded.failure, | |
| 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 | 1683 | getPullRequestDetail, |
| 1666 | 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 | 1720 | getPullRequestBaseComparison: (input) => { |
| 1669 | 1721 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1670 | 1722 | return graphqlRead({ |
| @@ -25,6 +25,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => · 24 unmodified lines | ||
| 25 | 25 | baseBranch: "main", |
| 26 | 26 | state: "open" as const, |
| 27 | 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 | 44 | expect(summary.state).toBe("open"); |
| 44 | 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 | 48 | reviewers: { request: true, listCandidates: true }, |
| 49 | 49 | edit: { changeRequest: true, comment: true }, |
| 50 | + stacks: true, | |
| 50 | 51 | labels: true, |
| 51 | 52 | }; |
| 52 | 53 | |
| @@ -292,7 +293,20 @@ export const make = Effect.gen(function* () { · 239 unmodified lines | ||
| 292 | 293 | .pipe(Effect.mapError(fail("listChangeRequestStats"))), |
| 293 | 294 | |
| 294 | 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 | 311 | getChangeRequest: (input) => |
| 298 | 312 | Effect.all( |
| @@ -103,6 +103,34 @@ export interface ProviderChangeRequestSummary { · 102 unmodified lines | ||
| 103 | 103 | readonly closedAt?: string | null; |
| 104 | 104 | readonly mergedAt?: string | null; |
| 105 | 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 | 136 | export interface ProviderChangeRequestPage { |
| @@ -331,6 +359,14 @@ export interface PullRequestProviderApi { · 222 unmodified lines | ||
| 331 | 359 | input: ProviderRepositoryRef & { readonly number: number }, |
| 332 | 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 | 370 | /** Comments, line threads, and commits, kept off the critical path for the core detail. */ |
| 335 | 371 | readonly getChangeRequestActivity: ( |
| 336 | 372 | input: ProviderRepositoryRef & { readonly number: number }, |
| @@ -32,6 +32,7 @@ function project(input: { · 31 unmodified lines | ||
| 32 | 32 | readonly repository?: string; |
| 33 | 33 | readonly provider?: string; |
| 34 | 34 | readonly host?: string; |
| 35 | + readonly remoteUrl?: string; | |
| 35 | 36 | }): OrchestrationProjectShell { |
| 36 | 37 | // The host defaults from the provider, so a fixture only names one when the point of the |
| 37 | 38 | // test is two hosts of the same kind. |
| @@ -47,7 +48,7 @@ function project(input: { · 9 unmodified lines | ||
| 47 | 48 | locator: { |
| 48 | 49 | source: "git-remote" as const, |
| 49 | 50 | remoteName: "origin", |
| 50 | − remoteUrl: `https://${host}/${input.repository}.git`, | |
| 51 | + remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`, | |
| 51 | 52 | }, |
| 52 | 53 | provider: input.provider ?? "github", |
| 53 | 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 | 472 | // Keyed by the selector Azure is actually asked with, which is the repository's own name. |
| 472 | 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 | 1808 | it.effect("refuses a diff on a host that cannot produce one", () => |
| 1567 | 1809 | Effect.gen(function* () { |
| 1568 | 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 | 3664 | it.effect("reuses an observed merged state for strict settlement reads", () => |
| 3381 | 3665 | Effect.gen(function* () { |
| 3382 | 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 | 3874 | it.effect("narrows the rows of a host that ignored the filters it was handed", () => |
| 3628 | 3875 | Effect.gen(function* () { |
| 3629 | 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 | 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 | 5 | import * as Cache from "effect/Cache"; |
| 2 | 6 | import * as Clock from "effect/Clock"; |
| 3 | 7 | import * as Context from "effect/Context"; |
| @@ -46,6 +50,7 @@ import { · 42 unmodified lines | ||
| 46 | 50 | type PullRequestLabelCandidateList, |
| 47 | 51 | type PullRequestLabelChangeInput, |
| 48 | 52 | type PullRequestSubmitReviewInput, |
| 53 | + type PullRequestStack, | |
| 49 | 54 | type PullRequestSummary, |
| 50 | 55 | type PullRequestThreadReplyInput, |
| 51 | 56 | type PullRequestThreadResolutionInput, |
| @@ -144,6 +149,13 @@ export class PullRequestService extends Context.Service< · 92 unmodified lines | ||
| 144 | 149 | input: PullRequestRef, |
| 145 | 150 | options?: { readonly recoverTransientFailure?: boolean }, |
| 146 | 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 | 159 | readonly subscribeMerges: Effect.Effect< |
| 148 | 160 | Stream.Stream<PullRequestMergeEvent>, |
| 149 | 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 | 254 | /** A project this page can read: its remote is on a host with an implementation. */ |
| 243 | 255 | interface SupportedProject { |
| 256 | + readonly cursorKey: string; | |
| 244 | 257 | readonly project: OrchestrationProjectShell; |
| 245 | 258 | readonly api: PullRequestProviderApi; |
| 246 | 259 | readonly repository: string; |
| @@ -453,7 +466,7 @@ function withRateLimitBackoff( · 206 unmodified lines | ||
| 453 | 466 | call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>, |
| 454 | 467 | ) => wrap(operation, call, true); |
| 455 | 468 | |
| 456 | − return { | |
| 469 | + const wrapped = { | |
| 457 | 470 | kind: api.kind, |
| 458 | 471 | capabilities: api.capabilities, |
| 459 | 472 | getViewer: wrap("getViewer", api.getViewer), |
| @@ -474,6 +487,9 @@ function withRateLimitBackoff( · 14 unmodified lines | ||
| 474 | 487 | : { |
| 475 | 488 | getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary), |
| 476 | 489 | }), |
| 490 | + ...(api.getChangeRequestStack === undefined | |
| 491 | + ? {} | |
| 492 | + : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }), | |
| 477 | 493 | getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), |
| 478 | 494 | ...(api.getReviewThreadComments === undefined |
| 479 | 495 | ? {} |
| @@ -506,30 +522,9 @@ function withRateLimitBackoff( · 26 unmodified lines | ||
| 506 | 522 | setReaction: interactive("setReaction", api.setReaction), |
| 507 | 523 | setThreadResolution: interactive("setThreadResolution", api.setThreadResolution), |
| 508 | 524 | }; |
| 509 | −} | |
| 510 | − | |
| 511 | −/** | |
| 512 | − * The provider-native repository selector. `displayName` is the full path below the host, which | |
| 513 | − * is what nested GitLab groups need; owner/name is the two-segment fallback for identities | |
| 514 | − * recorded before that field existed. | |
| 515 | − * | |
| 516 | − * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and | |
| 517 | − * takes the organisation and project from the checkout it detects — so the recorded | |
| 518 | − * `org/project/_git/repo` path is refused outright and the whole repository reads as | |
| 519 | − * unavailable. Its name is the last segment, which is what this hands over. | |
| 520 | − * | |
| 521 | − * One function because everything downstream is keyed by what it answers: the rows' own | |
| 522 | − * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. | |
| 523 | − */ | |
| 524 | −export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { | |
| 525 | − const identity = project.repositoryIdentity; | |
| 526 | − if (!identity) return null; | |
| 527 | − if (identity.provider === "azure-devops") { | |
| 528 | − const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); | |
| 529 | − return identity.name || segments.at(-1) || null; | |
| 530 | − } | |
| 531 | − if (identity.displayName) return identity.displayName; | |
| 532 | − return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; | |
| 525 | + // Optional provider methods must be forwarded too; returning the interface alone permits omissions. | |
| 526 | + return wrapped satisfies PullRequestProviderApi & | |
| 527 | + Record<Exclude<keyof PullRequestProviderApi, keyof typeof wrapped>, never>; | |
| 533 | 528 | } |
| 534 | 529 | |
| 535 | 530 | export const make = Effect.gen(function* () { |
| @@ -554,7 +549,11 @@ export const make = Effect.gen(function* () { · 18 unmodified lines | ||
| 554 | 549 | for (const project of projects) { |
| 555 | 550 | if (filter.projectId !== undefined && project.id !== filter.projectId) continue; |
| 556 | 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 | 557 | const host = pullRequestHostOf(identity, "unknown"); |
| 559 | 558 | // A legacy identity has no canonical host until its provider is refined, so it must reach |
| 560 | 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 | 627 | if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; |
| 629 | 628 | const identity = project.repositoryIdentity; |
| 630 | 629 | let kind = identity?.provider as SourceControlProviderKind | undefined; |
| 631 | − const repository = repositoryIdentityOf(project); | |
| 630 | + const repository = sourceControlRepositorySelector(project.repositoryIdentity); | |
| 632 | 631 | if (!identity || kind === undefined || repository === null) continue; |
| 633 | 632 | // Worktrees of one repository are separate projects; reading the remote once keeps |
| 634 | 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 | 646 | if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); |
| 648 | 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 | 653 | if (seen.has(key)) continue; |
| 652 | 654 | seen.add(key); |
| 653 | 655 | if (api === null) { |
| @@ -657,6 +659,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines | ||
| 657 | 659 | continue; |
| 658 | 660 | } |
| 659 | 661 | supported.push({ |
| 662 | + cursorKey: key, | |
| 660 | 663 | project, |
| 661 | 664 | api: withRateLimitBackoff(api, host, rateLimits), |
| 662 | 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 | 680 | const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> => |
| 671 | 681 | listWorkspaceProjects({ projectId: ref.projectId }).pipe( |
| 672 | 682 | Effect.flatMap(({ supported }): Effect.Effect<SupportedProject, PullRequestError> => { |
| 673 | − const match = supported[0]; | |
| 674 | − if (!match) { | |
| 675 | − return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); | |
| 683 | + const own = supported[0]; | |
| 684 | + const repository = ref.repository.trim(); | |
| 685 | + const host = ref.host?.trim().toLowerCase(); | |
| 686 | + if (own !== undefined && own.repository.toLowerCase() === repository.toLowerCase()) { | |
| 687 | + // Hostless references only ever meant the project's own repository, and a hosted one | |
| 688 | + // naming it still is; either way the project serves itself. | |
| 689 | + if (host === undefined || host === own.host) return Effect.succeed(own); | |
| 676 | 690 | } |
| 677 | − // The repository travels through the client, so it is checked against the project's | |
| 678 | − // own remote rather than being handed to a provider verbatim. | |
| 679 | − if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) { | |
| 691 | + if (host === undefined) { | |
| 692 | + if (own === undefined) { | |
| 693 | + return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); | |
| 694 | + } | |
| 695 | + // The repository travels through the client, so it is checked against the project's | |
| 696 | + // own remote rather than being handed to a provider verbatim. | |
| 680 | 697 | return Effect.fail( |
| 681 | 698 | new PullRequestOperationError({ |
| 682 | 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 | 878 | viewer: string, |
| 830 | 879 | ): boolean => { |
| 831 | 880 | if (filters === undefined) return true; |
| 832 | − const labels = item.labels.map((label) => label.name.trim().toLowerCase()); | |
| 833 | − const holds = (label: string) => labels.includes(label.trim().toLowerCase()); | |
| 881 | + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); | |
| 882 | + const holds = (label: string) => labels.has(label.trim().toLowerCase()); | |
| 834 | 883 | return ( |
| 835 | 884 | (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && |
| 836 | 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 | 998 | const selected = |
| 950 | 999 | continuation === null |
| 951 | 1000 | ? projects |
| 952 | − : projects.filter(({ host, repository }) => | |
| 953 | − continuation.has(listCursorKey(host, repository)), | |
| 954 | − ); | |
| 1001 | + : projects.filter(({ cursorKey }) => continuation.has(cursorKey)); | |
| 955 | 1002 | const readable = selected.filter(({ host }) => viewers[host] !== undefined); |
| 956 | 1003 | // A host that could not be read still has projects, and they are absent from the list. |
| 957 | 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 | 1041 | const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; |
| 995 | 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 | 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 | 1049 | const readRepository = (project: SupportedProject): Effect.Effect<RepositoryBatch> => { |
| 1003 | 1050 | { |
| 1004 | 1051 | const viewer = viewers[project.host]!; |
| 1005 | − const key = listCursorKey(project.host, project.repository); | |
| 1052 | + const key = project.cursorKey; | |
| 1006 | 1053 | const cursor = cursorOf(project); |
| 1007 | 1054 | return project.api |
| 1008 | 1055 | .listChangeRequests({ |
| @@ -1161,7 +1208,7 @@ export const make = Effect.gen(function* () { · 152 unmodified lines | ||
| 1161 | 1208 | !cursorHere.seenAt.includes(item.number), |
| 1162 | 1209 | ); |
| 1163 | 1210 | return Effect.succeed({ |
| 1164 | − key: listCursorKey(project.host, project.repository), | |
| 1211 | + key: project.cursorKey, | |
| 1165 | 1212 | entries: items |
| 1166 | 1213 | .filter((item) => matchesRowFilters(item, input.filters, viewer)) |
| 1167 | 1214 | .map((item) => toEntry({ project, item, viewer })), |
| @@ -1254,17 +1301,67 @@ export const make = Effect.gen(function* () { · 86 unmodified lines | ||
| 1254 | 1301 | title: changeRequest.title, |
| 1255 | 1302 | url: changeRequest.url, |
| 1256 | 1303 | state: changeRequest.state, |
| 1257 | − ...(changeRequest.isDraft === true ? { isDraft: true } : {}), | |
| 1258 | 1304 | headBranch: changeRequest.headBranch, |
| 1259 | 1305 | baseBranch: changeRequest.baseBranch, |
| 1260 | 1306 | closedAt: changeRequest.closedAt ?? null, |
| 1261 | 1307 | mergedAt: changeRequest.mergedAt ?? null, |
| 1262 | 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 | 1365 | const detailUncached: PullRequestService["Service"]["detail"] = (input) => |
| 1269 | 1366 | requireProject(input).pipe( |
| 1270 | 1367 | Effect.flatMap((project) => |
| @@ -1507,7 +1604,11 @@ export const make = Effect.gen(function* () { · 236 unmodified lines | ||
| 1507 | 1604 | }) |
| 1508 | 1605 | .pipe( |
| 1509 | 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 | 2234 | let turnRefreshEpoch = 0; |
| 2134 | 2235 | const refEpochs = new Map<string, number>(); |
| 2135 | 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 | 2239 | const refEpoch = (ref: PullRequestRef) => |
| 2138 | 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 | 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 | 2266 | // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse |
| 2142 | 2267 | // them; explicit refreshes, mutations, and turns strand old and in-flight results. |
| 2143 | 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 | 2307 | const summaryCache = yield* Cache.makeWith( |
| 2183 | 2308 | (key: string) => { |
| 2184 | − const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; | |
| 2185 | − return summaryUncached({ projectId, repository, number } as PullRequestRef); | |
| 2309 | + return summaryUncached(refOfCacheKey(key)); | |
| 2186 | 2310 | }, |
| 2187 | 2311 | { |
| 2188 | 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 | 2337 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder |
| 2207 | 2338 | // of in-flight state: concurrent identical reads coalesce on the key into one host request. |
| 2208 | 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 | 2414 | const detailCache = yield* Cache.makeWith( |
| 2284 | 2415 | (key: string) => { |
| 2285 | 2416 | const statsKey = statsCacheKey(key); |
| 2286 | − const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; | |
| 2287 | − return detailUncached({ projectId, repository, number } as PullRequestRef).pipe( | |
| 2417 | + return detailUncached(refOfCacheKey(key)).pipe( | |
| 2288 | 2418 | Effect.tap( |
| 2289 | 2419 | Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) { |
| 2290 | 2420 | recordStats( |
| @@ -2307,7 +2437,12 @@ export const make = Effect.gen(function* () { · 16 unmodified lines | ||
| 2307 | 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 | 2446 | provider: detail.provider, |
| 2312 | 2447 | projectId: detail.projectId, |
| 2313 | 2448 | repository: detail.repository, |
| @@ -2315,7 +2450,12 @@ export const make = Effect.gen(function* () { · 1 unmodified line | ||
| 2315 | 2450 | title: detail.title, |
| 2316 | 2451 | url: detail.url, |
| 2317 | 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 | 2459 | headBranch: detail.headBranch, |
| 2320 | 2460 | baseBranch: detail.baseBranch, |
| 2321 | 2461 | closedAt: detail.closedAt, |
| @@ -2338,7 +2478,7 @@ export const make = Effect.gen(function* () { · 16 unmodified lines | ||
| 2338 | 2478 | key, |
| 2339 | 2479 | Cache.get(detailCache, key).pipe( |
| 2340 | 2480 | Effect.tap((value) => { |
| 2341 | − const summary = summaryFromDetail(value); | |
| 2481 | + const summary = summaryFromDetail(value, lastGoodSummary.peek(key)); | |
| 2342 | 2482 | return shouldReplaceHeldSummary(key, summary) |
| 2343 | 2483 | ? lastGoodSummary.record(key, summary) |
| 2344 | 2484 | : Effect.void; |
| @@ -2350,8 +2490,7 @@ export const make = Effect.gen(function* () { · 5 unmodified lines | ||
| 2350 | 2490 | |
| 2351 | 2491 | const activityCache = yield* Cache.makeWith( |
| 2352 | 2492 | (key: string) => { |
| 2353 | − const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; | |
| 2354 | − return activityUncached({ projectId, repository, number } as PullRequestRef); | |
| 2493 | + return activityUncached(refOfCacheKey(key)); | |
| 2355 | 2494 | }, |
| 2356 | 2495 | { |
| 2357 | 2496 | capacity: DETAIL_CACHE_CAPACITY, |
| @@ -2365,9 +2504,10 @@ export const make = Effect.gen(function* () { · 7 unmodified lines | ||
| 2365 | 2504 | |
| 2366 | 2505 | const diffCache = yield* Cache.makeWith( |
| 2367 | 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 | 2508 | number, |
| 2370 | 2509 | string, |
| 2510 | + string | null, | |
| 2371 | 2511 | string, |
| 2372 | 2512 | number, |
| 2373 | 2513 | string | null, |
| @@ -2375,6 +2515,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line | ||
| 2375 | 2515 | ]; |
| 2376 | 2516 | return diffUncached({ |
| 2377 | 2517 | projectId, |
| 2518 | + ...(host === null ? {} : { host }), | |
| 2378 | 2519 | repository, |
| 2379 | 2520 | number, |
| 2380 | 2521 | ...(cursor === null ? {} : { cursor }), |
| @@ -2385,7 +2526,7 @@ export const make = Effect.gen(function* () { · 4 unmodified lines | ||
| 2385 | 2526 | capacity: DIFF_CACHE_CAPACITY, |
| 2386 | 2527 | timeToLive: (exit, key) => { |
| 2387 | 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 | 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 | 2535 | const key = JSON.stringify([ |
| 2395 | 2536 | refEpoch(input), |
| 2396 | 2537 | input.projectId, |
| 2397 | − input.repository, | |
| 2538 | + input.host?.toLowerCase() ?? null, | |
| 2539 | + input.repository.toLowerCase(), | |
| 2398 | 2540 | input.number, |
| 2399 | 2541 | input.cursor ?? null, |
| 2400 | 2542 | input.commit ?? null, |
| @@ -2523,6 +2665,7 @@ export const make = Effect.gen(function* () { · 122 unmodified lines | ||
| 2523 | 2665 | list, |
| 2524 | 2666 | listStats, |
| 2525 | 2667 | summary, |
| 2668 | + stack, | |
| 2526 | 2669 | subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( |
| 2527 | 2670 | Effect.map((subscription) => Stream.fromSubscription(subscription)), |
| 2528 | 2671 | ), |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | decodePullRequestListJson, |
| 12 | 12 | decodePullRequestNodeIdJson, |
| 13 | 13 | decodePullRequestSearchJson, |
| 14 | + decodePullRequestStacksJson, | |
| 14 | 15 | decodeLabelCandidatesJson, |
| 15 | 16 | decodeRepositoryAccessJson, |
| 16 | 17 | decodeReviewerCandidatesJson, |
| @@ -1500,3 +1501,81 @@ describe("how far a branch trails its base", () => { · 1483 unmodified lines | ||
| 1500 | 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 | 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 | +} | |