@foss.global/forgefixtures 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.smartconfig.json +49 -0
  2. package/changelog.md +17 -0
  3. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  4. package/dist_ts/00_commitinfo_data.js +9 -0
  5. package/dist_ts/classes.certificateauthority.d.ts +22 -0
  6. package/dist_ts/classes.certificateauthority.js +93 -0
  7. package/dist_ts/classes.containerlifecycle.d.ts +88 -0
  8. package/dist_ts/classes.containerlifecycle.js +383 -0
  9. package/dist_ts/classes.giteafixture.d.ts +41 -0
  10. package/dist_ts/classes.giteafixture.js +182 -0
  11. package/dist_ts/classes.giteaseed.d.ts +13 -0
  12. package/dist_ts/classes.giteaseed.js +432 -0
  13. package/dist_ts/classes.gitlabfixture.d.ts +49 -0
  14. package/dist_ts/classes.gitlabfixture.js +237 -0
  15. package/dist_ts/classes.gitlabseed.d.ts +13 -0
  16. package/dist_ts/classes.gitlabseed.js +466 -0
  17. package/dist_ts/classes.httpclient.d.ts +53 -0
  18. package/dist_ts/classes.httpclient.js +116 -0
  19. package/dist_ts/classes.reaper.d.ts +25 -0
  20. package/dist_ts/classes.reaper.js +102 -0
  21. package/dist_ts/classes.tlsterminator.d.ts +21 -0
  22. package/dist_ts/classes.tlsterminator.js +131 -0
  23. package/dist_ts/constants.d.ts +22 -0
  24. package/dist_ts/constants.js +30 -0
  25. package/dist_ts/giteaseed.default.d.ts +10 -0
  26. package/dist_ts/giteaseed.default.js +87 -0
  27. package/dist_ts/gitlabseed.default.d.ts +12 -0
  28. package/dist_ts/gitlabseed.default.js +84 -0
  29. package/dist_ts/index.d.ts +17 -0
  30. package/dist_ts/index.js +17 -0
  31. package/dist_ts/interfaces.d.ts +92 -0
  32. package/dist_ts/interfaces.giteaseed.d.ts +182 -0
  33. package/dist_ts/interfaces.giteaseed.js +2 -0
  34. package/dist_ts/interfaces.gitlabseed.d.ts +183 -0
  35. package/dist_ts/interfaces.gitlabseed.js +2 -0
  36. package/dist_ts/interfaces.js +2 -0
  37. package/dist_ts/ownership.d.ts +29 -0
  38. package/dist_ts/ownership.js +140 -0
  39. package/dist_ts/plugins.d.ts +13 -0
  40. package/dist_ts/plugins.js +18 -0
  41. package/dist_ts/responses.d.ts +7 -0
  42. package/dist_ts/responses.js +30 -0
  43. package/license.md +21 -0
  44. package/package.json +65 -0
  45. package/readme.md +206 -0
  46. package/ts/00_commitinfo_data.ts +8 -0
  47. package/ts/classes.certificateauthority.ts +117 -0
  48. package/ts/classes.containerlifecycle.ts +432 -0
  49. package/ts/classes.giteafixture.ts +205 -0
  50. package/ts/classes.giteaseed.ts +486 -0
  51. package/ts/classes.gitlabfixture.ts +258 -0
  52. package/ts/classes.gitlabseed.ts +502 -0
  53. package/ts/classes.httpclient.ts +156 -0
  54. package/ts/classes.reaper.ts +126 -0
  55. package/ts/classes.tlsterminator.ts +136 -0
  56. package/ts/constants.ts +35 -0
  57. package/ts/giteaseed.default.ts +88 -0
  58. package/ts/gitlabseed.default.ts +86 -0
  59. package/ts/index.ts +17 -0
  60. package/ts/interfaces.giteaseed.ts +130 -0
  61. package/ts/interfaces.gitlabseed.ts +135 -0
  62. package/ts/interfaces.ts +94 -0
  63. package/ts/ownership.ts +160 -0
  64. package/ts/plugins.ts +23 -0
  65. package/ts/responses.ts +33 -0
