UNPKG

alinea

Version:
257 lines (254 loc) 8.68 kB
import "../../chunks/chunk-NZLE2WMY.js"; // src/backend/api/GithubApi.ts import { parseCoAuthoredBy } from "alinea/cli/util/CommitMessage"; import { HttpError } from "alinea/core/HttpError"; import { GithubSource } from "alinea/core/source/GithubSource"; import { ShaMismatchError } from "alinea/core/source/ShaMismatchError"; import { base64, btoa } from "alinea/core/util/Encoding"; import { fileVersions } from "alinea/core/util/EntryFilenames"; import { join } from "alinea/core/util/Paths"; var GithubApi = class extends GithubSource { #options; constructor(options) { super(options); this.#options = options; } async write(request) { const currentCommit = await this.#getLatestCommitOid(); const currentSha = await this.shaAt(currentCommit); if (currentSha !== request.fromSha) throw new ShaMismatchError(currentSha, request.fromSha); const { author } = this.#options; let commitMessage = request.description; if (author) { commitMessage += ` Co-authored-by: ${author.name} <${author.email}>`; } const newCommit = await this.#applyChangesToRepo( currentCommit, request.changes, commitMessage ); return { sha: await this.shaAt(newCommit) }; } async revisions(file) { return this.#getFileCommitHistory(file); } async revisionData(file, revisionId) { const content = await this.#getFileContentAtCommit(file, revisionId); try { return content ? JSON.parse(content) : void 0; } catch (error) { return void 0; } } async #graphQL(query, variables, token) { return fetch("https://api.github.com/graphql", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }) }).then(async (response) => { if (response.ok) return response.json(); throw new HttpError(response.status, await response.text()); }).then((result) => { if (Array.isArray(result.errors) && result.errors.length > 0) { const message = result.errors.map((e) => e.message).join("; "); console.trace(result.errors); throw new Error(message); } return result; }); } async #getFileCommitHistory(file) { const { owner, repo, branch, authToken, rootDir } = this.#options; const seen = /* @__PURE__ */ new Set(); const queue = fileVersions(file); const allRevisions = Array(); const maxRequests = 3; let requestCount = 0; while (queue.length) { if (requestCount === maxRequests) break; requestCount++; const versions = [...queue].filter((v) => !seen.has(v)); for (const v of versions) seen.add(v); queue.length = 0; const aliasMap = versions.map((v, idx) => ({ alias: `file${idx}`, version: v, path: join(rootDir, v) })); const query = aliasMap.map( ({ alias, path }) => ` ${alias}: history(path: "${path}", first: 100) { nodes { oid committedDate message author { name email } } }` ).join(""); const gql = ` query GetFileHistory($owner: String!, $repo: String!, $branch: String!) { repository(owner: $owner, name: $repo) { ref(qualifiedName: $branch) { target { ... on Commit {${query}} } } } }`; const result = await this.#graphQL(gql, { owner, repo, branch }, authToken); for (const { alias, version, path } of aliasMap) { const commits = result.data.repository.ref.target[alias]?.nodes || []; if (!commits.length) continue; allRevisions.push( ...commits.map((commit) => ({ ref: commit.oid, createdAt: new Date(commit.committedDate).getTime(), file: version, user: parseCoAuthoredBy(commit.message) ?? (commit.author ? { name: commit.author.name, email: commit.author.email } : void 0), description: commit.message })) ); const earliest = commits[commits.length - 1].oid; const res = await fetch( `https://api.github.com/repos/${owner}/${repo}/commits/${earliest}`, { headers: { Authorization: `Bearer ${authToken}` } } ); if (!res.ok) throw new HttpError(res.status, await res.text()); const commitData = await res.json(); const fileEntry = Array.isArray(commitData.files) ? commitData.files.find((f) => f.filename === path) : void 0; const prev = fileEntry?.previous_filename; if (prev) { const prefix = rootDir ? `${rootDir}/` : ""; const relative = prev.startsWith(prefix) ? prev.slice(prefix.length) : prev; queue.push(...fileVersions(relative)); } } } return allRevisions.sort((a, b) => b.createdAt - a.createdAt); } async #getFileContentAtCommit(file, ref) { const { owner, repo, authToken, rootDir } = this.#options; const result = await this.#graphQL( `query GetFileContent($owner: String!, $repo: String!, $expression: String!) { repository(owner: $owner, name: $repo) { object(expression: $expression) { ... on Blob { text } } } }`, { owner, repo, expression: `${ref}:${join(rootDir, file)}` }, authToken ); return result.data.repository.object?.text; } async #applyChangesToRepo(expectedHeadOid, changes, commitMessage) { const { additions, deletions } = await this.#processChanges(changes); const { owner, repo, branch, authToken } = this.#options; return this.#graphQL( `mutation CreateCommitOnBranch($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }`, { input: { branch: { repositoryNameWithOwner: `${owner}/${repo}`, branchName: branch }, message: { headline: commitMessage }, fileChanges: { additions, deletions }, expectedHeadOid } }, authToken ).then((result) => { const commitId = result.data.createCommitOnBranch.commit.oid; return commitId; }).catch((error) => { if (error instanceof Error) { const mismatchMessage = /is at ([a-z0-9]+) but expected ([a-z0-9]+)/; const match = error.message.match(mismatchMessage); if (match) { const [_, actual, expected] = match; throw new ShaMismatchError(actual, expected); } const expectedMessage = /Expected branch to point to "([a-z0-9]+)"/; const expectedMatch = error.message.match(expectedMessage); if (expectedMatch) { const actualSha = expectedMatch[1]; throw new ShaMismatchError(actualSha, expectedHeadOid); } } throw error; }); } async #processChanges(changes) { const { rootDir } = this.#options; const additions = Array(); const deletions = Array(); for (const change of changes) { switch (change.op) { case "addContent": { additions.push({ path: join(this.contentLocation, change.path), contents: btoa(change.contents) }); break; } case "uploadFile": { const file = join(rootDir, change.location); additions.push({ path: file, contents: await this.#fetchUploadedContent(change.url) }); break; } case "deleteContent": { const file = join(this.contentLocation, change.path); deletions.push({ path: file }); break; } case "removeFile": { const file = join(rootDir, change.location); deletions.push({ path: file }); break; } } } return { additions, deletions }; } async #getLatestCommitOid() { const { owner, repo, branch, authToken } = this.#options; return this.#graphQL( `query GetLatestCommit($owner: String!, $repo: String!, $branch: String!) { repository(owner: $owner, name: $repo) { ref(qualifiedName: $branch) { target { oid } } } }`, { owner, repo, branch }, authToken ).then((result) => result.data.repository.ref.target.oid); } async #fetchUploadedContent(url) { const response = await fetch(url); return base64.stringify(new Uint8Array(await response.arrayBuffer())); } }; export { GithubApi };