looply-cli 0.5.10 → 0.5.12
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/.src-hash +1 -1
- package/dist/autonomy-controller.d.ts +23 -1
- package/dist/autonomy-controller.d.ts.map +1 -1
- package/dist/autonomy-controller.js +50 -9
- package/dist/autonomy-controller.js.map +1 -1
- package/dist/autonomy-store.d.ts +33 -1
- package/dist/autonomy-store.d.ts.map +1 -1
- package/dist/autonomy-store.js +58 -8
- package/dist/autonomy-store.js.map +1 -1
- package/dist/autonomy-tick.d.ts +23 -1
- package/dist/autonomy-tick.d.ts.map +1 -1
- package/dist/autonomy-tick.js +80 -14
- package/dist/autonomy-tick.js.map +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +34 -5
- package/dist/bin.js.map +1 -1
- package/dist/branch-delivery-wiring.d.ts +4 -1
- package/dist/branch-delivery-wiring.d.ts.map +1 -1
- package/dist/branch-delivery-wiring.js +39 -12
- package/dist/branch-delivery-wiring.js.map +1 -1
- package/dist/branch-delivery.d.ts +157 -8
- package/dist/branch-delivery.d.ts.map +1 -1
- package/dist/branch-delivery.js +580 -82
- package/dist/branch-delivery.js.map +1 -1
- package/dist/branch-replay.d.ts +45 -0
- package/dist/branch-replay.d.ts.map +1 -0
- package/dist/branch-replay.js +123 -0
- package/dist/branch-replay.js.map +1 -0
- package/dist/commit-ownership.d.ts +102 -0
- package/dist/commit-ownership.d.ts.map +1 -0
- package/dist/commit-ownership.js +97 -0
- package/dist/commit-ownership.js.map +1 -0
- package/dist/coordinator-state.d.ts.map +1 -1
- package/dist/coordinator-state.js +23 -1
- package/dist/coordinator-state.js.map +1 -1
- package/dist/daemon-cmd.d.ts.map +1 -1
- package/dist/daemon-cmd.js +47 -4
- package/dist/daemon-cmd.js.map +1 -1
- package/dist/deliver-cmd.d.ts +18 -0
- package/dist/deliver-cmd.d.ts.map +1 -1
- package/dist/deliver-cmd.js +71 -5
- package/dist/deliver-cmd.js.map +1 -1
- package/dist/delivery-settings-cache.d.ts +15 -1
- package/dist/delivery-settings-cache.d.ts.map +1 -1
- package/dist/delivery-settings-cache.js +31 -3
- package/dist/delivery-settings-cache.js.map +1 -1
- package/dist/environment-hold.d.ts +69 -0
- package/dist/environment-hold.d.ts.map +1 -0
- package/dist/environment-hold.js +120 -0
- package/dist/environment-hold.js.map +1 -0
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +15 -1
- package/dist/gate.js.map +1 -1
- package/dist/github-api.d.ts +19 -0
- package/dist/github-api.d.ts.map +1 -1
- package/dist/github-api.js +26 -0
- package/dist/github-api.js.map +1 -1
- package/dist/goal-adaptive.d.ts +1 -0
- package/dist/goal-adaptive.d.ts.map +1 -1
- package/dist/goal-adaptive.js +26 -13
- package/dist/goal-adaptive.js.map +1 -1
- package/dist/goal-compile.d.ts +37 -0
- package/dist/goal-compile.d.ts.map +1 -1
- package/dist/goal-compile.js +25 -1
- package/dist/goal-compile.js.map +1 -1
- package/dist/goal-replay.d.ts +83 -0
- package/dist/goal-replay.d.ts.map +1 -0
- package/dist/goal-replay.js +121 -0
- package/dist/goal-replay.js.map +1 -0
- package/dist/goal-reproof.d.ts +5 -0
- package/dist/goal-reproof.d.ts.map +1 -1
- package/dist/goal-reproof.js +11 -4
- package/dist/goal-reproof.js.map +1 -1
- package/dist/grant-pause.d.ts +48 -0
- package/dist/grant-pause.d.ts.map +1 -0
- package/dist/grant-pause.js +103 -0
- package/dist/grant-pause.js.map +1 -0
- package/dist/grant-reconcile.d.ts +8 -2
- package/dist/grant-reconcile.d.ts.map +1 -1
- package/dist/grant-reconcile.js +9 -2
- package/dist/grant-reconcile.js.map +1 -1
- package/dist/grant-waiting-wiring.d.ts +49 -0
- package/dist/grant-waiting-wiring.d.ts.map +1 -0
- package/dist/grant-waiting-wiring.js +77 -0
- package/dist/grant-waiting-wiring.js.map +1 -0
- package/dist/machine-run-slots.d.ts +10 -2
- package/dist/machine-run-slots.d.ts.map +1 -1
- package/dist/machine-run-slots.js +15 -4
- package/dist/machine-run-slots.js.map +1 -1
- package/dist/maintenance-cmd.d.ts +44 -0
- package/dist/maintenance-cmd.d.ts.map +1 -0
- package/dist/maintenance-cmd.js +132 -0
- package/dist/maintenance-cmd.js.map +1 -0
- package/dist/maintenance-facts.d.ts +52 -0
- package/dist/maintenance-facts.d.ts.map +1 -0
- package/dist/maintenance-facts.js +256 -0
- package/dist/maintenance-facts.js.map +1 -0
- package/dist/maintenance-tick.d.ts +51 -0
- package/dist/maintenance-tick.d.ts.map +1 -0
- package/dist/maintenance-tick.js +156 -0
- package/dist/maintenance-tick.js.map +1 -0
- package/dist/planner-timeouts.d.ts +14 -0
- package/dist/planner-timeouts.d.ts.map +1 -0
- package/dist/planner-timeouts.js +51 -0
- package/dist/planner-timeouts.js.map +1 -0
- package/dist/run-cmd.d.ts +12 -1
- package/dist/run-cmd.d.ts.map +1 -1
- package/dist/run-cmd.js +146 -10
- package/dist/run-cmd.js.map +1 -1
- package/dist/run-launcher.d.ts +3 -0
- package/dist/run-launcher.d.ts.map +1 -1
- package/dist/run-launcher.js +3 -2
- package/dist/run-launcher.js.map +1 -1
- package/dist/run-refusal-marks.d.ts +7 -1
- package/dist/run-refusal-marks.d.ts.map +1 -1
- package/dist/run-refusal-marks.js +4 -0
- package/dist/run-refusal-marks.js.map +1 -1
- package/dist/server-client.d.ts +39 -1
- package/dist/server-client.d.ts.map +1 -1
- package/dist/server-client.js +28 -0
- package/dist/server-client.js.map +1 -1
- package/dist/start-attempts.d.ts +4 -2
- package/dist/start-attempts.d.ts.map +1 -1
- package/dist/start-attempts.js +6 -1
- package/dist/start-attempts.js.map +1 -1
- package/package.json +2 -2
package/dist/branch-delivery.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.NO_CHECKS_GRACE_MS = exports.CHECKS_PATIENCE_MS = exports.BRANCH_DELIVERIES_FILE = void 0;
|
|
3
|
+
exports.FOLLOW_DEPLOYS_MS = exports.NO_CHECKS_GRACE_MS = exports.CHECKS_PATIENCE_MS = exports.BRANCH_DELIVERIES_FILE = void 0;
|
|
4
4
|
exports.nodeDeliveryGit = nodeDeliveryGit;
|
|
5
5
|
exports.makeDeployPush = makeDeployPush;
|
|
6
6
|
exports.fileDeliveryStore = fileDeliveryStore;
|
|
@@ -8,8 +8,12 @@ exports.memoryDeliveryStore = memoryDeliveryStore;
|
|
|
8
8
|
exports.findGoalBranch = findGoalBranch;
|
|
9
9
|
exports.stackedOn = stackedOn;
|
|
10
10
|
exports.pullTitle = pullTitle;
|
|
11
|
+
exports.maintenanceSection = maintenanceSection;
|
|
11
12
|
exports.pullBody = pullBody;
|
|
12
13
|
exports.deliverBranch = deliverBranch;
|
|
14
|
+
exports.describeForeign = describeForeign;
|
|
15
|
+
exports.unmergedForeign = unmergedForeign;
|
|
16
|
+
exports.mergeGuard = mergeGuard;
|
|
13
17
|
exports.decideChecks = decideChecks;
|
|
14
18
|
exports.advanceDeliveries = advanceDeliveries;
|
|
15
19
|
exports.makeReleaseRunner = makeReleaseRunner;
|
|
@@ -19,8 +23,12 @@ const node_path_1 = require("node:path");
|
|
|
19
23
|
const delivery_rules_1 = require("./delivery-rules");
|
|
20
24
|
const git_driver_1 = require("./git-driver");
|
|
21
25
|
const integrator_1 = require("./integrator");
|
|
26
|
+
const branch_replay_1 = require("./branch-replay");
|
|
27
|
+
const commit_ownership_1 = require("./commit-ownership");
|
|
22
28
|
const run_base_1 = require("./run-base");
|
|
23
|
-
function nodeDeliveryGit(cwd, configuredBase, rawExec = (0, git_driver_1.nodeGitExec)(cwd)
|
|
29
|
+
function nodeDeliveryGit(cwd, configuredBase, rawExec = (0, git_driver_1.nodeGitExec)(cwd),
|
|
30
|
+
/** Where a rebuild's temporary worktree goes (os tmpdir by default), and how git is run in it. */
|
|
31
|
+
rebuild = {}) {
|
|
24
32
|
/**
|
|
25
33
|
* NO SIGN-IN WINDOW, EVER. The network verbs here (push, ls-remote) reach the
|
|
26
34
|
* person's credential helper — Git Credential Manager on Windows — which opens
|
|
@@ -77,6 +85,47 @@ function nodeDeliveryGit(cwd, configuredBase, rawExec = (0, git_driver_1.nodeGit
|
|
|
77
85
|
return { ok: true };
|
|
78
86
|
return { ok: false, because: (0, delivery_rules_1.redactUrl)(`${r.stderr}\n${r.stdout}`.trim()).slice(0, 400) || `git push exited ${r.code}` };
|
|
79
87
|
},
|
|
88
|
+
async freshBase(base) {
|
|
89
|
+
const name = (0, delivery_rules_1.shortBranch)(base);
|
|
90
|
+
if (/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name) && !name.includes('..')) {
|
|
91
|
+
// Read-only for the person: it moves origin's remote-tracking copy and nothing else.
|
|
92
|
+
await out(['fetch', '--quiet', '--no-tags', delivery_rules_1.DELIVERY_REMOTE, `refs/heads/${name}:refs/remotes/${delivery_rules_1.DELIVERY_REMOTE}/${name}`]).catch(() => undefined);
|
|
93
|
+
}
|
|
94
|
+
const tracking = `refs/remotes/${delivery_rules_1.DELIVERY_REMOTE}/${name}`;
|
|
95
|
+
return (await driver.refExists(tracking).catch(() => false)) ? tracking : base;
|
|
96
|
+
},
|
|
97
|
+
async commitsBetween(base, tip) {
|
|
98
|
+
const r = await out(['rev-list', '--reverse', '--topo-order', '--parents', `${base}..${tip}`, '--']).catch(() => null);
|
|
99
|
+
if (r === null || r.code !== 0)
|
|
100
|
+
return null;
|
|
101
|
+
return r.stdout
|
|
102
|
+
.split(/\r?\n/)
|
|
103
|
+
.map((l) => l.trim().split(/\s+/))
|
|
104
|
+
.filter((ids) => /^[0-9a-f]{40}$/.test(ids[0] ?? ''))
|
|
105
|
+
.map(([sha, ...parents]) => ({ sha: sha, parents }));
|
|
106
|
+
},
|
|
107
|
+
async replay(base, commits) {
|
|
108
|
+
try {
|
|
109
|
+
return await (0, branch_replay_1.replayOnto)(exec, (dir) => {
|
|
110
|
+
const inner = (rebuild.execAt ?? git_driver_1.nodeGitExec)(dir);
|
|
111
|
+
return (args) => inner(['-c', 'credential.interactive=never', ...args]);
|
|
112
|
+
}, base, commits, rebuild.scratchRoot);
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
return { ok: false, because: e.message.slice(0, 300) };
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
async treeOf(ref) {
|
|
119
|
+
const r = await out(['rev-parse', '--verify', '--quiet', `${ref}^{tree}`]).catch(() => null);
|
|
120
|
+
return r !== null && r.code === 0 && r.stdout.trim() !== '' ? r.stdout.trim() : null;
|
|
121
|
+
},
|
|
122
|
+
async createBranch(name, sha) {
|
|
123
|
+
// `branch` without `-f` refuses an existing name, so this can only ever ADD a branch.
|
|
124
|
+
if (!name.startsWith(integrator_1.RUN_BRANCH_PREFIX) || !/^[0-9a-f]{40}$/.test(sha))
|
|
125
|
+
return false;
|
|
126
|
+
const r = await out(['branch', '--no-track', name, sha]).catch(() => null);
|
|
127
|
+
return r !== null && r.code === 0;
|
|
128
|
+
},
|
|
80
129
|
};
|
|
81
130
|
}
|
|
82
131
|
/**
|
|
@@ -174,12 +223,7 @@ async function findGoalBranch(git, input, branches) {
|
|
|
174
223
|
return null;
|
|
175
224
|
}
|
|
176
225
|
/** Which goal a run branch belongs to, from the lane claims; undefined when nothing says. */
|
|
177
|
-
|
|
178
|
-
for (let i = claims.length - 1; i >= 0; i--)
|
|
179
|
-
if (claims[i].branch === branch)
|
|
180
|
-
return claims[i].goalId;
|
|
181
|
-
return undefined;
|
|
182
|
-
}
|
|
226
|
+
const goalOfBranch = commit_ownership_1.ownerOfBranch;
|
|
183
227
|
/**
|
|
184
228
|
* THE OTHER, UNMERGED GOAL'S BRANCH THIS ONE IS BUILT ON — or null.
|
|
185
229
|
*
|
|
@@ -235,6 +279,7 @@ function reportOf(e) {
|
|
|
235
279
|
dependsOn: e.dependsOn,
|
|
236
280
|
mergeSha: e.mergeSha ?? null,
|
|
237
281
|
deploy: e.deploy ?? null,
|
|
282
|
+
...(e.environments === undefined ? {} : { environments: e.environments }),
|
|
238
283
|
};
|
|
239
284
|
}
|
|
240
285
|
async function save(deps, e) {
|
|
@@ -253,6 +298,37 @@ function pullTitle(goalText) {
|
|
|
253
298
|
const clipped = first.length > 72 ? `${first.slice(0, 71).trimEnd()}…` : first;
|
|
254
299
|
return `Looply: ${clipped}`;
|
|
255
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* The maintenance part of a pull request's description: what changed, the
|
|
303
|
+
* changelog and advisory links, the build note, and why Looply will not merge it.
|
|
304
|
+
*/
|
|
305
|
+
function maintenanceSection(m) {
|
|
306
|
+
const kind = m.kind === 'upgrade' ? 'dependency upgrade' : m.kind === 'security' ? 'security fixes' : 'unused dependencies and dead code';
|
|
307
|
+
const out = [`### Scheduled maintenance: ${kind} (${m.week}, ${m.root === '.' ? 'repository root' : m.root}, ${m.packageManager})`];
|
|
308
|
+
if (m.packages.length > 0) {
|
|
309
|
+
out.push('', '| Package | From | To | Step |', '|---|---|---|---|');
|
|
310
|
+
for (const p of m.packages)
|
|
311
|
+
out.push(`| \`${p.name}\`${p.dev ? ' (dev)' : ''}${p.native ? ' (native)' : ''} | ${p.from} | ${p.to} | ${p.bump} |`);
|
|
312
|
+
}
|
|
313
|
+
if (m.advisories.length > 0) {
|
|
314
|
+
out.push('', 'Advisories:');
|
|
315
|
+
for (const a of m.advisories)
|
|
316
|
+
out.push(`- ${a.severity}: ${a.package}: ${a.url ? `[${a.title}](${a.url})` : a.title}`);
|
|
317
|
+
}
|
|
318
|
+
if (m.links.length > 0 && m.kind !== 'security') {
|
|
319
|
+
out.push('', 'Changelogs:');
|
|
320
|
+
for (const l of m.links)
|
|
321
|
+
out.push(`- [${l.label}](${l.url})`);
|
|
322
|
+
}
|
|
323
|
+
if (m.buildNote)
|
|
324
|
+
out.push('', `⚠️ ${m.buildNote}`);
|
|
325
|
+
if (!m.autoMerge) {
|
|
326
|
+
out.push('', m.major
|
|
327
|
+
? '🔒 This includes a major upgrade. Looply never merges it automatically, whatever "When Looply finishes" says: a person reviews and merges it.'
|
|
328
|
+
: '🔒 This changes native modules. Looply never merges it automatically: a person merges it once a native build is planned.');
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
256
332
|
function pullBody(input) {
|
|
257
333
|
const quoted = input.goalText.trim().slice(0, 3000).split(/\r?\n/).map((l) => `> ${l}`).join('\n');
|
|
258
334
|
const checks = Object.entries(input.checks ?? {}).map(([k, v]) => `${k} ${v}`).join(', ');
|
|
@@ -268,8 +344,10 @@ function pullBody(input) {
|
|
|
268
344
|
? 'a person reviews and merges.'
|
|
269
345
|
: input.mode === 'merge'
|
|
270
346
|
? 'Looply merges this once the required CI checks are green.'
|
|
271
|
-
: "Looply merges this once the required CI checks are green, then runs the project's declared release step."}`,
|
|
347
|
+
: "Looply merges this once the required CI checks are green, then runs the project's declared release step."}${input.modeCapped ? ` (${input.modeCapped}.)` : ''}`,
|
|
272
348
|
];
|
|
349
|
+
if (input.maintenance)
|
|
350
|
+
lines.push('', ...maintenanceSection(input.maintenance));
|
|
273
351
|
if (input.stacked !== null) {
|
|
274
352
|
lines.push('');
|
|
275
353
|
if (input.stacked.onOrigin) {
|
|
@@ -280,6 +358,14 @@ function pullBody(input) {
|
|
|
280
358
|
lines.push(`⚠️ This builds on \`${input.stacked.branch}\`${input.stacked.goalId ? ` (goal ${input.stacked.goalId})` : ''}, which is not merged and not on GitHub, so this pull request also contains that branch's commits. Review or merge that work first.`);
|
|
281
359
|
}
|
|
282
360
|
}
|
|
361
|
+
if (input.rebuilt) {
|
|
362
|
+
lines.push('');
|
|
363
|
+
lines.push(`This branch was rebuilt from \`${input.rebuilt.from}\` onto the base with only this goal's own commits: ${input.rebuilt.without}.`);
|
|
364
|
+
}
|
|
365
|
+
if (input.foreign) {
|
|
366
|
+
lines.push('');
|
|
367
|
+
lines.push(`⚠️ ${input.foreign.charAt(0).toUpperCase()}${input.foreign.slice(1)}. Looply will not merge this while that is so.`);
|
|
368
|
+
}
|
|
283
369
|
return lines.join('\n');
|
|
284
370
|
}
|
|
285
371
|
/**
|
|
@@ -313,6 +399,7 @@ async function deliverOnce(deps, input) {
|
|
|
313
399
|
base: null,
|
|
314
400
|
projectBase: null,
|
|
315
401
|
mode: input.mode,
|
|
402
|
+
...(input.maintenance && !input.maintenance.autoMerge ? { noAutoMerge: true } : {}),
|
|
316
403
|
state: 'not-pushed',
|
|
317
404
|
note: null,
|
|
318
405
|
dependsOn: null,
|
|
@@ -332,10 +419,14 @@ async function deliverOnce(deps, input) {
|
|
|
332
419
|
? `there is no local branch ${input.branch} (only ${integrator_1.RUN_BRANCH_PREFIX}* branches are delivered)`
|
|
333
420
|
: `no ${integrator_1.RUN_BRANCH_PREFIX}* branch on this machine holds commit ${input.sha.slice(0, 12)}`);
|
|
334
421
|
}
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
422
|
+
const original = { name: found.name, sha: found.sha };
|
|
423
|
+
let branch = found.name;
|
|
424
|
+
let tip = found.sha;
|
|
425
|
+
// Already done at this tip — as it is, or as its rebuild: say it again (the server may have missed it), do nothing else.
|
|
426
|
+
if (input.replace !== true &&
|
|
427
|
+
prior !== null &&
|
|
428
|
+
prior.pullRequestUrl !== null &&
|
|
429
|
+
((prior.branch === branch && prior.sha === tip) || (prior.rebuiltFrom?.branch === branch && prior.rebuiltFrom.sha === tip))) {
|
|
339
430
|
return save(deps, { ...prior, updatedAt: at });
|
|
340
431
|
}
|
|
341
432
|
const remoteUrl = await deps.git.remoteUrl();
|
|
@@ -350,6 +441,30 @@ async function deliverOnce(deps, input) {
|
|
|
350
441
|
const refused = (0, delivery_rules_1.pushRefusal)({ remote: delivery_rules_1.DELIVERY_REMOTE, remoteUrl, branch, base, protectedBranches });
|
|
351
442
|
if (refused !== null)
|
|
352
443
|
return notPushed(branch, refused);
|
|
444
|
+
/**
|
|
445
|
+
* OTHER GOALS' UNMERGED COMMITS UNDER THIS ONE: REBUILD WITHOUT THEM, WHEN IT CAN.
|
|
446
|
+
* A pull request that carries another ticket's unreviewed work is one a green check — or an
|
|
447
|
+
* "Approve and ship" — would merge into the base with it. So unless that other goal is one
|
|
448
|
+
* this goal DECLARES it depends on (then the stacking is the point, and the merge guard holds
|
|
449
|
+
* it until the dependency merges), the goal's own commits are replayed onto the base as
|
|
450
|
+
* `<branch>-solo`, and that is what is delivered. When the replay refuses, the branch goes as
|
|
451
|
+
* it is, and says so.
|
|
452
|
+
*/
|
|
453
|
+
const onOrigin = await deps.git.remoteRunBranches().catch(() => new Set());
|
|
454
|
+
const plan = await planRebuild(deps, input, { branch, tip, base, branches, onOrigin, prior, at });
|
|
455
|
+
const rebuilt = plan.kind === 'rebuilt' ? plan : null;
|
|
456
|
+
const foreignLeft = plan.kind === 'stacked' ? plan.foreign : [];
|
|
457
|
+
const knownBranches = rebuilt === null ? branches : [...branches, { name: rebuilt.branch, sha: rebuilt.sha }];
|
|
458
|
+
if (rebuilt !== null) {
|
|
459
|
+
branch = rebuilt.branch;
|
|
460
|
+
tip = rebuilt.sha;
|
|
461
|
+
deps.log(`deliver : rebuilt ${original.name} as ${branch} (${tip.slice(0, 12)}) without ${rebuilt.withoutText}`);
|
|
462
|
+
if (api !== null && (await api.branchProtected(branch)) === true)
|
|
463
|
+
protectedBranches.push(branch);
|
|
464
|
+
const refusedSolo = (0, delivery_rules_1.pushRefusal)({ remote: delivery_rules_1.DELIVERY_REMOTE, remoteUrl, branch, base, protectedBranches });
|
|
465
|
+
if (refusedSolo !== null)
|
|
466
|
+
return notPushed(branch, refusedSolo);
|
|
467
|
+
}
|
|
353
468
|
// Never forced: origin's copy must be absent, this tip, or something this tip contains.
|
|
354
469
|
const theirs = await deps.git.remoteSha(branch);
|
|
355
470
|
if (theirs !== null && theirs !== tip && !(await deps.git.isAncestor(theirs, tip))) {
|
|
@@ -361,9 +476,8 @@ async function deliverOnce(deps, input) {
|
|
|
361
476
|
return notPushed(branch, `git push to origin failed: ${pushed.because}`);
|
|
362
477
|
deps.log(`deliver : pushed ${branch} (${tip.slice(0, 12)}) to origin`);
|
|
363
478
|
}
|
|
364
|
-
const onOrigin = await deps.git.remoteRunBranches().catch(() => new Set());
|
|
365
479
|
onOrigin.add(branch);
|
|
366
|
-
const stack = await stackedOn(deps.git, { branch, tip, goalId: input.goalId, base, claims: input.claims, branches, onOrigin });
|
|
480
|
+
const stack = await stackedOn(deps.git, { branch, tip, goalId: input.goalId, base, claims: input.claims, branches: knownBranches, onOrigin });
|
|
367
481
|
const projectBase = base === null ? null : (0, delivery_rules_1.shortBranch)(base);
|
|
368
482
|
const entry = {
|
|
369
483
|
...blank(branch),
|
|
@@ -373,6 +487,7 @@ async function deliverOnce(deps, input) {
|
|
|
373
487
|
projectBase,
|
|
374
488
|
dependsOn: stack?.branch ?? null,
|
|
375
489
|
state: 'pushed',
|
|
490
|
+
...(rebuilt === null ? {} : { rebuiltFrom: { branch: original.name, sha: original.sha, without: rebuilt.without } }),
|
|
376
491
|
};
|
|
377
492
|
if (gh === null) {
|
|
378
493
|
return save(deps, { ...entry, note: `pushed to origin (${remoteUrl === null ? 'no URL' : (0, delivery_rules_1.redactUrl)(remoteUrl)}); origin is not on github.com, so no pull request was opened` });
|
|
@@ -394,7 +509,12 @@ async function deliverOnce(deps, input) {
|
|
|
394
509
|
if (prBase === null) {
|
|
395
510
|
return save(deps, { ...entry, note: `pushed ${branch} to origin, but no pull request was opened: the project's base branch could not be found (set run.base_branch)` });
|
|
396
511
|
}
|
|
512
|
+
const absorbs = replacing !== null ? [replacing.goalId] : [];
|
|
513
|
+
const foreignStill = foreignLeft.filter((f) => f.goalId === undefined || !absorbs.includes(f.goalId));
|
|
514
|
+
const foreignText = foreignStill.length > 0 ? describeForeign(deps.store, foreignStill, projectBase ?? prBase) : null;
|
|
515
|
+
const couldNot = plan.kind === 'stacked' && plan.because !== null ? `Looply could not rebuild it without that work (${plan.because})` : null;
|
|
397
516
|
let pull = await api.findPull(branch);
|
|
517
|
+
let replacedOwn = null;
|
|
398
518
|
if (pull === null || (pull.state === 'closed' && !pull.merged)) {
|
|
399
519
|
const stackedPull = stack !== null && stack.onOrigin ? await api.findPull(stack.branch).catch(() => null) : null;
|
|
400
520
|
try {
|
|
@@ -402,26 +522,65 @@ async function deliverOnce(deps, input) {
|
|
|
402
522
|
title: pullTitle(input.goalText),
|
|
403
523
|
head: branch,
|
|
404
524
|
base: prBase,
|
|
405
|
-
body: pullBody({
|
|
525
|
+
body: pullBody({
|
|
526
|
+
goalId: input.goalId,
|
|
527
|
+
goalText: input.goalText,
|
|
528
|
+
branch,
|
|
529
|
+
sha: tip,
|
|
530
|
+
...(input.checks ? { checks: input.checks } : {}),
|
|
531
|
+
mode: input.mode,
|
|
532
|
+
stacked: stack,
|
|
533
|
+
stackedPull,
|
|
534
|
+
rebuilt: rebuilt === null ? null : { from: original.name, without: rebuilt.withoutText },
|
|
535
|
+
foreign: foreignText,
|
|
536
|
+
maintenance: input.maintenance ?? null,
|
|
537
|
+
modeCapped: input.modeCapped ?? null,
|
|
538
|
+
}),
|
|
406
539
|
});
|
|
407
540
|
deps.log(`deliver : opened ${pull.url} (${branch} → ${prBase})`);
|
|
408
541
|
if (replacing !== null) {
|
|
409
542
|
await api.closePull(replacing.pullNumber, `Replaced by ${pull.url}, which contains this work and its fix.`).catch(() => undefined);
|
|
410
543
|
await save(deps, { ...replacing, done: true, note: `replaced by ${pull.url}`, updatedAt: at });
|
|
411
544
|
}
|
|
545
|
+
// `--rebuild`: the old pull request of this goal's stacked branch is closed, pointing here.
|
|
546
|
+
if (input.replace === true && rebuilt !== null && prior !== null && prior.pullNumber !== null && prior.branch === original.name && prior.pullRequestUrl !== pull.url) {
|
|
547
|
+
const old = await api.getPull(prior.pullNumber).catch(() => null);
|
|
548
|
+
if (old !== null && old.state === 'open' && !old.merged) {
|
|
549
|
+
await api.closePull(prior.pullNumber, `Replaced by ${pull.url}, which holds only this ticket's work.`);
|
|
550
|
+
deps.log(`deliver : closed ${prior.pullRequestUrl} (replaced by ${pull.url})`);
|
|
551
|
+
replacedOwn = prior;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
412
554
|
}
|
|
413
555
|
catch (e) {
|
|
414
556
|
return save(deps, { ...entry, note: `pushed ${branch} to origin, but GitHub refused the pull request: ${e.message}` });
|
|
415
557
|
}
|
|
416
558
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
559
|
+
/**
|
|
560
|
+
* 🔴 UNDER `merge`/`deploy`, A PULL REQUEST INTO THE BASE THAT STILL CARRIES ANOTHER GOAL'S
|
|
561
|
+
* UNMERGED WORK IS `blocked` FROM THE START — the merge guard in `advanceOne` would say so on
|
|
562
|
+
* its first pass anyway, and the ticket should not read "waiting for checks" in between. One
|
|
563
|
+
* that targets its dependency's branch waits for that branch as before, and meets the guard
|
|
564
|
+
* once it is retargeted.
|
|
565
|
+
*/
|
|
566
|
+
const blocked = !pull.merged && input.mode !== 'pr' && foreignStill.length > 0 && (pull.base || prBase) === projectBase;
|
|
567
|
+
const state = pull.merged ? 'merged' : input.mode === 'pr' ? 'pr-open' : blocked ? 'blocked' : 'waiting-checks';
|
|
568
|
+
const tail = (s) => (couldNot === null ? s : `${s}; ${couldNot}`);
|
|
569
|
+
const note = replacing !== null
|
|
570
|
+
? `replaces ${replacing.pullRequestUrl} (its checks failed); contains that work and the fix`
|
|
571
|
+
: blocked
|
|
572
|
+
? couldNot === null
|
|
573
|
+
? guardNote(foreignText, original.name)
|
|
574
|
+
: `not merged: ${foreignText}; merge or close that first (${couldNot})`
|
|
575
|
+
: rebuilt !== null
|
|
576
|
+
? `rebuilt onto ${projectBase ?? prBase} without ${rebuilt.withoutText}${replacedOwn !== null ? `; replaces ${replacedOwn.pullRequestUrl}` : ''}`
|
|
577
|
+
: stack !== null
|
|
578
|
+
? tail(stack.onOrigin
|
|
579
|
+
? `builds on ${stack.branch}, which is not merged yet; the pull request targets it`
|
|
580
|
+
: `builds on ${stack.branch}${stack.goalId ? ` (goal ${stack.goalId})` : ''}, which is not merged or pushed; the pull request also contains its commits`)
|
|
581
|
+
: foreignText !== null
|
|
582
|
+
? tail(foreignText)
|
|
583
|
+
: null;
|
|
425
584
|
return save(deps, {
|
|
426
585
|
...entry,
|
|
427
586
|
pullNumber: pull.number,
|
|
@@ -429,8 +588,167 @@ async function deliverOnce(deps, input) {
|
|
|
429
588
|
base: pull.base || prBase,
|
|
430
589
|
state,
|
|
431
590
|
note,
|
|
591
|
+
...(absorbs.length > 0 ? { absorbs } : {}),
|
|
432
592
|
});
|
|
433
593
|
}
|
|
594
|
+
/**
|
|
595
|
+
* Decide — and, when it is the answer, do — the rebuild of a branch that carries other goals'
|
|
596
|
+
* unmerged commits. Never throws for git's sake: anything git will not do is `stacked` with the
|
|
597
|
+
* reason, and the branch is delivered as it is.
|
|
598
|
+
*/
|
|
599
|
+
async function planRebuild(deps, input, at) {
|
|
600
|
+
if (at.base === null)
|
|
601
|
+
return { kind: 'unknown' };
|
|
602
|
+
// The same goal at the same tip was rebuilt before and that branch is still here: use it.
|
|
603
|
+
const before = at.prior?.rebuiltFrom;
|
|
604
|
+
if (before && before.branch === at.branch && before.sha === at.tip) {
|
|
605
|
+
const kept = at.branches.find((b) => b.name === at.prior.branch);
|
|
606
|
+
if (kept !== undefined) {
|
|
607
|
+
return { kind: 'rebuilt', branch: kept.name, sha: kept.sha, without: before.without, withoutText: withoutSentence(before.without) };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const ref = await deps.git.freshBase(at.base);
|
|
611
|
+
const owned = await (0, commit_ownership_1.commitOwnership)(deps.git, { tip: at.tip, base: ref, goalId: input.goalId, claims: input.claims, branches: at.branches, onOrigin: at.onOrigin });
|
|
612
|
+
if (owned === null)
|
|
613
|
+
return { kind: 'unknown' };
|
|
614
|
+
const foreign = await unmergedForeign(deps.git, deps.store, owned.foreign, []);
|
|
615
|
+
if (foreign.length === 0)
|
|
616
|
+
return { kind: 'clean' };
|
|
617
|
+
if (foreign.some((f) => f.goalId !== undefined && (input.dependsOn ?? []).includes(f.goalId))) {
|
|
618
|
+
return { kind: 'stacked', foreign, because: null };
|
|
619
|
+
}
|
|
620
|
+
if (owned.merges.some((m) => owned.own.includes(m))) {
|
|
621
|
+
return { kind: 'stacked', foreign, because: "this goal's own commits include a merge, which Looply never replays" };
|
|
622
|
+
}
|
|
623
|
+
const replayed = await deps.git.replay(ref, owned.own);
|
|
624
|
+
if (!replayed.ok)
|
|
625
|
+
return { kind: 'stacked', foreign, because: replayed.because };
|
|
626
|
+
// `<branch>-solo`, or the next free `-solo-N`; one already here with exactly this content is reused.
|
|
627
|
+
const stem = at.branch.replace(/-solo(-\d+)?$/, '');
|
|
628
|
+
let name = null;
|
|
629
|
+
let sha = replayed.sha;
|
|
630
|
+
for (let i = 1; i <= 20 && name === null; i++) {
|
|
631
|
+
const candidate = i === 1 ? `${stem}${commit_ownership_1.SOLO_SUFFIX}` : `${stem}${commit_ownership_1.SOLO_SUFFIX}-${i}`;
|
|
632
|
+
const existing = at.branches.find((b) => b.name === candidate);
|
|
633
|
+
if (existing === undefined && !at.onOrigin.has(candidate)) {
|
|
634
|
+
if (!(await deps.git.createBranch(candidate, replayed.sha))) {
|
|
635
|
+
return { kind: 'stacked', foreign, because: `the branch ${candidate} could not be created` };
|
|
636
|
+
}
|
|
637
|
+
name = candidate;
|
|
638
|
+
}
|
|
639
|
+
else if (existing !== undefined && (await deps.git.treeOf(existing.sha)) === replayed.tree && (await deps.git.isAncestor(ref, existing.sha))) {
|
|
640
|
+
name = candidate;
|
|
641
|
+
sha = existing.sha;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (name === null)
|
|
645
|
+
return { kind: 'stacked', foreign, because: `every ${stem}${commit_ownership_1.SOLO_SUFFIX} name up to -20 is taken` };
|
|
646
|
+
// Name the rebuilt branch as this goal's in the lane claims, from the original branch's own record.
|
|
647
|
+
const source = [...input.claims].reverse().find((r) => r.branch === at.branch && r.goalId === input.goalId);
|
|
648
|
+
if (source !== undefined && deps.recordClaim !== undefined) {
|
|
649
|
+
try {
|
|
650
|
+
deps.recordClaim({
|
|
651
|
+
schema: source.schema,
|
|
652
|
+
kind: 'claims',
|
|
653
|
+
at: at.at,
|
|
654
|
+
branch: name,
|
|
655
|
+
runId: source.runId,
|
|
656
|
+
goalId: input.goalId,
|
|
657
|
+
goalVersion: source.goalVersion,
|
|
658
|
+
planRevision: source.planRevision,
|
|
659
|
+
servings: [],
|
|
660
|
+
...(source.kind === 'claims' && source.workspace !== undefined ? { workspace: source.workspace } : {}),
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
/* bookkeeping; `ownerOfBranch` still reads a -solo branch through its original */
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const without = foreign.map((f) => ({ goalId: f.goalId ?? null, branch: f.branch, commits: f.commits.length }));
|
|
668
|
+
return { kind: 'rebuilt', branch: name, sha, without, withoutText: withoutSentence(without) };
|
|
669
|
+
}
|
|
670
|
+
function plural(n, word) {
|
|
671
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
672
|
+
}
|
|
673
|
+
function withoutSentence(without) {
|
|
674
|
+
const n = without.reduce((s, w) => s + w.commits, 0);
|
|
675
|
+
const byGoal = new Map();
|
|
676
|
+
for (const w of without) {
|
|
677
|
+
const key = w.goalId ?? `branch:${w.branch}`;
|
|
678
|
+
const g = byGoal.get(key) ?? { goalId: w.goalId, branches: [], commits: 0 };
|
|
679
|
+
g.branches.push(w.branch);
|
|
680
|
+
g.commits += w.commits;
|
|
681
|
+
byGoal.set(key, g);
|
|
682
|
+
}
|
|
683
|
+
const parts = [...byGoal.values()].map((g) => `${g.commits} of ${g.goalId ? `goal ${g.goalId}` : 'another goal'} (${g.branches.join(', ')})`);
|
|
684
|
+
return `${plural(n, 'commit')} from other goals: ${parts.join('; ')}`;
|
|
685
|
+
}
|
|
686
|
+
/** The plain sentence a ticket shows for other goals' work still in a pull request. */
|
|
687
|
+
function describeForeign(store, foreign, base) {
|
|
688
|
+
const groups = (0, commit_ownership_1.foreignByGoal)(foreign);
|
|
689
|
+
const prOf = (goalId, branches) => {
|
|
690
|
+
const e = goalId !== undefined ? store?.get(goalId) : store?.all().find((x) => branches.includes(x.branch));
|
|
691
|
+
if (!e || e.pullNumber === null || e.done === true)
|
|
692
|
+
return '';
|
|
693
|
+
return branches.includes(e.branch) || (e.rebuiltFrom && branches.includes(e.rebuiltFrom.branch)) ? `, PR #${e.pullNumber}` : '';
|
|
694
|
+
};
|
|
695
|
+
const parts = groups.map((g) => `${plural(g.commits, 'commit')} of ${g.goalId ? `goal ${g.goalId}` : 'another goal'} (${g.branches.join(', ')}${prOf(g.goalId, g.branches)})`);
|
|
696
|
+
if (parts.length === 1)
|
|
697
|
+
return `it also contains ${parts[0]}, which is not merged into ${base}`;
|
|
698
|
+
const n = groups.reduce((s, g) => s + g.commits, 0);
|
|
699
|
+
return `it also contains ${n} commits of other goals that are not merged into ${base}: ${parts.join('; ')}`;
|
|
700
|
+
}
|
|
701
|
+
function guardNote(foreignText, branch) {
|
|
702
|
+
return `not merged: ${foreignText}; merge or close that first, or rebuild this branch without it (looply deliver --rebuild ${branch})`;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Drop the foreign work that is merged after all, or carried on purpose:
|
|
706
|
+
* - a goal this pull request ABSORBS (a fix-up that replaced that goal's red pull request);
|
|
707
|
+
* - a goal whose own delivery is merged, from this branch or a rebuild of it — a person may
|
|
708
|
+
* have squashed it, so its commits are not in the base by sha, but its work is.
|
|
709
|
+
*/
|
|
710
|
+
async function unmergedForeign(git, store, foreign, absorbs = []) {
|
|
711
|
+
const out = [];
|
|
712
|
+
for (const f of foreign) {
|
|
713
|
+
if (f.goalId !== undefined && absorbs.includes(f.goalId))
|
|
714
|
+
continue;
|
|
715
|
+
const e = f.goalId !== undefined ? store.get(f.goalId) : store.all().find((x) => x.branch === f.branch);
|
|
716
|
+
if (e && (e.state === 'merged' || e.state === 'deployed' || e.state === 'deploy-failed')) {
|
|
717
|
+
const top = f.commits[f.commits.length - 1];
|
|
718
|
+
const same = e.branch === f.branch || e.rebuiltFrom?.branch === f.branch || (top !== undefined && (await git.isAncestor(top, e.sha).catch(() => false)));
|
|
719
|
+
if (same)
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
out.push(f);
|
|
723
|
+
}
|
|
724
|
+
return out;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* 🔴 THE MERGE GUARD: may this pull request's head be merged into the base without bringing
|
|
728
|
+
* another goal's unmerged work with it? Asked right before every merge — automatic or approved —
|
|
729
|
+
* and on every pass while the answer is no. Origin's base is fetched first; a head this machine
|
|
730
|
+
* cannot read is a NO (it is checked again next pass), never a guess.
|
|
731
|
+
*/
|
|
732
|
+
async function mergeGuard(deps, e, head) {
|
|
733
|
+
const base = e.projectBase ?? e.base;
|
|
734
|
+
if (base === null)
|
|
735
|
+
return { ok: false, note: 'not merged: the base branch it would merge into is not known', foreign: [] };
|
|
736
|
+
const ref = await deps.git.freshBase(base);
|
|
737
|
+
const branches = await deps.git.runBranches().catch(() => []);
|
|
738
|
+
const onOrigin = await deps.git.remoteRunBranches().catch(() => new Set());
|
|
739
|
+
const owned = await (0, commit_ownership_1.commitOwnership)(deps.git, { tip: head, base: ref, goalId: e.goalId, claims: deps.claims?.() ?? [], branches, onOrigin });
|
|
740
|
+
if (owned === null) {
|
|
741
|
+
return {
|
|
742
|
+
ok: false,
|
|
743
|
+
note: `not merged: Looply could not read this pull request's commits (${head.slice(0, 12)}) on this machine to check whose work they are; it checks again on the next pass`,
|
|
744
|
+
foreign: [],
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
const left = await unmergedForeign(deps.git, deps.store, owned.foreign, e.absorbs ?? []);
|
|
748
|
+
if (left.length === 0)
|
|
749
|
+
return { ok: true };
|
|
750
|
+
return { ok: false, note: guardNote(describeForeign(deps.store, left, base), e.rebuiltFrom?.branch ?? e.branch), foreign: left };
|
|
751
|
+
}
|
|
434
752
|
/* ─────────────────────────────────────────────── merge, then deploy ── */
|
|
435
753
|
/** How long `merge` waits for checks before handing the pull request to a person. */
|
|
436
754
|
exports.CHECKS_PATIENCE_MS = 24 * 60 * 60_000;
|
|
@@ -461,9 +779,13 @@ function decideChecks(required, checks) {
|
|
|
461
779
|
const waiting = checks.filter((c) => c.state === 'pending').map((c) => c.name);
|
|
462
780
|
return waiting.length > 0 ? { kind: 'pending', waiting } : { kind: 'green' };
|
|
463
781
|
}
|
|
464
|
-
/**
|
|
465
|
-
|
|
466
|
-
|
|
782
|
+
/**
|
|
783
|
+
* States a pass may move on. `pr-open` and `checks-failed` move only on a person's approval.
|
|
784
|
+
* `blocked` is re-examined on every pass: it unblocks by itself once the other goals' work it
|
|
785
|
+
* carries is merged into the base, and an approval given meanwhile is remembered, not acted on.
|
|
786
|
+
*/
|
|
787
|
+
const ADVANCING = ['waiting-checks', 'merged', 'pr-open', 'checks-failed', 'blocked', 'deployed', 'deploy-failed'];
|
|
788
|
+
const APPROVABLE = ['pr-open', 'waiting-checks', 'checks-failed', 'blocked'];
|
|
467
789
|
/**
|
|
468
790
|
* One pass over every delivered pull request still waiting on Looply:
|
|
469
791
|
*
|
|
@@ -490,36 +812,86 @@ async function advanceDeliveries(deps) {
|
|
|
490
812
|
async function advanceOne(deps, e) {
|
|
491
813
|
const now = deps.now();
|
|
492
814
|
const at = new Date(now).toISOString();
|
|
493
|
-
const
|
|
815
|
+
const asked = deps.mode(e.goalId);
|
|
816
|
+
const mode = e.noAutoMerge === true && (asked === 'merge' || asked === 'deploy') ? 'pr' : asked;
|
|
494
817
|
// A person's approval, read from the goal mirror.
|
|
495
818
|
if (!e.approvedAt && APPROVABLE.includes(e.state) && e.pullNumber !== null) {
|
|
496
819
|
const approved = deps.shipRequested(e.goalId);
|
|
497
820
|
if (approved !== null) {
|
|
498
|
-
|
|
821
|
+
// 🔴 A BLOCKED ONE STAYS BLOCKED: the approval is kept, and acted on once it unblocks.
|
|
822
|
+
e =
|
|
823
|
+
e.state === 'blocked'
|
|
824
|
+
? { ...e, approvedAt: approved, updatedAt: at }
|
|
825
|
+
: { ...e, approvedAt: approved, state: 'waiting-checks', note: 'approved to ship by a person; merging once the checks allow', updatedAt: at };
|
|
499
826
|
await save(deps, e);
|
|
500
827
|
}
|
|
501
828
|
}
|
|
502
829
|
const approved = !!e.approvedAt;
|
|
503
830
|
const wantsMerge = approved || mode === 'merge' || mode === 'deploy';
|
|
504
831
|
const wantsDeploy = approved || mode === 'deploy';
|
|
505
|
-
if (e.state === 'pr-open' || e.state === 'checks-failed')
|
|
506
|
-
return;
|
|
507
832
|
if (e.state === 'waiting-checks' && !wantsMerge) {
|
|
508
833
|
await save(deps, { ...e, mode, state: 'pr-open', note: `delivery is now "${mode}", so Looply will not merge it; a person merges`, updatedAt: at });
|
|
509
834
|
return;
|
|
510
835
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
836
|
+
const client = e.owner !== null && e.repo !== null ? await deps.github(e.owner, e.repo) : null;
|
|
837
|
+
const gh = client?.api ?? null;
|
|
838
|
+
// Merged: where did it get to? (Looply's own deploy steps once, then GitHub's word for 14 days.)
|
|
839
|
+
if (e.state === 'merged' || e.state === 'deployed' || e.state === 'deploy-failed') {
|
|
840
|
+
await followDeploys(deps, gh, e, wantsDeploy, mode);
|
|
514
841
|
return;
|
|
515
|
-
|
|
516
|
-
if (
|
|
842
|
+
}
|
|
843
|
+
if (gh === null || e.pullNumber === null)
|
|
517
844
|
return; // no credentials right now: try again next pass
|
|
518
|
-
const api =
|
|
845
|
+
const api = gh;
|
|
846
|
+
const pullNumber = e.pullNumber;
|
|
847
|
+
/**
|
|
848
|
+
* A PERSON'S OWN MERGE IS SEEN TOO. A pull request Looply is not merging (`pr-open`,
|
|
849
|
+
* `checks-failed`) is still read every pass: merged on GitHub by a person, it becomes `merged`
|
|
850
|
+
* with its merge commit, and its deploys are followed like any other.
|
|
851
|
+
*/
|
|
852
|
+
if (e.state === 'pr-open' || e.state === 'checks-failed') {
|
|
853
|
+
const pull = await api.getPull(pullNumber);
|
|
854
|
+
if (pull.merged) {
|
|
855
|
+
e = { ...e, state: 'merged', mergeSha: pull.mergeSha ?? e.mergeSha ?? null, note: 'merged by a person', updatedAt: at };
|
|
856
|
+
await save(deps, e);
|
|
857
|
+
await followDeploys(deps, api, e, wantsDeploy, mode);
|
|
858
|
+
}
|
|
859
|
+
else if (pull.state === 'closed') {
|
|
860
|
+
await save(deps, { ...e, done: true, note: 'the pull request was closed without merging; Looply leaves it', updatedAt: at });
|
|
861
|
+
}
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (e.state === 'blocked') {
|
|
865
|
+
const pull = await api.getPull(pullNumber);
|
|
866
|
+
if (pull.merged) {
|
|
867
|
+
// A person merged it themselves: theirs to decide, and it is what happened.
|
|
868
|
+
e = { ...e, state: 'merged', mergeSha: pull.mergeSha ?? e.mergeSha ?? null, note: 'merged by a person', updatedAt: at };
|
|
869
|
+
await save(deps, e);
|
|
870
|
+
}
|
|
871
|
+
else if (pull.state === 'closed') {
|
|
872
|
+
await save(deps, { ...e, state: 'pr-open', done: true, note: 'the pull request was closed without merging; Looply leaves it', updatedAt: at });
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
else {
|
|
876
|
+
const guard = await mergeGuard(deps, e, pull.headSha || e.sha);
|
|
877
|
+
if (!guard.ok) {
|
|
878
|
+
if (guard.note !== e.note)
|
|
879
|
+
await save(deps, { ...e, note: guard.note, updatedAt: at });
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const base = e.projectBase ?? pull.base;
|
|
883
|
+
if (!wantsMerge) {
|
|
884
|
+
await save(deps, { ...e, mode, state: 'pr-open', note: `the other goals' work it carried is merged into ${base} now; a person merges this one`, updatedAt: at });
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
e = { ...e, state: 'waiting-checks', note: `the other goals' work it carried is merged into ${base} now; merging once the checks allow`, updatedAt: at };
|
|
888
|
+
await save(deps, e);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
519
891
|
if (e.state === 'waiting-checks') {
|
|
520
|
-
const pull = await api.getPull(
|
|
892
|
+
const pull = await api.getPull(pullNumber);
|
|
521
893
|
if (pull.merged) {
|
|
522
|
-
e = { ...e, state: 'merged', note: 'merged by a person', updatedAt: at };
|
|
894
|
+
e = { ...e, state: 'merged', mergeSha: pull.mergeSha ?? e.mergeSha ?? null, note: 'merged by a person', updatedAt: at };
|
|
523
895
|
await save(deps, e);
|
|
524
896
|
}
|
|
525
897
|
else if (pull.state === 'closed') {
|
|
@@ -540,6 +912,17 @@ async function advanceOne(deps, e) {
|
|
|
540
912
|
}
|
|
541
913
|
return;
|
|
542
914
|
}
|
|
915
|
+
/**
|
|
916
|
+
* 🔴 NEVER ANOTHER GOAL'S UNMERGED WORK. Checked here — after any retarget, before the
|
|
917
|
+
* checks are even read — so neither green CI nor "Approve and ship" can carry another
|
|
918
|
+
* ticket's unreviewed commits into the base. `merge` below is pinned to the same head.
|
|
919
|
+
*/
|
|
920
|
+
const guard = await mergeGuard(deps, e, pull.headSha || e.sha);
|
|
921
|
+
if (!guard.ok) {
|
|
922
|
+
deps.log(`deliver : ${e.goalId}: ${guard.note}`);
|
|
923
|
+
await save(deps, { ...e, state: 'blocked', note: guard.note, updatedAt: at });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
543
926
|
const verdict = decideChecks(await api.requiredChecks(pull.base), await api.checksFor(pull.headSha));
|
|
544
927
|
const age = now - Date.parse(e.approvedAt ?? e.openedAt);
|
|
545
928
|
if (verdict.kind === 'red') {
|
|
@@ -569,51 +952,166 @@ async function advanceOne(deps, e) {
|
|
|
569
952
|
await save(deps, e);
|
|
570
953
|
}
|
|
571
954
|
}
|
|
572
|
-
if (e.state
|
|
573
|
-
|
|
574
|
-
await save(deps, await deployMerged(deps, e, mode));
|
|
955
|
+
if (e.state === 'merged')
|
|
956
|
+
await followDeploys(deps, api, e, wantsDeploy, mode);
|
|
575
957
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
958
|
+
/* ────────────────────────────────────────── where the merge got to ── */
|
|
959
|
+
/** How long a merged commit's deploys are followed for, so one done outside Looply is still seen. */
|
|
960
|
+
exports.FOLLOW_DEPLOYS_MS = 14 * 24 * 60 * 60_000;
|
|
961
|
+
/** The environments to deploy, in order; the single `deploy` (or, under `deploy`, a declared release step) when none are named. */
|
|
962
|
+
function plansFor(deps, mode) {
|
|
963
|
+
const named = deps.environments?.() ?? [];
|
|
964
|
+
if (named.length > 0)
|
|
965
|
+
return [...named];
|
|
580
966
|
// Absent: under `deploy` with a declared release step, run it (the setting before deploy kinds existed).
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
967
|
+
const d = deps.deploy() ?? (mode === 'deploy' && deps.release() !== null ? { kind: 'run' } : null);
|
|
968
|
+
if (d === null || d.kind === 'none')
|
|
969
|
+
return [];
|
|
970
|
+
return [{ name: d.kind === 'push' ? d.remote : 'default', deploy: d, unnamed: true }];
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* 🔴 WHERE A MERGED COMMIT IS, TOLD TRUTHFULLY, PER ENVIRONMENT.
|
|
974
|
+
*
|
|
975
|
+
* The first time it is seen merged, every environment gets a row and Looply runs its OWN deploy
|
|
976
|
+
* steps, in order (only under `deploy` or "Approve and ship"; a failure stops the ones after it):
|
|
977
|
+
* - `push` → deployed when the git push exited 0 (a push to Heroku returns only after the release);
|
|
978
|
+
* - `run` → deployed when the project's declared release step exited 0;
|
|
979
|
+
* - `merge-deploys` → UNCONFIRMED: the merge should deploy it, but a merge is not a deploy;
|
|
980
|
+
* - `none` → waiting: nothing here deploys it.
|
|
981
|
+
* Then, every pass, GitHub's Deployments for the merge commit are read, and an environment of the
|
|
982
|
+
* same name (any, for the one unnamed environment) that GitHub says is `success` is `deployed`,
|
|
983
|
+
* `failure`/`error` is `failed`, still running is `deploying` — which is how a Heroku or Netlify
|
|
984
|
+
* deploy, or one a person ran by hand, is seen. Nothing else ever makes a row `deployed`.
|
|
985
|
+
*
|
|
986
|
+
* The delivery is `done` once every environment is deployed or failed, or 14 days after the merge;
|
|
987
|
+
* a pass that changes nothing records nothing. No environment configured: done at the merge, and
|
|
988
|
+
* the report says nothing is configured to deploy.
|
|
989
|
+
*/
|
|
990
|
+
async function followDeploys(deps, api, e, wantsDeploy, mode) {
|
|
991
|
+
const nowMs = deps.now();
|
|
992
|
+
const at = new Date(nowMs).toISOString();
|
|
993
|
+
let x = { ...e, mergedAt: e.mergedAt ?? at };
|
|
994
|
+
const plans = plansFor(deps, mode);
|
|
995
|
+
if (x.environments === undefined) {
|
|
996
|
+
if (plans.length === 0) {
|
|
997
|
+
await save(deps, { ...x, done: true, deploy: { kind: 'none', ok: true, detail: 'merged; nothing is configured to deploy' }, updatedAt: at });
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
x = await deployAll(deps, x, plans, wantsDeploy, at);
|
|
1001
|
+
}
|
|
1002
|
+
const sha = x.mergeSha ?? null;
|
|
1003
|
+
if (api?.deploymentsFor && sha !== null) {
|
|
1004
|
+
const found = await api.deploymentsFor(sha).catch(() => null);
|
|
1005
|
+
if (found !== null)
|
|
1006
|
+
x = { ...x, environments: (x.environments ?? []).map((row) => fromGitHub(row, plans, found)) };
|
|
1007
|
+
}
|
|
1008
|
+
const rows = x.environments ?? [];
|
|
1009
|
+
const settled = rows.every((r) => r.state === 'deployed' || r.state === 'failed');
|
|
1010
|
+
const expired = nowMs - Date.parse(x.mergedAt) > exports.FOLLOW_DEPLOYS_MS;
|
|
1011
|
+
const failed = rows.find((r) => r.state === 'failed');
|
|
1012
|
+
const state = failed ? 'deploy-failed' : rows.length > 0 && settled ? 'deployed' : 'merged';
|
|
1013
|
+
const open = rows.filter((r) => r.state !== 'deployed');
|
|
1014
|
+
const note = open.length === 0
|
|
1015
|
+
? null
|
|
1016
|
+
: `${expired && !settled ? 'after 14 days, ' : ''}${open.map((r) => `${r.name}: ${r.detail ?? r.state}`).join('; ')}`.slice(0, 600);
|
|
1017
|
+
x = { ...x, state, note, ...(settled || expired ? { done: true } : {}) };
|
|
1018
|
+
const same = JSON.stringify({ ...x, updatedAt: '' }) === JSON.stringify({ ...e, updatedAt: '' });
|
|
1019
|
+
if (!same)
|
|
1020
|
+
await save(deps, { ...x, updatedAt: at });
|
|
1021
|
+
}
|
|
1022
|
+
/** Run Looply's own deploy step for each environment, in order, and make every row. */
|
|
1023
|
+
async function deployAll(deps, e, plans, wantsDeploy, at) {
|
|
1024
|
+
const base = e.projectBase ?? '';
|
|
1025
|
+
const sha = e.mergeSha ?? null;
|
|
1026
|
+
const rows = [];
|
|
1027
|
+
let stoppedBy = null;
|
|
1028
|
+
for (const p of plans) {
|
|
1029
|
+
const row = (state, detail) => ({
|
|
1030
|
+
name: p.name,
|
|
1031
|
+
test: p.test === true,
|
|
1032
|
+
state,
|
|
1033
|
+
sha,
|
|
1034
|
+
at: state === 'waiting' ? null : at,
|
|
1035
|
+
url: p.url ?? null,
|
|
1036
|
+
detail: detail.slice(0, 600),
|
|
1037
|
+
});
|
|
1038
|
+
const d = p.deploy;
|
|
1039
|
+
if (stoppedBy !== null) {
|
|
1040
|
+
rows.push(row('waiting', `not deployed: the deploy to ${stoppedBy} failed first`));
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
if (d.kind === 'none') {
|
|
1044
|
+
rows.push(row('waiting', `no deploy step is configured for ${p.name}`));
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
if (d.kind === 'merge-deploys') {
|
|
1048
|
+
rows.push(row('unconfirmed', `merged into ${base}, which should deploy it; not confirmed yet`));
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
if (!wantsDeploy) {
|
|
1052
|
+
rows.push(row('waiting', `Looply deploys only under "deploy" or after "Approve and ship"`));
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (d.kind === 'push') {
|
|
1056
|
+
if (sha === null) {
|
|
1057
|
+
rows.push(row('failed', 'merged, but GitHub did not say which commit the merge made, so nothing was pushed to deploy'));
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
deps.log(`deliver : deploying ${sha.slice(0, 12)} to ${d.remote} ${d.branch} (${p.name})`);
|
|
1061
|
+
const r = await deps.deployPush(d.remote, d.branch, sha, base);
|
|
1062
|
+
rows.push(r.ok ? row('deployed', `pushed ${sha.slice(0, 12)} to ${d.branch} on ${d.remote}`) : row('failed', `pushing to ${d.remote} ${d.branch} failed: ${r.because}`));
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
else {
|
|
1066
|
+
const command = deps.release();
|
|
1067
|
+
if (command === null) {
|
|
1068
|
+
rows.push(row('failed', 'merged; not deployed — no release step is declared (deliver.release in .looply/config.yaml)'));
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
deps.log(`deliver : running the declared release step for ${e.goalId} (${p.name})`);
|
|
1072
|
+
const r = await deps.runRelease(command, {
|
|
1073
|
+
LOOPLY_GOAL_ID: e.goalId,
|
|
1074
|
+
LOOPLY_BRANCH: e.branch,
|
|
1075
|
+
LOOPLY_BASE_BRANCH: base,
|
|
1076
|
+
LOOPLY_MERGED_SHA: sha ?? '',
|
|
1077
|
+
LOOPLY_PULL_REQUEST_URL: e.pullRequestUrl ?? '',
|
|
1078
|
+
LOOPLY_ENVIRONMENT: p.name,
|
|
1079
|
+
});
|
|
1080
|
+
rows.push(r.code === 0 ? row('deployed', 'the declared release step finished') : row('failed', `the release step exited ${r.code}: ${r.tail.slice(-300)}`));
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (rows[rows.length - 1].state === 'failed')
|
|
1084
|
+
stoppedBy = p.name;
|
|
1085
|
+
}
|
|
1086
|
+
// The one-line summary older readers know: the failed step, else the first environment's.
|
|
1087
|
+
const lead = rows.find((r) => r.state === 'failed') ?? rows[0];
|
|
1088
|
+
const leadPlan = plans[rows.indexOf(lead)];
|
|
1089
|
+
return { ...e, environments: rows, deploy: { kind: leadPlan.deploy.kind, ok: lead.state !== 'failed', detail: lead.detail ?? '' } };
|
|
1090
|
+
}
|
|
1091
|
+
/** GitHub's word on one environment, when it has one and the row is not already settled. */
|
|
1092
|
+
function fromGitHub(row, plans, found) {
|
|
1093
|
+
if (row.state === 'deployed' || row.state === 'failed')
|
|
1094
|
+
return row;
|
|
1095
|
+
const plan = plans.find((p) => p.name === row.name);
|
|
1096
|
+
const match = found.find((d) => plan?.unnamed === true || d.environment.toLowerCase() === row.name.toLowerCase());
|
|
1097
|
+
if (match === undefined)
|
|
1098
|
+
return row;
|
|
1099
|
+
const state = match.state === 'success' || match.state === 'inactive'
|
|
1100
|
+
? 'deployed'
|
|
1101
|
+
: match.state === 'failure' || match.state === 'error'
|
|
1102
|
+
? 'failed'
|
|
1103
|
+
: ['in_progress', 'queued', 'pending'].includes(match.state)
|
|
1104
|
+
? 'deploying'
|
|
1105
|
+
: null;
|
|
1106
|
+
if (state === null || (state === row.state && (match.url ?? row.url) === row.url))
|
|
1107
|
+
return row;
|
|
1108
|
+
const said = match.description ? `: ${match.description}` : '';
|
|
1109
|
+
const detail = state === 'deployed'
|
|
1110
|
+
? `GitHub reports it deployed to ${match.environment}${match.state === 'inactive' ? ' (since replaced by a newer deploy)' : ''}${said}`
|
|
1111
|
+
: state === 'failed'
|
|
1112
|
+
? `GitHub reports the deploy to ${match.environment} failed${said}`
|
|
1113
|
+
: `GitHub reports a deploy to ${match.environment} in progress${said}`;
|
|
1114
|
+
return { ...row, state, at: Number.isNaN(Date.parse(match.at)) ? row.at : new Date(match.at).toISOString(), url: match.url ?? row.url, detail: detail.slice(0, 600) };
|
|
617
1115
|
}
|
|
618
1116
|
/**
|
|
619
1117
|
* Run the project's declared release step in the project folder, hidden, bounded
|