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
131 changes: 131 additions & 0 deletions .github/scripts/__tests__/sync_dependency_campaign.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,137 @@ test('mergeCampaignState counts observed handoffs from the current payload only'
assert.equal(state.delivery_handoffs.length, 2);
});

test('three consecutive identical exceptions create one claim generation and one agent handoff', () => {
const item = buildQueueItem({
repoFullName: 'stranske/Ready',
pr: {
number: 42,
title: 'sync workflows',
html_url: 'https://github.com/stranske/Ready/pull/42',
head: { sha: 'abc123', ref: 'sync/workflows-deadbeef' },
base: { ref: 'main' },
},
threads: [{ id: 'thread-1', isResolved: false, isOutdated: false, comments_count: 1 }],
classification: 'sync',
now: '2026-08-02T00:00:00Z',
defaultOwner: 'stranske',
});

let state = {};
const observedAts = [
'2026-08-02T00:00:00Z',
'2026-08-02T01:00:00Z',
'2026-08-02T02:00:00Z',
];
for (const observedAt of observedAts) {
state = mergeCampaignState(state, [{ ...item }], observedAt);
}

const matching = state.items.filter((entry) => entry.id === item.id);
assert.equal(matching.length, 1);
assert.equal(matching[0].attempts, 0);
assert.equal(matching[0].status, 'needs-local-codex');
assert.equal(state.stats.items_claimable_local_codex, 1);
assert.equal(state.stats.exception_lifecycle.new, 0);
assert.equal(state.stats.exception_lifecycle.unchanged, 1);
assert.equal(state.stats.exception_lifecycle.resolved, 0);
assert.equal(state.stats.exception_lifecycle.re_opened, 0);
assert.match(
formatCampaignBody(state),
/Exception lifecycle \(new\/unchanged\/resolved\/re-opened\): 0\/1\/0\/0/,
);
});

test('deliberate-break: timestamp-only identity creates duplicate handoffs; real fingerprint does not', () => {
const base = buildQueueItem({
repoFullName: 'stranske/Ready',
pr: {
number: 77,
title: 'sync workflows',
html_url: 'https://github.com/stranske/Ready/pull/77',
head: { sha: 'fff111', ref: 'sync/workflows-cafe' },
base: { ref: 'main' },
},
threads: [{ id: 'thread-a', isResolved: false, isOutdated: false, comments_count: 2 }],
classification: 'sync',
now: '2026-08-02T00:00:00Z',
defaultOwner: 'stranske',
});

// Broken identity wrongly folds updated_at into the fingerprint/id.
const brokenDiscoveries = [
{ ...base, id: `${base.id}:2026-08-02T00:00:00Z` },
{ ...base, id: `${base.id}:2026-08-02T01:00:00Z` },
{ ...base, id: `${base.id}:2026-08-02T02:00:00Z` },
];
let broken = {};
for (let i = 0; i < brokenDiscoveries.length; i += 1) {
broken = mergeCampaignState(broken, [brokenDiscoveries[i]], `2026-08-02T0${i}:00:00Z`);
}
assert.equal(
broken.items.filter((entry) => entry.id.startsWith(`${base.id}:`)).length,
3,
'timestamp-in-id break must surface as duplicate handoffs',
);
assert.equal(broken.stats.exception_lifecycle.new, 1);

// Correct fingerprint ignores wall-clock updated_at.
let good = {};
for (const observedAt of ['2026-08-02T00:00:00Z', '2026-08-02T01:00:00Z', '2026-08-02T02:00:00Z']) {
good = mergeCampaignState(good, [{ ...base }], observedAt);
}
assert.equal(good.items.filter((entry) => entry.id === base.id).length, 1);
assert.equal(good.stats.items_claimable_local_codex, 1);
assert.equal(good.stats.exception_lifecycle.unchanged, 1);
});

