@getrefino/github 0.1.0-rc.4 → 0.1.0-rc.6
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/adapter.d.ts +4 -2
- package/dist/adapter.js +25 -86
- package/dist/file-store.d.ts +29 -0
- package/dist/file-store.js +113 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/repository.d.ts +85 -0
- package/dist/repository.js +155 -0
- package/package.json +2 -2
package/dist/adapter.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub persistence
|
|
3
|
-
* (GET/PUT /repos/{owner}/{repo}/contents/{path}).
|
|
2
|
+
* GitHub persistence for the copy file.
|
|
4
3
|
*
|
|
5
4
|
* Why the Contents API: it reads a file with its blob SHA and writes a new
|
|
6
5
|
* version as a single commit only if the SHA still matches. That gives us
|
|
7
6
|
* load, conflict detection, and an understandable commit without a custom
|
|
8
7
|
* git implementation.
|
|
9
8
|
*
|
|
9
|
+
* The HTTP itself lives in `file-store.ts`, which the article document
|
|
10
|
+
* service also uses, so copy and content commit through one code path.
|
|
11
|
+
*
|
|
10
12
|
* Docs: https://docs.github.com/en/rest/repos/contents
|
|
11
13
|
*/
|
|
12
14
|
import type { ContentAdapter } from "@getrefino/core";
|
package/dist/adapter.js
CHANGED
|
@@ -1,62 +1,16 @@
|
|
|
1
|
-
import { ContentConflictError, ContentError, buildCommitMessage, parseCopy, planSave } from "@getrefino/core";
|
|
1
|
+
import { ContentConflictError, ContentError, buildCommitMessage, isContentError, parseCopy, planSave, } from "@getrefino/core";
|
|
2
|
+
import { createGitHubFileClient } from "./file-store.js";
|
|
2
3
|
import { resolveOptions } from "./options.js";
|
|
3
|
-
function encodePath(path) {
|
|
4
|
-
return path.split("/").map(encodeURIComponent).join("/");
|
|
5
|
-
}
|
|
6
|
-
function decodeBase64(text) {
|
|
7
|
-
return Buffer.from(text.replace(/\s/g, ""), "base64").toString("utf8");
|
|
8
|
-
}
|
|
9
|
-
function encodeBase64(text) {
|
|
10
|
-
return Buffer.from(text, "utf8").toString("base64");
|
|
11
|
-
}
|
|
12
4
|
export function createGitHubContentAdapter(options) {
|
|
13
5
|
const resolved = resolveOptions(options);
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const location = `${resolved.owner}/${resolved.repo}@${resolved.branch}:${resolved.path}`;
|
|
17
|
-
function headers(extra = {}) {
|
|
18
|
-
return {
|
|
19
|
-
accept: "application/vnd.github+json",
|
|
20
|
-
authorization: `Bearer ${resolved.token}`,
|
|
21
|
-
"x-github-api-version": resolved.apiVersion,
|
|
22
|
-
"user-agent": "refino-github-adapter",
|
|
23
|
-
...extra,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
/** Never let the token leak through an error message. */
|
|
27
|
-
function scrub(text) {
|
|
28
|
-
return text.split(resolved.token).join("[redacted]");
|
|
29
|
-
}
|
|
30
|
-
async function readError(response) {
|
|
31
|
-
try {
|
|
32
|
-
const body = (await response.json());
|
|
33
|
-
return scrub(body.message ?? `HTTP ${response.status}`);
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return `HTTP ${response.status}`;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
6
|
+
const client = createGitHubFileClient(resolved);
|
|
7
|
+
const location = client.location(resolved.path);
|
|
39
8
|
async function readCurrent() {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
if (response.status === 404) {
|
|
9
|
+
const file = await client.readFile(resolved.path);
|
|
10
|
+
if (!file) {
|
|
43
11
|
throw new ContentError("NOT_FOUND", `Copy file not found at ${location}. Check the repository, branch and path.`);
|
|
44
12
|
}
|
|
45
|
-
|
|
46
|
-
throw new ContentError("ADAPTER_ERROR", `GitHub rejected the token (HTTP ${response.status}). It needs Contents read/write on ${resolved.owner}/${resolved.repo}.`);
|
|
47
|
-
}
|
|
48
|
-
if (!response.ok) {
|
|
49
|
-
throw new ContentError("ADAPTER_ERROR", `GitHub read failed: ${await readError(response)}`);
|
|
50
|
-
}
|
|
51
|
-
const file = (await response.json());
|
|
52
|
-
if (file.type !== "file" || typeof file.sha !== "string") {
|
|
53
|
-
throw new ContentError("ADAPTER_ERROR", `${location} is not a file.`);
|
|
54
|
-
}
|
|
55
|
-
if (file.encoding !== "base64" || typeof file.content !== "string") {
|
|
56
|
-
throw new ContentError("ADAPTER_ERROR", `${location} is too large to edit through the Contents API (1 MB limit).`);
|
|
57
|
-
}
|
|
58
|
-
const text = decodeBase64(file.content);
|
|
59
|
-
return { text, content: parseCopy(text), revision: file.sha };
|
|
13
|
+
return { text: file.text, content: parseCopy(file.text), revision: file.revision };
|
|
60
14
|
}
|
|
61
15
|
return {
|
|
62
16
|
name: "github",
|
|
@@ -73,43 +27,28 @@ export function createGitHubContentAdapter(options) {
|
|
|
73
27
|
const message = resolved.commitMessage
|
|
74
28
|
? resolved.commitMessage({ changedIds: plan.changedIds })
|
|
75
29
|
: buildCommitMessage(plan.changedIds);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (resolved.author)
|
|
85
|
-
body.author = resolved.author;
|
|
86
|
-
const response = await doFetch(fileUrl, {
|
|
87
|
-
method: "PUT",
|
|
88
|
-
headers: headers({ "content-type": "application/json" }),
|
|
89
|
-
body: JSON.stringify(body),
|
|
90
|
-
});
|
|
91
|
-
if (response.status === 409) {
|
|
92
|
-
// GitHub compared the SHA we sent with the file and found it moved.
|
|
93
|
-
const latest = await readCurrent();
|
|
94
|
-
throw new ContentConflictError("The copy file changed in the repository while you were editing. Reload the latest copy and reapply your edits.", { content: latest.content, revision: latest.revision });
|
|
95
|
-
}
|
|
96
|
-
if (response.status === 401 || response.status === 403) {
|
|
97
|
-
throw new ContentError("ADAPTER_ERROR", `GitHub refused the commit (HTTP ${response.status}). The token needs Contents read/write on ${resolved.owner}/${resolved.repo}, and the branch must not block direct pushes.`);
|
|
30
|
+
let written;
|
|
31
|
+
try {
|
|
32
|
+
written = await client.writeFile({
|
|
33
|
+
path: resolved.path,
|
|
34
|
+
text: plan.text,
|
|
35
|
+
baseRevision: current.revision,
|
|
36
|
+
message,
|
|
37
|
+
});
|
|
98
38
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
39
|
+
catch (error) {
|
|
40
|
+
// GitHub compared the SHA we sent with the file and found it moved.
|
|
41
|
+
// The editor gets the latest copy back so it can rebase in place.
|
|
42
|
+
if (isContentError(error) && error.code === "CONFLICT") {
|
|
43
|
+
const latest = await readCurrent();
|
|
44
|
+
throw new ContentConflictError("The copy file changed in the repository while you were editing. Reload the latest copy and reapply your edits.", { content: latest.content, revision: latest.revision });
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
106
47
|
}
|
|
107
|
-
const commitSha = result.commit?.sha ?? "";
|
|
108
|
-
const commitUrl = result.commit?.html_url;
|
|
109
48
|
return {
|
|
110
49
|
status: "saved",
|
|
111
|
-
snapshot: { content: plan.content, revision:
|
|
112
|
-
commit
|
|
50
|
+
snapshot: { content: plan.content, revision: written.revision },
|
|
51
|
+
...(written.commit ? { commit: written.commit } : {}),
|
|
113
52
|
};
|
|
114
53
|
},
|
|
115
54
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SourceFile, SourceFileStore, SourceFileWrite, SourceFileWriteResult } from "@getrefino/core/documents";
|
|
2
|
+
import type { GitHubCommitIdentity } from "./options.js";
|
|
3
|
+
export interface GitHubFileStoreOptions {
|
|
4
|
+
/** Fine-grained token with **Contents: Read and write** on this one repository. Server-side only. */
|
|
5
|
+
readonly token: string;
|
|
6
|
+
readonly owner: string;
|
|
7
|
+
readonly repo: string;
|
|
8
|
+
readonly branch: string;
|
|
9
|
+
readonly apiBaseUrl?: string;
|
|
10
|
+
readonly apiVersion?: string;
|
|
11
|
+
readonly fetch?: typeof fetch;
|
|
12
|
+
readonly committer?: GitHubCommitIdentity;
|
|
13
|
+
readonly author?: GitHubCommitIdentity;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The raw GitHub calls, shared by the copy adapter and the document store.
|
|
17
|
+
* `readFile` reports a missing file as `null` so each caller can say what a
|
|
18
|
+
* missing file means for it.
|
|
19
|
+
*/
|
|
20
|
+
export interface GitHubFileClient {
|
|
21
|
+
readonly location: (path: string) => string;
|
|
22
|
+
readFile(path: string): Promise<(SourceFile & {
|
|
23
|
+
readonly tooLarge?: false;
|
|
24
|
+
}) | null>;
|
|
25
|
+
writeFile(write: SourceFileWrite): Promise<SourceFileWriteResult>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createGitHubFileClient(options: GitHubFileStoreOptions): GitHubFileClient;
|
|
28
|
+
/** A `SourceFileStore` over one repository and branch. */
|
|
29
|
+
export declare function createGitHubFileStore(options: GitHubFileStoreOptions): SourceFileStore;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ContentError } from "@getrefino/core";
|
|
2
|
+
import { DEFAULT_API_BASE_URL, DEFAULT_API_VERSION, validateRepoPath } from "./options.js";
|
|
3
|
+
function encodePath(path) {
|
|
4
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
5
|
+
}
|
|
6
|
+
function decodeBase64(text) {
|
|
7
|
+
return Buffer.from(text.replace(/\s/g, ""), "base64").toString("utf8");
|
|
8
|
+
}
|
|
9
|
+
function encodeBase64(text) {
|
|
10
|
+
return Buffer.from(text, "utf8").toString("base64");
|
|
11
|
+
}
|
|
12
|
+
export function createGitHubFileClient(options) {
|
|
13
|
+
const token = options.token;
|
|
14
|
+
const apiBaseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
15
|
+
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
|
|
16
|
+
const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
17
|
+
const repoUrl = `${apiBaseUrl}/repos/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}/contents`;
|
|
18
|
+
const location = (path) => `${options.owner}/${options.repo}@${options.branch}:${path}`;
|
|
19
|
+
function headers(extra = {}) {
|
|
20
|
+
return {
|
|
21
|
+
accept: "application/vnd.github+json",
|
|
22
|
+
authorization: `Bearer ${token}`,
|
|
23
|
+
"x-github-api-version": apiVersion,
|
|
24
|
+
"user-agent": "refino-github-adapter",
|
|
25
|
+
...extra,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Never let the token leak through an error message. */
|
|
29
|
+
function scrub(text) {
|
|
30
|
+
return text.split(token).join("[redacted]");
|
|
31
|
+
}
|
|
32
|
+
async function readError(response) {
|
|
33
|
+
try {
|
|
34
|
+
const body = (await response.json());
|
|
35
|
+
return scrub(body.message ?? `HTTP ${response.status}`);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return `HTTP ${response.status}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function rejected(response) {
|
|
42
|
+
return new ContentError("ADAPTER_ERROR", `GitHub rejected the token (HTTP ${response.status}). It needs Contents read/write on ${options.owner}/${options.repo}.`);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
location,
|
|
46
|
+
async readFile(path) {
|
|
47
|
+
const safePath = validateRepoPath(path);
|
|
48
|
+
const url = `${repoUrl}/${encodePath(safePath)}?ref=${encodeURIComponent(options.branch)}`;
|
|
49
|
+
const response = await doFetch(url, { method: "GET", headers: headers(), cache: "no-store" });
|
|
50
|
+
if (response.status === 404)
|
|
51
|
+
return null;
|
|
52
|
+
if (response.status === 401 || response.status === 403)
|
|
53
|
+
throw rejected(response);
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new ContentError("ADAPTER_ERROR", `GitHub read failed: ${await readError(response)}`);
|
|
56
|
+
}
|
|
57
|
+
const file = (await response.json());
|
|
58
|
+
if (file.type !== "file" || typeof file.sha !== "string") {
|
|
59
|
+
throw new ContentError("ADAPTER_ERROR", `${location(safePath)} is not a file.`);
|
|
60
|
+
}
|
|
61
|
+
if (file.encoding !== "base64" || typeof file.content !== "string") {
|
|
62
|
+
throw new ContentError("ADAPTER_ERROR", `${location(safePath)} is too large to edit through the Contents API (1 MB limit).`);
|
|
63
|
+
}
|
|
64
|
+
return { text: decodeBase64(file.content), revision: file.sha };
|
|
65
|
+
},
|
|
66
|
+
async writeFile(write) {
|
|
67
|
+
const safePath = validateRepoPath(write.path);
|
|
68
|
+
const body = {
|
|
69
|
+
message: write.message,
|
|
70
|
+
content: encodeBase64(write.text),
|
|
71
|
+
sha: write.baseRevision,
|
|
72
|
+
branch: options.branch,
|
|
73
|
+
};
|
|
74
|
+
if (options.committer)
|
|
75
|
+
body.committer = options.committer;
|
|
76
|
+
if (options.author)
|
|
77
|
+
body.author = options.author;
|
|
78
|
+
const response = await doFetch(`${repoUrl}/${encodePath(safePath)}`, {
|
|
79
|
+
method: "PUT",
|
|
80
|
+
headers: headers({ "content-type": "application/json" }),
|
|
81
|
+
body: JSON.stringify(body),
|
|
82
|
+
});
|
|
83
|
+
if (response.status === 409) {
|
|
84
|
+
throw new ContentError("CONFLICT", `${safePath} changed in the repository while you were editing. Reload the latest content and reapply your edits.`, { path: safePath });
|
|
85
|
+
}
|
|
86
|
+
if (response.status === 401 || response.status === 403) {
|
|
87
|
+
throw new ContentError("ADAPTER_ERROR", `GitHub refused the commit (HTTP ${response.status}). The token needs Contents read/write on ${options.owner}/${options.repo}, and the branch must not block direct pushes.`);
|
|
88
|
+
}
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
throw new ContentError("ADAPTER_ERROR", `GitHub commit failed: ${await readError(response)}`);
|
|
91
|
+
}
|
|
92
|
+
const result = (await response.json());
|
|
93
|
+
const newSha = result.content?.sha;
|
|
94
|
+
if (typeof newSha !== "string") {
|
|
95
|
+
throw new ContentError("ADAPTER_ERROR", "GitHub commit succeeded but returned no file SHA.");
|
|
96
|
+
}
|
|
97
|
+
const commitUrl = result.commit?.html_url;
|
|
98
|
+
const commit = commitUrl
|
|
99
|
+
? { sha: result.commit?.sha ?? "", message: write.message, url: commitUrl }
|
|
100
|
+
: { sha: result.commit?.sha ?? "", message: write.message };
|
|
101
|
+
return { revision: newSha, commit };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** A `SourceFileStore` over one repository and branch. */
|
|
106
|
+
export function createGitHubFileStore(options) {
|
|
107
|
+
const client = createGitHubFileClient(options);
|
|
108
|
+
return {
|
|
109
|
+
name: "github",
|
|
110
|
+
readFile: (path) => client.readFile(path),
|
|
111
|
+
writeFile: (write) => client.writeFile(write),
|
|
112
|
+
};
|
|
113
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { createGitHubContentAdapter } from "./adapter.js";
|
|
2
|
+
export { createGitHubFileClient, createGitHubFileStore } from "./file-store.js";
|
|
3
|
+
export type { GitHubFileClient, GitHubFileStoreOptions } from "./file-store.js";
|
|
2
4
|
export { DEFAULT_API_BASE_URL, DEFAULT_API_VERSION, GITHUB_ENV, githubOptionsFromEnv, validateRepoPath, } from "./options.js";
|
|
3
5
|
export type { GitHubCommitIdentity, GitHubContentAdapterOptions } from "./options.js";
|
|
6
|
+
export { GitHubRepositoryError, MAX_BLOB_BYTES, createGitHubRepository, isGitHubRepositoryError } from "./repository.js";
|
|
7
|
+
export type { CommitFile, CommitResult, GitHubRepository, GitHubRepositoryErrorCode, GitHubRepositoryOptions, RepositorySnapshot } from "./repository.js";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { createGitHubContentAdapter } from "./adapter.js";
|
|
2
|
+
export { createGitHubFileClient, createGitHubFileStore } from "./file-store.js";
|
|
2
3
|
export { DEFAULT_API_BASE_URL, DEFAULT_API_VERSION, GITHUB_ENV, githubOptionsFromEnv, validateRepoPath, } from "./options.js";
|
|
4
|
+
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.
|
|
3
|
+
"version": "0.1.0-rc.6",
|
|
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.
|
|
39
|
+
"@getrefino/core": "0.1.0-rc.6"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/node": "22.20.2",
|