@knowttl/gitea-axi 0.0.0-stage → 0.1.1
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 +414 -2
- package/axi-credential.schema.json +28 -0
- package/dist/action-values.js +37 -0
- package/dist/api.js +195 -0
- package/dist/attachments.js +171 -0
- package/dist/catalogue.js +65 -0
- package/dist/ci.js +24 -0
- package/dist/cli.js +11 -0
- package/dist/credentials.js +196 -0
- package/dist/error.js +9 -0
- package/dist/git.js +89 -0
- package/dist/local-workflows.js +222 -0
- package/dist/main.js +376 -0
- package/dist/private-file.js +51 -0
- package/dist/pulls.js +299 -0
- package/dist/reads.js +236 -0
- package/dist/registry.js +189 -0
- package/dist/releases.js +145 -0
- package/dist/repo-workflows.js +147 -0
- package/dist/repository.js +37 -0
- package/dist/search.js +111 -0
- package/dist/stacks.js +486 -0
- package/dist/transport.js +120 -0
- package/dist/vendor.generated.js +5 -0
- package/dist/writes.js +547 -0
- package/generated/SKILL.md +141 -0
- package/generated/capabilities.json +964 -0
- package/generated/help.txt +5387 -0
- package/generated/host-inventory.json +7641 -0
- package/generated/routes.json +5096 -0
- package/package.json +46 -4
- package/schemas/catalogue.json +2672 -0
- package/schemas/responses.json +63 -0
- package/schemas/vendor.json +7130 -0
package/dist/pulls.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { collect, object, read } from './api.js';
|
|
2
|
+
import { CliError } from './error.js';
|
|
3
|
+
const usage = (message) => { throw new CliError('USAGE', message, 2); };
|
|
4
|
+
const gap = (message) => { throw new CliError('CAPABILITY_GAP', message); };
|
|
5
|
+
const names = (value) => value === undefined ? [] : [...new Set((Array.isArray(value) ? value : [value]).map(String))];
|
|
6
|
+
const sorted = (values) => [...values].sort();
|
|
7
|
+
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
8
|
+
const delta = (previous, add, remove) => [...new Set([...previous.map(String), ...names(add)])].filter(value => !names(remove).includes(value));
|
|
9
|
+
const members = (pull, field, key) => (pull[field] ?? []).map(row => object(row)[key]);
|
|
10
|
+
export function validatePull(operation, options) {
|
|
11
|
+
if (operation === 'pr review') {
|
|
12
|
+
if (['approve', 'request-changes', 'comment'].filter(key => options[key]).length !== 1)
|
|
13
|
+
usage('Choose exactly one of --approve, --request-changes or --comment');
|
|
14
|
+
gap('C043 unresolved: review wire events and atomic stale-head refusal lack vendor evidence; no review will be posted, including with --match-head-commit');
|
|
15
|
+
}
|
|
16
|
+
if (operation === 'pr merge') {
|
|
17
|
+
const styles = ['merge', 'squash', 'rebase'].filter(key => options[key]);
|
|
18
|
+
if (styles.length + Number(options.method !== undefined) !== 1)
|
|
19
|
+
usage('Choose exactly one of --method, --merge, --squash or --rebase');
|
|
20
|
+
if (!['merge', 'squash', 'rebase'].includes(String(options.method ?? styles[0])))
|
|
21
|
+
usage('Supported merge methods: merge, squash, rebase');
|
|
22
|
+
if (!/^[a-f0-9]{40}$/.test(String(options['match-head-commit'] ?? '')))
|
|
23
|
+
usage('--match-head-commit requires the full 40-character head SHA');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function fields(pull) {
|
|
27
|
+
return { title: pull.title, body: pull.body ?? '', state: pull.state, head: object(pull.head).ref, base: object(pull.base).ref,
|
|
28
|
+
assignees: sorted(members(pull, 'assignees', 'login')), labels: sorted(members(pull, 'labels', 'id')),
|
|
29
|
+
milestone: pull.milestone == null ? null : object(pull.milestone).id,
|
|
30
|
+
reviewers: sorted(members(pull, 'requested_reviewers', 'login')), teams: sorted(members(pull, 'requested_reviewers_teams', 'name')) };
|
|
31
|
+
}
|
|
32
|
+
export async function pullPlan(client, operation, base, number, options) {
|
|
33
|
+
const action = operation.split(' ')[1];
|
|
34
|
+
const path = base + '/pulls' + (number ? '/' + number : '');
|
|
35
|
+
const steps = [];
|
|
36
|
+
const preconditions = {};
|
|
37
|
+
const reads = new Set();
|
|
38
|
+
const snapshot = async (path, list = false) => {
|
|
39
|
+
let value;
|
|
40
|
+
if (list) {
|
|
41
|
+
const page = await collect(client, path);
|
|
42
|
+
if (page.truncated)
|
|
43
|
+
throw new CliError('INCOMPLETE_READ', 'A complete collection is required before writing');
|
|
44
|
+
value = page.items;
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
value = await read(client, path);
|
|
48
|
+
preconditions[path] = value;
|
|
49
|
+
reads.add(path);
|
|
50
|
+
return value;
|
|
51
|
+
};
|
|
52
|
+
const add = (method, path, payload) => steps.push({ method, path, ...(payload === undefined ? {} : { payload }) });
|
|
53
|
+
const before = action === 'create' ? undefined : object(await snapshot(path));
|
|
54
|
+
const expected = before ? { id: before.id, number: before.number, ...fields(before), merged: before.merged ?? false, headSHA: object(before.head).sha } :
|
|
55
|
+
{ title: '', body: '', state: 'open', merged: false, assignees: [], labels: [], milestone: null, reviewers: [], teams: [] };
|
|
56
|
+
if (action === 'create')
|
|
57
|
+
await snapshot(path + '?state=all', true);
|
|
58
|
+
if (action === 'create' || action === 'edit') {
|
|
59
|
+
for (const [flag, collection, key] of [['assignee', 'assignees', 'login'], ['team-reviewer', 'teams', 'name']]) {
|
|
60
|
+
const requested = action === 'create' ? names(options[flag]) : [...names(options['add-' + flag]), ...names(options['remove-' + flag])];
|
|
61
|
+
if (!requested.length)
|
|
62
|
+
continue;
|
|
63
|
+
const available = await snapshot(base + '/' + collection, true);
|
|
64
|
+
for (const name of requested) {
|
|
65
|
+
const selected = available.find(row => row[key] === name);
|
|
66
|
+
if (!selected)
|
|
67
|
+
usage('Unknown exact ' + flag + ' name: ' + name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const addedReviewers = names(action === 'create' ? options.reviewer : options['add-reviewer']);
|
|
71
|
+
const requestedReviewers = [...new Set([...addedReviewers, ...names(options['remove-reviewer'])])];
|
|
72
|
+
if (requestedReviewers.length) {
|
|
73
|
+
const author = object(action === 'create' ? await snapshot('/api/v1/user') : before.user);
|
|
74
|
+
for (const name of requestedReviewers) {
|
|
75
|
+
let reviewer;
|
|
76
|
+
try {
|
|
77
|
+
reviewer = object(await snapshot('/api/v1/users/' + encodeURIComponent(name)));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof CliError && error.code === 'NOT_FOUND_OR_HIDDEN')
|
|
81
|
+
usage('Unknown exact reviewer name: ' + name);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
if (reviewer.login !== name)
|
|
85
|
+
usage('Unknown exact reviewer name: ' + name);
|
|
86
|
+
if (addedReviewers.includes(name) && reviewer.id === author.id)
|
|
87
|
+
usage('The PR author cannot be requested as a reviewer');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const payload = {};
|
|
91
|
+
for (const field of ['title', 'body', 'base'])
|
|
92
|
+
if (options[field] !== undefined)
|
|
93
|
+
payload[field] = options[field];
|
|
94
|
+
if (action === 'create') {
|
|
95
|
+
for (const field of ['title', 'base', 'head'])
|
|
96
|
+
if (!options[field])
|
|
97
|
+
usage('--' + field + ' is required');
|
|
98
|
+
if (String(options.head).includes(':'))
|
|
99
|
+
gap('Cross-repository PR creation requires separate head repository verification');
|
|
100
|
+
payload.head = options.head;
|
|
101
|
+
payload.assignees = names(options.assignee);
|
|
102
|
+
expected.reviewers = sorted(addedReviewers);
|
|
103
|
+
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;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
if (options['add-assignee'] !== undefined || options['remove-assignee'] !== undefined)
|
|
109
|
+
payload.assignees = delta(expected.assignees, options['add-assignee'], options['remove-assignee']);
|
|
110
|
+
}
|
|
111
|
+
if (options.base !== undefined)
|
|
112
|
+
await snapshot(base + '/branches/' + encodeURIComponent(String(options.base)));
|
|
113
|
+
if (options.milestone !== undefined) {
|
|
114
|
+
const list = await snapshot(base + '/milestones?state=all', true);
|
|
115
|
+
const selected = list.find(row => row.title === options.milestone);
|
|
116
|
+
if (!selected)
|
|
117
|
+
usage('Unknown exact milestone title');
|
|
118
|
+
payload.milestone = selected.id;
|
|
119
|
+
}
|
|
120
|
+
if (options.label !== undefined || options['add-label'] !== undefined || options['remove-label'] !== undefined) {
|
|
121
|
+
const labels = await snapshot(base + '/labels', true);
|
|
122
|
+
const ids = (value) => names(value).map(name => {
|
|
123
|
+
const label = labels.find(row => row.name === name);
|
|
124
|
+
if (!label)
|
|
125
|
+
return usage('Unknown exact label name: ' + name);
|
|
126
|
+
return label.id;
|
|
127
|
+
});
|
|
128
|
+
payload.labels = action === 'create' ? ids(options.label) : [...new Set([...expected.labels, ...ids(options['add-label'])])].filter(id => !ids(options['remove-label']).includes(id));
|
|
129
|
+
}
|
|
130
|
+
for (const field of ['title', 'body', 'base', 'head', 'assignees', 'labels', 'milestone'])
|
|
131
|
+
if (payload[field] !== undefined)
|
|
132
|
+
expected[field] = payload[field];
|
|
133
|
+
if (action === 'create') {
|
|
134
|
+
add('POST', path, payload);
|
|
135
|
+
if (addedReviewers.length || names(options['team-reviewer']).length)
|
|
136
|
+
steps.push({ method: 'POST', path: path + '/0/requested_reviewers',
|
|
137
|
+
payload: { reviewers: addedReviewers, team_reviewers: names(options['team-reviewer']) }, createdPull: true });
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
for (const [flag, field, wire] of [['reviewer', 'reviewers', 'reviewers'], ['team-reviewer', 'teams', 'team_reviewers']]) {
|
|
141
|
+
const previous = expected[field];
|
|
142
|
+
const next = delta(previous, options['add-' + flag], options['remove-' + flag]);
|
|
143
|
+
const added = next.filter(name => !previous.includes(name));
|
|
144
|
+
const removed = previous.filter(name => !next.includes(String(name)));
|
|
145
|
+
if (added.length)
|
|
146
|
+
add('POST', path + '/requested_reviewers', { [wire]: added });
|
|
147
|
+
if (removed.length)
|
|
148
|
+
add('DELETE', path + '/requested_reviewers', { [wire]: removed });
|
|
149
|
+
expected[field] = next;
|
|
150
|
+
}
|
|
151
|
+
if (Object.keys(payload).length)
|
|
152
|
+
add('PATCH', path, payload);
|
|
153
|
+
}
|
|
154
|
+
if (!steps.length && !options.attach)
|
|
155
|
+
usage('Choose a PR field or membership to edit');
|
|
156
|
+
}
|
|
157
|
+
else if (action === 'close' || action === 'reopen') {
|
|
158
|
+
expected.state = action === 'close' ? 'closed' : 'open';
|
|
159
|
+
add('PATCH', path, { state: expected.state });
|
|
160
|
+
}
|
|
161
|
+
else if (action === 'merge') {
|
|
162
|
+
if (expected.headSHA !== options['match-head-commit'])
|
|
163
|
+
throw new CliError('STALE_HEAD', 'Selected PR head differs from --match-head-commit');
|
|
164
|
+
if (before.state !== 'open' || before.merged)
|
|
165
|
+
usage('Select an open unmerged PR');
|
|
166
|
+
const style = options.method ?? ['merge', 'squash', 'rebase'].find(key => options[key]);
|
|
167
|
+
const payload = { Do: style, head_commit_id: options['match-head-commit'] };
|
|
168
|
+
if (options.subject !== undefined)
|
|
169
|
+
payload.MergeTitleField = options.subject;
|
|
170
|
+
if (options.body !== undefined)
|
|
171
|
+
payload.MergeMessageField = options.body;
|
|
172
|
+
if (style === 'rebase')
|
|
173
|
+
await snapshot(path + '/commits', true);
|
|
174
|
+
await snapshot(base + '/branches/' + encodeURIComponent(String(expected.base)));
|
|
175
|
+
add('POST', path + '/merge', payload);
|
|
176
|
+
expected.state = 'closed';
|
|
177
|
+
}
|
|
178
|
+
else if (action === 'update-branch') {
|
|
179
|
+
await snapshot(base + '/branches/' + encodeURIComponent(String(expected.base)));
|
|
180
|
+
add('POST', path + '/update');
|
|
181
|
+
}
|
|
182
|
+
if (action === 'comment' || action === 'close' && options.comment !== undefined) {
|
|
183
|
+
const body = action === 'comment' ? options.body : options.comment;
|
|
184
|
+
if (body === undefined || body === '')
|
|
185
|
+
usage('--body is required');
|
|
186
|
+
const comments = base + '/issues/' + number + '/comments';
|
|
187
|
+
await snapshot(comments, true);
|
|
188
|
+
add('POST', comments, { body });
|
|
189
|
+
}
|
|
190
|
+
return { target: path, steps, reads: [...reads], preconditions, pull: { expected, action, repository: base } };
|
|
191
|
+
}
|
|
192
|
+
export async function verifyPull(client, plan, results, observations) {
|
|
193
|
+
const { expected, action, repository: base } = plan.pull;
|
|
194
|
+
const before = object(plan.preconditions);
|
|
195
|
+
const mismatch = () => { throw new CliError('READBACK_MISMATCH', 'PR read-back did not confirm identity, requested fields or commit graph'); };
|
|
196
|
+
const observation = (path) => observations.find(row => row.path === path)?.data;
|
|
197
|
+
let pull;
|
|
198
|
+
if (action === 'create') {
|
|
199
|
+
const result = object(results[0]);
|
|
200
|
+
pull = observation(plan.target + '?state=all').find(row => row.id === result.id && row.number === result.number) ?? mismatch();
|
|
201
|
+
if (before[plan.target + '?state=all'].some(row => row.id === pull.id || row.number === pull.number))
|
|
202
|
+
mismatch();
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
pull = object(observation(plan.target));
|
|
206
|
+
if (pull.id !== expected.id || pull.number !== expected.number)
|
|
207
|
+
mismatch();
|
|
208
|
+
}
|
|
209
|
+
const actual = fields(pull);
|
|
210
|
+
for (const field of Object.keys(actual)) {
|
|
211
|
+
const value = ['assignees', 'labels', 'reviewers', 'teams'].includes(field) ? sorted(expected[field]) : expected[field];
|
|
212
|
+
if (!equal(actual[field], value))
|
|
213
|
+
mismatch();
|
|
214
|
+
}
|
|
215
|
+
if (action !== 'update-branch' && object(pull.head).sha !== expected.headSHA)
|
|
216
|
+
mismatch();
|
|
217
|
+
if (action !== 'merge' && (pull.merged ?? false) !== expected.merged)
|
|
218
|
+
mismatch();
|
|
219
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
220
|
+
if (!step.path.endsWith('/comments'))
|
|
221
|
+
continue;
|
|
222
|
+
const comments = observation(step.path);
|
|
223
|
+
const previous = before[step.path];
|
|
224
|
+
const id = object(results[index]).id;
|
|
225
|
+
const added = comments.filter(row => !previous.some(old => old.id === row.id));
|
|
226
|
+
if (added.length !== 1 || added[0].id !== id || added[0].body !== object(step.payload).body || previous.some(old => !comments.some(row => equal(row, old))))
|
|
227
|
+
mismatch();
|
|
228
|
+
}
|
|
229
|
+
if (action !== 'merge' && action !== 'update-branch')
|
|
230
|
+
return;
|
|
231
|
+
const branchPath = base + '/branches/' + encodeURIComponent(String(expected.base));
|
|
232
|
+
const baseSHA = object(object(before[branchPath]).commit).id;
|
|
233
|
+
const commit = async (sha) => {
|
|
234
|
+
if (typeof sha !== 'string' || !/^[a-f0-9]{40}$/.test(sha))
|
|
235
|
+
return mismatch();
|
|
236
|
+
const path = base + '/git/commits/' + sha;
|
|
237
|
+
const data = object(await read(client, path));
|
|
238
|
+
if (data.sha !== sha || !Array.isArray(data.parents))
|
|
239
|
+
mismatch();
|
|
240
|
+
observations.push({ path, data });
|
|
241
|
+
return data;
|
|
242
|
+
};
|
|
243
|
+
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))
|
|
249
|
+
continue;
|
|
250
|
+
visited.add(current.sha);
|
|
251
|
+
if (visited.has(baseSHA) && visited.has(expected.headSHA))
|
|
252
|
+
return;
|
|
253
|
+
for (const parent of members(current, 'parents', 'sha')) {
|
|
254
|
+
if (!visited.has(parent))
|
|
255
|
+
pending.push(await commit(parent));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
mismatch();
|
|
259
|
+
}
|
|
260
|
+
if (pull.merged !== true || !pull.merge_commit_sha)
|
|
261
|
+
mismatch();
|
|
262
|
+
const finalSHA = object(object(observation(branchPath)).commit).id;
|
|
263
|
+
if (finalSHA !== pull.merge_commit_sha)
|
|
264
|
+
mismatch();
|
|
265
|
+
const final = await commit(finalSHA);
|
|
266
|
+
const style = object(plan.steps[0].payload).Do;
|
|
267
|
+
if (style === 'merge') {
|
|
268
|
+
if (!equal(members(final, 'parents', 'sha'), [baseSHA, expected.headSHA]))
|
|
269
|
+
mismatch();
|
|
270
|
+
}
|
|
271
|
+
else if (style === 'squash') {
|
|
272
|
+
if (!equal(members(final, 'parents', 'sha'), [baseSHA]) || finalSHA === expected.headSHA)
|
|
273
|
+
mismatch();
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
const originals = before[plan.target + '/commits'];
|
|
277
|
+
if (!originals.length)
|
|
278
|
+
mismatch();
|
|
279
|
+
const bySHA = new Map(originals.map(row => [row.sha, row]));
|
|
280
|
+
let original = bySHA.get(expected.headSHA);
|
|
281
|
+
let current = final;
|
|
282
|
+
for (let index = 0; index < originals.length; index++) {
|
|
283
|
+
const parents = members(current, 'parents', 'sha');
|
|
284
|
+
if (!original || parents.length !== 1 || object(current.commit).message !== object(original.commit).message)
|
|
285
|
+
mismatch();
|
|
286
|
+
if (index === originals.length - 1) {
|
|
287
|
+
if (parents[0] !== baseSHA)
|
|
288
|
+
mismatch();
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
const originalParents = members(original, 'parents', 'sha');
|
|
292
|
+
if (originalParents.length !== 1)
|
|
293
|
+
mismatch();
|
|
294
|
+
original = bySHA.get(originalParents[0]);
|
|
295
|
+
current = await commit(parents[0]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
package/dist/reads.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
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, unavailable = gaps) {
|
|
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 => unavailable.includes(field));
|
|
22
|
+
if (gap)
|
|
23
|
+
throw new CliError('CAPABILITY_GAP', gap + ' has no evidenced Gitea 1.23.4 equivalent' + (gap === 'id' ? '; fullDatabaseId is the numeric vendor ID, not a GraphQL node ID' : ''));
|
|
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, unfiltered_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('/')}`, ...continuationHelp(page, rows.length, limit)] };
|
|
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 && !(combined.sha === '' && combined.total_count === 0 && (combined.statuses === null || Array.isArray(combined.statuses) && !combined.statuses.length)))
|
|
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.items.length > 0 && !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), source: 'commit-statuses', partial: true };
|
|
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 continuationHelp(page, count, limit) {
|
|
227
|
+
if (!page.truncated && count <= limit)
|
|
228
|
+
return [];
|
|
229
|
+
return [page.truncated ? 'Use gitea-axi api GET "<collection>?limit=50&page=<page>" to page beyond the bounded read (maximum 1000 items).' : 'Raise --limit to see more items (maximum 1000).'];
|
|
230
|
+
}
|
|
231
|
+
export function boundedList(page, limit, fields) {
|
|
232
|
+
return { items: page.items.slice(0, limit).map(row => Object.fromEntries(fields.map(key => [key, row[key] ?? null]))),
|
|
233
|
+
count: Math.min(page.items.length, limit), total: page.total, next: page.next,
|
|
234
|
+
truncated: page.truncated || page.items.length > limit, continuation: page.truncated || page.items.length > limit,
|
|
235
|
+
...(continuationHelp(page, page.items.length, limit).length ? { help: continuationHelp(page, page.items.length, limit) } : {}) };
|
|
236
|
+
}
|