authorgravatar for pawel@mikolajczuk.inPaweł Mikołajczuk <pawel@mikolajczuk.in> 2026-10-13 16:25:11+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 00:25:11-05:00
logbbd98a5f873eaed5f5efbfbacc6913ee87804443
treea92f33fb56dcdd8a7d683bc6301181412618ec2f
parent6abccc1f5e4890374327c837098f09f8d7b5179a
signature Signed by PGP key B5690EEEBB952194

fix(server): review guides find Bitbucket heads reported by short hash (#18900)

Co-authored-by: Pawel Mikolajczuk <pmikolajczuk@atlassian.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

3 files changed, 400 insertions(+), 15 deletions(-)

apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts+322-1
...@@ -9,6 +9,7 @@ import * as Ref from "effect/Ref";...@@ -9,6 +9,7 @@ import * as Ref from "effect/Ref";
9import * as Schedule from "effect/Schedule";9import * as Schedule from "effect/Schedule";
10import {10import {
11 ProjectId,11 ProjectId,
12 GitCommandError,
12 type PullRequestActivity,13 type PullRequestActivity,
13 type PullRequestDetail,14 type PullRequestDetail,
14 type PullRequestStack,15 type PullRequestStack,
...@@ -144,7 +145,7 @@ const stack = (repositories: {...@@ -144,7 +145,7 @@ const stack = (repositories: {
144 {145 {
145 number: 8,146 number: 8,
146 title: "Upper",147 title: "Upper",
147 headSha: repositories.upperSha,148 headSha: repositories.upperSha.slice(0, 12),
148 headBranch: "upper",149 headBranch: "upper",
149 state: "open",150 state: "open",
150 },151 },
...@@ -228,8 +229,328 @@ const layerBase = Layer.mergeAll(...@@ -228,8 +229,328 @@ const layerBase = Layer.mergeAll(
228 SqlitePersistence.layerMemory,229 SqlitePersistence.layerMemory,
229).pipe(Layer.provideMerge(NodeServices.layer));230).pipe(Layer.provideMerge(NodeServices.layer));
230231
232type Repositories = Effect.Success<typeof makeRepositories>;
233
234const awaitRunFailure = (
235 service: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"],
236 input: PullRequestRef,
237) =>
238 service.get(input).pipe(
239 Effect.flatMap((result) =>
240 result.run?._tag === "Failed"
241 ? Effect.succeed(result.run.detail)
242 : Effect.fail("still running" as const),
243 ),
244 Effect.retry(Schedule.spaced("20 millis")),
245 );
246
247/** A workspace cloned with only `main`, so the change's head is not there until fetched. */
248const startSingleBranchRun = (input: {
249 readonly repositoryName: string;
250 readonly reportedHead: (repositories: Repositories) => string;
251 readonly refuseCommitFetch: boolean;
252 readonly prepare: (
253 repositories: Repositories,
254 workspace: string,
255 ) => Effect.Effect<void, GitCommandError, GitVcsDriver.GitVcsDriver>;
256 /** Runs inside the head branch's fetch, after the service asked for it and before git runs. */
257 readonly onBranchFetch?:
258 | ((
259 repositories: Repositories,
260 ) => Effect.Effect<void, GitCommandError, GitVcsDriver.GitVcsDriver>)
261 | undefined;
262}) =>
263 Effect.gen(function* () {
264 const repositories = yield* makeRepositories;
265 const workspace = `${repositories.root}/single-branch`;
266 yield* git(repositories.root, [
267 "clone",
268 "--quiet",
269 "--no-local",
270 "--single-branch",
271 "--branch",
272 "main",
273 `${repositories.root}/remote`,
274 workspace,
275 ]);
276 yield* input.prepare(repositories, workspace);
277 const reportedHead = input.reportedHead(repositories);
278 const stored = yield* Deferred.make<void>();
279 const driver = yield* GitVcsDriver.GitVcsDriver;
280 const fetchedRefs: Array<string> = [];
281 const observed: Array<{ cwd: string; head: string; changed: string }> = [];
282 const onBranchFetch = input.onBranchFetch;
283 const observedDriver = {
284 ...driver,
285 execute: (call: GitVcsDriver.ExecuteGitInput) =>
286 Effect.gen(function* () {
287 const ref = call.args.at(-1);
288 if (call.args[0] !== "fetch" || ref === undefined) return yield* driver.execute(call);
289 fetchedRefs.push(ref);
290 if (input.refuseCommitFetch && ref === repositories.headSha) {
291 return yield* new GitCommandError({
292 operation: call.operation,
293 command: "git",
294 cwd: call.cwd,
295 detail: "The test remote refuses fetches by commit.",
296 exitCode: 128,
297 });
298 }
299 if (ref === "refs/heads/feature" && onBranchFetch !== undefined) {
300 yield* onBranchFetch(repositories).pipe(
301 Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
302 );
303 }
304 return yield* driver.execute(call);
305 }),
306 };
307 const readCheckout = (cwd: string, args: ReadonlyArray<string>) =>
308 driver
309 .execute({ operation: "test.readCheckout", cwd, args })
310 .pipe(Effect.map((result) => result.stdout.trim()));
311 const context = yield* Layer.build(
312 makeLayer({
313 ...repositories,
314 headSha: reportedHead,
315 workspace,
316 stored,
317 identity: {
318 ...ACME_APP,
319 canonicalKey: `github.com/acme/${input.repositoryName}`,
320 displayName: `acme/${input.repositoryName}`,
321 name: input.repositoryName,
322 },
323 generate: (generation) =>
324 Effect.gen(function* () {
325 observed.push({
326 cwd: generation.cwd,
327 head: yield* readCheckout(generation.cwd, ["rev-parse", "HEAD"]),
328 changed: yield* readCheckout(generation.cwd, [
329 "diff",
330 "--name-only",
331 `${generation.baseCommit}...HEAD`,
332 ]),
333 });
334 return { stops: [] };
335 }).pipe(Effect.orDie),
336 }).pipe(Layer.provide(Layer.succeed(GitVcsDriver.GitVcsDriver, observedDriver))),
337 );
338 const service = Context.get(
339 context,
340 PullRequestReviewGuideService.PullRequestReviewGuideService,
341 );
342 const guideReference = { ...reference, repository: `acme/${input.repositoryName}` };
343 yield* service.generate(guideReference);
344 return {
345 repositories,
346 reportedHead,
347 service,
348 reference: guideReference,
349 stored,
350 fetchedRefs,
351 observed,
352 };
353 });
354
231// Runs drive real git, which the test clock does not advance, so waits use real time.355// Runs drive real git, which the test clock does not advance, so waits use real time.
232it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideService", (it) => {356it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideService", (it) => {
357 it.effect.each([
358 {
359 name: "a short head already fetched",
360 shortHash: true,
361 prepare: "fetch-feature",
362 pushDuringFetch: false,
363 refuseCommitFetch: true,
364 expectedFetches: [],
365 },
366 {
367 name: "a short head on its branch",
368 shortHash: true,
369 prepare: "none",
370 pushDuringFetch: false,
371 refuseCommitFetch: true,
372 expectedFetches: ["refs/heads/feature"],
373 },
374 {
375 name: "a short head whose branch moves during the fetch",
376 shortHash: true,
377 prepare: "none",
378 pushDuringFetch: true,
379 refuseCommitFetch: true,
380 expectedFetches: ["refs/heads/feature"],
381 },
382 {
383 name: "a full head served by commit",
384 shortHash: false,
385 prepare: "none",
386 pushDuringFetch: false,
387 refuseCommitFetch: false,
388 expectedFetches: ["<head>"],
389 },
390 {
391 name: "a full head on its branch",
392 shortHash: false,
393 prepare: "none",
394 pushDuringFetch: false,
395 refuseCommitFetch: true,
396 expectedFetches: ["<head>", "refs/heads/feature"],
397 },
398 {
399 name: "a full head only on its pull ref",
400 shortHash: false,
401 prepare: "refs/pull/7/head",
402 pushDuringFetch: false,
403 refuseCommitFetch: true,
404 expectedFetches: ["<head>", "refs/heads/feature", "refs/pull/7/head"],
405 },
406 {
407 name: "a full head only on its merge request ref",
408 shortHash: false,
409 prepare: "refs/merge-requests/7/head",
410 pushDuringFetch: false,
411 refuseCommitFetch: true,
412 expectedFetches: [
413 "<head>",
414 "refs/heads/feature",
415 "refs/pull/7/head",
416 "refs/merge-requests/7/head",
417 ],
418 },
419 ])(
420 "writes a guide for the reported commit: $name",
421 ({ shortHash, prepare, pushDuringFetch, refuseCommitFetch, expectedFetches }) =>
422 Effect.scoped(
423 Effect.gen(function* () {
424 const run = yield* startSingleBranchRun({
425 repositoryName: "fetched-head",
426 reportedHead: (repositories) =>
427 shortHash ? repositories.headSha.slice(0, 12) : repositories.headSha,
428 refuseCommitFetch,
429 prepare: (repositories, workspace) => {
430 const remote = `${repositories.root}/remote`;
431 if (prepare === "fetch-feature") {
432 return git(workspace, ["fetch", "--quiet", "origin", "feature"]).pipe(
433 Effect.asVoid,
434 );
435 }
436 if (prepare === "none") return Effect.void;
437 return git(remote, ["update-ref", prepare, repositories.headSha]).pipe(
438 Effect.andThen(
439 git(remote, ["update-ref", "refs/heads/feature", repositories.lowerSha]),
440 ),
441 Effect.asVoid,
442 );
443 },
444 onBranchFetch: pushDuringFetch
445 ? (repositories) => {
446 const remote = `${repositories.root}/remote`;
447 return git(remote, ["checkout", "--quiet", "feature"]).pipe(
448 Effect.andThen(
449 git(remote, [
450 "commit",
451 "--quiet",
452 "--allow-empty",
453 "-m",
454 "push during fetch",
455 ]),
456 ),
457 Effect.asVoid,
458 );
459 }
460 : undefined,
461 });
462 yield* Effect.raceFirst(
463 Deferred.await(run.stored),
464 awaitRunFailure(run.service, run.reference).pipe(
465 Effect.flatMap((detail) => Effect.die(`run failed: ${detail}`)),
466 ),
467 );
468 const { headSha } = run.repositories;
469 const result = yield* run.service.get(run.reference);
470 assert.strictEqual(result.guide?.headSha, run.reportedHead);
471 assert.deepStrictEqual(result.guide?.stops[0]?.files, [{ path: "feature.txt" }]);
472 assert.deepStrictEqual(run.observed, [
473 { cwd: run.observed[0]?.cwd, head: headSha, changed: "feature.txt" },
474 ]);
475 const headRefs = new Set([
476 headSha,
477 run.reportedHead,
478 "refs/heads/feature",
479 "refs/pull/7/head",
480 "refs/merge-requests/7/head",
481 ]);
482 assert.deepStrictEqual(
483 run.fetchedRefs.filter((ref) => headRefs.has(ref)),
484 expectedFetches.map((ref) => (ref === "<head>" ? headSha : ref)),
485 );
486 const fs = yield* FileSystem.FileSystem;
487 assert.isFalse(yield* fs.exists(run.observed[0]?.cwd ?? ""));
488 }),
489 ),
490 );
491
492 it.effect.each([
493 {
494 name: "a branch named like the short head",
495 reportedHead: (repositories: Repositories) => repositories.headSha.slice(0, 12),
496 shadow: "branch",
497 expected: ["a branch or tag rather than the commit"],
498 },
499 {
500 name: "a tag named like the short head",
501 reportedHead: (repositories: Repositories) => repositories.headSha.slice(0, 12),
502 shadow: "tag",
503 expected: ["a branch or tag rather than the commit"],
504 },
505 {
506 name: "a head that is not a commit hash",
507 reportedHead: (repositories: Repositories) =>
508 `--upload-pack=touch ${repositories.root}/injected`,
509 shadow: "none",
510 expected: ["not a commit hash"],
511 },
512 {
513 name: "a head no source has",
514 reportedHead: () => "e".repeat(40),
515 shadow: "none",
516 expected: [
517 "local objects:",
518 "the commit:",
519 "branch feature:",
520 "refs/pull/7/head:",
521 "refs/merge-requests/7/head:",
522 ],
523 },
524 ])("refuses to write a guide for $name", ({ reportedHead, shadow, expected }) =>
525 Effect.scoped(
526 Effect.gen(function* () {
527 const run = yield* startSingleBranchRun({
528 repositoryName: "refused-head",
529 reportedHead,
530 refuseCommitFetch: false,
531 prepare: (repositories, workspace) =>
532 git(workspace, ["fetch", "--quiet", "origin", "feature"]).pipe(
533 Effect.andThen(
534 shadow === "none"
535 ? Effect.void
536 : git(workspace, [shadow, repositories.headSha.slice(0, 12), "HEAD"]),
537 ),
538 Effect.asVoid,
539 ),
540 });
541 const detail = yield* Effect.raceFirst(
542 awaitRunFailure(run.service, run.reference),
543 Deferred.await(run.stored).pipe(Effect.andThen(Effect.die("a guide was written"))),
544 );
545 for (const part of expected) assert.include(detail, part);
546 assert.deepStrictEqual(run.observed, []);
547 assert.isFalse(run.fetchedRefs.some((ref) => ref.startsWith("-")));
548 const fs = yield* FileSystem.FileSystem;
549 assert.isFalse(yield* fs.exists(`${run.repositories.root}/injected`));
550 }),
551 ),
552 );
553
233 it.effect("briefs the change against its own base in a checkout it then removes", () =>554 it.effect("briefs the change against its own base in a checkout it then removes", () =>
234 Effect.scoped(555 Effect.scoped(
235 Effect.gen(function* () {556 Effect.gen(function* () {
apps/server/src/pullRequest/PullRequestReviewGuideService.ts+73-14
...@@ -21,6 +21,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -21,6 +21,7 @@ import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";21import * as Layer from "effect/Layer";
22import * as Option from "effect/Option";22import * as Option from "effect/Option";
23import * as Ref from "effect/Ref";23import * as Ref from "effect/Ref";
24import * as Result from "effect/Result";
24import * as Semaphore from "effect/Semaphore";25import * as Semaphore from "effect/Semaphore";
2526
26import {27import {
...@@ -52,6 +53,8 @@ import { pullRequestSyncKey } from "./pullRequestSyncKey.ts";...@@ -52,6 +53,8 @@ import { pullRequestSyncKey } from "./pullRequestSyncKey.ts";
52import { reviewGuideInputFromPullRequest } from "./pullRequestReviewGuideInput.ts";53import { reviewGuideInputFromPullRequest } from "./pullRequestReviewGuideInput.ts";
53import { normalizeReviewGuide, parseReviewGuideChange } from "./pullRequestReviewGuideNormalize.ts";54import { normalizeReviewGuide, parseReviewGuideChange } from "./pullRequestReviewGuideNormalize.ts";
5455
56const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,64}$/;
57
55export class PullRequestReviewGuideService extends Context.Service<58export class PullRequestReviewGuideService extends Context.Service<
56 PullRequestReviewGuideService,59 PullRequestReviewGuideService,
57 {60 {
...@@ -173,9 +176,9 @@ const make = Effect.gen(function* () {...@@ -173,9 +176,9 @@ const make = Effect.gen(function* () {
173 });176 });
174177
175 /**178 /**
176 * A detached checkout of the head beside the project, removed when the run ends. Fetched by179 * A detached checkout of the head beside the project, removed when the run ends. Pinned to
177 * commit rather than by branch so a fork's head and a branch checked out elsewhere both work,180 * the reported commit, so a branch checked out elsewhere works and the checkout is exactly
178 * and so the checkout is exactly what the host reported.181 * what the host reported.
179 */182 */
180 const checkoutHead = Effect.fn("PullRequestReviewGuideService.checkoutHead")(function* (183 const checkoutHead = Effect.fn("PullRequestReviewGuideService.checkoutHead")(function* (
181 workspaceRoot: string,184 workspaceRoot: string,
...@@ -192,9 +195,7 @@ const make = Effect.gen(function* () {...@@ -192,9 +195,7 @@ const make = Effect.gen(function* () {
192 args: ["fetch", "--quiet", "--no-tags", remote, ...args],195 args: ["fetch", "--quiet", "--no-tags", remote, ...args],
193 timeoutMs: 5 * 60_000,196 timeoutMs: 5 * 60_000,
194 });197 });
195 // The base branch is what the change is measured from; the head commit is the change. Hosts198 // The base branch is what the change is measured from; the head commit is the change.
196 // that do not serve an unadvertised commit still serve their pull refs, which GitHub and
197 // Forgejo name the same way.
198 // The host names the base branch. A name git rejects, or one that would change the refspec199 // The host names the base branch. A name git rejects, or one that would change the refspec
199 // (a leading `+`, a `:`), is refused before it reaches `git fetch`.200 // (a leading `+`, a `:`), is refused before it reaches `git fetch`.
200 yield* git201 yield* git
...@@ -227,9 +228,63 @@ const make = Effect.gen(function* () {...@@ -227,9 +228,63 @@ const make = Effect.gen(function* () {
227 .pipe(Effect.ignore({ log: true })),228 .pipe(Effect.ignore({ log: true })),
228 );229 );
229 const baseTip = yield* git.resolveCommit({ cwd: workspaceRoot, revision: baseRef });230 const baseTip = yield* git.resolveCommit({ cwd: workspaceRoot, revision: baseRef });
230 const fetchHead = (number: number, sha: string) =>231 const fetchHead = Effect.fn("PullRequestReviewGuideService.fetchHead")(function* (
231 fetch([sha]).pipe(Effect.catch(() => fetch([`refs/pull/${number}/head`])));232 number: number,
232 yield* fetchHead(detail.number, headSha);233 reportedSha: string,
234 branch: string,
235 ) {
236 const sha = reportedSha.toLowerCase();
237 if (!COMMIT_HASH_PATTERN.test(sha)) {
238 return yield* new PullRequestOperationError({
239 operation: "generateReviewGuide",
240 detail: `The host reported a head for #${number} that is not a commit hash.`,
241 });
242 }
243 // Not redundant: rev-parse prefers a branch or tag named like a short hash over the commit.
244 const resolveHead = git.resolveCommit({ cwd: workspaceRoot, revision: sha }).pipe(
245 Effect.filterOrFail(
246 ({ commitSha }) => commitSha.startsWith(sha),
247 ({ commitSha }) =>
248 new PullRequestOperationError({
249 operation: "generateReviewGuide",
250 detail: `${sha} names ${commitSha} here, a branch or tag rather than the commit.`,
251 }),
252 ),
253 );
254 const fetchAndResolve = (ref: string) => fetch([ref]).pipe(Effect.andThen(resolveHead));
255 const branchRef = `refs/heads/${branch}`;
256 const checkBranch = git.execute({
257 operation: "PullRequestReviewGuideService.checkHeadBranch",
258 cwd: workspaceRoot,
259 args: ["check-ref-format", branchRef],
260 });
261 const sources = [
262 { source: "local objects", head: resolveHead },
263 ...(sha.length === 40 || sha.length === 64
264 ? [{ source: "the commit", head: fetchAndResolve(sha) }]
265 : []),
266 {
267 source: `branch ${branch}`,
268 head: checkBranch.pipe(Effect.andThen(fetchAndResolve(branchRef))),
269 },
270 { source: `refs/pull/${number}/head`, head: fetchAndResolve(`refs/pull/${number}/head`) },
271 {
272 source: `refs/merge-requests/${number}/head`,
273 head: fetchAndResolve(`refs/merge-requests/${number}/head`),
274 },
275 ];
276 const failures: Array<string> = [];
277 for (const { source, head } of sources) {
278 const result = yield* Effect.result(head);
279 if (Result.isSuccess(result)) return result.success;
280 failures.push(`${source}: ${result.failure.detail}`);
281 }
282 return yield* new PullRequestOperationError({
283 operation: "generateReviewGuide",
284 detail: `Could not find head ${sha} of #${number}. ${failures.join(" ")}`,
285 });
286 });
287 const resolvedHead = yield* fetchHead(detail.number, headSha, detail.headBranch);
233 // The other layers of a stack are context the model may read across a seam. A layer that288 // The other layers of a stack are context the model may read across a seam. A layer that
234 // cannot be fetched is still named in the prompt, without a head to read.289 // cannot be fetched is still named in the prompt, without a head to read.
235 const layers: ReadonlyArray<ReviewGuideStackLayer> = yield* Effect.forEach(290 const layers: ReadonlyArray<ReviewGuideStackLayer> = yield* Effect.forEach(
...@@ -239,10 +294,14 @@ const make = Effect.gen(function* () {...@@ -239,10 +294,14 @@ const make = Effect.gen(function* () {
239 if (layer.number === detail.number) return Effect.succeed({ ...described, headSha });294 if (layer.number === detail.number) return Effect.succeed({ ...described, headSha });
240 const sha = layer.headSha;295 const sha = layer.headSha;
241 if (sha === undefined) return Effect.succeed({ ...described, headSha: null });296 if (sha === undefined) return Effect.succeed({ ...described, headSha: null });
242 return fetchHead(layer.number, sha).pipe(297 return fetchHead(layer.number, sha, layer.headBranch).pipe(
243 Effect.andThen(git.resolveCommit({ cwd: workspaceRoot, revision: sha })),298 Effect.map(({ commitSha }) => ({ ...described, headSha: commitSha })),
244 Effect.as({ ...described, headSha: sha }),299 Effect.catch((error) =>
245 Effect.orElseSucceed(() => ({ ...described, headSha: null })),300 Effect.logInfo("Review guide could not read a stack layer's head", {
301 layer: layer.number,
302 detail: error.detail,
303 }).pipe(Effect.as({ ...described, headSha: null })),
304 ),
246 );305 );
247 },306 },
248 { concurrency: 4 },307 { concurrency: 4 },
...@@ -255,7 +314,7 @@ const make = Effect.gen(function* () {...@@ -255,7 +314,7 @@ const make = Effect.gen(function* () {
255 yield* git.execute({314 yield* git.execute({
256 operation: "PullRequestReviewGuideService.addWorktree",315 operation: "PullRequestReviewGuideService.addWorktree",
257 cwd: workspaceRoot,316 cwd: workspaceRoot,
258 args: ["worktree", "add", "--detach", "--quiet", path, headSha],317 args: ["worktree", "add", "--detach", "--quiet", path, resolvedHead.commitSha],
259 timeoutMs: 5 * 60_000,318 timeoutMs: 5 * 60_000,
260 });319 });
261 yield* Effect.addFinalizer(() =>320 yield* Effect.addFinalizer(() =>
packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts+5
...@@ -18,6 +18,11 @@ it.effect.each([...@@ -18,6 +18,11 @@ it.effect.each([
18 commit: { hash: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93" },18 commit: { hash: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93" },
19 headSha: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93",19 headSha: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93",
20 },20 },
21 {
22 metadata: "short source commit hash",
23 commit: { hash: "9f3a7c2d8b1e" },
24 headSha: "9f3a7c2d8b1e",
25 },
21 { metadata: "absent commit", commit: undefined, headSha: undefined },26 { metadata: "absent commit", commit: undefined, headSha: undefined },
22 { metadata: "null commit", commit: null, headSha: undefined },27 { metadata: "null commit", commit: null, headSha: undefined },
23 { metadata: "absent hash", commit: {}, headSha: undefined },28 { metadata: "absent hash", commit: {}, headSha: undefined },