@@ -0,0 +1,486 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as responses from './responses.js';
3
+ import type { GiteaFixture } from './classes.giteafixture.js';
4
+ import type { ForgeFixtureHttpClient } from './classes.httpclient.js';
5
+ import type {
6
+ IGiteaSeedFile,
7
+ IGiteaSeedIssue,
8
+ IGiteaSeedManifest,
9
+ IGiteaSeedManifestAuthor,
10
+ IGiteaSeedManifestRepository,
11
+ IGiteaSeedPullRequest,
12
+ IGiteaSeedRepository,
13
+ IGiteaSeedSpec,
14
+ TGiteaVisibility,
15
+ } from './interfaces.giteaseed.js';
16
+
17
+ const defaultBranch = 'main';
18
+ const lfsMediaType = 'application/vnd.git-lfs+json';
19
+ const namePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,38}$/;
20
+ const colorPattern = /^#[0-9a-f]{6}$/;
21
+
22
+ interface ISeededIssueRef {
23
+ key: string;
24
+ kind: 'issue' | 'pullRequest';
25
+ number: number;
26
+ }
27
+
28
+ interface ISeededRepositoryRefs {
29
+ spec: IGiteaSeedRepository;
30
+ labels: Map<string, number>;
31
+ milestones: Map<string, { id: number; state: 'open' | 'closed' }>;
32
+ tags: Array<{ name: string; commitSha: string }>;
33
+ items: ISeededIssueRef[];
34
+ releases: Array<{ tagName: string; id: number; draft: boolean; prerelease: boolean }>;
35
+ lfsObjects: Array<{ path: string; oid: string; size: number }>;
36
+ }
37
+
38
+ const segment = (valueArg: string) => encodeURIComponent(valueArg);
39
+
40
+ /** Rejects inconsistent specs before the first mutation. */
41
+ export const validateGiteaSeedSpec = (specArg: IGiteaSeedSpec): void => {
42
+ const fail = (messageArg: string): never => {
43
+ throw new TypeError(`Invalid Gitea seed spec: ${messageArg}`);
44
+ };
45
+ const unique = (valuesArg: string[], whatArg: string) => {
46
+ const seen = new Set<string>();
47
+ for (const value of valuesArg) {
48
+ if (seen.has(value.toLowerCase())) fail(`duplicate ${whatArg} "${value}"`);
49
+ seen.add(value.toLowerCase());
50
+ }
51
+ };
52
+ const users = new Set(specArg.users.map((userArg) => userArg.username));
53
+ const organizations = new Set(specArg.organizations.map((organizationArg) => organizationArg.name));
54
+ for (const name of [...users, ...organizations]) if (!namePattern.test(name)) fail(`malformed name "${name}"`);
55
+ unique([...users, ...organizations], 'user or organization');
56
+ for (const username of specArg.deleteUsers ?? []) if (!users.has(username)) fail(`cannot delete unknown user "${username}"`);
57
+ unique(specArg.repositories.map((repositoryArg) => `${repositoryArg.owner}/${repositoryArg.name}`), 'repository');
58
+ const deleted = new Set(specArg.deleteUsers ?? []);
59
+ for (const repository of specArg.repositories) {
60
+ const where = `${repository.owner}/${repository.name}`;
61
+ if (!namePattern.test(repository.name)) fail(`malformed repository name "${where}"`);
62
+ if (!users.has(repository.owner) && !organizations.has(repository.owner)) fail(`${where} has an unknown owner`);
63
+ if (deleted.has(repository.owner)) fail(`${where} is owned by a user scheduled for deletion`);
64
+ const knownUser = (usernameArg: string) => {
65
+ if (!users.has(usernameArg)) fail(`${where} references unknown user "${usernameArg}"`);
66
+ };
67
+ for (const collaborator of repository.collaborators ?? []) knownUser(collaborator.username);
68
+ const lfsPaths = (repository.lfsObjects ?? []).map((fileArg) => fileArg.path);
69
+ const filePaths = [...(repository.files ?? []).map((fileArg) => fileArg.path), ...lfsPaths];
70
+ unique(filePaths, `${where} file`);
71
+ if (filePaths.includes('README.md')) fail(`${where} cannot seed README.md; auto-initialisation creates it`);
72
+ if (lfsPaths.length > 0 && filePaths.includes('.gitattributes')) fail(`${where} cannot combine LFS objects with its own .gitattributes`);
73
+ const branches = new Set([defaultBranch]);
74
+ for (const branch of repository.branches ?? []) {
75
+ if (branches.has(branch.name)) fail(`${where} declares branch "${branch.name}" twice`);
76
+ if (!branches.has(branch.from ?? defaultBranch)) fail(`${where} branch "${branch.name}" starts from an undeclared branch`);
77
+ branches.add(branch.name);
78
+ }
79
+ const labels = new Set((repository.labels ?? []).map((labelArg) => {
80
+ if (!colorPattern.test(labelArg.color)) fail(`${where} label "${labelArg.name}" needs a lowercase #rrggbb color`);
81
+ return labelArg.name;
82
+ }));
83
+ unique([...labels], `${where} label`);
84
+ const milestones = new Set((repository.milestones ?? []).map((milestoneArg) => milestoneArg.title));
85
+ unique([...milestones], `${where} milestone`);
86
+ unique((repository.tags ?? []).map((tagArg) => tagArg.name), `${where} tag`);
87
+ unique((repository.issuesAndPullRequests ?? []).map((itemArg) => itemArg.key), `${where} issue key`);
88
+ for (const item of repository.issuesAndPullRequests ?? []) {
89
+ knownUser(item.author);
90
+ for (const label of item.labels ?? []) if (!labels.has(label)) fail(`${where} ${item.key} uses unknown label "${label}"`);
91
+ if (item.kind === 'issue') {
92
+ if (item.milestone !== undefined && !milestones.has(item.milestone)) fail(`${where} ${item.key} uses an unknown milestone`);
93
+ for (const assignee of item.assignees ?? []) knownUser(assignee);
94
+ for (const comment of item.comments ?? []) knownUser(comment.author);
95
+ } else if (!branches.has(item.head) || !branches.has(item.base) || item.head === item.base) {
96
+ fail(`${where} ${item.key} needs two distinct declared branches`);
97
+ }
98
+ }
99
+ }
100
+ };
101
+
102
+ /**
103
+ * Seeds a running {@link GiteaFixture} through Gitea's documented REST and
104
+ * Git LFS APIs, then reads everything back into a ground-truth manifest.
105
+ */
106
+ export class GiteaSeedBuilder {
107
+ readonly #fixture: GiteaFixture;
108
+
109
+ constructor(fixtureArg: GiteaFixture) {
110
+ this.#fixture = fixtureArg;
111
+ }
112
+
113
+ public async apply(specArg: IGiteaSeedSpec): Promise<IGiteaSeedManifest> {
114
+ validateGiteaSeedSpec(specArg);
115
+ const runtime = this.#fixture.runtime;
116
+ const http = this.#fixture.http;
117
+ const users = new Map<string, number>();
118
+ for (const user of specArg.users) {
119
+ const created = await this.#api(http, 'POST', '/api/v1/admin/users', [201], {
120
+ json: {
121
+ username: user.username,
122
+ email: `${user.username.toLowerCase()}@example.com`,
123
+ password: plugins.crypto.randomBytes(24).toString('base64url'),
124
+ must_change_password: false,
125
+ ...(user.fullName === undefined ? {} : { full_name: user.fullName }),
126
+ visibility: user.visibility ?? 'public',
127
+ },
128
+ });
129
+ users.set(user.username, responses.integer(responses.field(created, 'id', 'created user'), 'user id'));
130
+ }
131
+ const organizations: IGiteaSeedManifest['organizations'] = [];
132
+ for (const organization of specArg.organizations) {
133
+ const created = await this.#api(http, 'POST', '/api/v1/orgs', [201], {
134
+ json: { username: organization.name, visibility: organization.visibility },
135
+ });
136
+ organizations.push({
137
+ name: organization.name,
138
+ id: responses.integer(responses.field(created, 'id', 'created organization'), 'organization id'),
139
+ visibility: this.#visibility(responses.field(created, 'visibility', 'created organization')),
140
+ });
141
+ }
142
+ const organizationNames = new Set(specArg.organizations.map((organizationArg) => organizationArg.name));
143
+ const seeded: ISeededRepositoryRefs[] = [];
144
+ for (const repository of specArg.repositories) {
145
+ seeded.push(await this.#seedRepository(http, repository, organizationNames.has(repository.owner)));
146
+ }
147
+ for (const username of specArg.deleteUsers ?? []) {
148
+ await this.#api(http, 'DELETE', `/api/v1/admin/users/${segment(username)}`, [204]);
149
+ }
150
+ const deleted = new Set(specArg.deleteUsers ?? []);
151
+ const repositories: IGiteaSeedManifestRepository[] = [];
152
+ for (const refs of seeded) repositories.push(await this.#readBack(http, refs));
153
+ return {
154
+ baseUrl: runtime.baseUrl,
155
+ version: runtime.version,
156
+ users: [...users].map(([username, id]) => ({ username, id, deleted: deleted.has(username) })),
157
+ organizations,
158
+ repositories,
159
+ };
160
+ }
161
+
162
+ async #seedRepository(
163
+ httpArg: ForgeFixtureHttpClient,
164
+ specArg: IGiteaSeedRepository,
165
+ ownedByOrganizationArg: boolean,
166
+ ): Promise<ISeededRepositoryRefs> {
167
+ const repoPath = `/api/v1/repos/${segment(specArg.owner)}/${segment(specArg.name)}`;
168
+ await this.#api(
169
+ httpArg, 'POST',
170
+ ownedByOrganizationArg ? `/api/v1/orgs/${segment(specArg.owner)}/repos` : `/api/v1/admin/users/${segment(specArg.owner)}/repos`,
171
+ [201],
172
+ {
173
+ json: {
174
+ name: specArg.name, private: specArg.private, description: specArg.description ?? '',
175
+ auto_init: true, default_branch: defaultBranch, readme: 'Default',
176
+ },
177
+ },
178
+ );
179
+ for (const collaborator of specArg.collaborators ?? []) {
180
+ await this.#api(httpArg, 'PUT', `${repoPath}/collaborators/${segment(collaborator.username)}`, [204], {
181
+ json: { permission: collaborator.permission },
182
+ });
183
+ }
184
+ const refs: ISeededRepositoryRefs = {
185
+ spec: specArg, labels: new Map(), milestones: new Map(), tags: [], items: [], releases: [], lfsObjects: [],
186
+ };
187
+ for (const lfsObject of specArg.lfsObjects ?? []) {
188
+ refs.lfsObjects.push({ path: lfsObject.path, ...await this.#uploadLfsObject(httpArg, specArg, lfsObject.content) });
189
+ }
190
+ const defaultFiles: IGiteaSeedFile[] = [...(specArg.files ?? [])];
191
+ if (refs.lfsObjects.length > 0) {
192
+ defaultFiles.push({
193
+ path: '.gitattributes',
194
+ content: refs.lfsObjects.map((objectArg) => `${objectArg.path} filter=lfs diff=lfs merge=lfs -text\n`).join(''),
195
+ });
196
+ for (const object of refs.lfsObjects) {
197
+ defaultFiles.push({
198
+ path: object.path,
199
+ content: `version https://git-lfs.github.com/spec/v1\noid sha256:${object.oid}\nsize ${object.size}\n`,
200
+ });
201
+ }
202
+ }
203
+ if (defaultFiles.length > 0) {
204
+ await this.#commitFiles(httpArg, repoPath, defaultFiles, { branch: defaultBranch }, 'Seed default branch files');
205
+ }
206
+ for (const branch of specArg.branches ?? []) {
207
+ await this.#commitFiles(httpArg, repoPath, branch.files, {
208
+ branch: branch.from ?? defaultBranch, new_branch: branch.name,
209
+ }, `Seed branch ${branch.name}`);
210
+ }
211
+ for (const label of specArg.labels ?? []) {
212
+ const created = await this.#api(httpArg, 'POST', `${repoPath}/labels`, [201], {
213
+ json: { name: label.name, color: label.color, description: label.description ?? '' },
214
+ });
215
+ refs.labels.set(label.name, responses.integer(responses.field(created, 'id', 'created label'), 'label id'));
216
+ }
217
+ for (const milestone of specArg.milestones ?? []) {
218
+ const created = await this.#api(httpArg, 'POST', `${repoPath}/milestones`, [201], {
219
+ json: { title: milestone.title, description: milestone.description ?? '', state: milestone.state ?? 'open' },
220
+ });
221
+ refs.milestones.set(milestone.title, {
222
+ id: responses.integer(responses.field(created, 'id', 'created milestone'), 'milestone id'),
223
+ state: this.#state(responses.field(created, 'state', 'created milestone')),
224
+ });
225
+ }
226
+ for (const tag of specArg.tags ?? []) {
227
+ const created = await this.#api(httpArg, 'POST', `${repoPath}/tags`, [200, 201], {
228
+ json: { tag_name: tag.name, target: tag.target, message: tag.message ?? '' },
229
+ });
230
+ refs.tags.push({ name: tag.name, commitSha: this.#tagCommitSha(created) });
231
+ }
232
+ for (const item of specArg.issuesAndPullRequests ?? []) {
233
+ refs.items.push(item.kind === 'issue'
234
+ ? await this.#seedIssue(httpArg, repoPath, refs, item)
235
+ : await this.#seedPullRequest(httpArg, repoPath, refs, item));
236
+ }
237
+ for (const release of specArg.releases ?? []) {
238
+ const created = await this.#api(httpArg, 'POST', `${repoPath}/releases`, [201], {
239
+ json: {
240
+ tag_name: release.tagName, target_commitish: release.target, name: release.name, body: release.body ?? '',
241
+ draft: release.draft ?? false, prerelease: release.prerelease ?? false,
242
+ },
243
+ });
244
+ refs.releases.push({
245
+ tagName: release.tagName,
246
+ id: responses.integer(responses.field(created, 'id', 'created release'), 'release id'),
247
+ draft: responses.boolean(responses.field(created, 'draft', 'created release'), 'release draft'),
248
+ prerelease: responses.boolean(responses.field(created, 'prerelease', 'created release'), 'release prerelease'),
249
+ });
250
+ }
251
+ return refs;
252
+ }
253
+
254
+ async #seedIssue(
255
+ httpArg: ForgeFixtureHttpClient,
256
+ repoPathArg: string,
257
+ refsArg: ISeededRepositoryRefs,
258
+ issueArg: IGiteaSeedIssue,
259
+ ): Promise<ISeededIssueRef> {
260
+ const created = await this.#api(httpArg, 'POST', `${repoPathArg}/issues`, [201], {
261
+ sudo: issueArg.author, json: { title: issueArg.title, body: issueArg.body ?? '' },
262
+ });
263
+ const number = responses.integer(responses.field(created, 'number', 'created issue'), 'issue number');
264
+ const issuePath = `${repoPathArg}/issues/${number}`;
265
+ for (const comment of issueArg.comments ?? []) {
266
+ await this.#api(httpArg, 'POST', `${issuePath}/comments`, [201], { sudo: comment.author, json: { body: comment.body } });
267
+ }
268
+ await this.#applyLabels(httpArg, issuePath, refsArg, issueArg.labels);
269
+ const edit: Record<string, unknown> = {};
270
+ if (issueArg.milestone !== undefined) edit.milestone = refsArg.milestones.get(issueArg.milestone)!.id;
271
+ if (issueArg.assignees !== undefined) edit.assignees = issueArg.assignees;
272
+ if (issueArg.state === 'closed') edit.state = 'closed';
273
+ if (Object.keys(edit).length > 0) await this.#api(httpArg, 'PATCH', issuePath, [201], { json: edit });
274
+ return { key: issueArg.key, kind: 'issue', number };
275
+ }
276
+
277
+ async #seedPullRequest(
278
+ httpArg: ForgeFixtureHttpClient,
279
+ repoPathArg: string,
280
+ refsArg: ISeededRepositoryRefs,
281
+ pullArg: IGiteaSeedPullRequest,
282
+ ): Promise<ISeededIssueRef> {
283
+ const created = await this.#api(httpArg, 'POST', `${repoPathArg}/pulls`, [201], {
284
+ sudo: pullArg.author, json: { title: pullArg.title, body: pullArg.body ?? '', head: pullArg.head, base: pullArg.base },
285
+ });
286
+ const number = responses.integer(responses.field(created, 'number', 'created pull request'), 'pull request number');
287
+ await this.#applyLabels(httpArg, `${repoPathArg}/issues/${number}`, refsArg, pullArg.labels);
288
+ if (pullArg.state === 'closed') {
289
+ await this.#api(httpArg, 'PATCH', `${repoPathArg}/pulls/${number}`, [201], { json: { state: 'closed' } });
290
+ }
291
+ return { key: pullArg.key, kind: 'pullRequest', number };
292
+ }
293
+
294
+ async #applyLabels(
295
+ httpArg: ForgeFixtureHttpClient,
296
+ issuePathArg: string,
297
+ refsArg: ISeededRepositoryRefs,
298
+ labelsArg: string[] | undefined,
299
+ ): Promise<void> {
300
+ if (!labelsArg || labelsArg.length === 0) return;
301
+ await this.#api(httpArg, 'POST', `${issuePathArg}/labels`, [200], {
302
+ json: { labels: labelsArg.map((labelArg) => refsArg.labels.get(labelArg)!) },
303
+ });
304
+ }
305
+
306
+ async #commitFiles(
307
+ httpArg: ForgeFixtureHttpClient,
308
+ repoPathArg: string,
309
+ filesArg: IGiteaSeedFile[],
310
+ branchArg: { branch: string; new_branch?: string },
311
+ messageArg: string,
312
+ ): Promise<void> {
313
+ await this.#api(httpArg, 'POST', `${repoPathArg}/contents`, [201], {
314
+ json: {
315
+ ...branchArg,
316
+ message: messageArg,
317
+ files: filesArg.map((fileArg) => ({
318
+ operation: 'create',
319
+ path: fileArg.path,
320
+ content: Buffer.from(fileArg.content, 'utf8').toString('base64'),
321
+ })),
322
+ },
323
+ });
324
+ }
325
+
326
+ /** Uploads one object through the Git LFS batch API using the administrator's token as password. */
327
+ async #uploadLfsObject(
328
+ httpArg: ForgeFixtureHttpClient,
329
+ repositoryArg: IGiteaSeedRepository,
330
+ contentArg: string,
331
+ ): Promise<{ oid: string; size: number }> {
332
+ const content = Buffer.from(contentArg, 'utf8');
333
+ const oid = plugins.crypto.createHash('sha256').update(content).digest('hex');
334
+ const size = content.byteLength;
335
+ const runtime = this.#fixture.runtime;
336
+ const basic = `Basic ${Buffer.from(`${runtime.admin.username}:${runtime.admin.token}`).toString('base64')}`;
337
+ const batch = await httpArg.requestJson({
338
+ method: 'POST',
339
+ url: `/${segment(repositoryArg.owner)}/${segment(repositoryArg.name)}.git/info/lfs/objects/batch`,
340
+ headers: { accept: lfsMediaType, 'content-type': lfsMediaType, authorization: basic },
341
+ body: Buffer.from(JSON.stringify({ operation: 'upload', transfers: ['basic'], objects: [{ oid, size }] })),
342
+ expectedStatus: [200],
343
+ });
344
+ const objects = responses.array(responses.field(batch, 'objects', 'LFS batch response'), 'LFS batch objects');
345
+ if (objects.length !== 1) throw new Error('The LFS batch response does not describe exactly one object.');
346
+ const object = responses.record(objects[0], 'LFS batch object');
347
+ if (object.oid !== oid || object.size !== size || object.error !== undefined) {
348
+ throw new Error(`The LFS batch refused object ${oid}.`);
349
+ }
350
+ const actions = object.actions === undefined ? {} : responses.record(object.actions, 'LFS actions');
351
+ const actionHeaders = (actionArg: Record<string, unknown>): Record<string, string> => {
352
+ const headers: Record<string, string> = {};
353
+ if (actionArg.header === undefined) return headers;
354
+ for (const [name, value] of Object.entries(responses.record(actionArg.header, 'LFS action header'))) {
355
+ headers[name] = responses.text(value, `LFS action header ${name}`);
356
+ }
357
+ return headers;
358
+ };
359
+ if (actions.upload !== undefined) {
360
+ const upload = responses.record(actions.upload, 'LFS upload action');
361
+ await httpArg.request({
362
+ method: 'PUT', url: responses.text(upload.href, 'LFS upload href'),
363
+ headers: { ...actionHeaders(upload), 'content-type': 'application/octet-stream' },
364
+ body: content, expectedStatus: [200],
365
+ });
366
+ }
367
+ if (actions.verify !== undefined) {
368
+ const verify = responses.record(actions.verify, 'LFS verify action');
369
+ await httpArg.request({
370
+ method: 'POST', url: responses.text(verify.href, 'LFS verify href'),
371
+ headers: { ...actionHeaders(verify), accept: lfsMediaType, 'content-type': lfsMediaType },
372
+ body: Buffer.from(JSON.stringify({ oid, size })), expectedStatus: [200],
373
+ });
374
+ }
375
+ return { oid, size };
376
+ }
377
+
378
+ async #readBack(httpArg: ForgeFixtureHttpClient, refsArg: ISeededRepositoryRefs): Promise<IGiteaSeedManifestRepository> {
379
+ const specArg = refsArg.spec;
380
+ const repoPath = `/api/v1/repos/${segment(specArg.owner)}/${segment(specArg.name)}`;
381
+ const repository = responses.record(await this.#api(httpArg, 'GET', repoPath, [200]), 'repository');
382
+ const owner = responses.record(repository.owner, 'repository owner');
383
+ const branches: IGiteaSeedManifestRepository['branches'] = [];
384
+ for (const name of [defaultBranch, ...(specArg.branches ?? []).map((branchArg) => branchArg.name)]) {
385
+ const branch = await this.#api(httpArg, 'GET', `${repoPath}/branches/${segment(name)}`, [200]);
386
+ branches.push({ name, commitSha: responses.text(responses.field(responses.field(branch, 'commit', 'branch'), 'id', 'branch commit'), 'branch commit id') });
387
+ }
388
+ const issues: IGiteaSeedManifestRepository['issues'] = [];
389
+ const pullRequests: IGiteaSeedManifestRepository['pullRequests'] = [];
390
+ for (const item of refsArg.items) {
391
+ if (item.kind === 'issue') {
392
+ const issue = responses.record(await this.#api(httpArg, 'GET', `${repoPath}/issues/${item.number}`, [200]), 'issue');
393
+ const comments = responses.array(await this.#api(httpArg, 'GET', `${repoPath}/issues/${item.number}/comments`, [200]), 'comments');
394
+ const milestone = issue.milestone === null || issue.milestone === undefined
395
+ ? null
396
+ : responses.text(responses.field(issue.milestone, 'title', 'issue milestone'), 'milestone title');
397
+ issues.push({
398
+ key: item.key,
399
+ id: responses.integer(issue.id, 'issue id'),
400
+ number: responses.integer(issue.number, 'issue number'),
401
+ state: this.#state(issue.state),
402
+ author: this.#author(issue.user),
403
+ labels: responses.array(issue.labels ?? [], 'issue labels')
404
+ .map((labelArg) => responses.text(responses.field(labelArg, 'name', 'issue label'), 'label name')),
405
+ milestone,
406
+ assignees: responses.array(issue.assignees ?? [], 'issue assignees').map((assigneeArg) => this.#author(assigneeArg).login),
407
+ comments: comments.map((commentArg) => ({
408
+ id: responses.integer(responses.field(commentArg, 'id', 'comment'), 'comment id'),
409
+ author: this.#author(responses.field(commentArg, 'user', 'comment')),
410
+ })),
411
+ });
412
+ } else {
413
+ const pull = responses.record(await this.#api(httpArg, 'GET', `${repoPath}/pulls/${item.number}`, [200]), 'pull request');
414
+ const head = responses.record(pull.head, 'pull request head');
415
+ const base = responses.record(pull.base, 'pull request base');
416
+ pullRequests.push({
417
+ key: item.key,
418
+ id: responses.integer(pull.id, 'pull request id'),
419
+ number: responses.integer(pull.number, 'pull request number'),
420
+ state: this.#state(pull.state),
421
+ author: this.#author(pull.user),
422
+ head: responses.text(head.ref, 'pull request head ref'),
423
+ base: responses.text(base.ref, 'pull request base ref'),
424
+ headSha: responses.text(head.sha, 'pull request head sha'),
425
+ });
426
+ }
427
+ }
428
+ return {
429
+ owner: specArg.owner,
430
+ name: specArg.name,
431
+ fullName: responses.text(repository.full_name, 'repository full name'),
432
+ id: responses.integer(repository.id, 'repository id'),
433
+ ownerId: responses.integer(owner.id, 'repository owner id'),
434
+ private: responses.boolean(repository.private, 'repository private'),
435
+ internal: responses.boolean(repository.internal, 'repository internal'),
436
+ defaultBranch: responses.text(repository.default_branch, 'repository default branch'),
437
+ htmlUrl: responses.text(repository.html_url, 'repository html url'),
438
+ cloneUrl: responses.text(repository.clone_url, 'repository clone url'),
439
+ branches,
440
+ tags: refsArg.tags,
441
+ labels: [...refsArg.labels].map(([name, id]) => ({ name, id })),
442
+ milestones: [...refsArg.milestones].map(([title, milestone]) => ({ title, ...milestone })),
443
+ issues,
444
+ pullRequests,
445
+ releases: refsArg.releases,
446
+ lfsObjects: refsArg.lfsObjects,
447
+ };
448
+ }
449
+
450
+ /** Tag responses carry the target commit as `commit.sha`. */
451
+ #tagCommitSha(tagArg: unknown): string {
452
+ return responses.text(responses.field(responses.field(tagArg, 'commit', 'tag'), 'sha', 'tag commit'), 'tag commit sha');
453
+ }
454
+
455
+ #author(userArg: unknown): IGiteaSeedManifestAuthor {
456
+ const user = responses.record(userArg, 'user');
457
+ return { id: responses.integer(user.id, 'user id'), login: responses.text(user.login, 'user login') };
458
+ }
459
+
460
+ #state(valueArg: unknown): 'open' | 'closed' {
461
+ if (valueArg !== 'open' && valueArg !== 'closed') throw new Error('Expected state to be open or closed.');
462
+ return valueArg;
463
+ }
464
+
465
+ #visibility(valueArg: unknown): TGiteaVisibility {
466
+ if (valueArg !== 'public' && valueArg !== 'limited' && valueArg !== 'private') {
467
+ throw new Error('Expected visibility to be public, limited or private.');
468
+ }
469
+ return valueArg;
470
+ }
471
+
472
+ async #api(
473
+ httpArg: ForgeFixtureHttpClient,
474
+ methodArg: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
475
+ pathArg: string,
476
+ expectedStatusArg: number[],
477
+ optionsArg: { json?: unknown; sudo?: string } = {},
478
+ ): Promise<unknown> {
479
+ const headers: Record<string, string> = { authorization: `token ${this.#fixture.runtime.admin.token}` };
480
+ if (optionsArg.sudo !== undefined) headers.sudo = optionsArg.sudo;
481
+ return httpArg.requestJson({
482
+ method: methodArg, url: pathArg, headers, expectedStatus: expectedStatusArg,
483
+ ...(optionsArg.json === undefined ? {} : { json: optionsArg.json }),
484
+ });
485
+ }
486
+ }