@getrefino/github 0.1.0-rc.4 → 0.1.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { createGitHubContentAdapter } from "./adapter.js";
2
2
  export { DEFAULT_API_BASE_URL, DEFAULT_API_VERSION, GITHUB_ENV, githubOptionsFromEnv, validateRepoPath, } from "./options.js";
3
3
  export type { GitHubCommitIdentity, GitHubContentAdapterOptions } from "./options.js";
4
+ export { GitHubRepositoryError, MAX_BLOB_BYTES, createGitHubRepository, isGitHubRepositoryError } from "./repository.js";
5
+ export type { CommitFile, CommitResult, GitHubRepository, GitHubRepositoryErrorCode, GitHubRepositoryOptions, RepositorySnapshot } from "./repository.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { createGitHubContentAdapter } from "./adapter.js";
2
2
  export { DEFAULT_API_BASE_URL, DEFAULT_API_VERSION, GITHUB_ENV, githubOptionsFromEnv, validateRepoPath, } from "./options.js";
3
+ export { GitHubRepositoryError, MAX_BLOB_BYTES, createGitHubRepository, isGitHubRepositoryError } from "./repository.js";
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Reading a branch at one commit and committing several files on top of it
3
+ * atomically, through the REST Git Database API.
4
+ *
5
+ * The content adapter commits one file with the Contents API, which is the
6
+ * right tool for the copy file. An integration update changes a handful of
7
+ * files together (package.json, the lockfile, generated modules), and they
8
+ * must land as one commit or not at all, so this module builds the tree and
9
+ * the commit itself and then moves the branch with `force: false`:
10
+ *
11
+ * read: ref → commit → recursive tree → blobs on demand
12
+ * write: tree (base_tree = the tree that was read) → commit (parent = the
13
+ * commit that was read) → PATCH ref, fast-forward only
14
+ *
15
+ * If anyone pushed in between, GitHub refuses the fast-forward and nothing on
16
+ * the branch changes; the objects already created are unreferenced and
17
+ * harmless. The same token as the content adapter works: Contents read and
18
+ * write covers the Git Database API.
19
+ *
20
+ * Docs: https://docs.github.com/en/rest/git
21
+ */
22
+ import type { GitHubCommitIdentity } from "./options.js";
23
+ export interface GitHubRepositoryOptions {
24
+ /** Same token as the content adapter: Contents read and write on this repository. Server-side only. */
25
+ readonly token: string;
26
+ readonly owner: string;
27
+ readonly repo: string;
28
+ readonly branch: string;
29
+ readonly apiBaseUrl?: string;
30
+ readonly apiVersion?: string;
31
+ readonly fetch?: typeof fetch;
32
+ readonly committer?: GitHubCommitIdentity;
33
+ readonly author?: GitHubCommitIdentity;
34
+ }
35
+ export type GitHubRepositoryErrorCode =
36
+ /** The branch moved since it was read. Nothing was changed. */
37
+ "CONFLICT"
38
+ /** GitHub refused: the token lacks access, or the branch does not accept direct pushes. */
39
+ | "REFUSED" | "NOT_FOUND"
40
+ /** The repository or a file is too large to read through the API. */
41
+ | "TOO_LARGE"
42
+ /** GitHub could not be reached or answered with an error. Nothing was changed. */
43
+ | "UNAVAILABLE"
44
+ /**
45
+ * The branch update was sent and its outcome could not be established:
46
+ * the commit may or may not be on the branch. The only code after which
47
+ * "nothing was changed" must not be said.
48
+ */
49
+ | "UNKNOWN";
50
+ export declare class GitHubRepositoryError extends Error {
51
+ readonly code: GitHubRepositoryErrorCode;
52
+ constructor(code: GitHubRepositoryErrorCode, message: string);
53
+ }
54
+ export declare function isGitHubRepositoryError(error: unknown): error is GitHubRepositoryError;
55
+ export interface CommitFile {
56
+ readonly path: string;
57
+ /** UTF-8 text. */
58
+ readonly content: string;
59
+ }
60
+ export interface CommitResult {
61
+ readonly sha: string;
62
+ readonly url?: string;
63
+ }
64
+ /** The branch as it was at one commit. */
65
+ export interface RepositorySnapshot {
66
+ readonly commitSha: string;
67
+ /** Every file path (blobs only), repository-relative. */
68
+ readonly paths: readonly string[];
69
+ /** A file's text at this commit, or null when there is no such file. */
70
+ read(path: string): Promise<string | null>;
71
+ /**
72
+ * Commit `files` on top of this snapshot and move the branch to it, only if
73
+ * the branch still points at `commitSha`. Throws `CONFLICT` otherwise.
74
+ */
75
+ commit(input: {
76
+ readonly message: string;
77
+ readonly files: readonly CommitFile[];
78
+ }): Promise<CommitResult>;
79
+ }
80
+ export interface GitHubRepository {
81
+ snapshot(): Promise<RepositorySnapshot>;
82
+ }
83
+ /** A file larger than this is not read: nothing Refino updates comes close. */
84
+ export declare const MAX_BLOB_BYTES: number;
85
+ export declare function createGitHubRepository(options: GitHubRepositoryOptions): GitHubRepository;
@@ -0,0 +1,155 @@
1
+ import { DEFAULT_API_BASE_URL, DEFAULT_API_VERSION, validateRepoPath } from "./options.js";
2
+ export class GitHubRepositoryError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = "GitHubRepositoryError";
7
+ this.code = code;
8
+ }
9
+ }
10
+ export function isGitHubRepositoryError(error) {
11
+ return error instanceof GitHubRepositoryError;
12
+ }
13
+ /** A file larger than this is not read: nothing Refino updates comes close. */
14
+ export const MAX_BLOB_BYTES = 20 * 1024 * 1024;
15
+ const OWNER_REPO = /^[A-Za-z0-9_.-]+$/;
16
+ export function createGitHubRepository(options) {
17
+ const { token, owner, repo, branch } = options;
18
+ if (!token || !OWNER_REPO.test(owner) || !OWNER_REPO.test(repo) || !branch || branch.includes("..") || /\s/.test(branch)) {
19
+ throw new GitHubRepositoryError("NOT_FOUND", "GitHub repository: token, owner, repo and branch are required and must be valid.");
20
+ }
21
+ const apiBase = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
22
+ const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
23
+ const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
24
+ const repoUrl = `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
25
+ const refPath = `heads/${branch.split("/").map(encodeURIComponent).join("/")}`;
26
+ const scrub = (text) => text.split(token).join("[redacted]");
27
+ async function call(method, path, body) {
28
+ let response;
29
+ try {
30
+ response = await doFetch(`${repoUrl}${path}`, {
31
+ method,
32
+ cache: "no-store",
33
+ headers: {
34
+ accept: "application/vnd.github+json",
35
+ authorization: `Bearer ${token}`,
36
+ "x-github-api-version": apiVersion,
37
+ "user-agent": "refino-github-repository",
38
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
39
+ },
40
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
41
+ });
42
+ }
43
+ catch (error) {
44
+ throw new GitHubRepositoryError("UNAVAILABLE", `Could not reach GitHub: ${scrub(String(error))}`);
45
+ }
46
+ let parsed;
47
+ try {
48
+ parsed = await response.json();
49
+ }
50
+ catch {
51
+ parsed = null;
52
+ }
53
+ const message = scrub(typeof parsed?.message === "string" ? parsed.message : `HTTP ${response.status}`);
54
+ return { status: response.status, body: response.ok ? parsed : null, message };
55
+ }
56
+ function fail(step, status, message) {
57
+ if (status === 401 || status === 403)
58
+ throw new GitHubRepositoryError("REFUSED", `GitHub refused to ${step} (HTTP ${status}): ${message}`);
59
+ if (status === 404)
60
+ throw new GitHubRepositoryError("NOT_FOUND", `GitHub could not find what it needed to ${step}: ${message}`);
61
+ throw new GitHubRepositoryError("UNAVAILABLE", `GitHub could not ${step} (HTTP ${status}): ${message}`);
62
+ }
63
+ return {
64
+ async snapshot() {
65
+ const ref = await call("GET", `/git/ref/${refPath}`);
66
+ const commitSha = ref.body?.object?.sha;
67
+ if (!ref.body || typeof commitSha !== "string")
68
+ fail(`read branch ${branch}`, ref.status, ref.message);
69
+ const commit = await call("GET", `/git/commits/${commitSha}`);
70
+ const treeSha = commit.body?.tree?.sha;
71
+ if (!commit.body || typeof treeSha !== "string")
72
+ fail("read the latest commit", commit.status, commit.message);
73
+ const tree = await call("GET", `/git/trees/${treeSha}?recursive=1`);
74
+ if (!tree.body || !Array.isArray(tree.body.tree))
75
+ fail("list the repository", tree.status, tree.message);
76
+ if (tree.body.truncated)
77
+ throw new GitHubRepositoryError("TOO_LARGE", "The repository has too many files to list through the GitHub API.");
78
+ const entries = new Map(tree.body.tree.filter((entry) => entry.type === "blob").map((entry) => [entry.path, entry]));
79
+ const cache = new Map();
80
+ return {
81
+ commitSha,
82
+ paths: [...entries.keys()],
83
+ async read(path) {
84
+ const entry = entries.get(path);
85
+ if (!entry)
86
+ return null;
87
+ const cached = cache.get(path);
88
+ if (cached !== undefined)
89
+ return cached;
90
+ if ((entry.size ?? 0) > MAX_BLOB_BYTES)
91
+ throw new GitHubRepositoryError("TOO_LARGE", `${path} is too large to read.`);
92
+ const blob = await call("GET", `/git/blobs/${entry.sha}`);
93
+ if (!blob.body || typeof blob.body.content !== "string" || blob.body.encoding !== "base64")
94
+ fail(`read ${path}`, blob.status, blob.message);
95
+ const text = Buffer.from(blob.body.content.replace(/\s/g, ""), "base64").toString("utf8");
96
+ cache.set(path, text);
97
+ return text;
98
+ },
99
+ async commit({ message, files }) {
100
+ if (files.length === 0)
101
+ throw new GitHubRepositoryError("UNAVAILABLE", "Nothing to commit.");
102
+ const tree = files.map((file) => {
103
+ const path = validateRepoPath(file.path);
104
+ const existing = entries.get(path);
105
+ // Never write through a symlink or into a submodule; keep an executable bit if a file had one.
106
+ if (existing && existing.mode !== "100644" && existing.mode !== "100755") {
107
+ throw new GitHubRepositoryError("REFUSED", `${path} is not a regular file in the repository.`);
108
+ }
109
+ return { path, mode: existing?.mode ?? "100644", type: "blob", content: file.content };
110
+ });
111
+ const created = await call("POST", "/git/trees", { base_tree: treeSha, tree });
112
+ if (!created.body?.sha)
113
+ fail("prepare the commit", created.status, created.message);
114
+ const commitBody = { message, tree: created.body.sha, parents: [commitSha] };
115
+ if (options.author)
116
+ commitBody.author = options.author;
117
+ if (options.committer)
118
+ commitBody.committer = options.committer;
119
+ const made = await call("POST", "/git/commits", commitBody);
120
+ if (!made.body?.sha)
121
+ fail("create the commit", made.status, made.message);
122
+ // The only step that changes the branch, and only as a fast-forward from what was read.
123
+ const commitResult = made.body.html_url ? { sha: made.body.sha, url: made.body.html_url } : { sha: made.body.sha };
124
+ let moved;
125
+ try {
126
+ moved = await call("PATCH", `/git/refs/${refPath}`, { sha: made.body.sha, force: false });
127
+ }
128
+ catch {
129
+ moved = { status: 0, body: null, message: "no answer" };
130
+ }
131
+ if (!moved.body && (moved.status === 0 || moved.status >= 500)) {
132
+ // The request may or may not have landed. Ask the branch rather than guess.
133
+ const now = await call("GET", `/git/ref/${refPath}`).catch(() => null);
134
+ const head = now?.body?.object?.sha;
135
+ if (head === made.body.sha)
136
+ return commitResult;
137
+ if (typeof head === "string")
138
+ throw new GitHubRepositoryError(head === commitSha ? "UNAVAILABLE" : "CONFLICT", `GitHub did not update ${branch}: ${moved.message}`);
139
+ throw new GitHubRepositoryError("UNKNOWN", `GitHub did not confirm whether ${branch} was updated.`);
140
+ }
141
+ if (!moved.body) {
142
+ if (moved.status === 422 && /fast.?forward/i.test(moved.message)) {
143
+ throw new GitHubRepositoryError("CONFLICT", `${branch} changed while the update was being prepared.`);
144
+ }
145
+ if (moved.status === 422 || moved.status === 409) {
146
+ throw new GitHubRepositoryError("REFUSED", `GitHub refused to update ${branch}: ${moved.message}`);
147
+ }
148
+ fail(`update ${branch}`, moved.status, moved.message);
149
+ }
150
+ return commitResult;
151
+ },
152
+ };
153
+ },
154
+ };
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrefino/github",
3
- "version": "0.1.0-rc.4",
3
+ "version": "0.1.0-rc.5",
4
4
  "description": "GitHub persistence adapter for Refino: reads and commits the canonical copy file through the GitHub Contents API, so the repository stays the source of truth.",
5
5
  "keywords": [
6
6
  "refino",
@@ -36,7 +36,7 @@
36
36
  "./package.json": "./package.json"
37
37
  },
38
38
  "dependencies": {
39
- "@getrefino/core": "0.1.0-rc.4"
39
+ "@getrefino/core": "0.1.0-rc.5"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "22.20.2",