@knowttl/gitea-axi 0.1.0 → 0.1.2

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/stacks.js ADDED
@@ -0,0 +1,486 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { Ajv } from 'ajv';
5
+ import { Git } from './git.js';
6
+ import { CliError } from './error.js';
7
+ import { collect, object, read } from './api.js';
8
+ import { repoPath } from './repo-workflows.js';
9
+ import { pullPlan } from './pulls.js';
10
+ import { gap, load, lock, same, save, usage, verifyPlannedPull } from './local-workflows.js';
11
+ import { write } from './writes.js';
12
+ const branch = { type: 'string', minLength: 1 };
13
+ const tips = { type: 'object', additionalProperties: { type: 'string', pattern: '^[a-f0-9]{40}$' } };
14
+ const schema = new Ajv().compile({
15
+ type: 'object', additionalProperties: false, required: ['version', 'revision', 'id', 'trunk', 'branches', 'remote', 'origin', 'repository', 'pulls'],
16
+ properties: {
17
+ version: { const: 1 }, revision: { type: 'integer', minimum: 0 }, id: branch, trunk: branch,
18
+ branches: { type: 'array', uniqueItems: true, items: branch }, remote: branch, origin: branch, repository: branch,
19
+ pulls: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['branch', 'number', 'base', 'head', 'state', 'merged'],
20
+ properties: { branch, number: { type: 'integer', minimum: 1 }, base: branch, head: { type: 'string', pattern: '^[a-f0-9]{40}$' },
21
+ state: { enum: ['open', 'closed'] }, merged: { type: 'boolean' } } } },
22
+ pending: { type: 'object', additionalProperties: false, required: ['action', 'confirmations', 'originalBranch', 'originalTips', 'steps'], properties: { action: branch,
23
+ confirmations: { type: 'array', uniqueItems: true, items: { type: 'string', pattern: '^[a-f0-9-]{36}$' } },
24
+ originalBranch: { type: 'string' }, originalTips: { type: 'string' },
25
+ steps: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['effect', 'state'],
26
+ properties: { effect: branch, state: { enum: ['pending', 'dispatched', 'completed'] } } } },
27
+ rebase: { type: 'object', additionalProperties: false,
28
+ required: ['branches', 'oldBases', 'originalTips', 'expectedTips', 'originalBranch', 'cursor', 'dispatched', 'authorDate'],
29
+ properties: { branches: { type: 'array', items: branch, uniqueItems: true }, oldBases: tips, originalTips: tips, expectedTips: tips,
30
+ originalBranch: branch, cursor: { type: 'integer', minimum: 0 }, dispatched: { type: 'boolean' }, authorDate: { type: 'boolean' } } } } },
31
+ },
32
+ });
33
+ async function stackAt(path, git) {
34
+ if (!existsSync(path))
35
+ return undefined;
36
+ const value = await load(path);
37
+ if (!schema(value) || value.origin !== git.origin || value.branches.includes(value.trunk) ||
38
+ new Set(value.pulls.map(pull => pull.branch)).size !== value.pulls.length ||
39
+ value.pulls.some(pull => !value.branches.includes(pull.branch))) {
40
+ throw new CliError('STACK_INVALID', 'Stack metadata is invalid, inconsistent or belongs to a different origin');
41
+ }
42
+ const rebase = value.pending?.rebase;
43
+ if (rebase) {
44
+ if (rebase.cursor > rebase.branches.length || rebase.branches.some(branch => !value.branches.includes(branch) || !rebase.oldBases[branch]) ||
45
+ Object.keys(rebase.oldBases).length !== rebase.branches.length ||
46
+ [rebase.originalTips, rebase.expectedTips].some(tips => Object.keys(tips).length !== value.branches.length || value.branches.some(branch => !Object.hasOwn(tips, branch)))) {
47
+ throw new CliError('STACK_INVALID', 'Rebase checkpoints must describe only the owned stack branches');
48
+ }
49
+ await git.branch(rebase.originalBranch);
50
+ }
51
+ for (const name of [value.trunk, ...value.branches])
52
+ await git.branch(name);
53
+ return value;
54
+ }
55
+ async function ancestor(git, parent, child) {
56
+ return (await git.run(['merge-base', parent, child])) === await git.tip(parent);
57
+ }
58
+ export async function stackWorkflow(client, identity, action, args, options) {
59
+ const git = await new Git(String(options.workspace), identity.origin, identity.token).workspace();
60
+ const directory = join(await git.run(['rev-parse', '--path-format=absolute', '--git-common-dir']), 'gitea-axi');
61
+ const path = join(directory, 'stacks.json');
62
+ const before = await stackAt(path, git);
63
+ const snapshot = await git.snapshot();
64
+ if (action === 'view') {
65
+ if (args.length)
66
+ usage('stack view takes no positional arguments');
67
+ return { stack: before ?? null, current: snapshot.branch, recovery: before?.pending ?? null };
68
+ }
69
+ if (action === 'submit')
70
+ gap('Stack submit requires --auto scheduling; actual merge_when_checks_succeed scheduling is not verified, so no branch or PR was changed');
71
+ if (options.all && options.update)
72
+ usage('Choose --all or --update');
73
+ if ((options.all || options.update) && !options.message)
74
+ usage('--all and --update require --message');
75
+ if (options.continue && options.abort)
76
+ usage('Choose --continue or --abort');
77
+ const recoveringRebase = action === 'rebase' && (options.continue || options.abort);
78
+ if (!snapshot.branch && !(recoveringRebase && before?.pending?.rebase))
79
+ usage('Check out a branch before running stack commands');
80
+ const recoveringPush = action === 'sync' && before?.pending?.action === 'push';
81
+ const recoveringSync = action === 'sync' && before?.pending?.action === 'sync';
82
+ const recovering = recoveringRebase || recoveringPush || recoveringSync;
83
+ if (recoveringRebase && (args.length || ['downstack', 'upstack', 'no-trunk', 'committer-date-is-author-date', 'remote'].some(key => options[key])))
84
+ usage('Recovery uses the recorded rebase options only');
85
+ if (before?.pending && !recovering)
86
+ throw new CliError('STACK_RECOVERY_REQUIRED', 'Stack operation is pending; inspect stack view and the native Git state before recovery');
87
+ if (!recovering && !(action === 'add' && options.message))
88
+ await git.clean();
89
+ if (!before && !['init', 'link'].includes(action))
90
+ usage('Initialize or link the stack first');
91
+ const remote = await git.remote(String(options.remote ?? before?.remote ?? 'origin'));
92
+ if (before && remote.repository !== before.repository)
93
+ usage('Selected remote must address the recorded stack repository');
94
+ const base = repoPath(remote.repository);
95
+ const remoteRefs = ['push', 'sync', 'rebase'].includes(action) ? await git.run(['ls-remote', '--heads', '--', remote.url]) : '';
96
+ if (recoveringPush) {
97
+ if (before.pending.originalTips !== snapshot.refs || remote.name !== before.remote)
98
+ throw new CliError('STACK_RECOVERY_REQUIRED', 'Push recovery requires the recorded branch tips and remote');
99
+ for (const name of before.branches) {
100
+ if (!remoteRefs.split('\n').includes(await git.tip('refs/heads/' + name) + '\trefs/heads/' + name)) {
101
+ throw new CliError('STACK_RECOVERY_REQUIRED', 'Remote does not confirm the interrupted push; no effect repeated');
102
+ }
103
+ }
104
+ }
105
+ let state = before ? structuredClone(before) : { version: 1, revision: 0, id: '', trunk: '', branches: [],
106
+ remote: remote.name, origin: identity.origin, repository: remote.repository, pulls: [] };
107
+ state.remote = remote.name;
108
+ const plans = [];
109
+ const commands = [];
110
+ let destination;
111
+ let remove = false;
112
+ let rebase;
113
+ const apiPreconditions = [];
114
+ const select = (selector) => {
115
+ if (state.branches.includes(selector) || selector === state.trunk)
116
+ return selector;
117
+ if (selector === state.id)
118
+ return state.branches[0];
119
+ const url = /^https?:/.test(selector) ? new URL(selector) : undefined;
120
+ if (url && (url.origin !== identity.origin || url.pathname.split('/').slice(1, 3).join('/') !== state.repository || url.search || url.hash || url.username || url.password))
121
+ usage('PR URL must belong to the stack repository');
122
+ const number = url ? /^\/[^/]+\/[^/]+\/pulls\/([1-9]\d*)$/.exec(url.pathname)?.[1] : selector;
123
+ return state.pulls.find(pull => String(pull.number) === number)?.branch ?? usage('Select an owned stack, branch or linked PR');
124
+ };
125
+ if (action === 'init' || action === 'link') {
126
+ if (before && action === 'init')
127
+ usage('A stack already exists; use stack add or unstack first');
128
+ if (!args.length)
129
+ usage('Select ordered existing branches or PR refs');
130
+ state.trunk = await git.branch(String(options.base ?? before?.trunk ?? snapshot.branch));
131
+ const branches = [];
132
+ const pulls = [];
133
+ for (const selector of args) {
134
+ let name = selector;
135
+ let number;
136
+ if (action === 'link') {
137
+ if (/^[1-9]\d*$/.test(selector))
138
+ number = selector;
139
+ else if (/^https?:/.test(selector)) {
140
+ const url = new URL(selector);
141
+ if (url.origin !== identity.origin || url.username || url.password || url.search || url.hash ||
142
+ !url.pathname.startsWith('/' + remote.repository + '/pulls/'))
143
+ usage('Select PR URLs in the stack repository');
144
+ number = url.pathname.split('/').at(-1);
145
+ if (!/^[1-9]\d*$/.test(number ?? ''))
146
+ usage('Select a positive PR number');
147
+ }
148
+ if (!number) {
149
+ const page = await collect(client, base + '/pulls?state=open');
150
+ if (page.truncated)
151
+ throw new CliError('INCOMPLETE_READ', 'Link requires the complete PR collection');
152
+ const matches = page.items.filter(pull => object(pull.head).ref === selector);
153
+ if (matches.length !== 1)
154
+ usage('Select a branch with exactly one open PR');
155
+ number = String(matches[0].number);
156
+ }
157
+ const pull = object(await read(client, base + '/pulls/' + number));
158
+ const head = object(pull.head);
159
+ if (pull.state !== 'open' || object(head.repo).full_name !== remote.repository)
160
+ usage('Link requires open same-repository PRs');
161
+ name = String(head.ref);
162
+ if (await git.tip('refs/heads/' + await git.branch(name)) !== head.sha)
163
+ throw new CliError('STALE_HEAD', 'Local linked branch differs from its PR head');
164
+ const parent = branches.at(-1) ?? state.trunk;
165
+ const plan = await pullPlan(client, 'pr edit', base, number, { base: parent });
166
+ plans.push(plan);
167
+ apiPreconditions.push(plan.preconditions);
168
+ pulls.push({ branch: name, number: Number(number), base: parent, head: String(head.sha), state: 'open', merged: false });
169
+ }
170
+ await git.branch(name);
171
+ if (name === state.trunk || branches.includes(name))
172
+ usage('Stack branch membership must be unique and exclude trunk');
173
+ const parent = branches.at(-1) ?? state.trunk;
174
+ if (!await ancestor(git, 'refs/heads/' + parent, 'refs/heads/' + name))
175
+ usage('Branches must form the requested ancestor order');
176
+ branches.push(name);
177
+ }
178
+ state = { ...state, id: branches[0], branches, pulls };
179
+ }
180
+ else if (action === 'add') {
181
+ if (args.length > 1)
182
+ usage('Select at most one existing branch');
183
+ const name = await git.branch(args[0] ?? snapshot.branch);
184
+ if (state.branches.includes(name) || name === state.trunk)
185
+ usage('Select a branch outside the stack');
186
+ if (options.message && name !== snapshot.branch)
187
+ usage('Commit options require the current branch');
188
+ if (!await ancestor(git, 'refs/heads/' + (state.branches.at(-1) ?? state.trunk), 'refs/heads/' + name))
189
+ usage('New branch must descend from the stack top');
190
+ if (options.all || options.update)
191
+ commands.push(['add', '--update', '--', '.']);
192
+ if (options.message)
193
+ commands.push(['commit', '-m', String(options.message)]);
194
+ state.branches.push(name);
195
+ }
196
+ else if (['checkout', 'up', 'down', 'top', 'bottom', 'trunk'].includes(action)) {
197
+ if (action === 'checkout') {
198
+ if (args.length !== 1)
199
+ usage('Select one stack, branch, PR number or URL');
200
+ destination = select(args[0]);
201
+ }
202
+ else if (action === 'up' || action === 'down') {
203
+ if (args.length > 1 || args[0] !== undefined && !/^[1-9]\d*$/.test(args[0]))
204
+ usage('Select a positive step count');
205
+ const index = state.branches.indexOf(snapshot.branch);
206
+ if (index < 0 && snapshot.branch !== state.trunk)
207
+ usage('Current branch is outside the stack');
208
+ const target = index + (action === 'up' ? 1 : -1) * Number(args[0] ?? 1);
209
+ if (target < -1 || target >= state.branches.length)
210
+ usage('Movement is outside the stack');
211
+ destination = target === -1 ? state.trunk : state.branches[target];
212
+ }
213
+ else {
214
+ if (args.length)
215
+ usage('Navigation takes no positional arguments');
216
+ destination = action === 'trunk' ? state.trunk : action === 'top' ? state.branches.at(-1) : state.branches[0];
217
+ if (!destination)
218
+ usage('The stack has no remaining branches');
219
+ }
220
+ commands.push(['checkout', destination]);
221
+ }
222
+ else if (action === 'push') {
223
+ if (args.length)
224
+ usage('stack push takes no positional arguments');
225
+ if (!state.branches.length)
226
+ usage('The stack has no remaining branches to push');
227
+ const leases = state.branches.map(name => '--force-with-lease=refs/heads/' + name + ':' +
228
+ (remoteRefs.split('\n').find(line => line.endsWith('\trefs/heads/' + name))?.split('\t')[0] ?? ''));
229
+ commands.push(['push', '--atomic', ...leases, '--', remote.url, ...state.branches.map(name => 'refs/heads/' + name + ':refs/heads/' + name)]);
230
+ }
231
+ else if (action === 'sync') {
232
+ if (args.length)
233
+ usage('stack sync takes no positional arguments');
234
+ const page = await collect(client, base + '/pulls?state=all');
235
+ if (page.truncated)
236
+ throw new CliError('INCOMPLETE_READ', 'Sync requires a complete PR collection');
237
+ apiPreconditions.push(page.items);
238
+ for (const linked of state.pulls) {
239
+ const pull = page.items.find(pull => pull.number === linked.number);
240
+ if (!pull)
241
+ throw new CliError('NOT_FOUND_OR_HIDDEN', 'A recorded PR is missing from the complete sync result');
242
+ const head = object(pull.head);
243
+ if (head.ref !== linked.branch || object(head.repo).full_name !== state.repository ||
244
+ !/^[a-f0-9]{40}$/.test(String(head.sha)) || !['open', 'closed'].includes(String(pull.state))) {
245
+ throw new CliError('INVALID_RESPONSE', 'A synced PR changed its recorded branch or repository identity');
246
+ }
247
+ linked.head = String(head.sha);
248
+ linked.base = await git.branch(String(object(pull.base).ref));
249
+ linked.state = pull.state;
250
+ linked.merged = pull.merged === true;
251
+ }
252
+ const existing = [state.trunk, ...state.branches].filter(name => remoteRefs.split('\n').some(line => line.endsWith('\trefs/heads/' + name)));
253
+ if (existing.length)
254
+ commands.push(['fetch', '--no-tags', '--', remote.url, ...existing.map(name => 'refs/heads/' + name + ':refs/remotes/' + remote.name + '/' + name)]);
255
+ if (options.prune) {
256
+ for (const member of [...state.branches]) {
257
+ const linked = state.pulls.find(pull => pull.branch === member);
258
+ const pull = page.items.find(pull => pull.number === linked?.number);
259
+ if (!linked || !pull || pull.merged !== true || object(pull.head).sha !== await git.tip('refs/heads/' + member))
260
+ continue;
261
+ if (member === snapshot.branch || snapshot.worktrees.split('\n').includes('branch refs/heads/' + member))
262
+ continue;
263
+ const merge = String(pull.merge_commit_sha);
264
+ if (!/^[a-f0-9]{40}$/.test(merge) || !await git.run(['rev-parse', '--verify', '--quiet', merge + '^{commit}'], [0, 1]) ||
265
+ !await ancestor(git, 'refs/heads/' + member, merge))
266
+ continue;
267
+ commands.push(['branch', '-d', member]);
268
+ }
269
+ }
270
+ }
271
+ else if (action === 'unstack') {
272
+ if (args.length > 1 || args[0] !== undefined && args[0] !== state.id)
273
+ usage('Select the recorded stack identity');
274
+ if (!options.local) {
275
+ for (const pull of state.pulls) {
276
+ const plan = await pullPlan(client, 'pr edit', base, String(pull.number), { base: state.trunk });
277
+ plans.push(plan);
278
+ apiPreconditions.push(plan.preconditions);
279
+ }
280
+ }
281
+ remove = true;
282
+ }
283
+ else if (action === 'merge') {
284
+ if (args.length !== 1)
285
+ usage('Select the stack identity or linked PR');
286
+ const selected = args[0] === state.id ? state.branches.at(-1) : select(args[0]);
287
+ const styles = ['merge', 'squash', 'rebase'].filter(key => options[key]);
288
+ if (styles.length + Number(options['merge-method'] !== undefined) !== 1)
289
+ usage('Choose exactly one merge method');
290
+ const method = String(options['merge-method'] ?? styles[0]);
291
+ if (!['merge', 'squash', 'rebase'].includes(method))
292
+ usage('Use merge, squash or rebase');
293
+ for (const member of state.branches.slice(0, state.branches.indexOf(selected) + 1)) {
294
+ const linked = state.pulls.find(pull => pull.branch === member) ?? usage('Every merged stack branch requires a linked PR');
295
+ const pull = object(await read(client, base + '/pulls/' + linked.number));
296
+ apiPreconditions.push(pull);
297
+ if (pull.merged === true) {
298
+ const head = object(pull.head);
299
+ if (pull.state !== 'closed' || head.ref !== member || object(head.repo).full_name !== state.repository ||
300
+ head.sha !== await git.tip('refs/heads/' + member))
301
+ throw new CliError('STALE_HEAD', 'Completed dependency differs from its owned branch');
302
+ linked.state = 'closed';
303
+ linked.merged = true;
304
+ linked.head = String(head.sha);
305
+ continue;
306
+ }
307
+ if (linked.base !== state.trunk) {
308
+ const edit = await pullPlan(client, 'pr edit', base, String(linked.number), { base: state.trunk });
309
+ plans.push(edit);
310
+ apiPreconditions.push(edit.preconditions);
311
+ }
312
+ const plan = await pullPlan(client, 'pr merge', base, String(linked.number), { method, 'match-head-commit': await git.tip('refs/heads/' + member) });
313
+ plans.push(plan);
314
+ apiPreconditions.push(plan.preconditions);
315
+ }
316
+ }
317
+ else if (action === 'rebase') {
318
+ if (recoveringRebase) {
319
+ rebase = state.pending?.rebase ?? usage('No recorded stack rebase to recover');
320
+ }
321
+ else {
322
+ if (args.length > 1)
323
+ usage('Select at most one stack branch');
324
+ const selected = args[0] ?? snapshot.branch;
325
+ const index = state.branches.indexOf(selected);
326
+ if (index < 0)
327
+ usage('Select a stack branch for rebase');
328
+ const first = options.downstack ? 0 : index;
329
+ const last = options.upstack ? state.branches.length : index + 1;
330
+ const branches = state.branches.slice(first, last).filter(name => !(options['no-trunk'] && state.branches.indexOf(name) === 0));
331
+ if (!branches.length)
332
+ usage('The selected rebase scope is empty');
333
+ const originalTips = Object.create(null), oldBases = Object.create(null);
334
+ for (const name of state.branches)
335
+ originalTips[name] = await git.tip('refs/heads/' + name);
336
+ for (const name of branches) {
337
+ const parent = state.branches[state.branches.indexOf(name) - 1] ?? state.trunk;
338
+ oldBases[name] = await git.run(['merge-base', 'refs/heads/' + parent, 'refs/heads/' + name]);
339
+ }
340
+ rebase = { branches, oldBases, originalTips, expectedTips: { ...originalTips }, originalBranch: snapshot.branch, cursor: 0, dispatched: false,
341
+ authorDate: Boolean(options['committer-date-is-author-date']) };
342
+ state.pending = { action: 'rebase', rebase, confirmations: [], originalBranch: snapshot.branch, originalTips: snapshot.refs, steps: [] };
343
+ }
344
+ }
345
+ else
346
+ usage('Unknown stack operation');
347
+ const localAction = action === 'rebase' ? options.continue ? 'continue' : options.abort ? 'abort' : 'rebase' :
348
+ ['up', 'down', 'top', 'bottom', 'trunk'].includes(action) ? 'checkout' : action;
349
+ const local = 'gitea.local.stack.' + localAction;
350
+ const begin = { method: 'LOCAL', path: git.cwd, local, payload: { phase: 'begin', action, args, options: { ...options, preview: undefined, confirm: undefined }, remote,
351
+ ...(recovering ? { recoverLocal: before.pending.confirmations } : {}) } };
352
+ const finish = { method: 'LOCAL', path: git.cwd, local, payload: { phase: 'finish', state, commands, remove, destination } };
353
+ const steps = [begin];
354
+ const apiResultIndices = [];
355
+ for (const [index, plan] of plans.entries()) {
356
+ steps.push({ method: 'LOCAL', path: git.cwd, local, payload: { phase: 'prepare', index } });
357
+ apiResultIndices.push(steps.length);
358
+ steps.push(...plan.steps, { method: 'LOCAL', path: git.cwd, local, payload: { phase: 'verify', index } });
359
+ }
360
+ steps.push(finish);
361
+ const plan = { target: git.cwd, reads: [], steps, preconditions: { metadata: before ?? null, snapshot, api: apiPreconditions,
362
+ remoteRefs: remoteRefs || null },
363
+ ...(recovering ? { recoverLocal: before.pending.confirmations } : {}),
364
+ notes: action === 'merge' ? { yes: true, order: plans.filter(plan => plan.pull?.action === 'merge').map(plan => plan.target) } : undefined };
365
+ let unlock;
366
+ const runtimePlans = [...plans];
367
+ try {
368
+ return await write(client, identity, plan, options, async (step, results, confirmation) => {
369
+ const payload = object(step.payload);
370
+ if (payload.phase === 'begin') {
371
+ unlock = await lock(directory);
372
+ if (!same(before, await stackAt(path, git)) || !same(snapshot, await git.snapshot()))
373
+ throw new CliError('CONFIRMATION_MISMATCH', 'Stack or workspace changed before dispatch');
374
+ if (action !== 'rebase')
375
+ state.pending = { action, confirmations: recovering ? [...before.pending.confirmations] : [],
376
+ originalBranch: before?.pending?.originalBranch ?? snapshot.branch, originalTips: before?.pending?.originalTips ?? snapshot.refs,
377
+ steps: [...plans.map(plan => ({ effect: plan.target, state: 'pending' })), ...commands.map(command => ({ effect: command[0], state: 'pending' }))] };
378
+ state.pending.confirmations.push(confirmation);
379
+ await save(path, state);
380
+ return { state: 'started' };
381
+ }
382
+ if (payload.phase === 'prepare') {
383
+ const index = Number(payload.index), planned = plans[index];
384
+ if (planned.pull?.action === 'merge') {
385
+ const fresh = await pullPlan(client, 'pr merge', base, planned.target.split('/').at(-1), {
386
+ method: String(object(planned.steps[0].payload).Do), 'match-head-commit': String(planned.pull.expected.headSHA),
387
+ });
388
+ if (!same(fresh.steps, planned.steps) || fresh.pull.expected.base !== state.trunk)
389
+ throw new CliError('STALE_HEAD', 'Stack merge base or checked head changed');
390
+ runtimePlans[index] = fresh;
391
+ }
392
+ state.pending.steps[index].state = 'dispatched';
393
+ await save(path, state);
394
+ return { prepared: planned.target };
395
+ }
396
+ if (payload.phase === 'verify') {
397
+ const index = Number(payload.index);
398
+ await verifyPlannedPull(client, runtimePlans[index], results[apiResultIndices[index]]);
399
+ const verified = runtimePlans[index].pull;
400
+ const member = state.pulls.find(pull => pull.number === Number(runtimePlans[index].target.split('/').at(-1)));
401
+ if (member) {
402
+ member.base = String(verified.expected.base);
403
+ member.head = String(verified.expected.headSHA);
404
+ member.merged = verified.action === 'merge' || verified.expected.merged === true;
405
+ member.state = member.merged ? 'closed' : verified.expected.state;
406
+ }
407
+ state.pending.steps[index].state = 'completed';
408
+ await save(path, state);
409
+ return { verified: plans[index].target };
410
+ }
411
+ if (rebase) {
412
+ const nativeRebase = existsSync(await git.run(['rev-parse', '--path-format=absolute', '--git-path', 'rebase-merge'])) ||
413
+ existsSync(await git.run(['rev-parse', '--path-format=absolute', '--git-path', 'rebase-apply']));
414
+ for (const [name, expected] of Object.entries(rebase.expectedTips)) {
415
+ if (await git.tip('refs/heads/' + name) !== expected)
416
+ throw new CliError('STACK_RECOVERY_REQUIRED', 'A branch tip changed outside the recorded checkpoint; no effect repeated');
417
+ }
418
+ if (options.abort) {
419
+ if (nativeRebase)
420
+ await git.run(['rebase', '--abort']);
421
+ for (const [name, expected] of Object.entries(rebase.expectedTips)) {
422
+ if (expected !== rebase.originalTips[name])
423
+ await git.run(['update-ref', 'refs/heads/' + name, rebase.originalTips[name], expected]);
424
+ }
425
+ await git.run(['checkout', rebase.originalBranch]);
426
+ }
427
+ else {
428
+ if (rebase.dispatched) {
429
+ if (!nativeRebase || !options.continue)
430
+ throw new CliError('STACK_RECOVERY_REQUIRED', 'Dispatched rebase has no native conflict state; inspect it without repeating the effect');
431
+ await git.run(['rebase', '--continue']);
432
+ const name = rebase.branches[rebase.cursor];
433
+ rebase.expectedTips[name] = await git.tip('refs/heads/' + name);
434
+ rebase.cursor++;
435
+ rebase.dispatched = false;
436
+ await save(path, state);
437
+ }
438
+ for (; rebase.cursor < rebase.branches.length;) {
439
+ const name = rebase.branches[rebase.cursor];
440
+ const parent = state.branches[state.branches.indexOf(name) - 1] ?? state.trunk;
441
+ rebase.dispatched = true;
442
+ await save(path, state);
443
+ await git.run(['rebase', ...(rebase.authorDate ? ['--committer-date-is-author-date'] : []), '--onto',
444
+ 'refs/heads/' + parent, rebase.oldBases[name], name]);
445
+ rebase.expectedTips[name] = await git.tip('refs/heads/' + name);
446
+ rebase.cursor++;
447
+ rebase.dispatched = false;
448
+ await save(path, state);
449
+ }
450
+ await git.run(['checkout', rebase.originalBranch]);
451
+ }
452
+ }
453
+ else {
454
+ for (const [index, command] of commands.entries()) {
455
+ state.pending.steps[plans.length + index].state = 'dispatched';
456
+ await save(path, state);
457
+ await git.run(command);
458
+ if (action === 'sync' && command[0] === 'branch') {
459
+ state.branches = state.branches.filter(name => name !== command[2]);
460
+ state.pulls = state.pulls.filter(pull => pull.branch !== command[2]);
461
+ }
462
+ state.pending.steps[plans.length + index].state = 'completed';
463
+ await save(path, state);
464
+ }
465
+ if (action === 'push') {
466
+ const refs = await git.run(['ls-remote', '--heads', '--', remote.url]);
467
+ for (const name of state.branches) {
468
+ if (!refs.split('\n').includes(await git.tip('refs/heads/' + name) + '\trefs/heads/' + name))
469
+ throw new CliError('READBACK_MISMATCH', 'Selected remote did not confirm all pushed branch tips');
470
+ }
471
+ }
472
+ }
473
+ delete state.pending;
474
+ state.revision++;
475
+ if (remove)
476
+ await rm(path);
477
+ else
478
+ await save(path, state);
479
+ return { stack: remove ? null : state, current: await git.run(['symbolic-ref', '--short', 'HEAD']) };
480
+ });
481
+ }
482
+ finally {
483
+ if (unlock)
484
+ await unlock();
485
+ }
486
+ }
package/dist/transport.js CHANGED
@@ -1,5 +1,24 @@
1
1
  import { limits } from './registry.js';
