@getrefino/github 0.1.0-rc.5 → 0.1.0
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 +2 -0
- package/dist/index.js +1 -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,4 +1,6 @@
|
|
|
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";
|
|
4
6
|
export { GitHubRepositoryError, MAX_BLOB_BYTES, createGitHubRepository, isGitHubRepositoryError } from "./repository.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +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";
|
|
3
4
|
export { GitHubRepositoryError, MAX_BLOB_BYTES, createGitHubRepository, isGitHubRepositoryError } from "./repository.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getrefino/github",
|
|
3
|
-
"version": "0.1.0
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
|
39
|
+
"@getrefino/core": "0.1.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/node": "22.20.2",
|