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
39 changes: 39 additions & 0 deletions .github/scripts/__tests__/sync-pr-merge-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ const assert = require('node:assert/strict');

const {
buildMarkdownSummary,
buildDeliveryHandoff,
buildMergeReport,
classifyGeneratedPr,
classifySyncPrChecks,
collectDeletableSyncBranches,
generatedDeliveryLane,
isTrustedGeneratedDeliveryPr,
isTrustedSyncPr,
normalizeSyncHash,
parseBooleanInput,
Expand Down Expand Up @@ -68,6 +72,22 @@ test('isTrustedSyncPr requires the configured actor and sync branch', () => {
assert.equal(isTrustedSyncPr({ ...trusted, user: { login: 'untrusted' } }, ['stranske']), false);
});

test('generated delivery classification gives sync and dev-tool lanes identical check and review dispositions', () => {
const record = '<!-- sync-pr-delivery-record:v1 {"schema":"sync-pr-delivery-record/v1","durable_issue_url":"https://github.com/stranske/Workflows/issues/1836","plan_id":"plan-abc","generation":"generation-1","repository":"stranske/Ready","desired_tree_hash":"tree-abc","source_commit":"source-abc","lease_expires_at":"2026-08-02T00:00:00Z","predecessor_prs":[],"successor_prs":[]} -->';
const sync = { ...pr(1, 'sync/workflows-current', '2026-04-25T01:00:00Z'), body: record, user: { login: 'stranske' } };
const devTool = { ...pr(2, 'deps/sync-dev-versions-20260801', '2026-04-25T01:00:00Z'), body: record, user: { login: 'stranske' } };

assert.equal(generatedDeliveryLane(sync.head.ref), 'sync');
assert.equal(generatedDeliveryLane(devTool.head.ref), 'dev-tool-sync');
assert.equal(isTrustedGeneratedDeliveryPr(devTool, ['stranske']), true);
for (const candidate of [sync, devTool]) {
assert.equal(classifyGeneratedPr({ pr: candidate, now: '2026-08-01T00:00:00Z' }).disposition, 'current');
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');
}
});

test('selectActiveSyncPr honors target hash instead of newest PR', () => {
const selection = selectActiveSyncPr(
[
Expand Down Expand Up @@ -135,6 +155,7 @@ test('buildMergeReport provides machine-readable summary counts', () => {
branch_delete_failed: 0,
checks_failed: 0,
checks_pending: 0,
review_blocked: 0,
ready: 0,
dry_run_merge: 1,
merge_blocked_runtime_ac: 0,
Expand All @@ -143,6 +164,24 @@ test('buildMergeReport provides machine-readable summary counts', () => {
delivery_contract_blocked: 0,
error: 0,
});
assert.deepEqual(report.handoff_records, []);
});

test('buildDeliveryHandoff preserves the restart fields for a generated PR', () => {
assert.deepEqual(buildDeliveryHandoff({
owner: 'stranske', repo: 'Ready', pr: 11, branch: 'deps/sync-dev-versions-20260801',
head_sha: 'abc', delivery_generation: 'g2', delivery_disposition: 'review-blocked',
blocker_owner: 'closer', next_command: 'resolve-active-review-threads',
}), {
schema: 'workflows-generated-delivery-handoff/v1', repository: 'stranske/Ready', pr: 11,
branch: 'deps/sync-dev-versions-20260801', head_sha: 'abc', delivery_generation: 'g2',
lane: 'dev-tool-sync', disposition: 'review-blocked', blocker_owner: 'closer',
next_command: 'resolve-active-review-threads',
});
});

test('buildDeliveryHandoff rejects results that lack required restart fields', () => {
assert.equal(buildDeliveryHandoff({ owner: 'stranske', repo: 'Ready', pr: 11, branch: 'sync/workflows-current' }), null);
});

test('collectDeletableSyncBranches keeps open PR branches and non-sync branches', () => {
Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/sync_dependency_campaign.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const LABEL_CAMPAIGN = 'campaign:sync-dependabot';
const LABEL_ACTIVE = 'campaign:active';
const LABEL_NEEDS_LOCAL_CODEX = 'campaign:needs-local-codex';
const SYNC_BRANCH_PREFIX = 'sync/workflows-';
const DEV_TOOL_SYNC_BRANCH_PREFIX = 'deps/sync-dev-versions-';
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_MAX_RETAINED_ITEMS = 120;
const DEFAULT_MAX_SOURCE_REVIEW_HISTORY = 80;
Expand Down Expand Up @@ -92,6 +93,7 @@ function isSyncPullRequest(pr = {}) {
const labels = labelsForPullRequest(pr).map((label) => label.toLowerCase());
return (
headRef.startsWith(SYNC_BRANCH_PREFIX) ||
headRef.startsWith(DEV_TOOL_SYNC_BRANCH_PREFIX) ||
title.startsWith('chore: sync workflow templates') ||
labels.includes('sync') ||
body.includes('workflows-sync-lifecycle')
Expand Down
84 changes: 81 additions & 3 deletions .github/scripts/sync_pr_merge_contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

const REPORT_SCHEMA = 'workflows-sync-pr-merge/v1';
const SYNC_BRANCH_PREFIX = 'sync/workflows-';
const DEV_TOOL_SYNC_BRANCH_PREFIX = 'deps/sync-dev-versions-';
const GENERATED_DELIVERY_BRANCH_PREFIXES = [SYNC_BRANCH_PREFIX, DEV_TOOL_SYNC_BRANCH_PREFIX];
const { parseDeliveryRecord, mergeEligibility } = require('./sync_pr_lease_contract');

function normalizeSyncHash(value) {
Expand All @@ -28,11 +30,59 @@ function isSyncBranchName(value) {
return branchNameFromRef(value).startsWith(SYNC_BRANCH_PREFIX);
}

function generatedDeliveryLane(value) {
const branch = branchNameFromRef(value);
if (branch.startsWith(SYNC_BRANCH_PREFIX)) return 'sync';
if (branch.startsWith(DEV_TOOL_SYNC_BRANCH_PREFIX)) return 'dev-tool-sync';
return '';
}

function isGeneratedDeliveryBranchName(value) {
return Boolean(generatedDeliveryLane(value));
}

function isTrustedSyncPr(pr, trustedActors = []) {
const actor = String(pr?.user?.login || '').trim();
return isSyncBranchName(pr?.head?.ref) && new Set(trustedActors).has(actor);
}

function isTrustedGeneratedDeliveryPr(pr, trustedActors = []) {
Comment thread
stranske marked this conversation as resolved.
const actor = String(pr?.user?.login || '').trim();
return isGeneratedDeliveryBranchName(pr?.head?.ref) && new Set(trustedActors).has(actor);
}

function classifyGeneratedPr({ pr = {}, checkState = {}, activeReviewThreadCount = 0, now } = {}) {
const record = parseDeliveryRecord(pr.body || '');
const lane = generatedDeliveryLane(pr?.head?.ref || pr?.headRefName);
if (!lane) return { disposition: 'owner-decision', blocker_owner: 'source', next_command: '' };
if (!record) return {
disposition: 'owner-decision',
blocker_owner: 'source',
next_command: 'attach-or-infer-delivery-record',
};
const eligibility = mergeEligibility(record, { now });
if (eligibility.reason === 'lease_expired') {
return { disposition: 'expired', blocker_owner: 'maint-71', next_command: 'close-expired-delivery' };
}
if (!eligibility.eligible) {
return { disposition: 'superseded', blocker_owner: 'maint-71', next_command: 'close-or-refresh-delivery' };
Comment thread
stranske marked this conversation as resolved.
}
const reviewThreadCount = Number(activeReviewThreadCount);
if (!Number.isFinite(reviewThreadCount) || reviewThreadCount < 0) {
return { disposition: 'review-blocked', blocker_owner: 'closer', next_command: 'retry-review-thread-query' };
}
if (reviewThreadCount > 0) {
return { disposition: 'review-blocked', blocker_owner: 'closer', next_command: 'resolve-active-review-threads' };
}
if (checkState.status === 'checks_pending') {
return { disposition: 'awaiting-checks', blocker_owner: 'ci', next_command: 'await-required-checks' };
}
if (checkState.status === 'checks_failed') {
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' };
}

function parseBooleanInput(value, defaultValue = false) {
if (value === undefined || value === null || String(value).trim() === '') {
return Boolean(defaultValue);
Expand All @@ -55,17 +105,17 @@ function collectDeletableSyncBranches({
const openBranches = new Set(
(openPullRequests || [])
.map((pr) => branchNameFromRef(pr?.head?.ref || pr?.headRefName || pr?.branch))
.filter(isSyncBranchName),
.filter(isGeneratedDeliveryBranchName),
);
const closedBranches = new Set(
(closedPullRequests || [])
.map((pr) => branchNameFromRef(pr?.head?.ref || pr?.headRefName || pr?.branch))
.filter(isSyncBranchName),
.filter(isGeneratedDeliveryBranchName),
);

return (branches || [])
.map((branch) => branchNameFromRef(branch?.name || branch?.ref || branch))
.filter(isSyncBranchName)
.filter(isGeneratedDeliveryBranchName)
.filter((branch) => !openBranches.has(branch))
.filter((branch) => closedBranches.has(branch))
Comment thread
stranske marked this conversation as resolved.
.sort();
Expand Down Expand Up @@ -203,6 +253,7 @@ function summarizeResults(results) {
branch_delete_failed: 0,
checks_failed: 0,
checks_pending: 0,
review_blocked: 0,
ready: 0,
dry_run_merge: 0,
merge_blocked_runtime_ac: 0,
Expand All @@ -219,6 +270,25 @@ function summarizeResults(results) {
return counts;
}

function buildDeliveryHandoff(result = {}) {
if (!result.pr) return null;
const headSha = String(result.head_sha || '');
const deliveryGeneration = String(result.delivery_generation || '');
if (!headSha || !deliveryGeneration) return null;
return {
schema: 'workflows-generated-delivery-handoff/v1',
repository: `${result.owner || ''}/${result.repo || ''}`.replace(/^\//, ''),
pr: Number(result.pr),
branch: branchNameFromRef(result.branch),
head_sha: headSha,
delivery_generation: deliveryGeneration,
lane: generatedDeliveryLane(result.branch),
disposition: String(result.delivery_disposition || result.status || ''),
blocker_owner: String(result.blocker_owner || ''),
next_command: String(result.next_command || ''),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function buildMergeReport({
results = [],
registeredRepos = [],
Expand All @@ -244,6 +314,7 @@ function buildMergeReport({
},
summary: summarizeResults(results),
results,
handoff_records: (results || []).map(buildDeliveryHandoff).filter(Boolean),
};
}

Expand Down Expand Up @@ -278,10 +349,16 @@ function buildMarkdownSummary(report) {
module.exports = {
REPORT_SCHEMA,
SYNC_BRANCH_PREFIX,
DEV_TOOL_SYNC_BRANCH_PREFIX,
GENERATED_DELIVERY_BRANCH_PREFIXES,
branchNameFromRef,
classifyGeneratedPr,
classifySyncPrChecks,
collectDeletableSyncBranches,
generatedDeliveryLane,
isGeneratedDeliveryBranchName,
isSyncBranchName,
isTrustedGeneratedDeliveryPr,
isTrustedSyncPr,
normalizeSyncHash,
syncBranchForHash,
Expand All @@ -292,5 +369,6 @@ module.exports = {
selectMergeEligibleSyncPr,
summarizeResults,
buildMergeReport,
buildDeliveryHandoff,
buildMarkdownSummary,
};
Loading
Loading