2
2
  import { CliError } from './error.js';
3
+ import { canonicalPath } from './api.js';
4
+ import { transferBytes } from './attachments.js';
5
+ function transportCause(error) {
6
+ if (!(error instanceof Error))
7
+ return 'connection error';
8
+ if (error.name === 'TimeoutError' || error.name === 'AbortError')
9
+ return 'ETIMEDOUT';
10
+ const cause = error.cause ?? error;
11
+ const code = cause && typeof cause === 'object' && 'code' in cause ? cause.code : undefined;
12
+ if (typeof code === 'string') {
13
+ if (['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH'].includes(code))
14
+ return code;
15
+ if (code === 'UND_ERR_CONNECT_TIMEOUT')
16
+ return 'ETIMEDOUT';
17
+ if (/^(?:ERR_TLS_|ERR_SSL_|CERT_|DEPTH_ZERO_|SELF_SIGNED_|UNABLE_TO_)/.test(code))
18
+ return 'TLS error';
19
+ }
20
+ return 'connection error';
21
+ }
3
22
  export class Client {
4
23
  origin;
5
24
  token;
@@ -9,19 +28,27 @@ export class Client {
9
28
  this.token = token;
10
29
  this.deadline = Date.now() + timeoutMs;
11
30
  }
12
- async request(path, text = false) {
31
+ async request(path, text = false, mutation) {
32
+ canonicalPath(path);
13
33
  const url = new URL(path, this.origin);
14
34
  if (url.origin !== this.origin || !path.startsWith('/') || path.startsWith('//'))
15
35
  throw new CliError('UNSAFE_PATH', 'Request must stay within the selected origin');
16
36
  try {
37
+ const form = mutation?.upload ? new FormData() : undefined;
38
+ if (form && mutation?.upload)
39
+ form.set('attachment', new Blob([Buffer.from(mutation.upload.base64, 'base64')], { type: mutation.upload.type }), mutation.upload.name);
17
40
  const response = await fetch(url, {
18
- headers: { accept: text ? 'text/plain' : 'application/json', ...(this.token ? { authorization: 'token ' + this.token } : {}) },
41
+ method: mutation?.method ?? 'GET',
42
+ ...(form ? { body: form } : mutation?.payload === undefined ? {} : { body: JSON.stringify(mutation.payload) }),
43
+ headers: { accept: text ? 'text/plain' : 'application/json', ...(mutation && !form ? { 'content-type': 'application/json' } : {}), ...(this.token ? { authorization: 'token ' + this.token } : {}) },
19
44
  redirect: 'error',
20
- signal: AbortSignal.timeout(Math.max(1, this.deadline - Date.now())),
45
+ signal: AbortSignal.timeout(Math.max(1, Math.min(limits.timeoutMs, this.deadline - Date.now()))),
21
46
  });
22
- if (response.ok && !text && !response.headers.get('content-type')?.includes('application/json')) {
47
+ if (mutation?.sensitive) {
23
48
  await response.body?.cancel();
24
- throw new CliError('INVALID_RESPONSE', 'Gitea did not return JSON');
49
+ if (!response.ok)
50
+ throw new CliError('HTTP_ERROR', 'Protected secret operation returned HTTP ' + response.status);
51
+ return { data: null, headers: response.headers };
25
52
  }
26
53
  const chunks = [];
27
54
  let bytes = 0;
@@ -33,6 +60,8 @@ export class Client {
33
60
  chunks.push(chunk);
34
61
  }
35
62
  const body = Buffer.concat(chunks).toString('utf8');
63
+ if (response.ok && response.status !== 204 && !text && !(mutation && body === '') && !response.headers.get('content-type')?.includes('application/json'))
64
+ throw new CliError('INVALID_RESPONSE', 'Gitea did not return JSON');
36
65
  if (!response.ok) {
37
66
  let reason = '';
38
67
  if (response.headers.get('content-type')?.includes('application/json')) {
@@ -48,17 +77,43 @@ export class Client {
48
77
  response.status === 404 ? 'NOT_FOUND_OR_HIDDEN' : response.status === 429 ? 'RATE_LIMITED' : 'HTTP_ERROR';
49
78
  throw new CliError(code, `${path.split('?')[0]} returned HTTP ${response.status}${reason}${retry ? '; retry-after ' + retry : ''}`);
50
79
  }
51
- return { data: text ? body : JSON.parse(body), headers: response.headers };
80
+ return { data: response.status === 204 || mutation && body === '' ? null : text ? body : JSON.parse(body), headers: response.headers };
52
81
  }
53
82
  catch (error) {
54
83
  if (error instanceof CliError)
55
84
  throw error;
56
85
  if (error instanceof SyntaxError)
57
86
  throw new CliError('INVALID_RESPONSE', 'Gitea returned malformed JSON');
58
- throw new CliError('TRANSPORT_ERROR', 'Gitea request failed; check the selected origin and connection');
87
+ throw new CliError('TRANSPORT_ERROR', 'Gitea request failed (' + transportCause(error) + '); check the selected origin and connection');
59
88
  }
60
89
  }
61
90
  async get(path) { return (await this.request(path)).data; }
91
+ async attachment(uuid, expectedType) {
92
+ if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(uuid))
93
+ throw new CliError('UNSAFE_PATH', 'Invalid attachment UUID');
94
+ try {
95
+ const response = await fetch(new URL('/attachments/' + uuid, this.origin), { headers: { ...(this.token ? { authorization: 'token ' + this.token } : {}) }, redirect: 'error', signal: AbortSignal.timeout(Math.max(1, this.deadline - Date.now())) });
96
+ const type = response.headers.get('content-type')?.split(';')[0]?.trim();
97
+ if (!response.ok || !type || expectedType && type !== expectedType) {
98
+ await response.body?.cancel();
99
+ throw new CliError('INVALID_RESPONSE', 'Binary attachment returned an unexpected status or content type');
100
+ }
101
+ let size = 0;
102
+ const chunks = [];
103
+ for await (const chunk of response.body ?? []) {
104
+ size += chunk.byteLength;
105
+ if (size > transferBytes)
106
+ throw new CliError('TRANSFER_LIMIT', 'Binary attachment exceeds 10000000 bytes');
107
+ chunks.push(chunk);
108
+ }
109
+ return { bytes: Buffer.concat(chunks), type };
110
+ }
111
+ catch (error) {
112
+ if (error instanceof CliError)
113
+ throw error;
114
+ throw new CliError('TRANSPORT_ERROR', 'Binary attachment transfer failed (' + transportCause(error) + '); redirects are refused');
115
+ }
116
+ }
62
117
  }
63
118
  export async function get(origin, path, token) {
64
119
  return new Client(origin, token).get(path);