diff --git a/src/api.js b/src/api.js index e493fb9..3fc874f 100644 --- a/src/api.js +++ b/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); } diff --git a/test/api.test.js b/test/api.test.js index 9b517f7..56cd7c4 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -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');