@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/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
- const result = await checks(client, base, sha);
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 = 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
@@ -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
- 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 } : {}) };
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) {