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 pkg = require('../package.json');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 30000;
|
const REQUEST_TIMEOUT_MS = 30000;
|
||||||
// Repository migrations clone the full source repository and can legitimately
|
// Repository migrations clone the full source repository and can legitimately
|
||||||
// take minutes, so they get a much longer budget.
|
// take minutes, so they get a much longer budget.
|
||||||
const MIGRATE_TIMEOUT_MS = 10 * 60 * 1000;
|
const MIGRATE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
class ForgejoClient {
|
class ForgejoClient {
|
||||||
constructor(baseUrl, token = null) {
|
constructor(baseUrl, token = null) {
|
||||||
|
|
@ -94,6 +96,47 @@ class ForgejoClient {
|
||||||
return data;
|
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) {
|
get(endpoint) {
|
||||||
return this.request('GET', endpoint);
|
return this.request('GET', endpoint);
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +272,17 @@ class ForgejoClient {
|
||||||
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`, payload);
|
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 = {}) {
|
async listLabels(owner, repo, opts = {}) {
|
||||||
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, opts);
|
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`, opts);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
const { test, afterEach } = require('node:test');
|
const { test, afterEach } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
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 { ForgejoClient } = require('../src/api');
|
||||||
const pkg = require('../package.json');
|
const pkg = require('../package.json');
|
||||||
|
|
||||||
const realFetch = global.fetch;
|
const realFetch = global.fetch;
|
||||||
|
const realAbortTimeout = AbortSignal.timeout;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = realFetch;
|
global.fetch = realFetch;
|
||||||
|
AbortSignal.timeout = realAbortTimeout;
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockFetch(handler) {
|
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');
|
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 () => {
|
test('label endpoints map to the expected URLs and payloads', async () => {
|
||||||
const calls = mockFetch(() => jsonResponse({ id: 3 }));
|
const calls = mockFetch(() => jsonResponse({ id: 3 }));
|
||||||
const client = new ForgejoClient('https://forge.test', 'tok');
|
const client = new ForgejoClient('https://forge.test', 'tok');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue