@foss.global/codefeed 4.1.0 → 4.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.
- package/changelog.md +20 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.giteasourceinventory.js +129 -10
- package/dist_ts/classes.giteasourcetransfer.d.ts +55 -3
- package/dist_ts/classes.giteasourcetransfer.js +155 -4
- package/dist_ts/classes.lfsbatchclient.js +17 -118
- package/dist_ts/forgeinventory.transfer.d.ts +75 -0
- package/dist_ts/forgeinventory.transfer.js +174 -0
- package/package.json +3 -3
- package/readme.md +12 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.giteasourceinventory.ts +136 -10
- package/ts/classes.giteasourcetransfer.ts +182 -4
- package/ts/classes.lfsbatchclient.ts +15 -98
- package/ts/forgeinventory.transfer.ts +203 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import * as validation from './forgeinventory.validation.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reads and drops at most 64 KiB of a response body that carries nothing the
|
|
5
|
+
* caller uses, so its connection can be reused; a longer or failing body is
|
|
6
|
+
* discarded with its connection.
|
|
7
|
+
*/
|
|
8
|
+
export declare const discardBody: (bodyArg: AsyncIterable<Uint8Array> | null) => Promise<void>;
|
|
9
|
+
export interface ITransferBoundsOptions {
|
|
10
|
+
/** Wall-clock limit of the whole transfer. */
|
|
11
|
+
limitMs: number;
|
|
12
|
+
/** Longest gap between two chunks of a watched source. */
|
|
13
|
+
idleMs: number;
|
|
14
|
+
/** The code a passed wall-clock or idle limit fails with. */
|
|
15
|
+
timedOutCode: string;
|
|
16
|
+
/** The code a watched source that goes on after the transfer ended fails with. */
|
|
17
|
+
failedCode: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ITransferBounds {
|
|
20
|
+
/** Aborts once the caller aborts, a limit passes or the transfer ends. */
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
/** Ends the transfer: clears its timers and aborts `signal`. Idempotent. */
|
|
23
|
+
end: () => void;
|
|
24
|
+
/**
|
|
25
|
+
* The error the transfer fails with: the reason that ended it (`request-aborted`
|
|
26
|
+
* or the timeout code), else `errorArg` when it is a `ForgeInventoryError`,
|
|
27
|
+
* else `fallbackArg`.
|
|
28
|
+
*/
|
|
29
|
+
failure: (errorArg: unknown, fallbackArg: string) => validation.ForgeInventoryError;
|
|
30
|
+
/** Runs one step of the transfer; it rejects as soon as the transfer ends, even if the step does not. */
|
|
31
|
+
run: <T>(stepArg: () => Promise<T>) => Promise<T>;
|
|
32
|
+
/** Re-arms the idle limit, before a step that waits on the network without a watched source. */
|
|
33
|
+
touch: () => void;
|
|
34
|
+
/** Passes chunks on and re-arms the idle limit on each one. */
|
|
35
|
+
watch: (sourceArg: AsyncIterable<Uint8Array>) => AsyncGenerator<Uint8Array, void, undefined>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The bounds of one streamed transfer: the caller's signal, a wall-clock limit
|
|
39
|
+
* and an idle limit that `watch` re-arms on every chunk. Whichever ends it
|
|
40
|
+
* first decides the failure code. Every transfer must be ended with `end`.
|
|
41
|
+
*/
|
|
42
|
+
export declare const createTransferBounds: (signalArg: AbortSignal | undefined, optionsArg: ITransferBoundsOptions) => ITransferBounds;
|
|
43
|
+
export interface IHeldBackCodes {
|
|
44
|
+
/** A chunk that is not a `Uint8Array`. */
|
|
45
|
+
invalidChunk: string;
|
|
46
|
+
/** More bytes than the declared size. */
|
|
47
|
+
overrun: string;
|
|
48
|
+
/** Fewer bytes than the declared size. */
|
|
49
|
+
short: string;
|
|
50
|
+
}
|
|
51
|
+
export interface IHeldBackChecks {
|
|
52
|
+
/** Sees every non-empty chunk in order, before it is passed on. */
|
|
53
|
+
inspect?: (chunkArg: Uint8Array) => void;
|
|
54
|
+
/** Runs once exactly `size` bytes have arrived, before the last chunk is passed on; a rejection keeps it back. */
|
|
55
|
+
complete?: () => void | Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Passes the bytes of `sourceArg` on while it counts them. The last chunk is
|
|
59
|
+
* held back until the source has ended with exactly `sizeArg` bytes and
|
|
60
|
+
* `checksArg.complete` has passed, so a consumer never receives a complete
|
|
61
|
+
* body that is wrong; otherwise it throws `codesArg.overrun` as soon as the
|
|
62
|
+
* size is exceeded, `codesArg.short` when the source ends early, or what
|
|
63
|
+
* `complete` threw.
|
|
64
|
+
*/
|
|
65
|
+
export declare function heldBackChunks(sourceArg: AsyncIterable<Uint8Array>, sizeArg: number, codesArg: IHeldBackCodes, checksArg?: IHeldBackChecks): AsyncGenerator<Uint8Array, void, undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* The readable stream of one transfer's bytes. It pulls `chunksArg` with
|
|
68
|
+
* backpressure and ends `boundsArg` and calls `onEndArg` as soon as it is
|
|
69
|
+
* destroyed, also while a pull is pending: ending the bounds aborts the
|
|
70
|
+
* request, which closes its connection, and the pending pull's result is
|
|
71
|
+
* dropped. (`Readable.from` waits for a pending pull before it finishes a
|
|
72
|
+
* destroy, so a stalled body would keep its connection until the idle limit.)
|
|
73
|
+
* The stream errors with what `chunksArg` throws.
|
|
74
|
+
*/
|
|
75
|
+
export declare const createTransferStream: (boundsArg: ITransferBounds, chunksArg: AsyncGenerator<Uint8Array, void, undefined>, onEndArg?: () => void) => InstanceType<typeof plugins.stream.Readable>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import * as validation from './forgeinventory.validation.js';
|
|
3
|
+
/** Response bytes read from an unused answer before the rest is discarded. */
|
|
4
|
+
const maxDiscardedResponseBytes = 64 * 1024;
|
|
5
|
+
/**
|
|
6
|
+
* Reads and drops at most 64 KiB of a response body that carries nothing the
|
|
7
|
+
* caller uses, so its connection can be reused; a longer or failing body is
|
|
8
|
+
* discarded with its connection.
|
|
9
|
+
*/
|
|
10
|
+
export const discardBody = async (bodyArg) => {
|
|
11
|
+
if (!bodyArg)
|
|
12
|
+
return;
|
|
13
|
+
const chunks = bodyArg[Symbol.asyncIterator]();
|
|
14
|
+
let bytes = 0;
|
|
15
|
+
try {
|
|
16
|
+
while (bytes <= maxDiscardedResponseBytes) {
|
|
17
|
+
const result = await chunks.next();
|
|
18
|
+
if (result.done)
|
|
19
|
+
return;
|
|
20
|
+
bytes += result.value.byteLength;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// The status already decided the outcome.
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
try {
|
|
28
|
+
await chunks.return?.();
|
|
29
|
+
}
|
|
30
|
+
catch { /* The connection is discarded. */ }
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The bounds of one streamed transfer: the caller's signal, a wall-clock limit
|
|
35
|
+
* and an idle limit that `watch` re-arms on every chunk. Whichever ends it
|
|
36
|
+
* first decides the failure code. Every transfer must be ended with `end`.
|
|
37
|
+
*/
|
|
38
|
+
export const createTransferBounds = (signalArg, optionsArg) => {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
let reason = null;
|
|
41
|
+
const stop = (reasonArg) => {
|
|
42
|
+
reason ??= reasonArg;
|
|
43
|
+
controller.abort();
|
|
44
|
+
};
|
|
45
|
+
const abort = () => stop('request-aborted');
|
|
46
|
+
if (signalArg?.aborted)
|
|
47
|
+
abort();
|
|
48
|
+
signalArg?.addEventListener('abort', abort, { once: true });
|
|
49
|
+
const deadline = setTimeout(() => stop(optionsArg.timedOutCode), optionsArg.limitMs);
|
|
50
|
+
let idle;
|
|
51
|
+
const rearm = () => {
|
|
52
|
+
clearTimeout(idle);
|
|
53
|
+
if (!controller.signal.aborted)
|
|
54
|
+
idle = setTimeout(() => stop(optionsArg.timedOutCode), optionsArg.idleMs);
|
|
55
|
+
};
|
|
56
|
+
rearm();
|
|
57
|
+
const end = () => {
|
|
58
|
+
clearTimeout(deadline);
|
|
59
|
+
clearTimeout(idle);
|
|
60
|
+
signalArg?.removeEventListener('abort', abort);
|
|
61
|
+
stop('transfer-ended');
|
|
62
|
+
};
|
|
63
|
+
const failure = (errorArg, fallbackArg) => {
|
|
64
|
+
if (reason !== null && reason !== 'transfer-ended')
|
|
65
|
+
return new validation.ForgeInventoryError(reason);
|
|
66
|
+
return errorArg instanceof validation.ForgeInventoryError ? errorArg : new validation.ForgeInventoryError(fallbackArg);
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
end,
|
|
71
|
+
failure,
|
|
72
|
+
touch: rearm,
|
|
73
|
+
run: async (stepArg) => {
|
|
74
|
+
if (controller.signal.aborted)
|
|
75
|
+
throw failure(undefined, 'request-aborted');
|
|
76
|
+
let onAbort;
|
|
77
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
78
|
+
onAbort = () => reject(failure(undefined, 'request-aborted'));
|
|
79
|
+
controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
return await Promise.race([stepArg(), aborted]);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
watch: async function* (sourceArg) {
|
|
89
|
+
for await (const chunk of sourceArg) {
|
|
90
|
+
if (controller.signal.aborted)
|
|
91
|
+
throw failure(undefined, optionsArg.failedCode);
|
|
92
|
+
rearm();
|
|
93
|
+
yield chunk;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Passes the bytes of `sourceArg` on while it counts them. The last chunk is
|
|
100
|
+
* held back until the source has ended with exactly `sizeArg` bytes and
|
|
101
|
+
* `checksArg.complete` has passed, so a consumer never receives a complete
|
|
102
|
+
* body that is wrong; otherwise it throws `codesArg.overrun` as soon as the
|
|
103
|
+
* size is exceeded, `codesArg.short` when the source ends early, or what
|
|
104
|
+
* `complete` threw.
|
|
105
|
+
*/
|
|
106
|
+
export async function* heldBackChunks(sourceArg, sizeArg, codesArg, checksArg = {}) {
|
|
107
|
+
let bytes = 0;
|
|
108
|
+
let held = null;
|
|
109
|
+
for await (const chunk of sourceArg) {
|
|
110
|
+
if (!(chunk instanceof Uint8Array))
|
|
111
|
+
throw new validation.ForgeInventoryError(codesArg.invalidChunk);
|
|
112
|
+
if (chunk.byteLength === 0)
|
|
113
|
+
continue;
|
|
114
|
+
bytes += chunk.byteLength;
|
|
115
|
+
if (bytes > sizeArg)
|
|
116
|
+
throw new validation.ForgeInventoryError(codesArg.overrun);
|
|
117
|
+
checksArg.inspect?.(chunk);
|
|
118
|
+
if (held)
|
|
119
|
+
yield held;
|
|
120
|
+
held = chunk;
|
|
121
|
+
}
|
|
122
|
+
if (bytes !== sizeArg)
|
|
123
|
+
throw new validation.ForgeInventoryError(codesArg.short);
|
|
124
|
+
await checksArg.complete?.();
|
|
125
|
+
if (held)
|
|
126
|
+
yield held;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The readable stream of one transfer's bytes. It pulls `chunksArg` with
|
|
130
|
+
* backpressure and ends `boundsArg` and calls `onEndArg` as soon as it is
|
|
131
|
+
* destroyed, also while a pull is pending: ending the bounds aborts the
|
|
132
|
+
* request, which closes its connection, and the pending pull's result is
|
|
133
|
+
* dropped. (`Readable.from` waits for a pending pull before it finishes a
|
|
134
|
+
* destroy, so a stalled body would keep its connection until the idle limit.)
|
|
135
|
+
* The stream errors with what `chunksArg` throws.
|
|
136
|
+
*/
|
|
137
|
+
export const createTransferStream = (boundsArg, chunksArg, onEndArg) => {
|
|
138
|
+
let pulling = false;
|
|
139
|
+
const stream = new plugins.stream.Readable({
|
|
140
|
+
read() {
|
|
141
|
+
if (pulling)
|
|
142
|
+
return;
|
|
143
|
+
pulling = true;
|
|
144
|
+
void (async () => {
|
|
145
|
+
try {
|
|
146
|
+
while (!stream.destroyed) {
|
|
147
|
+
const result = await chunksArg.next();
|
|
148
|
+
if (stream.destroyed)
|
|
149
|
+
return;
|
|
150
|
+
if (result.done)
|
|
151
|
+
return void stream.push(null);
|
|
152
|
+
if (!stream.push(result.value))
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
stream.destroy(error);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
pulling = false;
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
},
|
|
164
|
+
destroy(errorArg, callbackArg) {
|
|
165
|
+
boundsArg.end();
|
|
166
|
+
onEndArg?.();
|
|
167
|
+
// Runs the generator's cleanup once a pending pull has settled; nothing waits for it.
|
|
168
|
+
chunksArg.return(undefined).catch(() => undefined);
|
|
169
|
+
callbackArg(errorArg);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
return stream;
|
|
173
|
+
};
|
|
174
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZm9yZ2VpbnZlbnRvcnkudHJhbnNmZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi90cy9mb3JnZWludmVudG9yeS50cmFuc2Zlci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGNBQWMsQ0FBQztBQUN4QyxPQUFPLEtBQUssVUFBVSxNQUFNLGdDQUFnQyxDQUFDO0FBRTdELDhFQUE4RTtBQUM5RSxNQUFNLHlCQUF5QixHQUFHLEVBQUUsR0FBRyxJQUFJLENBQUM7QUFFNUM7Ozs7R0FJRztBQUNILE1BQU0sQ0FBQyxNQUFNLFdBQVcsR0FBRyxLQUFLLEVBQUUsT0FBeUMsRUFBaUIsRUFBRTtJQUM1RixJQUFJLENBQUMsT0FBTztRQUFFLE9BQU87SUFDckIsTUFBTSxNQUFNLEdBQUcsT0FBTyxDQUFDLE1BQU0sQ0FBQyxhQUFhLENBQUMsRUFBRSxDQUFDO0lBQy9DLElBQUksS0FBSyxHQUFHLENBQUMsQ0FBQztJQUNkLElBQUksQ0FBQztRQUNILE9BQU8sS0FBSyxJQUFJLHlCQUF5QixFQUFFLENBQUM7WUFDMUMsTUFBTSxNQUFNLEdBQUcsTUFBTSxNQUFNLENBQUMsSUFBSSxFQUFFLENBQUM7WUFDbkMsSUFBSSxNQUFNLENBQUMsSUFBSTtnQkFBRSxPQUFPO1lBQ3hCLEtBQUssSUFBSSxNQUFNLENBQUMsS0FBSyxDQUFDLFVBQVUsQ0FBQztRQUNuQyxDQUFDO0lBQ0gsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLDBDQUEwQztJQUM1QyxDQUFDO1lBQVMsQ0FBQztRQUNULElBQUksQ0FBQztZQUFDLE1BQU0sTUFBTSxDQUFDLE1BQU0sRUFBRSxFQUFFLENBQUM7UUFBQyxDQUFDO1FBQUMsTUFBTSxDQUFDLENBQUMsa0NBQWtDLENBQUMsQ0FBQztJQUMvRSxDQUFDO0FBQ0gsQ0FBQyxDQUFDO0FBZ0NGOzs7O0dBSUc7QUFDSCxNQUFNLENBQUMsTUFBTSxvQkFBb0IsR0FBRyxDQUFDLFNBQWtDLEVB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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foss.global/codefeed",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The @foss.global/codefeed module generates commit/activity feeds from forge providers including Gitea and GitManager.",
|
|
6
6
|
"exports": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"@foss.global/forgefixtures": "^0.3.1",
|
|
15
15
|
"@git.zone/tsbuild": "^6.0.2",
|
|
16
|
-
"@git.zone/tsbundle": "^3.
|
|
16
|
+
"@git.zone/tsbundle": "^3.4.0",
|
|
17
17
|
"@git.zone/tsrun": "^3.0.3",
|
|
18
18
|
"@git.zone/tstest": "^8.0.0",
|
|
19
19
|
"@push.rocks/qenv": "^8.2.1",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@foss.global/gitmanager": "^2.4.0",
|
|
24
|
-
"@foss.global/interfaces": "^2.
|
|
24
|
+
"@foss.global/interfaces": "^2.7.0",
|
|
25
25
|
"@push.rocks/lik": "^6.4.4",
|
|
26
26
|
"@push.rocks/smartnpm": "^2.1.3",
|
|
27
27
|
"@push.rocks/smarttime": "^5.0.1"
|
package/readme.md
CHANGED
|
@@ -109,6 +109,8 @@ Discovery records each repository's Gitea `id` as a canonical decimal string, an
|
|
|
109
109
|
|
|
110
110
|
What is measured: users with their account flags, two-factor and SSH/GPG keys; organizations, members, teams, team memberships and repository grants; repositories with their flags, Git refs split into branches, tags, pull request refs and other refs, Git objects and their uncompressed bytes, LFS objects referenced anywhere in history with the bytes Gitea holds and the number it lacks, issues, pull requests, comments, reviews, review comments, labels, milestones, reactions and attachments; releases including drafts, assets and asset bytes; wiki pages; collaborators, team grants, deploy keys, webhooks, branch and tag protections and push mirrors; packages per type with versions, files and bytes; system webhooks, Actions runners, workflows, runs, artifacts and the number of Actions secrets and variables; unadopted repositories.
|
|
111
111
|
|
|
112
|
+
Beyond counts, each repository keeps records of what the migration recreates. `releaseRecords` lists every release, drafts and prereleases included, sorted by numeric id: tag name, target commitish, name, body, the draft and prerelease flags, created and published times as Gitea reports them (a draft reports its creation time as published, and its tag does not exist as a Git ref yet), an author snapshot (account id and login; Gitea's placeholder for a deleted account has id `-1`) and its assets sorted by numeric id with id, storage `uuid`, name, size, download count and creation time. Gitea exposes no content digest, so a record carries none; a replaced file carries another id and uuid, and the download count moves with every download, so it never decides whether a release changed. `protections` holds the branch protection rules sorted by rule name (code-unit order) with every restriction Gitea reads back, allowlists included, and the tag protection rules sorted by numeric id with their name pattern and allowlist. `releaseRecords` is null while a release count is not complete, and `protections` is null while either protection count is not; the counts then say why.
|
|
113
|
+
|
|
112
114
|
Secrets are counted, never named: the report carries no tokens, Actions secret or variable values or names, webhook secrets or mirror remote URLs. Reasons are adapter codes or fixed text, never response bodies or URLs. Gitea has no read that lists user-scoped Actions secrets; that is listed in `limitations`, since they are reissued. User-scoped webhooks, variables and runners are read as that user through the `Sudo` header.
|
|
113
115
|
|
|
114
116
|
Git-level measures come from a mirror clone of every ref Gitea advertises, `refs/pull/*` included, made with `@foss.global/gitmanager` in a fresh directory below `gitWorkDir` and removed afterwards. The reader's login and token form Basic credentials; the credential reaches Git only through GIT_CONFIG_* entries in the child process environment; it is never in the URL, the command line or any file. The private CA list applies to the clone as well. The mirror's HEAD becomes the repository's `head`: the ref it names as `target` and whether it `resolves`. In a repository that is not `empty`, a HEAD that does not resolve names a missing branch, such as a default branch deleted at the source, and a `target` other than `refs/heads/<defaultBranch>` means Gitea's setting and its Git HEAD disagree. `head` is null when the mirror or its refs could not be read. A detached source HEAD is one such case: GitManager's mirror import requires a symbolic HEAD, so the clone fails with `git-mirror-failed`, and this reader never reports a `target` of null. The mirror's distinct LFS pointers, from every reachable revision, are checked against Gitea in batches of 100: an object with a download action counts as held, one answered with error 404 as missing, and any other answer makes the LFS measures `partial`. A clone that fails or exceeds `gitTimeoutMs` makes every Git and LFS measure of that repository `partial`; only the API-reported size stays complete. A clone starts only once its scratch reservation, twice the reported size plus 64 MiB, fits the free space of the file system that holds `gitWorkDir`, counting the reservations of the clones already in flight from every reader in the process. A clone that does not fit waits for them, in arrival order; one that does not fit even alone makes every Git and LFS measure of its repository `partial` with `git-workdir-too-small`, so the inventory stays incomplete. Size `gitWorkDir` for the largest repositories measured at once.
|
|
@@ -120,7 +122,7 @@ Listings page until an empty page or the `x-total-count` total is reached. A tot
|
|
|
120
122
|
|
|
121
123
|
### Gitea source transfer
|
|
122
124
|
|
|
123
|
-
`GiteaSourceTransfer` reads one repository's Git data
|
|
125
|
+
`GiteaSourceTransfer` reads one repository's Git data, LFS objects and release assets for the one-time migration, with the same reader credentials as the inventory. It never writes to Gitea: Git is read only through `git-upload-pack`, LFS only through batch `download` requests and their download actions, release assets only through GETs of their attachment.
|
|
124
126
|
|
|
125
127
|
```ts
|
|
126
128
|
import { GitManager } from '@foss.global/gitmanager';
|
|
@@ -130,7 +132,8 @@ const source = new GiteaSourceTransfer({
|
|
|
130
132
|
baseUrl: 'https://gitea.example.com', // HTTPS only
|
|
131
133
|
login: readerLogin,
|
|
132
134
|
token: readerToken, // read it from a file; never pass it on a command line
|
|
133
|
-
// caCertificates: [privateCaPem], gitTimeoutMs: 30 * 60 * 1000, requestTimeoutMs: 60_000, maxResponseBytes: 16 * 1024 * 1024,
|
|
135
|
+
// caCertificates: [privateCaPem], gitTimeoutMs: 30 * 60 * 1000, requestTimeoutMs: 60_000, maxResponseBytes: 16 * 1024 * 1024,
|
|
136
|
+
// lfsTransferTimeoutMs: 30 * 60 * 1000, releaseAssetTimeoutMs: 30 * 60 * 1000,
|
|
134
137
|
});
|
|
135
138
|
|
|
136
139
|
// A started GitManager rooted in the caller's own scratch space; codefeed creates no scratch roots.
|
|
@@ -147,6 +150,10 @@ if (download.status === 'missing') {
|
|
|
147
150
|
} else {
|
|
148
151
|
await pipeline(download.stream, destination); // errors unless the bytes are exactly the object
|
|
149
152
|
}
|
|
153
|
+
|
|
154
|
+
// A release asset as the inventory recorded it: { id, uuid, name, size } from releaseRecords[].assets[].
|
|
155
|
+
const asset = await source.downloadReleaseAsset({ owner: 'team', name: 'app', id: '11' }, release.id, recordedAsset, { signal });
|
|
156
|
+
await pipeline(asset.stream, destination); // errors unless exactly `size` bytes of the recorded asset arrived
|
|
150
157
|
```
|
|
151
158
|
|
|
152
159
|
A source repository is named by `owner`, `name` and its Gitea repository `id`, a canonical decimal string as the inventory records it; anything else rejects with `invalid-repository`. Git and the LFS API reach a repository only by name, and a name can move to another repository between two reads: Gitea answers a renamed repository's old path with a redirect, and a new repository can take the old name. Every source read is therefore bracketed by an identity read of `GET /api/v1/repositories/{id}`, which must still name `owner/name` (compared case-insensitively, as Gitea does). A repository that was renamed, or whose name now belongs to another repository, rejects with `repository-identity-changed`; a deleted one rejects with `http-404`. A transfer that fails is explained the same way when the identity read shows one of those two causes.
|
|
@@ -157,6 +164,8 @@ While the import runs, GitManager holds `target`: writes to it, such as `initBar
|
|
|
157
164
|
|
|
158
165
|
`downloadLfsObject` sends one batch `download` request with the reader's Basic credentials, then a GET of the action's href with the action's headers only, and then reads the identity; when that read rejects, the stream is destroyed and the call rejects with its code. A missing object resolves as `{ status: 'missing' }`, so a caller can list the missing oids; any other object error rejects with `lfs-object-error-<code>`, such as `lfs-object-error-422` for a size Gitea disagrees with. The returned stream never holds the whole object: it counts and hashes the bytes as they pass and holds back the last chunk until the body ended with exactly `size` bytes whose SHA-256 is `oid`. Otherwise it errors with `lfs-size-overrun`, `lfs-short-body` or `lfs-digest-mismatch`; a `content-length` other than `size` rejects with `lfs-size-mismatch` before streaming. An abort errors it with `request-aborted`, a transfer longer than `lfsTransferTimeoutMs` or idle for `requestTimeoutMs` with `lfs-transfer-timed-out`. Destroying the stream ends the download.
|
|
159
166
|
|
|
167
|
+
`downloadReleaseAsset(repository, releaseId, asset)` takes the release id and the asset's `id`, `uuid`, `name` and `size` as the inventory recorded them; anything else rejects with `release-asset-invalid` before any request. It reads the repository by id, then `GET /api/v1/repos/{owner}/{name}/releases/{releaseId}`, which must still carry an asset with the same id, uuid, name and size; otherwise it rejects with `release-asset-identity-changed` (a deleted release with `http-404`). The download count is not compared. It then GETs `{baseUrl}/attachments/{uuid}` with the reader's `token` authorization: no redirect is followed and no range is asked for, so any answer but 200 rejects, with `http-<status>` or, for a refused redirect, `release-asset-download-failed`, and a `content-length` other than `size` rejects with `release-asset-size-mismatch`. No digest is computed while the bytes stream: the stream counts them and holds back the last chunk until exactly `size` bytes have arrived and both identity reads have passed once more, so a consumer never receives a complete asset that is short, long or no longer the recorded one. Otherwise it errors with `release-asset-size-overrun`, `release-asset-short-body`, `repository-identity-changed` or `release-asset-identity-changed`. Content digests come from the content itself, such as a publisher's `SHA256SUMS` asset or the importer's own hash. An abort errors it with `request-aborted`, a download longer than `releaseAssetTimeoutMs` (default 30 minutes, at most six hours) or idle for `requestTimeoutMs` with `release-asset-transfer-timed-out`. A failed request is explained by the identity reads as for `downloadLfsObject`. Destroying the stream ends the download; the download's connections close with the stream. The stream fails internally with `release-asset-transfer-failed` when body bytes still arrive after the download has ended, which happens only once the stream is destroyed, so a reader of the stream does not see it.
|
|
168
|
+
|
|
160
169
|
### Git LFS batch client
|
|
161
170
|
|
|
162
171
|
`LfsBatchClient` is a provider-neutral Git LFS batch API client with the `basic` transfer adapter, bound to one LFS endpoint and its credential. `GiteaSourceTransfer` downloads through it; the migration uploads to the native Git service through it.
|
|
@@ -347,7 +356,7 @@ The repo contains:
|
|
|
347
356
|
- A mocked pagination test that does not require network.
|
|
348
357
|
- A GitManager provider mapping test using a local test stub.
|
|
349
358
|
- A default-deny GitManager public-client mapping test using a local test stub.
|
|
350
|
-
- A mocked `GiteaSourceInventory` test, Git-level `GiteaSourceInventory` and `GiteaSourceTransfer` tests against a local HTTPS Git, LFS batch and
|
|
359
|
+
- A mocked `GiteaSourceInventory` test, Git-level `GiteaSourceInventory` and `GiteaSourceTransfer` tests and a release asset download test against a local HTTPS Git, LFS batch, REST and attachment server (needs `openssl` and `git http-backend`), and an opt-in one against a Docker Gitea from `@foss.global/forgefixtures`, run with `FORGEFIXTURES=gitea pnpm test` (Docker required).
|
|
351
360
|
- An `LfsBatchClient` test against local loopback LFS servers over HTTP and over HTTPS with a private CA.
|
|
352
361
|
|
|
353
362
|
Run tests:
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -13,6 +13,9 @@ type IInstance = plugins.fossInterfaces.data.IForgeSourceInstanceInventory;
|
|
|
13
13
|
type TOwner = plugins.fossInterfaces.data.TForgeSourceOwnerInventory;
|
|
14
14
|
type IRepository = plugins.fossInterfaces.data.IForgeSourceRepositoryInventory;
|
|
15
15
|
type IPackageTypeCounts = plugins.fossInterfaces.data.IForgeSourcePackageTypeCounts;
|
|
16
|
+
type IRelease = plugins.fossInterfaces.data.IForgeSourceRelease;
|
|
17
|
+
type IBranchProtection = plugins.fossInterfaces.data.IForgeSourceBranchProtection;
|
|
18
|
+
type ITagProtection = plugins.fossInterfaces.data.IForgeSourceTagProtection;
|
|
16
19
|
type IInventory = plugins.fossInterfaces.data.IForgeSourceInventory;
|
|
17
20
|
|
|
18
21
|
export interface IGiteaSourceInventoryOptions {
|
|
@@ -119,6 +122,35 @@ const requiredString = (valueArg: unknown): string => {
|
|
|
119
122
|
return value;
|
|
120
123
|
};
|
|
121
124
|
|
|
125
|
+
/** A string the forge may leave empty, such as a release body. */
|
|
126
|
+
const text = (valueArg: unknown): string => {
|
|
127
|
+
if (typeof valueArg !== 'string') throw new validation.ForgeInventoryError('invalid-string');
|
|
128
|
+
return valueArg;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** A list of names; Gitea answers an empty one as `null`. */
|
|
132
|
+
const texts = (valueArg: unknown): string[] => {
|
|
133
|
+
if (valueArg === null) return [];
|
|
134
|
+
if (!Array.isArray(valueArg)) throw new validation.ForgeInventoryError('invalid-string');
|
|
135
|
+
return valueArg.map(text);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** A signed account id; Gitea's placeholder for a deleted account is -1. */
|
|
139
|
+
const accountId = (valueArg: unknown): string => {
|
|
140
|
+
if (typeof valueArg === 'number' && Number.isSafeInteger(valueArg)) return String(valueArg);
|
|
141
|
+
throw new validation.ForgeInventoryError('invalid-or-imprecise-integer');
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** An RFC 3339 time as the forge reports it. */
|
|
145
|
+
const requiredTimestamp = (valueArg: unknown): string => {
|
|
146
|
+
const value = validation.timestamp(valueArg);
|
|
147
|
+
if (value === null) throw new validation.ForgeInventoryError('invalid-timestamp');
|
|
148
|
+
return value;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Orders canonical nonnegative decimal strings numerically. */
|
|
152
|
+
const compareDecimal = (aArg: string, bArg: string): number => aArg.length - bArg.length || compareText(aArg, bArg);
|
|
153
|
+
|
|
122
154
|
/** One provider path segment, as an owner or repository name. */
|
|
123
155
|
const segment = (valueArg: unknown): string => {
|
|
124
156
|
const segments = validation.segments(valueArg);
|
|
@@ -142,6 +174,83 @@ const assets = (valueArg: unknown): { count: bigint; bytes: bigint } => {
|
|
|
142
174
|
}), { count: 0n, bytes: 0n });
|
|
143
175
|
};
|
|
144
176
|
|
|
177
|
+
/** A release as Gitea reads it, drafts included, with its assets by numeric id. */
|
|
178
|
+
const releaseRecord = (itemArg: unknown): IRelease => {
|
|
179
|
+
const item = validation.record(itemArg);
|
|
180
|
+
const author = validation.record(item.author);
|
|
181
|
+
const itemAssets = item.assets ?? [];
|
|
182
|
+
if (!Array.isArray(itemAssets)) throw new validation.ForgeInventoryError('invalid-assets');
|
|
183
|
+
return {
|
|
184
|
+
id: validation.decimal(item.id),
|
|
185
|
+
tagName: requiredString(item.tag_name),
|
|
186
|
+
targetCommitish: text(item.target_commitish),
|
|
187
|
+
name: text(item.name),
|
|
188
|
+
body: text(item.body),
|
|
189
|
+
draft: requiredBoolean(item.draft),
|
|
190
|
+
prerelease: requiredBoolean(item.prerelease),
|
|
191
|
+
createdAt: requiredTimestamp(item.created_at),
|
|
192
|
+
publishedAt: requiredTimestamp(item.published_at),
|
|
193
|
+
author: { id: accountId(author.id), login: requiredString(author.login) },
|
|
194
|
+
assets: itemAssets.map((assetArg: unknown) => {
|
|
195
|
+
const asset = validation.record(assetArg);
|
|
196
|
+
return {
|
|
197
|
+
id: validation.decimal(asset.id),
|
|
198
|
+
uuid: requiredString(asset.uuid),
|
|
199
|
+
name: requiredString(asset.name),
|
|
200
|
+
size: validation.decimal(asset.size),
|
|
201
|
+
downloadCount: validation.decimal(asset.download_count),
|
|
202
|
+
createdAt: requiredTimestamp(asset.created_at),
|
|
203
|
+
};
|
|
204
|
+
}).sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)),
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const allowlist = (usernamesArg: unknown, teamsArg: unknown) => ({ usernames: texts(usernamesArg), teams: texts(teamsArg) });
|
|
209
|
+
|
|
210
|
+
/** A branch protection rule with every restriction Gitea reads back. */
|
|
211
|
+
const branchProtection = (itemArg: unknown): IBranchProtection => {
|
|
212
|
+
const item = validation.record(itemArg);
|
|
213
|
+
return {
|
|
214
|
+
ruleName: requiredString(item.rule_name),
|
|
215
|
+
priority: validation.decimal(item.priority),
|
|
216
|
+
enablePush: requiredBoolean(item.enable_push),
|
|
217
|
+
enablePushAllowlist: requiredBoolean(item.enable_push_whitelist),
|
|
218
|
+
pushAllowlist: allowlist(item.push_whitelist_usernames, item.push_whitelist_teams),
|
|
219
|
+
pushAllowlistDeployKeys: requiredBoolean(item.push_whitelist_deploy_keys),
|
|
220
|
+
enableForcePush: requiredBoolean(item.enable_force_push),
|
|
221
|
+
enableForcePushAllowlist: requiredBoolean(item.enable_force_push_allowlist),
|
|
222
|
+
forcePushAllowlist: allowlist(item.force_push_allowlist_usernames, item.force_push_allowlist_teams),
|
|
223
|
+
forcePushAllowlistDeployKeys: requiredBoolean(item.force_push_allowlist_deploy_keys),
|
|
224
|
+
enableMergeAllowlist: requiredBoolean(item.enable_merge_whitelist),
|
|
225
|
+
mergeAllowlist: allowlist(item.merge_whitelist_usernames, item.merge_whitelist_teams),
|
|
226
|
+
enableBypassAllowlist: requiredBoolean(item.enable_bypass_allowlist),
|
|
227
|
+
bypassAllowlist: allowlist(item.bypass_allowlist_usernames, item.bypass_allowlist_teams),
|
|
228
|
+
enableStatusCheck: requiredBoolean(item.enable_status_check),
|
|
229
|
+
statusCheckContexts: texts(item.status_check_contexts),
|
|
230
|
+
requiredApprovals: validation.decimal(item.required_approvals),
|
|
231
|
+
enableApprovalsAllowlist: requiredBoolean(item.enable_approvals_whitelist),
|
|
232
|
+
approvalsAllowlist: allowlist(item.approvals_whitelist_username, item.approvals_whitelist_teams),
|
|
233
|
+
blockOnRejectedReviews: requiredBoolean(item.block_on_rejected_reviews),
|
|
234
|
+
blockOnOfficialReviewRequests: requiredBoolean(item.block_on_official_review_requests),
|
|
235
|
+
blockOnOutdatedBranch: requiredBoolean(item.block_on_outdated_branch),
|
|
236
|
+
dismissStaleApprovals: requiredBoolean(item.dismiss_stale_approvals),
|
|
237
|
+
ignoreStaleApprovals: requiredBoolean(item.ignore_stale_approvals),
|
|
238
|
+
blockAdminMergeOverride: requiredBoolean(item.block_admin_merge_override),
|
|
239
|
+
requireSignedCommits: requiredBoolean(item.require_signed_commits),
|
|
240
|
+
protectedFilePatterns: text(item.protected_file_patterns),
|
|
241
|
+
unprotectedFilePatterns: text(item.unprotected_file_patterns),
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const tagProtection = (itemArg: unknown): ITagProtection => {
|
|
246
|
+
const item = validation.record(itemArg);
|
|
247
|
+
return {
|
|
248
|
+
id: validation.decimal(item.id),
|
|
249
|
+
namePattern: requiredString(item.name_pattern),
|
|
250
|
+
allowlist: allowlist(item.whitelist_usernames, item.whitelist_teams),
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
|
|
145
254
|
/** A `{ <rows>, total_count }` envelope whose rows must match its total. */
|
|
146
255
|
const countedEnvelope = (bodyArg: unknown, rowsKeyArg: string): { rows: unknown[]; failure: string | null } => {
|
|
147
256
|
const body = validation.record(bodyArg);
|
|
@@ -516,6 +625,8 @@ export class GiteaSourceInventory {
|
|
|
516
625
|
const parent = item.parent === undefined || item.parent === null ? null : requiredString(validation.record(item.parent).full_name);
|
|
517
626
|
if (fork !== (parent !== null)) throw new validation.ForgeInventoryError('inconsistent-fork-parent');
|
|
518
627
|
const options = { signal: signalArg };
|
|
628
|
+
const branchRules = await this.#unpaged(`${path}/branch_protections`, options);
|
|
629
|
+
const tagRules = await this.#unpaged(`${path}/tag_protections`, options);
|
|
519
630
|
return {
|
|
520
631
|
owner: ownerArg,
|
|
521
632
|
name: nameArg,
|
|
@@ -533,17 +644,21 @@ export class GiteaSourceInventory {
|
|
|
533
644
|
...await this.#git(ownerArg, nameArg, item, signalArg),
|
|
534
645
|
collaboration: await this.#collaboration(path, features, signalArg),
|
|
535
646
|
// Releases stay readable while their unit is disabled; a disabled wiki answers 404.
|
|
536
|
-
|
|
647
|
+
...await this.#releases(path, signalArg),
|
|
537
648
|
wikiPages: features.wiki ? await this.#count(`${path}/wiki/pages`, { ...options, missingIsEmpty: true }) : unitDisabled('wiki'),
|
|
538
649
|
access: {
|
|
539
650
|
collaborators: await this.#count(`${path}/collaborators`, options),
|
|
540
651
|
teams: await this.#repositoryTeams(path, options),
|
|
541
652
|
deployKeys: await this.#count(`${path}/keys`, options),
|
|
542
653
|
webhooks: await this.#count(`${path}/hooks`, options),
|
|
543
|
-
branchProtections:
|
|
544
|
-
tagProtections:
|
|
654
|
+
branchProtections: branchRules.measure,
|
|
655
|
+
tagProtections: tagRules.measure,
|
|
545
656
|
pushMirrors: await this.#count(`${path}/push_mirrors`, options),
|
|
546
657
|
},
|
|
658
|
+
protections: branchRules.rows === null || tagRules.rows === null ? null : {
|
|
659
|
+
branches: branchRules.rows.map(branchProtection).sort((aArg, bArg) => compareText(aArg.ruleName, bArg.ruleName)),
|
|
660
|
+
tags: tagRules.rows.map(tagProtection).sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)),
|
|
661
|
+
},
|
|
547
662
|
actions: await this.#actions(path, empty, signalArg),
|
|
548
663
|
};
|
|
549
664
|
}
|
|
@@ -655,7 +770,8 @@ export class GiteaSourceInventory {
|
|
|
655
770
|
};
|
|
656
771
|
}
|
|
657
772
|
|
|
658
|
-
|
|
773
|
+
/** The release counts, and the release records once the listing is complete. */
|
|
774
|
+
async #releases(pathArg: string, signalArg?: AbortSignal): Promise<Pick<IRepository, 'releases' | 'releaseRecords'>> {
|
|
659
775
|
// An administrator's listing includes drafts.
|
|
660
776
|
const releases = await this.#list(`${pathArg}/releases`, { signal: signalArg });
|
|
661
777
|
let drafts = 0n;
|
|
@@ -667,10 +783,15 @@ export class GiteaSourceInventory {
|
|
|
667
783
|
releaseAssets = { count: releaseAssets.count + itemAssets.count, bytes: releaseAssets.bytes + itemAssets.bytes };
|
|
668
784
|
}
|
|
669
785
|
return {
|
|
670
|
-
releases:
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
786
|
+
releases: {
|
|
787
|
+
releases: listingMeasure(releases),
|
|
788
|
+
drafts: listingMeasure(releases, drafts),
|
|
789
|
+
assets: listingMeasure(releases, releaseAssets.count),
|
|
790
|
+
assetBytes: listingMeasure(releases, releaseAssets.bytes),
|
|
791
|
+
},
|
|
792
|
+
releaseRecords: releases.failure === null
|
|
793
|
+
? releases.items.map(releaseRecord).sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id))
|
|
794
|
+
: null,
|
|
674
795
|
};
|
|
675
796
|
}
|
|
676
797
|
|
|
@@ -725,13 +846,18 @@ export class GiteaSourceInventory {
|
|
|
725
846
|
}
|
|
726
847
|
|
|
727
848
|
async #unpagedCount(pathArg: string, optionsArg: IListOptions): Promise<TMeasure> {
|
|
849
|
+
return (await this.#unpaged(pathArg, optionsArg)).measure;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** A listing Gitea does not page: its count, and its rows when the read succeeded. */
|
|
853
|
+
async #unpaged(pathArg: string, optionsArg: IListOptions): Promise<{ measure: TMeasure; rows: unknown[] | null }> {
|
|
728
854
|
try {
|
|
729
855
|
const response = (await this.#get(pathArg, optionsArg)).body;
|
|
730
856
|
const body = response === null && optionsArg.nullIsEmpty ? [] : response;
|
|
731
857
|
if (!Array.isArray(body)) throw new validation.ForgeInventoryError('invalid-page');
|
|
732
|
-
return complete(body.length);
|
|
858
|
+
return { measure: complete(body.length), rows: body };
|
|
733
859
|
} catch (error) {
|
|
734
|
-
return partial(0, reason(error));
|
|
860
|
+
return { measure: partial(0, reason(error)), rows: null };
|
|
735
861
|
}
|
|
736
862
|
}
|
|
737
863
|
|