test('exception lifecycle counts new, resolved, and re-opened transitions', () => {
const first = buildQueueItem({
repoFullName: 'stranske/Ready',
pr: {
number: 9,
title: 'sync',
html_url: 'https://github.com/stranske/Ready/pull/9',
head: { sha: 'aaa', ref: 'sync/workflows-aaa' },
base: { ref: 'main' },
},
threads: [{ id: 't1', isResolved: false, isOutdated: false, comments_count: 1 }],
classification: 'sync',
now: '2026-08-02T00:00:00Z',
defaultOwner: 'stranske',
});
const second = buildQueueItem({
repoFullName: 'stranske/Ready',
pr: {
number: 10,
title: 'sync',
html_url: 'https://github.com/stranske/Ready/pull/10',
head: { sha: 'bbb', ref: 'sync/workflows-bbb' },
base: { ref: 'main' },
},
threads: [{ id: 't2', isResolved: false, isOutdated: false, comments_count: 1 }],
classification: 'sync',
now: '2026-08-02T00:00:00Z',
defaultOwner: 'stranske',
});

const opened = mergeCampaignState({}, [first, second], '2026-08-02T00:00:00Z');
assert.deepEqual(opened.stats.exception_lifecycle, {
new: 2, unchanged: 0, resolved: 0, re_opened: 0,
});

const resolvedOne = mergeCampaignState(opened, [first], '2026-08-02T01:00:00Z');
assert.deepEqual(resolvedOne.stats.exception_lifecycle, {
new: 0, unchanged: 1, resolved: 1, re_opened: 0,
});

const reopened = mergeCampaignState(resolvedOne, [first, second], '2026-08-02T02:00:00Z');
assert.deepEqual(reopened.stats.exception_lifecycle, {
new: 0, unchanged: 1, resolved: 0, re_opened: 1,
});
assert.equal(reopened.items.find((item) => item.id === second.id).status, 'needs-local-codex');
});

test('formats and parses campaign marker', () => {
const state = {
schema: 'sync-dependabot-campaign/v1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,54 @@ test('generated delivery classification gives sync and dev-tool lanes identical
assert.equal(classifyGeneratedPr({ pr: candidate, activeReviewThreadCount: 1, now: '2026-08-01T00:00:00Z' }).disposition, 'review-blocked');
assert.equal(classifyGeneratedPr({ pr: candidate, activeReviewThreadCount: -1, now: '2026-08-01T00:00:00Z' }).next_command, 'retry-review-thread-query');
assert.equal(classifyGeneratedPr({ pr: candidate, checkState: { status: 'checks_failed' }, now: '2026-08-01T00:00:00Z' }).disposition, 'repo-local-failure');
assert.equal(
classifyGeneratedPr({
pr: candidate,
checkState: { status: 'checks_failed', failure_scope: 'shared-source' },
now: '2026-08-01T00:00:00Z',
}).disposition,
'shared-source-failure',
);
}

const sharedHealth = classifySyncPrChecks({
checkRuns: [checkRun({ name: 'Health 40 Sweep', conclusion: 'failure' })],
requiredContexts: ['Health 40 Sweep'],
});
assert.equal(sharedHealth.status, 'checks_failed');
assert.equal(sharedHealth.failure_scope, 'shared-source');
assert.equal(
classifyGeneratedPr({ pr: sync, checkState: sharedHealth, now: '2026-08-01T00:00:00Z' }).disposition,
'shared-source-failure',
);

const aggregateGate = classifySyncPrChecks({
checkRuns: [checkRun({ name: 'Gate / gate', conclusion: 'failure' })],
requiredContexts: ['Gate / gate'],
});
assert.equal(aggregateGate.failure_scope, 'repo-local');

const localWorkflowCheck = classifySyncPrChecks({
checkRuns: [checkRun({ name: 'workflow integration tests', conclusion: 'failure' })],
requiredContexts: ['workflow integration tests'],
});
assert.equal(localWorkflowCheck.failure_scope, 'repo-local');

const explicitlyShared = classifySyncPrChecks({
checkRuns: [{ ...checkRun({ name: 'Gate / gate', conclusion: 'failure' }), shared_source: true }],
requiredContexts: ['Gate / gate'],
});
assert.equal(explicitlyShared.failure_scope, 'shared-source');

const localFail = classifySyncPrChecks({
checkRuns: [checkRun({ name: 'unit-tests', conclusion: 'failure' })],
requiredContexts: ['unit-tests'],
});
assert.equal(localFail.failure_scope, 'repo-local');
assert.equal(
classifyGeneratedPr({ pr: sync, checkState: localFail, now: '2026-08-01T00:00:00Z' }).disposition,
'repo-local-failure',
);
});

