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.
Files changed (126) hide show
  1. package/dist/.src-hash +1 -1
  2. package/dist/autonomy-controller.d.ts +23 -1
  3. package/dist/autonomy-controller.d.ts.map +1 -1
  4. package/dist/autonomy-controller.js +50 -9
  5. package/dist/autonomy-controller.js.map +1 -1
  6. package/dist/autonomy-store.d.ts +33 -1
  7. package/dist/autonomy-store.d.ts.map +1 -1
  8. package/dist/autonomy-store.js +58 -8
  9. package/dist/autonomy-store.js.map +1 -1
  10. package/dist/autonomy-tick.d.ts +23 -1
  11. package/dist/autonomy-tick.d.ts.map +1 -1
  12. package/dist/autonomy-tick.js +80 -14
  13. package/dist/autonomy-tick.js.map +1 -1
  14. package/dist/bin.d.ts.map +1 -1
  15. package/dist/bin.js +34 -5
  16. package/dist/bin.js.map +1 -1
  17. package/dist/branch-delivery-wiring.d.ts +4 -1
  18. package/dist/branch-delivery-wiring.d.ts.map +1 -1
  19. package/dist/branch-delivery-wiring.js +39 -12
  20. package/dist/branch-delivery-wiring.js.map +1 -1
  21. package/dist/branch-delivery.d.ts +157 -8
  22. package/dist/branch-delivery.d.ts.map +1 -1
  23. package/dist/branch-delivery.js +580 -82
  24. package/dist/branch-delivery.js.map +1 -1
  25. package/dist/branch-replay.d.ts +45 -0
  26. package/dist/branch-replay.d.ts.map +1 -0
  27. package/dist/branch-replay.js +123 -0
  28. package/dist/branch-replay.js.map +1 -0
  29. package/dist/commit-ownership.d.ts +102 -0
  30. package/dist/commit-ownership.d.ts.map +1 -0
  31. package/dist/commit-ownership.js +97 -0
  32. package/dist/commit-ownership.js.map +1 -0
  33. package/dist/coordinator-state.d.ts.map +1 -1
  34. package/dist/coordinator-state.js +23 -1
  35. package/dist/coordinator-state.js.map +1 -1
  36. package/dist/daemon-cmd.d.ts.map +1 -1
  37. package/dist/daemon-cmd.js +47 -4
  38. package/dist/daemon-cmd.js.map +1 -1
  39. package/dist/deliver-cmd.d.ts +18 -0
  40. package/dist/deliver-cmd.d.ts.map +1 -1
  41. package/dist/deliver-cmd.js +71 -5
  42. package/dist/deliver-cmd.js.map +1 -1
  43. package/dist/delivery-settings-cache.d.ts +15 -1
  44. package/dist/delivery-settings-cache.d.ts.map +1 -1
  45. package/dist/delivery-settings-cache.js +31 -3
  46. package/dist/delivery-settings-cache.js.map +1 -1
  47. package/dist/environment-hold.d.ts +69 -0
  48. package/dist/environment-hold.d.ts.map +1 -0
  49. package/dist/environment-hold.js +120 -0
  50. package/dist/environment-hold.js.map +1 -0
  51. package/dist/gate.d.ts.map +1 -1
  52. package/dist/gate.js +15 -1
  53. package/dist/gate.js.map +1 -1
  54. package/dist/github-api.d.ts +19 -0
  55. package/dist/github-api.d.ts.map +1 -1
  56. package/dist/github-api.js +26 -0
  57. package/dist/github-api.js.map +1 -1
  58. package/dist/goal-adaptive.d.ts +1 -0
  59. package/dist/goal-adaptive.d.ts.map +1 -1
  60. package/dist/goal-adaptive.js +26 -13
  61. package/dist/goal-adaptive.js.map +1 -1
  62. package/dist/goal-compile.d.ts +37 -0
  63. package/dist/goal-compile.d.ts.map +1 -1
  64. package/dist/goal-compile.js +25 -1
  65. package/dist/goal-compile.js.map +1 -1
  66. package/dist/goal-replay.d.ts +83 -0
  67. package/dist/goal-replay.d.ts.map +1 -0
  68. package/dist/goal-replay.js +121 -0
  69. package/dist/goal-replay.js.map +1 -0
  70. package/dist/goal-reproof.d.ts +5 -0
  71. package/dist/goal-reproof.d.ts.map +1 -1
  72. package/dist/goal-reproof.js +11 -4
  73. package/dist/goal-reproof.js.map +1 -1
  74. package/dist/grant-pause.d.ts +48 -0
  75. package/dist/grant-pause.d.ts.map +1 -0
  76. package/dist/grant-pause.js +103 -0
  77. package/dist/grant-pause.js.map +1 -0
  78. package/dist/grant-reconcile.d.ts +8 -2
  79. package/dist/grant-reconcile.d.ts.map +1 -1
  80. package/dist/grant-reconcile.js +9 -2
  81. package/dist/grant-reconcile.js.map +1 -1
  82. package/dist/grant-waiting-wiring.d.ts +49 -0
  83. package/dist/grant-waiting-wiring.d.ts.map +1 -0
  84. package/dist/grant-waiting-wiring.js +77 -0
  85. package/dist/grant-waiting-wiring.js.map +1 -0
  86. package/dist/machine-run-slots.d.ts +10 -2
  87. package/dist/machine-run-slots.d.ts.map +1 -1
  88. package/dist/machine-run-slots.js +15 -4
  89. package/dist/machine-run-slots.js.map +1 -1
  90. package/dist/maintenance-cmd.d.ts +44 -0
  91. package/dist/maintenance-cmd.d.ts.map +1 -0
  92. package/dist/maintenance-cmd.js +132 -0
  93. package/dist/maintenance-cmd.js.map +1 -0
  94. package/dist/maintenance-facts.d.ts +52 -0
  95. package/dist/maintenance-facts.d.ts.map +1 -0
  96. package/dist/maintenance-facts.js +256 -0
  97. package/dist/maintenance-facts.js.map +1 -0
  98. package/dist/maintenance-tick.d.ts +51 -0
  99. package/dist/maintenance-tick.d.ts.map +1 -0
  100. package/dist/maintenance-tick.js +156 -0
  101. package/dist/maintenance-tick.js.map +1 -0
  102. package/dist/planner-timeouts.d.ts +14 -0
  103. package/dist/planner-timeouts.d.ts.map +1 -0
  104. package/dist/planner-timeouts.js +51 -0
  105. package/dist/planner-timeouts.js.map +1 -0
  106. package/dist/run-cmd.d.ts +12 -1
  107. package/dist/run-cmd.d.ts.map +1 -1
  108. package/dist/run-cmd.js +146 -10
  109. package/dist/run-cmd.js.map +1 -1
  110. package/dist/run-launcher.d.ts +3 -0
  111. package/dist/run-launcher.d.ts.map +1 -1
  112. package/dist/run-launcher.js +3 -2
  113. package/dist/run-launcher.js.map +1 -1
  114. package/dist/run-refusal-marks.d.ts +7 -1
  115. package/dist/run-refusal-marks.d.ts.map +1 -1
  116. package/dist/run-refusal-marks.js +4 -0
  117. package/dist/run-refusal-marks.js.map +1 -1
  118. package/dist/server-client.d.ts +39 -1
  119. package/dist/server-client.d.ts.map +1 -1
  120. package/dist/server-client.js +28 -0
  121. package/dist/server-client.js.map +1 -1
  122. package/dist/start-attempts.d.ts +4 -2
  123. package/dist/start-attempts.d.ts.map +1 -1
  124. package/dist/start-attempts.js +6 -1
  125. package/dist/start-attempts.js.map +1 -1
  126. package/package.json +2 -2
