Skip to content

Commit d51f527

Browse files
committed
feat: add gh-read GitHub app helper
1 parent 6f159a9 commit d51f527

4 files changed

Lines changed: 343 additions & 0 deletions

File tree

docs/help/scripts.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,32 @@ Use these when a task is clearly tied to a script; otherwise prefer the CLI.
2121

2222
Auth monitoring is covered in [Authentication](/gateway/authentication). The scripts under `scripts/` are optional extras for systemd/Termux phone workflows.
2323

24+
## GitHub read helper
25+
26+
Use `scripts/gh-read` when you want `gh` to use a GitHub App installation token for repo-scoped read calls while leaving normal `gh` on your personal login for write actions.
27+
28+
Required env:
29+
30+
- `OPENCLAW_GH_READ_APP_ID`
31+
- `OPENCLAW_GH_READ_PRIVATE_KEY_FILE`
32+
33+
Optional env:
34+
35+
- `OPENCLAW_GH_READ_INSTALLATION_ID` when you want to skip repo-based installation lookup
36+
- `OPENCLAW_GH_READ_PERMISSIONS` as a comma-separated override for the read permission subset to request
37+
38+
Repo resolution order:
39+
40+
- `gh ... -R owner/repo`
41+
- `GH_REPO`
42+
- `git remote origin`
43+
44+
Examples:
45+
46+
- `scripts/gh-read pr view 123`
47+
- `scripts/gh-read run list -R openclaw/openclaw`
48+
- `scripts/gh-read api repos/openclaw/openclaw/pulls/123`
49+
2450
## When adding scripts
2551

2652
- Keep scripts focused and documented.

scripts/gh-read

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
script_dir="$(cd "$(dirname "$0")" && pwd)"
5+
6+
exec node --import tsx "$script_dir/gh-read.ts" "$@"

