looply-cli 0.5.7 → 0.5.9
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/bin.d.ts.map +1 -1
- package/dist/bin.js +9 -1
- package/dist/bin.js.map +1 -1
- package/dist/branch-delivery-wiring.d.ts +41 -0
- package/dist/branch-delivery-wiring.d.ts.map +1 -0
- package/dist/branch-delivery-wiring.js +85 -0
- package/dist/branch-delivery-wiring.js.map +1 -0
- package/dist/branch-delivery.d.ts +275 -0
- package/dist/branch-delivery.d.ts.map +1 -0
- package/dist/branch-delivery.js +659 -0
- package/dist/branch-delivery.js.map +1 -0
- package/dist/config.d.ts +20 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +24 -0
- package/dist/config.js.map +1 -1
- package/dist/coordinator-state.d.ts.map +1 -1
- package/dist/coordinator-state.js +28 -18
- package/dist/coordinator-state.js.map +1 -1
- package/dist/daemon-cmd.d.ts.map +1 -1
- package/dist/daemon-cmd.js +64 -5
- package/dist/daemon-cmd.js.map +1 -1
- package/dist/daemon-lock.d.ts +65 -2
- package/dist/daemon-lock.d.ts.map +1 -1
- package/dist/daemon-lock.js +149 -5
- package/dist/daemon-lock.js.map +1 -1
- package/dist/daemon.d.ts +12 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +23 -3
- package/dist/daemon.js.map +1 -1
- package/dist/deliver-cmd.d.ts +41 -0
- package/dist/deliver-cmd.d.ts.map +1 -0
- package/dist/deliver-cmd.js +189 -0
- package/dist/deliver-cmd.js.map +1 -0
- package/dist/delivery-rules.d.ts +71 -0
- package/dist/delivery-rules.d.ts.map +1 -0
- package/dist/delivery-rules.js +186 -0
- package/dist/delivery-rules.js.map +1 -0
- package/dist/delivery-settings-cache.d.ts +35 -0
- package/dist/delivery-settings-cache.d.ts.map +1 -0
- package/dist/delivery-settings-cache.js +119 -0
- package/dist/delivery-settings-cache.js.map +1 -0
- package/dist/github-api.d.ts +99 -0
- package/dist/github-api.d.ts.map +1 -0
- package/dist/github-api.js +226 -0
- package/dist/github-api.js.map +1 -0
- package/dist/goal-runs.d.ts +2 -2
- package/dist/goal-runs.d.ts.map +1 -1
- package/dist/goal-runs.js +13 -11
- package/dist/goal-runs.js.map +1 -1
- package/dist/machine-settings.d.ts +188 -0
- package/dist/machine-settings.d.ts.map +1 -0
- package/dist/machine-settings.js +658 -0
- package/dist/machine-settings.js.map +1 -0
- package/dist/project-terminal.d.ts +6 -0
- package/dist/project-terminal.d.ts.map +1 -1
- package/dist/project-terminal.js +3 -0
- package/dist/project-terminal.js.map +1 -1
- package/dist/run-ending-locator.d.ts.map +1 -1
- package/dist/run-ending-locator.js +5 -3
- package/dist/run-ending-locator.js.map +1 -1
- package/dist/serve-cmd.d.ts +8 -0
- package/dist/serve-cmd.d.ts.map +1 -1
- package/dist/serve-cmd.js +12 -0
- package/dist/serve-cmd.js.map +1 -1
- package/dist/server-client.d.ts +29 -2
- package/dist/server-client.d.ts.map +1 -1
- package/dist/server-client.js +39 -2
- package/dist/server-client.js.map +1 -1
- package/dist/stale-run-sweep.js +11 -10
- package/dist/stale-run-sweep.js.map +1 -1
- package/dist/untrusted-exec.d.ts +11 -0
- package/dist/untrusted-exec.d.ts.map +1 -1
- package/dist/untrusted-exec.js +1 -0
- package/dist/untrusted-exec.js.map +1 -1
- package/dist/workspace-provisioner.d.ts +8 -0
- package/dist/workspace-provisioner.d.ts.map +1 -1
- package/dist/workspace-provisioner.js +18 -0
- package/dist/workspace-provisioner.js.map +1 -1
- package/dist/workspace.d.ts +47 -13
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +121 -94
- package/dist/workspace.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NO_CHECKS_GRACE_MS = exports.CHECKS_PATIENCE_MS = exports.BRANCH_DELIVERIES_FILE = void 0;
|
|
4
|
+
exports.nodeDeliveryGit = nodeDeliveryGit;
|
|
5
|
+
exports.makeDeployPush = makeDeployPush;
|
|
6
|
+
exports.fileDeliveryStore = fileDeliveryStore;
|
|
7
|
+
exports.memoryDeliveryStore = memoryDeliveryStore;
|
|
8
|
+
exports.findGoalBranch = findGoalBranch;
|
|
9
|
+
exports.stackedOn = stackedOn;
|
|
10
|
+
exports.pullTitle = pullTitle;
|
|
11
|
+
exports.pullBody = pullBody;
|
|
12
|
+
exports.deliverBranch = deliverBranch;
|
|
13
|
+
exports.decideChecks = decideChecks;
|
|
14
|
+
exports.advanceDeliveries = advanceDeliveries;
|
|
15
|
+
exports.makeReleaseRunner = makeReleaseRunner;
|
|
16
|
+
const node_child_process_1 = require("node:child_process");
|
|
17
|
+
const node_fs_1 = require("node:fs");
|
|
18
|
+
const node_path_1 = require("node:path");
|
|
19
|
+
const delivery_rules_1 = require("./delivery-rules");
|
|
20
|
+
const git_driver_1 = require("./git-driver");
|
|
21
|
+
const integrator_1 = require("./integrator");
|
|
22
|
+
const run_base_1 = require("./run-base");
|
|
23
|
+
function nodeDeliveryGit(cwd, configuredBase, rawExec = (0, git_driver_1.nodeGitExec)(cwd)) {
|
|
24
|
+
/**
|
|
25
|
+
* NO SIGN-IN WINDOW, EVER. The network verbs here (push, ls-remote) reach the
|
|
26
|
+
* person's credential helper — Git Credential Manager on Windows — which opens
|
|
27
|
+
* a sign-in dialog when its token has lapsed. A daemon has nobody to answer it
|
|
28
|
+
* and the owner must never see a window pop up; `GIT_TERMINAL_PROMPT=0` only
|
|
29
|
+
* covers the terminal. So every call says `credential.interactive=never`, and a
|
|
30
|
+
* lapsed credential is a failed push with git's own words instead.
|
|
31
|
+
*/
|
|
32
|
+
const exec = (args) => rawExec(['-c', 'credential.interactive=never', ...args]);
|
|
33
|
+
const driver = new git_driver_1.GitDriver(exec);
|
|
34
|
+
const out = async (args) => exec(args);
|
|
35
|
+
return {
|
|
36
|
+
async remoteUrl() {
|
|
37
|
+
const r = await out(['remote', 'get-url', delivery_rules_1.DELIVERY_REMOTE]);
|
|
38
|
+
const url = r.stdout.trim();
|
|
39
|
+
return r.code === 0 && url !== '' ? url : null;
|
|
40
|
+
},
|
|
41
|
+
runBranches: () => driver.listBranches(integrator_1.RUN_BRANCH_PREFIX),
|
|
42
|
+
isAncestor: (a, b) => driver.isAncestor(a, b).catch(() => false),
|
|
43
|
+
async baseRef() {
|
|
44
|
+
const r = await (0, run_base_1.resolveBaseBranch)(driver, configuredBase).catch(() => null);
|
|
45
|
+
return r?.ref ?? null;
|
|
46
|
+
},
|
|
47
|
+
async inBase(sha, base) {
|
|
48
|
+
const name = (0, delivery_rules_1.shortBranch)(base);
|
|
49
|
+
for (const ref of [`refs/remotes/${delivery_rules_1.DELIVERY_REMOTE}/${name}`, base]) {
|
|
50
|
+
if ((await driver.refExists(ref).catch(() => false)) && (await driver.isAncestor(sha, ref).catch(() => false)))
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
},
|
|
55
|
+
async remoteSha(branch) {
|
|
56
|
+
return driver.remoteRef(delivery_rules_1.DELIVERY_REMOTE, `refs/heads/${branch}`);
|
|
57
|
+
},
|
|
58
|
+
async remoteRunBranches() {
|
|
59
|
+
const r = await out(['ls-remote', '--heads', delivery_rules_1.DELIVERY_REMOTE, `refs/heads/${integrator_1.RUN_BRANCH_PREFIX}*`]);
|
|
60
|
+
const names = new Set();
|
|
61
|
+
if (r.code !== 0)
|
|
62
|
+
return names;
|
|
63
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
64
|
+
const ref = line.trim().split(/\s+/)[1];
|
|
65
|
+
if (ref?.startsWith('refs/heads/'))
|
|
66
|
+
names.add(ref.slice('refs/heads/'.length));
|
|
67
|
+
}
|
|
68
|
+
return names;
|
|
69
|
+
},
|
|
70
|
+
async push(argv) {
|
|
71
|
+
// 🔴 THE LAST LOOK, at the one place that runs it.
|
|
72
|
+
const unsafe = (0, delivery_rules_1.unsafePushArgv)(argv);
|
|
73
|
+
if (unsafe !== null)
|
|
74
|
+
return { ok: false, because: `refused before git ran: ${unsafe}` };
|
|
75
|
+
const r = await out([...argv]);
|
|
76
|
+
if (r.code === 0)
|
|
77
|
+
return { ok: true };
|
|
78
|
+
return { ok: false, because: (0, delivery_rules_1.redactUrl)(`${r.stderr}\n${r.stdout}`.trim()).slice(0, 400) || `git push exited ${r.code}` };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* THE `push` DEPLOY: the merged commit to the configured remote's branch.
|
|
84
|
+
*
|
|
85
|
+
* Fetches origin's base branch first so the merge commit is here, and refuses
|
|
86
|
+
* unless that commit IS on origin's base — only merged work is ever deployed.
|
|
87
|
+
* The argv is checked against the configured remote and branch just before git
|
|
88
|
+
* runs (`unsafeDeployPushArgv`): never forced, never another remote, never a delete.
|
|
89
|
+
*/
|
|
90
|
+
function makeDeployPush(cwd, rawExec = (0, git_driver_1.nodeGitExec)(cwd)) {
|
|
91
|
+
const exec = (args) => rawExec(['-c', 'credential.interactive=never', ...args]);
|
|
92
|
+
return async (remote, branch, sha, base) => {
|
|
93
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(base) || base.includes('..'))
|
|
94
|
+
return { ok: false, because: `"${base}" is not a base branch name` };
|
|
95
|
+
const url = await exec(['remote', 'get-url', remote]);
|
|
96
|
+
if (url.code !== 0 || url.stdout.trim() === '')
|
|
97
|
+
return { ok: false, because: `this repository has no remote named ${remote}` };
|
|
98
|
+
const fetched = await exec(['fetch', '--quiet', delivery_rules_1.DELIVERY_REMOTE, base]);
|
|
99
|
+
if (fetched.code !== 0)
|
|
100
|
+
return { ok: false, because: `could not fetch origin ${base}: ${(0, delivery_rules_1.redactUrl)(fetched.stderr.trim()).slice(0, 300)}` };
|
|
101
|
+
const onBase = await exec(['merge-base', '--is-ancestor', sha, `refs/remotes/${delivery_rules_1.DELIVERY_REMOTE}/${base}`]);
|
|
102
|
+
if (onBase.code !== 0)
|
|
103
|
+
return { ok: false, because: `${sha.slice(0, 12)} is not on origin's ${base}; only merged work is deployed` };
|
|
104
|
+
const argv = (0, delivery_rules_1.deployPushArgv)(remote, branch, sha);
|
|
105
|
+
const unsafe = (0, delivery_rules_1.unsafeDeployPushArgv)(argv, { remote, branch });
|
|
106
|
+
if (unsafe !== null)
|
|
107
|
+
return { ok: false, because: `refused before git ran: ${unsafe}` };
|
|
108
|
+
const r = await exec(argv);
|
|
109
|
+
if (r.code === 0)
|
|
110
|
+
return { ok: true };
|
|
111
|
+
return { ok: false, because: (0, delivery_rules_1.redactUrl)(`${r.stderr}
|
|
112
|
+
${r.stdout}`.trim()).slice(0, 400) || `git push exited ${r.code}` };
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/* ────────────────────────────────────────────────────────────── store ── */
|
|
116
|
+
exports.BRANCH_DELIVERIES_FILE = 'branch-deliveries.json';
|
|
117
|
+
function fileDeliveryStore(stateDir) {
|
|
118
|
+
const path = (0, node_path_1.join)(stateDir, exports.BRANCH_DELIVERIES_FILE);
|
|
119
|
+
const read = () => {
|
|
120
|
+
try {
|
|
121
|
+
const raw = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
122
|
+
return raw.entries && typeof raw.entries === 'object' ? raw.entries : {};
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
get: (goalId) => read()[goalId] ?? null,
|
|
130
|
+
all: () => Object.values(read()),
|
|
131
|
+
put(entry) {
|
|
132
|
+
const entries = read();
|
|
133
|
+
entries[entry.goalId] = entry;
|
|
134
|
+
(0, node_fs_1.mkdirSync)(stateDir, { recursive: true });
|
|
135
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
136
|
+
(0, node_fs_1.writeFileSync)(tmp, `${JSON.stringify({ schema: 1, entries }, null, 2)}\n`, 'utf8');
|
|
137
|
+
(0, node_fs_1.renameSync)(tmp, path);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function memoryDeliveryStore() {
|
|
142
|
+
const m = new Map();
|
|
143
|
+
return { get: (g) => m.get(g) ?? null, all: () => [...m.values()], put: (e) => void m.set(e.goalId, { ...e }) };
|
|
144
|
+
}
|
|
145
|
+
/* ─────────────────────────────────────────────────────── which branch ── */
|
|
146
|
+
/**
|
|
147
|
+
* The goal's own run branch that holds `sha`, or null.
|
|
148
|
+
*
|
|
149
|
+
* In order: the lane-claims DELIVERY record for this goal at this sha (the run
|
|
150
|
+
* that delivered wrote it on the branch it cut); the branch named for the run id
|
|
151
|
+
* (`looply/run-<stamp>-<last 8 of the id>`); the newest run branch whose tip IS
|
|
152
|
+
* the sha. Whatever is chosen must exist and contain the sha.
|
|
153
|
+
*/
|
|
154
|
+
async function findGoalBranch(git, input, branches) {
|
|
155
|
+
const byName = new Map(branches.map((b) => [b.name, b]));
|
|
156
|
+
const holds = async (b) => b !== undefined && (b.sha === input.sha || (await git.isAncestor(input.sha, b.sha)));
|
|
157
|
+
const mine = input.claims.filter((r) => r.goalId === input.goalId);
|
|
158
|
+
const delivered = mine.filter((r) => r.kind === 'delivery' && r.commitSha === input.sha);
|
|
159
|
+
for (const r of [...delivered].reverse())
|
|
160
|
+
if (await holds(byName.get(r.branch)))
|
|
161
|
+
return byName.get(r.branch);
|
|
162
|
+
if (input.runId !== null && input.runId.length >= 8) {
|
|
163
|
+
const suffix = `-${input.runId.slice(-8)}`;
|
|
164
|
+
const named = branches.find((b) => b.name.endsWith(suffix));
|
|
165
|
+
if (await holds(named))
|
|
166
|
+
return named;
|
|
167
|
+
}
|
|
168
|
+
const atTip = branches.filter((b) => b.sha === input.sha).sort((a, b) => b.name.localeCompare(a.name));
|
|
169
|
+
if (atTip[0] !== undefined)
|
|
170
|
+
return atTip[0];
|
|
171
|
+
for (const r of [...mine].reverse())
|
|
172
|
+
if (await holds(byName.get(r.branch)))
|
|
173
|
+
return byName.get(r.branch);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
/** 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
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* THE OTHER, UNMERGED GOAL'S BRANCH THIS ONE IS BUILT ON — or null.
|
|
185
|
+
*
|
|
186
|
+
* A candidate is another `looply/run-*` branch whose tip this branch contains and
|
|
187
|
+
* the base does not, and which is ANOTHER goal's work: the lane claims name a
|
|
188
|
+
* different goal, or it is on origin (only a delivered goal's branch is pushed).
|
|
189
|
+
* A branch of this goal's own earlier runs is not a dependency — it is this
|
|
190
|
+
* goal's own history. The nearest candidate wins: the one no other contains.
|
|
191
|
+
*/
|
|
192
|
+
async function stackedOn(git, input) {
|
|
193
|
+
const candidates = [];
|
|
194
|
+
for (const b of input.branches) {
|
|
195
|
+
if (b.name === input.branch || b.sha === input.tip)
|
|
196
|
+
continue;
|
|
197
|
+
const owner = goalOfBranch(input.claims, b.name);
|
|
198
|
+
if (owner === input.goalId)
|
|
199
|
+
continue;
|
|
200
|
+
if (owner === undefined && !input.onOrigin.has(b.name))
|
|
201
|
+
continue;
|
|
202
|
+
if (!(await git.isAncestor(b.sha, input.tip)))
|
|
203
|
+
continue;
|
|
204
|
+
if (input.base !== null && (await git.inBase(b.sha, input.base)))
|
|
205
|
+
continue;
|
|
206
|
+
candidates.push(b);
|
|
207
|
+
}
|
|
208
|
+
const nearest = [];
|
|
209
|
+
for (const c of candidates) {
|
|
210
|
+
let inner = false;
|
|
211
|
+
for (const d of candidates) {
|
|
212
|
+
if (d !== c && d.sha !== c.sha && (await git.isAncestor(c.sha, d.sha))) {
|
|
213
|
+
inner = true;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!inner)
|
|
218
|
+
nearest.push(c);
|
|
219
|
+
}
|
|
220
|
+
// Prefer one origin has (a pull request can target it), then the newest.
|
|
221
|
+
nearest.sort((a, b) => Number(input.onOrigin.has(b.name)) - Number(input.onOrigin.has(a.name)) || b.name.localeCompare(a.name));
|
|
222
|
+
const pick = nearest[0];
|
|
223
|
+
if (pick === undefined)
|
|
224
|
+
return null;
|
|
225
|
+
return { branch: pick.name, onOrigin: input.onOrigin.has(pick.name), goalId: goalOfBranch(input.claims, pick.name) };
|
|
226
|
+
}
|
|
227
|
+
function reportOf(e) {
|
|
228
|
+
return {
|
|
229
|
+
state: e.state,
|
|
230
|
+
branch: e.branch,
|
|
231
|
+
base: e.base,
|
|
232
|
+
pullRequestUrl: e.pullRequestUrl,
|
|
233
|
+
mode: e.mode,
|
|
234
|
+
note: e.note,
|
|
235
|
+
dependsOn: e.dependsOn,
|
|
236
|
+
mergeSha: e.mergeSha ?? null,
|
|
237
|
+
deploy: e.deploy ?? null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
async function save(deps, e) {
|
|
241
|
+
deps.store.put(e);
|
|
242
|
+
const report = reportOf(e);
|
|
243
|
+
try {
|
|
244
|
+
await deps.record(e.goalId, report);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
deps.log(`deliver : could not record ${e.goalId}'s delivery on the server (${err.message})`);
|
|
248
|
+
}
|
|
249
|
+
return report;
|
|
250
|
+
}
|
|
251
|
+
function pullTitle(goalText) {
|
|
252
|
+
const first = goalText.split(/\r?\n/).map((l) => l.trim()).find((l) => l !== '') ?? 'finished goal';
|
|
253
|
+
const clipped = first.length > 72 ? `${first.slice(0, 71).trimEnd()}…` : first;
|
|
254
|
+
return `Looply: ${clipped}`;
|
|
255
|
+
}
|
|
256
|
+
function pullBody(input) {
|
|
257
|
+
const quoted = input.goalText.trim().slice(0, 3000).split(/\r?\n/).map((l) => `> ${l}`).join('\n');
|
|
258
|
+
const checks = Object.entries(input.checks ?? {}).map(([k, v]) => `${k} ${v}`).join(', ');
|
|
259
|
+
const lines = [
|
|
260
|
+
'Looply finished this goal and opened this pull request for review.',
|
|
261
|
+
'',
|
|
262
|
+
quoted,
|
|
263
|
+
'',
|
|
264
|
+
`- Branch: \`${input.branch}\` at ${input.sha.slice(0, 12)}`,
|
|
265
|
+
...(checks === '' ? [] : [`- Checks Looply ran before keeping the work: ${checks}`]),
|
|
266
|
+
`- Goal: ${input.goalId}`,
|
|
267
|
+
`- Delivery: \`${input.mode}\` — ${input.mode === 'pr'
|
|
268
|
+
? 'a person reviews and merges.'
|
|
269
|
+
: input.mode === 'merge'
|
|
270
|
+
? '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."}`,
|
|
272
|
+
];
|
|
273
|
+
if (input.stacked !== null) {
|
|
274
|
+
lines.push('');
|
|
275
|
+
if (input.stacked.onOrigin) {
|
|
276
|
+
const ref = input.stackedPull ? `#${input.stackedPull.number} (\`${input.stacked.branch}\`)` : `\`${input.stacked.branch}\``;
|
|
277
|
+
lines.push(`⚠️ This builds on ${ref}${input.stacked.goalId ? ` — goal ${input.stacked.goalId}` : ''}, which is not merged yet. Merge that first; this pull request targets it so its diff shows only this goal's work.`);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
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
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return lines.join('\n');
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Push a satisfied goal's branch and open its pull request. Idempotent: a goal
|
|
287
|
+
* already delivered at this sha is re-recorded, not pushed again; an existing
|
|
288
|
+
* pull request for the branch is found, not duplicated. Never throws.
|
|
289
|
+
*/
|
|
290
|
+
async function deliverBranch(deps, input) {
|
|
291
|
+
if (input.mode === 'off')
|
|
292
|
+
return null;
|
|
293
|
+
try {
|
|
294
|
+
return await deliverOnce(deps, input);
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
deps.log(`deliver : ${input.goalId}: ${e.message}`);
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function deliverOnce(deps, input) {
|
|
302
|
+
const at = new Date(deps.now()).toISOString();
|
|
303
|
+
const prior = deps.store.get(input.goalId);
|
|
304
|
+
const blank = (branch) => ({
|
|
305
|
+
goalId: input.goalId,
|
|
306
|
+
goalText: input.goalText,
|
|
307
|
+
branch,
|
|
308
|
+
sha: input.sha,
|
|
309
|
+
owner: null,
|
|
310
|
+
repo: null,
|
|
311
|
+
pullNumber: null,
|
|
312
|
+
pullRequestUrl: null,
|
|
313
|
+
base: null,
|
|
314
|
+
projectBase: null,
|
|
315
|
+
mode: input.mode,
|
|
316
|
+
state: 'not-pushed',
|
|
317
|
+
note: null,
|
|
318
|
+
dependsOn: null,
|
|
319
|
+
openedAt: at,
|
|
320
|
+
updatedAt: at,
|
|
321
|
+
});
|
|
322
|
+
const notPushed = (branch, note) => {
|
|
323
|
+
deps.log(`deliver : ${input.goalId}: not pushed — ${note}`);
|
|
324
|
+
return save(deps, { ...blank(branch), note });
|
|
325
|
+
};
|
|
326
|
+
const branches = await deps.git.runBranches();
|
|
327
|
+
const found = input.branch !== undefined
|
|
328
|
+
? (branches.find((b) => b.name === input.branch) ?? null)
|
|
329
|
+
: await findGoalBranch(deps.git, input, branches);
|
|
330
|
+
if (found === null) {
|
|
331
|
+
return notPushed(input.branch ?? '', input.branch !== undefined
|
|
332
|
+
? `there is no local branch ${input.branch} (only ${integrator_1.RUN_BRANCH_PREFIX}* branches are delivered)`
|
|
333
|
+
: `no ${integrator_1.RUN_BRANCH_PREFIX}* branch on this machine holds commit ${input.sha.slice(0, 12)}`);
|
|
334
|
+
}
|
|
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) {
|
|
339
|
+
return save(deps, { ...prior, updatedAt: at });
|
|
340
|
+
}
|
|
341
|
+
const remoteUrl = await deps.git.remoteUrl();
|
|
342
|
+
const base = await deps.git.baseRef();
|
|
343
|
+
const gh = remoteUrl === null ? null : (0, delivery_rules_1.githubRepoOf)(remoteUrl);
|
|
344
|
+
const client = gh === null ? null : await deps.github(gh.owner, gh.repo);
|
|
345
|
+
const api = client?.api ?? null;
|
|
346
|
+
// GitHub's own word on protection, when it will give it, joins the rules.
|
|
347
|
+
const protectedBranches = [...(input.protectedBranches ?? [])];
|
|
348
|
+
if (api !== null && (await api.branchProtected(branch)) === true)
|
|
349
|
+
protectedBranches.push(branch);
|
|
350
|
+
const refused = (0, delivery_rules_1.pushRefusal)({ remote: delivery_rules_1.DELIVERY_REMOTE, remoteUrl, branch, base, protectedBranches });
|
|
351
|
+
if (refused !== null)
|
|
352
|
+
return notPushed(branch, refused);
|
|
353
|
+
// Never forced: origin's copy must be absent, this tip, or something this tip contains.
|
|
354
|
+
const theirs = await deps.git.remoteSha(branch);
|
|
355
|
+
if (theirs !== null && theirs !== tip && !(await deps.git.isAncestor(theirs, tip))) {
|
|
356
|
+
return notPushed(branch, `origin already has ${branch} at ${theirs.slice(0, 12)}, which this branch does not contain; Looply never force-pushes`);
|
|
357
|
+
}
|
|
358
|
+
if (theirs !== tip) {
|
|
359
|
+
const pushed = await deps.git.push((0, delivery_rules_1.deliveryPushArgv)(branch, tip));
|
|
360
|
+
if (!pushed.ok)
|
|
361
|
+
return notPushed(branch, `git push to origin failed: ${pushed.because}`);
|
|
362
|
+
deps.log(`deliver : pushed ${branch} (${tip.slice(0, 12)}) to origin`);
|
|
363
|
+
}
|
|
364
|
+
const onOrigin = await deps.git.remoteRunBranches().catch(() => new Set());
|
|
365
|
+
onOrigin.add(branch);
|
|
366
|
+
const stack = await stackedOn(deps.git, { branch, tip, goalId: input.goalId, base, claims: input.claims, branches, onOrigin });
|
|
367
|
+
const projectBase = base === null ? null : (0, delivery_rules_1.shortBranch)(base);
|
|
368
|
+
const entry = {
|
|
369
|
+
...blank(branch),
|
|
370
|
+
sha: tip,
|
|
371
|
+
owner: gh?.owner ?? null,
|
|
372
|
+
repo: gh?.repo ?? null,
|
|
373
|
+
projectBase,
|
|
374
|
+
dependsOn: stack?.branch ?? null,
|
|
375
|
+
state: 'pushed',
|
|
376
|
+
};
|
|
377
|
+
if (gh === null) {
|
|
378
|
+
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` });
|
|
379
|
+
}
|
|
380
|
+
if (api === null) {
|
|
381
|
+
const because = client !== null && client.api === null ? client.because : 'no GitHub credentials';
|
|
382
|
+
return save(deps, { ...entry, note: `pushed ${branch} to origin, but no pull request could be opened: ${because}` });
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* A FIX-UP: this goal depends on the goal it stacks on, and that goal's pull request
|
|
386
|
+
* failed its checks (AutoRyn sent the failure back to Looply). This branch holds that
|
|
387
|
+
* work plus the fix, so it goes straight to the base and REPLACES the red one.
|
|
388
|
+
*/
|
|
389
|
+
const repairs = stack !== null && stack.goalId !== undefined && (input.dependsOn ?? []).includes(stack.goalId)
|
|
390
|
+
? deps.store.get(stack.goalId)
|
|
391
|
+
: null;
|
|
392
|
+
const replacing = repairs !== null && repairs.pullNumber !== null && repairs.state === 'checks-failed' ? repairs : null;
|
|
393
|
+
const prBase = replacing === null && stack !== null && stack.onOrigin ? stack.branch : projectBase;
|
|
394
|
+
if (prBase === null) {
|
|
395
|
+
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
|
+
}
|
|
397
|
+
let pull = await api.findPull(branch);
|
|
398
|
+
if (pull === null || (pull.state === 'closed' && !pull.merged)) {
|
|
399
|
+
const stackedPull = stack !== null && stack.onOrigin ? await api.findPull(stack.branch).catch(() => null) : null;
|
|
400
|
+
try {
|
|
401
|
+
pull = await api.createPull({
|
|
402
|
+
title: pullTitle(input.goalText),
|
|
403
|
+
head: branch,
|
|
404
|
+
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 }),
|
|
406
|
+
});
|
|
407
|
+
deps.log(`deliver : opened ${pull.url} (${branch} → ${prBase})`);
|
|
408
|
+
if (replacing !== null) {
|
|
409
|
+
await api.closePull(replacing.pullNumber, `Replaced by ${pull.url}, which contains this work and its fix.`).catch(() => undefined);
|
|
410
|
+
await save(deps, { ...replacing, done: true, note: `replaced by ${pull.url}`, updatedAt: at });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
return save(deps, { ...entry, note: `pushed ${branch} to origin, but GitHub refused the pull request: ${e.message}` });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
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`;
|
|
425
|
+
return save(deps, {
|
|
426
|
+
...entry,
|
|
427
|
+
pullNumber: pull.number,
|
|
428
|
+
pullRequestUrl: pull.url,
|
|
429
|
+
base: pull.base || prBase,
|
|
430
|
+
state,
|
|
431
|
+
note,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/* ─────────────────────────────────────────────── merge, then deploy ── */
|
|
435
|
+
/** How long `merge` waits for checks before handing the pull request to a person. */
|
|
436
|
+
exports.CHECKS_PATIENCE_MS = 24 * 60 * 60_000;
|
|
437
|
+
/** How long a pull request with NO checks at all is given for CI to show up. */
|
|
438
|
+
exports.NO_CHECKS_GRACE_MS = 15 * 60_000;
|
|
439
|
+
/**
|
|
440
|
+
* ARE THE REQUIRED CHECKS GREEN?
|
|
441
|
+
*
|
|
442
|
+
* When the base branch's protection names required checks, each must report
|
|
443
|
+
* success. When it names none, or cannot be read, EVERY check on the head must be
|
|
444
|
+
* green — and there must be at least one: a pull request with no CI at all is
|
|
445
|
+
* never merged automatically, because "nothing failed" is not "it passed".
|
|
446
|
+
*/
|
|
447
|
+
function decideChecks(required, checks) {
|
|
448
|
+
const failed = checks.filter((c) => c.state === 'failure').map((c) => c.name);
|
|
449
|
+
if (required !== null && required.length > 0) {
|
|
450
|
+
const byName = new Map(checks.map((c) => [c.name, c.state]));
|
|
451
|
+
const redRequired = required.filter((n) => byName.get(n) === 'failure');
|
|
452
|
+
if (redRequired.length > 0)
|
|
453
|
+
return { kind: 'red', failed: redRequired };
|
|
454
|
+
const waiting = required.filter((n) => byName.get(n) !== 'success');
|
|
455
|
+
return waiting.length > 0 ? { kind: 'pending', waiting } : { kind: 'green' };
|
|
456
|
+
}
|
|
457
|
+
if (checks.length === 0)
|
|
458
|
+
return { kind: 'none' };
|
|
459
|
+
if (failed.length > 0)
|
|
460
|
+
return { kind: 'red', failed };
|
|
461
|
+
const waiting = checks.filter((c) => c.state === 'pending').map((c) => c.name);
|
|
462
|
+
return waiting.length > 0 ? { kind: 'pending', waiting } : { kind: 'green' };
|
|
463
|
+
}
|
|
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'];
|
|
467
|
+
/**
|
|
468
|
+
* One pass over every delivered pull request still waiting on Looply:
|
|
469
|
+
*
|
|
470
|
+
* - a person's "Approve and ship" turns an open pull request into one Looply merges;
|
|
471
|
+
* - a pull request Looply merges (`merge`/`deploy`, or approved) is merged at the head it
|
|
472
|
+
* checked, only on green required checks — an approved one also when there is no CI at all,
|
|
473
|
+
* a red one never;
|
|
474
|
+
* - a merged one is deployed by the project's configured deploy step (`deploy`, or approved).
|
|
475
|
+
*
|
|
476
|
+
* Each step is recorded on the goal, which is what puts an audit line on the ticket. Never throws.
|
|
477
|
+
*/
|
|
478
|
+
async function advanceDeliveries(deps) {
|
|
479
|
+
for (const e of deps.store.all()) {
|
|
480
|
+
if (e.done === true || !ADVANCING.includes(e.state))
|
|
481
|
+
continue;
|
|
482
|
+
try {
|
|
483
|
+
await advanceOne(deps, e);
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
deps.log(`deliver : ${e.goalId}: ${err.message}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async function advanceOne(deps, e) {
|
|
491
|
+
const now = deps.now();
|
|
492
|
+
const at = new Date(now).toISOString();
|
|
493
|
+
const mode = deps.mode();
|
|
494
|
+
// A person's approval, read from the goal mirror.
|
|
495
|
+
if (!e.approvedAt && APPROVABLE.includes(e.state) && e.pullNumber !== null) {
|
|
496
|
+
const approved = deps.shipRequested(e.goalId);
|
|
497
|
+
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 };
|
|
499
|
+
await save(deps, e);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const approved = !!e.approvedAt;
|
|
503
|
+
const wantsMerge = approved || mode === 'merge' || mode === 'deploy';
|
|
504
|
+
const wantsDeploy = approved || mode === 'deploy';
|
|
505
|
+
if (e.state === 'pr-open' || e.state === 'checks-failed')
|
|
506
|
+
return;
|
|
507
|
+
if (e.state === 'waiting-checks' && !wantsMerge) {
|
|
508
|
+
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
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (e.state === 'merged' && (!wantsDeploy || e.deploy))
|
|
512
|
+
return;
|
|
513
|
+
if (e.owner === null || e.repo === null || e.pullNumber === null)
|
|
514
|
+
return;
|
|
515
|
+
const client = await deps.github(e.owner, e.repo);
|
|
516
|
+
if (client.api === null)
|
|
517
|
+
return; // no credentials right now: try again next pass
|
|
518
|
+
const api = client.api;
|
|
519
|
+
if (e.state === 'waiting-checks') {
|
|
520
|
+
const pull = await api.getPull(e.pullNumber);
|
|
521
|
+
if (pull.merged) {
|
|
522
|
+
e = { ...e, state: 'merged', note: 'merged by a person', updatedAt: at };
|
|
523
|
+
await save(deps, e);
|
|
524
|
+
}
|
|
525
|
+
else if (pull.state === 'closed') {
|
|
526
|
+
await save(deps, { ...e, state: 'pr-open', done: true, note: 'the pull request was closed without merging; Looply leaves it', updatedAt: at });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
// Stacked: it may merge only into the base branch, once what it builds on has merged there.
|
|
531
|
+
if (e.projectBase !== null && pull.base !== e.projectBase) {
|
|
532
|
+
const dep = await api.findPull(pull.base).catch(() => null);
|
|
533
|
+
const depGone = !(await api.branchExists(pull.base).catch(() => true));
|
|
534
|
+
if ((dep !== null && dep.merged) || depGone) {
|
|
535
|
+
await api.setBase(pull.number, e.projectBase);
|
|
536
|
+
await save(deps, { ...e, base: e.projectBase, note: `${pull.base} merged; retargeted to ${e.projectBase}`, updatedAt: at });
|
|
537
|
+
}
|
|
538
|
+
else if (e.note === null || !e.note.startsWith('waiting for')) {
|
|
539
|
+
await save(deps, { ...e, note: `waiting for ${pull.base} to merge first`, updatedAt: at });
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const verdict = decideChecks(await api.requiredChecks(pull.base), await api.checksFor(pull.headSha));
|
|
544
|
+
const age = now - Date.parse(e.approvedAt ?? e.openedAt);
|
|
545
|
+
if (verdict.kind === 'red') {
|
|
546
|
+
await save(deps, { ...e, state: 'checks-failed', note: `not merged: ${verdict.failed.join(', ')} failed`, updatedAt: at });
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
// No CI at all: an automatic merge waits a little, then hands it to a person; a person's approval is the check.
|
|
550
|
+
if (verdict.kind === 'none' && !approved) {
|
|
551
|
+
if (age < exports.NO_CHECKS_GRACE_MS)
|
|
552
|
+
return;
|
|
553
|
+
await save(deps, { ...e, state: 'pr-open', note: 'not merged: no CI checks report on this pull request, so a person merges it', updatedAt: at });
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (verdict.kind === 'pending') {
|
|
557
|
+
if (age > exports.CHECKS_PATIENCE_MS) {
|
|
558
|
+
await save(deps, { ...e, state: 'pr-open', note: `not merged: still waiting for ${verdict.waiting.join(', ')} after a day; a person merges`, updatedAt: at });
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const merged = await api.merge(pull.number, pull.headSha, pullTitle(e.goalText));
|
|
563
|
+
if (!merged.merged) {
|
|
564
|
+
await save(deps, { ...e, state: 'checks-failed', note: `checks allow it but GitHub would not merge: ${merged.message}`, updatedAt: at });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
deps.log(`deliver : merged ${e.pullRequestUrl} into ${pull.base}`);
|
|
568
|
+
e = { ...e, state: 'merged', mergeSha: merged.sha ?? null, note: null, updatedAt: at };
|
|
569
|
+
await save(deps, e);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (e.state !== 'merged' || !wantsDeploy || e.deploy)
|
|
573
|
+
return;
|
|
574
|
+
await save(deps, await deployMerged(deps, e, mode));
|
|
575
|
+
}
|
|
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 ?? '';
|
|
580
|
+
// 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');
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Run the project's declared release step in the project folder, hidden, bounded
|
|
620
|
+
* to 30 minutes, its output appended to `.looply/state/run-logs/release.log`.
|
|
621
|
+
*/
|
|
622
|
+
function makeReleaseRunner(projectDir, stateDir) {
|
|
623
|
+
return (command, env) => new Promise((resolve) => {
|
|
624
|
+
const logDir = (0, node_path_1.join)(stateDir, 'run-logs');
|
|
625
|
+
const logPath = (0, node_path_1.join)(logDir, 'release.log');
|
|
626
|
+
const lines = [];
|
|
627
|
+
const note = (s) => {
|
|
628
|
+
lines.push(s);
|
|
629
|
+
try {
|
|
630
|
+
(0, node_fs_1.mkdirSync)(logDir, { recursive: true });
|
|
631
|
+
(0, node_fs_1.appendFileSync)(logPath, s, 'utf8');
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
/* the log is a convenience */
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
note(`\n=== ${new Date().toISOString()} ${command}\n`);
|
|
638
|
+
let child;
|
|
639
|
+
try {
|
|
640
|
+
child = (0, node_child_process_1.spawn)(command, { cwd: projectDir, shell: true, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
641
|
+
}
|
|
642
|
+
catch (e) {
|
|
643
|
+
resolve({ code: -1, tail: e.message });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const timer = setTimeout(() => child.kill(), 30 * 60_000);
|
|
647
|
+
child.stdout?.on('data', (d) => note(d.toString('utf8')));
|
|
648
|
+
child.stderr?.on('data', (d) => note(d.toString('utf8')));
|
|
649
|
+
child.on('error', (e) => {
|
|
650
|
+
clearTimeout(timer);
|
|
651
|
+
resolve({ code: -1, tail: e.message });
|
|
652
|
+
});
|
|
653
|
+
child.on('close', (code) => {
|
|
654
|
+
clearTimeout(timer);
|
|
655
|
+
resolve({ code: code ?? -1, tail: lines.join('').slice(-2000) });
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
//# sourceMappingURL=branch-delivery.js.map
|