@knowttl/gitea-axi 0.1.2 → 0.1.4
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/README.md +34 -12
- package/dist/api.js +5 -5
- package/dist/cli.js +2 -1
- package/dist/git.js +3 -3
- package/dist/local-workflows.js +15 -18
- package/dist/main.js +22 -19
- package/dist/output.js +60 -0
- package/dist/pulls.js +42 -18
- package/dist/reads.js +21 -11
- package/dist/registry.js +3 -2
- package/dist/search.js +4 -2
- package/dist/stacks.js +61 -8
- package/dist/transport.js +4 -3
- package/dist/writes.js +32 -8
- package/generated/help.txt +385 -1
- package/generated/host-inventory.json +513 -1
- package/package.json +1 -1
- package/generated/capabilities.json +0 -964
package/dist/reads.js
CHANGED
|
@@ -111,8 +111,8 @@ export async function listIssues(client, base, kind, options) {
|
|
|
111
111
|
truncated: page.truncated || rows.length > limit, next: page.next, continuation: page.truncated || rows.length > limit,
|
|
112
112
|
help: [`gitea-axi ${kind} view <number> --repo ${base.split('/').slice(-2).join('/')}`, ...continuationHelp(page, rows.length, limit)] };
|
|
113
113
|
}
|
|
114
|
-
async function complete(client, path) {
|
|
115
|
-
const page = await collect(client, path);
|
|
114
|
+
async function complete(client, path, signal) {
|
|
115
|
+
const page = await collect(client, path, undefined, signal);
|
|
116
116
|
if (page.truncated)
|
|
117
117
|
throw new CliError('RESPONSE_LIMIT', 'Related collection exceeds the bounded read; use the raw API to page explicitly');
|
|
118
118
|
return page.items;
|
|
@@ -127,11 +127,11 @@ async function reviews(client, path, full) {
|
|
|
127
127
|
}
|
|
128
128
|
return result;
|
|
129
129
|
}
|
|
130
|
-
export async function checks(client, base, sha) {
|
|
131
|
-
const combined = object(await read(client, base + '/commits/' + encodeURIComponent(sha) + '/status'));
|
|
130
|
+
export async function checks(client, base, sha, signal) {
|
|
131
|
+
const combined = object(await read(client, base + '/commits/' + encodeURIComponent(sha) + '/status', signal));
|
|
132
132
|
if (combined.sha !== sha && !(combined.sha === '' && combined.total_count === 0 && (combined.statuses === null || Array.isArray(combined.statuses) && !combined.statuses.length)))
|
|
133
133
|
throw new CliError('INVALID_RESPONSE', 'Check response does not match the pull request head SHA');
|
|
134
|
-
const statuses = await complete(client, base + '/commits/' + encodeURIComponent(sha) + '/statuses');
|
|
134
|
+
const statuses = await complete(client, base + '/commits/' + encodeURIComponent(sha) + '/statuses', signal);
|
|
135
135
|
statuses.sort((a, b) => Number(b.id) - Number(a.id));
|
|
136
136
|
const latest = new Map();
|
|
137
137
|
for (const status of statuses)
|
|
@@ -146,28 +146,37 @@ export async function checks(client, base, sha) {
|
|
|
146
146
|
export async function watchChecks(client, base, number, options) {
|
|
147
147
|
const seconds = positive(options.timeout, 30, 3600);
|
|
148
148
|
const interval = positive(options.interval, 5, 60) * 1000;
|
|
149
|
-
const stop = Date.now() + seconds * 1000;
|
|
149
|
+
const stop = Math.min(client.deadline, Date.now() + seconds * 1000);
|
|
150
150
|
const controller = new AbortController();
|
|
151
|
+
let head = null;
|
|
152
|
+
let latest;
|
|
151
153
|
const cancel = () => controller.abort();
|
|
152
154
|
process.once('SIGINT', cancel);
|
|
153
155
|
process.once('SIGTERM', cancel);
|
|
154
156
|
try {
|
|
155
157
|
while (true) {
|
|
156
|
-
const pull = object(await read(client, base + '/pulls/' + number));
|
|
158
|
+
const pull = object(await read(client, base + '/pulls/' + number, controller.signal));
|
|
157
159
|
const sha = object(pull.head).sha;
|
|
158
160
|
if (typeof sha !== 'string' || !sha)
|
|
159
161
|
throw new CliError('INVALID_RESPONSE', 'Pull request head SHA is missing');
|
|
160
|
-
|
|
162
|
+
head = sha;
|
|
163
|
+
const result = await checks(client, base, sha, controller.signal);
|
|
164
|
+
latest = result;
|
|
161
165
|
if (!options.watch || result.items.length > 0 && !result.pending || result.failed || Date.now() >= stop || controller.signal.aborted)
|
|
162
|
-
return { ...result, cancelled: controller.signal.aborted };
|
|
166
|
+
return { ...result, exitCode: controller.signal.aborted ? 8 : result.exitCode, cancelled: controller.signal.aborted };
|
|
163
167
|
await delay(Math.min(interval, Math.max(1, stop - Date.now())), undefined, { signal: controller.signal }).catch(error => {
|
|
164
168
|
if (!controller.signal.aborted)
|
|
165
169
|
throw error;
|
|
166
170
|
});
|
|
167
171
|
if (Date.now() >= stop || controller.signal.aborted)
|
|
168
|
-
return { ...result, cancelled: controller.signal.aborted };
|
|
172
|
+
return { ...result, exitCode: controller.signal.aborted ? 8 : result.exitCode, cancelled: controller.signal.aborted };
|
|
169
173
|
}
|
|
170
174
|
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (!controller.signal.aborted)
|
|
177
|
+
throw error;
|
|
178
|
+
return { ...(latest ?? { head, items: [], passed: 0, failed: 0, pending: 0 }), exitCode: 8, cancelled: true };
|
|
179
|
+
}
|
|
171
180
|
finally {
|
|
172
181
|
process.removeListener('SIGINT', cancel);
|
|
173
182
|
process.removeListener('SIGTERM', cancel);
|
|
@@ -196,7 +205,8 @@ export async function view(client, base, kind, number, options) {
|
|
|
196
205
|
headRepository: head.repo == null ? null : { name: object(head.repo).name, nameWithOwner: object(head.repo).full_name },
|
|
197
206
|
headRepositoryOwner: head.repo == null ? null : user(object(head.repo).owner), isCrossRepository: head.repo_id == null || tail.repo_id == null ? null : head.repo_id !== tail.repo_id,
|
|
198
207
|
mergeCommit: row.merge_commit_sha ? { oid: row.merge_commit_sha } : null, mergedBy: user(row.merged_by),
|
|
199
|
-
mergeable: row.mergeable == null ? 'UNKNOWN' : row.mergeable ? 'MERGEABLE' : 'CONFLICTING', reviewRequests: users(row.requested_reviewers)
|
|
208
|
+
mergeable: row.mergeable == null ? 'UNKNOWN' : row.mergeable ? 'MERGEABLE' : 'CONFLICTING', reviewRequests: [...users(row.requested_reviewers),
|
|
209
|
+
...(row.requested_reviewers_teams ?? []).map(team => ({ type: 'team', id: team.id, name: team.name }))] });
|
|
200
210
|
}
|
|
201
211
|
if (options.reviews || requested.has('reviews') || requested.has('latestReviews')) {
|
|
202
212
|
const all = await reviews(client, path, Boolean(options.full || fields));
|
package/dist/registry.js
CHANGED
|
@@ -22,6 +22,7 @@ export const command = {
|
|
|
22
22
|
};
|
|
23
23
|
export const limits = { timeoutMs: 30_000, responseBytes: 2_000_000 };
|
|
24
24
|
const globalFlags = {
|
|
25
|
+
json: { type: 'boolean', description: 'Emit JSON instead of TOON; help follows this format, version remains plain text' },
|
|
25
26
|
origin: command.flags.origin, 'allow-insecure-http': command.flags['allow-insecure-http'], help: command.flags.help,
|
|
26
27
|
config: { type: 'string', description: 'Absolute configuration path' },
|
|
27
28
|
profile: { type: 'string', description: 'Select one file profile' },
|
|
@@ -91,7 +92,7 @@ const definitions = [
|
|
|
91
92
|
['pr view', { ...repo, ...detail, reviews: { type: 'boolean' }, checks: { type: 'boolean' } }],
|
|
92
93
|
['pr checks', { ...repo, watch: { type: 'boolean' }, timeout: { type: 'string', description: 'Watch deadline seconds, required with --watch' }, interval: { type: 'string', description: 'Watch interval seconds, default 5' } }],
|
|
93
94
|
['pr diff', { ...repo, full: detail.full }],
|
|
94
|
-
['release list', { ...repo, limit: list.limit, 'exclude-drafts': { type: 'boolean' }, 'exclude-pre-releases': { type: 'boolean' } }],
|
|
95
|
+
['release list', { ...repo, limit: { ...list.limit, description: 'Maximum returned records, default 10, maximum 1000' }, 'exclude-drafts': { type: 'boolean' }, 'exclude-pre-releases': { type: 'boolean' } }],
|
|
95
96
|
['release view', { ...repo, full: detail.full }], ['api', { ...writeFlags,
|
|
96
97
|
method: { type: 'string', short: 'X', description: 'HTTP method; cannot combine with a positional method' },
|
|
97
98
|
field: { type: 'string', multiple: true, description: 'Repeatable key=value; numbers, booleans and null become JSON values' },
|
|
@@ -144,7 +145,7 @@ for (const command of commands) {
|
|
|
144
145
|
if (['repo create', 'repo fork'].includes(command.path.join(' ')))
|
|
145
146
|
Object.assign(command.flags, workspace);
|
|
146
147
|
}
|
|
147
|
-
commands.push({ path: ['capabilities'], description: 'Inspect all baseline command IDs, flags, globals and PR fields without claiming full parity', flags: { help: globalFlags.help }, effect: 'local-read' });
|
|
148
|
+
commands.push({ path: ['capabilities'], description: 'Inspect all baseline command IDs, flags, globals and PR fields without claiming full parity', flags: { help: globalFlags.help, json: globalFlags.json }, effect: 'local-read' });
|
|
148
149
|
const refusedFlags = [
|
|
149
150
|
['repo create', ['internal']],
|
|
150
151
|
['release create', ['latest', 'discussion-category']],
|
package/dist/search.js
CHANGED
|
@@ -9,7 +9,7 @@ export function validateSearch(kind, query, options) {
|
|
|
9
9
|
const allowed = kind === 'issues' || kind === 'prs' ? ['owner', 'state', 'label', 'assignee', 'author', 'sort', 'order', 'limit', 'fields', ...(kind === 'prs' ? ['draft'] : [])] :
|
|
10
10
|
kind === 'repos' ? ['owner', 'sort', 'order', 'limit', 'language', 'stars', 'fields'] : ['author', 'sort', 'order', 'limit', 'revision', 'fields'];
|
|
11
11
|
for (const key of Object.keys(options)) {
|
|
12
|
-
if (!['origin', 'allow-insecure-http', 'config', 'profile', 'repo'].includes(key) && !allowed.includes(key))
|
|
12
|
+
if (!['origin', 'allow-insecure-http', 'config', 'profile', 'hostname', 'repo', 'json'].includes(key) && !allowed.includes(key))
|
|
13
13
|
throw new CliError('CAPABILITY_GAP', '--' + key + ' is not evidenced for search ' + kind);
|
|
14
14
|
}
|
|
15
15
|
positive(options.limit, 1000, 1000);
|
|
@@ -94,9 +94,11 @@ export async function search(client, kind, query, options) {
|
|
|
94
94
|
return true;
|
|
95
95
|
});
|
|
96
96
|
const key = options.sort === 'updated' ? 'updated_at' : options.sort === 'comments' ? 'comments' : options.sort === 'stars' ? 'stars_count' : options.sort === 'name' ? 'full_name' : kind === 'commits' ? 'created' : 'created_at';
|
|
97
|
+
const temporal = ['created', 'created_at', 'updated_at'].includes(key);
|
|
97
98
|
rows.sort((a, b) => {
|
|
98
99
|
const left = a[key] ?? '', right = b[key] ?? '';
|
|
99
|
-
const compared =
|
|
100
|
+
const compared = temporal && left !== '' && right !== '' ? Date.parse(String(left)) - Date.parse(String(right)) :
|
|
101
|
+
typeof left === 'number' && typeof right === 'number' ? left - right : String(left).localeCompare(String(right));
|
|
100
102
|
return (compared || String(a.id ?? a.sha).localeCompare(String(b.id ?? b.sha))) * (options.order === 'asc' ? 1 : -1);
|
|
101
103
|
});
|
|
102
104
|
const limit = positive(options.limit, 1000, 1000);
|
package/dist/stacks.js
CHANGED
|
@@ -24,6 +24,8 @@ const schema = new Ajv().compile({
|
|
|
24
24
|
originalBranch: { type: 'string' }, originalTips: { type: 'string' },
|
|
25
25
|
steps: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['effect', 'state'],
|
|
26
26
|
properties: { effect: branch, state: { enum: ['pending', 'dispatched', 'completed'] } } } },
|
|
27
|
+
prune: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['branch', 'number', 'head', 'merge'],
|
|
28
|
+
properties: { branch, number: { type: 'integer', minimum: 1 }, head: { type: 'string', pattern: '^[a-f0-9]{40}$' }, merge: { type: 'string', pattern: '^[a-f0-9]{40}$' } } } },
|
|
27
29
|
rebase: { type: 'object', additionalProperties: false,
|
|
28
30
|
required: ['branches', 'oldBases', 'originalTips', 'expectedTips', 'originalBranch', 'cursor', 'dispatched', 'authorDate'],
|
|
29
31
|
properties: { branches: { type: 'array', items: branch, uniqueItems: true }, oldBases: tips, originalTips: tips, expectedTips: tips,
|
|
@@ -39,6 +41,12 @@ async function stackAt(path, git) {
|
|
|
39
41
|
value.pulls.some(pull => !value.branches.includes(pull.branch))) {
|
|
40
42
|
throw new CliError('STACK_INVALID', 'Stack metadata is invalid, inconsistent or belongs to a different origin');
|
|
41
43
|
}
|
|
44
|
+
const prune = value.pending?.prune;
|
|
45
|
+
if (prune && (value.pending.action !== 'sync' || new Set(prune.map(item => item.branch)).size !== prune.length ||
|
|
46
|
+
prune.some(item => !value.branches.includes(item.branch) || !value.pulls.some(pull => pull.branch === item.branch && pull.number === item.number) ||
|
|
47
|
+
value.pending.steps.filter(step => step.effect === 'prune ' + item.branch).length !== 1))) {
|
|
48
|
+
throw new CliError('STACK_INVALID', 'Prune checkpoints must describe only the owned stack branches and linked PRs');
|
|
49
|
+
}
|
|
42
50
|
const rebase = value.pending?.rebase;
|
|
43
51
|
if (rebase) {
|
|
44
52
|
if (rebase.cursor > rebase.branches.length || rebase.branches.some(branch => !value.branches.includes(branch) || !rebase.oldBases[branch]) ||
|
|
@@ -53,7 +61,13 @@ async function stackAt(path, git) {
|
|
|
53
61
|
return value;
|
|
54
62
|
}
|
|
55
63
|
async function ancestor(git, parent, child) {
|
|
56
|
-
return (await git.run(['merge-base', parent, child])) === await git.tip(parent);
|
|
64
|
+
return (await git.run(['merge-base', parent, child], [0, 1])) === await git.tip(parent);
|
|
65
|
+
}
|
|
66
|
+
function releaseBranch(state, branch) {
|
|
67
|
+
state.branches = state.branches.filter(name => name !== branch);
|
|
68
|
+
state.pulls = state.pulls.filter(pull => pull.branch !== branch);
|
|
69
|
+
if (state.pending?.prune)
|
|
70
|
+
state.pending.prune = state.pending.prune.filter(item => item.branch !== branch);
|
|
57
71
|
}
|
|
58
72
|
export async function stackWorkflow(client, identity, action, args, options) {
|
|
59
73
|
const git = await new Git(String(options.workspace), identity.origin, identity.token).workspace();
|
|
@@ -107,6 +121,8 @@ export async function stackWorkflow(client, identity, action, args, options) {
|
|
|
107
121
|
state.remote = remote.name;
|
|
108
122
|
const plans = [];
|
|
109
123
|
const commands = [];
|
|
124
|
+
const prune = [];
|
|
125
|
+
const fetchedRefs = [];
|
|
110
126
|
let destination;
|
|
111
127
|
let remove = false;
|
|
112
128
|
let rebase;
|
|
@@ -249,21 +265,43 @@ export async function stackWorkflow(client, identity, action, args, options) {
|
|
|
249
265
|
linked.state = pull.state;
|
|
250
266
|
linked.merged = pull.merged === true;
|
|
251
267
|
}
|
|
268
|
+
for (const member of [...state.branches]) {
|
|
269
|
+
if (snapshot.refs.split('\n').some(line => line.startsWith('refs/heads/' + member + ' ')))
|
|
270
|
+
continue;
|
|
271
|
+
const pending = before?.pending;
|
|
272
|
+
const recorded = pending?.prune?.find(item => item.branch === member);
|
|
273
|
+
const checkpoint = pending?.steps.find(step => step.effect === 'prune ' + member);
|
|
274
|
+
const linked = state.pulls.find(pull => pull.branch === member);
|
|
275
|
+
const pull = page.items.find(pull => pull.number === linked?.number);
|
|
276
|
+
if (!recoveringSync || !recorded || !checkpoint || checkpoint.state === 'pending' || !pull ||
|
|
277
|
+
pull.number !== recorded.number || pull.state !== 'closed' || pull.merged !== true ||
|
|
278
|
+
object(pull.head).sha !== recorded.head || pull.merge_commit_sha !== recorded.merge ||
|
|
279
|
+
!pending.originalTips.split('\n').includes('refs/heads/' + member + ' ' + recorded.head) ||
|
|
280
|
+
!await git.run(['rev-parse', '--verify', '--quiet', recorded.head + '^{commit}'], [0, 1]) ||
|
|
281
|
+
!await git.run(['rev-parse', '--verify', '--quiet', recorded.merge + '^{commit}'], [0, 1]) ||
|
|
282
|
+
!await ancestor(git, recorded.head, recorded.merge)) {
|
|
283
|
+
throw new CliError('STACK_RECOVERY_REQUIRED', 'An absent owned branch requires its dispatched prune checkpoint and verified merged PR history');
|
|
284
|
+
}
|
|
285
|
+
releaseBranch(state, member);
|
|
286
|
+
}
|
|
252
287
|
const existing = [state.trunk, ...state.branches].filter(name => remoteRefs.split('\n').some(line => line.endsWith('\trefs/heads/' + name)));
|
|
288
|
+
for (const name of existing)
|
|
289
|
+
fetchedRefs.push({ ref: 'refs/remotes/' + remote.name + '/' + name,
|
|
290
|
+
head: remoteRefs.split('\n').find(line => line.endsWith('\trefs/heads/' + name)).split('\t')[0] });
|
|
253
291
|
if (existing.length)
|
|
254
292
|
commands.push(['fetch', '--no-tags', '--', remote.url, ...existing.map(name => 'refs/heads/' + name + ':refs/remotes/' + remote.name + '/' + name)]);
|
|
255
293
|
if (options.prune) {
|
|
256
294
|
for (const member of [...state.branches]) {
|
|
257
295
|
const linked = state.pulls.find(pull => pull.branch === member);
|
|
258
296
|
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))
|
|
297
|
+
if (!linked || !pull || pull.state !== 'closed' || pull.merged !== true || object(pull.head).sha !== await git.tip('refs/heads/' + member))
|
|
260
298
|
continue;
|
|
261
299
|
if (member === snapshot.branch || snapshot.worktrees.split('\n').includes('branch refs/heads/' + member))
|
|
262
300
|
continue;
|
|
263
301
|
const merge = String(pull.merge_commit_sha);
|
|
264
|
-
if (!/^[a-f0-9]{40}$/.test(merge)
|
|
265
|
-
!await ancestor(git, 'refs/heads/' + member, merge))
|
|
302
|
+
if (!/^[a-f0-9]{40}$/.test(merge))
|
|
266
303
|
continue;
|
|
304
|
+
prune.push({ branch: member, number: linked.number, head: linked.head, merge });
|
|
267
305
|
commands.push(['branch', '-d', member]);
|
|
268
306
|
}
|
|
269
307
|
}
|
|
@@ -349,7 +387,7 @@ export async function stackWorkflow(client, identity, action, args, options) {
|
|
|
349
387
|
const local = 'gitea.local.stack.' + localAction;
|
|
350
388
|
const begin = { method: 'LOCAL', path: git.cwd, local, payload: { phase: 'begin', action, args, options: { ...options, preview: undefined, confirm: undefined }, remote,
|
|
351
389
|
...(recovering ? { recoverLocal: before.pending.confirmations } : {}) } };
|
|
352
|
-
const finish = { method: 'LOCAL', path: git.cwd, local, payload: { phase: 'finish', state, commands, remove, destination } };
|
|
390
|
+
const finish = { method: 'LOCAL', path: git.cwd, local, payload: { phase: 'finish', state, commands, prune, remove, destination } };
|
|
353
391
|
const steps = [begin];
|
|
354
392
|
const apiResultIndices = [];
|
|
355
393
|
for (const [index, plan] of plans.entries()) {
|
|
@@ -374,7 +412,9 @@ export async function stackWorkflow(client, identity, action, args, options) {
|
|
|
374
412
|
if (action !== 'rebase')
|
|
375
413
|
state.pending = { action, confirmations: recovering ? [...before.pending.confirmations] : [],
|
|
376
414
|
originalBranch: before?.pending?.originalBranch ?? snapshot.branch, originalTips: before?.pending?.originalTips ?? snapshot.refs,
|
|
377
|
-
|
|
415
|
+
...(prune.length ? { prune: structuredClone(prune) } : {}),
|
|
416
|
+
steps: [...plans.map(plan => ({ effect: plan.target, state: 'pending' })),
|
|
417
|
+
...commands.map(command => ({ effect: command[0] === 'branch' ? 'prune ' + command[2] : command[0], state: 'pending' }))] };
|
|
378
418
|
state.pending.confirmations.push(confirmation);
|
|
379
419
|
await save(path, state);
|
|
380
420
|
return { state: 'started' };
|
|
@@ -452,12 +492,25 @@ export async function stackWorkflow(client, identity, action, args, options) {
|
|
|
452
492
|
}
|
|
453
493
|
else {
|
|
454
494
|
for (const [index, command] of commands.entries()) {
|
|
495
|
+
if (action === 'sync' && command[0] === 'branch') {
|
|
496
|
+
const selected = prune.find(item => item.branch === command[2]);
|
|
497
|
+
if (await git.tip('refs/heads/' + selected.branch) !== selected.head)
|
|
498
|
+
throw new CliError('CONFIRMATION_MISMATCH', 'A prune candidate changed before deletion');
|
|
499
|
+
if (!await git.run(['rev-parse', '--verify', '--quiet', selected.merge + '^{commit}'], [0, 1]) ||
|
|
500
|
+
!await ancestor(git, selected.head, selected.merge))
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
455
503
|
state.pending.steps[plans.length + index].state = 'dispatched';
|
|
456
504
|
await save(path, state);
|
|
457
505
|
await git.run(command);
|
|
506
|
+
if (prune.length && command[0] === 'fetch') {
|
|
507
|
+
for (const expected of fetchedRefs) {
|
|
508
|
+
if (await git.tip(expected.ref) !== expected.head)
|
|
509
|
+
throw new CliError('CONFIRMATION_MISMATCH', 'Fetched branch differs from the confirmed remote graph');
|
|
510
|
+
}
|
|
511
|
+
}
|
|
458
512
|
if (action === 'sync' && command[0] === 'branch') {
|
|
459
|
-
state
|
|
460
|
-
state.pulls = state.pulls.filter(pull => pull.branch !== command[2]);
|
|
513
|
+
releaseBranch(state, command[2]);
|
|
461
514
|
}
|
|
462
515
|
state.pending.steps[plans.length + index].state = 'completed';
|
|
463
516
|
await save(path, state);
|
package/dist/transport.js
CHANGED
|
@@ -28,13 +28,14 @@ export class Client {
|
|
|
28
28
|
this.token = token;
|
|
29
29
|
this.deadline = Date.now() + timeoutMs;
|
|
30
30
|
}
|
|
31
|
-
async request(path, text = false, mutation) {
|
|
31
|
+
async request(path, text = false, mutation, signal) {
|
|
32
32
|
canonicalPath(path);
|
|
33
33
|
const url = new URL(path, this.origin);
|
|
34
34
|
if (url.origin !== this.origin || !path.startsWith('/') || path.startsWith('//'))
|
|
35
35
|
throw new CliError('UNSAFE_PATH', 'Request must stay within the selected origin');
|
|
36
36
|
try {
|
|
37
37
|
const form = mutation?.upload ? new FormData() : undefined;
|
|
38
|
+
const timeout = AbortSignal.timeout(Math.max(1, Math.min(limits.timeoutMs, this.deadline - Date.now())));
|
|
38
39
|
if (form && mutation?.upload)
|
|
39
40
|
form.set('attachment', new Blob([Buffer.from(mutation.upload.base64, 'base64')], { type: mutation.upload.type }), mutation.upload.name);
|
|
40
41
|
const response = await fetch(url, {
|
|
@@ -42,7 +43,7 @@ export class Client {
|
|
|
42
43
|
...(form ? { body: form } : mutation?.payload === undefined ? {} : { body: JSON.stringify(mutation.payload) }),
|
|
43
44
|
headers: { accept: text ? 'text/plain' : 'application/json', ...(mutation && !form ? { 'content-type': 'application/json' } : {}), ...(this.token ? { authorization: 'token ' + this.token } : {}) },
|
|
44
45
|
redirect: 'error',
|
|
45
|
-
signal: AbortSignal.
|
|
46
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
46
47
|
});
|
|
47
48
|
if (mutation?.sensitive) {
|
|
48
49
|
await response.body?.cancel();
|
|
@@ -87,7 +88,7 @@ export class Client {
|
|
|
87
88
|
throw new CliError('TRANSPORT_ERROR', 'Gitea request failed (' + transportCause(error) + '); check the selected origin and connection');
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
|
-
async get(path) { return (await this.request(path)).data; }
|
|
91
|
+
async get(path, signal) { return (await this.request(path, false, undefined, signal)).data; }
|
|
91
92
|
async attachment(uuid, expectedType) {
|
|
92
93
|
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(uuid))
|
|
93
94
|
throw new CliError('UNSAFE_PATH', 'Invalid attachment UUID');
|
package/dist/writes.js
CHANGED
|
@@ -3,7 +3,7 @@ import { constants, existsSync } from 'node:fs';
|
|
|
3
3
|
import { mkdir, open, readdir, rm } from 'node:fs/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
6
|
-
import { canonicalPath, collect, effectFor, object, read, validate, validatePayload } from './api.js';
|
|
6
|
+
import { canonicalPath, collect, effectFor, object, read, routeFor, validate, validatePayload } from './api.js';
|
|
7
7
|
import { CliError } from './error.js';
|
|
8
8
|
import { readPrivateFile, writePrivateFile } from './private-file.js';
|
|
9
9
|
import { verifyPull } from './pulls.js';
|
|
@@ -11,6 +11,7 @@ import { verifyWorkflow } from './repo-workflows.js';
|
|
|
11
11
|
import { actionValues, valuePlan, verifyValue } from './action-values.js';
|
|
12
12
|
import { verifyUploads } from './attachments.js';
|
|
13
13
|
import { controlText } from './credentials.js';
|
|
14
|
+
import { writeOutput } from './output.js';
|
|
14
15
|
function canonical(value) {
|
|
15
16
|
if (Array.isArray(value))
|
|
16
17
|
return '[' + value.map(canonical).join(',') + ']';
|
|
@@ -45,7 +46,7 @@ async function key(root) {
|
|
|
45
46
|
}
|
|
46
47
|
return readPrivateFile(path, true);
|
|
47
48
|
}
|
|
48
|
-
export async function reconcile(client, id) {
|
|
49
|
+
export async function reconcile(client, id, json = false) {
|
|
49
50
|
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
50
51
|
usage('Select the exact confirmation ID');
|
|
51
52
|
const receipt = JSON.parse(await readPrivateFile(join(directory(), id + '.json'), true));
|
|
@@ -53,8 +54,8 @@ export async function reconcile(client, id) {
|
|
|
53
54
|
throw new CliError('CONFIRMATION_MISMATCH', 'Reconciliation origin differs from the attempted write');
|
|
54
55
|
const observations = await observe(client, receipt.reads);
|
|
55
56
|
controlText(id);
|
|
56
|
-
return { confirmation: id, operations: receipt.operations, states: receipt.states, observations,
|
|
57
|
-
help: 'Inspect the exact identity and requested fields in these reads before authorizing any new attempt' };
|
|
57
|
+
return writeOutput({ confirmation: id, operations: receipt.operations, states: receipt.states, observations,
|
|
58
|
+
help: 'Inspect the exact identity and requested fields in these reads before authorizing any new attempt' }, json);
|
|
58
59
|
}
|
|
59
60
|
async function observe(client, paths) {
|
|
60
61
|
const observations = [];
|
|
@@ -268,8 +269,8 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
268
269
|
const receipt = { binding, origin: client.origin, reads: plan.reads, states: plan.steps.map(() => 'pending'), operations, attempt, guardVersion: 2,
|
|
269
270
|
...(plan.steps.every(step => step.local) ? { localTarget: plan.target, localGuards: guards.map(path => path.slice(root.length + 1, -8)) } : {}) };
|
|
270
271
|
await writePrivateFile(join(root, id + '.json'), JSON.stringify(receipt));
|
|
271
|
-
return { confirmation: id, origin: client.origin, target: plan.target, operations, steps: plan.steps.map(({ upload, ...step }) => ({ ...step, ...(plan.sensitive && step.payload !== undefined ? { payload: '[REDACTED]' } : {}), ...(upload ? { upload: { name: upload.name, type: upload.type, size: upload.size, sha256: upload.sha256 } } : {}) })), preconditions: plan.preconditions,
|
|
272
|
-
...(plan.notes ? { notes: plan.notes } : {}), help: controlText('Repeat the same command with --confirm ' + id + ' instead of --preview') };
|
|
272
|
+
return writeOutput({ confirmation: id, origin: client.origin, target: plan.target, operations, steps: plan.steps.map(({ upload, ...step }) => ({ ...step, ...(plan.sensitive && step.payload !== undefined ? { payload: '[REDACTED]' } : {}), ...(upload ? { upload: { name: upload.name, type: upload.type, size: upload.size, sha256: upload.sha256 } } : {}) })), preconditions: plan.preconditions,
|
|
273
|
+
...(plan.notes ? { notes: plan.notes } : {}), help: controlText('Repeat the same command with --confirm ' + id + ' instead of --preview') }, Boolean(options.json));
|
|
273
274
|
}
|
|
274
275
|
const id = String(options.confirm);
|
|
275
276
|
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
@@ -356,6 +357,19 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
356
357
|
const result = dispatched.local ? await executeLocal(dispatched, results, id) :
|
|
357
358
|
(await client.request(dispatched.path, false, { ...dispatched, ...(plan.sensitive ? { sensitive: true } : {}) })).data;
|
|
358
359
|
results.push(result);
|
|
360
|
+
if (plan.wikiRename) {
|
|
361
|
+
const original = dispatched.path.split('?')[0];
|
|
362
|
+
const page = object(validate(original, result));
|
|
363
|
+
if (typeof page.sub_url !== 'string' || !page.sub_url)
|
|
364
|
+
throw new CliError('INVALID_RESPONSE', 'Wiki rename returned no page identity');
|
|
365
|
+
// Gitea's sub_url is already escaped and may differ from the requested display title.
|
|
366
|
+
const renamed = original.slice(0, original.lastIndexOf('/') + 1) + page.sub_url;
|
|
367
|
+
const url = new URL(renamed, client.origin);
|
|
368
|
+
if (url.origin !== client.origin || url.pathname !== renamed || url.search || url.hash || routeFor(renamed).operation !== 'repoGetWikiPage') {
|
|
369
|
+
throw new CliError('INVALID_RESPONSE', 'Wiki rename returned an unsafe page identity');
|
|
370
|
+
}
|
|
371
|
+
receipt.reads = [renamed];
|
|
372
|
+
}
|
|
359
373
|
if (plan.steps.some(step => step.assetParent === index)) {
|
|
360
374
|
const parent = object(result);
|
|
361
375
|
if (!Number.isSafeInteger(parent.id) || Number(parent.id) <= 0)
|
|
@@ -415,7 +429,7 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
415
429
|
recovered.states = recovered.states.map(state => state === 'completed' ? state : 'recovered');
|
|
416
430
|
await writePrivateFile(join(root, previous + '.json'), JSON.stringify(recovered));
|
|
417
431
|
}
|
|
418
|
-
return { confirmation: id, states: receipt.states, results, observations, ...(plan.notes ? { notes: plan.notes } : {}), reconcile: command(id, options) };
|
|
432
|
+
return writeOutput({ confirmation: id, states: receipt.states, results, observations, ...(plan.notes ? { notes: plan.notes } : {}), reconcile: command(id, options) }, Boolean(options.json));
|
|
419
433
|
}
|
|
420
434
|
export async function rawWritePlan(client, method, path, payload) {
|
|
421
435
|
const route = validatePayload(method, path, payload);
|
|
@@ -434,7 +448,17 @@ export async function rawWritePlan(client, method, path, payload) {
|
|
|
434
448
|
if (verification.endsWith('/issues'))
|
|
435
449
|
verification += '?state=all&type=issues';
|
|
436
450
|
const preconditions = await observe(client, [verification]);
|
|
437
|
-
|
|
451
|
+
let reads = [verification];
|
|
452
|
+
const wikiRename = route.operation === 'repoEditWikiPage';
|
|
453
|
+
if (wikiRename)
|
|
454
|
+
reads = [verification.slice(0, verification.lastIndexOf('/page/')) + '/pages'];
|
|
455
|
+
if (route.operation === 'repoEdit' && typeof object(payload).name === 'string' && object(payload).name !== '') {
|
|
456
|
+
const id = object(preconditions[0].data).id;
|
|
457
|
+
if (!Number.isSafeInteger(id) || Number(id) <= 0)
|
|
458
|
+
throw new CliError('INVALID_RESPONSE', 'Repository rename requires an immutable repository identity');
|
|
459
|
+
reads = ['/api/v1/repositories/' + id];
|
|
460
|
+
}
|
|
461
|
+
return { target: path, steps: [{ method, path, ...(payload === undefined ? {} : { payload }) }], reads, preconditions, ...(wikiRename ? { wikiRename: true } : {}) };
|
|
438
462
|
}
|
|
439
463
|
const names = (value) => [...new Set(Array.isArray(value) ? value.map(String) : [String(value)])];
|
|
440
464
|
export async function issueLabelPlan(client, operation, base, number, options) {
|