test('selectActiveSyncPr honors target hash instead of newest PR', () => {
Expand Down
38 changes: 36 additions & 2 deletions .github/scripts/sync_dependency_campaign.js
Original file line number Diff line number Diff line change
Expand Up @@ -412,11 +412,18 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue,
);
const failedRepos = new Set(parseCsv(options.failedRepos));
const nextItems = [];
const exceptionLifecycle = {
new: 0,
unchanged: 0,
resolved: 0,
re_opened: 0,
};

for (const discovered of discoveredItems) {
const previous = previousById.get(discovered.id);
const sourceFixedCandidate = sourceFixedCandidateFor(discovered, finishedBySourceReviewKey);
if (!previous) {
exceptionLifecycle.new += 1;
const item = {
...discovered,
status: discovered.status || 'needs-local-codex',
Expand All @@ -432,8 +439,17 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue,
});
continue;
}
if (ACTIVE_STATUSES.has(previous.status)) {
exceptionLifecycle.unchanged += 1;
} else {
exceptionLifecycle.re_opened += 1;
Comment thread
stranske marked this conversation as resolved.
}

let status = previous.status || 'needs-local-codex';
// A rediscovered inactive exception is actionable again. Retaining `stale`
// would report it as re-opened while silently keeping it out of the claim queue.
let status = ACTIVE_STATUSES.has(previous.status)
? previous.status
: 'needs-local-codex';
let lease = previous.lease || null;
const attempts = Number(previous.attempts || 0);

Expand Down Expand Up @@ -461,7 +477,7 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue,
attempts,
lease: status === 'local-codex-claimed' ? lease : null,
result: previous.result || null,
source_fixed_candidate: sourceFixedCandidate || previous.source_fixed_candidate || null,
source_fixed_candidate: sourceFixedCandidate || null,
};
item.status = localCodexClaimableStatus(item, item.status);
nextItems.push({
Expand All @@ -480,6 +496,7 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue,
updated_at: now,
});
} else if (ACTIVE_STATUSES.has(previous.status)) {
exceptionLifecycle.resolved += 1;
nextItems.push({
...previous,
status: 'stale',
Expand Down Expand Up @@ -513,6 +530,8 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue,
...buildStats(items, discoveredItems, options),
// Count only this run's payload observations, not the retained durable total.
delivery_handoffs_observed: observedIncomingHandoffs.length,
// Per-run exception lifecycle (new / unchanged / resolved / re-opened).
exception_lifecycle: exceptionLifecycle,
},
source_review_history: sourceReviewHistory,
delivery_handoffs: deliveryHandoffs,
Expand Down Expand Up @@ -1014,6 +1033,11 @@ function formatCampaignBody(state) {
`- Claimable local Codex items: ${stats.items_claimable_local_codex || 0}`,
`- Source-fixed candidates: ${stats.items_source_fixed_candidates || 0}`,
`- Superseded sync candidates: ${stats.items_superseded_sync_candidates || 0}`,
`- Exception lifecycle (new/unchanged/resolved/re-opened): ` +
`${Number(stats.exception_lifecycle?.new || 0)}/` +
`${Number(stats.exception_lifecycle?.unchanged || 0)}/` +
`${Number(stats.exception_lifecycle?.resolved || 0)}/` +
`${Number(stats.exception_lifecycle?.re_opened || 0)}`,
`- Source sync states: ${formatSourceSyncStatusCounts(stats.source_sync_status_counts)}`,
`- Finished local results without published source changes: ${stats.items_unpublished_source_results || 0}`,
`- Claimed local Codex items: ${claims.count || 0}`,
Expand Down Expand Up @@ -1185,6 +1209,11 @@ function formatCompactCampaignBody(state) {
`- Claimable local Codex items: ${stats.items_claimable_local_codex || 0}`,
`- Source-fixed candidates: ${stats.items_source_fixed_candidates || 0}`,
`- Superseded sync candidates: ${stats.items_superseded_sync_candidates || 0}`,
`- Exception lifecycle (new/unchanged/resolved/re-opened): ` +
`${Number(stats.exception_lifecycle?.new || 0)}/` +
`${Number(stats.exception_lifecycle?.unchanged || 0)}/` +
`${Number(stats.exception_lifecycle?.resolved || 0)}/` +
`${Number(stats.exception_lifecycle?.re_opened || 0)}`,
`- Source sync states: ${formatSourceSyncStatusCounts(stats.source_sync_status_counts)}`,
`- Finished local results without published source changes: ${stats.items_unpublished_source_results || 0}`,
`- Claimed local Codex items: ${claims.count || 0}`,
Expand Down Expand Up @@ -1555,6 +1584,11 @@ function formatCampaignRunSummaryMarkdown(state = {}, issue = null) {
`- Claimable local Codex items: ${stats.items_claimable_local_codex || 0}`,
`- Source-fixed candidates: ${stats.items_source_fixed_candidates || 0}`,
`- Superseded sync candidates: ${stats.items_superseded_sync_candidates || 0}`,
`- Exception lifecycle (new/unchanged/resolved/re-opened): ` +
`${Number(stats.exception_lifecycle?.new || 0)}/` +
`${Number(stats.exception_lifecycle?.unchanged || 0)}/` +
`${Number(stats.exception_lifecycle?.resolved || 0)}/` +
`${Number(stats.exception_lifecycle?.re_opened || 0)}`,
`- Source sync states: ${formatSourceSyncStatusCounts(stats.source_sync_status_counts)}`,
`- Finished local results without published source changes: ${stats.items_unpublished_source_results || 0}`,
`- Items claimed: ${stats.items_claimed || 0}`,
Expand Down
25 changes: 24 additions & 1 deletion .github/scripts/sync_pr_merge_contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ function classifyGeneratedPr({ pr = {}, checkState = {}, activeReviewThreadCount
return { disposition: 'awaiting-checks', blocker_owner: 'ci', next_command: 'await-required-checks' };
}
if (checkState.status === 'checks_failed') {
const failureScope = String(checkState.failure_scope || '').trim().toLowerCase();
if (failureScope === 'shared-source' || checkState.shared_source === true) {
return {
disposition: 'shared-source-failure',
blocker_owner: 'source',
next_command: 'repair-shared-source-and-redeliver',
};
}
return { disposition: 'repo-local-failure', blocker_owner: 'repo', next_command: 'repair-required-checks' };
}
return { disposition: 'current', blocker_owner: 'maint-71', next_command: 'merge-current-delivery' };
Expand Down Expand Up @@ -178,6 +186,20 @@ function selectSyncPrGatingChecks({
);
}

// Workflows-owned / template-propagated gates. Consumer app test names stay repo-local.
// Keep these identities deliberately narrow: an aggregate Gate only reports that a
// consumer required context failed; it does not identify the failed Gate leg.
const SHARED_SOURCE_CHECK_RE =
/^(?:health\s+\d+(?:\s|$)|consumer\s+sync(?:\s|$)|sync\s+templates?(?:\s|$))/i;

function isSharedSourceFailedCheck(check = {}) {
const name = String(check?.name || check?.context || '').trim();
const explicitScope = String(check?.failure_scope || check?.source || '').trim().toLowerCase();
return check?.shared_source === true ||
explicitScope === 'shared-source' ||
(Boolean(name) && SHARED_SOURCE_CHECK_RE.test(name));
}

function classifySyncPrChecks({
checkRuns = [],
requiredContexts = [],
Expand All @@ -198,7 +220,8 @@ function classifySyncPrChecks({
const pending = gatingChecks.filter((check) => check?.status !== 'completed');

if (failed.length > 0) {
return { status: 'checks_failed', failed, pending: [] };
const failure_scope = failed.some(isSharedSourceFailedCheck) ? 'shared-source' : 'repo-local';
return { status: 'checks_failed', failure_scope, failed, pending: [] };
}
if (pending.length > 0) {
return { status: 'checks_pending', failed: [], pending };
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/maint-71-merge-sync-prs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# - Repository dispatch (event_type: merge-sync-prs) for Codespace triggers
#
# Safety:
# - Only merges PRs with sync/workflows-* branch names
# - Merges trusted generated delivery PRs (sync/workflows-* and deps/sync-dev-versions-*)
# - Requires all checks to pass
# - Uses merge commit (not squash) to preserve sync history

Expand Down
Loading
Loading