Skip to content

Commit 15023e9

Browse files
authored
Merge branch 'main' into release-please--branches--main--groups--engine-and-dependents
2 parents a693f5b + fc6d689 commit 15023e9

8 files changed

Lines changed: 424 additions & 43 deletions

.github/workflows/mcp-release-please.yml

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,24 @@ jobs:
101101
# lock file"). Patches it directly on whichever release branch(es) release-please just
102102
# created/updated, using the same branch naming convention its own commits already rely on.
103103
#
104-
# The engine branch also needs packages/loopover-miner/expected-engine.version bumped to match
105-
# packages/loopover-engine/package.json's new version: release-please's engine component only
106-
# ever touches files under packages/loopover-engine/**, so it can never update that cross-package
107-
# pin itself -- scripts/check-engine-parity.ts's checkMinerEngineVersionPinSync would otherwise
108-
# fail on every single engine release PR (confirmed live on the engine-v3.1.0 release PR, #5807).
104+
# Also needs packages/loopover-miner/expected-engine.version bumped to match
105+
# packages/loopover-engine/package.json's new version whenever a branch bumps engine:
106+
# release-please's engine component only ever touches files under packages/loopover-engine/**,
107+
# so it can never update that cross-package pin itself -- scripts/check-engine-parity.ts's
108+
# checkMinerEngineVersionPinSync would otherwise fail on every release that bumps engine
109+
# (confirmed live on the engine-v3.1.0 release PR, #5807).
110+
#
111+
# Branches to check are content-derived (git ls-remote --heads matching the release-please
112+
# branch prefix), not a hardcoded per-component list: the linked-versions plugin (grouping
113+
# engine with its dependents, release-please-config.json) puts mcp/engine/miner on ONE shared
114+
# `--groups--<groupName>` branch instead of three separate `--components--<name>` branches, so
115+
# a hardcoded `for component in mcp engine miner ui-kit` loop checking only the old
116+
# `--components--` naming would silently never find/sync that branch at all (confirmed live:
117+
# PR #7127's engine-and-dependents group branch left expected-engine.version stale, failing
118+
# engine-parity:drift-check and two engine-version-display tests). Similarly, the
119+
# expected-engine.version sync itself is now driven by comparing the checked-out branch's own
120+
# committed pin against its own package.json (not an `if [ component = engine ]` check), so it
121+
# fires correctly whether engine's bump landed via a solo branch or a shared group branch.
109122
- name: Sync package-lock.json and engine-version pin on any release branch
110123
env:
111124
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
@@ -117,14 +130,13 @@ jobs:
117130
cp scripts/sync-release-lockfile-versions.mjs "$trusted_sync_script"
118131
git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git"
119132
gh auth setup-git
120-
for component in mcp engine miner ui-kit; do
121-
branch="release-please--branches--main--components--${component}"
122-
if ! git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
123-
echo "No release branch for $component, skipping."
124-
continue
125-
fi
133+
branches="$(git ls-remote --heads origin 'release-please--branches--main--*' | awk '{print $2}' | sed 's#refs/heads/##')"
134+
if [ -z "$branches" ]; then
135+
echo "No release-please branches found, skipping."
136+
fi
137+
for branch in $branches; do
126138
git fetch origin "$branch"
127-
git checkout -B "sync-check-${component}" "origin/$branch"
139+
git checkout -B "sync-check-$(printf '%s' "$branch" | tr '/' '-')" "origin/$branch"
128140
node "$trusted_sync_script" packages/loopover-mcp packages/loopover-engine packages/loopover-miner packages/loopover-ui-kit
129141
if git diff --quiet package-lock.json; then
130142
echo "package-lock.json already in sync on $branch."
@@ -133,16 +145,14 @@ jobs:
133145
git commit -m "chore(release): sync package-lock.json"
134146
git push origin "HEAD:$branch"
135147
fi
136-
if [ "$component" = "engine" ]; then
137-
engine_version="$(node -p "require('./packages/loopover-engine/package.json').version")"
138-
printf '%s\n' "$engine_version" > packages/loopover-miner/expected-engine.version
139-
if git diff --quiet packages/loopover-miner/expected-engine.version; then
140-
echo "expected-engine.version already in sync on $branch."
141-
else
142-
git add packages/loopover-miner/expected-engine.version
143-
git commit -m "chore(release): sync miner engine-version pin"
144-
git push origin "HEAD:$branch"
145-
fi
148+
engine_version="$(node -p "require('./packages/loopover-engine/package.json').version")"
149+
printf '%s\n' "$engine_version" > packages/loopover-miner/expected-engine.version
150+
if git diff --quiet packages/loopover-miner/expected-engine.version; then
151+
echo "expected-engine.version already in sync on $branch."
152+
else
153+
git add packages/loopover-miner/expected-engine.version
154+
git commit -m "chore(release): sync miner engine-version pin"
155+
git push origin "HEAD:$branch"
146156
fi
147157
done
148158

