feat: stream release asset uploads
This commit is contained in:
parent
c09943ea32
commit
0fac095509
2 changed files with 101 additions and 0 deletions
54
src/api.js
54
src/api.js
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue