@knowttl/gitea-axi 0.1.0 → 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,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
+ }
package/dist/search.js ADDED
@@ -0,0 +1,111 @@
1
+ import { collect, object, read } from './api.js';
2
+ import { CliError } from './error.js';
3
+ import { continuationHelp, issue, issueFields, positive, repository, selectedFields } from './reads.js';
4
+ export function validateSearch(kind, query, options) {
5
+ if (!query.trim() || /\S+:\S*/.test(query))
6
+ throw new CliError('USAGE', 'Use plain search text and explicit flags; query qualifiers are unsupported', 2);
7
+ if (kind === 'code')
8
+ throw new CliError('CAPABILITY_GAP', 'Indexed code search is unresolved; repository contents are not indexed search');
9
+ const allowed = kind === 'issues' || kind === 'prs' ? ['owner', 'state', 'label', 'assignee', 'author', 'sort', 'order', 'limit', 'fields', ...(kind === 'prs' ? ['draft'] : [])] :
10
+ kind === 'repos' ? ['owner', 'sort', 'order', 'limit', 'language', 'stars', 'fields'] : ['author', 'sort', 'order', 'limit', 'revision', 'fields'];
11
+ for (const key of Object.keys(options)) {
12
+ if (!['origin', 'allow-insecure-http', 'config', 'profile', 'repo'].includes(key) && !allowed.includes(key))
13
+ throw new CliError('CAPABILITY_GAP', '--' + key + ' is not evidenced for search ' + kind);
14
+ }
15
+ positive(options.limit, 1000, 1000);
16
+ if (options.state && !['open', 'closed', 'all'].includes(String(options.state)))
17
+ throw new CliError('USAGE', '--state must be open, closed or all', 2);
18
+ const sorts = kind === 'repos' ? ['name', 'created', 'updated', 'stars'] : kind === 'commits' ? ['created'] : ['created', 'updated', 'comments'];
19
+ if (options.sort && !sorts.includes(String(options.sort)))
20
+ throw new CliError('USAGE', 'Choose --sort ' + sorts.join(','), 2);
21
+ if (options.order && !['asc', 'desc'].includes(String(options.order)))
22
+ throw new CliError('USAGE', '--order must be asc or desc', 2);
23
+ if (options.stars && !/^(?:>=|<=|>|<|=)?\d+$/.test(String(options.stars)))
24
+ throw new CliError('USAGE', '--stars requires an integer comparison such as >100', 2);
25
+ selectedFields(options, kind === 'repos' ? ['id', 'full_name', 'description', 'language', 'stars_count'] : kind === 'commits' ? ['sha', 'message', 'author', 'created', 'url'] : issueFields.concat(['closedAt', 'commentsCount', 'isLocked', ...(kind === 'prs' ? ['isDraft'] : [])]), []);
26
+ }
27
+ export async function search(client, kind, query, options) {
28
+ const target = typeof options.repo === 'string' ? repository(options.repo, client.origin) : undefined;
29
+ const base = target ? '/api/v1/repos/' + target.split('/').map(encodeURIComponent).join('/') : undefined;
30
+ let page;
31
+ let revision = null;
32
+ if (kind === 'commits') {
33
+ if (!base)
34
+ throw new CliError('USAGE', 'Commit search requires --repo OWNER/NAME', 2);
35
+ const repo = object(await read(client, base));
36
+ const ref = String(options.revision ?? repo.default_branch);
37
+ const commit = object(await read(client, base + '/git/commits/' + encodeURIComponent(ref)));
38
+ if (typeof commit.sha !== 'string' || !commit.sha)
39
+ throw new CliError('INVALID_RESPONSE', 'Revision has no commit SHA');
40
+ revision = commit.sha;
41
+ page = await collect(client, base + '/commits?sha=' + encodeURIComponent(commit.sha));
42
+ }
43
+ else if (kind === 'repos') {
44
+ page = await collect(client, '/api/v1/repos/search?q=' + encodeURIComponent(query) + '&includeDesc=true', 'data');
45
+ }
46
+ else {
47
+ page = await collect(client, base ? base + (kind === 'prs' ? '/pulls' : '/issues') + '?state=all' + (kind === 'issues' ? '&type=issues' : '') :
48
+ '/api/v1/repos/issues/search?state=all&type=' + (kind === 'prs' ? 'pulls' : 'issues') + '&q=' + encodeURIComponent(query) + (options.owner ? '&owner=' + encodeURIComponent(String(options.owner)) : ''));
49
+ }
50
+ if (kind === 'prs' && !base) {
51
+ page.items = page.items.map(row => ({ ...row, draft: row.pull_request ? object(row.pull_request).draft : undefined }));
52
+ }
53
+ const needle = query.toLowerCase();
54
+ const rows = page.items.filter(row => {
55
+ const fullName = kind === 'repos' ? row.full_name : row.repository ? object(row.repository).full_name : target;
56
+ if (target && kind === 'repos' && fullName !== target)
57
+ return false;
58
+ if (options.owner && String(fullName).split('/')[0] !== options.owner)
59
+ return false;
60
+ if (kind === 'repos') {
61
+ if (!String(row.full_name ?? '').toLowerCase().includes(needle) && !String(row.description ?? '').toLowerCase().includes(needle))
62
+ return false;
63
+ if (options.language && String(row.language).toLowerCase() !== String(options.language).toLowerCase())
64
+ return false;
65
+ if (options.stars) {
66
+ const [, op, value] = /^(>=|<=|>|<|=)?(\d+)$/.exec(String(options.stars));
67
+ const count = Number(row.stars_count), expected = Number(value);
68
+ if (!(op === '>' ? count > expected : op === '<' ? count < expected : op === '>=' ? count >= expected : op === '<=' ? count <= expected : count === expected))
69
+ return false;
70
+ }
71
+ }
72
+ else if (kind === 'commits') {
73
+ if (!String(object(row.commit).message).toLowerCase().includes(needle))
74
+ return false;
75
+ if (options.author && (!row.author || object(row.author).login !== options.author))
76
+ return false;
77
+ }
78
+ else {
79
+ if (kind === 'issues' && row.pull_request)
80
+ return false;
81
+ if (!(String(row.title) + '\n' + String(row.body ?? '')).toLowerCase().includes(needle))
82
+ return false;
83
+ if (options.state && options.state !== 'all' && row.state !== options.state)
84
+ return false;
85
+ if (options.author && (!row.user || object(row.user).login !== options.author))
86
+ return false;
87
+ if (options.assignee && !(row.assignees ?? []).some(user => user.login === options.assignee))
88
+ return false;
89
+ if (options.label && !(row.labels ?? []).some(label => String(options.label).split(',').includes(String(label.name))))
90
+ return false;
91
+ if (options.draft && row.draft !== true)
92
+ return false;
93
+ }
94
+ return true;
95
+ });
96
+ const key = options.sort === 'updated' ? 'updated_at' : options.sort === 'comments' ? 'comments' : options.sort === 'stars' ? 'stars_count' : options.sort === 'name' ? 'full_name' : kind === 'commits' ? 'created' : 'created_at';
97
+ rows.sort((a, b) => {
98
+ const left = a[key] ?? '', right = b[key] ?? '';
99
+ const compared = typeof left === 'number' && typeof right === 'number' ? left - right : String(left).localeCompare(String(right));
100
+ return (compared || String(a.id ?? a.sha).localeCompare(String(b.id ?? b.sha))) * (options.order === 'asc' ? 1 : -1);
101
+ });
102
+ const limit = positive(options.limit, 1000, 1000);
103
+ const fields = typeof options.fields === 'string' ? options.fields.split(',') : kind === 'repos' ? ['id', 'full_name', 'description'] : kind === 'commits' ? ['sha', 'message', 'author'] : ['number', 'title', 'state'];
104
+ return { items: rows.slice(0, limit).map(row => {
105
+ const projection = kind === 'repos' ? row : kind === 'commits' ? { sha: row.sha, message: object(row.commit).message, author: row.author, created: row.created, url: row.html_url } :
106
+ { ...issue(row, Boolean(options.fields)), closedAt: row.closed_at ?? null, commentsCount: row.comments ?? 0, isLocked: row.is_locked ?? false, isDraft: row.draft ?? null };
107
+ return { ...(kind === 'issues' || kind === 'prs' ? { repository: row.repository ? object(row.repository).full_name : target } : {}), ...Object.fromEntries(fields.map(field => [field, projection[field]])) };
108
+ }), count: Math.min(rows.length, limit), total: page.truncated ? null : rows.length, truncated: page.truncated || rows.length > limit, next: page.next,
109
+ scope: { repository: target ?? 'visible repositories', owner: options.owner ?? null, revision, pages: 'maximum 20 pages / 1000 candidates per collection', indexed: false },
110
+ continuation: page.truncated || rows.length > limit, help: continuationHelp(page, rows.length, limit) };
111
+ }