@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/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 = typeof left === 'number' && typeof right === 'number' ? left - right : String(left).localeCompare(String(right));
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) || !await git.run(['rev-parse', '--verify', '--quiet', merge + '^{commit}'], [0, 1]) ||
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
- steps: [...plans.map(plan => ({ effect: plan.target, state: 'pending' })), ...commands.map(command => ({ effect: command[0], state: 'pending' }))] };
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.branches = state.branches.filter(name => name !== command[2]);
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.timeout(Math.max(1, Math.min(limits.timeoutMs, this.deadline - Date.now()))),
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
- return { confirmation: id, operations: receipt.operations, states: receipt.states, observations,
55
- help: 'Inspect the exact identity and requested fields in these reads before authorizing any new attempt' };
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
- if (/\{(?:template_)?repo\}/.test(route.path)) {
229
- for (const [index, segment] of route.path.split('/').entries()) {
230
- if (/^\{(?:owner|repo|org|template_owner|template_repo)\}$/.test(segment)) {
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
- return { target: path, steps: [{ method, path, ...(payload === undefined ? {} : { payload }) }], reads: [verification], preconditions };
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) {
@@ -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 30, maximum 1000"
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 30, maximum 1000"
1123
+ "description": "Maximum returned records, default 10, maximum 1000"
1124
1124
  },
1125
1125
  "exclude-drafts": {
1126
1126
  "type": "boolean"