@knowttl/gitea-axi 0.0.0-stage → 0.1.0
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 +108 -2
- package/axi-credential.schema.json +21 -0
- package/dist/api.js +134 -0
- package/dist/cli.js +11 -0
- package/dist/credentials.js +133 -0
- package/dist/error.js +9 -0
- package/dist/main.js +156 -0
- package/dist/private-file.js +49 -0
- package/dist/reads.js +230 -0
- package/dist/registry.js +55 -0
- package/dist/repository.js +37 -0
- package/dist/transport.js +65 -0
- package/dist/vendor.generated.js +5 -0
- package/generated/help.txt +11 -0
- package/generated/host-inventory.json +792 -0
- package/generated/routes.json +2025 -0
- package/package.json +33 -4
- package/schemas/responses.json +63 -0
- package/schemas/vendor.json +7130 -0
package/dist/reads.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { CliError } from './error.js';
|
|
4
|
+
import { collect, object, read } from './api.js';
|
|
5
|
+
const scalarFields = {
|
|
6
|
+
additions: 'additions', body: 'body', changedFiles: 'changed_files', closedAt: 'closed_at', createdAt: 'created_at',
|
|
7
|
+
deletions: 'deletions', fullDatabaseId: 'id', isDraft: 'draft', maintainerCanModify: 'allow_maintainer_edit',
|
|
8
|
+
mergedAt: 'merged_at', number: 'number', title: 'title', updatedAt: 'updated_at', url: 'html_url',
|
|
9
|
+
};
|
|
10
|
+
const gaps = ['autoMergeRequest', 'closingIssuesReferences', 'id', 'mergeStateStatus', 'potentialMergeCommit', 'projectCards', 'projectItems', 'reviewDecision'];
|
|
11
|
+
export const prFields = [...Object.keys(scalarFields), ...gaps, 'assignees', 'author', 'baseRefName', 'baseRefOid', 'closed', 'comments', 'commits', 'files',
|
|
12
|
+
'headRefName', 'headRefOid', 'headRepository', 'headRepositoryOwner', 'isCrossRepository', 'labels', 'latestReviews', 'mergeCommit',
|
|
13
|
+
'mergeable', 'mergedBy', 'milestone', 'reactionGroups', 'reviewRequests', 'reviews', 'state', 'statusCheckRollup'];
|
|
14
|
+
export const issueFields = ['number', 'title', 'state', 'body', 'author', 'assignees', 'labels', 'milestone', 'createdAt', 'updatedAt', 'url'];
|
|
15
|
+
export function selectedFields(options, allowed) {
|
|
16
|
+
if (typeof options.fields !== 'string')
|
|
17
|
+
return;
|
|
18
|
+
const fields = options.fields.split(',');
|
|
19
|
+
if (fields.some(field => !allowed.includes(field)) || new Set(fields).size !== fields.length)
|
|
20
|
+
throw new CliError('USAGE', 'Unknown or repeated field; choose ' + allowed.join(','), 2);
|
|
21
|
+
const gap = fields.find(field => gaps.includes(field));
|
|
22
|
+
if (gap)
|
|
23
|
+
throw new CliError('CAPABILITY_GAP', gap + ' has no evidenced Gitea equivalent');
|
|
24
|
+
if (options.full || options.comments || options.reviews || options.checks)
|
|
25
|
+
throw new CliError('USAGE', '--fields cannot combine with --full, --comments, --reviews or --checks', 2);
|
|
26
|
+
return fields;
|
|
27
|
+
}
|
|
28
|
+
export function positive(value, fallback, maximum) {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return fallback;
|
|
31
|
+
if (typeof value !== 'string' || !/^\d+$/.test(value) || Number(value) < 1 || Number(value) > maximum)
|
|
32
|
+
throw new CliError('USAGE', 'Expected a positive integer up to ' + maximum, 2);
|
|
33
|
+
return Number(value);
|
|
34
|
+
}
|
|
35
|
+
export function repository(explicit, origin) {
|
|
36
|
+
if (typeof explicit === 'string') {
|
|
37
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*\/[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(explicit))
|
|
38
|
+
throw new CliError('USAGE', '--repo must be OWNER/NAME', 2);
|
|
39
|
+
return explicit;
|
|
40
|
+
}
|
|
41
|
+
if (process.env.GITEA_AXI_PROFILE === 'artium')
|
|
42
|
+
throw new CliError('USAGE', 'Hosted calls require --repo OWNER/NAME', 2);
|
|
43
|
+
let remote;
|
|
44
|
+
try {
|
|
45
|
+
remote = execFileSync('git', ['config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new CliError('USAGE', 'Select --repo OWNER/NAME or a matching origin remote', 2);
|
|
49
|
+
}
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(remote.replace(/^git@([^:]+):(.+)$/, 'ssh://git@$1/$2'));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new CliError('USAGE', 'Remote must identify a Gitea host; use --repo', 2);
|
|
56
|
+
}
|
|
57
|
+
const target = new URL(origin);
|
|
58
|
+
if (url.hostname !== target.hostname || (url.port && url.port !== target.port))
|
|
59
|
+
throw new CliError('USAGE', 'Remote host does not match the selected credential origin; use --repo', 2);
|
|
60
|
+
return repository(url.pathname.slice(1).replace(/\.git$/, ''), origin);
|
|
61
|
+
}
|
|
62
|
+
const user = (value) => value == null ? null : { login: object(value).login, name: object(value).full_name ?? null };
|
|
63
|
+
const users = (value) => value == null ? [] : value.map(user);
|
|
64
|
+
const label = (value) => { const row = object(value); return { name: row.name, color: row.color, description: row.description ?? null }; };
|
|
65
|
+
function preview(body, full) {
|
|
66
|
+
const text = typeof body === 'string' ? body : '';
|
|
67
|
+
return full || text.length <= 1000 ? text : text.slice(0, 1000) + `... (truncated, ${text.length} chars total; use --full)`;
|
|
68
|
+
}
|
|
69
|
+
export function issue(row, full = false) {
|
|
70
|
+
return { number: row.number, title: row.title, state: row.state, body: preview(row.body, full), author: user(row.user),
|
|
71
|
+
assignees: users(row.assignees), labels: (row.labels ?? []).map(label), milestone: row.milestone == null ? null : { title: object(row.milestone).title },
|
|
72
|
+
createdAt: row.created_at ?? null, updatedAt: row.updated_at ?? null, url: row.html_url ?? null };
|
|
73
|
+
}
|
|
74
|
+
export function listOptions(options) {
|
|
75
|
+
const limit = positive(options.limit, 30, 1000);
|
|
76
|
+
if (options.state !== undefined && !['open', 'closed', 'all'].includes(String(options.state)))
|
|
77
|
+
throw new CliError('USAGE', '--state must be open, closed or all', 2);
|
|
78
|
+
if (options.sort !== undefined && !['created', 'updated', 'number', 'comments'].includes(String(options.sort)))
|
|
79
|
+
throw new CliError('USAGE', 'Unsupported --sort', 2);
|
|
80
|
+
if (options.order !== undefined && !['asc', 'desc'].includes(String(options.order)))
|
|
81
|
+
throw new CliError('USAGE', '--order must be asc or desc', 2);
|
|
82
|
+
return limit;
|
|
83
|
+
}
|
|
84
|
+
export async function listIssues(client, base, kind, options) {
|
|
85
|
+
const limit = listOptions(options);
|
|
86
|
+
const fields = selectedFields(options, kind === 'pr' ? issueFields.concat(['isDraft', 'headRefName', 'baseRefName']) : issueFields) ?? ['number', 'title', 'state'];
|
|
87
|
+
let milestone;
|
|
88
|
+
if (typeof options.milestone === 'string') {
|
|
89
|
+
const page = await collect(client, base + '/milestones?state=all');
|
|
90
|
+
milestone = page.items.find(row => row.title === options.milestone)?.id;
|
|
91
|
+
if (milestone === undefined)
|
|
92
|
+
throw new CliError('USAGE', 'Unknown milestone name', 2);
|
|
93
|
+
}
|
|
94
|
+
const page = await collect(client, base + (kind === 'pr' ? '/pulls' : '/issues') + '?state=all' + (kind === 'issue' ? '&type=issues' : ''));
|
|
95
|
+
const rows = page.items.filter(row => (kind !== 'issue' || !row.pull_request) &&
|
|
96
|
+
(options.state === 'all' || row.state === (options.state ?? 'open')) &&
|
|
97
|
+
(!options.author || (row.user && object(row.user).login === options.author)) &&
|
|
98
|
+
(!options.assignee || (row.assignees ?? []).some(item => item.login === options.assignee)) &&
|
|
99
|
+
(!options.label || (row.labels ?? []).some(item => item.name === options.label)) &&
|
|
100
|
+
(milestone === undefined || (row.milestone && object(row.milestone).id === milestone)));
|
|
101
|
+
const sort = options.sort === 'number' ? 'number' : options.sort === 'comments' ? 'comments' : options.sort === 'updated' ? 'updated_at' : 'created_at';
|
|
102
|
+
rows.sort((a, b) => {
|
|
103
|
+
const left = a[sort] ?? '', right = b[sort] ?? '';
|
|
104
|
+
const comparison = typeof left === 'number' && typeof right === 'number' ? left - right : String(left).localeCompare(String(right));
|
|
105
|
+
return (comparison || Number(a.number) - Number(b.number)) * (options.order === 'asc' ? 1 : -1);
|
|
106
|
+
});
|
|
107
|
+
return { items: rows.slice(0, limit).map(row => {
|
|
108
|
+
const projection = { ...issue(row), isDraft: row.draft ?? null, headRefName: row.head ? object(row.head).ref : null, baseRefName: row.base ? object(row.base).ref : null };
|
|
109
|
+
return Object.fromEntries(fields.map(field => [field, projection[field]]));
|
|
110
|
+
}), count: Math.min(rows.length, limit), total: page.truncated ? null : rows.length, vendor_total: page.total,
|
|
111
|
+
truncated: page.truncated || rows.length > limit, next: page.next, continuation: page.truncated || rows.length > limit,
|
|
112
|
+
help: `gitea-axi ${kind} view <number> --repo ${base.split('/').slice(-2).join('/')}` };
|
|
113
|
+
}
|
|
114
|
+
async function complete(client, path) {
|
|
115
|
+
const page = await collect(client, path);
|
|
116
|
+
if (page.truncated)
|
|
117
|
+
throw new CliError('RESPONSE_LIMIT', 'Related collection exceeds the bounded read; use the raw API to page explicitly');
|
|
118
|
+
return page.items;
|
|
119
|
+
}
|
|
120
|
+
async function reviews(client, path, full) {
|
|
121
|
+
const rows = await complete(client, path + '/reviews');
|
|
122
|
+
const result = [];
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
const comments = await complete(client, path + '/reviews/' + row.id + '/comments');
|
|
125
|
+
result.push({ id: row.id, author: user(row.user), state: row.state, dismissed: row.dismissed ?? false, stale: row.stale ?? false, body: preview(row.body, full), commit: { oid: row.commit_id },
|
|
126
|
+
submittedAt: row.submitted_at ?? null, comments: comments.map(comment => ({ id: comment.id, body: preview(comment.body, full), path: comment.path, position: comment.position, commit: { oid: comment.commit_id } })) });
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
export async function checks(client, base, sha) {
|
|
131
|
+
const combined = object(await read(client, base + '/commits/' + encodeURIComponent(sha) + '/status'));
|
|
132
|
+
if (combined.sha !== sha)
|
|
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');
|
|
135
|
+
statuses.sort((a, b) => Number(b.id) - Number(a.id));
|
|
136
|
+
const latest = new Map();
|
|
137
|
+
for (const status of statuses)
|
|
138
|
+
if (!latest.has(status.context))
|
|
139
|
+
latest.set(status.context, status);
|
|
140
|
+
const items = [...latest.values()].map(row => ({ name: row.context, state: row.status, description: row.description ?? null, url: row.target_url ?? null }));
|
|
141
|
+
const passed = items.filter(row => row.state === 'success').length;
|
|
142
|
+
const failed = items.filter(row => ['failure', 'error'].includes(String(row.state))).length;
|
|
143
|
+
const pending = items.length - passed - failed;
|
|
144
|
+
return { head: sha, items, passed, failed, pending, exitCode: !items.length || failed ? 1 : pending ? 8 : 0 };
|
|
145
|
+
}
|
|
146
|
+
export async function watchChecks(client, base, number, options) {
|
|
147
|
+
const seconds = positive(options.timeout, 30, 3600);
|
|
148
|
+
const interval = positive(options.interval, 5, 60) * 1000;
|
|
149
|
+
const stop = Date.now() + seconds * 1000;
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const cancel = () => controller.abort();
|
|
152
|
+
process.once('SIGINT', cancel);
|
|
153
|
+
process.once('SIGTERM', cancel);
|
|
154
|
+
try {
|
|
155
|
+
while (true) {
|
|
156
|
+
const pull = object(await read(client, base + '/pulls/' + number));
|
|
157
|
+
const sha = object(pull.head).sha;
|
|
158
|
+
if (typeof sha !== 'string' || !sha)
|
|
159
|
+
throw new CliError('INVALID_RESPONSE', 'Pull request head SHA is missing');
|
|
160
|
+
const result = await checks(client, base, sha);
|
|
161
|
+
if (!options.watch || !result.pending || result.failed || Date.now() >= stop || controller.signal.aborted)
|
|
162
|
+
return { ...result, cancelled: controller.signal.aborted };
|
|
163
|
+
await delay(Math.min(interval, Math.max(1, stop - Date.now())), undefined, { signal: controller.signal }).catch(error => {
|
|
164
|
+
if (!controller.signal.aborted)
|
|
165
|
+
throw error;
|
|
166
|
+
});
|
|
167
|
+
if (Date.now() >= stop || controller.signal.aborted)
|
|
168
|
+
return { ...result, cancelled: controller.signal.aborted };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
process.removeListener('SIGINT', cancel);
|
|
173
|
+
process.removeListener('SIGTERM', cancel);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export async function view(client, base, kind, number, options) {
|
|
177
|
+
const fields = selectedFields(options, kind === 'pr' ? prFields : issueFields);
|
|
178
|
+
const path = base + (kind === 'pr' ? '/pulls/' : '/issues/') + number;
|
|
179
|
+
const row = object(await read(client, path));
|
|
180
|
+
if (row.number !== Number(number))
|
|
181
|
+
throw new CliError('INVALID_RESPONSE', 'Response number does not match the selected item');
|
|
182
|
+
const result = issue(row, Boolean(options.full || fields));
|
|
183
|
+
const requested = new Set(fields ?? []);
|
|
184
|
+
if (options.comments || requested.has('comments'))
|
|
185
|
+
result.comments = (await complete(client, base + '/issues/' + number + '/comments')).map(comment => ({ id: comment.id, author: user(comment.user), body: preview(comment.body, Boolean(options.full || fields)), createdAt: comment.created_at ?? null }));
|
|
186
|
+
if (kind === 'pr') {
|
|
187
|
+
const head = object(row.head), tail = object(row.base);
|
|
188
|
+
if (typeof head.sha !== 'string' || !head.sha)
|
|
189
|
+
throw new CliError('INVALID_RESPONSE', 'Pull request head SHA is missing');
|
|
190
|
+
Object.assign(result, { headRefOid: head.sha, headRefName: head.ref ?? null, baseRefOid: tail.sha ?? null, baseRefName: tail.ref ?? null, isDraft: row.draft ?? null });
|
|
191
|
+
if (fields) {
|
|
192
|
+
for (const [field, key] of Object.entries(scalarFields))
|
|
193
|
+
if (requested.has(field))
|
|
194
|
+
result[field] = row[key] ?? null;
|
|
195
|
+
Object.assign(result, { closed: row.state === 'closed' || row.merged === true, state: row.merged ? 'MERGED' : String(row.state).toUpperCase(),
|
|
196
|
+
headRepository: head.repo == null ? null : { name: object(head.repo).name, nameWithOwner: object(head.repo).full_name },
|
|
197
|
+
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
|
+
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) });
|
|
200
|
+
}
|
|
201
|
+
if (options.reviews || requested.has('reviews') || requested.has('latestReviews')) {
|
|
202
|
+
const all = await reviews(client, path, Boolean(options.full || fields));
|
|
203
|
+
result.reviews = all;
|
|
204
|
+
if (requested.has('latestReviews')) {
|
|
205
|
+
const latest = new Map();
|
|
206
|
+
for (const review of [...all].sort((a, b) => Number(a.id) - Number(b.id)))
|
|
207
|
+
latest.set(review.author?.login, review);
|
|
208
|
+
result.latestReviews = [...latest.values()];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (options.checks || requested.has('statusCheckRollup'))
|
|
212
|
+
result.statusCheckRollup = await checks(client, base, head.sha);
|
|
213
|
+
if (requested.has('commits'))
|
|
214
|
+
result.commits = (await complete(client, path + '/commits')).map(commit => ({ oid: commit.sha, message: object(commit.commit).message }));
|
|
215
|
+
if (requested.has('files'))
|
|
216
|
+
result.files = (await complete(client, path + '/files')).map(file => ({ path: file.filename, additions: file.additions, deletions: file.deletions, status: file.status }));
|
|
217
|
+
if (requested.has('reactionGroups')) {
|
|
218
|
+
const groups = new Map();
|
|
219
|
+
for (const reaction of await complete(client, base + '/issues/' + number + '/reactions'))
|
|
220
|
+
groups.set(reaction.content, [...(groups.get(reaction.content) ?? []), reaction]);
|
|
221
|
+
result.reactionGroups = [...groups].map(([content, reactions]) => ({ content, users: { totalCount: reactions.length } }));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return fields ? Object.fromEntries(fields.map(field => [field, result[field] ?? null])) : result;
|
|
225
|
+
}
|
|
226
|
+
export function boundedList(page, limit, fields) {
|
|
227
|
+
return { items: page.items.slice(0, limit).map(row => Object.fromEntries(fields.map(key => [key, row[key] ?? null]))),
|
|
228
|
+
count: Math.min(page.items.length, limit), total: page.total, next: page.next,
|
|
229
|
+
truncated: page.truncated || page.items.length > limit, continuation: page.truncated || page.items.length > limit };
|
|
230
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const command = {
|
|
2
|
+
path: ['repo', 'view'],
|
|
3
|
+
description: 'Read repository identity and default branch with server contract metadata.',
|
|
4
|
+
commandIds: ['C075'],
|
|
5
|
+
operations: ['OP-001', 'OP-002', 'OP-004'],
|
|
6
|
+
outcomes: ['AC-001', 'AC-002', 'AC-004'],
|
|
7
|
+
effect: 'read',
|
|
8
|
+
flags: {
|
|
9
|
+
repo: { type: 'string', default: 'matching local origin remote', description: 'Repository as OWNER/NAME' },
|
|
10
|
+
origin: { type: 'string', default: 'selected profile', description: 'Gitea origin; HTTPS by default' },
|
|
11
|
+
'allow-insecure-http': {
|
|
12
|
+
type: 'boolean', default: false,
|
|
13
|
+
description: 'Allow HTTP only for the explicitly selected origin',
|
|
14
|
+
},
|
|
15
|
+
help: { type: 'boolean', default: false, description: 'Show command help' },
|
|
16
|
+
},
|
|
17
|
+
examples: [
|
|
18
|
+
'gitea-axi repo view --repo sample-owner/sample-repo --origin https://gitea.example.test',
|
|
19
|
+
'gitea-axi repo view --help',
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
export const limits = { timeoutMs: 30_000, responseBytes: 2_000_000 };
|
|
23
|
+
const globalFlags = {
|
|
24
|
+
origin: command.flags.origin, 'allow-insecure-http': command.flags['allow-insecure-http'], help: command.flags.help,
|
|
25
|
+
config: { type: 'string', description: 'Absolute configuration path' },
|
|
26
|
+
profile: { type: 'string', description: 'Select one file profile' },
|
|
27
|
+
};
|
|
28
|
+
const repo = { repo: { type: 'string', description: 'OWNER/NAME; otherwise a matching local origin remote' } };
|
|
29
|
+
const list = {
|
|
30
|
+
limit: { type: 'string', description: 'Maximum returned records, default 30, maximum 1000' },
|
|
31
|
+
state: { type: 'string', description: 'open, closed or all; default open' },
|
|
32
|
+
sort: { type: 'string', description: 'created, updated or number; default created' },
|
|
33
|
+
order: { type: 'string', description: 'asc or desc; default desc' },
|
|
34
|
+
fields: { type: 'string', description: 'Comma-separated selected fields' },
|
|
35
|
+
};
|
|
36
|
+
const detail = {
|
|
37
|
+
fields: list.fields, full: { type: 'boolean', description: 'Full body within response ceiling' },
|
|
38
|
+
comments: { type: 'boolean', description: 'Include comments' },
|
|
39
|
+
};
|
|
40
|
+
const definitions = [
|
|
41
|
+
['dashboard', repo], ['auth status', {}], ['profile list', {}], ['config path', {}], ['doctor', {}],
|
|
42
|
+
['repo list', { limit: list.limit, org: { type: 'string', description: 'List repositories belonging to this organization' } }],
|
|
43
|
+
['org view', {}], ['issue list', { ...repo, ...list, assignee: { type: 'string' }, author: { type: 'string' }, label: { type: 'string' }, milestone: { type: 'string' } }],
|
|
44
|
+
['issue view', { ...repo, ...detail }], ['label list', { ...repo, limit: list.limit }],
|
|
45
|
+
['pr list', { ...repo, ...list, assignee: { type: 'string' }, author: { type: 'string' }, label: { type: 'string' }, milestone: { type: 'string' } }],
|
|
46
|
+
['pr view', { ...repo, ...detail, reviews: { type: 'boolean' }, checks: { type: 'boolean' } }],
|
|
47
|
+
['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' } }],
|
|
48
|
+
['pr diff', { ...repo, full: detail.full }], ['run list', { ...repo, limit: list.limit }],
|
|
49
|
+
['release list', { ...repo, limit: list.limit }], ['api', {}],
|
|
50
|
+
];
|
|
51
|
+
export const commands = [
|
|
52
|
+
{ ...command, flags: { ...command.flags, ...globalFlags } },
|
|
53
|
+
...definitions.map(([name, flags]) => ({ path: name.split(' '), description: name === 'dashboard' ? 'Bounded repository dashboard with explicit partial results' : name,
|
|
54
|
+
flags: { ...globalFlags, ...flags }, effect: 'read' })),
|
|
55
|
+
];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { Ajv } from 'ajv';
|
|
3
|
+
import { routes } from './vendor.generated.js';
|
|
4
|
+
import { get } from './transport.js';
|
|
5
|
+
import { CliError } from './error.js';
|
|
6
|
+
const schemas = JSON.parse(readFileSync(new URL('../schemas/responses.json', import.meta.url), 'utf8'));
|
|
7
|
+
const ajv = new Ajv();
|
|
8
|
+
const validateRepository = ajv.compile(schemas.Repository);
|
|
9
|
+
const validateVersion = ajv.compile(schemas.ServerVersion);
|
|
10
|
+
const validateSpecification = ajv.compile(schemas.Specification);
|
|
11
|
+
export async function readRepository(origin, repo, token) {
|
|
12
|
+
const version = await get(origin, routes.version, token);
|
|
13
|
+
if (!validateVersion(version))
|
|
14
|
+
throw new CliError('INVALID_RESPONSE', 'Gitea version response is invalid');
|
|
15
|
+
const specification = await get(origin, routes.specification, token);
|
|
16
|
+
if (!validateSpecification(specification)) {
|
|
17
|
+
throw new CliError('INVALID_RESPONSE', 'Gitea specification response is invalid');
|
|
18
|
+
}
|
|
19
|
+
const [owner, name] = repo.split('/');
|
|
20
|
+
const path = routes.repository.replace('{owner}', encodeURIComponent(owner)).replace('{repo}', encodeURIComponent(name));
|
|
21
|
+
const repository = await get(origin, path, token);
|
|
22
|
+
if (!validateRepository(repository) || repository.full_name !== repo || !repository.default_branch) {
|
|
23
|
+
throw new CliError('INVALID_RESPONSE', 'Gitea repository identity or default branch is invalid');
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
server: {
|
|
27
|
+
version: version.version,
|
|
28
|
+
specification_version: specification.info.version,
|
|
29
|
+
specification_paths: Object.keys(specification.paths).length,
|
|
30
|
+
},
|
|
31
|
+
repository: {
|
|
32
|
+
id: repository.id,
|
|
33
|
+
full_name: repository.full_name,
|
|
34
|
+
default_branch: repository.default_branch,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { limits } from './registry.js';
|
|
2
|
+
import { CliError } from './error.js';
|
|
3
|
+
export class Client {
|
|
4
|
+
origin;
|
|
5
|
+
token;
|
|
6
|
+
deadline;
|
|
7
|
+
constructor(origin, token, timeoutMs = limits.timeoutMs) {
|
|
8
|
+
this.origin = origin;
|
|
9
|
+
this.token = token;
|
|
10
|
+
this.deadline = Date.now() + timeoutMs;
|
|
11
|
+
}
|
|
12
|
+
async request(path, text = false) {
|
|
13
|
+
const url = new URL(path, this.origin);
|
|
14
|
+
if (url.origin !== this.origin || !path.startsWith('/') || path.startsWith('//'))
|
|
15
|
+
throw new CliError('UNSAFE_PATH', 'Request must stay within the selected origin');
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(url, {
|
|
18
|
+
headers: { accept: text ? 'text/plain' : 'application/json', ...(this.token ? { authorization: 'token ' + this.token } : {}) },
|
|
19
|
+
redirect: 'error',
|
|
20
|
+
signal: AbortSignal.timeout(Math.max(1, this.deadline - Date.now())),
|
|
21
|
+
});
|
|
22
|
+
if (response.ok && !text && !response.headers.get('content-type')?.includes('application/json')) {
|
|
23
|
+
await response.body?.cancel();
|
|
24
|
+
throw new CliError('INVALID_RESPONSE', 'Gitea did not return JSON');
|
|
25
|
+
}
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let bytes = 0;
|
|
28
|
+
for await (const chunk of response.body ?? []) {
|
|
29
|
+
bytes += chunk.byteLength;
|
|
30
|
+
if (bytes > limits.responseBytes) {
|
|
31
|
+
throw new CliError('RESPONSE_LIMIT', 'Gitea response exceeded the byte limit');
|
|
32
|
+
}
|
|
33
|
+
chunks.push(chunk);
|
|
34
|
+
}
|
|
35
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
let reason = '';
|
|
38
|
+
if (response.headers.get('content-type')?.includes('application/json')) {
|
|
39
|
+
try {
|
|
40
|
+
const error = JSON.parse(body);
|
|
41
|
+
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string')
|
|
42
|
+
reason = ': ' + error.message.slice(0, 1000);
|
|
43
|
+
}
|
|
44
|
+
catch { /* HTTP status remains authoritative for malformed errors. */ }
|
|
45
|
+
}
|
|
46
|
+
const retry = response.headers.get('retry-after');
|
|
47
|
+
const code = response.status === 401 ? 'AUTH_REQUIRED' : response.status === 403 ? 'PERMISSION_DENIED' :
|
|
48
|
+
response.status === 404 ? 'NOT_FOUND_OR_HIDDEN' : response.status === 429 ? 'RATE_LIMITED' : 'HTTP_ERROR';
|
|
49
|
+
throw new CliError(code, `${path.split('?')[0]} returned HTTP ${response.status}${reason}${retry ? '; retry-after ' + retry : ''}`);
|
|
50
|
+
}
|
|
51
|
+
return { data: text ? body : JSON.parse(body), headers: response.headers };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof CliError)
|
|
55
|
+
throw error;
|
|
56
|
+
if (error instanceof SyntaxError)
|
|
57
|
+
throw new CliError('INVALID_RESPONSE', 'Gitea returned malformed JSON');
|
|
58
|
+
throw new CliError('TRANSPORT_ERROR', 'Gitea request failed; check the selected origin and connection');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async get(path) { return (await this.request(path)).data; }
|
|
62
|
+
}
|
|
63
|
+
export async function get(origin, path, token) {
|
|
64
|
+
return new Client(origin, token).get(path);
|
|
65
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
command: gitea-axi repo view
|
|
2
|
+
description: Read repository identity and default branch with server contract metadata.
|
|
3
|
+
flags[4]{flag,type,requirement,description}:
|
|
4
|
+
"--repo",string,default matching local origin remote,Repository as OWNER/NAME
|
|
5
|
+
"--origin",string,default selected profile,Gitea origin; HTTPS by default
|
|
6
|
+
"--allow-insecure-http",boolean,default false,Allow HTTP only for the explicitly selected origin
|
|
7
|
+
"--help",boolean,default false,Show command help
|
|
8
|
+
limits:
|
|
9
|
+
timeoutMs: 30000
|
|
10
|
+
responseBytes: 2000000
|
|
11
|
+
examples[2]: "gitea-axi repo view --repo sample-owner/sample-repo --origin https://gitea.example.test",gitea-axi repo view --help
|