@knowttl/gitea-axi 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -12
- package/dist/api.js +5 -5
- package/dist/cli.js +2 -1
- package/dist/git.js +3 -3
- package/dist/local-workflows.js +15 -18
- package/dist/main.js +22 -19
- package/dist/output.js +60 -0
- package/dist/pulls.js +42 -18
- package/dist/reads.js +21 -11
- package/dist/registry.js +3 -2
- package/dist/search.js +4 -2
- package/dist/stacks.js +61 -8
- package/dist/transport.js +4 -3
- package/dist/writes.js +32 -8
- package/generated/help.txt +385 -1
- package/generated/host-inventory.json +513 -1
- package/package.json +1 -1
- package/generated/capabilities.json +0 -964
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Agent-facing Gitea CLI with private credential profiles, read-only by default.
|
|
|
8
8
|
|
|
9
9
|
Agents need bounded, predictable output and explicit write controls instead of guessed API calls and unbounded responses.
|
|
10
10
|
gitea-axi offers a bounded repository dashboard, detailed reads, Actions task summaries, bounded search, repository, release, issue, label and pull request mutations, and classified raw API requests.
|
|
11
|
-
Output
|
|
11
|
+
Output defaults to compact TOON, with optional JSON and structured errors.
|
|
12
12
|
Reads are bounded, and writes require an exact operation grant, a preview and a matching confirmation.
|
|
13
13
|
See [Limitations](#limitations) for verification scope.
|
|
14
14
|
|
|
@@ -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.
|
|
@@ -106,12 +107,15 @@ Encoded route separators and traversal remain refused.
|
|
|
106
107
|
Anonymous reads require an explicit origin; repository selection follows the rules below.
|
|
107
108
|
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
|
-
Output uses TOON
|
|
110
|
+
Output uses TOON by default; `--json` selects JSON for results, structured errors and help.
|
|
111
|
+
Version aliases remain plain text when combined with `--json`.
|
|
112
|
+
Structured errors use stdout, with exit 1 for operational failures and exit 2 for usage errors.
|
|
110
113
|
Business reads share a 30-second deadline, except check watches use their explicit timeout; each request has a 2,000,000-byte response limit.
|
|
114
|
+
The check-watch timeout includes initial repository reads; polling cannot extend the client's deadline.
|
|
111
115
|
`repo view` instead gives each of its server version, specification and repository requests a separate 30-second timeout.
|
|
112
116
|
An explicit repository wins over a matching local origin remote; a remote never changes the selected profile.
|
|
113
|
-
`-R` aliases `--repo
|
|
114
|
-
For the default dashboard, use `gitea-axi --repo OWNER/NAME
|
|
117
|
+
`-R` aliases `--repo`, with the same duplicate-selector checks.
|
|
118
|
+
For the default dashboard, use `gitea-axi --repo OWNER/NAME` or `gitea-axi -R OWNER/NAME`.
|
|
115
119
|
Hosted repository reads require an explicit repository.
|
|
116
120
|
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
121
|
Automatic page queries apply only to routes whose retained Gitea specification declares a `page` parameter.
|
|
@@ -126,7 +130,10 @@ The dashboard shows a compact repository projection (ID, full name, visibility,
|
|
|
126
130
|
Dashboard sections report permission failures as partial results and point truncated sections to the corresponding standalone list command with `--repo` and `--limit`.
|
|
127
131
|
Issue and PR bodies default to 1000 characters, diffs to 10000 characters, and rendered output to 100000 characters.
|
|
128
132
|
`--full` expands bodies and diffs within the response and output ceilings.
|
|
129
|
-
|
|
133
|
+
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.
|
|
134
|
+
`pr checks` exits 8 for pending checks and 1 for failed or absent checks; watches require an explicit deadline.
|
|
135
|
+
During check polling, SIGINT or SIGTERM aborts in-flight PR and status requests, including paginated responses, and returns `cancelled: true` with exit code 8.
|
|
136
|
+
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
137
|
Commits without statuses return an empty check result; watches continue waiting for checks to register until the deadline or cancellation.
|
|
131
138
|
`--fields` refuses unknown names and fields without an evidenced Gitea equivalent, and cannot combine with detail expansion flags.
|
|
132
139
|
PR statusCheckRollup explicitly identifies partial commit statuses, and `fullDatabaseId` is a numeric vendor ID rather than a GraphQL node ID.
|
|
@@ -154,8 +161,11 @@ Issue and label deletion require a complete collection and at least one remainin
|
|
|
154
161
|
|
|
155
162
|
### Pull requests
|
|
156
163
|
|
|
164
|
+
`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
165
|
Pull request mutations include `create`, `edit`, `close`, `reopen`, `comment`, `update-branch` and `merge`.
|
|
158
166
|
Creation requires explicit `--title`, `--base` and `--head` branch names in the selected repository.
|
|
167
|
+
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.
|
|
168
|
+
Branch resolution binds commit identities into preview and confirmation while preserving API path separator and traversal protections.
|
|
159
169
|
Cross-repository heads, draft and project inputs remain unavailable.
|
|
160
170
|
Create accepts repeatable `--reviewer` user names and `--team-reviewer` team names, plus issue-style assignees, labels and milestone selectors.
|
|
161
171
|
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 +177,7 @@ If the reviewer step fails after creation, the error reports the created PR ID a
|
|
|
167
177
|
Close with `--comment` records closure and comment as separate steps and reports partial outcomes without retrying.
|
|
168
178
|
Successful writes verify the same PR identity, expected fields and memberships through fresh reads.
|
|
169
179
|
Update-branch verifies that the resulting head contains both its previous head and base commits; conflicts never report completion.
|
|
180
|
+
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
181
|
Merge requires exactly one of `--merge`, `--squash`, `--rebase` or `--method merge|squash|rebase`, and a full `--match-head-commit` SHA.
|
|
171
182
|
It sends the vendor `head_commit_id` precondition and verifies the closed, merged PR, base branch tip and final commit topology.
|
|
172
183
|
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.
|
|
@@ -188,8 +199,7 @@ Boolean flags accept explicit `=true` and `=false`, including disabling issues o
|
|
|
188
199
|
Fork takes a source through `--repo` or a positional OWNER/NAME and optionally `--org` for the destination.
|
|
189
200
|
Verification checks the destination identity, requested fields and fork parent.
|
|
190
201
|
Internal visibility refuses before mutation.
|
|
191
|
-
|
|
192
|
-
Repository creation `--push` and `--remote` require `--source`.
|
|
202
|
+
See [Local Git workflows and owned stacks](#local-git-workflows-and-owned-stacks) for supported workspace flag combinations and explicit `--workspace` selection.
|
|
193
203
|
|
|
194
204
|
### Releases
|
|
195
205
|
|
|
@@ -259,8 +269,14 @@ gitea-axi issue close 42 --repo OWNER/NAME --comment 'Resolved' --allow-write is
|
|
|
259
269
|
gitea-axi label create --repo OWNER/NAME --name bug --color ff0000 --allow-write issueCreateLabel --preview
|
|
260
270
|
```
|
|
261
271
|
|
|
262
|
-
The preview identifies the exact operations
|
|
272
|
+
The preview identifies the exact operations and payload without sending a mutation.
|
|
273
|
+
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.
|
|
274
|
+
Summarized output sets `truncated: true` and lists affected fields in `truncated_fields`.
|
|
275
|
+
Summaries report `summary: true` with `total_items`, `total_fields` or `total_chars`; object summaries retain numeric `id` and `number` when present.
|
|
276
|
+
Summarized observations retain paths, and observations and results retain at most ten entries with their original counts in `observations_total` and `results_total`.
|
|
277
|
+
Preview steps and requested payloads remain complete subject to credential redaction; output that still exceeds the ceiling fails with `OUTPUT_LIMIT`.
|
|
263
278
|
Confirmation binds the credential revision, origin, target, operations, canonical payload and preconditions.
|
|
279
|
+
Display summaries do not weaken confirmation binding or readback verification, which use the complete data.
|
|
264
280
|
Changing any binding requires a fresh preview.
|
|
265
281
|
Organization casing must match the preview exactly.
|
|
266
282
|
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 +298,10 @@ Each attempt is persisted before dispatch.
|
|
|
282
298
|
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
299
|
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
300
|
Reconciliation performs complete metadata reads for inspection and never repeats a mutation.
|
|
301
|
+
If its display is summarized as described above, use targeted reads to inspect the exact identity and requested fields before authorizing another attempt.
|
|
302
|
+
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.
|
|
303
|
+
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.
|
|
304
|
+
These read targets preserve the original confirmation binding and attempted-effect identity.
|
|
285
305
|
An unresolved dispatched attempt cannot be replayed by reusing its confirmation or obtaining another preview for the same effect.
|
|
286
306
|
Replay guards also treat repository owner, repository name and organization name case variants as the same identity for named and raw writes.
|
|
287
307
|
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,14 +323,14 @@ Incomplete environment profiles report missing variables; profile schema errors
|
|
|
303
323
|
For file profiles, an explicit profile must match; otherwise exactly one matching profile is required, ignoring `defaultProfile`.
|
|
304
324
|
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
325
|
A host selector cannot construct an anonymous origin, bypass tokenOrigin binding or enable HTTP.
|
|
306
|
-
Search commands
|
|
326
|
+
Search commands support `--hostname` and `GH_HOST` with the same profile selection and credential binding checks.
|
|
307
327
|
Hosted invocations refuse credential/configuration overrides and ignore ambient `GH_HOST` for the injected Artium profile.
|
|
308
328
|
On Linux, the config file must be owned by the invoking account and must not be group- or world-writable.
|
|
309
329
|
Config files and file SecretRefs must be regular files; symlinks are refused.
|
|
310
330
|
See [the credential schema](axi-credential.schema.json) for profile fields and env/file SecretRef shapes.
|
|
311
331
|
`origin` and `tokenOrigin` must identify the same canonical origin.
|
|
312
332
|
Inline tokens and secret arguments are refused.
|
|
313
|
-
Resolved credentials are redacted from response and error string values before TOON serialization, including their JSON-escaped and URL-encoded forms.
|
|
333
|
+
Resolved credentials are redacted from response and error string values before TOON or JSON serialization, including their JSON-escaped and URL-encoded forms.
|
|
314
334
|
Redaction also masks recognized secret fields, secret query values, URL credentials, JWTs, private keys and account keys.
|
|
315
335
|
Generated confirmation IDs and fixed confirmation/reconciliation instructions remain intact when they overlap a protected value; payloads and upstream response text remain subject to redaction.
|
|
316
336
|
The complete environment profile uses `GITEA_AXI_ORIGIN`, `GITEA_AXI_TOKEN_ORIGIN` and a JSON SecretRef in `GITEA_AXI_TOKEN`.
|
|
@@ -329,7 +349,9 @@ Local workflows require an explicit absolute `--workspace` and use native Git ar
|
|
|
329
349
|
Select the native repository root, or an existing parent directory for cloning.
|
|
330
350
|
`repo clone OWNER/NAME` clones into a new child directory of the selected parent and checks out the previewed default commit.
|
|
331
351
|
`repo create --source /absolute/repo --push --remote NAME` publishes the current source branch with its complete history.
|
|
332
|
-
|
|
352
|
+
Workspace repository creation requires both `--source` and `--push` and refuses `--clone`.
|
|
353
|
+
`repo fork --clone` clones the verified fork; `--remote NAME` selects the clone's origin name.
|
|
354
|
+
Workspace forks require `--clone` and refuse `--source` and `--push`.
|
|
333
355
|
Existing remotes are never overwritten.
|
|
334
356
|
Use a repository-local Git user name and email for workflows that create commits.
|
|
335
357
|
Git credential helpers, hooks, transport rewrites and redirects are disabled or refused.
|
|
@@ -401,7 +423,7 @@ Set `GITEA_AXI_REPORT` to retain their reports at a chosen path.
|
|
|
401
423
|
Their default reports go to unique files in the OS temporary directory; temporary installation files are removed.
|
|
402
424
|
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
425
|
|
|
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.
|
|
426
|
+
`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
427
|
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
428
|
Run it independently with `node --import ./scripts/deny-network.ts scripts/verify-package.ts`.
|
|
407
429
|
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/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
const args = process.argv.slice(2);
|
|
4
|
-
|
|
4
|
+
const versionArgs = args.filter(arg => !['--json', '--json=true', '--json=false'].includes(arg));
|
|
5
|
+
if (args.length <= 2 && versionArgs.length === 1 && ['--version', '-v', '-V'].includes(versionArgs[0] ?? '')) {
|
|
5
6
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
6
7
|
console.log(pkg.version);
|
|
7
8
|
}
|
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', '--
|
|
47
|
-
|
|
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/local-workflows.js
CHANGED
|
@@ -20,6 +20,10 @@ export function validateLocal(operation, options) {
|
|
|
20
20
|
if (process.env.GITEA_AXI_HOST_WRITE_OPERATIONS !== undefined || process.env.GITEA_AXI_CREDENTIAL_REVISION !== undefined) {
|
|
21
21
|
throw new CliError('READ_ONLY', 'Workspace workflows are standalone only');
|
|
22
22
|
}
|
|
23
|
+
if (operation === 'repo create' && (!options.source || !options.push || options.clone))
|
|
24
|
+
usage('Workspace repository creation requires --source and --push without --clone');
|
|
25
|
+
if (operation === 'repo fork' && (!options.clone || options.source !== undefined || options.push))
|
|
26
|
+
usage('Workspace forks require --clone without --source or --push');
|
|
23
27
|
if (operation.startsWith('stack ') && (options.repo || process.env.GH_REPO))
|
|
24
28
|
usage('Stack commands reject -R/--repo and GH_REPO; use the explicit local workspace');
|
|
25
29
|
if (typeof options.workspace !== 'string' || !isAbsolute(options.workspace))
|
|
@@ -68,10 +72,7 @@ export async function localWorkflow(client, identity, operation, args, options)
|
|
|
68
72
|
if (operation === 'repo create' || operation === 'repo fork') {
|
|
69
73
|
if (args.length > 1)
|
|
70
74
|
usage('Select at most one repository');
|
|
71
|
-
|
|
72
|
-
usage('--push and repository creation --remote require --source');
|
|
73
|
-
if (options.source && options.clone)
|
|
74
|
-
usage('Choose --source or --clone');
|
|
75
|
+
const creating = operation === 'repo create';
|
|
75
76
|
const apiOptions = { ...options };
|
|
76
77
|
for (const key of ['source', 'push', 'remote', 'clone', 'workspace'])
|
|
77
78
|
delete apiOptions[key];
|
|
@@ -85,39 +86,35 @@ export async function localWorkflow(client, identity, operation, args, options)
|
|
|
85
86
|
if (!/^[A-Za-z0-9_-]+$/.test(remote))
|
|
86
87
|
usage('Select a simple remote name');
|
|
87
88
|
const destination = join(workspace, destinationName.split('/')[1]);
|
|
88
|
-
if (
|
|
89
|
-
const sourcePath = String(options.source
|
|
89
|
+
if (creating) {
|
|
90
|
+
const sourcePath = String(options.source);
|
|
90
91
|
if (!isAbsolute(sourcePath))
|
|
91
92
|
usage('--source requires an absolute native repository root');
|
|
92
93
|
source = await new Git(await realpath(sourcePath), identity.origin, identity.token).workspace();
|
|
93
94
|
if (source.cwd !== workspace)
|
|
94
95
|
usage('--source must equal the explicit --workspace');
|
|
95
96
|
await source.clean();
|
|
96
|
-
if (
|
|
97
|
+
if (!await source.run(['symbolic-ref', '--quiet', '--short', 'HEAD'], [0, 1]))
|
|
97
98
|
usage('Publishing requires a named source branch');
|
|
98
99
|
if ((await source.run(['remote'])).split('\n').includes(remote))
|
|
99
100
|
usage('Selected remote already exists; choose another --remote');
|
|
100
101
|
preconditions = await source.snapshot();
|
|
101
102
|
}
|
|
102
|
-
else
|
|
103
|
+
else {
|
|
103
104
|
if (existsSync(destination))
|
|
104
105
|
usage('Clone destination already exists');
|
|
105
106
|
preconditions = { parent: workspace, contents: await readdir(workspace) };
|
|
106
107
|
}
|
|
107
|
-
|
|
108
|
-
usage('Workspace composite requires --source or --clone');
|
|
109
|
-
return localEffect(client, identity, 'composite', options, git, preconditions, { source: source?.cwd, remote, url, clone: Boolean(options.clone), push: Boolean(options.push) }, async () => {
|
|
108
|
+
return localEffect(client, identity, 'composite', options, git, preconditions, { source: source?.cwd, remote, url, clone: !creating, push: creating }, async () => {
|
|
110
109
|
if (source) {
|
|
111
110
|
if (!same(await source.snapshot(), preconditions))
|
|
112
111
|
throw new CliError('CONFIRMATION_MISMATCH', 'Source changed after API creation; repository created, local publication stopped');
|
|
113
112
|
await source.run(['remote', 'add', remote, url]);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
return { workspace, remote, published: Boolean(options.push), head: await source.tip() };
|
|
113
|
+
const branch = await source.run(['symbolic-ref', '--short', 'HEAD']);
|
|
114
|
+
await source.run(['push', '--', url, 'refs/heads/' + branch + ':refs/heads/' + branch]);
|
|
115
|
+
if (!(await source.run(['ls-remote', '--', url, 'refs/heads/' + branch])).startsWith(await source.tip()))
|
|
116
|
+
throw new CliError('READBACK_MISMATCH', 'Published head was not confirmed');
|
|
117
|
+
return { workspace, remote, published: true, head: await source.tip() };
|
|
121
118
|
}
|
|
122
119
|
if (existsSync(destination))
|
|
123
120
|
throw new CliError('CONFIRMATION_MISMATCH', 'Clone destination appeared; API repository was created');
|
package/dist/main.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { isAbsolute } from 'node:path';
|
|
3
|
-
import { encode } from '@toon-format/toon';
|
|
4
3
|
import { commands } from './registry.js';
|
|
5
4
|
import { CliError } from './error.js';
|
|
6
5
|
import { readRepository } from './repository.js';
|
|
7
|
-
import { configPath, credentials, profileList,
|
|
6
|
+
import { configPath, credentials, profileList, registerSecret } from './credentials.js';
|
|
8
7
|
import { Client } from './transport.js';
|
|
9
8
|
import { collect, object, rawPath, rawRead, read } from './api.js';
|
|
10
9
|
import { boundedList, issueFields, listIssues, listOptions, positive, prFields, repository, selectedFields, view, watchChecks } from './reads.js';
|
|
@@ -19,6 +18,7 @@ import { actionValues, valuePlan } from './action-values.js';
|
|
|
19
18
|
import { assetPath, attachToPlan, download, uploadPlan } from './attachments.js';
|
|
20
19
|
import { isLocal, localWorkflow, validateLocal } from './local-workflows.js';
|
|
21
20
|
import { capabilityOutput, commandGap } from './catalogue.js';
|
|
21
|
+
import { diffOutput, maxOutputChars, render } from './output.js';
|
|
22
22
|
async function input(path) {
|
|
23
23
|
if (path !== '-')
|
|
24
24
|
return readPrivateFile(path, true, 1_000_000);
|
|
@@ -32,6 +32,8 @@ async function input(path) {
|
|
|
32
32
|
return body;
|
|
33
33
|
}
|
|
34
34
|
export async function main(args) {
|
|
35
|
+
let json = args.some(arg => arg === '--json' || arg === '--json=true');
|
|
36
|
+
const print = (value) => console.log(render(value, json));
|
|
35
37
|
let operation = args[0] && !args[0].startsWith('-') ? args[0] : 'dashboard';
|
|
36
38
|
try {
|
|
37
39
|
for (const arg of args) {
|
|
@@ -42,19 +44,20 @@ export async function main(args) {
|
|
|
42
44
|
if (args.some(arg => /^--(?:token|api-key|password|authorization)(?:=|$)/i.test(arg))) {
|
|
43
45
|
throw new CliError('CONFIG_INVALID', 'token and other secret arguments are not accepted');
|
|
44
46
|
}
|
|
45
|
-
const command = args.length === 0 || args[0]?.startsWith('
|
|
47
|
+
const command = args.length === 0 || args[0]?.startsWith('-') ? commands.find(command => command.path[0] === 'dashboard') :
|
|
46
48
|
[...commands].sort((a, b) => b.path.length - a.path.length).find(command => command.path.every((part, index) => args[index] === part));
|
|
47
49
|
if (!command) {
|
|
48
50
|
const children = commands.filter(command => command.path[0] === args[0]);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
const helpArgs = args.filter(arg => arg !== '--json');
|
|
52
|
+
if (children.length && helpArgs.length === 2 && helpArgs[1] === '--help' && args.length <= 3) {
|
|
53
|
+
print({ command: 'gitea-axi ' + operation, commands: children.map(command => 'gitea-axi ' + command.path.join(' ')) });
|
|
51
54
|
return;
|
|
52
55
|
}
|
|
53
56
|
throw new CliError('USAGE', children.length ? 'Choose a command: ' + children.map(command => 'gitea-axi ' + command.path.join(' ')).join(', ') : 'Unknown command; use gitea-axi --help', 2);
|
|
54
57
|
}
|
|
55
58
|
const path = command.path;
|
|
56
59
|
operation = command.path.join(' ');
|
|
57
|
-
const rest = args[0]?.startsWith('
|
|
60
|
+
const rest = args[0]?.startsWith('-') || !args.length ? args : args.slice(path.length);
|
|
58
61
|
let parsed;
|
|
59
62
|
try {
|
|
60
63
|
const options = Object.fromEntries(Object.entries(command.flags).map(([name, flag]) => [name, { type: flag.type,
|
|
@@ -80,23 +83,24 @@ export async function main(args) {
|
|
|
80
83
|
throw new CliError('USAGE', 'Invalid arguments; valid flags: ' + Object.keys(command.flags).map(f => '--' + f).join(', '), 2);
|
|
81
84
|
}
|
|
82
85
|
const { values, positionals } = parsed;
|
|
86
|
+
json = Boolean(values.json);
|
|
83
87
|
const names = parsed.tokens.flatMap(token => token.kind === 'option' && !command.flags[token.name]?.multiple ? [token.name] : []);
|
|
84
88
|
if (new Set(names).size !== names.length) {
|
|
85
89
|
throw new CliError('USAGE', 'Choose each flag only once; --repo requires exactly one selector', 2);
|
|
86
90
|
}
|
|
87
91
|
if (values.help) {
|
|
88
|
-
|
|
92
|
+
print({ bin: process.argv[1], description: command.description, command: 'gitea-axi ' + operation,
|
|
89
93
|
flags: Object.entries(command.flags).map(([name, flag]) => ({ flag: '--' + name, ...flag })),
|
|
90
94
|
...(operation === 'dashboard' ? { commands: commands.map(command => command.path.join(' ')) } : {}),
|
|
91
95
|
examples: operation === 'api' ? ['gitea-axi api GET repos/OWNER/NAME', 'gitea-axi api version'] :
|
|
92
|
-
[`gitea-axi ${operation} --help`, `gitea-axi ${operation} --profile <name>`] })
|
|
96
|
+
[`gitea-axi ${operation} --help`, `gitea-axi ${operation} --profile <name>`] });
|
|
93
97
|
return;
|
|
94
98
|
}
|
|
95
99
|
const options = values;
|
|
96
100
|
if (operation === 'capabilities') {
|
|
97
101
|
if (positionals.length > 1)
|
|
98
102
|
throw new CliError('USAGE', 'Use capabilities [C001-C124]', 2);
|
|
99
|
-
|
|
103
|
+
print(capabilityOutput(commands, prFields, positionals[0]));
|
|
100
104
|
return;
|
|
101
105
|
}
|
|
102
106
|
if (command.gap)
|
|
@@ -118,8 +122,8 @@ export async function main(args) {
|
|
|
118
122
|
...(typeof options.hostname === 'string' ? { hostname: options.hostname } : {}),
|
|
119
123
|
});
|
|
120
124
|
const output = await localWorkflow(new Client(identity.origin, identity.token, 300_000), identity, operation, positionals, options);
|
|
121
|
-
const rendered =
|
|
122
|
-
if (rendered.length >
|
|
125
|
+
const rendered = render(output, json);
|
|
126
|
+
if (rendered.length > maxOutputChars)
|
|
123
127
|
throw new CliError('OUTPUT_LIMIT', 'Rendered output exceeds 100000 characters');
|
|
124
128
|
console.log(rendered);
|
|
125
129
|
return;
|
|
@@ -261,11 +265,11 @@ export async function main(args) {
|
|
|
261
265
|
if (options.config && (typeof options.config !== 'string' || !isAbsolute(options.config)))
|
|
262
266
|
throw new CliError('USAGE', '--config must be absolute', 2);
|
|
263
267
|
if (operation === 'config path') {
|
|
264
|
-
|
|
268
|
+
print({ path: configPath(options.config) });
|
|
265
269
|
return;
|
|
266
270
|
}
|
|
267
271
|
if (operation === 'profile list') {
|
|
268
|
-
|
|
272
|
+
print(await profileList(options.config));
|
|
269
273
|
return;
|
|
270
274
|
}
|
|
271
275
|
const identity = await credentials(typeof values.origin === 'string' ? values.origin : undefined, Boolean(values['allow-insecure-http']), {
|
|
@@ -283,7 +287,7 @@ export async function main(args) {
|
|
|
283
287
|
else if (searching)
|
|
284
288
|
output = await search(client, path[1], positionals[0], options);
|
|
285
289
|
else if (operation === 'write reconcile')
|
|
286
|
-
output = await reconcile(client, positionals[0]);
|
|
290
|
+
output = await reconcile(client, positionals[0], json);
|
|
287
291
|
else if (operation === 'api')
|
|
288
292
|
output = method === 'GET' ? await rawRead(client, apiPath) :
|
|
289
293
|
await write(client, identity, await rawWritePlan(client, method, apiPath, payload), options);
|
|
@@ -359,18 +363,17 @@ export async function main(args) {
|
|
|
359
363
|
else if (operation === 'pr diff') {
|
|
360
364
|
const response = await client.request(base + '/pulls/' + positionals[0] + '.diff', true);
|
|
361
365
|
const text = String(response.data);
|
|
362
|
-
|
|
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' } : {}) };
|
|
366
|
+
output = diffOutput(text, Boolean(options.full), json);
|
|
364
367
|
}
|
|
365
368
|
}
|
|
366
|
-
const rendered =
|
|
367
|
-
if (rendered.length >
|
|
369
|
+
const rendered = render(output, json);
|
|
370
|
+
if (rendered.length > maxOutputChars)
|
|
368
371
|
throw new CliError('OUTPUT_LIMIT', 'Rendered output exceeds 100000 characters; select fewer fields or a lower --limit');
|
|
369
372
|
console.log(rendered);
|
|
370
373
|
}
|
|
371
374
|
catch (error) {
|
|
372
375
|
const failure = error instanceof CliError ? error : new CliError('INTERNAL_ERROR', 'Unable to read the repository');
|
|
373
|
-
|
|
376
|
+
print({ error: { code: failure.code, operation, reason: failure.message }, help: commands.some(command => command.path.join(' ') === operation || command.path[0] === operation) ? 'gitea-axi ' + operation + ' --help' : 'gitea-axi --help' });
|
|
374
377
|
process.exitCode = failure.exitCode;
|
|
375
378
|
}
|
|
376
379
|
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { encode } from '@toon-format/toon';
|
|
2
|
+
import { redact } from './credentials.js';
|
|
3
|
+
export const maxOutputChars = 100_000;
|
|
4
|
+
export function render(value, json = false) {
|
|
5
|
+
const safe = redact(value);
|
|
6
|
+
return json ? JSON.stringify(safe) : encode(safe);
|
|
7
|
+
}
|
|
8
|
+
// Reserve the newline added by console.log.
|
|
9
|
+
const fits = (value, json) => render(value, json).length < maxOutputChars;
|
|
10
|
+
function summary(value) {
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return { summary: true, total_items: value.length };
|
|
13
|
+
if (value && typeof value === 'object') {
|
|
14
|
+
const row = value;
|
|
15
|
+
return { summary: true, total_fields: Object.keys(row).length,
|
|
16
|
+
...(typeof row.id === 'number' ? { id: row.id } : {}), ...(typeof row.number === 'number' ? { number: row.number } : {}) };
|
|
17
|
+
}
|
|
18
|
+
return typeof value === 'string' ? { summary: true, total_chars: value.length } : value;
|
|
19
|
+
}
|
|
20
|
+
export function writeOutput(value, json = false) {
|
|
21
|
+
const displayed = { ...value };
|
|
22
|
+
const truncated = [];
|
|
23
|
+
// Keep requested effects complete; project only their verification context.
|
|
24
|
+
for (const field of ['preconditions', 'observations', 'results']) {
|
|
25
|
+
if (fits(displayed, json))
|
|
26
|
+
return displayed;
|
|
27
|
+
if (!(field in value))
|
|
28
|
+
continue;
|
|
29
|
+
const context = value[field];
|
|
30
|
+
if (field === 'observations' && Array.isArray(context)) {
|
|
31
|
+
displayed[field] = context.slice(0, 10).map(row => ({ path: row.path, data: summary(row.data) }));
|
|
32
|
+
displayed.observations_total = context.length;
|
|
33
|
+
}
|
|
34
|
+
else if (field === 'results' && Array.isArray(context)) {
|
|
35
|
+
displayed[field] = context.slice(0, 10).map(summary);
|
|
36
|
+
displayed.results_total = context.length;
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
displayed[field] = summary(context);
|
|
40
|
+
truncated.push(field);
|
|
41
|
+
displayed.truncated = true;
|
|
42
|
+
displayed.truncated_fields = [...truncated];
|
|
43
|
+
}
|
|
44
|
+
return displayed;
|
|
45
|
+
}
|
|
46
|
+
export function diffOutput(text, full, json = false) {
|
|
47
|
+
const candidate = (length) => ({ diff: text.slice(0, length), chars: text.length, truncated: text.length > length,
|
|
48
|
+
...(text.length > length ? { help: full ? 'Diff is bounded to the rendered output ceiling' : 'Use --full for a larger bounded diff' } : {}) });
|
|
49
|
+
let low = 0, high = Math.min(text.length, full ? maxOutputChars : 10_000);
|
|
50
|
+
if (fits(candidate(high), json))
|
|
51
|
+
return candidate(high);
|
|
52
|
+
while (low < high) {
|
|
53
|
+
const middle = Math.ceil((low + high) / 2);
|
|
54
|
+
if (fits(candidate(middle), json))
|
|
55
|
+
low = middle;
|
|
56
|
+
else
|
|
57
|
+
high = middle - 1;
|
|
58
|
+
}
|
|
59
|
+
return candidate(low);
|
|
60
|
+
}
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
232
|
-
const baseSHA =
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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);
|