authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-10-11 11:25:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 11:25:24-07:00
log49466027846028b1f49dacdf20e853ae7235cc26
treeace0a841a097f0a2ea699b7970e9c1f26a3a2ecc
parentf23d0084b6e023ffb1af9e332d8666affc99d542
signature Signed by PGP key B5690EEEBB952194

fix(server): refresh linked PRs without a checkout (#17167)


2 files changed, 175 insertions(+), 6 deletions(-)

apps/server/src/pullRequest/PullRequestService.test.ts+139-1
...@@ -2566,7 +2566,145 @@ it.effect("refuses Azure cross-organization reads and writes without its checkou...@@ -2566,7 +2566,145 @@ it.effect("refuses Azure cross-organization reads and writes without its checkou
2566 }),2566 }),
2567);2567);
25682568
2569it.effect("refuses a hosted reference when nothing is checked out from that host", () =>2569it.effect.each([
2570 ["github.com", "github"],
2571 ["bitbucket.org", "bitbucket"],
2572 ["codeberg.org", "forgejo"],
2573] as const)("reads linked PR summaries and stacks on %s without a checkout", ([host, kind]) =>
2574 Effect.gen(function* () {
2575 const seen: Array<{ cwd: string; repository: string; host: string }> = [];
2576 const service = yield* makeService({
2577 projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })],
2578 providers: [
2579 fakeProvider(SourceControlProviderKind.make(kind), {
2580 getChangeRequestSummary: (input) =>
2581 Effect.sync(() => {
2582 seen.push({ cwd: input.cwd, repository: input.repository, host: input.host });
2583 return changeRequest(7, "2026-07-02T00:00:00Z");
2584 }),
2585 getChangeRequestStack: (input) =>
2586 Effect.sync(() => {
2587 seen.push({ cwd: input.cwd, repository: input.repository, host: input.host });
2588 return null;
2589 }),
2590 }),
2591 ],
2592 });
2593 const reference = {
2594 projectId: "inbox" as ProjectId,
2595 host,
2596 repository: "acme/api",
2597 number: 7,
2598 };
2599 const summary = yield* service.summary(reference, { recoverTransientFailure: false });
2600 const stack = yield* service.stack(reference, { includeDetails: false });
2601 assert.strictEqual(summary.title, "Change request 7");
2602 assert.strictEqual(summary.state, "open");
2603 assert.strictEqual(summary.projectId, reference.projectId);
2604 assert.strictEqual(stack, null);
2605 assert.deepStrictEqual(seen, [
2606 { cwd: "/home/alex", repository: "acme/api", host },
2607 { cwd: "/home/alex", repository: "acme/api", host },
2608 ]);
2609 }),
2610);
2611
2612it.effect("prefers a matching checkout over a non-repository project's directory", () =>
2613 Effect.gen(function* () {
2614 const seen: string[] = [];
2615 const service = yield* makeService({
2616 projects: [
2617 project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" }),
2618 project({ id: "repo", title: "API", workspaceRoot: "/api", repository: "acme/api" }),
2619 ],
2620 providers: [
2621 fakeProvider(SourceControlProviderKind.make("github"), {
2622 getChangeRequestSummary: (input) =>
2623 Effect.sync(() => {
2624 seen.push(input.cwd);
2625 return changeRequest(7, "2026-07-02T00:00:00Z");
2626 }),
2627 }),
2628 ],
2629 });
2630 yield* service.summary(
2631 {
2632 projectId: "inbox" as ProjectId,
2633 host: "github.com",
2634 repository: "acme/api",
2635 number: 7,
2636 },
2637 { recoverTransientFailure: false },
2638 );
2639 assert.deepStrictEqual(seen, ["/api"]);
2640 }),
2641);
2642
2643it.effect("requires an explicit host for a non-repository project's linked PR", () =>
2644 Effect.gen(function* () {
2645 const service = yield* makeService({
2646 projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })],
2647 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
2648 });
2649 const error = yield* service
2650 .summary(
2651 {
2652 projectId: "inbox" as ProjectId,
2653 repository: "acme/api",
2654 number: 7,
2655 },
2656 { recoverTransientFailure: false },
2657 )
2658 .pipe(Effect.flip);
2659 assert.strictEqual(error._tag, "PullRequestUnavailableError");
2660 }),
2661);
2662
2663it.effect("refuses a public host reference without a selected project directory", () =>
2664 Effect.gen(function* () {
2665 const service = yield* makeService({
2666 projects: [],
2667 providers: [fakeProvider(SourceControlProviderKind.make("github"))],
2668 });
2669 const error = yield* service
2670 .summary(
2671 {
2672 projectId: "missing" as ProjectId,
2673 host: "github.com",
2674 repository: "acme/api",
2675 number: 7,
2676 },
2677 { recoverTransientFailure: false },
2678 )
2679 .pipe(Effect.flip);
2680 assert.strictEqual(error._tag, "PullRequestUnavailableError");
2681 }),
2682);
2683
2684it.effect(
2685 "requires a checkout for GitLab because its adapter does not explicitly target the host",
2686 () =>
2687 Effect.gen(function* () {
2688 const service = yield* makeService({
2689 projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })],
2690 providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))],
2691 });
2692 const error = yield* service
2693 .summary(
2694 {
2695 projectId: "inbox" as ProjectId,
2696 host: "gitlab.com",
2697 repository: "acme/api",
2698 number: 7,
2699 },
2700 { recoverTransientFailure: false },
2701 )
2702 .pipe(Effect.flip);
2703 assert.strictEqual(error._tag, "PullRequestUnavailableError");
2704 }),
2705);
2706
2707it.effect("refuses a hosted reference when its provider is unavailable", () =>
2570 Effect.gen(function* () {2708 Effect.gen(function* () {
2571 const service = yield* makeService({2709 const service = yield* makeService({
2572 projects: [2710 projects: [
apps/server/src/pullRequest/PullRequestService.ts+36-5
...@@ -902,8 +902,9 @@ export const make = Effect.gen(function* () {...@@ -902,8 +902,9 @@ export const make = Effect.gen(function* () {
902 * The project whose checkout and credentials serve a reference. The project's own902 * The project whose checkout and credentials serve a reference. The project's own
903 * repository is the default; a reference that names a `host` may instead point at any903 * repository is the default; a reference that names a `host` may instead point at any
904 * repository on that host. Prefer its own checkout; providers with explicit repository904 * repository on that host. Prefer its own checkout; providers with explicit repository
905 * targeting can fall back to another checkout on the host. Azure derives its organization905 * targeting can fall back to another checkout on the host. Providers with explicit host
906 * from the checkout, so it requires a matching repository.906 * targeting can also use the selected project's directory for supported public hosts.
907 * Azure derives its organization from the checkout, so it requires a matching repository.
907 */908 */
908 const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> =>909 const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> =>
909 listWorkspaceProjects({ projectId: ref.projectId }).pipe(910 listWorkspaceProjects({ projectId: ref.projectId }).pipe(
...@@ -949,9 +950,39 @@ export const make = Effect.gen(function* () {...@@ -949,9 +950,39 @@ export const make = Effect.gen(function* () {
949 ) ??950 ) ??
950 onHost.find((candidate) => candidate.repositoryKey === null);951 onHost.find((candidate) => candidate.repositoryKey === null);
951 if (route === undefined) {952 if (route === undefined) {
952 return Effect.fail(953 return Effect.gen(function* () {
953 new PullRequestUnavailableError({ reason: "provider-unsupported" }),954 // These providers target the repository explicitly and authenticate by host;
954 );955 // a linked PR in a non-repository project still needs its snapshot refreshed.
956 // GitLab still selects its host from the checkout or CLI configuration.
957 const provider = ["github.com", "bitbucket.org", "codeberg.org"].includes(host)
958 ? detectSourceControlProviderFromRemoteUrl(`https://${host}/${repository}.git`)
959 : null;
960 const api = provider === null ? null : registry.get(provider.kind);
961 if (api !== null) {
962 const selected = yield* projects.getShell(ref.projectId).pipe(
963 Effect.mapError(
964 (cause) =>
965 new PullRequestOperationError({
966 operation: "resolveRepository",
967 detail: "The selected project could not be read.",
968 cause,
969 }),
970 ),
971 );
972 if (Option.isSome(selected)) {
973 return {
974 cursorKey: listCursorKey(host, repository),
975 project: selected.value,
976 api: withRateLimitBackoff(api, host, rateLimits),
977 repository,
978 host,
979 remote: normalizeGitRemoteUrl(`https://${host}/${repository}`),
980 repositoryKey: null,
981 };
982 }
983 }
984 return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" });
985 });
955 }986 }
956 return Effect.succeed(987 return Effect.succeed(
957 route.repositoryKey !== null ||988 route.repositoryKey !== null ||