@on-par/factory-cli 0.9.5-next.1 → 0.9.5-next.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/doctor.d.ts +8 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +41 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.d.ts +21 -146
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +122 -545
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/land-pr.d.ts +102 -0
- package/dist/cli/land-pr.d.ts.map +1 -0
- package/dist/cli/land-pr.js +233 -0
- package/dist/cli/land-pr.js.map +1 -0
- package/dist/cli/land-runtime.d.ts +16 -0
- package/dist/cli/land-runtime.d.ts.map +1 -0
- package/dist/cli/land-runtime.js +11 -0
- package/dist/cli/land-runtime.js.map +1 -0
- package/dist/cli/land.d.ts +54 -0
- package/dist/cli/land.d.ts.map +1 -0
- package/dist/cli/land.js +298 -0
- package/dist/cli/land.js.map +1 -0
- package/package.json +4 -4
package/dist/cli/index.js
CHANGED
|
@@ -9,11 +9,14 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:pat
|
|
|
9
9
|
import { promisify, styleText } from 'node:util';
|
|
10
10
|
import { BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION, BRANCH_PREFIX_OPTION_DESCRIPTION, INVALID_BRANCH_PREFIX_MESSAGE, resolveBranchPrefixOption, } from './branch-prefix.js';
|
|
11
11
|
import { acquirePortLease, aggregateCosts, appendKpiHistoryLine, applyRepoConfig, buildPhase, checkPhase, clearProxyState, closedWorkSkipReason, computeHealthKpis, ConstitutionLoader, createDefaultWorkSourceRegistry, createFileApprovalGate, createLogger, createLaneProxy, defaultFindPortListeners, describeEffectiveConfig, detectPostMergeDefects, diagnoseModels, probeDeclaredHarnesses, drainSteering, estimateTrailingSpend, fetchDefectSources, fetchHumanEventSources, fetchSubscriptionUsage, formatKpiLines, formatUsageReport, getConstitutionsDir, getFactoryPaths, GITHUB_ISSUE_SOURCE, hasUnresolvedPark, InvalidArtifactsDirError, InvalidWorkRequestInputError, InvalidWorkspaceError, inspectPortLeases, isCommandAvailable, isProxyRunning, kpiBranchPrefixes, KNOWN_HARNESS_IDS, kpisToHistoryRecord, laneBaseUrl, laneEnv, LaneFileGuard, laneHostLabel, listQueuedSteering, loadFactoryConfig, loadFactoryConfigForRepo, loadModelsConfig, loadRepoConfig, loadRoutesConfig, LOCAL_BRIEF_SOURCE, localOnlyWorkspace, joinClassifierOutcomes, mergeClassifierOutcomes, parseClassifierOutcomes, summarizeClassifierOutcomes, formatClassifierReport, mergedPrRefs, ModelRegistry, ModelRouter, parkReasonFor, parseKpiHistory, parseQueue, partitionLocalQueueByActivity, phaseSnapshotFile, planPhase, provisionLaneContainer, ProviderBreaker, readEvents, readPortLeases, readQueue, readUsage, reapOrphanProcesses, reapStalePortLeases, reconstructKpiEvents, recordLeasePgid, releasePortLease, renderKpiReport, renderKpiTrend, resolveAutoFailover, resolveCodexDisabled, resolveDefectWindowDays, resolveBuildPublish, resolveSizeGateMode, parseSizeGateMode, parseMaxSlices, resolveSizeGateMaxSlices, SIZE_GATE_MODES, resolveDesignRegressionBlock, resolveReadinessUpdateIssueBody, resolveEffectiveBuildRoute, resolveEffectiveModelPins, resolveEfficiencyPolicy, resolveLaneBreakerThreshold, resolveEnvironmentPorts, resolveEnvironmentProxy, resolveArtifactsDir, resolveIngestConfig, resolveLocalOnlyPolicy, resolveMergePolicy, resolvePlanApproval, resolveProcessGroupGraceMs, resolveSandboxPolicy, resolveSkipCI, resolveTimeouts, resolveUsageCap, resolveWatchdogPolicy, ReworkHistory, rewriteQueueForDecomposition, runAutoIngest, runIssue, shipPhase, summarizeEvent, formatCostTotal, touchLastEvent, touchRunActivity, validateQueue, watchUsage, worktreeWorkspace, writeBenchmarkArtifacts, writeLocalRunReport, writePhaseSnapshot, writeProxyState, } from '@on-par/factory-core';
|
|
12
|
-
import { acquirePidFile, createCheckResumeStore, createPlanReuseStore, checkBaseHealth, currentSlice, egressHostMessage, egressSummaryMessage, findSlicePlanComment, upsertSlicePlanComment, withSliceState, DEFAULT_GARDEN_MAX_CLUSTERS, DEFAULT_GARDEN_SINCE, capGardenClusters, clusterGarden, dedupGardenClusters, filterGardenOnlyNew, filterGardenWindow, markGardenTrackingUnknown, parseGardenDuration, readHarvestEvents, renderGardenJson, renderGardenReport, formatWorktreeLocation, laneWorktreePath, resolveWorktreeRoot, migrateRepoConfigToYaml, REPO_CONFIG_YAML_HEADER, branchFor, claudeConfigDirOverride, claudeKeychainService, cleanupWorktree, createDaemonLogSink, factoryBranchIssue, factoryBranchPrefixes,
|
|
12
|
+
import { acquirePidFile, createCheckResumeStore, createPlanReuseStore, checkBaseHealth, currentSlice, egressHostMessage, egressSummaryMessage, findSlicePlanComment, recordSliceMerged, upsertSlicePlanComment, withSliceState, DEFAULT_GARDEN_MAX_CLUSTERS, DEFAULT_GARDEN_SINCE, capGardenClusters, clusterGarden, dedupGardenClusters, filterGardenOnlyNew, filterGardenWindow, markGardenTrackingUnknown, parseGardenDuration, readHarvestEvents, renderGardenJson, renderGardenReport, formatWorktreeLocation, laneWorktreePath, resolveWorktreeRoot, migrateRepoConfigToYaml, REPO_CONFIG_YAML_HEADER, branchFor, claudeConfigDirOverride, claudeKeychainService, cleanupWorktree, createDaemonLogSink, factoryBranchIssue, factoryBranchPrefixes, LEGACY_BRANCH_PREFIX, createDockerEngine, createFactorydServer, createRunRuntime, createShipExecutor, createGithubQueue, createLocalSmallDryRun, createOctokitFilingClient, createOctokitGreenPrClient, createOctokitQueueClient, daemonRuntimePaths, DEFAULT_FACTORYD_PORT, defaultRegistryPath, ensureDir, FACTORY_RUNTIME_CONFIG_KEYS, findUnmergedGreenPrs, formatGcReport, gitFetch, listMicroVms, listOrphanContainers, logCost, logEvent, microVmName, planQueueMigration, readCosts, readGithubQueueSnapshot, reapLaneWorktree, reapOrphanContainers, reapOrphanMicroVm, releaseRuntimeFiles, releaseStaleClaims, resolveBranchPrefix, resolveEffectiveConfig, worktreeSandboxFor, resolveExperimental, resolveFilingPolicy, resolveLocalOnly, resolvePrClassifierPolicy, resolveRunUsageHarness, resolveReviewFloorRules, RunLockHeldError, runOvernightQueue, findOpenPR, parseRemoteSlug, setupWorktree, shellEscape, listBuiltInCheckers, shouldProxyEgress, slugify, startEgressProxy, sweepWorktrees, watchChecks, withAdmissionGuard, withFileLock, withGitLock, withRunLock, wrapCommandInSandbox, writePortFile, buildTriagePrompt, normalizeTriageQueue, parseTriageCandidates, codexChatgptSessionState, hasClaudeLoginEvidence, claudeStoredSessionState, localAuthFailureSinceLogin, recordLocalAuthFailure, hasOpencodeLoginEvidence, } from '@on-par/factory-core/internal';
|
|
13
13
|
import { runTui } from '@on-par/factory-tui';
|
|
14
14
|
import { Command } from 'commander';
|
|
15
15
|
import { admissionStateReaderFor, runQueueReconcile } from './admission.js';
|
|
16
16
|
import { cmdClassifierBacktest, ClassifierBacktestInputError, } from './classifier-backtest.js';
|
|
17
|
+
import { findOpenPRForIssue, landIssue, waitForMerge } from './land.js';
|
|
18
|
+
import { AwaitingReviewError, CiFailedError, CiUnverifiedError, getPullRequestLandState, LandConflictError, LandFailureError, MergeGatedError, } from './land-pr.js';
|
|
19
|
+
import { setLandRuntime } from './land-runtime.js';
|
|
17
20
|
import { runQueueClear } from './queue-clear.js';
|
|
18
21
|
import { applyStateRootOption } from './state-root.js';
|
|
19
22
|
import { buildCostJson } from './cost-json.js';
|
|
@@ -292,6 +295,17 @@ export function errorDetail(err) {
|
|
|
292
295
|
return e.message;
|
|
293
296
|
return String(err);
|
|
294
297
|
}
|
|
298
|
+
// The land flow (land.ts, land-pr.ts) reads these live, so per-call deps injection reaches it (#2784).
|
|
299
|
+
setLandRuntime(() => ({
|
|
300
|
+
configLoaders,
|
|
301
|
+
gitOps,
|
|
302
|
+
internalOps,
|
|
303
|
+
shellOps,
|
|
304
|
+
getOctokit: () => getOctokit(),
|
|
305
|
+
errorDetail,
|
|
306
|
+
worktreePathFor,
|
|
307
|
+
LaneParkError,
|
|
308
|
+
}));
|
|
295
309
|
export function hasGitHubToken(env = process.env, tryToken) {
|
|
296
310
|
if (env.GITHUB_TOKEN || env.GH_TOKEN)
|
|
297
311
|
return true;
|
|
@@ -902,6 +916,7 @@ async function cmdCheck(issueRaw, opts) {
|
|
|
902
916
|
const repoConfig = configLoaders.loadRepoConfig(repoRoot);
|
|
903
917
|
const effective = resolveEffectiveConfig(repoConfig);
|
|
904
918
|
const router = new coreOps.ModelRouter(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig), configLoaders.loadRoutesConfig(), false, undefined, effective.allowExperimental, effective.localOnly);
|
|
919
|
+
router.setLocalAuthSink?.(recordLocalAuthFailure);
|
|
905
920
|
runModel = createDeepModelRunner(router, repoRoot);
|
|
906
921
|
}
|
|
907
922
|
let report;
|
|
@@ -1005,6 +1020,7 @@ async function cmdClassifierBacktestEntry(opts) {
|
|
|
1005
1020
|
const repoConfig = configLoaders.loadRepoConfig(repoRoot);
|
|
1006
1021
|
const effective = resolveEffectiveConfig(repoConfig);
|
|
1007
1022
|
const router = new coreOps.ModelRouter(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig), configLoaders.loadRoutesConfig(), false, undefined, effective.allowExperimental, effective.localOnly);
|
|
1023
|
+
router.setLocalAuthSink?.(recordLocalAuthFailure);
|
|
1008
1024
|
try {
|
|
1009
1025
|
await cmdClassifierBacktest({ ...opts, branchPrefix }, {
|
|
1010
1026
|
repoRoot,
|
|
@@ -1247,6 +1263,7 @@ export async function cmdStatus(opts = {}) {
|
|
|
1247
1263
|
const routesConfig = configLoaders.loadRoutesConfig();
|
|
1248
1264
|
const effective = resolveEffectiveConfig(repoConfig);
|
|
1249
1265
|
const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
|
|
1266
|
+
router.setLocalAuthSink?.(recordLocalAuthFailure);
|
|
1250
1267
|
const product = readActiveProduct(paths.product) ?? '(none)';
|
|
1251
1268
|
console.log(styleText('bold', `== ${ghRepo} ==`));
|
|
1252
1269
|
console.log(`Product: ${product}`);
|
|
@@ -1425,6 +1442,7 @@ async function cmdTui(opts = {}, runTuiImpl = runTui) {
|
|
|
1425
1442
|
const routesConfig = configLoaders.loadRoutesConfig();
|
|
1426
1443
|
const effective = resolveEffectiveConfig(repoConfig);
|
|
1427
1444
|
const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
|
|
1445
|
+
router.setLocalAuthSink?.(recordLocalAuthFailure);
|
|
1428
1446
|
const effectiveConfigLines = describeEffectiveConfig({
|
|
1429
1447
|
router,
|
|
1430
1448
|
repo: repoConfig,
|
|
@@ -1618,6 +1636,7 @@ export async function shipIssue(issueNum, opts, ctx, deps = {}) {
|
|
|
1618
1636
|
: { ...resolveEffectiveConfig(repoConfig), branchPrefix: opts.branchPrefix },
|
|
1619
1637
|
};
|
|
1620
1638
|
const router = new core.ModelRouter(policy.models, policy.routes, false, undefined, policy.effective.allowExperimental, policy.effective.localOnly);
|
|
1639
|
+
router.setLocalAuthSink?.(recordLocalAuthFailure);
|
|
1621
1640
|
router.skipMissingHarnessClis(core.isCommandAvailable);
|
|
1622
1641
|
let issueSpend = 0;
|
|
1623
1642
|
let laneSandboxRuntime = 'none';
|
|
@@ -2584,132 +2603,6 @@ export async function cmdLand(issueNum, opts = {}, deps = {}) {
|
|
|
2584
2603
|
octokitFactory = previousOctokitFactory;
|
|
2585
2604
|
}
|
|
2586
2605
|
}
|
|
2587
|
-
async function landIssue(issueNum, repoRoot, ghRepo, paths, octokit, skipCI, branchPrefix, opts = {}) {
|
|
2588
|
-
const [owner, repoName] = ghRepo.split('/');
|
|
2589
|
-
const log = (type, msg, extra) => logEvent(paths.events, type, issueNum, msg, extra);
|
|
2590
|
-
// The issue title may have been edited since the PR was opened, so a
|
|
2591
|
-
// freshly-derived branch name can drift from the branch the PR actually
|
|
2592
|
-
// lives on (same failure mode fixed for waitForMerge in #51). Guess the
|
|
2593
|
-
// branch from the current title first, but fall back to matching the open
|
|
2594
|
-
// PR that references this issue directly and use its real head branch.
|
|
2595
|
-
// Legacy-prefix (LEGACY_BRANCH_PREFIX) branches are guessed too, after the resolved prefix (#1708).
|
|
2596
|
-
const resolved = branchPrefix ?? resolveEffectiveConfig(configLoaders.loadRepoConfig(repoRoot)).branchPrefix;
|
|
2597
|
-
const title = await getIssueTitle(ghRepo, issueNum, octokit);
|
|
2598
|
-
const guesses = factoryBranchPrefixes(resolved).map((p) => branchFor(issueNum, title, p));
|
|
2599
|
-
const guessedBranch = guesses[0];
|
|
2600
|
-
let branch = guessedBranch;
|
|
2601
|
-
let prNumber;
|
|
2602
|
-
try {
|
|
2603
|
-
const findByGuess = async () => {
|
|
2604
|
-
for (const guess of guesses) {
|
|
2605
|
-
const found = await findOpenPRNumber(octokit, owner, repoName, guess);
|
|
2606
|
-
if (found) {
|
|
2607
|
-
branch = guess;
|
|
2608
|
-
return found;
|
|
2609
|
-
}
|
|
2610
|
-
}
|
|
2611
|
-
return undefined;
|
|
2612
|
-
};
|
|
2613
|
-
[, prNumber] = await Promise.all([gitOps.gitFetch(repoRoot), findByGuess()]);
|
|
2614
|
-
if (!prNumber) {
|
|
2615
|
-
const fallback = await findOpenPRForIssue(octokit, owner, repoName, issueNum);
|
|
2616
|
-
if (fallback) {
|
|
2617
|
-
branch = fallback.branch;
|
|
2618
|
-
prNumber = fallback.number;
|
|
2619
|
-
}
|
|
2620
|
-
}
|
|
2621
|
-
}
|
|
2622
|
-
catch (err) {
|
|
2623
|
-
const failure = prLookupFailure(issueNum, guessedBranch, err);
|
|
2624
|
-
log('fail', failure.message);
|
|
2625
|
-
throw failure;
|
|
2626
|
-
}
|
|
2627
|
-
if (!prNumber) {
|
|
2628
|
-
log('fail', `no open PR for ${guessedBranch}`);
|
|
2629
|
-
throw new LandFailureError(`no open PR for issue #${issueNum} (${guessedBranch})`, 1);
|
|
2630
|
-
}
|
|
2631
|
-
const landFactoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
|
|
2632
|
-
// Every merge path honors the no-auto-merge gate on the issue and on the PR; unreadable labels fail closed (#1720).
|
|
2633
|
-
const filingPolicy = resolveFilingPolicy(landFactoryConfig);
|
|
2634
|
-
const gateLabel = filingPolicy.selfFixLabel;
|
|
2635
|
-
if (opts.allowGated) {
|
|
2636
|
-
console.log(`AUDIT factory land #${issueNum}: --allow-gated override — merging PR #${prNumber} past the ${gateLabel} gate`);
|
|
2637
|
-
log('merge-gated-override', `--allow-gated: merging PR #${prNumber} past ${gateLabel} gate (human override)`);
|
|
2638
|
-
}
|
|
2639
|
-
else {
|
|
2640
|
-
let where;
|
|
2641
|
-
try {
|
|
2642
|
-
const [issueLabels, prLabels] = await Promise.all([
|
|
2643
|
-
defaultListIssueLabels(octokit, owner, repoName, issueNum),
|
|
2644
|
-
defaultListIssueLabels(octokit, owner, repoName, prNumber),
|
|
2645
|
-
]);
|
|
2646
|
-
if (isAutoMergeBlocked(issueLabels, filingPolicy))
|
|
2647
|
-
where = `issue #${issueNum} is labelled ${gateLabel}`;
|
|
2648
|
-
else if (isAutoMergeBlocked(prLabels, filingPolicy))
|
|
2649
|
-
where = `PR #${prNumber} is labelled ${gateLabel}`;
|
|
2650
|
-
}
|
|
2651
|
-
catch (err) {
|
|
2652
|
-
where = `labels could not be read (${errorDetail(err)}) — failing closed on the ${gateLabel} gate`;
|
|
2653
|
-
}
|
|
2654
|
-
if (where) {
|
|
2655
|
-
log('merge-gated', `land refused: ${where}`);
|
|
2656
|
-
throw new MergeGatedError(`not merging PR #${prNumber} for issue #${issueNum}: ${where} (human approval required). Re-run \`factory land ${issueNum} --allow-gated\` to merge it deliberately.`, issueNum, prNumber, gateLabel);
|
|
2657
|
-
}
|
|
2658
|
-
}
|
|
2659
|
-
const worktree = worktreePathFor(repoRoot, ghRepo, issueNum, branch.startsWith(`${LEGACY_BRANCH_PREFIX}/`) ? LEGACY_BRANCH_PREFIX : resolved);
|
|
2660
|
-
const landSandboxPolicy = resolveSandboxPolicy(landFactoryConfig.sandbox, {
|
|
2661
|
-
worktree,
|
|
2662
|
-
repoRoot,
|
|
2663
|
-
});
|
|
2664
|
-
const worktreeSandbox = worktreeSandboxFor(landSandboxPolicy?.runtime, {
|
|
2665
|
-
allowHosts: landSandboxPolicy?.allowHosts ?? [],
|
|
2666
|
-
});
|
|
2667
|
-
const withLandLock = (fn) => withGitLock(repoRoot, () => withFileLock(paths.mergeLock, fn, {
|
|
2668
|
-
onSteal: (pid) => log('lock-stolen', `stole ${paths.mergeLock} from dead holder pid ${pid ?? 'unknown'}`),
|
|
2669
|
-
}));
|
|
2670
|
-
try {
|
|
2671
|
-
try {
|
|
2672
|
-
await landOpenPullRequest({
|
|
2673
|
-
octokit,
|
|
2674
|
-
owner,
|
|
2675
|
-
repoName,
|
|
2676
|
-
ghRepo,
|
|
2677
|
-
repoRoot,
|
|
2678
|
-
issue: issueNum,
|
|
2679
|
-
branch,
|
|
2680
|
-
worktree,
|
|
2681
|
-
prNumber: prNumber,
|
|
2682
|
-
log,
|
|
2683
|
-
skipCI,
|
|
2684
|
-
adminMerge: resolveMergePolicy(landFactoryConfig).admin,
|
|
2685
|
-
withLock: withLandLock,
|
|
2686
|
-
ensureWorktree: async () => {
|
|
2687
|
-
if (!existsSync(worktree)) {
|
|
2688
|
-
await gitOps.setupWorktree(repoRoot, branch, worktree, `origin/${branch}`, worktreeSandbox, log);
|
|
2689
|
-
}
|
|
2690
|
-
},
|
|
2691
|
-
});
|
|
2692
|
-
}
|
|
2693
|
-
catch (err) {
|
|
2694
|
-
if (err instanceof AwaitingReviewError || err instanceof CiFailedError) {
|
|
2695
|
-
await withLandLock(() => gitOps.cleanupWorktree(repoRoot, worktree, log, worktreeSandbox));
|
|
2696
|
-
}
|
|
2697
|
-
throw err;
|
|
2698
|
-
}
|
|
2699
|
-
log('merged', `squash-merged PR #${prNumber}`);
|
|
2700
|
-
await withLandLock(() => gitOps.cleanupWorktree(repoRoot, worktree, log, worktreeSandbox));
|
|
2701
|
-
}
|
|
2702
|
-
catch (err) {
|
|
2703
|
-
if (err instanceof LandConflictError || err instanceof AwaitingReviewError || err instanceof CiFailedError)
|
|
2704
|
-
throw err;
|
|
2705
|
-
log('fail', `merge failed: ${err.message}`);
|
|
2706
|
-
throw new LandFailureError(`merge failed for issue #${issueNum}: ${err.message}`, 5);
|
|
2707
|
-
}
|
|
2708
|
-
return { branch, prNumber };
|
|
2709
|
-
}
|
|
2710
|
-
export function prLookupFailure(issueNum, branch, err) {
|
|
2711
|
-
return new LandFailureError(`PR lookup failed for issue #${issueNum} (${branch}): ${errorDetail(err)}`, 5);
|
|
2712
|
-
}
|
|
2713
2606
|
async function cmdTriage(opts) {
|
|
2714
2607
|
const repoRoot = await getRepoRoot();
|
|
2715
2608
|
const ghRepo = await getGitHubRepo();
|
|
@@ -2723,6 +2616,7 @@ async function cmdTriage(opts) {
|
|
|
2723
2616
|
const routesConfig = configLoaders.loadRoutesConfig();
|
|
2724
2617
|
const effective = resolveEffectiveConfig(repoConfig);
|
|
2725
2618
|
const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
|
|
2619
|
+
router.setLocalAuthSink?.(recordLocalAuthFailure);
|
|
2726
2620
|
router.skipMissingHarnessClis(coreOps.isCommandAvailable);
|
|
2727
2621
|
router.setCostSink((entry) => logCost(paths.costs, { ...entry, issue: '-' }));
|
|
2728
2622
|
let plannerError;
|
|
@@ -3272,7 +3166,10 @@ async function cmdRun(opts = {}) {
|
|
|
3272
3166
|
repo,
|
|
3273
3167
|
preflight: withAdmissionGuard({
|
|
3274
3168
|
reader: admissionStateReaderFor(paths.state),
|
|
3275
|
-
preflight: (issue) => preflightQueuedIssue(issue, createQueuePreflightOps(octokit, owner, repo
|
|
3169
|
+
preflight: (issue) => preflightQueuedIssue(issue, createQueuePreflightOps(octokit, owner, repo, {
|
|
3170
|
+
autoMergeStack: resolveSizeGateMode(factoryConfig) === 'stack' &&
|
|
3171
|
+
resolveMergePolicy(factoryConfig, process.env, { auto: opts.autoMerge }).auto,
|
|
3172
|
+
})),
|
|
3276
3173
|
onConflict: (issue, verdict) => {
|
|
3277
3174
|
const line = `#${issue} refused — ${verdict.reason} | repair: ${verdict.repair}`;
|
|
3278
3175
|
console.error(styleText('yellow', `factory: ${line}`));
|
|
@@ -3388,6 +3285,7 @@ async function cmdRun(opts = {}) {
|
|
|
3388
3285
|
ship: (issue, o, c) => shipIssue(issue, { ...o, branchPrefix, prClassifier: opts.prClassifier }, c),
|
|
3389
3286
|
sweepFinished: (issue) => sweepBetweenLaneIssues(issue, planned.lane, repoRoot, ghRepo, paths).then(() => { }),
|
|
3390
3287
|
reapWorktree: (issue) => reapParkedLaneWorktree(issue, repoRoot, ghRepo, paths, opts.branchPrefix === undefined ? undefined : branchPrefix),
|
|
3288
|
+
recordStackMerge: (issue, prNumber) => recordSliceMerged({ octokit: getOctokit(), repo: ghRepo, issue, prNumber }).then(() => { }),
|
|
3391
3289
|
waitMerge: (issue, branch, root, gh, p, d = {}) => waitForMerge(issue, branch, root, gh, p, { ...d, mergeOverrides: { auto: opts.autoMerge } }),
|
|
3392
3290
|
}));
|
|
3393
3291
|
}
|
|
@@ -3522,8 +3420,13 @@ export function laneQueueDeps(queue, lane) {
|
|
|
3522
3420
|
countRemaining: async () => (await queue.list(lane)).length,
|
|
3523
3421
|
};
|
|
3524
3422
|
}
|
|
3525
|
-
export function createQueuePreflightOps(octokit, owner, repo) {
|
|
3423
|
+
export function createQueuePreflightOps(octokit, owner, repo, opts = {}) {
|
|
3526
3424
|
return {
|
|
3425
|
+
autoMergeStack: opts.autoMergeStack ?? false,
|
|
3426
|
+
getPullRefs: async (prNumber) => {
|
|
3427
|
+
const { data } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
|
3428
|
+
return { headRef: data.head.ref, baseRef: data.base.ref, headSha: data.head.sha };
|
|
3429
|
+
},
|
|
3527
3430
|
expectedHeadRepoFullName: `${owner}/${repo}`,
|
|
3528
3431
|
findOpenPR: (issue) => findOpenPRForIssue(octokit, owner, repo, issue),
|
|
3529
3432
|
getLandState: (prNumber) => getPullRequestLandState(octokit, owner, repo, prNumber),
|
|
@@ -3540,8 +3443,10 @@ export function createQueuePreflightOps(octokit, owner, repo) {
|
|
|
3540
3443
|
},
|
|
3541
3444
|
};
|
|
3542
3445
|
}
|
|
3543
|
-
/** ADR-0147:
|
|
3544
|
-
*
|
|
3446
|
+
/** ADR-0147/ADR-0161: walk every pr-open slice PR bottom-up. Merged layers are recorded, a closed layer parks
|
|
3447
|
+
* (naming the layers above it), and the lowest open layer defers, or is adopted when stack auto-merge is on and
|
|
3448
|
+
* its base is main with green CI on the rebased head. Returns undefined to fall through to the Closes-PR
|
|
3449
|
+
* preflight only when no slice PR is still open. */
|
|
3545
3450
|
async function preflightSlice(issue, deps) {
|
|
3546
3451
|
let lookup;
|
|
3547
3452
|
try {
|
|
@@ -3559,36 +3464,80 @@ async function preflightSlice(issue, deps) {
|
|
|
3559
3464
|
if (lookup.status === 'not-found')
|
|
3560
3465
|
return undefined;
|
|
3561
3466
|
const { plan } = lookup;
|
|
3562
|
-
const
|
|
3563
|
-
|
|
3467
|
+
const n = plan.slices.length;
|
|
3468
|
+
const open = plan.slices.filter((s) => s.state === 'pr-open');
|
|
3469
|
+
if (open.length === 0)
|
|
3564
3470
|
return undefined;
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3471
|
+
let next = plan;
|
|
3472
|
+
let lastMerged;
|
|
3473
|
+
const save = async () => {
|
|
3474
|
+
if (lastMerged === undefined)
|
|
3475
|
+
return undefined;
|
|
3476
|
+
try {
|
|
3477
|
+
await deps.saveSlicePlan(issue, next);
|
|
3478
|
+
}
|
|
3479
|
+
catch (err) {
|
|
3480
|
+
return {
|
|
3481
|
+
kind: 'park',
|
|
3482
|
+
reason: `${lastMerged.label} PR #${lastMerged.pr} merged but updating the slice plan failed: ${errorDetail(err)}`,
|
|
3483
|
+
};
|
|
3484
|
+
}
|
|
3485
|
+
return undefined;
|
|
3486
|
+
};
|
|
3487
|
+
for (const [at, slice] of open.entries()) {
|
|
3488
|
+
const label = `slice ${slice.index}/${n}`;
|
|
3489
|
+
if (slice.prNumber === undefined) {
|
|
3490
|
+
return { kind: 'park', reason: `${label} of #${issue} is pr-open but the slice plan records no PR number` };
|
|
3491
|
+
}
|
|
3492
|
+
const pr = slice.prNumber;
|
|
3493
|
+
let state;
|
|
3494
|
+
try {
|
|
3495
|
+
state = await deps.getPullState(pr);
|
|
3496
|
+
}
|
|
3497
|
+
catch (err) {
|
|
3498
|
+
return { kind: 'park', reason: `${label} PR #${pr} state lookup failed: ${errorDetail(err)}` };
|
|
3499
|
+
}
|
|
3500
|
+
if (state === 'merged') {
|
|
3501
|
+
next = withSliceState(next, slice.index, 'merged', pr);
|
|
3502
|
+
lastMerged = { label, pr };
|
|
3503
|
+
continue;
|
|
3504
|
+
}
|
|
3505
|
+
if (state === 'closed') {
|
|
3506
|
+
const above = open.slice(at + 1);
|
|
3507
|
+
const suffix = above.length > 0
|
|
3508
|
+
? `; layers above it: ${above.map((a) => `slice ${a.index}/${n} (#${a.prNumber})`).join(', ')}`
|
|
3509
|
+
: '';
|
|
3510
|
+
return { kind: 'park', reason: `${label} PR #${pr} was closed without merging${suffix}` };
|
|
3511
|
+
}
|
|
3512
|
+
const saved = await save();
|
|
3513
|
+
if (saved)
|
|
3514
|
+
return saved;
|
|
3515
|
+
if (!deps.autoMergeStack)
|
|
3516
|
+
return { kind: 'defer' };
|
|
3517
|
+
let refs;
|
|
3518
|
+
try {
|
|
3519
|
+
refs = await deps.getPullRefs(pr);
|
|
3520
|
+
}
|
|
3521
|
+
catch (err) {
|
|
3522
|
+
return { kind: 'park', reason: `${label} PR #${pr} ref lookup failed: ${errorDetail(err)}` };
|
|
3523
|
+
}
|
|
3524
|
+
// GitHub has not retargeted the layer to main yet: the lower layer is still unmerged.
|
|
3525
|
+
if (refs.baseRef !== 'main')
|
|
3526
|
+
return { kind: 'defer' };
|
|
3527
|
+
let outcome;
|
|
3528
|
+
try {
|
|
3529
|
+
outcome = await deps.watch(refs.headSha);
|
|
3530
|
+
}
|
|
3531
|
+
catch (err) {
|
|
3532
|
+
return { kind: 'park', reason: `CI watch for ${label} PR #${pr} failed: ${errorDetail(err)}` };
|
|
3533
|
+
}
|
|
3534
|
+
if (outcome === 'failure')
|
|
3535
|
+
return { kind: 'park', reason: `CI for ${label} PR #${pr} ended failure` };
|
|
3536
|
+
if (outcome !== 'success')
|
|
3537
|
+
return { kind: 'defer' };
|
|
3538
|
+
return { kind: 'adopt', branch: refs.headRef, stackLayer: { index: slice.index, count: n, prNumber: pr } };
|
|
3590
3539
|
}
|
|
3591
|
-
return undefined;
|
|
3540
|
+
return (await save()) ?? undefined;
|
|
3592
3541
|
}
|
|
3593
3542
|
/** Resolve queued work from GitHub evidence without consuming model or worktree resources. */
|
|
3594
3543
|
export async function preflightQueuedIssue(issue, deps) {
|
|
@@ -3690,7 +3639,7 @@ export function renderLanePausedComment(payload) {
|
|
|
3690
3639
|
].join('\n\n');
|
|
3691
3640
|
}
|
|
3692
3641
|
export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {}) {
|
|
3693
|
-
const { ship = shipIssue, waitMerge = waitForMerge, pathExists = existsSync, emitEvent = logEvent, claimNext = async () => null, releaseIssue = async () => { }, heartbeat, reapWorktree = async () => { }, sweepFinished = async () => { }, countRemaining, baseHoldGate, laneBreakerThreshold = 0, commentIssue = async () => { }, } = deps;
|
|
3642
|
+
const { ship = shipIssue, waitMerge = waitForMerge, recordStackMerge = async () => { }, pathExists = existsSync, emitEvent = logEvent, claimNext = async () => null, releaseIssue = async () => { }, heartbeat, reapWorktree = async () => { }, sweepFinished = async () => { }, countRemaining, baseHoldGate, laneBreakerThreshold = 0, commentIssue = async () => { }, } = deps;
|
|
3694
3643
|
let streak = null;
|
|
3695
3644
|
let merged = 0;
|
|
3696
3645
|
let awaitingReview = 0;
|
|
@@ -3752,6 +3701,7 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
|
|
|
3752
3701
|
streak = null;
|
|
3753
3702
|
continue;
|
|
3754
3703
|
}
|
|
3704
|
+
const layer = decision.kind === 'adopt' ? decision.stackLayer : undefined;
|
|
3755
3705
|
try {
|
|
3756
3706
|
const sliceOutcome = {};
|
|
3757
3707
|
const branch = decision.kind === 'adopt'
|
|
@@ -3774,6 +3724,15 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
|
|
|
3774
3724
|
await settle(issue, 'queued');
|
|
3775
3725
|
continue;
|
|
3776
3726
|
}
|
|
3727
|
+
if (layer) {
|
|
3728
|
+
// ADR-0161: land exactly this layer's branch, record it, and re-queue for the next layer.
|
|
3729
|
+
await withHeartbeat(issue, heartbeat, () => waitMerge(issue, branch, repoRoot, ghRepo, paths, { landBranch: branch }));
|
|
3730
|
+
await recordStackMerge(issue, layer.prNumber).catch((err) => emitEvent(paths.events, 'warn', issue, `slice ${layer.index}/${layer.count} merged but recording it failed: ${errorDetail(err)}`, { lane }));
|
|
3731
|
+
merged++;
|
|
3732
|
+
streak = null;
|
|
3733
|
+
await settle(issue, layer.index < layer.count ? 'queued' : 'done');
|
|
3734
|
+
continue;
|
|
3735
|
+
}
|
|
3777
3736
|
await withHeartbeat(issue, heartbeat, () => waitMerge(issue, branch, repoRoot, ghRepo, paths));
|
|
3778
3737
|
merged++;
|
|
3779
3738
|
streak = null;
|
|
@@ -3785,7 +3744,7 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
|
|
|
3785
3744
|
// worktree — this is a clean outcome, not a park; move to the next issue.
|
|
3786
3745
|
awaitingReview++;
|
|
3787
3746
|
streak = null;
|
|
3788
|
-
await settle(issue, 'done');
|
|
3747
|
+
await settle(issue, layer && layer.index < layer.count ? 'queued' : 'done');
|
|
3789
3748
|
continue;
|
|
3790
3749
|
}
|
|
3791
3750
|
if (err instanceof IssueSkippedError) {
|
|
@@ -3876,99 +3835,6 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
|
|
|
3876
3835
|
}
|
|
3877
3836
|
emitEvent(paths.events, 'lane-done', lane, `lane complete (${merged} merged, ${awaitingReview} awaiting review, ${skipped} skipped, ${decomposed} decomposed, ${parked} parked)`, { lane });
|
|
3878
3837
|
}
|
|
3879
|
-
export async function isPrMerged(octokit, owner, repoName, branch) {
|
|
3880
|
-
const { data: prs } = await octokit.rest.pulls.list({
|
|
3881
|
-
owner,
|
|
3882
|
-
repo: repoName,
|
|
3883
|
-
state: 'all',
|
|
3884
|
-
head: `${owner}:${branch}`,
|
|
3885
|
-
});
|
|
3886
|
-
if (prs.length === 0)
|
|
3887
|
-
return false;
|
|
3888
|
-
// Ship-it branch names get reused across separate runs for the same issue
|
|
3889
|
-
// (e.g. a "verify-and-close" pass recreates the exact branch name an
|
|
3890
|
-
// earlier, already-merged PR used) — GitHub PR numbers only increase, so
|
|
3891
|
-
// the highest-numbered PR under this head is always the current one.
|
|
3892
|
-
// Checking "was ANY PR ever merged under this branch name" (the old
|
|
3893
|
-
// `state: 'closed'` + `.some()` behavior) matched the stale prior PR and
|
|
3894
|
-
// falsely reported a brand-new, still-open PR under the same name as
|
|
3895
|
-
// merged — waitForMerge then logged "landed" and skipped calling land(),
|
|
3896
|
-
// leaving the real PR open forever while the lane believed it was done.
|
|
3897
|
-
const latest = prs.reduce((newest, pr) => (pr.number > newest.number ? pr : newest));
|
|
3898
|
-
return Boolean(latest.merged_at);
|
|
3899
|
-
}
|
|
3900
|
-
export async function findOpenPRNumber(octokit, owner, repoName, branch) {
|
|
3901
|
-
const { data: prs } = await octokit.rest.pulls.list({
|
|
3902
|
-
owner,
|
|
3903
|
-
repo: repoName,
|
|
3904
|
-
state: 'open',
|
|
3905
|
-
head: `${owner}:${branch}`,
|
|
3906
|
-
});
|
|
3907
|
-
return prs[0]?.number;
|
|
3908
|
-
}
|
|
3909
|
-
export async function findOpenPRForIssue(octokit, owner, repoName, issueNum) {
|
|
3910
|
-
const perPage = 100;
|
|
3911
|
-
const matches = new RegExp(`\\bcloses\\s+#${issueNum}\\b`, 'i');
|
|
3912
|
-
for (let page = 1;; page++) {
|
|
3913
|
-
const { data: prs } = await octokit.rest.pulls.list({
|
|
3914
|
-
owner,
|
|
3915
|
-
repo: repoName,
|
|
3916
|
-
state: 'open',
|
|
3917
|
-
per_page: perPage,
|
|
3918
|
-
page,
|
|
3919
|
-
});
|
|
3920
|
-
const pr = prs.find((p) => matches.test(p.body ?? ''));
|
|
3921
|
-
if (pr) {
|
|
3922
|
-
const headRepoFullName = pr.head.repo?.full_name ?? null;
|
|
3923
|
-
if (headRepoFullName !== `${owner}/${repoName}`) {
|
|
3924
|
-
return {
|
|
3925
|
-
number: pr.number,
|
|
3926
|
-
branch: pr.head.ref,
|
|
3927
|
-
headSha: pr.head.sha,
|
|
3928
|
-
headRepoFullName,
|
|
3929
|
-
};
|
|
3930
|
-
}
|
|
3931
|
-
return { number: pr.number, branch: pr.head.ref, headSha: pr.head.sha, headRepoFullName };
|
|
3932
|
-
}
|
|
3933
|
-
if (prs.length < perPage)
|
|
3934
|
-
return undefined;
|
|
3935
|
-
}
|
|
3936
|
-
}
|
|
3937
|
-
export async function squashMergeAndDelete(octokit, owner, repoName, branch, prNumber,
|
|
3938
|
-
/** `sha`: the CI-verified head commit. GitHub refuses the merge if the PR head has moved. */
|
|
3939
|
-
opts = {}) {
|
|
3940
|
-
const { sha } = opts;
|
|
3941
|
-
if (opts.admin) {
|
|
3942
|
-
const run = opts.run ?? shellOps.exec;
|
|
3943
|
-
const pin = sha ? ` --match-head-commit ${shellEscape(sha)}` : '';
|
|
3944
|
-
await run(`gh pr merge ${prNumber} --repo ${shellEscape(`${owner}/${repoName}`)} --admin --squash --delete-branch${pin}`);
|
|
3945
|
-
return;
|
|
3946
|
-
}
|
|
3947
|
-
await octokit.rest.pulls.merge({
|
|
3948
|
-
owner,
|
|
3949
|
-
repo: repoName,
|
|
3950
|
-
pull_number: prNumber,
|
|
3951
|
-
merge_method: 'squash',
|
|
3952
|
-
...(sha ? { sha } : {}),
|
|
3953
|
-
});
|
|
3954
|
-
// Best-effort branch delete: the merge is the source of truth.
|
|
3955
|
-
await octokit.rest.git.deleteRef({ owner, repo: repoName, ref: `heads/${branch}` }).catch(() => { });
|
|
3956
|
-
}
|
|
3957
|
-
export class MergeGatedError extends Error {
|
|
3958
|
-
issue;
|
|
3959
|
-
prNumber;
|
|
3960
|
-
label;
|
|
3961
|
-
constructor(message, issue, prNumber, label) {
|
|
3962
|
-
super(message);
|
|
3963
|
-
this.issue = issue;
|
|
3964
|
-
this.prNumber = prNumber;
|
|
3965
|
-
this.label = label;
|
|
3966
|
-
this.name = 'MergeGatedError';
|
|
3967
|
-
}
|
|
3968
|
-
}
|
|
3969
|
-
export class LandConflictError extends Error {
|
|
3970
|
-
parkReason = 'conflict';
|
|
3971
|
-
}
|
|
3972
3838
|
/** The target issue was already resolved before the run started — a clean skip,
|
|
3973
3839
|
* not a park. Callers advance to the next queue entry. (#681) */
|
|
3974
3840
|
export class IssueSkippedError extends Error {
|
|
@@ -3978,13 +3844,6 @@ export class IssueSkippedError extends Error {
|
|
|
3978
3844
|
this.reason = reason;
|
|
3979
3845
|
}
|
|
3980
3846
|
}
|
|
3981
|
-
export class AwaitingReviewError extends Error {
|
|
3982
|
-
prNumber;
|
|
3983
|
-
constructor(message, prNumber) {
|
|
3984
|
-
super(message);
|
|
3985
|
-
this.prNumber = prNumber;
|
|
3986
|
-
}
|
|
3987
|
-
}
|
|
3988
3847
|
/** The target issue tripped the PLAN pre-flight size gate and was decomposed into real
|
|
3989
3848
|
* filed sub-issues; the queue was rewritten. Not a park — the lane continues with the
|
|
3990
3849
|
* children in place of the oversized issue. (#823) */
|
|
@@ -3995,191 +3854,6 @@ export class IssueDecomposedError extends Error {
|
|
|
3995
3854
|
this.childIssues = childIssues;
|
|
3996
3855
|
}
|
|
3997
3856
|
}
|
|
3998
|
-
export class LandFailureError extends Error {
|
|
3999
|
-
code;
|
|
4000
|
-
constructor(message, code) {
|
|
4001
|
-
super(message);
|
|
4002
|
-
this.code = code;
|
|
4003
|
-
}
|
|
4004
|
-
}
|
|
4005
|
-
/** Thrown when watchChecks reports a confirmed CI failure — a completed check run with a
|
|
4006
|
-
* non-passing conclusion. A confirmed failing check must never be merged, admin override
|
|
4007
|
-
* or not. See CiUnverifiedError for the no-verdict case. */
|
|
4008
|
-
export class CiFailedError extends Error {
|
|
4009
|
-
prNumber;
|
|
4010
|
-
parkReason = 'ci-failed';
|
|
4011
|
-
constructor(message, prNumber) {
|
|
4012
|
-
super(message);
|
|
4013
|
-
this.prNumber = prNumber;
|
|
4014
|
-
}
|
|
4015
|
-
}
|
|
4016
|
-
/** Thrown when the CI watch never reached a green verdict — a deadline timeout, no check runs ever
|
|
4017
|
-
* registering, or repeated API failures. A subclass of CiFailedError so every existing guard
|
|
4018
|
-
* (parkReasonFor → 'ci-failed', worktree cleanup, cmdLand's exit-0 path) treats "we don't know"
|
|
4019
|
-
* exactly like "we know it's red": never merge. */
|
|
4020
|
-
export class CiUnverifiedError extends CiFailedError {
|
|
4021
|
-
}
|
|
4022
|
-
/** Thrown when the one infra rerun (#2365) also failed for infrastructure reasons on the same head SHA (#2366).
|
|
4023
|
-
* A CiFailedError subclass so every guard still leaves the PR open and unmerged, but it parks as
|
|
4024
|
-
* 'ci-infra', which the ADR-0155 steward skips. */
|
|
4025
|
-
export class CiInfraError extends CiFailedError {
|
|
4026
|
-
parkReason = 'ci-infra';
|
|
4027
|
-
}
|
|
4028
|
-
const MAX_MERGE_ATTEMPTS = 5;
|
|
4029
|
-
const MERGE_RETRY_BASE_MS = 5_000;
|
|
4030
|
-
/** True when GitHub refused a SHA-pinned merge because the PR head moved past the verified
|
|
4031
|
-
* commit (REST 409, or gh's "Head branch was modified"). Retrying as-is can never succeed. */
|
|
4032
|
-
export function isHeadModifiedMergeError(err) {
|
|
4033
|
-
if (err?.status === 409)
|
|
4034
|
-
return true;
|
|
4035
|
-
const msg = `${err instanceof Error ? err.message : ''}\n${errorDetail(err)}`;
|
|
4036
|
-
return /head branch was modified/i.test(msg);
|
|
4037
|
-
}
|
|
4038
|
-
export function isReviewRequiredMergeError(err, state) {
|
|
4039
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
4040
|
-
if (/approving review/i.test(msg) || /review is required/i.test(msg))
|
|
4041
|
-
return true;
|
|
4042
|
-
return state.mergeStateStatus === 'BLOCKED' && state.reviewDecision === 'REVIEW_REQUIRED';
|
|
4043
|
-
}
|
|
4044
|
-
export async function getPullRequestLandState(octokit, owner, repoName, prNumber) {
|
|
4045
|
-
const result = await octokit.graphql(`query PullRequestLandState($owner: String!, $repo: String!, $number: Int!) {
|
|
4046
|
-
repository(owner: $owner, name: $repo) {
|
|
4047
|
-
pullRequest(number: $number) {
|
|
4048
|
-
id
|
|
4049
|
-
isDraft
|
|
4050
|
-
mergeStateStatus
|
|
4051
|
-
reviewDecision
|
|
4052
|
-
}
|
|
4053
|
-
}
|
|
4054
|
-
}`, { owner, repo: repoName, number: prNumber });
|
|
4055
|
-
return result.repository?.pullRequest ?? {};
|
|
4056
|
-
}
|
|
4057
|
-
export async function markPullRequestReady(octokit, pullRequestId) {
|
|
4058
|
-
await octokit.graphql(`mutation MarkPullRequestReady($id: ID!) {
|
|
4059
|
-
markPullRequestReadyForReview(input: { pullRequestId: $id }) {
|
|
4060
|
-
pullRequest { isDraft }
|
|
4061
|
-
}
|
|
4062
|
-
}`, { id: pullRequestId });
|
|
4063
|
-
}
|
|
4064
|
-
export async function rebaseDirtyPullRequest(opts) {
|
|
4065
|
-
const { issue, branch, worktree, prNumber, log, run = shellOps.exec, pathExists = existsSync } = opts;
|
|
4066
|
-
if (!pathExists(worktree)) {
|
|
4067
|
-
const msg = `PR #${prNumber} DIRTY on ${branch} and worktree gone`;
|
|
4068
|
-
log('conflict', msg);
|
|
4069
|
-
throw new LandConflictError(msg);
|
|
4070
|
-
}
|
|
4071
|
-
try {
|
|
4072
|
-
await run('git rebase origin/main', { cwd: worktree });
|
|
4073
|
-
await run(`git push --force-with-lease origin ${shellEscape(branch)}`, { cwd: worktree });
|
|
4074
|
-
}
|
|
4075
|
-
catch {
|
|
4076
|
-
// Best-effort cleanup: the conflict logged below is the error we surface.
|
|
4077
|
-
await run('git rebase --abort', { cwd: worktree }).catch(() => { });
|
|
4078
|
-
const msg = `rebase conflict on ${branch} — parked`;
|
|
4079
|
-
log('conflict', msg);
|
|
4080
|
-
throw new LandConflictError(`issue #${issue}: ${msg}`);
|
|
4081
|
-
}
|
|
4082
|
-
}
|
|
4083
|
-
export async function landOpenPullRequest(opts) {
|
|
4084
|
-
const { octokit, owner, repoName, issue, branch, worktree, prNumber, log, run, pathExists, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), skipCI = false, adminMerge = process.env.FACTORY_MERGE_ADMIN === '1', watch = internalOps.watchChecks, rerun, withLock = (fn) => fn(), ensureWorktree, } = opts;
|
|
4085
|
-
/** Watches CI on the PR's current head commit and returns that SHA once it is green, so the
|
|
4086
|
-
* merge can be pinned to exactly the commit CI verified. */
|
|
4087
|
-
const watchCi = async () => {
|
|
4088
|
-
let headSha;
|
|
4089
|
-
let outcome;
|
|
4090
|
-
try {
|
|
4091
|
-
const { data: pr } = await octokit.rest.pulls.get({ owner, repo: repoName, pull_number: prNumber });
|
|
4092
|
-
headSha = pr.head.sha;
|
|
4093
|
-
outcome = await watchChecksWithInfraRerun({
|
|
4094
|
-
octokit,
|
|
4095
|
-
owner,
|
|
4096
|
-
repo: repoName,
|
|
4097
|
-
ref: headSha,
|
|
4098
|
-
watch,
|
|
4099
|
-
rerun,
|
|
4100
|
-
onRescued: ({ runIds }) => log('ci-rescued', `CI rescued for PR #${prNumber} on ${branch}: reran infra-failed run(s) ${runIds.join(', ')} once and the re-watch is green`),
|
|
4101
|
-
onRescueFailed: ({ runIds, outcome: failedOutcome }) => log('ci-rescue-failed', `CI rescue failed for PR #${prNumber} on ${branch}: reran infra-failed run(s) ${runIds.join(', ')} once and the re-watch ended ${failedOutcome}`),
|
|
4102
|
-
});
|
|
4103
|
-
}
|
|
4104
|
-
catch (err) {
|
|
4105
|
-
const msg = `CI watch for ${branch} failed: ${errorDetail(err)} — refusing to merge without a green verdict`;
|
|
4106
|
-
log('ci-failed', msg);
|
|
4107
|
-
throw new CiUnverifiedError(msg, prNumber);
|
|
4108
|
-
}
|
|
4109
|
-
if (outcome === 'success')
|
|
4110
|
-
return headSha;
|
|
4111
|
-
if (outcome === 'infra-failure') {
|
|
4112
|
-
const msg = `CI failed again for infrastructure reasons for PR #${prNumber} on ${branch} after one rerun — parked as ci-infra, not merged`;
|
|
4113
|
-
log('ci-infra', msg);
|
|
4114
|
-
throw new CiInfraError(msg, prNumber);
|
|
4115
|
-
}
|
|
4116
|
-
if (outcome === 'failure') {
|
|
4117
|
-
const msg = `CI failed for PR #${prNumber} on ${branch} — refusing to merge with a failing check`;
|
|
4118
|
-
log('ci-failed', msg);
|
|
4119
|
-
throw new CiFailedError(msg, prNumber);
|
|
4120
|
-
}
|
|
4121
|
-
const msg = `CI watch for ${branch} ended ${outcome} — refusing to merge without a green verdict`;
|
|
4122
|
-
log('ci-failed', msg);
|
|
4123
|
-
throw new CiUnverifiedError(msg, prNumber);
|
|
4124
|
-
};
|
|
4125
|
-
// The CI watch is a 10-20 minute wait, not a mutation: it must never run under the land
|
|
4126
|
-
// locks, or every other lane's setupWorktree serializes behind it (#645).
|
|
4127
|
-
let verifiedSha = skipCI ? undefined : await watchCi();
|
|
4128
|
-
let state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
|
|
4129
|
-
if (state.mergeStateStatus === 'DIRTY') {
|
|
4130
|
-
const rebased = await withLock(async () => {
|
|
4131
|
-
// Re-check under the lock: a sibling lane may have landed (or rebased this branch)
|
|
4132
|
-
// while we waited for it, so the DIRTY verdict read above can be stale.
|
|
4133
|
-
state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
|
|
4134
|
-
if (state.mergeStateStatus !== 'DIRTY')
|
|
4135
|
-
return false;
|
|
4136
|
-
await ensureWorktree?.();
|
|
4137
|
-
await rebaseDirtyPullRequest({ issue, branch, worktree, prNumber, log, run, pathExists });
|
|
4138
|
-
return true;
|
|
4139
|
-
});
|
|
4140
|
-
if (rebased) {
|
|
4141
|
-
if (!skipCI) {
|
|
4142
|
-
verifiedSha = await watchCi();
|
|
4143
|
-
}
|
|
4144
|
-
state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
|
|
4145
|
-
}
|
|
4146
|
-
}
|
|
4147
|
-
for (let attempt = 1;; attempt++) {
|
|
4148
|
-
try {
|
|
4149
|
-
await withLock(async () => {
|
|
4150
|
-
// Same rule as the rebase section: whatever we read before taking the lock (or before
|
|
4151
|
-
// the backoff sleep) may be stale, so the attempt decides from a fresh read.
|
|
4152
|
-
state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
|
|
4153
|
-
if (state.isDraft && state.id) {
|
|
4154
|
-
log('land', `PR #${prNumber} still a draft — re-issuing ready-for-review (attempt ${attempt})`);
|
|
4155
|
-
await markPullRequestReady(octokit, state.id).catch((err) => log('warn', `ready-for-review flip failed for PR #${prNumber}: ${errorDetail(err)}`));
|
|
4156
|
-
}
|
|
4157
|
-
await squashMergeAndDelete(octokit, owner, repoName, branch, prNumber, {
|
|
4158
|
-
admin: adminMerge,
|
|
4159
|
-
run,
|
|
4160
|
-
sha: verifiedSha,
|
|
4161
|
-
});
|
|
4162
|
-
});
|
|
4163
|
-
return;
|
|
4164
|
-
}
|
|
4165
|
-
catch (err) {
|
|
4166
|
-
if (verifiedSha && isHeadModifiedMergeError(err)) {
|
|
4167
|
-
const msg = `PR #${prNumber} head moved past CI-verified commit ${verifiedSha} — refusing to merge an unverified head`;
|
|
4168
|
-
log('ci-failed', msg);
|
|
4169
|
-
throw new CiUnverifiedError(msg, prNumber);
|
|
4170
|
-
}
|
|
4171
|
-
if (isReviewRequiredMergeError(err, state)) {
|
|
4172
|
-
log('awaiting-review', `PR #${prNumber} blocked on human review — leaving open for approval`);
|
|
4173
|
-
throw new AwaitingReviewError(`PR #${prNumber} awaiting review: ${err.message}`, prNumber);
|
|
4174
|
-
}
|
|
4175
|
-
if (attempt >= MAX_MERGE_ATTEMPTS)
|
|
4176
|
-
throw err;
|
|
4177
|
-
log('land', `merge attempt ${attempt}/${MAX_MERGE_ATTEMPTS} failed (${err.message}); mergeStateStatus=${state.mergeStateStatus ?? 'unknown'} — retrying with backoff`);
|
|
4178
|
-
// Backoff outside the lock: waiting is not a mutation (#645).
|
|
4179
|
-
await sleep(MERGE_RETRY_BASE_MS * 2 ** (attempt - 1));
|
|
4180
|
-
}
|
|
4181
|
-
}
|
|
4182
|
-
}
|
|
4183
3857
|
export function issueFromFactoryBranch(branch, prefix) {
|
|
4184
3858
|
return factoryBranchIssue(branch, prefix) ?? undefined;
|
|
4185
3859
|
}
|
|
@@ -4261,105 +3935,6 @@ export async function cmdResumeApproved(opts = {}) {
|
|
|
4261
3935
|
throw new CliExitError(`factory: resume-approved: ${result.failed.length} PR(s) failed to land`, 5);
|
|
4262
3936
|
}
|
|
4263
3937
|
}
|
|
4264
|
-
/** Consecutive merged-state check failures tolerated before waitForMerge parks the lane.
|
|
4265
|
-
* At the shipped 120s poll that is ~20 minutes of 100% failure (ADR: sustained-failure park). */
|
|
4266
|
-
export const MERGE_CHECK_MAX_CONSECUTIVE_FAILURES = 10;
|
|
4267
|
-
/** Wall-clock backstop: continuous merged-state check failure for this long parks the lane even
|
|
4268
|
-
* if fewer than MERGE_CHECK_MAX_CONSECUTIVE_FAILURES attempts were made (slower poll cadence). */
|
|
4269
|
-
export const MERGE_CHECK_FAILURE_BUDGET_MS = 2 * 60 * 60 * 1000;
|
|
4270
|
-
/** GitHub signals both primary and secondary rate limits with 403 — the same status it uses for a
|
|
4271
|
-
* token that genuinely lacks the scope. Only a 403 with no rate-limit evidence is permanent. */
|
|
4272
|
-
function looksRateLimited(err) {
|
|
4273
|
-
const headers = err?.response?.headers ?? {};
|
|
4274
|
-
const remaining = headers['x-ratelimit-remaining'];
|
|
4275
|
-
if (typeof remaining === 'string' && remaining.trim() === '0')
|
|
4276
|
-
return true;
|
|
4277
|
-
if (typeof remaining === 'number' && remaining === 0)
|
|
4278
|
-
return true;
|
|
4279
|
-
if (headers['retry-after'] !== undefined)
|
|
4280
|
-
return true;
|
|
4281
|
-
return /rate limit|secondary rate|abuse detection/i.test(errorDetail(err));
|
|
4282
|
-
}
|
|
4283
|
-
/** True when a merged-state check failure cannot recover by retrying: an expired/revoked token
|
|
4284
|
-
* (401) or a token missing `pulls:read` (403 with no rate-limit evidence). */
|
|
4285
|
-
export function isPermanentMergeCheckError(err) {
|
|
4286
|
-
const status = err?.status;
|
|
4287
|
-
if (status === 401)
|
|
4288
|
-
return true;
|
|
4289
|
-
if (status !== 403)
|
|
4290
|
-
return false;
|
|
4291
|
-
return !looksRateLimited(err);
|
|
4292
|
-
}
|
|
4293
|
-
async function defaultListIssueLabels(octokit, owner, repo, issue) {
|
|
4294
|
-
const { data } = await octokit.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: issue });
|
|
4295
|
-
return data.map((label) => label.name);
|
|
4296
|
-
}
|
|
4297
|
-
export async function waitForMerge(issue, branch, repoRoot, ghRepo, paths, deps = {}) {
|
|
4298
|
-
const { createOctokit = getOctokit, pathExists = existsSync, checkMerged = isPrMerged, loadConfig = configLoaders.loadFactoryConfigForRepo, land = landIssue, listIssueLabels = defaultListIssueLabels, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), emitEvent = logEvent, mergeEnabled, writeLine = (line) => console.log(line), now = () => Date.now(), mergeOverrides, } = deps;
|
|
4299
|
-
const factoryConfig = loadConfig(paths.config);
|
|
4300
|
-
const isMergeEnabled = mergeEnabled ?? (() => resolveMergePolicy(factoryConfig, process.env, mergeOverrides).auto);
|
|
4301
|
-
const skipCI = configLoaders.resolveSkipCI(factoryConfig);
|
|
4302
|
-
const filingPolicy = resolveFilingPolicy(factoryConfig);
|
|
4303
|
-
const octokit = createOctokit();
|
|
4304
|
-
const [owner, repoName] = ghRepo.split('/');
|
|
4305
|
-
emitEvent(paths.events, 'await-merge', issue, `waiting to merge ${branch}`);
|
|
4306
|
-
let consecutiveFailures = 0;
|
|
4307
|
-
let firstFailureAt = null;
|
|
4308
|
-
while (!pathExists(paths.stop)) {
|
|
4309
|
-
// The merge wait logs nothing on its happy path, so each poll bumps the per-issue heartbeat:
|
|
4310
|
-
// status/TUI staleness rules (ADR-0086, ADR-0093) otherwise read a live wait as a dead run.
|
|
4311
|
-
if (paths.runs)
|
|
4312
|
-
touchRunActivity(phaseSnapshotFile(paths.runs, issue), new Date().toISOString()).catch(() => { });
|
|
4313
|
-
let merged = false;
|
|
4314
|
-
try {
|
|
4315
|
-
merged = await checkMerged(octokit, owner, repoName, branch);
|
|
4316
|
-
consecutiveFailures = 0;
|
|
4317
|
-
firstFailureAt = null;
|
|
4318
|
-
}
|
|
4319
|
-
catch (err) {
|
|
4320
|
-
consecutiveFailures++;
|
|
4321
|
-
firstFailureAt ??= now();
|
|
4322
|
-
const detail = errorDetail(err);
|
|
4323
|
-
emitEvent(paths.events, 'warn', issue, `merged-state check failed (treating as not merged): ${detail}`);
|
|
4324
|
-
const permanent = isPermanentMergeCheckError(err);
|
|
4325
|
-
const elapsedMs = now() - firstFailureAt;
|
|
4326
|
-
const budgetSpent = elapsedMs >= MERGE_CHECK_FAILURE_BUDGET_MS;
|
|
4327
|
-
const streakSpent = consecutiveFailures >= MERGE_CHECK_MAX_CONSECUTIVE_FAILURES;
|
|
4328
|
-
if (permanent || streakSpent || budgetSpent) {
|
|
4329
|
-
const trigger = permanent
|
|
4330
|
-
? 'permanent error (auth/scope — retrying cannot help)'
|
|
4331
|
-
: `${consecutiveFailures} consecutive failures over ${Math.round(elapsedMs / 1000)}s`;
|
|
4332
|
-
const msg = `merged-state check for ${branch} is not recovering — parking lane after ${trigger}: ${detail}`;
|
|
4333
|
-
emitEvent(paths.events, 'escalate', issue, msg);
|
|
4334
|
-
throw new LaneParkError(msg, 'escalate');
|
|
4335
|
-
}
|
|
4336
|
-
}
|
|
4337
|
-
if (merged) {
|
|
4338
|
-
emitEvent(paths.events, 'landed', issue, 'PR merged');
|
|
4339
|
-
return;
|
|
4340
|
-
}
|
|
4341
|
-
if (isMergeEnabled()) {
|
|
4342
|
-
let labels = [];
|
|
4343
|
-
try {
|
|
4344
|
-
labels = await listIssueLabels(octokit, owner, repoName, issue);
|
|
4345
|
-
}
|
|
4346
|
-
catch (err) {
|
|
4347
|
-
emitEvent(paths.events, 'warn', issue, `label check failed (treating as blocked): ${errorDetail(err)}`);
|
|
4348
|
-
labels = [filingPolicy.selfFixLabel];
|
|
4349
|
-
}
|
|
4350
|
-
if (isAutoMergeBlocked(labels, filingPolicy)) {
|
|
4351
|
-
emitEvent(paths.events, 'merge-gated', issue, `auto-merge blocked by ${filingPolicy.selfFixLabel} — awaiting human approval`);
|
|
4352
|
-
writeLine(`[factory] #${issue} auto-merge gated (${filingPolicy.selfFixLabel}); awaiting human merge (poll 120s)`);
|
|
4353
|
-
await sleep(120_000);
|
|
4354
|
-
continue;
|
|
4355
|
-
}
|
|
4356
|
-
await land(issue, repoRoot, ghRepo, paths, octokit, skipCI);
|
|
4357
|
-
return;
|
|
4358
|
-
}
|
|
4359
|
-
writeLine(`[factory] #${issue} awaiting human merge (poll 120s)`);
|
|
4360
|
-
await sleep(120_000);
|
|
4361
|
-
}
|
|
4362
|
-
}
|
|
4363
3938
|
const USAGE_UNAVAILABLE_LINE = '[factory] supervise: usage signal unavailable — proceeding without resume gate (set FACTORY_USAGE_ESTIMATOR=1 to gate on the heuristic)';
|
|
4364
3939
|
export async function superviseLoop(deps) {
|
|
4365
3940
|
const { cap, resumeAt, pollMs, watch = true, watchSource = 'default', resumeAtSource = 'default', pollSource = 'default', estimator = false, stopFile, eventsFile, now, runQueue, readUsageFn = () => readUsage({ cap, estimator }), pathExists = existsSync, clearStop = (path) => rmSync(path, { force: true }), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), emitEvent = logEvent, writeLine = (line) => console.log(line), ingest, } = deps;
|
|
@@ -4521,6 +4096,8 @@ async function cmdDoctor(opts = {}) {
|
|
|
4521
4096
|
},
|
|
4522
4097
|
fileExists: existsSync,
|
|
4523
4098
|
}),
|
|
4099
|
+
claudeSessionState: () => claudeStoredSessionState(),
|
|
4100
|
+
localAuthFailure: (agent) => localAuthFailureSinceLogin(agent),
|
|
4524
4101
|
opencodeLoggedIn: () => hasOpencodeLoginEvidence(),
|
|
4525
4102
|
commandHarnesses: () => repoRoot === null
|
|
4526
4103
|
? []
|