@@ -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
- function goalOfBranch(claims, branch) {
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 branch = found.name;
336
- const tip = found.sha;
337
- // Already done at this tip: say it again (the server may have missed it), do nothing else.
338
- if (prior !== null && prior.branch === branch && prior.sha === tip && prior.pullRequestUrl !== null) {
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({ goalId: input.goalId, goalText: input.goalText, branch, sha: tip, ...(input.checks ? { checks: input.checks } : {}), mode: input.mode, stacked: stack, stackedPull }),
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
- const state = pull.merged ? 'merged' : input.mode === 'pr' ? 'pr-open' : 'waiting-checks';
418
- const note = stack === null
419
- ? null
420
- : replacing !== null
421
- ? `replaces ${replacing.pullRequestUrl} (its checks failed); contains that work and the fix`
422
- : stack.onOrigin
423
- ? `builds on ${stack.branch}, which is not merged yet; the pull request targets it`
424
- : `builds on ${stack.branch}${stack.goalId ? ` (goal ${stack.goalId})` : ''}, which is not merged or pushed; the pull request also contains its commits`;
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
- /** States a pass may move on. `pr-open` and `checks-failed` move only on a person's approval. */
465
- const ADVANCING = ['waiting-checks', 'merged', 'pr-open', 'checks-failed'];
466
- const APPROVABLE = ['pr-open', 'waiting-checks', 'checks-failed'];
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 mode = deps.mode();
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
- e = { ...e, approvedAt: approved, state: 'waiting-checks', note: 'approved to ship by a person; merging once the checks allow', updatedAt: at };
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
- if (e.state === 'merged' && (!wantsDeploy || e.deploy))
512
- return;
513
- if (e.owner === null || e.repo === null || e.pullNumber === null)
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
- const client = await deps.github(e.owner, e.repo);
516
- if (client.api === null)
842
+ }
843
+ if (gh === null || e.pullNumber === null)
517
844
  return; // no credentials right now: try again next pass
518
- const api = client.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(e.pullNumber);
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 !== 'merged' || !wantsDeploy || e.deploy)
573
- return;
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
- /** Run the configured deploy step for a merged pull request, and say what happened. */
577
- async function deployMerged(deps, e, mode) {
578
- const at = new Date(deps.now()).toISOString();
579
- const base = e.projectBase ?? '';
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 cfg = deps.deploy() ?? (mode === 'deploy' && deps.release() !== null ? { kind: 'run' } : { kind: 'none' });
582
- const done = (ok, detail, state) => ({
583
- ...e,
584
- state,
585
- done: true,
586
- deploy: { kind: cfg.kind, ok, detail: detail.slice(0, 600) },
587
- note: ok ? null : detail.slice(0, 600),
588
- updatedAt: at,
589
- });
590
- if (cfg.kind === 'none')
591
- return done(true, 'merged; nothing is configured to deploy', 'merged');
592
- if (cfg.kind === 'merge-deploys')
593
- return done(true, `merged into ${base}, which deploys it${e.mergeSha ? ` (${e.mergeSha.slice(0, 12)})` : ''}`, 'deployed');
594
- if (cfg.kind === 'push') {
595
- if (!e.mergeSha)
596
- return done(false, 'merged, but GitHub did not say which commit the merge made, so nothing was pushed to deploy', 'deploy-failed');
597
- deps.log(`deliver : deploying ${e.mergeSha.slice(0, 12)} to ${cfg.remote} ${cfg.branch}`);
598
- const r = await deps.deployPush(cfg.remote, cfg.branch, e.mergeSha, base);
599
- return r.ok
600
- ? done(true, `pushed ${e.mergeSha.slice(0, 12)} to ${cfg.branch} on ${cfg.remote}`, 'deployed')
601
- : done(false, `pushing to ${cfg.remote} ${cfg.branch} failed: ${r.because}`, 'deploy-failed');
602
- }
603
- const command = deps.release();
604
- if (command === null)
605
- return done(false, 'merged; not deployed — no release step is declared (deliver.release in .looply/config.yaml)', 'deploy-failed');
606
- deps.log(`deliver : running the declared release step for ${e.goalId}`);
607
- const r = await deps.runRelease(command, {
608
- LOOPLY_GOAL_ID: e.goalId,
609
- LOOPLY_BRANCH: e.branch,
610
- LOOPLY_BASE_BRANCH: base,
611
- LOOPLY_MERGED_SHA: e.mergeSha ?? '',
612
- LOOPLY_PULL_REQUEST_URL: e.pullRequestUrl ?? '',
613
- });
614
- return r.code === 0
615
- ? done(true, 'the declared release step finished', 'deployed')
616
- : done(false, `the release step exited ${r.code}: ${r.tail.slice(-300)}`, 'deploy-failed');
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