scripts/gh-read.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { execFileSync, spawnSync } from "node:child_process";
2+
import { createPrivateKey, createSign } from "node:crypto";
3+
import { readFileSync } from "node:fs";
4+
import { pathToFileURL } from "node:url";
5+
6+
const APP_ID_ENV = "OPENCLAW_GH_READ_APP_ID";
7+
const KEY_FILE_ENV = "OPENCLAW_GH_READ_PRIVATE_KEY_FILE";
8+
const INSTALLATION_ID_ENV = "OPENCLAW_GH_READ_INSTALLATION_ID";
9+
const PERMISSIONS_ENV = "OPENCLAW_GH_READ_PERMISSIONS";
10+
const API_VERSION = "2022-11-28";
11+
const DEFAULT_READ_PERMISSION_KEYS = [
12+
"actions",
13+
"checks",
14+
"contents",
15+
"issues",
16+
"metadata",
17+
"pull_requests",
18+
"statuses",
19+
] as const;
20+
21+
type GrantedPermissionLevel = "read" | "write" | "admin" | null | undefined;
22+
type RequestedPermissionLevel = "read" | "write";
23+
type GrantedPermissions = Record<string, GrantedPermissionLevel>;
24+
type RequestedPermissions = Record<string, RequestedPermissionLevel>;
25+
26+
type InstallationResponse = {
27+
id: number;
28+
permissions?: GrantedPermissions;
29+
};
30+
31+
type AccessTokenResponse = {
32+
token: string;
33+
};
34+
35+
export function parseRepoArg(args: string[]): string | null {
36+
for (let i = 0; i < args.length; i += 1) {
37+
const arg = args[i];
38+
if (arg === "-R" || arg === "--repo") {
39+
return normalizeRepo(args[i + 1] ?? null);
40+
}
41+
if (arg.startsWith("--repo=")) {
42+
return normalizeRepo(arg.slice("--repo=".length));
43+
}
44+
if (arg.startsWith("-R") && arg.length > 2) {
45+
return normalizeRepo(arg.slice(2));
46+
}
47+
}
48+
return null;
49+
}
50+
51+
export function normalizeRepo(value: string | null | undefined): string | null {
52+
const trimmed = value?.trim();
53+
if (!trimmed) {
54+
return null;
55+
}
56+
57+
const withoutProtocol = trimmed.replace(/^[a-z]+:\/\//i, "");
58+
const withoutHost = withoutProtocol.replace(/^(?:[^@/]+@)?github\.com[:/]/i, "");
59+
const normalized = withoutHost.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
60+
const parts = normalized.split("/").filter(Boolean);
61+
if (parts.length < 2) {
62+
return null;
63+
}
64+
65+
return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
66+
}
67+
68+
export function parsePermissionKeys(raw: string | null | undefined): string[] {
69+
const trimmed = raw?.trim();
70+
if (!trimmed) {
71+
return [...DEFAULT_READ_PERMISSION_KEYS];
72+
}
73+
74+
return trimmed
75+
.split(",")
76+
.map((value) => value.trim())
77+
.filter(Boolean);
78+
}
79+
80+
export function buildReadPermissions(
81+
grantedPermissions: GrantedPermissions | null | undefined,
82+
requestedKeys: readonly string[],
83+
): RequestedPermissions {
84+
const permissions: RequestedPermissions = {};
85+
for (const key of requestedKeys) {
86+
const granted = grantedPermissions?.[key];
87+
if (granted === "read" || granted === "write") {
88+
permissions[key] = "read";
89+
}
90+
}
91+
return permissions;
92+
}
93+
94+
function isMainModule() {
95+
const entry = process.argv[1];
96+
return entry ? import.meta.url === pathToFileURL(entry).href : false;
97+
}
98+
99+
function fail(message: string): never {
100+
console.error(`gh-read: ${message}`);
101+
process.exit(1);
102+
}
103+
104+
function readRequiredEnv(name: string): string {
105+
const value = process.env[name]?.trim();
106+
if (!value) {
107+
fail(`missing ${name}`);
108+
}
109+
return value;
110+
}
111+
112+
function resolveRepo(args: string[]): string | null {
113+
const fromArgs = parseRepoArg(args);
114+
if (fromArgs) {
115+
return fromArgs;
116+
}
117+
118+
const fromEnv = normalizeRepo(process.env.GH_REPO);
119+
if (fromEnv) {
120+
return fromEnv;
121+
}
122+
123+
try {
124+
const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], {
125+
encoding: "utf8",
126+
stdio: ["ignore", "pipe", "ignore"],
127+
}).trim();
128+
return normalizeRepo(remote);
129+
} catch {
130+
return null;
131+
}
132+
}
133+
134+
function base64UrlEncode(value: string | Uint8Array) {
135+
return Buffer.from(value)
136+
.toString("base64")
137+
.replace(/\+/g, "-")
138+
.replace(/\//g, "_")
139+
.replace(/=+$/g, "");
140+
}
141+
142+
function createAppJwt(appId: string, privateKeyPem: string) {
143+
const now = Math.floor(Date.now() / 1000);
144+
const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
145+
const payload = base64UrlEncode(JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: appId }));
146+
const signingInput = `${header}.${payload}`;
147+
const signer = createSign("RSA-SHA256");
148+
signer.update(signingInput);
149+
signer.end();
150+
const signature = signer.sign(createPrivateKey(privateKeyPem));
151+
return `${signingInput}.${base64UrlEncode(signature)}`;
152+
}
153+
154+
async function githubJson<T>(
155+
path: string,
156+
bearerToken: string,
157+
init?: {
158+
method?: "GET" | "POST";
159+
body?: unknown;
160+
},
161+
): Promise<T> {
162+
const response = await fetch(`https://api.github.com${path}`, {
163+
method: init?.method ?? "GET",
164+
headers: {
165+
Accept: "application/vnd.github+json",
166+
Authorization: `Bearer ${bearerToken}`,
167+
"Content-Type": "application/json",
168+
"User-Agent": "openclaw-gh-read",
169+
"X-GitHub-Api-Version": API_VERSION,
170+
},
171+
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
172+
});
173+
174+
if (!response.ok) {
175+
const text = await response.text();
176+
fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`);
177+
}
178+
179+
return (await response.json()) as T;
180+
}
181+
182+
async function resolveInstallation(
183+
appJwt: string,
184+
repo: string | null,
185+
): Promise<InstallationResponse> {
186+
const installationId = process.env[INSTALLATION_ID_ENV]?.trim();
187+
if (repo) {
188+
return githubJson<InstallationResponse>(`/repos/${repo}/installation`, appJwt);
189+
}
190+
if (installationId) {
191+
return githubJson<InstallationResponse>(`/app/installations/${installationId}`, appJwt);
192+
}
193+
fail(
194+
`missing repo context; pass -R owner/repo, set GH_REPO, or set ${INSTALLATION_ID_ENV} for a direct installation lookup`,
195+
);
196+
}
197+
198+
async function createInstallationToken(
199+
appJwt: string,
200+
installation: InstallationResponse,
201+
repo: string | null,
202+
): Promise<string> {
203+
const repoName = repo?.split("/")[1] ?? null;
204+
const requestedPermissionKeys = parsePermissionKeys(process.env[PERMISSIONS_ENV]);
205+
const permissions = buildReadPermissions(installation.permissions, requestedPermissionKeys);
206+
const body: {
207+
repositories?: string[];
208+
permissions?: RequestedPermissions;
209+
} = {};
210+
211+
if (repoName) {
212+
body.repositories = [repoName];
213+
}
214+
if (Object.keys(permissions).length > 0) {
215+
body.permissions = permissions;
216+
}
217+
218+
const tokenResponse = await githubJson<AccessTokenResponse>(
219+
`/app/installations/${installation.id}/access_tokens`,
220+
appJwt,
221+
{ method: "POST", body },
222+
);
223+
return tokenResponse.token;
224+
}
225+
226+
async function main() {
227+
if (process.argv.length <= 2) {
228+
fail(
229+
"usage: scripts/gh-read <gh args...>\nset OPENCLAW_GH_READ_APP_ID and OPENCLAW_GH_READ_PRIVATE_KEY_FILE first",
230+
);
231+
}
232+
233+
const ghArgs = process.argv.slice(2);
234+
const appId = readRequiredEnv(APP_ID_ENV);
235+
const privateKeyPath = readRequiredEnv(KEY_FILE_ENV);
236+
const privateKeyPem = readFileSync(privateKeyPath, "utf8");
237+
const repo = resolveRepo(ghArgs);
238+
const appJwt = createAppJwt(appId, privateKeyPem);
239+
const installation = await resolveInstallation(appJwt, repo);
240+
const token = await createInstallationToken(appJwt, installation, repo);
241+
const child = spawnSync("gh", ghArgs, {
242+
stdio: "inherit",
243+
env: {
244+
...process.env,
245+
GH_TOKEN: token,
246+
GITHUB_TOKEN: token,
247+
},
248+
});
249+
250+
if (child.error) {
251+
fail(child.error.message);
252+
}
253+
254+
process.exit(child.status ?? 1);
255+
}
256+
257+
if (isMainModule()) {
258+
await main();
259+
}

test/scripts/gh-read.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
buildReadPermissions,
4+
normalizeRepo,
5+
parsePermissionKeys,
6+
parseRepoArg,
7+
} from "../../scripts/gh-read.js";
8+
9+
describe("gh-read helpers", () => {
10+
it("finds repo from gh args", () => {
11+
expect(parseRepoArg(["pr", "view", "42", "-R", "openclaw/openclaw"])).toBe("openclaw/openclaw");
12+
expect(parseRepoArg(["run", "list", "--repo=openclaw/docs"])).toBe("openclaw/docs");
13+
expect(parseRepoArg(["pr", "view", "42"])).toBeNull();
14+
});
15+
16+
it("normalizes repo strings from common git formats", () => {
17+
expect(normalizeRepo("openclaw/openclaw")).toBe("openclaw/openclaw");
18+
expect(normalizeRepo("github.com/openclaw/openclaw")).toBe("openclaw/openclaw");
19+
expect(normalizeRepo("https://github.com/openclaw/openclaw.git")).toBe("openclaw/openclaw");
20+
expect(normalizeRepo("git@github.com:openclaw/openclaw.git")).toBe("openclaw/openclaw");
21+
expect(normalizeRepo("invalid")).toBeNull();
22+
});
23+
24+
it("builds a read-only permission subset from granted permissions", () => {
25+
expect(
26+
buildReadPermissions(
27+
{
28+
actions: "write",
29+
issues: "read",
30+
administration: "write",
31+
metadata: "read",
32+
statuses: null,
33+
},
34+
["actions", "issues", "metadata", "statuses", "administration"],
35+
),
36+
).toEqual({
37+
administration: "read",
38+
actions: "read",
39+
issues: "read",
40+
metadata: "read",
41+
});
42+
});
43+
44+
it("parses permission key overrides", () => {
45+
expect(parsePermissionKeys(undefined)).toContain("pull_requests");
46+
expect(parsePermissionKeys("actions, contents ,issues")).toEqual([
47+
"actions",
48+
"contents",
49+
"issues",
50+
]);
51+
});
52+
});

0 commit comments

Comments
 (0)