@knowttl/gitea-axi 0.1.2 → 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/README.md CHANGED
@@ -71,6 +71,7 @@ Full workflow run APIs require Gitea 1.24 or later; the retained workflow, artif
71
71
  ### Search
72
72
 
73
73
  `search issues|prs|repos|commits "text"` searches at most 20 pages and 1000 candidates per collection, then filters, sorts and applies the total output limit (default 1000).
74
+ Search sorts creation and update times chronologically across time zone offsets before applying the output limit.
74
75
  Issue and PR searches support exact arbitrary author and assignee names and comma-separated label ANY matching; an unknown label alone matches nothing.
75
76
  Use `--repo OWNER/NAME` for repository scope or `--owner OWNER` for issue, PR and repository searches.
76
77
  Repository search supports `--language` and integer `--stars` comparisons.
@@ -108,10 +109,11 @@ Anonymous HTTP reads require `--allow-insecure-http` for that selected origin.
108
109
  The tool follows no redirects and never imports tea or Git credentials.
109
110
  Output uses TOON; structured errors use stdout, with exit 1 for operational failures and exit 2 for usage errors.
110
111
  Business reads share a 30-second deadline, except check watches use their explicit timeout; each request has a 2,000,000-byte response limit.
112
+ The check-watch timeout includes initial repository reads; polling cannot extend the client's deadline.
111
113
  `repo view` instead gives each of its server version, specification and repository requests a separate 30-second timeout.
112
114
  An explicit repository wins over a matching local origin remote; a remote never changes the selected profile.
113
- `-R` aliases `--repo` after an explicit command, with the same duplicate-selector checks.
114
- For the default dashboard, use `gitea-axi --repo OWNER/NAME`; root-level `-R` remains unsupported.
115
+ `-R` aliases `--repo`, with the same duplicate-selector checks.
116
+ For the default dashboard, use `gitea-axi --repo OWNER/NAME` or `gitea-axi -R OWNER/NAME`.
115
117
  Hosted repository reads require an explicit repository.
116
118
  Lists default to 30 returned records, except `run list` and `release list` default to 10; collection scans stop at 20 pages or at least 1000 unique records and retain at most 1000.
117
119
  Automatic page queries apply only to routes whose retained Gitea specification declares a `page` parameter.
