@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.
@@ -0,0 +1,189 @@
1
+ import { baseline, commandGap, flagGap } from './catalogue.js';
2
+ export const command = {
3
+ path: ['repo', 'view'],
4
+ description: 'Read repository identity and default branch with server contract metadata.',
5
+ commandIds: ['C075'],
6
+ operations: ['OP-001', 'OP-002', 'OP-004'],
7
+ outcomes: ['AC-001', 'AC-002', 'AC-004'],
8
+ effect: 'read',
9
+ flags: {
10
+ repo: { type: 'string', short: 'R', default: 'matching local origin remote', description: 'Repository as OWNER/NAME' },
11
+ origin: { type: 'string', default: 'selected profile', description: 'Gitea origin; HTTPS by default' },
12
+ 'allow-insecure-http': {
13
+ type: 'boolean', default: false,
14
+ description: 'Allow HTTP only for the explicitly selected origin',
15
+ },
16
+ help: { type: 'boolean', default: false, description: 'Show command help' },
17
+ },
18
+ examples: [
19
+ 'gitea-axi repo view --repo sample-owner/sample-repo --origin https://gitea.example.test',
20
+ 'gitea-axi repo view --help',
21
+ ],
22
+ };
23
+ export const limits = { timeoutMs: 30_000, responseBytes: 2_000_000 };
24
+ const globalFlags = {
25
+ origin: command.flags.origin, 'allow-insecure-http': command.flags['allow-insecure-http'], help: command.flags.help,
26
+ config: { type: 'string', description: 'Absolute configuration path' },
27
+ profile: { type: 'string', description: 'Select one file profile' },
28
+ hostname: { type: 'string', description: 'Select exactly one matching configured host:port; otherwise GH_HOST' },
29
+ };
30
+ const repo = { repo: { type: 'string', short: 'R', description: 'OWNER/NAME; otherwise a matching local origin remote' } };
31
+ const list = {
32
+ limit: { type: 'string', description: 'Maximum returned records, default 30, maximum 1000' },
33
+ state: { type: 'string', description: 'open, closed or all; default open' },
34
+ sort: { type: 'string', description: 'created, updated or number; default created' },
35
+ order: { type: 'string', description: 'asc or desc; default desc' },
36
+ fields: { type: 'string', description: 'Comma-separated selected fields' },
37
+ };
38
+ const detail = {
39
+ fields: list.fields, full: { type: 'boolean', description: 'Full body within response ceiling' },
40
+ comments: { type: 'boolean', description: 'Include comments' },
41
+ };
42
+ const writeFlags = {
43
+ 'allow-write': { type: 'string', description: 'Comma-separated exact vendor operation IDs authorized for this workflow' },
44
+ preview: { type: 'boolean', description: 'Preview and bind this exact write without dispatch' },
45
+ confirm: { type: 'string', description: 'Execute the unchanged preview confirmation ID once' },
46
+ };
47
+ const text = { type: 'string' };
48
+ const repeated = { type: 'string', multiple: true };
49
+ const issueBody = { body: text, 'body-file': { type: 'string', description: 'Body from an owned file or - for bounded stdin' } };
50
+ const issueEdit = { title: text, ...issueBody, milestone: text };
51
+ const mutationDefinitions = [
52
+ ['issue upload', { file: repeated, 'content-type': text }], ['pr upload', { file: repeated, 'content-type': text }], ['release upload', { file: repeated, 'content-type': text }],
53
+ ['secret set', { org: text, input: { ...text, description: 'Protected value file or - for stdin; values never accepted in argv' } }],
54
+ ['secret delete', { org: text }],
55
+ ['variable set', { org: text, body: text, input: { ...text, description: 'Value file or - for stdin; preserves empty values' } }],
56
+ ['variable delete', { org: text }],
57
+ ['repo create', { public: { type: 'boolean' }, private: { type: 'boolean' }, internal: { type: 'boolean' }, description: text, template: text,
58
+ source: text, push: { type: 'boolean' }, remote: text, clone: { type: 'boolean' } }],
59
+ ['repo edit', { description: text, visibility: text, 'default-branch': text, 'enable-issues': { type: 'boolean' }, 'enable-wiki': { type: 'boolean' } }],
60
+ ['repo fork', { org: text, clone: { type: 'boolean' }, remote: text, source: text, push: { type: 'boolean' } }],
61
+ ['release create', { attach: repeated, 'content-type': text, title: { ...text, short: 't' }, notes: { ...text, short: 'n' }, 'notes-file': { ...text, short: 'F' }, ...issueBody,
62
+ draft: { type: 'boolean', short: 'd' }, prerelease: { type: 'boolean', short: 'p' }, target: text,
63
+ 'generate-notes': { type: 'boolean' }, 'notes-start-tag': text, 'notes-from-tag': { type: 'boolean' }, 'verify-tag': { type: 'boolean' },
64
+ 'fail-on-no-commits': { type: 'boolean' }, latest: { type: 'boolean' }, 'discussion-category': text }],
65
+ ['release edit', { attach: repeated, 'content-type': text, title: text, notes: { ...text, short: 'n' }, 'notes-file': { ...text, short: 'F' }, ...issueBody, draft: { type: 'boolean' }, prerelease: { type: 'boolean' }, latest: { type: 'boolean' } }],
66
+ ['release delete', {}],
67
+ ['pr create', { ...issueEdit, attach: repeated, 'content-type': text, base: text, head: text, assignee: repeated, label: repeated, reviewer: repeated, 'team-reviewer': repeated }],
68
+ ['pr edit', { ...issueEdit, attach: repeated, 'content-type': text, base: text, 'add-label': repeated, 'remove-label': repeated, 'add-assignee': repeated, 'remove-assignee': repeated, 'add-reviewer': repeated, 'remove-reviewer': repeated, 'add-team-reviewer': repeated, 'remove-team-reviewer': repeated }],
69
+ ['pr close', { comment: text }], ['pr reopen', {}], ['pr comment', { ...issueBody, attach: repeated, 'content-type': text }],
70
+ ['pr update-branch', {}],
71
+ ['pr merge', { method: text, merge: { type: 'boolean' }, squash: { type: 'boolean' }, rebase: { type: 'boolean' }, 'match-head-commit': text,
72
+ auto: { type: 'boolean' }, admin: { type: 'boolean' }, 'delete-branch': { type: 'boolean' }, subject: text, ...issueBody }],
73
+ ['pr review', { approve: { type: 'boolean' }, 'request-changes': { type: 'boolean' }, comment: { type: 'boolean' }, 'match-head-commit': text, ...issueBody }],
74
+ ['issue create', { ...issueEdit, attach: repeated, 'content-type': text, assignee: repeated, label: repeated }],
75
+ ['issue edit', { ...issueEdit, attach: repeated, 'content-type': text, 'add-label': repeated, 'remove-label': repeated, 'add-assignee': repeated, 'remove-assignee': repeated }],
76
+ ['issue close', { comment: text }], ['issue reopen', {}], ['issue comment', { ...issueBody, attach: repeated, 'content-type': text }],
77
+ ['issue delete', {}], ['issue pin', {}], ['issue unpin', {}],
78
+ ['label create', { name: text, color: text, description: text }],
79
+ ['label edit', { name: text, color: text, description: text }], ['label delete', {}],
80
+ ];
81
+ const definitions = [
82
+ ['issue download', { ...repo, dir: text, output: text, 'asset-id': text, pattern: repeated, sha256: text, 'content-type': text, clobber: { type: 'boolean' } }],
83
+ ['pr download', { ...repo, dir: text, output: text, 'asset-id': text, pattern: repeated, sha256: text, 'content-type': text, clobber: { type: 'boolean' } }],
84
+ ['release download', { ...repo, dir: text, output: text, 'asset-id': text, pattern: repeated, sha256: text, 'content-type': text, clobber: { type: 'boolean' } }],
85
+ ['secret list', { ...repo, org: text, limit: list.limit }], ['variable list', { ...repo, org: text, limit: list.limit }],
86
+ ['dashboard', repo], ['auth status', {}], ['profile list', {}], ['config path', {}], ['doctor', {}],
87
+ ['repo list', { limit: list.limit, org: { type: 'string', description: 'List repositories belonging to this organization' } }],
88
+ ['org view', {}], ['issue list', { ...repo, ...list, assignee: { type: 'string' }, author: { type: 'string' }, label: { type: 'string' }, milestone: { type: 'string' } }],
89
+ ['issue view', { ...repo, ...detail }], ['label list', { ...repo, limit: list.limit }],
90
+ ['pr list', { ...repo, ...list, assignee: { type: 'string' }, author: { type: 'string' }, label: { type: 'string' }, milestone: { type: 'string' } }],
91
+ ['pr view', { ...repo, ...detail, reviews: { type: 'boolean' }, checks: { type: 'boolean' } }],
92
+ ['pr checks', { ...repo, watch: { type: 'boolean' }, timeout: { type: 'string', description: 'Watch deadline seconds, required with --watch' }, interval: { type: 'string', description: 'Watch interval seconds, default 5' } }],
93
+ ['pr diff', { ...repo, full: detail.full }],
94
+ ['release list', { ...repo, limit: list.limit, 'exclude-drafts': { type: 'boolean' }, 'exclude-pre-releases': { type: 'boolean' } }],
95
+ ['release view', { ...repo, full: detail.full }], ['api', { ...writeFlags,
96
+ method: { type: 'string', short: 'X', description: 'HTTP method; cannot combine with a positional method' },
97
+ field: { type: 'string', multiple: true, description: 'Repeatable key=value; numbers, booleans and null become JSON values' },
98
+ 'raw-field': { type: 'string', multiple: true, description: 'Repeatable key=value string' },
99
+ input: { type: 'string', description: 'Protected JSON request file; use - for stdin' } }],
100
+ ['write reconcile', {}],
101
+ ];
102
+ export const commands = [
103
+ { ...command, flags: { ...command.flags, ...globalFlags } },
104
+ ...definitions.map(([name, flags]) => ({ path: name.split(' '), description: name === 'dashboard' ? 'Bounded repository dashboard with explicit partial results' : name,
105
+ flags: { ...globalFlags, ...flags }, effect: name.endsWith(' download') ? 'download' : 'read' })),
106
+ ...mutationDefinitions.map(([name, flags]) => ({ path: name.split(' '), description: name === 'pr review' ? 'pr review (unresolved vendor events and atomic stale-head refusal; no posting)' : name,
107
+ flags: { ...globalFlags, ...repo, ...writeFlags, ...flags }, effect: 'write' })),
108
+ ];
109
+ const strings = (...names) => Object.fromEntries(names.map(name => [name, { type: 'string' }]));
110
+ const booleans = (...names) => Object.fromEntries(names.map(name => [name, { type: 'boolean' }]));
111
+ const stageFive = [
112
+ ['run list', strings('workflow', 'branch', 'status', 'event', 'user', 'commit', 'limit', 'fields'), 'read'],
113
+ ['run view', { ...strings('job', 'conclusion'), ...booleans('log', 'verbose', 'log-failed') }, 'read'],
114
+ ['run watch', strings('timeout', 'interval'), 'read'],
115
+ ['run rerun', { ...strings('job'), ...booleans('failed', 'debug') }, 'pending-write'],
116
+ ['run cancel', {}, 'pending-write'], ['run delete', {}, 'pending-write'],
117
+ ['run download', strings('name', 'dir'), 'download'],
118
+ ['workflow list', { ...strings('limit'), ...booleans('all') }, 'read'],
119
+ ['workflow view', booleans('yaml'), 'read'],
120
+ ['workflow run', strings('ref', 'field'), 'pending-write'],
121
+ ['workflow enable', {}, 'pending-write'], ['workflow disable', {}, 'pending-write'],
122
+ ...['issues', 'prs', 'repos', 'commits', 'code'].map((kind) => [
123
+ 'search ' + kind,
124
+ { ...strings('owner', 'state', 'label', 'assignee', 'author', 'sort', 'order', 'limit', 'fields', 'review', 'language', 'stars', 'revision'), ...booleans('draft') },
125
+ 'read',
126
+ ]),
127
+ ];
128
+ commands.push(...stageFive.map(([name, flags, effect]) => ({ path: name.split(' '), description: name + (effect === 'pending-write' ? ' (pending shared write gate)' : ''), flags: { ...globalFlags, ...repo, ...flags }, effect })));
129
+ const workspace = { workspace: { ...text, description: 'Absolute native Git repository root; clone uses an existing absolute parent directory' } };
130
+ const stackDefinitions = [
131
+ ['view', {}], ['init', strings('base')], ['add', { ...strings('message'), ...booleans('all', 'update') }],
132
+ ['checkout', {}], ['push', strings('remote')], ['submit', { ...strings('remote'), ...booleans('open') }],
133
+ ['sync', { ...strings('remote'), ...booleans('prune') }],
134
+ ['rebase', { ...strings('remote'), ...booleans('downstack', 'upstack', 'no-trunk', 'continue', 'abort', 'committer-date-is-author-date', 'preserve-dates') }],
135
+ ['link', { ...strings('base', 'remote'), ...booleans('open') }], ['unstack', booleans('local')],
136
+ ['merge', { ...strings('merge-method'), ...booleans('merge', 'squash', 'rebase') }],
137
+ ...['up', 'down', 'top', 'bottom', 'trunk'].map((name) => [name, {}]),
138
+ ];
139
+ commands.push(...stackDefinitions.map(([name, flags]) => ({ path: ['stack', name], description: 'Owned local stack ' + name,
140
+ flags: { ...globalFlags, ...workspace, ...writeFlags, ...flags }, effect: name === 'view' ? 'local-read' : 'local-write' })));
141
+ commands.push(...['repo clone', 'pr checkout', 'pr revert'].map(name => ({ path: name.split(' '), description: name + ' in an explicit standalone workspace',
142
+ flags: { ...globalFlags, ...repo, ...workspace, ...writeFlags }, effect: 'local-write' })));
143
+ for (const command of commands) {
144
+ if (['repo create', 'repo fork'].includes(command.path.join(' ')))
145
+ Object.assign(command.flags, workspace);
146
+ }
147
+ commands.push({ path: ['capabilities'], description: 'Inspect all baseline command IDs, flags, globals and PR fields without claiming full parity', flags: { help: globalFlags.help }, effect: 'local-read' });
148
+ const refusedFlags = [
149
+ ['repo create', ['internal']],
150
+ ['release create', ['latest', 'discussion-category']],
151
+ ['release edit', ['latest']],
152
+ ['pr merge', ['auto', 'admin', 'delete-branch']],
153
+ ['run list', ['user']],
154
+ ['search issues', ['review', 'language', 'stars', 'revision', 'draft']],
155
+ ['search prs', ['review', 'language', 'stars', 'revision']],
156
+ ['search repos', ['state', 'label', 'assignee', 'author', 'review', 'revision', 'draft']],
157
+ ['search commits', ['owner', 'state', 'label', 'assignee', 'review', 'language', 'stars', 'draft']],
158
+ ['stack rebase', ['preserve-dates']],
159
+ ['stack link', ['open']],
160
+ ['stack submit', ['open']],
161
+ ];
162
+ for (const [path, names] of refusedFlags) {
163
+ const command = commands.find(command => command.path.join(' ') === path);
164
+ for (const name of names)
165
+ command.flags[name] = { ...command.flags[name], gap: flagGap(path, name) };
166
+ }
167
+ // Retain unsupported leaves and options in the same registry that owns help and admission metadata.
168
+ const booleanFlags = new Set(['comments', 'full', 'public', 'secret', 'files', 'raw', 'web', 'no-type', 'draft', 'failed', 'fail-fast', 'patch', 'undo', 'all', 'prune', 'open', 'local', 'merge', 'squash', 'rebase', 'downstack', 'upstack', 'no-trunk', 'continue', 'abort', 'committer-date-is-author-date', 'preserve-dates', 'check', 'closed', 'clear', 'remove']);
169
+ for (const row of baseline) {
170
+ if (row.path === '--version' || row.path.startsWith('api ') || row.path === 'update --check')
171
+ continue;
172
+ if (!commands.some(command => command.path.join(' ') === row.path)) {
173
+ const gap = commandGap(row.path);
174
+ if (!gap)
175
+ throw new Error('Missing capability decision for ' + row.id);
176
+ commands.push({ path: row.path.split(' '), description: row.path + ' (compatibility gap)', flags: { ...globalFlags, ...repo }, effect: 'gap', gap });
177
+ }
178
+ }
179
+ for (const row of baseline) {
180
+ const path = row.path.startsWith('api ') ? 'api' : row.path === 'update --check' ? 'update' : row.path;
181
+ const command = commands.find(command => command.path.join(' ') === path);
182
+ if (!command)
183
+ continue;
184
+ for (const flag of row.flags) {
185
+ const name = flag.name === '-X' ? 'method' : flag.name.replace(/^--/, '');
186
+ if (!command.flags[name])
187
+ command.flags[name] = { type: booleanFlags.has(name) ? 'boolean' : 'string', description: 'Compatibility gap: ' + flagGap(path, name), gap: flagGap(path, name) };
188
+ }
189
+ }
@@ -0,0 +1,145 @@
1
+ import { collect, object, read, resolveCommit } from './api.js';
2
+ import { CliError } from './error.js';
3
+ import { boundedList, positive } from './reads.js';
4
+ const usage = (message) => { throw new CliError('USAGE', message, 2); };
5
+ export const releaseListFields = ['id', 'tag_name', 'name', 'draft', 'prerelease', 'published_at'];
6
+ async function completeCollection(client, path) {
7
+ const page = await collect(client, path);
8
+ if (page.truncated)
9
+ throw new CliError('INCOMPLETE_READ', 'Exact identity lookup requires a complete collection');
10
+ return page.items;
11
+ }
12
+ async function readTag(client, base, tag) {
13
+ return (await completeCollection(client, base + '/tags')).find(row => row.name === tag) ?? null;
14
+ }
15
+ export function validateRelease(options) {
16
+ if (options['notes-start-tag'] && !options['generate-notes'])
17
+ usage('--notes-start-tag requires --generate-notes');
18
+ if (options['fail-on-no-commits'] && !options['generate-notes'])
19
+ usage('--fail-on-no-commits requires --generate-notes');
20
+ if (options.notes !== undefined && options.body !== undefined || options['notes-file'] !== undefined && options['body-file'] !== undefined)
21
+ usage('Choose one notes input');
22
+ if (options['notes-from-tag'] && (options['generate-notes'] || options.notes !== undefined || options.body !== undefined || options['notes-file'] !== undefined || options['body-file'] !== undefined))
23
+ usage('--notes-from-tag cannot combine with another notes input');
24
+ }
25
+ export async function releaseRead(client, operation, base, tag, options) {
26
+ if (operation === 'release list') {
27
+ const page = await collect(client, base + '/releases');
28
+ const items = page.items.filter(row => !(options['exclude-drafts'] && row.draft) && !(options['exclude-pre-releases'] && row.prerelease));
29
+ return boundedList({ ...page, items, total: options['exclude-drafts'] || options['exclude-pre-releases'] ? page.truncated ? null : items.length : page.total }, positive(options.limit, 10, 1000), releaseListFields);
30
+ }
31
+ const selected = (await completeCollection(client, base + '/releases')).find(row => row.tag_name === tag);
32
+ if (!selected)
33
+ throw new CliError('NOT_FOUND_OR_HIDDEN', 'The selected release is missing or hidden');
34
+ const release = object(await read(client, base + '/releases/' + selected.id));
35
+ if (release.tag_name !== tag)
36
+ throw new CliError('INVALID_RESPONSE', 'Release tag identity differs from the selected tag');
37
+ const body = String(release.body ?? ''), limit = options.full ? 80_000 : 1500;
38
+ return { release: { ...release, body: body.slice(0, limit) }, chars: body.length, truncated: body.length > limit,
39
+ ...(body.length > limit ? { help: 'Use gitea-axi release view ' + tag + ' --repo ' + base.split('/').slice(-2).join('/') + ' --full within the output ceiling' } : {}) };
40
+ }
41
+ async function generatedNotes(client, base, target, start) {
42
+ const cache = new Map();
43
+ const commit = async (ref) => {
44
+ const row = cache.get(ref) ?? object(await read(client, base + '/git/commits/' + encodeURIComponent(ref)));
45
+ if (typeof row.sha !== 'string' || !Array.isArray(row.parents) || typeof object(row.commit).message !== 'string')
46
+ throw new CliError('INVALID_RESPONSE', 'Notes require commit identities, parents and messages');
47
+ return row;
48
+ };
49
+ const end = await resolveCommit(client, base, target);
50
+ const history = await collect(client, base + '/commits?sha=' + encodeURIComponent(String(end.sha)));
51
+ if (history.truncated)
52
+ throw new CliError('INCOMPLETE_READ', 'Notes require the complete target commit history; narrow the release target');
53
+ for (const row of history.items)
54
+ cache.set(String(row.sha), row);
55
+ const begin = start ? await readTag(client, base, start) : null;
56
+ if (start && !begin)
57
+ usage('--notes-start-tag must select an existing tag');
58
+ const startSHA = begin ? String(object(object(begin).commit).sha) : null;
59
+ const walk = async (sha) => {
60
+ const found = new Map(), pending = [sha];
61
+ while (pending.length) {
62
+ const next = pending.pop();
63
+ if (found.has(next))
64
+ continue;
65
+ if (found.size >= 1000)
66
+ throw new CliError('INCOMPLETE_READ', 'Notes exceed the 1000 commit boundary; select a nearer --notes-start-tag');
67
+ const row = await commit(next);
68
+ if (row.sha !== next)
69
+ throw new CliError('INVALID_RESPONSE', 'Commit identity differs from the requested boundary');
70
+ found.set(next, row);
71
+ pending.push(...row.parents.map(parent => String(object(parent).sha)));
72
+ }
73
+ return found;
74
+ };
75
+ const all = await walk(String(end.sha));
76
+ if (startSHA && !all.has(startSHA))
77
+ usage('--notes-start-tag must be an ancestor of the target');
78
+ const excluded = startSHA ? await walk(startSHA) : new Map();
79
+ const commits = [...all.values()].filter(row => !excluded.has(String(row.sha))).map(row => ({ sha: row.sha, message: String(object(row.commit).message).split('\n')[0] }));
80
+ return { kind: 'local-composite', from: startSHA, to: end.sha, commits,
81
+ body: commits.map(row => '- ' + row.message + ' (' + row.sha + ')').join('\n') };
82
+ }
83
+ export async function releasePlan(client, operation, base, tag, options) {
84
+ const path = base + '/releases';
85
+ const releases = await completeCollection(client, path);
86
+ const current = releases.find(row => row.tag_name === tag) ?? null;
87
+ const detail = current ? path + '/' + current.id : path;
88
+ const before = { release: current };
89
+ if (operation === 'release create' && current !== null)
90
+ throw new CliError('ALREADY_EXISTS', 'A release already exists for the selected tag; use release edit');
91
+ if (operation !== 'release create' && current === null)
92
+ throw new CliError('NOT_FOUND_OR_HIDDEN', 'The selected release is missing or hidden');
93
+ if (operation === 'release delete') {
94
+ before.releases = releases;
95
+ const tagState = await readTag(client, base, tag);
96
+ before.tag = tagState;
97
+ return { target: detail, steps: [{ method: 'DELETE', path: detail }], reads: [path, base + '/tags'], preconditions: before,
98
+ workflow: { path, expected: { tag_name: tag }, absentId: object(current).id, remaining: releases.filter(row => row.id !== object(current).id), tag: { path: base + '/tags', before: tagState } } };
99
+ }
100
+ const payload = {};
101
+ for (const [flag, field] of [['title', 'name'], ['body', 'body'], ['draft', 'draft'], ['prerelease', 'prerelease'], ['target', 'target_commitish']]) {
102
+ if (options[flag] !== undefined)
103
+ payload[field] = options[flag];
104
+ }
105
+ let notes;
106
+ if (operation === 'release create') {
107
+ Object.assign(payload, { tag_name: tag, name: payload.name ?? tag, body: payload.body ?? '', draft: payload.draft ?? false, prerelease: payload.prerelease ?? false });
108
+ const repo = object(await read(client, base));
109
+ before.repository = repo;
110
+ const existingTag = await readTag(client, base, tag);
111
+ before.tag = existingTag;
112
+ if (options['verify-tag'] && !existingTag)
113
+ usage('--verify-tag requires an existing tag');
114
+ if (options['notes-from-tag']) {
115
+ if (!existingTag)
116
+ usage('--notes-from-tag requires an existing tag');
117
+ payload.body = object(existingTag).message ?? '';
118
+ }
119
+ payload.target_commitish ??= repo.default_branch;
120
+ if (options['generate-notes']) {
121
+ let start = typeof options['notes-start-tag'] === 'string' ? options['notes-start-tag'] : undefined;
122
+ if (!start) {
123
+ const releases = await collect(client, path);
124
+ if (releases.truncated)
125
+ throw new CliError('INCOMPLETE_READ', 'Notes require a complete previous-release list');
126
+ const latest = releases.items.filter(row => !row.draft).sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)))[0];
127
+ if (latest)
128
+ start = String(latest.tag_name);
129
+ before.releases = releases.items;
130
+ }
131
+ notes = await generatedNotes(client, base, existingTag ? String(object(object(existingTag).commit).sha) : String(payload.target_commitish), start);
132
+ if (options['fail-on-no-commits'] && notes.commits.length === 0)
133
+ usage('No commits exist after the selected notes boundary');
134
+ payload.body = [payload.body, notes.body].filter(Boolean).join('\n\n');
135
+ if (!existingTag)
136
+ payload.target_commitish = notes.to;
137
+ before.notes = notes;
138
+ }
139
+ }
140
+ else if (!Object.keys(payload).length && !options.attach)
141
+ usage('Select at least one release field to edit');
142
+ const expected = current ? { ...Object.fromEntries(['id', 'tag_name', 'name', 'body', 'draft', 'prerelease', 'target_commitish'].map(key => [key, object(current)[key]])), ...payload } : payload;
143
+ return { target: detail, steps: Object.keys(payload).length ? [{ method: operation === 'release create' ? 'POST' : 'PATCH', path: current ? path + '/' + object(current).id : path, payload }] : [],
144
+ reads: [detail], preconditions: before, workflow: { path: detail, expected, created: current === null }, ...(notes ? { notes } : {}) };
145
+ }
@@ -0,0 +1,147 @@
1
+ import { object, read, resolveCommit } from './api.js';
2
+ import { CliError } from './error.js';
3
+ import { repository } from './reads.js';
4
+ const usage = (message) => { throw new CliError('USAGE', message, 2); };
5
+ export const repoPath = (name) => '/api/v1/repos/' + name.split('/').map(encodeURIComponent).join('/');
6
+ export function validateRepositoryWorkflow(operation, options) {
7
+ if ((options.push || options.remote) && !options.source)
8
+ usage('--push and --remote require --source');
9
+ if (['source', 'push', 'remote', 'clone'].some(key => options[key] !== undefined)) {
10
+ throw new CliError('CAPABILITY_GAP', 'Workspace options require the deferred local workspace workflow; use API-only arguments');
11
+ }
12
+ if (options.visibility === 'internal')
13
+ throw new CliError('CAPABILITY_GAP', 'Internal visibility is unsupported by the Gitea contract');
14
+ if (operation === 'repo create' && Boolean(options.public) === Boolean(options.private))
15
+ usage('Choose exactly --public or --private');
16
+ if (options.visibility !== undefined && !['public', 'private'].includes(String(options.visibility)))
17
+ usage('--visibility must be public or private');
18
+ }
19
+ export async function optionalRead(client, path) {
20
+ try {
21
+ return await read(client, path);
22
+ }
23
+ catch (error) {
24
+ if (error instanceof CliError && error.code === 'NOT_FOUND_OR_HIDDEN')
25
+ return null;
26
+ throw error;
27
+ }
28
+ }
29
+ export async function repositoryPlan(client, operation, selection, options) {
30
+ const before = {};
31
+ const action = operation.split(' ')[1];
32
+ if (selection && options.repo)
33
+ usage('Choose a positional repository or --repo once');
34
+ const selected = selection ?? (typeof options.repo === 'string' ? options.repo : undefined);
35
+ if (action === 'edit') {
36
+ const base = repoPath(repository(selected, client.origin));
37
+ const current = object(await read(client, base));
38
+ const payload = {};
39
+ for (const [flag, field] of [['description', 'description'], ['default-branch', 'default_branch'], ['enable-issues', 'has_issues'], ['enable-wiki', 'has_wiki']]) {
40
+ if (options[flag] !== undefined)
41
+ payload[field] = options[flag];
42
+ }
43
+ if (options.visibility !== undefined)
44
+ payload.private = options.visibility === 'private';
45
+ if (!Object.keys(payload).length)
46
+ usage('Select at least one repository field to edit');
47
+ return { target: base, steps: [{ method: 'PATCH', path: base, payload }], reads: [base], preconditions: current,
48
+ workflow: { path: base, expected: { id: current.id, full_name: current.full_name, ...payload } } };
49
+ }
50
+ const user = object(await read(client, '/api/v1/user'));
51
+ let owner = String(user.login), name, source, sourcePath;
52
+ if (action === 'fork') {
53
+ sourcePath = repoPath(repository(selected, client.origin));
54
+ source = object(await read(client, sourcePath));
55
+ before[sourcePath] = source;
56
+ name = String(source.full_name).split('/')[1];
57
+ if (options.org)
58
+ owner = String(options.org);
59
+ }
60
+ else {
61
+ if (!selected)
62
+ usage('Select the new repository name or OWNER/NAME');
63
+ const full = selected.includes('/') ? selected : owner + '/' + selected;
64
+ repository(full, client.origin);
65
+ [owner, name] = full.split('/');
66
+ if (options.template) {
67
+ sourcePath = repoPath(repository(String(options.template), client.origin));
68
+ source = object(await read(client, sourcePath));
69
+ if (source.template !== true)
70
+ usage('--template must select a template repository');
71
+ before[sourcePath] = source;
72
+ }
73
+ }
74
+ repository(owner + '/' + name, client.origin);
75
+ const base = repoPath(owner + '/' + name);
76
+ if (await optionalRead(client, base) !== null)
77
+ throw new CliError('ALREADY_EXISTS', 'The selected destination repository already exists');
78
+ before[base] = null;
79
+ if (owner !== user.login)
80
+ before.owner = await read(client, '/api/v1/orgs/' + encodeURIComponent(owner));
81
+ else
82
+ before.owner = user;
83
+ let path, payload;
84
+ let expected;
85
+ let content;
86
+ if (action === 'fork') {
87
+ path = sourcePath + '/forks';
88
+ payload = owner === user.login ? {} : { organization: owner };
89
+ expected = { full_name: owner + '/' + name, private: source.private, description: source.description, fork: true, parent: { id: source.id } };
90
+ }
91
+ else {
92
+ payload = { name: name, private: Boolean(options.private), description: options.description ?? '' };
93
+ expected = { full_name: owner + '/' + name, private: payload.private, description: payload.description };
94
+ if (source) {
95
+ path = sourcePath + '/generate';
96
+ Object.assign(payload, { owner, git_content: true });
97
+ const commit = await resolveCommit(client, sourcePath, String(source.default_branch));
98
+ const tree = object(await read(client, sourcePath + '/git/trees/' + commit.sha + '?recursive=true'));
99
+ if (tree.truncated !== false || !Array.isArray(tree.tree))
100
+ throw new CliError('INCOMPLETE_READ', 'Template content requires a complete tree');
101
+ if (tree.tree.some(item => object(item).path === '.gitea/template'))
102
+ throw new CliError('CAPABILITY_GAP', 'Transforming templates not yet supported');
103
+ before.content = tree;
104
+ content = { base, ref: String(source.default_branch), expected: treeEntries(tree.tree) };
105
+ expected.default_branch = source.default_branch;
106
+ payload.default_branch = source.default_branch;
107
+ }
108
+ else
109
+ path = owner === user.login ? '/api/v1/user/repos' : '/api/v1/orgs/' + encodeURIComponent(owner) + '/repos';
110
+ }
111
+ expected.owner = { login: owner };
112
+ return { target: base, steps: [{ method: 'POST', path, payload }], reads: [base], preconditions: before,
113
+ workflow: { path: base, expected, created: true, ...(content ? { content } : {}) } };
114
+ }
115
+ function treeEntries(value) {
116
+ if (!Array.isArray(value))
117
+ throw new CliError('INVALID_RESPONSE', 'Expected a complete Git tree');
118
+ return value.map(item => { const row = object(item); return { path: row.path, mode: row.mode, type: row.type, sha: row.sha }; }).sort((a, b) => String(a.path).localeCompare(String(b.path)));
119
+ }
120
+ export async function verifyWorkflow(client, plan, results, observations) {
121
+ const verification = plan.workflow;
122
+ const mismatch = () => { throw new CliError('READBACK_MISMATCH', 'Readback did not confirm the selected identity and requested fields'); };
123
+ const data = observations.find(row => row.path === verification.path)?.data;
124
+ if (verification.absentId !== undefined) {
125
+ if (!Array.isArray(data) || data.some(value => object(value).id === verification.absentId || object(value).tag_name === verification.expected.tag_name))
126
+ mismatch();
127
+ const rows = data;
128
+ if (rows.length !== verification.remaining.length || verification.remaining.some(row => JSON.stringify(rows.find(item => item.id === row.id)) !== JSON.stringify(row)))
129
+ mismatch();
130
+ const tags = observations.find(row => row.path === verification.tag.path)?.data;
131
+ if (!Array.isArray(tags) || JSON.stringify(tags.find(row => object(row).name === verification.expected.tag_name) ?? null) !== JSON.stringify(verification.tag.before))
132
+ mismatch();
133
+ return;
134
+ }
135
+ const actual = object(Array.isArray(data) ? data.find(row => object(row).tag_name === verification.expected.tag_name) : data);
136
+ const matches = (actual, expected) => Object.entries(expected).every(([key, value]) => value !== null && typeof value === 'object' && !Array.isArray(value) ? matches(object(actual[key]), object(value)) : JSON.stringify(actual[key]) === JSON.stringify(value));
137
+ if (!matches(actual, verification.expected))
138
+ mismatch();
139
+ if (verification.created && (actual.id !== object(results[0]).id || !Number.isSafeInteger(actual.id) || Number(actual.id) <= 0))
140
+ mismatch();
141
+ if (verification.content) {
142
+ const commit = await resolveCommit(client, verification.content.base, verification.content.ref);
143
+ const tree = object(await read(client, verification.content.base + '/git/trees/' + commit.sha + '?recursive=true'));
144
+ if (tree.truncated !== false || JSON.stringify(treeEntries(tree.tree)) !== JSON.stringify(verification.content.expected))
145
+ mismatch();
146
+ }
147
+ }
@@ -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
+ }