feat: stream release asset uploads
All checks were successful
labels / labels (pull_request) Successful in 10s
ci / test (pull_request) Successful in 17s

This commit is contained in:
codex-bot-andresmgsl 2026-08-30 09:48:02 +00:00
parent c09943ea32
commit 0fac095509
2 changed files with 101 additions and 0 deletions

View file

@ -12,11 +12,13 @@
*/
const pkg = require('../package.json');
const fs = require('node:fs');
const REQUEST_TIMEOUT_MS = 30000;
// Repository migrations clone the full source repository and can legitimately
// take minutes, so they get a much longer budget.
const MIGRATE_TIMEOUT_MS = 10 * 60 * 1000;
const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
class ForgejoClient {
constructor(baseUrl, token = null) {
@ -94,6 +96,47 @@ class ForgejoClient {
return data;
}
async uploadRequest(endpoint, form, { timeout = UPLOAD_TIMEOUT_MS } = {}) {
const url = `${this.baseUrl}/api/v1${endpoint}`;
const headers = this.headers();
delete headers['Content-Type'];
let res;
try {
res = await fetch(url, {
method: 'POST',
headers,
body: form,
signal: AbortSignal.timeout(timeout),
});
} catch (err) {
if (err.name === 'TimeoutError') {
throw new Error(`Upload to ${this.baseUrl} timed out after ${timeout / 1000}s`);
}
throw new Error(`Network error reaching ${this.baseUrl}: ${err.message}`);
}
const text = await res.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { raw: text };
}
}
if (!res.ok) {
const msg = data?.message || data?.raw || `HTTP ${res.status}`;
const err = new Error(msg);
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
get(endpoint) {
return this.request('GET', endpoint);
}
@ -229,6 +272,17 @@ class ForgejoClient {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, payload);
}
async uploadReleaseAsset(owner, repo, releaseId, filePath, name) {
const form = new FormData();
const file = await fs.openAsBlob(filePath);
form.append('attachment', file, name);
const query = new URLSearchParams({ name });
return this.uploadRequest(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${encodeURIComponent(releaseId)}/assets?${query}`,
form,
);
}
async listLabels(owner, repo, opts = {}) {
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, opts);
}

View file

@ -1,13 +1,18 @@
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { ForgejoClient } = require('../src/api');
const pkg = require('../package.json');
const realFetch = global.fetch;
const realAbortTimeout = AbortSignal.timeout;
afterEach(() => {
global.fetch = realFetch;
AbortSignal.timeout = realAbortTimeout;
});
function mockFetch(handler) {
@ -278,6 +283,48 @@ test('release endpoints map to the expected URLs and payloads', async () => {
assert.equal(JSON.parse(calls[2].opts.body).tag_name, '1.0.0');
});
test('uploadReleaseAsset streams multipart data without forcing a JSON content type', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-upload-api-'));
const assetPath = path.join(dir, 'artifact.bin');
fs.writeFileSync(assetPath, 'asset bytes');
const calls = mockFetch(() => jsonResponse({ id: 9, name: 'custom name.bin' }, 201));
const client = new ForgejoClient('https://forge.test', 'tok');
try {
await client.uploadReleaseAsset('heavy duty', 'stoke', 42, assetPath, 'custom name.bin');
const { url, opts } = calls[0];
assert.equal(url, 'https://forge.test/api/v1/repos/heavy%20duty/stoke/releases/42/assets?name=custom+name.bin');
assert.equal(opts.method, 'POST');
assert.equal(opts.headers.Authorization, 'token tok');
assert.equal(opts.headers['Content-Type'], undefined);
const attachment = opts.body.get('attachment');
assert.equal(attachment.name, 'custom name.bin');
assert.equal(await attachment.text(), 'asset bytes');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('uploadReleaseAsset uses the upload timeout instead of the 30 second JSON timeout', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-upload-timeout-'));
const assetPath = path.join(dir, 'large.bin');
fs.writeFileSync(assetPath, 'content');
let timeout;
AbortSignal.timeout = (milliseconds) => {
timeout = milliseconds;
return new AbortController().signal;
};
mockFetch(() => jsonResponse({ id: 10 }, 201));
const client = new ForgejoClient('https://forge.test', 'tok');
try {
await client.uploadReleaseAsset('owner', 'repo', 42, assetPath, 'large.bin');
assert.equal(timeout, 10 * 60 * 1000);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('label endpoints map to the expected URLs and payloads', async () => {
const calls = mockFetch(() => jsonResponse({ id: 3 }));
const client = new ForgejoClient('https://forge.test', 'tok');