@knowttl/gitea-axi 0.1.1 → 0.1.3
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/LICENSE +21 -0
- package/README.md +41 -10
- package/dist/api.js +21 -11
- package/dist/credentials.js +9 -0
- package/dist/git.js +3 -3
- package/dist/main.js +6 -6
- package/dist/output.js +56 -0
- package/dist/pulls.js +42 -18
- package/dist/reads.js +21 -11
- package/dist/registry.js +1 -1
- package/dist/search.js +4 -2
- package/dist/stacks.js +61 -8
- package/dist/transport.js +4 -3
- package/dist/writes.js +63 -18
- package/generated/help.txt +1 -1
- package/generated/host-inventory.json +1 -1
- package/generated/routes.json +425 -0
- package/package.json +2 -1
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'].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
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants, existsSync } from 'node:fs';
|
|
3
|
-
import { mkdir, open, rm } from 'node:fs/promises';
|
|
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';
|
|
10
10
|
import { verifyWorkflow } from './repo-workflows.js';
|
|
11
11
|
import { actionValues, valuePlan, verifyValue } from './action-values.js';
|
|
12
12
|
import { verifyUploads } from './attachments.js';
|
|
13
|
+
import { controlText } from './credentials.js';
|
|
14
|
+
import { writeOutput } from './output.js';
|
|
13
15
|
function canonical(value) {
|
|
14
16
|
if (Array.isArray(value))
|
|
15
17
|
return '[' + value.map(canonical).join(',') + ']';
|
|
@@ -21,9 +23,9 @@ const digest = (value) => createHash('sha256').update(canonical(value)).digest('
|
|
|
21
23
|
const usage = (message) => { throw new CliError('USAGE', message, 2); };
|
|
22
24
|
const logPath = () => process.env.GITEA_AXI_WRITE_LOG ?? join(process.platform === 'win32' ? process.env.LOCALAPPDATA ?? homedir() : process.env.XDG_STATE_HOME ?? join(homedir(), '.local/state'), 'gitea-axi', 'writes.log');
|
|
23
25
|
const directory = () => join(dirname(logPath()), 'write-receipts');
|
|
24
|
-
const command = (id, options) => 'gitea-axi write reconcile ' + id +
|
|
25
|
-
(options.config ? ' --config ' + shell(String(options.config)) : '') + (options.profile ? ' --profile ' + shell(String(options.profile)) : '') +
|
|
26
|
-
(options.origin ? ' --origin ' + shell(String(options.origin)) : '') + (options['allow-insecure-http'] ? ' --allow-insecure-http' : '');
|
|
26
|
+
const command = (id, options) => controlText('gitea-axi write reconcile ' + id) +
|
|
27
|
+
(options.config ? controlText(' --config ') + shell(String(options.config)) : '') + (options.profile ? controlText(' --profile ') + shell(String(options.profile)) : '') +
|
|
28
|
+
(options.origin ? controlText(' --origin ') + shell(String(options.origin)) : '') + (options['allow-insecure-http'] ? controlText(' --allow-insecure-http') : '');
|
|
27
29
|
function shell(value) { return "'" + value.replaceAll("'", "'\\''") + "'"; }
|
|
28
30
|
async function key(root) {
|
|
29
31
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
@@ -51,8 +53,9 @@ export async function reconcile(client, id) {
|
|
|
51
53
|
if (receipt.origin !== client.origin)
|
|
52
54
|
throw new CliError('CONFIRMATION_MISMATCH', 'Reconciliation origin differs from the attempted write');
|
|
53
55
|
const observations = await observe(client, receipt.reads);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
controlText(id);
|
|
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' });
|
|
56
59
|
}
|
|
57
60
|
async function observe(client, paths) {
|
|
58
61
|
const observations = [];
|
|
@@ -225,11 +228,9 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
225
228
|
return { method: step.method, path: step.path, payload: step.payload, local: localOperation(step).operation };
|
|
226
229
|
const route = validatePayload(step.method, step.path, step.payload);
|
|
227
230
|
const segments = canonicalPath(step.path).split('/');
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
segments[index] = encodeURIComponent(decodeURIComponent(segments[index]).toLowerCase());
|
|
232
|
-
}
|
|
231
|
+
for (const [index, segment] of route.path.split('/').entries()) {
|
|
232
|
+
if (/^\{(?:owner|repo|org|template_owner|template_repo)\}$/.test(segment)) {
|
|
233
|
+
segments[index] = encodeURIComponent(decodeURIComponent(segments[index]).toLowerCase());
|
|
233
234
|
}
|
|
234
235
|
}
|
|
235
236
|
return { method: step.method, path: segments.join('/'), ...(step.payload === undefined ? {} : { payload: step.payload }), ...(step.upload ? { upload: step.upload } : {}) };
|
|
@@ -238,6 +239,25 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
238
239
|
const effectDigest = (value) => plan.sensitive ? createHmac('sha256', secret).update(canonical(value)).digest('hex') : digest(value);
|
|
239
240
|
const attempt = effectDigest({ origin: client.origin, steps: effectSteps });
|
|
240
241
|
const guards = [...new Set(effectSteps.filter((_, index) => !plan.steps[index].createdPull).map(step => join(root, effectDigest({ origin: client.origin, step }) + '.attempt')))];
|
|
242
|
+
const organization = (path) => /^\/api\/v1\/orgs\/([^/?]+)/.exec(canonicalPath(path))?.[1];
|
|
243
|
+
const organizations = new Set(effectSteps.map(step => organization(step.path)).filter(value => value !== undefined));
|
|
244
|
+
if (organizations.size) {
|
|
245
|
+
for (const name of await readdir(root)) {
|
|
246
|
+
if (!/^[a-f0-9]{64}\.attempt$/.test(name))
|
|
247
|
+
continue;
|
|
248
|
+
const previous = await readPrivateFile(join(root, name), true);
|
|
249
|
+
if (!/^[a-f0-9-]{36}$/.test(previous))
|
|
250
|
+
usage('Invalid persisted confirmation ID');
|
|
251
|
+
const receipt = JSON.parse(await readPrivateFile(join(root, previous + '.json'), true));
|
|
252
|
+
if (receipt.guardVersion !== 2 && receipt.origin === client.origin &&
|
|
253
|
+
receipt.reads.some(path => {
|
|
254
|
+
const org = organization(path);
|
|
255
|
+
return org !== undefined && organizations.has(encodeURIComponent(decodeURIComponent(org).toLowerCase()));
|
|
256
|
+
})) {
|
|
257
|
+
throw new CliError('WRITE_OUTCOME_UNKNOWN', 'An attempt already exists; reconcile with ' + command(previous, options));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
241
261
|
for (const guard of guards) {
|
|
242
262
|
if (existsSync(guard)) {
|
|
243
263
|
const previous = await readPrivateFile(guard, true);
|
|
@@ -245,18 +265,19 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
245
265
|
}
|
|
246
266
|
}
|
|
247
267
|
if (options.preview) {
|
|
248
|
-
const id = randomUUID();
|
|
249
|
-
const receipt = { binding, origin: client.origin, reads: plan.reads, states: plan.steps.map(() => 'pending'), operations, attempt,
|
|
268
|
+
const id = controlText(randomUUID());
|
|
269
|
+
const receipt = { binding, origin: client.origin, reads: plan.reads, states: plan.steps.map(() => 'pending'), operations, attempt, guardVersion: 2,
|
|
250
270
|
...(plan.steps.every(step => step.local) ? { localTarget: plan.target, localGuards: guards.map(path => path.slice(root.length + 1, -8)) } : {}) };
|
|
251
271
|
await writePrivateFile(join(root, id + '.json'), JSON.stringify(receipt));
|
|
252
|
-
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,
|
|
253
|
-
...(plan.notes ? { notes: plan.notes } : {}), help: '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') });
|
|
254
274
|
}
|
|
255
275
|
const id = String(options.confirm);
|
|
256
276
|
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
257
277
|
usage('Use the confirmation ID returned by --preview');
|
|
258
278
|
const path = join(root, id + '.json');
|
|
259
279
|
const receipt = JSON.parse(await readPrivateFile(path, true));
|
|
280
|
+
controlText(id);
|
|
260
281
|
if (receipt.states.some(state => state !== 'pending'))
|
|
261
282
|
throw new CliError('WRITE_OUTCOME_UNKNOWN', 'Confirmation was already dispatched; reconcile with ' + command(id, options));
|
|
262
283
|
if (receipt.binding !== binding)
|
|
@@ -285,6 +306,7 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
285
306
|
throw error;
|
|
286
307
|
}
|
|
287
308
|
}
|
|
309
|
+
receipt.guardVersion = 2;
|
|
288
310
|
const results = [];
|
|
289
311
|
const outcome = () => 'steps=' + receipt.states.join(',') + (receipt.createdPull ? '; created PR id=' + receipt.createdPull.id + ' number=' + receipt.createdPull.number : '') + (receipt.createdParent ? '; existing parent id=' + receipt.createdParent.id + (receipt.createdParent.number ? ' number=' + receipt.createdParent.number : '') : '') + '; reconcile with ' + command(id, options);
|
|
290
312
|
for (const [index, step] of plan.steps.entries()) {
|
|
@@ -335,6 +357,19 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
335
357
|
const result = dispatched.local ? await executeLocal(dispatched, results, id) :
|
|
336
358
|
(await client.request(dispatched.path, false, { ...dispatched, ...(plan.sensitive ? { sensitive: true } : {}) })).data;
|
|
337
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
|
+
}
|
|
338
373
|
if (plan.steps.some(step => step.assetParent === index)) {
|
|
339
374
|
const parent = object(result);
|
|
340
375
|
if (!Number.isSafeInteger(parent.id) || Number(parent.id) <= 0)
|
|
@@ -394,7 +429,7 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
394
429
|
recovered.states = recovered.states.map(state => state === 'completed' ? state : 'recovered');
|
|
395
430
|
await writePrivateFile(join(root, previous + '.json'), JSON.stringify(recovered));
|
|
396
431
|
}
|
|
397
|
-
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) });
|
|
398
433
|
}
|
|
399
434
|
export async function rawWritePlan(client, method, path, payload) {
|
|
400
435
|
const route = validatePayload(method, path, payload);
|
|
@@ -413,7 +448,17 @@ export async function rawWritePlan(client, method, path, payload) {
|
|
|
413
448
|
if (verification.endsWith('/issues'))
|
|
414
449
|
verification += '?state=all&type=issues';
|
|
415
450
|
const preconditions = await observe(client, [verification]);
|
|
416
|
-
|
|
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 } : {}) };
|
|
417
462
|
}
|
|
418
463
|
const names = (value) => [...new Set(Array.isArray(value) ? value.map(String) : [String(value)])];
|
|
419
464
|
export async function issueLabelPlan(client, operation, base, number, options) {
|
package/generated/help.txt
CHANGED
|
@@ -784,7 +784,7 @@ commands[128]:
|
|
|
784
784
|
description: OWNER/NAME; otherwise a matching local origin remote
|
|
785
785
|
limit:
|
|
786
786
|
type: string
|
|
787
|
-
description: "Maximum returned records, default
|
|
787
|
+
description: "Maximum returned records, default 10, maximum 1000"
|
|
788
788
|
"exclude-drafts":
|
|
789
789
|
type: boolean
|
|
790
790
|
"exclude-pre-releases":
|
|
@@ -1120,7 +1120,7 @@
|
|
|
1120
1120
|
},
|
|
1121
1121
|
"limit": {
|
|
1122
1122
|
"type": "string",
|
|
1123
|
-
"description": "Maximum returned records, default
|
|
1123
|
+
"description": "Maximum returned records, default 10, maximum 1000"
|
|
1124
1124
|
},
|
|
1125
1125
|
"exclude-drafts": {
|
|
1126
1126
|
"type": "boolean"
|