@syntax-syllogism/aloop 0.5.3 → 0.6.1
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/CHANGELOG.md +43 -0
- package/README.md +49 -15
- package/bin/loop.mjs +197 -7
- package/package.json +7 -1
- package/presets/work-item/loop.config.mjs +1 -1
- package/presets/work-item/prompts/docs.md +6 -4
- package/presets/work-item/prompts/pr-description.md +27 -0
- package/presets/work-item/prompts/review.md +6 -1
- package/prompts/address.md +2 -1
- package/prompts/docs.md +5 -3
- package/prompts/{git.md → pr-description.md} +11 -8
- package/prompts/review.md +9 -4
- package/src/adapters.mjs +84 -9
- package/src/backends/gitlab.mjs +231 -0
- package/src/command.mjs +156 -6
- package/src/config.mjs +278 -53
- package/src/git.mjs +26 -0
- package/src/hermetic.mjs +160 -0
- package/src/index.mjs +22 -1
- package/src/manifest.mjs +67 -0
- package/src/metrics.mjs +251 -0
- package/src/operations.mjs +651 -0
- package/src/pipeline.mjs +505 -427
- package/src/policy.mjs +63 -0
- package/src/publish.mjs +183 -0
- package/src/reporter.mjs +102 -0
- package/src/runner.mjs +509 -0
- package/src/state.mjs +338 -16
- package/src/verdict.mjs +36 -2
- package/src/worktree.mjs +33 -0
- package/presets/work-item/prompts/git.md +0 -44
package/src/adapters.mjs
CHANGED
|
@@ -16,9 +16,29 @@
|
|
|
16
16
|
* treats the result as opaque text.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { runCommand } from './command.mjs';
|
|
23
|
+
|
|
19
24
|
/** Fields worth showing for a tool call, most specific first. */
|
|
20
25
|
const toolSummaryFields = ['command', 'file_path', 'pattern', 'path', 'url', 'description', 'prompt'];
|
|
21
26
|
|
|
27
|
+
export const PERMISSIONS = Object.freeze({
|
|
28
|
+
READ_ONLY: 'read-only',
|
|
29
|
+
WRITE_WORKTREE: 'write-worktree',
|
|
30
|
+
PUBLISH: 'publish',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Resolve a phase permission conservatively for adapters and BYO engines. */
|
|
34
|
+
export function permissionLevel(permissions) {
|
|
35
|
+
const requested = Array.isArray(permissions) ? permissions : [];
|
|
36
|
+
if (requested.includes(PERMISSIONS.READ_ONLY)) return PERMISSIONS.READ_ONLY;
|
|
37
|
+
if (requested.includes(PERMISSIONS.WRITE_WORKTREE)) return PERMISSIONS.WRITE_WORKTREE;
|
|
38
|
+
if (requested.includes(PERMISSIONS.PUBLISH)) return PERMISSIONS.PUBLISH;
|
|
39
|
+
return PERMISSIONS.READ_ONLY;
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
function condense(text, limit = 120) {
|
|
23
43
|
const flat = text.replace(/\s+/g, ' ').trim();
|
|
24
44
|
return flat.length > limit ? `${flat.slice(0, limit - 1)}\u2026` : flat;
|
|
@@ -51,6 +71,17 @@ function summarizeAgyParams(params) {
|
|
|
51
71
|
return '';
|
|
52
72
|
}
|
|
53
73
|
|
|
74
|
+
function cliVersion(command) {
|
|
75
|
+
return async () => {
|
|
76
|
+
const cwd = await mkdtemp(join(tmpdir(), 'aloop-version-'));
|
|
77
|
+
try {
|
|
78
|
+
return (await runCommand(command, ['--version'], { cwd, timeoutMs: 5000 })).stdout.trim();
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(cwd, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
function renderClaudeBlock(block) {
|
|
55
86
|
if (block.type === 'text') return block.text?.trim() ? `${block.text.trimEnd()}\n` : '';
|
|
56
87
|
if (block.type === 'thinking') return ' \u00b7 thinking\n';
|
|
@@ -141,10 +172,39 @@ export function renderAgyEvent(event) {
|
|
|
141
172
|
*/
|
|
142
173
|
export function createJsonlRenderer(renderEvent) {
|
|
143
174
|
let pending = '';
|
|
175
|
+
let usage = null;
|
|
176
|
+
const recordUsage = (event) => {
|
|
177
|
+
if (!event || typeof event !== 'object') return;
|
|
178
|
+
const source = event.usage ?? event.result?.usage ?? event;
|
|
179
|
+
if (!source || typeof source !== 'object') return;
|
|
180
|
+
const tokenValues = [
|
|
181
|
+
source.tokens,
|
|
182
|
+
source.total_tokens,
|
|
183
|
+
source.totalTokens,
|
|
184
|
+
source.input_tokens !== undefined && source.output_tokens !== undefined
|
|
185
|
+
? Number(source.input_tokens) + Number(source.output_tokens)
|
|
186
|
+
: undefined,
|
|
187
|
+
source.prompt_tokens !== undefined && source.completion_tokens !== undefined
|
|
188
|
+
? Number(source.prompt_tokens) + Number(source.completion_tokens)
|
|
189
|
+
: undefined,
|
|
190
|
+
].filter((value) => Number.isFinite(value));
|
|
191
|
+
const cost = [event.usage, event.result?.usage, event]
|
|
192
|
+
.filter((candidate) => candidate && typeof candidate === 'object')
|
|
193
|
+
.flatMap((candidate) => [candidate.cost, candidate.total_cost, candidate.totalCost, candidate.total_cost_usd])
|
|
194
|
+
.find((value) => Number.isFinite(value));
|
|
195
|
+
if (tokenValues.length || cost !== undefined) {
|
|
196
|
+
usage = {
|
|
197
|
+
...(tokenValues.length ? { tokens: tokenValues.at(-1) } : usage?.tokens !== undefined ? { tokens: usage.tokens } : {}),
|
|
198
|
+
...(cost !== undefined ? { cost } : usage?.cost !== undefined ? { cost: usage.cost } : {}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
};
|
|
144
202
|
const renderLine = (line) => {
|
|
145
203
|
if (!line.trim()) return '';
|
|
146
204
|
try {
|
|
147
|
-
|
|
205
|
+
const event = JSON.parse(line);
|
|
206
|
+
recordUsage(event);
|
|
207
|
+
return renderEvent(event);
|
|
148
208
|
} catch {
|
|
149
209
|
return `${line}\n`;
|
|
150
210
|
}
|
|
@@ -161,6 +221,9 @@ export function createJsonlRenderer(renderEvent) {
|
|
|
161
221
|
pending = '';
|
|
162
222
|
return renderLine(rest);
|
|
163
223
|
},
|
|
224
|
+
usage() {
|
|
225
|
+
return usage;
|
|
226
|
+
},
|
|
164
227
|
};
|
|
165
228
|
}
|
|
166
229
|
|
|
@@ -171,9 +234,11 @@ export function passthroughRenderer() {
|
|
|
171
234
|
|
|
172
235
|
const claudeAdapter = {
|
|
173
236
|
name: 'claude',
|
|
237
|
+
version: cliVersion('claude'),
|
|
174
238
|
efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
175
|
-
command({ prompt, addDirs, agent = {} }) {
|
|
176
|
-
// `auto`, not `acceptEdits
|
|
239
|
+
command({ prompt, addDirs, permissions, artifactOnly = false, agent = {} }) {
|
|
240
|
+
// `auto`, not `acceptEdits`, is used for worktree-writing and
|
|
241
|
+
// artifact-only phases: under acceptEdits a non-interactive `-p` run
|
|
177
242
|
// auto-denies every Bash call, because there is no one to answer the
|
|
178
243
|
// prompt it raises. Phases that have to build, test, and commit then spin
|
|
179
244
|
// until the timeout kills them.
|
|
@@ -181,7 +246,9 @@ const claudeAdapter = {
|
|
|
181
246
|
// the process exits, so a phase that runs for half an hour looks identical
|
|
182
247
|
// to one that has hung. stream-json emits an event per step, which
|
|
183
248
|
// `createRenderer` turns back into readable lines.
|
|
184
|
-
const
|
|
249
|
+
const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
|
|
250
|
+
const permissionMode = canWrite ? 'auto' : 'plan';
|
|
251
|
+
const args = ['-p', prompt, '--permission-mode', permissionMode, '--output-format', 'stream-json', '--verbose'];
|
|
185
252
|
if (agent.model) args.push('--model', agent.model);
|
|
186
253
|
if (agent.effort) args.push('--effort', agent.effort);
|
|
187
254
|
for (const dir of addDirs) args.push('--add-dir', dir);
|
|
@@ -192,9 +259,12 @@ const claudeAdapter = {
|
|
|
192
259
|
|
|
193
260
|
const codexAdapter = {
|
|
194
261
|
name: 'codex',
|
|
262
|
+
version: cliVersion('codex'),
|
|
195
263
|
efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
196
|
-
command({ prompt, cwd, addDirs, agent = {} }) {
|
|
197
|
-
const
|
|
264
|
+
command({ prompt, cwd, addDirs, permissions, artifactOnly = false, agent = {} }) {
|
|
265
|
+
const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
|
|
266
|
+
const sandbox = canWrite ? 'workspace-write' : 'read-only';
|
|
267
|
+
const args = ['exec', prompt, '--sandbox', sandbox, '--cd', cwd];
|
|
198
268
|
if (agent.model) args.push('--model', agent.model);
|
|
199
269
|
if (agent.effort) args.push('-c', `model_reasoning_effort=${JSON.stringify(agent.effort)}`);
|
|
200
270
|
for (const dir of addDirs) args.push('--add-dir', dir);
|
|
@@ -204,8 +274,9 @@ const codexAdapter = {
|
|
|
204
274
|
|
|
205
275
|
const agyAdapter = {
|
|
206
276
|
name: 'agy',
|
|
277
|
+
version: cliVersion('agy'),
|
|
207
278
|
efforts: ['low', 'medium', 'high'],
|
|
208
|
-
command({ prompt, addDirs, timeoutMs, agent = {} }) {
|
|
279
|
+
command({ prompt, addDirs, timeoutMs, permissions, artifactOnly = false, agent = {} }) {
|
|
209
280
|
// `--dangerously-skip-permissions`, not bare `accept-edits`: in headless
|
|
210
281
|
// `--print` mode agy cannot prompt for the `command` permission its Bash-
|
|
211
282
|
// style tools need, so it auto-denies the first one and exits 0 having done
|
|
@@ -218,12 +289,13 @@ const agyAdapter = {
|
|
|
218
289
|
// from a hang (the run log shows only the header). stream-json emits an
|
|
219
290
|
// event per step, which `createRenderer` turns back into readable lines —
|
|
220
291
|
// same reasoning as the claude adapter above.
|
|
292
|
+
const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
|
|
221
293
|
const args = [
|
|
222
294
|
'--print', prompt,
|
|
223
|
-
'--mode', 'accept-edits',
|
|
224
|
-
'--dangerously-skip-permissions',
|
|
295
|
+
'--mode', canWrite ? 'accept-edits' : 'plan',
|
|
225
296
|
'--output-format', 'stream-json',
|
|
226
297
|
];
|
|
298
|
+
if (canWrite) args.splice(4, 0, '--dangerously-skip-permissions');
|
|
227
299
|
if (agent.model) args.push('--model', agent.model);
|
|
228
300
|
if (agent.effort) args.push('--effort', agent.effort);
|
|
229
301
|
if (timeoutMs) args.push('--print-timeout', `${Math.ceil(timeoutMs / 1000)}s`);
|
|
@@ -245,6 +317,9 @@ function validateConfiguredAdapter(name, adapter) {
|
|
|
245
317
|
if (adapter.createRenderer !== undefined && typeof adapter.createRenderer !== 'function') {
|
|
246
318
|
throw new Error(`Invalid adapter "${name}": createRenderer must be a function.`);
|
|
247
319
|
}
|
|
320
|
+
if (adapter.version !== undefined && typeof adapter.version !== 'function') {
|
|
321
|
+
throw new Error(`Invalid adapter "${name}": version must be a function.`);
|
|
322
|
+
}
|
|
248
323
|
return adapter;
|
|
249
324
|
}
|
|
250
325
|
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { runCommand } from '../command.mjs';
|
|
2
|
+
import { GitFacade } from '../git.mjs';
|
|
3
|
+
import { PublishError } from '../publish.mjs';
|
|
4
|
+
|
|
5
|
+
const DRAFT_RE = /^(draft:\s*|wip:\s*)/i;
|
|
6
|
+
const NO_MERGE_REQUEST_RE = /\b(?:no merge requests? found|merge requests? not found|merge requests? (?:do|does) not exist)\b/i;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} GitLabMr
|
|
10
|
+
* @property {number} iid
|
|
11
|
+
* @property {string} web_url
|
|
12
|
+
* @property {string} target_branch
|
|
13
|
+
* @property {string} sha - The merge request head commit SHA.
|
|
14
|
+
* @property {boolean} [draft]
|
|
15
|
+
* @property {boolean} [work_in_progress] - Legacy GitLab draft field.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} GitLabTransport
|
|
20
|
+
* @property {(ctx: { host: string }) => Promise<void>} checkAuth
|
|
21
|
+
* @property {(ctx: { host: string, project: string, branch: string }) => Promise<GitLabMr|null>} getMergeRequest
|
|
22
|
+
* @property {(ctx: { host: string, project: string, sourceBranch: string, targetBranch: string, title: string, description: string, descriptionFilePath?: string, draft: boolean }) => Promise<void>} createMergeRequest
|
|
23
|
+
* @property {(ctx: { host: string, project: string, iid: number, branch: string, title: string, description: string, descriptionFilePath?: string, draft: boolean }) => Promise<void>} updateMergeRequest
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse the URL forms emitted by Git, retaining a self-hosted port in `host`.
|
|
28
|
+
* GitLab project paths may contain any number of namespace segments.
|
|
29
|
+
*/
|
|
30
|
+
export function parseGitLabRemoteUrl(remoteUrl) {
|
|
31
|
+
if (typeof remoteUrl !== 'string' || !remoteUrl.trim()) {
|
|
32
|
+
throw new PublishError('Configured GitLab remote is not a valid Git remote URL.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const value = remoteUrl.trim();
|
|
36
|
+
let host;
|
|
37
|
+
let project;
|
|
38
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(value)) {
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = new URL(value);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new PublishError(`Configured GitLab remote is not a valid Git remote URL: ${value}`, { cause: error });
|
|
44
|
+
}
|
|
45
|
+
if (!['http:', 'https:', 'ssh:'].includes(parsed.protocol) || !parsed.host) {
|
|
46
|
+
throw new PublishError(`Configured GitLab remote is not a valid Git remote URL: ${value}`);
|
|
47
|
+
}
|
|
48
|
+
host = parsed.host;
|
|
49
|
+
project = parsed.pathname;
|
|
50
|
+
} else {
|
|
51
|
+
const match = /^git@(?:(\[[^\]]+\])|([^:/]+)):(.+)$/i.exec(value);
|
|
52
|
+
if (!match) {
|
|
53
|
+
throw new PublishError(`Configured GitLab remote is not a valid Git remote URL: ${value}`);
|
|
54
|
+
}
|
|
55
|
+
host = match[1] ?? match[2];
|
|
56
|
+
project = match[3];
|
|
57
|
+
// Git's scp-like syntax has no standard port notation. Accept the common
|
|
58
|
+
// git@host:2222/group/project form while keeping normal namespace paths.
|
|
59
|
+
const portMatch = /^(\d+)\/(.+)$/.exec(project);
|
|
60
|
+
if (portMatch) {
|
|
61
|
+
host = `${host}:${portMatch[1]}`;
|
|
62
|
+
project = portMatch[2];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
project = project.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
|
|
67
|
+
if (!host || !project || project.split('/').some((segment) => !segment)) {
|
|
68
|
+
throw new PublishError(`Configured GitLab remote is not a valid Git remote URL: ${value}`);
|
|
69
|
+
}
|
|
70
|
+
return { host, project };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const withDraftPrefix = (title) => DRAFT_RE.test(title) ? title : `Draft: ${title}`;
|
|
74
|
+
export const withoutDraftPrefix = (title) => title.replace(DRAFT_RE, '');
|
|
75
|
+
|
|
76
|
+
function errorText(error) {
|
|
77
|
+
return [error?.output, error?.stderr, error?.cause?.output, error?.message]
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The default GitLab transport. `glab` is intentionally an external runtime
|
|
84
|
+
* requirement, so consumers can replace this transport with REST or MCP code.
|
|
85
|
+
*/
|
|
86
|
+
export function glabTransport({ cwd, runner = runCommand, env } = {}) {
|
|
87
|
+
async function run(args) {
|
|
88
|
+
try {
|
|
89
|
+
return await runner('glab', args, { cwd, ...(env ? { env } : {}) });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error.code === 'ENOENT') {
|
|
92
|
+
throw new PublishError('GitLab publishing requires the `glab` CLI, but it is not installed.', { cause: error });
|
|
93
|
+
}
|
|
94
|
+
throw new PublishError(errorText(error).trim() || 'GitLab CLI command failed.', { cause: error });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const repository = (host, project) => `https://${host}/${project}`;
|
|
99
|
+
const descriptionArgs = (description, descriptionFilePath) => descriptionFilePath
|
|
100
|
+
? ['--description-file', descriptionFilePath]
|
|
101
|
+
: ['--description', description];
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
async checkAuth({ host }) {
|
|
105
|
+
await run(['auth', 'status', '--hostname', host]);
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async getMergeRequest({ host, project, branch }) {
|
|
109
|
+
try {
|
|
110
|
+
const result = await run(['mr', 'view', branch, '--output', 'json', '--repo', repository(host, project)]);
|
|
111
|
+
return JSON.parse(result.stdout);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const output = errorText(error);
|
|
114
|
+
if (error.cause?.code === 'ENOENT') throw error;
|
|
115
|
+
// Only the CLI's explicit no-MR messages mean it is safe to create one.
|
|
116
|
+
// Other exit-code-1 failures, such as permissions or connectivity, must
|
|
117
|
+
// reach publish() instead of being mistaken for an absent MR.
|
|
118
|
+
if (NO_MERGE_REQUEST_RE.test(output)) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
throw new PublishError(`Unable to inspect GitLab merge request for ${branch}: ${error.message}`, { cause: error });
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async createMergeRequest({ host, project, sourceBranch, targetBranch, title, description, descriptionFilePath, draft }) {
|
|
126
|
+
const args = [
|
|
127
|
+
'mr', 'create',
|
|
128
|
+
'--repo', repository(host, project),
|
|
129
|
+
'--source-branch', sourceBranch,
|
|
130
|
+
'--target-branch', targetBranch,
|
|
131
|
+
'--title', title,
|
|
132
|
+
...descriptionArgs(description, descriptionFilePath),
|
|
133
|
+
...(draft ? ['--draft'] : []),
|
|
134
|
+
'--yes',
|
|
135
|
+
];
|
|
136
|
+
await run(args);
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async updateMergeRequest({ host, project, iid, title, description, descriptionFilePath, draft }) {
|
|
140
|
+
const args = [
|
|
141
|
+
'mr', 'update', String(iid),
|
|
142
|
+
'--repo', repository(host, project),
|
|
143
|
+
'--title', title,
|
|
144
|
+
...descriptionArgs(description, descriptionFilePath),
|
|
145
|
+
draft ? '--draft' : '--ready',
|
|
146
|
+
'--yes',
|
|
147
|
+
];
|
|
148
|
+
await run(args);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Adapt GitLab-native merge request data to aloop's verified pull-request port.
|
|
155
|
+
*
|
|
156
|
+
* @param {{ cwd: string, git?: GitFacade, transport?: GitLabTransport, env?: object }} options
|
|
157
|
+
*/
|
|
158
|
+
export function gitlabBackend({ cwd, git = new GitFacade(cwd), transport, env } = {}) {
|
|
159
|
+
transport ??= glabTransport({ cwd, env });
|
|
160
|
+
const repositories = new Map();
|
|
161
|
+
|
|
162
|
+
async function resolveRepository(remote) {
|
|
163
|
+
if (!repositories.has(remote)) {
|
|
164
|
+
const remoteUrl = await git.remoteUrl(remote);
|
|
165
|
+
repositories.set(remote, parseGitLabRemoteUrl(remoteUrl));
|
|
166
|
+
}
|
|
167
|
+
return repositories.get(remote);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function mergeRequest(remote, branch) {
|
|
171
|
+
const { host, project } = await resolveRepository(remote);
|
|
172
|
+
return { host, project, mr: await transport.getMergeRequest({ host, project, branch }) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function mapMergeRequest(mr) {
|
|
176
|
+
if (!mr) return null;
|
|
177
|
+
return {
|
|
178
|
+
url: mr.web_url,
|
|
179
|
+
baseRefName: mr.target_branch,
|
|
180
|
+
headRefOid: mr.sha,
|
|
181
|
+
isDraft: mr.draft ?? mr.work_in_progress ?? false,
|
|
182
|
+
number: Number(mr.iid),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
async precheck({ remote }) {
|
|
188
|
+
try {
|
|
189
|
+
const { host } = await resolveRepository(remote);
|
|
190
|
+
await transport.checkAuth({ host });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
throw new PublishError(`GitLab publishing precheck failed: ${error.message}`, { cause: error });
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
async view({ remote, branch }) {
|
|
197
|
+
return mapMergeRequest((await mergeRequest(remote, branch)).mr);
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
async create({ remote, base, branch, title, body, bodyFilePath, draft }) {
|
|
201
|
+
const { host, project } = await resolveRepository(remote);
|
|
202
|
+
await transport.createMergeRequest({
|
|
203
|
+
host,
|
|
204
|
+
project,
|
|
205
|
+
sourceBranch: branch,
|
|
206
|
+
targetBranch: base,
|
|
207
|
+
title: draft ? withDraftPrefix(title) : withoutDraftPrefix(title),
|
|
208
|
+
description: body,
|
|
209
|
+
descriptionFilePath: bodyFilePath,
|
|
210
|
+
draft,
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async update({ remote, branch, title, body, bodyFilePath, draft }) {
|
|
215
|
+
const { host, project, mr } = await mergeRequest(remote, branch);
|
|
216
|
+
if (!mr) throw new PublishError(`No GitLab merge request exists for branch ${branch}.`);
|
|
217
|
+
const iid = Number(mr.iid);
|
|
218
|
+
if (!Number.isInteger(iid)) throw new PublishError(`GitLab merge request for branch ${branch} has no valid IID.`);
|
|
219
|
+
await transport.updateMergeRequest({
|
|
220
|
+
host,
|
|
221
|
+
project,
|
|
222
|
+
iid,
|
|
223
|
+
branch,
|
|
224
|
+
title: draft ? withDraftPrefix(title) : withoutDraftPrefix(title),
|
|
225
|
+
description: body,
|
|
226
|
+
descriptionFilePath: bodyFilePath,
|
|
227
|
+
draft,
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
package/src/command.mjs
CHANGED
|
@@ -1,4 +1,115 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { win32 as windowsPath } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function signalProcessGroup(pid, signal, {
|
|
6
|
+
platform = process.platform,
|
|
7
|
+
spawnImpl = spawn,
|
|
8
|
+
onFailure,
|
|
9
|
+
} = {}) {
|
|
10
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return false;
|
|
11
|
+
if (platform === 'win32') {
|
|
12
|
+
const handleFailure = onFailure ?? (() => {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, signal);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error.code !== 'ESRCH') throw error;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
let killer;
|
|
20
|
+
try {
|
|
21
|
+
killer = spawnImpl('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
|
22
|
+
stdio: 'ignore',
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
});
|
|
25
|
+
} catch {
|
|
26
|
+
handleFailure();
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const addListener = killer?.once ?? killer?.on;
|
|
30
|
+
if (!addListener) {
|
|
31
|
+
handleFailure();
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
let completed = false;
|
|
35
|
+
const fail = () => {
|
|
36
|
+
if (completed) return;
|
|
37
|
+
completed = true;
|
|
38
|
+
handleFailure();
|
|
39
|
+
};
|
|
40
|
+
addListener.call(killer, 'error', fail);
|
|
41
|
+
addListener.call(killer, 'close', (code) => {
|
|
42
|
+
if (completed) return;
|
|
43
|
+
completed = true;
|
|
44
|
+
if (code !== 0) handleFailure();
|
|
45
|
+
});
|
|
46
|
+
killer?.unref?.();
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
process.kill(-pid, signal);
|
|
51
|
+
return true;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code !== 'ESRCH') throw error;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const WINDOWS_EXECUTABLE_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD', '.PS1'];
|
|
59
|
+
|
|
60
|
+
function environmentValue(env, name) {
|
|
61
|
+
if (env[name] !== undefined) return env[name];
|
|
62
|
+
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
63
|
+
return key ? env[key] : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveWindowsExecutable(command, env = process.env, { fileExists = existsSync } = {}) {
|
|
67
|
+
const pathEntries = (environmentValue(env, 'PATH') ?? '').split(';').filter(Boolean);
|
|
68
|
+
const pathExtensions = (environmentValue(env, 'PATHEXT') ?? WINDOWS_EXECUTABLE_EXTENSIONS.join(';'))
|
|
69
|
+
.split(';')
|
|
70
|
+
.map((extension) => extension.trim().toUpperCase())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
const hasDirectory = command.includes('\\') || command.includes('/');
|
|
73
|
+
const bases = hasDirectory ? [command] : pathEntries.map((directory) => windowsPath.join(directory, command));
|
|
74
|
+
const extensions = windowsPath.extname(command) ? [''] : pathExtensions;
|
|
75
|
+
|
|
76
|
+
for (const base of bases) {
|
|
77
|
+
for (const extension of extensions) {
|
|
78
|
+
const candidate = `${base}${extension}`;
|
|
79
|
+
if (fileExists(candidate)) return candidate;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function windowsSpawnSpec(command, args, env, resolveExecutable) {
|
|
86
|
+
const resolved = resolveExecutable(command, env) ?? command;
|
|
87
|
+
const extension = windowsPath.extname(resolved).toLowerCase();
|
|
88
|
+
if (extension === '.cmd' || extension === '.bat') {
|
|
89
|
+
return {
|
|
90
|
+
command: environmentValue(env, 'ComSpec') ?? 'cmd.exe',
|
|
91
|
+
args: ['/d', '/s', '/c', resolved, ...args],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (extension === '.ps1') {
|
|
95
|
+
return {
|
|
96
|
+
command: environmentValue(env, 'SystemRoot')
|
|
97
|
+
? windowsPath.join(environmentValue(env, 'SystemRoot'), 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
98
|
+
: 'powershell.exe',
|
|
99
|
+
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolved, ...args],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { command: resolved, args };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clearActiveProcess(path) {
|
|
106
|
+
if (!path) return;
|
|
107
|
+
try {
|
|
108
|
+
unlinkSync(path);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code !== 'ENOENT') throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
2
113
|
|
|
3
114
|
/**
|
|
4
115
|
* Run a command, capturing its output while optionally streaming it onward.
|
|
@@ -9,17 +120,56 @@ import { spawn } from 'node:child_process';
|
|
|
9
120
|
* most common unattended failure and it burns tokens for as long as it hangs.
|
|
10
121
|
*/
|
|
11
122
|
export async function runCommand(command, args = [], options = {}) {
|
|
12
|
-
const {
|
|
123
|
+
const {
|
|
124
|
+
cwd,
|
|
125
|
+
env = process.env,
|
|
126
|
+
timeoutMs,
|
|
127
|
+
onOutput,
|
|
128
|
+
input,
|
|
129
|
+
activeProcessPath,
|
|
130
|
+
platform = process.platform,
|
|
131
|
+
spawnImpl = spawn,
|
|
132
|
+
resolveExecutable = resolveWindowsExecutable,
|
|
133
|
+
} = options;
|
|
13
134
|
return new Promise((resolve, reject) => {
|
|
14
|
-
const
|
|
135
|
+
const spawnSpec = platform === 'win32' ? windowsSpawnSpec(command, args, env, resolveExecutable) : { command, args };
|
|
136
|
+
const child = spawnImpl(spawnSpec.command, spawnSpec.args, {
|
|
137
|
+
cwd,
|
|
138
|
+
env,
|
|
139
|
+
detached: platform !== 'win32',
|
|
140
|
+
...(platform === 'win32' ? { windowsVerbatimArguments: false } : {}),
|
|
141
|
+
});
|
|
142
|
+
if (activeProcessPath) {
|
|
143
|
+
writeFileSync(activeProcessPath, `${JSON.stringify({
|
|
144
|
+
pid: child.pid,
|
|
145
|
+
...(platform !== 'win32' ? { processGroupId: child.pid } : {}),
|
|
146
|
+
})}\n`);
|
|
147
|
+
}
|
|
15
148
|
let stdout = '';
|
|
16
149
|
let stderr = '';
|
|
17
150
|
let timedOut = false;
|
|
18
151
|
|
|
152
|
+
const timeoutError = (cause) => {
|
|
153
|
+
const error = new Error(`Command timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`, { cause });
|
|
154
|
+
error.code = 'ETIMEDOUT';
|
|
155
|
+
return decorate(error);
|
|
156
|
+
};
|
|
157
|
+
|
|
19
158
|
const timer = timeoutMs
|
|
20
159
|
? setTimeout(() => {
|
|
21
160
|
timedOut = true;
|
|
22
|
-
|
|
161
|
+
const killDirectChild = () => {
|
|
162
|
+
try {
|
|
163
|
+
if (child.kill('SIGKILL') === false) reject(timeoutError());
|
|
164
|
+
} catch (error) {
|
|
165
|
+
reject(timeoutError(error));
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
if (!signalProcessGroup(child.pid, 'SIGKILL', {
|
|
169
|
+
platform,
|
|
170
|
+
spawnImpl,
|
|
171
|
+
onFailure: killDirectChild,
|
|
172
|
+
})) killDirectChild();
|
|
23
173
|
}, timeoutMs)
|
|
24
174
|
: null;
|
|
25
175
|
|
|
@@ -36,6 +186,7 @@ export async function runCommand(command, args = [], options = {}) {
|
|
|
36
186
|
|
|
37
187
|
const decorate = (error) => {
|
|
38
188
|
if (timer) clearTimeout(timer);
|
|
189
|
+
clearActiveProcess(activeProcessPath);
|
|
39
190
|
error.command = [command, ...args].join(' ');
|
|
40
191
|
error.stdout = stdout;
|
|
41
192
|
error.stderr = stderr;
|
|
@@ -47,10 +198,9 @@ export async function runCommand(command, args = [], options = {}) {
|
|
|
47
198
|
child.on('error', (error) => reject(decorate(error)));
|
|
48
199
|
child.on('close', (code, signal) => {
|
|
49
200
|
if (timer) clearTimeout(timer);
|
|
201
|
+
clearActiveProcess(activeProcessPath);
|
|
50
202
|
if (timedOut) {
|
|
51
|
-
|
|
52
|
-
error.code = 'ETIMEDOUT';
|
|
53
|
-
return reject(error);
|
|
203
|
+
return reject(timeoutError());
|
|
54
204
|
}
|
|
55
205
|
if (code === 0) return resolve({ stdout, stderr, code });
|
|
56
206
|
const error = decorate(new Error(`Command failed: ${command} ${args.join(' ')}${stderr ? `\n${stderr}` : ''}`));
|