@foss.global/codefeed 3.2.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +24 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.giteasourceinventory.d.ts +32 -7
- package/dist_ts/classes.giteasourceinventory.js +65 -17
- package/dist_ts/classes.giteasourcetransfer.d.ts +26 -10
- package/dist_ts/classes.giteasourcetransfer.js +108 -46
- package/package.json +2 -2
- package/readme.md +11 -5
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.giteasourceinventory.ts +64 -16
- package/ts/classes.giteasourcetransfer.ts +107 -44
|
@@ -29,10 +29,15 @@ export interface IGiteaSourceTransferOptions {
|
|
|
29
29
|
lfsTransferTimeoutMs?: number;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* A source repository: its owner's and its own name, which Git and LFS reach
|
|
34
|
+
* it by, and the id Gitea gave it, which every read checks those names against.
|
|
35
|
+
*/
|
|
33
36
|
export interface IGiteaSourceRepository {
|
|
34
37
|
owner: string;
|
|
35
38
|
name: string;
|
|
39
|
+
/** Gitea's repository id, a decimal string. */
|
|
40
|
+
id: string;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export interface IGiteaMirrorImportOptions {
|
|
@@ -106,35 +111,34 @@ export class GiteaSourceTransfer {
|
|
|
106
111
|
* the GitManager's data directory, in the process-wide `GitScratchBudget`,
|
|
107
112
|
* and the reservation is released once the clone has ended. While it runs
|
|
108
113
|
* the import holds `target`: writes to it through the GitManager fail there
|
|
109
|
-
* with `repositoryBusy`.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* `
|
|
114
|
-
*
|
|
115
|
-
*
|
|
114
|
+
* with `repositoryBusy`. Git clones by name, so the repository `id` is
|
|
115
|
+
* read by id both before the clone and after it, and must still be named
|
|
116
|
+
* `owner/name` (compared without case, as Gitea looks names up): a rename,
|
|
117
|
+
* a transfer or a rename and re-create rejects with
|
|
118
|
+
* `repository-identity-changed`, a deleted repository with `http-404`, also
|
|
119
|
+
* when the clone itself failed. A rejection from the read after the clone
|
|
120
|
+
* leaves the clone at `target` for the caller to discard. Rejects with a
|
|
121
|
+
* fixed `ForgeInventoryError` code: `invalid-repository`, `invalid-target`,
|
|
122
|
+
* `gitmanager-not-running`, a REST code such as `http-404`,
|
|
123
|
+
* `repository-identity-changed`, `git-workdir-failed`,
|
|
124
|
+
* `git-workdir-too-small`, `git-mirror-failed` (also when GitManager refuses
|
|
125
|
+
* `target`, such as with `repositoryBusy` while another transfer holds it)
|
|
126
|
+
* or `request-aborted` (an abort, or a GitManager stop that cancelled the
|
|
127
|
+
* clone). GitManager error text is never passed on.
|
|
116
128
|
*/
|
|
117
129
|
public async importMirror(
|
|
118
130
|
repositoryArg: IGiteaSourceRepository,
|
|
119
131
|
gitManagerArg: plugins.TGitManager,
|
|
120
132
|
optionsArg: IGiteaMirrorImportOptions,
|
|
121
133
|
): Promise<IGiteaMirrorImport> {
|
|
122
|
-
const
|
|
134
|
+
const repository = this.#repository(repositoryArg);
|
|
135
|
+
const { owner, name } = repository;
|
|
123
136
|
const target = optionsArg?.target;
|
|
124
137
|
if (typeof target?.org !== 'string' || typeof target?.repo !== 'string') throw new validation.ForgeInventoryError('invalid-target');
|
|
125
138
|
const { signal } = optionsArg;
|
|
126
139
|
if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
|
|
127
140
|
if (!gitManagerArg.running) throw new validation.ForgeInventoryError('gitmanager-not-running');
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
const repository = validation.record((await this.#client.request(
|
|
131
|
-
new URL(`${this.#options.baseUrl}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`), { signal },
|
|
132
|
-
)).body);
|
|
133
|
-
reportedSizeKiB = BigInt(validation.decimal(repository.size));
|
|
134
|
-
} catch (error) {
|
|
135
|
-
if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
|
|
136
|
-
throw error;
|
|
137
|
-
}
|
|
141
|
+
const reportedSizeKiB = BigInt(validation.decimal((await this.#identified(repository, signal)).size));
|
|
138
142
|
let dataDir: string;
|
|
139
143
|
try {
|
|
140
144
|
dataDir = (await gitManagerArg.getStatus()).dataDir;
|
|
@@ -142,25 +146,31 @@ export class GiteaSourceTransfer {
|
|
|
142
146
|
throw new validation.ForgeInventoryError('git-workdir-failed');
|
|
143
147
|
}
|
|
144
148
|
const release = await reserveMirrorScratch(dataDir, reportedSizeKiB, signal);
|
|
149
|
+
let imported: plugins.TGitManagerRepositoryInfo;
|
|
145
150
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
try {
|
|
152
|
+
imported = await gitManagerArg.importMirror({
|
|
153
|
+
org: target.org,
|
|
154
|
+
repo: target.repo,
|
|
155
|
+
remoteUrl: giteaCloneUrl(this.#options.baseUrl, owner, name),
|
|
156
|
+
credential: { kind: 'basic', username: this.#options.login, password: this.#options.token },
|
|
157
|
+
caCertificates: this.#options.caCertificates,
|
|
158
|
+
timeoutMs: this.#options.gitTimeoutMs,
|
|
159
|
+
}, { signal });
|
|
160
|
+
} finally {
|
|
161
|
+
// The import settles only after Git has exited, so the scratch is free again.
|
|
162
|
+
release();
|
|
163
|
+
}
|
|
155
164
|
} catch (error) {
|
|
156
165
|
const { GitManagerError } = await plugins.getGitManager();
|
|
157
|
-
|
|
166
|
+
if (signal?.aborted || (error instanceof GitManagerError && error.code === 'transferAborted')) {
|
|
167
|
+
throw new validation.ForgeInventoryError('request-aborted');
|
|
168
|
+
}
|
|
158
169
|
// GitManager errors are redacted, but still remote text.
|
|
159
|
-
|
|
160
|
-
} finally {
|
|
161
|
-
// The import settles only after Git has exited, so the scratch is free again.
|
|
162
|
-
release();
|
|
170
|
+
return this.#explained(repository, new validation.ForgeInventoryError('git-mirror-failed'), signal);
|
|
163
171
|
}
|
|
172
|
+
await this.#identified(repository, signal);
|
|
173
|
+
return { repository: imported, reportedSizeKiB: reportedSizeKiB.toString() };
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
/**
|
|
@@ -169,11 +179,16 @@ export class GiteaSourceTransfer {
|
|
|
169
179
|
* the action's headers only. Resolves with `{ status: 'missing' }` when
|
|
170
180
|
* Gitea answers error 404, otherwise with a stream of the object verified
|
|
171
181
|
* against `oid` and `size` as described for `LfsBatchClient.download`.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
182
|
+
* LFS reaches the repository by name, so once Gitea has answered, the
|
|
183
|
+
* repository `id` is read by id as for `importMirror`; when it is no longer
|
|
184
|
+
* named `owner/name` the download is discarded with
|
|
185
|
+
* `repository-identity-changed`, when it is deleted with `http-404`, also
|
|
186
|
+
* when the download itself failed. Rejects with a fixed code, such as
|
|
187
|
+
* `lfs-object-error-422` for a size Gitea disagrees with.
|
|
174
188
|
*/
|
|
175
189
|
public async downloadLfsObject(repositoryArg: IGiteaSourceRepository, oidArg: string, sizeArg: number, optionsArg: ILfsRequestOptions = {}): Promise<TLfsDownload> {
|
|
176
|
-
const
|
|
190
|
+
const repository = this.#repository(repositoryArg);
|
|
191
|
+
const { owner, name } = repository;
|
|
177
192
|
const client = new LfsBatchClient({
|
|
178
193
|
endpoint: `${giteaCloneUrl(this.#options.baseUrl, owner, name)}/info/lfs`,
|
|
179
194
|
// Gitea's LFS routes take the token as a Basic password, not as a `token` authorization.
|
|
@@ -184,18 +199,64 @@ export class GiteaSourceTransfer {
|
|
|
184
199
|
maxResponseBytes: this.#options.maxResponseBytes,
|
|
185
200
|
batchSize: 1,
|
|
186
201
|
});
|
|
202
|
+
let download: TLfsDownload;
|
|
187
203
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
204
|
+
download = await client.download({ oid: oidArg, size: sizeArg }, optionsArg);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
client.close();
|
|
207
|
+
if (optionsArg.signal?.aborted) throw error;
|
|
208
|
+
return this.#explained(repository, error, optionsArg.signal);
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
// The batch answer and the object request have both reached Gitea by now; neither can still go to another repository.
|
|
212
|
+
await this.#identified(repository, optionsArg.signal);
|
|
195
213
|
} catch (error) {
|
|
214
|
+
if (download.status === 'available') download.stream.destroy();
|
|
196
215
|
client.close();
|
|
197
216
|
throw error;
|
|
198
217
|
}
|
|
218
|
+
if (download.status === 'available') {
|
|
219
|
+
download.stream.once('close', () => client.close());
|
|
220
|
+
} else {
|
|
221
|
+
client.close();
|
|
222
|
+
}
|
|
223
|
+
return download;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Reads the repository by its id and checks that Gitea still names it
|
|
228
|
+
* `owner/name`, without case as Gitea looks names up: other names reject
|
|
229
|
+
* with `repository-identity-changed`, a deleted repository with `http-404`.
|
|
230
|
+
*/
|
|
231
|
+
async #identified(repositoryArg: IGiteaSourceRepository, signalArg: AbortSignal | undefined): Promise<Record<string, unknown>> {
|
|
232
|
+
let record: Record<string, unknown>;
|
|
233
|
+
try {
|
|
234
|
+
record = validation.record((await this.#client.request(
|
|
235
|
+
new URL(`${this.#options.baseUrl}/api/v1/repositories/${repositoryArg.id}`), { signal: signalArg },
|
|
236
|
+
)).body);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (signalArg?.aborted) throw new validation.ForgeInventoryError('request-aborted');
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
const owner = validation.record(record.owner).login;
|
|
242
|
+
if (validation.decimal(record.id) !== repositoryArg.id || typeof owner !== 'string' || typeof record.name !== 'string'
|
|
243
|
+
|| owner.toLowerCase() !== repositoryArg.owner.toLowerCase() || record.name.toLowerCase() !== repositoryArg.name.toLowerCase()) {
|
|
244
|
+
throw new validation.ForgeInventoryError('repository-identity-changed');
|
|
245
|
+
}
|
|
246
|
+
return record;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Rejects with `errorArg`, a failed read by name, unless the identity read
|
|
251
|
+
* explains it: then with `repository-identity-changed` or `http-404`.
|
|
252
|
+
*/
|
|
253
|
+
async #explained(repositoryArg: IGiteaSourceRepository, errorArg: unknown, signalArg: AbortSignal | undefined): Promise<never> {
|
|
254
|
+
try {
|
|
255
|
+
await this.#identified(repositoryArg, signalArg);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (error instanceof validation.ForgeInventoryError && (error.code === 'repository-identity-changed' || error.code === 'http-404')) throw error;
|
|
258
|
+
}
|
|
259
|
+
throw errorArg;
|
|
199
260
|
}
|
|
200
261
|
|
|
201
262
|
#repository(repositoryArg: IGiteaSourceRepository): IGiteaSourceRepository {
|
|
@@ -204,8 +265,10 @@ export class GiteaSourceTransfer {
|
|
|
204
265
|
if (segments.length !== 1) throw new validation.ForgeInventoryError('invalid-repository');
|
|
205
266
|
return segments[0];
|
|
206
267
|
};
|
|
268
|
+
const id = repositoryArg?.id;
|
|
269
|
+
if (typeof id !== 'string') throw new validation.ForgeInventoryError('invalid-repository');
|
|
207
270
|
try {
|
|
208
|
-
return { owner: single(repositoryArg?.owner), name: single(repositoryArg?.name) };
|
|
271
|
+
return { owner: single(repositoryArg?.owner), name: single(repositoryArg?.name), id: validation.decimal(id) };
|
|
209
272
|
} catch {
|
|
210
273
|
throw new validation.ForgeInventoryError('invalid-repository');
|
|
211
274
|
}
|