packages/loopover-miner/lib/contribution-profile-extract.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@ export function extractContributionProfile(
1313
apiBaseUrl?: string;
1414
/** ISO timestamp for the profile's generatedAt; defaults to now. Injected so tests stay deterministic. */
1515
generatedAt?: string;
16+
/** Sleep seam for the transient-5xx/rate-limit retry (via fetchWithRetry). Injected so tests use no real timers. */
17+
sleepFn?: (ms: number) => Promise<unknown>;
1618
},
1719
): Promise<ContributionProfile>;

packages/loopover-miner/lib/contribution-profile-extract.js

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
emptyContributionProfile,
1010
weakestConfidence,
1111
} from "./contribution-profile.js";
12+
import { fetchWithRetry } from "./http-retry.js";
1213

1314
const DEFAULT_API_BASE_URL = "https://api.github.com";
1415
const GITHUB_API_VERSION = "2022-11-28";
@@ -75,15 +76,20 @@ function githubHeaders(githubToken) {
7576
return headers;
7677
}
7778

78-
/** Bounded, never-throwing JSON GET. Returns null on any transport/HTTP/parse failure. */
79-
async function getJson(url, headers, fetchImpl) {
79+
/** Bounded, never-throwing JSON GET. Rides out a transient GitHub 5xx or rate-limit response (429 / secondary-403)
80+
* via `fetchWithRetry` — the same discipline opportunity-fanout.js's sibling `githubGetJson` already uses — before
81+
* falling back to its fail-open contract: returns null on a non-retryable/exhausted HTTP, transport, or parse
82+
* failure. `timeoutMs` gives each attempt its own fresh `AbortSignal.timeout` (preserving the per-request bound),
83+
* and `sleepFn` is the injectable no-real-timers seam every other `fetchWithRetry` call site exposes. */
84+
async function getJson(url, headers, fetchImpl, sleepFn) {
8085
let response;
8186
try {
82-
response = await fetchImpl(url, {
83-
method: "GET",
84-
headers,
85-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
86-
});
87+
response = await fetchWithRetry(
88+
fetchImpl,
89+
url,
90+
{ method: "GET", headers },
91+
{ sleepFn, timeoutMs: REQUEST_TIMEOUT_MS },
92+
);
8793
} catch {
8894
return null;
8995
}
@@ -146,12 +152,13 @@ function decodeContents(payload) {
146152
}
147153

148154
/** Fetch CONTRIBUTING.md, probing the repo root then `.github/` (#6794: 6/10 at root, 2/10 under `.github/`). */
149-
async function fetchContributing(base, target, headers, fetchImpl) {
155+
async function fetchContributing(base, target, headers, fetchImpl, sleepFn) {
150156
for (const path of ["CONTRIBUTING.md", ".github/CONTRIBUTING.md"]) {
151157
const payload = await getJson(
152158
`${base}/repos/${target.owner}/${target.repo}/contents/${path}`,
153159
headers,
154160
fetchImpl,
161+
sleepFn,
155162
);
156163
const text = decodeContents(payload);
157164
if (text !== null) return text;
@@ -183,7 +190,7 @@ function extractPrBody(contributing) {
183190
* Extract a best-effort ContributionProfile for a repo from what it actually publishes.
184191
*
185192
* @param {string} repoFullName owner/repo
186-
* @param {{ fetchImpl?: typeof fetch, githubToken?: string, apiBaseUrl?: string, generatedAt?: string }} [options]
193+
* @param {{ fetchImpl?: typeof fetch, githubToken?: string, apiBaseUrl?: string, generatedAt?: string, sleepFn?: (ms: number) => Promise<unknown> }} [options]
187194
* @returns {Promise<import("./contribution-profile.js").ContributionProfile>}
188195
*/
189196
export async function extractContributionProfile(repoFullName, options = {}) {
@@ -209,17 +216,20 @@ export async function extractContributionProfile(repoFullName, options = {}) {
209216
options.githubToken ?? process.env.GITHUB_TOKEN,
210217
);
211218

219+
const sleepFn = options.sleepFn;
212220
const labelsPayload = await getJson(
213221
`${base}/repos/${target.owner}/${target.repo}/labels?per_page=100`,
214222
headers,
215223
fetchImpl,
224+
sleepFn,
216225
);
217226
const labels = Array.isArray(labelsPayload) ? labelsPayload : [];
218227
const contributing = await fetchContributing(
219228
base,
220229
target,
221230
headers,
222231
fetchImpl,
232+
sleepFn,
223233
);
224234

225235
const eligibilityLabels = classifyLabels(

packages/loopover-miner/lib/submission-freshness-check.d.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,14 @@ export type SubmissionFreshnessDeps = {
2929

3030
export type SubmissionFreshnessResult = { fresh: true } | { fresh: false; reason: FreshnessAbortReason };
3131

32-
export function checkSubmissionFreshness(candidate: SubmissionFreshnessCandidate, deps: SubmissionFreshnessDeps): Promise<SubmissionFreshnessResult>;
32+
export type SubmissionFreshnessRetryOptions = {
33+
maxAttempts?: number;
34+
sleepFn?: (ms: number) => Promise<unknown>;
35+
backoffMs?: (attempt: number) => number;
36+
};
37+
38+
export function checkSubmissionFreshness(
39+
candidate: SubmissionFreshnessCandidate,
40+
deps: SubmissionFreshnessDeps,
41+
options?: SubmissionFreshnessRetryOptions,
42+
): Promise<SubmissionFreshnessResult>;

packages/loopover-miner/lib/submission-freshness-check.js

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,27 @@
1515
// FAIL CLOSED: an unreachable/failed live-state fetch is treated as stale (aborts), never as "no evidence of
1616
// staleness, so proceed" -- mirrors this package's fail-closed convention elsewhere (harness-submission-
1717
// trigger.js's predicted_gate_unavailable/slop_assessment_unavailable, iterate-loop.ts's ambiguous-on-error).
18+
// That fail-closed OUTCOME is unchanged; a single transient blip just gets a bounded retry-with-backoff to
19+
// resolve itself FIRST (#7089) -- the same window claim-conflict-resolver.js's resolveClaimConflict (#6058)
20+
// already gives its own call to this identical fetchLiveIssueSnapshot, reusing http-retry.js's shared backoff.
21+
// Aborting here discards a fully-completed create/iterate loop's local work, so riding out a brief 5xx /
22+
// GraphQL-index propagation lag before failing closed matters more here than in the post-submission case.
1823
//
1924
// NOT a rejection outcome: staleness is caught BEFORE any PR exists, so it is not the same lifecycle event as
2025
// rejection-state-machine.js's DISENGAGED_OUTCOME (which handles an EXISTING PR a maintainer closed). "No PR,
2126
// no noisy failure" here just means: return a quiet not-fresh result, same shape as any other blocked gate
2227
// decision in this package -- never throw, never surface anything to the target repo.
2328

29+
import { defaultRetryBackoffMs } from "./http-retry.js";
30+
2431
export const SUBMISSION_FRESHNESS_ABORT_EVENT = "submission_freshness_abort";
2532

33+
// Bounded retry for the pre-submission live-state fetch (#7089), mirroring claim-conflict-resolver.js's
34+
// resolveClaimConflict (#6058): a few attempts with exponential backoff let a transient GitHub blur (a brief
35+
// 5xx, or GraphQL-index propagation lag) resolve itself before we fail closed, without an unbounded loop.
36+
const DEFAULT_SNAPSHOT_MAX_ATTEMPTS = 3;
37+
const defaultSnapshotSleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
38+
2639
/**
2740
* Evaluate whether a submission candidate's live repo state is still fresh enough to proceed toward open_pr.
2841
* Checks the miner's own claim-ledger status first (local, free) before spending a network round-trip on the
@@ -34,8 +47,14 @@ export const SUBMISSION_FRESHNESS_ABORT_EVENT = "submission_freshness_abort";
3447
* fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise<{ state: "open"|"closed", referencingPrs: Array<{ number: number, state: "open"|"closed"|"merged", authorLogin: string }> } | null>,
3548
* eventLedger: { appendEvent(event: { type: string, repoFullName?: string, payload: Record<string, unknown> }): unknown },
3649
* }} deps
50+
* @param {{ maxAttempts?: number, sleepFn?: (ms: number) => Promise<unknown>, backoffMs?: (attempt: number) => number }} [options]
51+
* Bounded retry for the live-state snapshot fetch (#7089): up to `maxAttempts` (default 3) attempts with
52+
* `backoffMs(attempt)` backoff between them, returning as soon as a real (non-null, well-formed) snapshot is
53+
* obtained. Optional -- every existing caller works unchanged. Pure over the injected `sleepFn`/`backoffMs`
54+
* -- no real timers in tests. Only the fetch itself is retried; a well-formed snapshot's own signals
55+
* (issue_closed / already_addressed) are decided once, never retried.
3756
*/
38-
export async function checkSubmissionFreshness(candidate, deps) {
57+
export async function checkSubmissionFreshness(candidate, deps, options = {}) {
3958
if (!candidate || typeof candidate !== "object") throw new Error("invalid_freshness_candidate");
4059
const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : "";
4160
if (!repoFullName) throw new Error("invalid_repo_full_name");
@@ -49,18 +68,34 @@ export async function checkSubmissionFreshness(candidate, deps) {
4968
if (typeof fetchLiveIssueSnapshot !== "function") throw new Error("invalid_live_state_fetcher");
5069
if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger");
5170

71+
const maxAttempts =
72+
Number.isFinite(options.maxAttempts) && options.maxAttempts >= 1 ? Math.floor(options.maxAttempts) : DEFAULT_SNAPSHOT_MAX_ATTEMPTS;
73+
const sleepFn = typeof options.sleepFn === "function" ? options.sleepFn : defaultSnapshotSleep;
74+
const backoffMs = typeof options.backoffMs === "function" ? options.backoffMs : defaultRetryBackoffMs;
75+
5276
const claim = claimLedger.listClaims({ repoFullName }).find((c) => c.issueNumber === candidate.issueNumber);
5377
if (!claim || claim.status !== "active") {
5478
return abort(eventLedger, repoFullName, candidate.issueNumber, "claim_superseded");
5579
}
5680

57-
let snapshot;
58-
try {
59-
snapshot = await fetchLiveIssueSnapshot(repoFullName, candidate.issueNumber);
60-
} catch {
61-
snapshot = null;
81+
let snapshot = null;
82+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
83+
let current;
84+
try {
85+
current = await fetchLiveIssueSnapshot(repoFullName, candidate.issueNumber);
86+
} catch {
87+
current = null;
88+
}
89+
if (current && typeof current === "object") {
90+
// A real, well-formed snapshot resolves the transient window: stop retrying and decide on it now.
91+
snapshot = current;
92+
break;
93+
}
94+
// Back off before the next attempt (transient 5xx / index-propagation lag); never after the last one.
95+
if (attempt < maxAttempts) await sleepFn(backoffMs(attempt));
6296
}
63-
if (!snapshot || typeof snapshot !== "object") {
97+
if (!snapshot) {
98+
// Retry budget exhausted with no usable snapshot -- fail closed exactly as before (#7089 only widens the window).
6499
return abort(eventLedger, repoFullName, candidate.issueNumber, "live_state_unavailable");
65100
}
66101

0 commit comments

Comments
 (0)