// // Endpoint methods for the Gitea v1 API. Each takes a `repo` of the shape // { owner, name } and returns plain objects with the fields the views need. // const { GiteaError } = require("./client.js"); const log = require("../util/log.js"); function segment(value) { return encodeURIComponent(String(value)); } function repoPath(repo, suffix = "") { return `/repos/${segment(repo.owner)}/${segment(repo.name)}${suffix}`; } class GiteaApi { constructor(client) { this.client = client; } get baseUrl() { return this.client.baseUrl; } // -- Instance --------------------------------------------------------- async version() { const body = await this.client.get("/version"); return (body && body.version) || "unknown"; } async currentUser() { return await this.client.get("/user"); } async accessibleRepositories(limit = 100) { return await this.client.paginate("/user/repos", { limit: limit }); } async repository(repo) { return await this.client.get(repoPath(repo)); } async branches(repo, limit = 100) { return await this.client.paginate(repoPath(repo, "/branches"), { limit: limit }); } async combinedStatus(repo, ref) { return await this.client.get(repoPath(repo, `/commits/${segment(ref)}/status`)); } // -- Actions: runs ---------------------------------------------------- /** * Lists workflow runs. Gitea gained /actions/runs in 1.24; older servers only * expose /actions/tasks, which the caller sees as the "recent runs" fallback. */ async listRuns(repo, { limit = 20, branch = null, event = null } = {}) { const query = { limit: limit, branch: branch, event: event }; try { const body = await this.client.get(repoPath(repo, "/actions/runs"), { query: query }); return { runs: (body && body.workflow_runs) || [], total: (body && body.total_count) || 0, legacy: false, }; } catch (error) { if (!(error instanceof GiteaError) || !error.isMissing) throw error; log.debug("falling back to /actions/tasks for", repo.owner + "/" + repo.name); const body = await this.client.get(repoPath(repo, "/actions/tasks"), { query: { limit: limit }, }); return { runs: (body && body.workflow_runs) || [], total: (body && body.total_count) || 0, legacy: true, }; } } async listWorkflows(repo) { try { const body = await this.client.get(repoPath(repo, "/actions/workflows")); return (body && body.workflows) || []; } catch (error) { if (error instanceof GiteaError && error.isMissing) return []; throw error; } } async listJobs(repo, runId, limit = 50) { const body = await this.client.get( repoPath(repo, `/actions/runs/${segment(runId)}/jobs`), { query: { limit: limit } }, ); return (body && body.jobs) || []; } async jobLogs(repo, jobId) { return await this.client.get(repoPath(repo, `/actions/jobs/${segment(jobId)}/logs`), { responseType: "text", headers: { Accept: "text/plain, */*" }, }); } async rerunRun(repo, runId) { await this.client.post(repoPath(repo, `/actions/runs/${segment(runId)}/rerun`), null); } async rerunFailedJobs(repo, runId) { await this.client.post( repoPath(repo, `/actions/runs/${segment(runId)}/rerun-failed-jobs`), null, ); } async rerunJob(repo, runId, jobId) { await this.client.post( repoPath(repo, `/actions/runs/${segment(runId)}/jobs/${segment(jobId)}/rerun`), null, ); } async cancelRun(repo, runId) { await this.client.post(repoPath(repo, `/actions/runs/${segment(runId)}/cancel`), null); } // -- Actions: artifacts ----------------------------------------------- async listArtifacts(repo, runId) { try { const body = await this.client.get( repoPath(repo, `/actions/runs/${segment(runId)}/artifacts`), ); return (body && body.artifacts) || []; } catch (error) { if (error instanceof GiteaError && error.isMissing) return []; throw error; } } /** * Downloads an artifact zip. Gitea answers the zip endpoint with a redirect * to storage; Nova's fetch follows it, so the bytes come back directly. */ async downloadArtifact(repo, artifact) { const path = artifact.archive_download_url ? artifact.archive_download_url : repoPath(repo, `/actions/artifacts/${segment(artifact.id)}/zip`); return await this.client.get(path, { responseType: "binary", headers: { Accept: "application/zip, */*" }, }); } // -- Actions: secrets and variables ----------------------------------- async listSecrets(repo) { return await this.client.paginate(repoPath(repo, "/actions/secrets"), { limit: 100 }); } async setSecret(repo, name, value) { await this.client.put(repoPath(repo, `/actions/secrets/${segment(name)}`), { data: value, }); } async deleteSecret(repo, name) { await this.client.delete(repoPath(repo, `/actions/secrets/${segment(name)}`)); } async listVariables(repo) { return await this.client.paginate(repoPath(repo, "/actions/variables"), { limit: 100 }); } async createVariable(repo, name, value) { await this.client.post(repoPath(repo, `/actions/variables/${segment(name)}`), { value: value, }); } async updateVariable(repo, name, value) { await this.client.put(repoPath(repo, `/actions/variables/${segment(name)}`), { value: value, }); } async deleteVariable(repo, name) { await this.client.delete(repoPath(repo, `/actions/variables/${segment(name)}`)); } // -- Pull requests ----------------------------------------------------- async listPullRequests(repo, { state = "open", limit = 30 } = {}) { return await this.client.paginate(repoPath(repo, "/pulls"), { limit: limit, query: { state: state, sort: "recentupdate" }, }); } async pullRequest(repo, index) { return await this.client.get(repoPath(repo, `/pulls/${segment(index)}`)); } async pullRequestFiles(repo, index, limit = 200) { return await this.client.paginate(repoPath(repo, `/pulls/${segment(index)}/files`), { limit: limit, }); } async pullRequestDiff(repo, index) { return await this.client.get(repoPath(repo, `/pulls/${segment(index)}.diff`), { responseType: "text", headers: { Accept: "text/plain, */*" }, }); } async pullRequestCommits(repo, index, limit = 100) { return await this.client.paginate(repoPath(repo, `/pulls/${segment(index)}/commits`), { limit: limit, }); } async createPullRequest(repo, { head, base, title, body }) { return await this.client.post(repoPath(repo, "/pulls"), { head: head, base: base, title: title, body: body || "", }); } async closePullRequest(repo, index) { return await this.client.patch(repoPath(repo, `/issues/${segment(index)}`), { state: "closed", }); } async mergePullRequest(repo, index, { strategy = "merge", title, message } = {}) { await this.client.post(repoPath(repo, `/pulls/${segment(index)}/merge`), { Do: strategy, MergeTitleField: title || "", MergeMessageField: message || "", }); } // -- Reviews ----------------------------------------------------------- async listReviews(repo, index) { return await this.client.paginate(repoPath(repo, `/pulls/${segment(index)}/reviews`), { limit: 100, }); } async reviewComments(repo, index, reviewId) { return await this.client.get( repoPath(repo, `/pulls/${segment(index)}/reviews/${segment(reviewId)}/comments`), ); } /** Every review comment on a pull request, flattened across its reviews. */ async allReviewComments(repo, index) { const reviews = await this.listReviews(repo, index); const withComments = reviews.filter((review) => (review.comments_count || 0) > 0); const batches = await Promise.all( withComments.map(async (review) => { try { const comments = await this.reviewComments(repo, index, review.id); return (comments || []).map((comment) => Object.assign({}, comment, { review: review }), ); } catch (error) { log.debug("review comments failed", review.id, String(error)); return []; } }), ); return { reviews: reviews, comments: batches.flat() }; } /** * Submits a review. `event` is APPROVED, REQUEST_CHANGES, or COMMENT, and * `comments` are file-anchored notes of the shape * { path, body, new_position } / { path, body, old_position }. */ async submitReview(repo, index, { event, body = "", comments = [], commitId } = {}) { return await this.client.post(repoPath(repo, `/pulls/${segment(index)}/reviews`), { event: event, body: body, comments: comments, commit_id: commitId, }); } async replyToReviewComment(repo, index, commentId, body) { return await this.client.post( repoPath(repo, `/pulls/${segment(index)}/comments/${segment(commentId)}/replies`), { body: body }, ); } async resolveReviewThread(repo, commentId) { await this.client.post(repoPath(repo, `/pulls/comments/${segment(commentId)}/resolve`), null); } async unresolveReviewThread(repo, commentId) { await this.client.post( repoPath(repo, `/pulls/comments/${segment(commentId)}/unresolve`), null, ); } // -- Issue timeline ---------------------------------------------------- async timeline(repo, index, limit = 100) { return await this.client.paginate(repoPath(repo, `/issues/${segment(index)}/timeline`), { limit: limit, }); } async issueComments(repo, index, limit = 100) { return await this.client.paginate(repoPath(repo, `/issues/${segment(index)}/comments`), { limit: limit, }); } async addIssueComment(repo, index, body) { return await this.client.post(repoPath(repo, `/issues/${segment(index)}/comments`), { body: body, }); } } exports.GiteaApi = GiteaApi; exports.repoPath = repoPath;