@@ -126,7 +128,10 @@ The dashboard shows a compact repository projection (ID, full name, visibility,
126
128
  Dashboard sections report permission failures as partial results and point truncated sections to the corresponding standalone list command with `--repo` and `--limit`.
127
129
  Issue and PR bodies default to 1000 characters, diffs to 10000 characters, and rendered output to 100000 characters.
128
130
  `--full` expands bodies and diffs within the response and output ceilings.
129
- `pr checks` exits 8 for pending checks and 1 for failed or absent checks; watches require an explicit deadline and accept cancellation.
131
+ Diffs retain an unchanged source prefix sized to fit the serialized output, including escaping and metadata; `chars` reports the original length and `truncated` indicates omitted text.
132
+ `pr checks` exits 8 for pending checks and 1 for failed or absent checks; watches require an explicit deadline.
133
+ During check polling, SIGINT or SIGTERM aborts in-flight PR and status requests, including paginated responses, and returns `cancelled: true` with exit code 8.
134
+ Cancellation preserves the last completed check result; before any completed result, it returns empty items and zero counts, with the head SHA if already read, otherwise null.
130
135
  Commits without statuses return an empty check result; watches continue waiting for checks to register until the deadline or cancellation.
131
136
  `--fields` refuses unknown names and fields without an evidenced Gitea equivalent, and cannot combine with detail expansion flags.
132
137
  PR statusCheckRollup explicitly identifies partial commit statuses, and `fullDatabaseId` is a numeric vendor ID rather than a GraphQL node ID.
@@ -154,8 +159,11 @@ Issue and label deletion require a complete collection and at least one remainin
154
159
 
155
160
  ### Pull requests
156
161
 
162
+ `pr view` includes both user and team review requests in `reviewRequests`: users have `login` and `name`, while teams have `type: team`, `id` and `name`.
157
163
  Pull request mutations include `create`, `edit`, `close`, `reopen`, `comment`, `update-branch` and `merge`.
158
164
  Creation requires explicit `--title`, `--base` and `--head` branch names in the selected repository.
165
+ PR creation, base edits, updates and merges support slash-containing branch names such as `work/topic` and `release/stable`, resolving exact Git refs rather than prefix matches.
166
+ Branch resolution binds commit identities into preview and confirmation while preserving API path separator and traversal protections.
159
167
  Cross-repository heads, draft and project inputs remain unavailable.
160
168
  Create accepts repeatable `--reviewer` user names and `--team-reviewer` team names, plus issue-style assignees, labels and milestone selectors.
161
169
  Edit accepts `--base`, text fields and assignee/label deltas, plus repeatable `--add-reviewer`, `--remove-reviewer`, `--add-team-reviewer` and `--remove-team-reviewer` deltas.
@@ -167,6 +175,7 @@ If the reviewer step fails after creation, the error reports the created PR ID a
167
175
  Close with `--comment` records closure and comment as separate steps and reports partial outcomes without retrying.
168
176
  Successful writes verify the same PR identity, expected fields and memberships through fresh reads.
169
177
  Update-branch verifies that the resulting head contains both its previous head and base commits; conflicts never report completion.
178
+ Verification stops once both commits are proven present, so older base history need not be traversed; it reads at most 1000 commits and refuses completion if that bound prevents proof.
170
179
  Merge requires exactly one of `--merge`, `--squash`, `--rebase` or `--method merge|squash|rebase`, and a full `--match-head-commit` SHA.
171
180
  It sends the vendor `head_commit_id` precondition and verifies the closed, merged PR, base branch tip and final commit topology.
172
181
  Merge commits retain the previous base and checked head as parents; squash uses one new commit on the previous base; rebase verifies the rewritten linear chain and original commit messages.
@@ -259,8 +268,14 @@ gitea-axi issue close 42 --repo OWNER/NAME --comment 'Resolved' --allow-write is
259
268
  gitea-axi label create --repo OWNER/NAME --name bug --color ff0000 --allow-write issueCreateLabel --preview
260
269
  ```
261
270
 
262
- The preview identifies the exact operations, payload and current preconditions without sending a mutation.
271
+ The preview identifies the exact operations and payload without sending a mutation.
272
+ To fit the rendered output ceiling, previews, write results and reconciliation output may summarize `preconditions`, then `observations`, then `results`, stopping as soon as the output fits.
273
+ Summarized output sets `truncated: true` and lists affected fields in `truncated_fields`.
274
+ Summaries report `summary: true` with `total_items`, `total_fields` or `total_chars`; object summaries retain numeric `id` and `number` when present.
275
+ Summarized observations retain paths, and observations and results retain at most ten entries with their original counts in `observations_total` and `results_total`.
276
+ Preview steps and requested payloads remain complete subject to credential redaction; output that still exceeds the ceiling fails with `OUTPUT_LIMIT`.
263
277
  Confirmation binds the credential revision, origin, target, operations, canonical payload and preconditions.
278
+ Display summaries do not weaken confirmation binding or readback verification, which use the complete data.
264
279
  Changing any binding requires a fresh preview.
265
280
  Organization casing must match the preview exactly.
266
281
  For issues that remain present, successful named writes verify identity and the expected final title, body, assignees, labels, milestone and state through fresh reads.
@@ -282,6 +297,10 @@ Each attempt is persisted before dispatch.
282
297
  API timeouts, interrupted requests and partial failures return `WRITE_OUTCOME_UNKNOWN`, completed/pending step information and an exact `gitea-axi write reconcile CONFIRMATION_ID` command.
283
298
  Dispatched local Git failures retain their specific error code and include reconciliation information; stack recovery follows the [metadata and recovery contract](docs/local-stack-contract.md).
284
299
  Reconciliation performs complete metadata reads for inspection and never repeats a mutation.
300
+ If its display is summarized as described above, use targeted reads to inspect the exact identity and requested fields before authorizing another attempt.
301
+ Raw repository edits with a nonempty `name` verify and reconcile through the repository's immutable ID, so a rename does not depend on the old URL.
302
+ Raw wiki edits use the returned escaped `sub_url` for verification and reconciliation; if the response is lost or its page identity is unusable, reconciliation reads the bounded wiki page list instead.
303
+ These read targets preserve the original confirmation binding and attempted-effect identity.
285
304
  An unresolved dispatched attempt cannot be replayed by reusing its confirmation or obtaining another preview for the same effect.
286
305
  Replay guards also treat repository owner, repository name and organization name case variants as the same identity for named and raw writes.
287
306
  Unresolved organization attempts from older versions conservatively block all writes to the same organization at the same origin, including different names or payloads, and return the original attempt's reconciliation command.
@@ -303,7 +322,7 @@ Incomplete environment profiles report missing variables; profile schema errors
303
322
  For file profiles, an explicit profile must match; otherwise exactly one matching profile is required, ignoring `defaultProfile`.
304
323
  Without a host selector, file selection uses the explicit profile, otherwise the file's `defaultProfile` or its sole profile; multiple profiles without a selection or default are refused.
305
324
  A host selector cannot construct an anonymous origin, bypass tokenOrigin binding or enable HTTP.
306
- Search commands currently reject `--hostname`; use `GH_HOST` with a matching profile instead.
325
+ Search commands support `--hostname` and `GH_HOST` with the same profile selection and credential binding checks.
307
326
  Hosted invocations refuse credential/configuration overrides and ignore ambient `GH_HOST` for the injected Artium profile.
308
327
  On Linux, the config file must be owned by the invoking account and must not be group- or world-writable.
309
328
  Config files and file SecretRefs must be regular files; symlinks are refused.
@@ -401,7 +420,7 @@ Set `GITEA_AXI_REPORT` to retain their reports at a chosen path.
401
420
  Their default reports go to unique files in the OS temporary directory; temporary installation files are removed.
402
421
  For S5, set `GITEA_AXI_REPORT_DIR` to an existing directory to retain `S5.1.json` or `S5.2.json`; otherwise only a console summary is emitted.
403
422
 
404
- `npm test` first runs the package-contract check in `scripts/verify-package.ts`, then the offline live-read regressions in `scripts/verify-live-reads.ts`, then the installed-package checks with local Gitea fakes for S1.1, S2.1 through S2.4, S3.1 through S3.5, S4.1 through S4.3, S5.1, S5.2 and S6.1 through S6.4 under an inherited outbound network guard.
423
+ `npm test` first runs the package-contract check in `scripts/verify-package.ts`, then the native Git workspace checks in `scripts/verify-git.ts`, then the offline live-read regressions in `scripts/verify-live-reads.ts`, then the installed-package checks with local Gitea fakes for S1.1, S2.1 through S2.4, S3.1 through S3.5, S4.1 through S4.3, S5.1, S5.2 and S6.1 through S6.4 under an inherited outbound network guard.
405
424
  The package-contract check builds, packs and installs offline, validates the tarball file list and bin entry, smoke-tests the installed `--help` and `--version`, parses the consumed JSON artifacts and imports the modules Artium consumes from the installed package.
406
425
  Run it independently with `node --import ./scripts/deny-network.ts scripts/verify-package.ts`.
407
426
  Each S2 task also has an independent entrypoint, for example `node scripts/verify.mjs --task S2.3`.
package/dist/api.js CHANGED
@@ -136,9 +136,9 @@ export function rawPath(input, method = 'GET') {
136
136
  throw new CliError('READ_ONLY', 'Unclassified API route');
137
137
  return path + (query ? '?' + query : '');
138
138
  }
139
- export async function read(client, path) {
139
+ export async function read(client, path, signal) {
140
140
  routeFor(path.split('?')[0]);
141
- const value = await client.get(path);
141
+ const value = await client.get(path, signal);
142
142
  return validate(path.split('?')[0], value);
143
143
  }
144
144
  export async function resolveCommit(client, base, ref) {
@@ -166,7 +166,7 @@ export async function rawRead(client, path) {
166
166
  const count = response.headers.get('x-total-count');
167
167
  return { data, total: count && /^\d+$/.test(count) ? Number(count) : null, next: nextPage(response.headers, path, client.origin), truncated: false };
168
168
  }
169
- export async function collect(client, path, envelope) {
169
+ export async function collect(client, path, envelope, signal) {
170
170
  const paginated = routeFor(path).paginated;
171
171
  let next = paginated ? path + (path.includes('?') ? '&' : '?') + 'limit=50&page=1' : path;
172
172
  const seenPages = new Set();
@@ -176,14 +176,14 @@ export async function collect(client, path, envelope) {
176
176
  if (seenPages.has(next))
177
177
  throw new CliError('INVALID_RESPONSE', 'Gitea repeated a pagination URL');
178
178
  seenPages.add(next);
179
- const response = await client.request(next);
179
+ const response = await client.request(next, false, undefined, signal);
180
180
  const data = validate(new URL(next, client.origin).pathname, response.data);
181
181
  const rows = envelope ? object(data)[envelope] : data;
182
182
  if (!Array.isArray(rows))
183
183
  throw new CliError('INVALID_RESPONSE', 'Expected a Gitea list' + (envelope ? ' envelope ' + envelope : ''));
184
184
  for (const value of rows) {
185
185
  const row = object(value);
186
- const id = /\/(?:tags|actions\/(?:secrets|variables))$/.test(path.split('?')[0]) ? row.name : row.id ?? row.number ?? row.sha ?? row.filename ?? (row.user && row.content ? String(object(row.user).id) + ':' + row.content : undefined);
186
+ const id = /\/(?:tags|actions\/(?:secrets|variables))$/.test(path.split('?')[0]) ? row.name : row.id ?? row.number ?? row.sha ?? row.ref ?? row.sub_url ?? row.filename ?? (row.user && row.content ? String(object(row.user).id) + ':' + row.content : undefined);
187
187
  if (typeof id !== 'number' && typeof id !== 'string')
188
188
  throw new CliError('INVALID_RESPONSE', 'List record has no identity');
189
189
  items.set(String(id), row);
package/dist/git.js CHANGED
@@ -43,9 +43,9 @@ export class Git {
43
43
  if (!isAbsolute(this.cwd) || await realpath(this.cwd) !== this.cwd || await this.run(['rev-parse', '--show-toplevel']) !== this.cwd) {
44
44
  throw new CliError('USAGE', '--workspace must be the absolute native repository root', 2);
45
45
  }
46
- const config = await this.run(['config', '--local', '--name-only', '--list']) + '\n' +
47
- await this.run(['config', '--worktree', '--name-only', '--list']);
48
- if (config.split('\n').some(key => /^(?:url\.|http\.|credential\.|include\.|includeif\.|filter\.|diff\.|core\.sshcommand$|remote\..*\.(?:proxy|uploadpack|receivepack|pushurl|vcs)$)/i.test(key))) {
46
+ const config = await this.run(['config', '--no-includes', '--show-scope', '--name-only', '--list']);
47
+ // Inspect repository settings without requiring config.worktree or rejecting our command-scope transport settings.
48
+ if (config.split('\n').some(key => /^(?:local|worktree)\t(?:url\.|http\.|credential\.|include\.|includeif\.|filter\.|diff\.|core\.sshcommand$|remote\..*\.(?:proxy|uploadpack|receivepack|pushurl|vcs)$)/i.test(key))) {
49
49
  throw new CliError('UNSAFE_GIT_CONFIG', 'Workspace transport overrides must be removed before using origin-bound Git');
50
50
  }
51
51
  return this;
package/dist/main.js CHANGED
@@ -19,6 +19,7 @@ import { actionValues, valuePlan } from './action-values.js';
19
19
  import { assetPath, attachToPlan, download, uploadPlan } from './attachments.js';
20
20
  import { isLocal, localWorkflow, validateLocal } from './local-workflows.js';
21
21
  import { capabilityOutput, commandGap } from './catalogue.js';
22
+ import { diffOutput, maxOutputChars } from './output.js';
22
23
  async function input(path) {
23
24
  if (path !== '-')
24
25
  return readPrivateFile(path, true, 1_000_000);
@@ -42,7 +43,7 @@ export async function main(args) {
42
43
  if (args.some(arg => /^--(?:token|api-key|password|authorization)(?:=|$)/i.test(arg))) {
43
44
  throw new CliError('CONFIG_INVALID', 'token and other secret arguments are not accepted');
44
45
  }
45
- const command = args.length === 0 || args[0]?.startsWith('--') ? commands.find(command => command.path[0] === 'dashboard') :
46
+ const command = args.length === 0 || args[0]?.startsWith('-') ? commands.find(command => command.path[0] === 'dashboard') :
46
47
  [...commands].sort((a, b) => b.path.length - a.path.length).find(command => command.path.every((part, index) => args[index] === part));
47
48
  if (!command) {
48
49
  const children = commands.filter(command => command.path[0] === args[0]);
@@ -54,7 +55,7 @@ export async function main(args) {
54
55
  }
55
56
  const path = command.path;
56
57
  operation = command.path.join(' ');
57
- const rest = args[0]?.startsWith('--') || !args.length ? args : args.slice(path.length);
58
+ const rest = args[0]?.startsWith('-') || !args.length ? args : args.slice(path.length);
58
59
  let parsed;
59
60
  try {
60
61
  const options = Object.fromEntries(Object.entries(command.flags).map(([name, flag]) => [name, { type: flag.type,
@@ -119,7 +120,7 @@ export async function main(args) {
119
120
  });
120
121
  const output = await localWorkflow(new Client(identity.origin, identity.token, 300_000), identity, operation, positionals, options);
121
122
  const rendered = encode(redact(output));
122
- if (rendered.length > 100_000)
123
+ if (rendered.length > maxOutputChars)
123
124
  throw new CliError('OUTPUT_LIMIT', 'Rendered output exceeds 100000 characters');
124
125
  console.log(rendered);
125
126
  return;
@@ -359,12 +360,11 @@ export async function main(args) {
359
360
  else if (operation === 'pr diff') {
360
361
  const response = await client.request(base + '/pulls/' + positionals[0] + '.diff', true);
361
362
  const text = String(response.data);
362
- const ceiling = options.full ? 100_000 : 10_000;
363
- output = { diff: text.slice(0, ceiling), chars: text.length, truncated: text.length > ceiling, ...(text.length > ceiling ? { help: 'Use --full within the 100000 character diff ceiling' } : {}) };
363
+ output = diffOutput(text, Boolean(options.full));
364
364
  }
365
365
  }
366
366
  const rendered = encode(redact(output));
367
- if (rendered.length > 100_000)
367
+ if (rendered.length > maxOutputChars)
368
368
  throw new CliError('OUTPUT_LIMIT', 'Rendered output exceeds 100000 characters; select fewer fields or a lower --limit');
369
369
  console.log(rendered);
370
370
  }
package/dist/output.js ADDED
@@ -0,0 +1,56 @@
1
+ import { encode } from '@toon-format/toon';
2
+ import { redact } from './credentials.js';
3
+ export const maxOutputChars = 100_000;
4
+ // Reserve the newline added by console.log.
5
+ const fits = (value) => encode(redact(value)).length < maxOutputChars;
6
+ function summary(value) {
7
+ if (Array.isArray(value))
8
+ return { summary: true, total_items: value.length };
9
+ if (value && typeof value === 'object') {
10
+ const row = value;
11
+ return { summary: true, total_fields: Object.keys(row).length,
12
+ ...(typeof row.id === 'number' ? { id: row.id } : {}), ...(typeof row.number === 'number' ? { number: row.number } : {}) };
13
+ }
14
+ return typeof value === 'string' ? { summary: true, total_chars: value.length } : value;
15
+ }
16
+ export function writeOutput(value) {
17
+ const displayed = { ...value };
18
+ const truncated = [];
19
+ // Keep requested effects complete; project only their verification context.
20
+ for (const field of ['preconditions', 'observations', 'results']) {
21
+ if (fits(displayed))
22
+ return displayed;
23
+ if (!(field in value))
24
+ continue;
25
+ const context = value[field];
26
+ if (field === 'observations' && Array.isArray(context)) {
27
+ displayed[field] = context.slice(0, 10).map(row => ({ path: row.path, data: summary(row.data) }));
28
+ displayed.observations_total = context.length;
29
+ }
30
+ else if (field === 'results' && Array.isArray(context)) {
31
+ displayed[field] = context.slice(0, 10).map(summary);
32
+ displayed.results_total = context.length;
33
+ }
34
+ else
35
+ displayed[field] = summary(context);
36
+ truncated.push(field);
37
+ displayed.truncated = true;
38
+ displayed.truncated_fields = [...truncated];
39
+ }
40
+ return displayed;
41
+ }
42
+ export function diffOutput(text, full) {
43
+ const candidate = (length) => ({ diff: text.slice(0, length), chars: text.length, truncated: text.length > length,
44
+ ...(text.length > length ? { help: full ? 'Diff is bounded to the rendered output ceiling' : 'Use --full for a larger bounded diff' } : {}) });
45
+ let low = 0, high = Math.min(text.length, full ? maxOutputChars : 10_000);
46
+ if (fits(candidate(high)))
47
+ return candidate(high);
48
+ while (low < high) {
49
+ const middle = Math.ceil((low + high) / 2);
50
+ if (fits(candidate(middle)))
51
+ low = middle;
52
+ else
53
+ high = middle - 1;
54
+ }
55
+ return candidate(low);
56
+ }
package/dist/pulls.js CHANGED
@@ -7,7 +7,27 @@ const sorted = (values) => [...values].sort();
7
7
  const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
8
8
  const delta = (previous, add, remove) => [...new Set([...previous.map(String), ...names(add)])].filter(value => !names(remove).includes(value));
9
9
  const members = (pull, field, key) => (pull[field] ?? []).map(row => object(row)[key]);
10
+ function branchPath(base, branch) {
11
+ const segments = branch.split('/');
12
+ if (segments.some(segment => !segment || segment === '.' || segment === '..') || /[\s\\\x00-\x1f\x7f]/.test(branch))
13
+ usage('Select a branch without unsafe path segments');
14
+ return base + '/git/refs/heads/' + segments.map(encodeURIComponent).join('/');
15
+ }
16
+ function branchSHA(value, branch) {
17
+ if (!Array.isArray(value))
18
+ throw new CliError('INVALID_RESPONSE', 'Expected branch references');
19
+ const selected = value.map(object).find(row => row.ref === 'refs/heads/' + branch);
20
+ if (!selected)
21
+ throw new CliError('NOT_FOUND_OR_HIDDEN', 'The selected branch is missing or hidden');
22
+ const commit = object(selected.object);
23
+ if (commit.type !== 'commit' || typeof commit.sha !== 'string' || !/^[a-f0-9]{40}$/.test(commit.sha))
24
+ throw new CliError('INVALID_RESPONSE', 'Expected an immutable branch commit identity');
25
+ return commit.sha;
26
+ }
10
27
  export function validatePull(operation, options) {
28
+ for (const ref of [options.head, options.base])
29
+ if (ref !== undefined)
30
+ branchPath('', String(ref));
11
31
  if (operation === 'pr review') {
12
32
  if (['approve', 'request-changes', 'comment'].filter(key => options[key]).length !== 1)
13
33
  usage('Choose exactly one of --approve, --request-changes or --comment');
@@ -49,6 +69,7 @@ export async function pullPlan(client, operation, base, number, options) {
49
69
  reads.add(path);
50
70
  return value;
51
71
  };
72
+ const snapshotBranch = async (branch) => branchSHA(await snapshot(branchPath(base, branch), true), branch);
52
73
  const add = (method, path, payload) => steps.push({ method, path, ...(payload === undefined ? {} : { payload }) });
53
74
  const before = action === 'create' ? undefined : object(await snapshot(path));
54
75
  const expected = before ? { id: before.id, number: before.number, ...fields(before), merged: before.merged ?? false, headSHA: object(before.head).sha } :
@@ -101,15 +122,14 @@ export async function pullPlan(client, operation, base, number, options) {
101
122
  payload.assignees = names(options.assignee);
102
123
  expected.reviewers = sorted(addedReviewers);
103
124
  expected.teams = sorted(names(options['team-reviewer']));
104
- const head = object(await snapshot(base + '/branches/' + encodeURIComponent(String(options.head))));
105
- expected.headSHA = object(head.commit).id;
125
+ expected.headSHA = await snapshotBranch(String(options.head));
106
126
  }
107
127
  else {
108
128
  if (options['add-assignee'] !== undefined || options['remove-assignee'] !== undefined)
109
129
  payload.assignees = delta(expected.assignees, options['add-assignee'], options['remove-assignee']);
110
130
  }
111
131
  if (options.base !== undefined)
112
- await snapshot(base + '/branches/' + encodeURIComponent(String(options.base)));
132
+ await snapshotBranch(String(options.base));
113
133
  if (options.milestone !== undefined) {
114
134
  const list = await snapshot(base + '/milestones?state=all', true);
115
135
  const selected = list.find(row => row.title === options.milestone);
@@ -171,12 +191,12 @@ export async function pullPlan(client, operation, base, number, options) {
171
191
  payload.MergeMessageField = options.body;
172
192
  if (style === 'rebase')
173
193
  await snapshot(path + '/commits', true);
174
- await snapshot(base + '/branches/' + encodeURIComponent(String(expected.base)));
194
+ await snapshotBranch(String(expected.base));
175
195
  add('POST', path + '/merge', payload);
176
196
  expected.state = 'closed';
177
197
  }
178
198
  else if (action === 'update-branch') {
179
- await snapshot(base + '/branches/' + encodeURIComponent(String(expected.base)));
199
+ await snapshotBranch(String(expected.base));
180
200
  add('POST', path + '/update');
181
201
  }
182
202
  if (action === 'comment' || action === 'close' && options.comment !== undefined) {
@@ -228,8 +248,8 @@ export async function verifyPull(client, plan, results, observations) {
228
248
  }
229
249
  if (action !== 'merge' && action !== 'update-branch')
230
250
  return;
231
- const branchPath = base + '/branches/' + encodeURIComponent(String(expected.base));
232
- const baseSHA = object(object(before[branchPath]).commit).id;
251
+ const baseBranchPath = branchPath(base, String(expected.base));
252
+ const baseSHA = branchSHA(before[baseBranchPath], String(expected.base));
233
253
  const commit = async (sha) => {
234
254
  if (typeof sha !== 'string' || !/^[a-f0-9]{40}$/.test(sha))
235
255
  return mismatch();
@@ -241,25 +261,29 @@ export async function verifyPull(client, plan, results, observations) {
241
261
  return data;
242
262
  };
243
263
  if (action === 'update-branch') {
244
- const head = await commit(object(pull.head).sha);
245
- const pending = [head], visited = new Set();
246
- while (pending.length && visited.size < 1000) {
247
- const current = pending.pop();
248
- if (visited.has(current.sha))
264
+ const required = new Set([baseSHA, expected.headSHA]);
265
+ if ([...required].some(sha => typeof sha !== 'string' || !/^[a-f0-9]{40}$/.test(sha)))
266
+ mismatch();
267
+ const pending = [object(pull.head).sha], visited = new Set();
268
+ for (let cursor = 0; cursor < pending.length && visited.size < 1000; cursor++) {
269
+ const sha = pending[cursor];
270
+ if (visited.has(sha))
249
271
  continue;
272
+ const current = await commit(sha);
250
273
  visited.add(current.sha);
251
- if (visited.has(baseSHA) && visited.has(expected.headSHA))
274
+ required.delete(current.sha);
275
+ const parents = members(current, 'parents', 'sha');
276
+ for (const parent of parents)
277
+ required.delete(parent);
278
+ if (!required.size)
252
279
  return;
253
- for (const parent of members(current, 'parents', 'sha')) {
254
- if (!visited.has(parent))
255
- pending.push(await commit(parent));
256
- }
280
+ pending.push(...parents.filter(parent => !visited.has(parent)));
257
281
  }
258
282
  mismatch();
259
283
  }
260
284
  if (pull.merged !== true || !pull.merge_commit_sha)
261
285
  mismatch();
262
- const finalSHA = object(object(observation(branchPath)).commit).id;
286
+ const finalSHA = branchSHA(observation(baseBranchPath), String(expected.base));
263
287
  if (finalSHA !== pull.merge_commit_sha)
264
288
  mismatch();
265
289
  const final = await commit(finalSHA);
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
@@ -91,7 +91,7 @@ const definitions = [
91
91
  ['pr view', { ...repo, ...detail, reviews: { type: 'boolean' }, checks: { type: 'boolean' } }],
92
92
  ['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
93
  ['pr diff', { ...repo, full: detail.full }],
94
- ['release list', { ...repo, limit: list.limit, 'exclude-drafts': { type: 'boolean' }, 'exclude-pre-releases': { type: 'boolean' } }],
94
+ ['release list', { ...repo, limit: { ...list.limit, description: 'Maximum returned records, default 10, maximum 1000' }, 'exclude-drafts': { type: 'boolean' }, 'exclude-pre-releases': { type: 'boolean' } }],
95
95
  ['release view', { ...repo, full: detail.full }], ['api', { ...writeFlags,
96
96
  method: { type: 'string', short: 'X', description: 'HTTP method; cannot combine with a positional method' },
97
97
  field: { type: 'string', multiple: true, description: 'Repeatable key=value; numbers, booleans and null become JSON values' },
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
@@ -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(',') + ']';
@@ -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' });
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') });
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) });
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) {
@@ -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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowttl/gitea-axi",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "An agent CLI for Gitea repository workflows",
5
5
  "license": "MIT",
6
6
  "repository": {