Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,8 @@ Authentication comes from `GH_TOKEN`, then `GITHUB_TOKEN`, then `gh auth token`.
Without `--since`, the point is the current head's push time, so an existing
review on that head is included. GitHub lists a push seconds to minutes after
it lands, so for the current head the wait rereads the listing every 15 seconds
until the push appears, warning once. Any other SHA must be a listed pushed tip;
until the push appears, warning once. Any other SHA must be a tip the listing
shows a push, or a merge into the branch such as **Update branch**, moving it to;
otherwise pass an ISO timestamp instead. Forks use the head
repository's activity. Deleted head repositories require a timestamp.
The initial PR commit listing must match the resolved head; a racing push or
Expand All @@ -344,7 +345,8 @@ commit), type, actor, and URL. The last line adds a resume cursor. Pass its
**last field** as `--since` on the next call. Cursors preserve commit membership
and events sharing a timestamp; treat them as opaque, and use them only for the
same PR. Explicit timestamps include events at that instant. Historical plain
pushes come from repository activity; pushes observed while waiting come from new
pushes come from repository activity, and a historical merge into the branch
prints as its new commit; pushes observed while waiting come from new
commit SHAs, regardless of git author dates. Force pushes come from the PR
timeline, excluding the one that created the starting head. A merge through
the queue prints `removed_from_merge_queue` about a second before `merged`, so a
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ earlier "Later work" milestone became m2.
- [Quest log theme](/quest/m1/theme/README.md) - an MMO-style identity, copy, board, landing page, and CLI output for the demo and launch
- [Launch material](/quest/m1/launch.md) - README, quickstart, demo, comparison, and launch-post drafts
- [Workers in a 5-minute cache](/quest/m1/worker-cache.md) - re-plan #276's worker/coordinator split and gate tickets, after confirming the cache premise
- [quest wait after a merge](/quest/m1/wait-merge-heads.md) - `quest wait` resolves a head that reached the branch by a merge into it
- [One PR's collection failure can't stall its fixer job](/quest/m1/fixer-isolate-failure.md) - errors stay on their PR, and a PR that keeps failing is set aside
- [Retire Decisions needed](/quest/m1/decisions-needed.md) - only `## Waiting` hands a draft back, once no open draft uses the old heading
- [Review harness check](/quest/m1/review-harness.md) - recurring each milestone: newest pinned review models, and the live sandbox test passes
26 changes: 0 additions & 26 deletions quest/m1/wait-merge-heads.md

This file was deleted.

