@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.
@@ -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.1.0",
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.3.0",
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.6.0",
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 and LFS objects 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.
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, lfsTransferTimeoutMs: 30 * 60 * 1000,
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 REST 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).
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:
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@foss.global/codefeed',
6
- version: '4.1.0',
6
+ version: '4.2.0',
7
7
  description: 'The @foss.global/codefeed module generates commit/activity feeds from forge providers including Gitea and GitManager.'
8
8
  }
@@ -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
- releases: await this.#releases(path, signalArg),
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: await this.#unpagedCount(`${path}/branch_protections`, options),
544
- tagProtections: await this.#unpagedCount(`${path}/tag_protections`, options),
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
- async #releases(pathArg: string, signalArg?: AbortSignal): Promise<IRepository['releases']> {
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: listingMeasure(releases),
671
- drafts: listingMeasure(releases, drafts),
672
- assets: listingMeasure(releases, releaseAssets.count),
673
- assetBytes: listingMeasure(releases, releaseAssets.bytes),
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