2 changes: 1 addition & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"Issue-comment edits print as edited, including updates to older bot status comments. The account's own edits are ignored.",
)
.argument("<pr>", "GitHub PR URL, or a PR number in origin")
.option("--since <point>", "ISO timestamp, pushed-tip SHA, or resume cursor (default: current head push time)")
.option("--since <point>", "ISO timestamp, branch-tip SHA, or resume cursor (default: current head push time)")
.option("--timeout <seconds>", "Maximum wait in seconds", positiveSeconds)
.action((value: string, options: { since?: string; timeout?: number }) => {
const pr = pullRequest(value, root());
Expand Down
2 changes: 1 addition & 1 deletion src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export async function pullRequestStatus(pr: PullRequest, deps: GitHubDependencie
}

/** Repository activity types that move a branch to a new head, including merges into it. */
const BRANCH_UPDATES = ["push", "force_push", "branch_creation", "pr_merge", "merge_queue_merge"];
export const BRANCH_UPDATES = ["push", "force_push", "branch_creation", "pr_merge", "merge_queue_merge"];

/**
* The head a PR node's auto-merge request was enabled on, or null when that cannot be told. GitHub
Expand Down
24 changes: 18 additions & 6 deletions src/cli/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
string,
} from "./github";
import { LAND_CHECK } from "./land";
import { classifyCheck, enabledHead } from "./status";
import { BRANCH_UPDATES, classifyCheck, enabledHead } from "./status";

const POLL = 5 * 60_000;
/**
Expand All @@ -28,6 +28,9 @@ const FINAL_POLL = 30_000;
const PAGE = 100;
/** How often setup rereads repository activity while GitHub has yet to list the head's push. */
const LISTING_POLL = 15_000;

/** Repository activity types a person's push makes; merges into the branch also move it (`BRANCH_UPDATES`). */
const PUSHES = ["push", "force_push", "branch_creation"];
const SHA = /^[a-f\d]{7,40}$/i;
/** The timeline records a force push a few seconds after repository activity (1-2s observed). */
const TIMELINE_LAG = 60_000;
Expand Down Expand Up @@ -208,7 +211,9 @@ async function watch(
const point = options.since ?? currentSha;
const shaPoint = SHA.test(point);
const time = shaPoint ? undefined : timestamp(point);
const isPoint = (row: ObjectValue) => string(row.after).startsWith(point.toLowerCase());
// A merge into the branch, such as GitHub's Update branch, can also have moved the head to the point.
const moved = (row: ObjectValue) => BRANCH_UPDATES.includes(string(row.activity_type));
const isPoint = (row: ObjectValue) => moved(row) && string(row.after).startsWith(point.toLowerCase());
let activities: ObjectValue[] = [];
if (head.repo !== null) {
const headRepo = object(head.repo);
Expand Down Expand Up @@ -236,10 +241,8 @@ async function watch(
} else if (shaPoint) {
throw new Error("head repository was deleted; pass a timestamp with --since");
}
const pushed = activities.filter((row) =>
["push", "force_push", "branch_creation"].includes(string(row.activity_type)),
);
const match = shaPoint ? pushed.find(isPoint) : undefined;
const pushed = activities.filter((row) => PUSHES.includes(string(row.activity_type)));
const match = shaPoint ? activities.find(isPoint) : undefined;
if (shaPoint && !match) throw new Error(`cannot resolve push time for ${point}; pass a timestamp with --since`);
const since = time ?? timestamp(string(match!.timestamp));
// An explicit timestamp includes its own instant; a SHA point excludes its own push.
Expand Down Expand Up @@ -267,6 +270,15 @@ async function watch(
// A push racing setup is reported once, as a push, not again as a new commit.
pushedTips = plain.map((row) => string(row.after));
for (const sha of pushedTips) baselineCommits.add(sha);
// A merge into the branch after the point is not a push, so it is reported as its new commit.
for (const row of activities)
if (
moved(row) &&
!PUSHES.includes(string(row.activity_type)) &&
after(row) &&
!pushedTips.includes(string(row.after))
)
baselineCommits.delete(string(row.after));
historical = plain.map((row) => ({
point: string(row.timestamp),
time: timestamp(string(row.timestamp)),
Expand Down
47 changes: 47 additions & 0 deletions test/wait.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,53 @@ test("unresolvable pushed SHA fails with a timestamp recommendation", async () =
await expect(waitForActivity(pr, { since: B }, fixture.deps)).rejects.toThrow("pass a timestamp with --since");
});

test("a SHA a merge into the branch made resolves to the merge's time, and the merge never prints as a push", async () => {
for (const type of ["pr_merge", "merge_queue_merge"]) {
const fixture = setup(
[
pull(),
response([pushed(A, POINT, type), pushed(B, "2026-10-07T11:00:00Z")]),
response([
committed(B),
committed(A),
reviewed(1, "2026-10-07T11:30:00Z"),
reviewed(2, "2026-10-07T12:05:00Z"),
]),
],
[B, A],
);
const lines = await waitForActivity(pr, { since: A }, fixture.deps);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain("pullrequestreview-2 q1.");
}
});

test("the current head a merge into the branch made is the default point", async () => {
const fixture = setup([pull(), response([pushed(A, POINT, "pr_merge")]), response([committed(A), reviewed()])]);
const lines = await waitForActivity(pr, {}, fixture.deps);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain("pullrequestreview-1 q1.");
});

test("a merge into the branch after the point is reported as its new commit, not as a push", async () => {
const fixture = setup(
[
pull(undefined, B),
response([pushed(B, "2026-10-07T12:05:00Z", "pr_merge"), pushed()]),
response([committed(A), committed(B)]),
],
[A, B],
);
const lines = await waitForActivity(pr, { since: A }, fixture.deps);
expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(new RegExp(`^${B} committed `));
});

test("a branch update that is not a move never resolves the point", async () => {
const fixture = setup([pull(), response([pushed(), pushed(B, POINT, "branch_deletion")])]);
await expect(waitForActivity(pr, { since: B }, fixture.deps)).rejects.toThrow("cannot resolve push time");
});

test("SHA lookup paginates repository activity", async () => {
const fixture = setup(
[
Expand Down
Loading