mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 01:52:04 +00:00
feat: add gh-read GitHub app helper
This commit is contained in:
@@ -21,6 +21,32 @@ Use these when a task is clearly tied to a script; otherwise prefer the CLI.
|
||||
|
||||
Auth monitoring is covered in [Authentication](/gateway/authentication). The scripts under `scripts/` are optional extras for systemd/Termux phone workflows.
|
||||
|
||||
## GitHub read helper
|
||||
|
||||
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.
|
||||
|
||||
Required env:
|
||||
|
||||
- `OPENCLAW_GH_READ_APP_ID`
|
||||
- `OPENCLAW_GH_READ_PRIVATE_KEY_FILE`
|
||||
|
||||
Optional env:
|
||||
|
||||
- `OPENCLAW_GH_READ_INSTALLATION_ID` when you want to skip repo-based installation lookup
|
||||
- `OPENCLAW_GH_READ_PERMISSIONS` as a comma-separated override for the read permission subset to request
|
||||
|
||||
Repo resolution order:
|
||||
|
||||
- `gh ... -R owner/repo`
|
||||
- `GH_REPO`
|
||||
- `git remote origin`
|
||||
|
||||
Examples:
|
||||
|
||||
- `scripts/gh-read pr view 123`
|
||||
- `scripts/gh-read run list -R openclaw/openclaw`
|
||||
- `scripts/gh-read api repos/openclaw/openclaw/pulls/123`
|
||||
|
||||
## When adding scripts
|
||||
|
||||
- Keep scripts focused and documented.
|
||||
|
||||
6
scripts/gh-read
Executable file
6
scripts/gh-read
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
exec node --import tsx "$script_dir/gh-read.ts" "$@"
|
||||
259
scripts/gh-read.ts
Normal file
259
scripts/gh-read.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { createPrivateKey, createSign } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const APP_ID_ENV = "OPENCLAW_GH_READ_APP_ID";
|
||||
const KEY_FILE_ENV = "OPENCLAW_GH_READ_PRIVATE_KEY_FILE";
|
||||
const INSTALLATION_ID_ENV = "OPENCLAW_GH_READ_INSTALLATION_ID";
|
||||
const PERMISSIONS_ENV = "OPENCLAW_GH_READ_PERMISSIONS";
|
||||
const API_VERSION = "2022-11-28";
|
||||
const DEFAULT_READ_PERMISSION_KEYS = [
|
||||
"actions",
|
||||
"checks",
|
||||
"contents",
|
||||
"issues",
|
||||
"metadata",
|
||||
"pull_requests",
|
||||
"statuses",
|
||||
] as const;
|
||||
|
||||
type GrantedPermissionLevel = "read" | "write" | "admin" | null | undefined;
|
||||
type RequestedPermissionLevel = "read" | "write";
|
||||
type GrantedPermissions = Record<string, GrantedPermissionLevel>;
|
||||
type RequestedPermissions = Record<string, RequestedPermissionLevel>;
|
||||
|
||||
type InstallationResponse = {
|
||||
id: number;
|
||||
permissions?: GrantedPermissions;
|
||||
};
|
||||
|
||||
type AccessTokenResponse = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export function parseRepoArg(args: string[]): string | null {
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === "-R" || arg === "--repo") {
|
||||
return normalizeRepo(args[i + 1] ?? null);
|
||||
}
|
||||
if (arg.startsWith("--repo=")) {
|
||||
return normalizeRepo(arg.slice("--repo=".length));
|
||||
}
|
||||
if (arg.startsWith("-R") && arg.length > 2) {
|
||||
return normalizeRepo(arg.slice(2));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeRepo(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withoutProtocol = trimmed.replace(/^[a-z]+:\/\//i, "");
|
||||
const withoutHost = withoutProtocol.replace(/^(?:[^@/]+@)?github\.com[:/]/i, "");
|
||||
const normalized = withoutHost.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
|
||||
}
|
||||
|
||||
export function parsePermissionKeys(raw: string | null | undefined): string[] {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return [...DEFAULT_READ_PERMISSION_KEYS];
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function buildReadPermissions(
|
||||
grantedPermissions: GrantedPermissions | null | undefined,
|
||||
requestedKeys: readonly string[],
|
||||
): RequestedPermissions {
|
||||
const permissions: RequestedPermissions = {};
|
||||
for (const key of requestedKeys) {
|
||||
const granted = grantedPermissions?.[key];
|
||||
if (granted === "read" || granted === "write") {
|
||||
permissions[key] = "read";
|
||||
}
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
const entry = process.argv[1];
|
||||
return entry ? import.meta.url === pathToFileURL(entry).href : false;
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`gh-read: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function readRequiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
fail(`missing ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveRepo(args: string[]): string | null {
|
||||
const fromArgs = parseRepoArg(args);
|
||||
if (fromArgs) {
|
||||
return fromArgs;
|
||||
}
|
||||
|
||||
const fromEnv = normalizeRepo(process.env.GH_REPO);
|
||||
if (fromEnv) {
|
||||
return fromEnv;
|
||||
}
|
||||
|
||||
try {
|
||||
const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return normalizeRepo(remote);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string | Uint8Array) {
|
||||
return Buffer.from(value)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function createAppJwt(appId: string, privateKeyPem: string) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
||||
const payload = base64UrlEncode(JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: appId }));
|
||||
const signingInput = `${header}.${payload}`;
|
||||
const signer = createSign("RSA-SHA256");
|
||||
signer.update(signingInput);
|
||||
signer.end();
|
||||
const signature = signer.sign(createPrivateKey(privateKeyPem));
|
||||
return `${signingInput}.${base64UrlEncode(signature)}`;
|
||||
}
|
||||
|
||||
async function githubJson<T>(
|
||||
path: string,
|
||||
bearerToken: string,
|
||||
init?: {
|
||||
method?: "GET" | "POST";
|
||||
body?: unknown;
|
||||
},
|
||||
): Promise<T> {
|
||||
const response = await fetch(`https://api.github.com${path}`, {
|
||||
method: init?.method ?? "GET",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "openclaw-gh-read",
|
||||
"X-GitHub-Api-Version": API_VERSION,
|
||||
},
|
||||
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function resolveInstallation(
|
||||
appJwt: string,
|
||||
repo: string | null,
|
||||
): Promise<InstallationResponse> {
|
||||
const installationId = process.env[INSTALLATION_ID_ENV]?.trim();
|
||||
if (repo) {
|
||||
return githubJson<InstallationResponse>(`/repos/${repo}/installation`, appJwt);
|
||||
}
|
||||
if (installationId) {
|
||||
return githubJson<InstallationResponse>(`/app/installations/${installationId}`, appJwt);
|
||||
}
|
||||
fail(
|
||||
`missing repo context; pass -R owner/repo, set GH_REPO, or set ${INSTALLATION_ID_ENV} for a direct installation lookup`,
|
||||
);
|
||||
}
|
||||
|
||||
async function createInstallationToken(
|
||||
appJwt: string,
|
||||
installation: InstallationResponse,
|
||||
repo: string | null,
|
||||
): Promise<string> {
|
||||
const repoName = repo?.split("/")[1] ?? null;
|
||||
const requestedPermissionKeys = parsePermissionKeys(process.env[PERMISSIONS_ENV]);
|
||||
const permissions = buildReadPermissions(installation.permissions, requestedPermissionKeys);
|
||||
const body: {
|
||||
repositories?: string[];
|
||||
permissions?: RequestedPermissions;
|
||||
} = {};
|
||||
|
||||
if (repoName) {
|
||||
body.repositories = [repoName];
|
||||
}
|
||||
if (Object.keys(permissions).length > 0) {
|
||||
body.permissions = permissions;
|
||||
}
|
||||
|
||||
const tokenResponse = await githubJson<AccessTokenResponse>(
|
||||
`/app/installations/${installation.id}/access_tokens`,
|
||||
appJwt,
|
||||
{ method: "POST", body },
|
||||
);
|
||||
return tokenResponse.token;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.length <= 2) {
|
||||
fail(
|
||||
"usage: scripts/gh-read <gh args...>\nset OPENCLAW_GH_READ_APP_ID and OPENCLAW_GH_READ_PRIVATE_KEY_FILE first",
|
||||
);
|
||||
}
|
||||
|
||||
const ghArgs = process.argv.slice(2);
|
||||
const appId = readRequiredEnv(APP_ID_ENV);
|
||||
const privateKeyPath = readRequiredEnv(KEY_FILE_ENV);
|
||||
const privateKeyPem = readFileSync(privateKeyPath, "utf8");
|
||||
const repo = resolveRepo(ghArgs);
|
||||
const appJwt = createAppJwt(appId, privateKeyPem);
|
||||
const installation = await resolveInstallation(appJwt, repo);
|
||||
const token = await createInstallationToken(appJwt, installation, repo);
|
||||
const child = spawnSync("gh", ghArgs, {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
GH_TOKEN: token,
|
||||
GITHUB_TOKEN: token,
|
||||
},
|
||||
});
|
||||
|
||||
if (child.error) {
|
||||
fail(child.error.message);
|
||||
}
|
||||
|
||||
process.exit(child.status ?? 1);
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
await main();
|
||||
}
|
||||
52
test/scripts/gh-read.test.ts
Normal file
52
test/scripts/gh-read.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildReadPermissions,
|
||||
normalizeRepo,
|
||||
parsePermissionKeys,
|
||||
parseRepoArg,
|
||||
} from "../../scripts/gh-read.js";
|
||||
|
||||
describe("gh-read helpers", () => {
|
||||
it("finds repo from gh args", () => {
|
||||
expect(parseRepoArg(["pr", "view", "42", "-R", "openclaw/openclaw"])).toBe("openclaw/openclaw");
|
||||
expect(parseRepoArg(["run", "list", "--repo=openclaw/docs"])).toBe("openclaw/docs");
|
||||
expect(parseRepoArg(["pr", "view", "42"])).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes repo strings from common git formats", () => {
|
||||
expect(normalizeRepo("openclaw/openclaw")).toBe("openclaw/openclaw");
|
||||
expect(normalizeRepo("github.com/openclaw/openclaw")).toBe("openclaw/openclaw");
|
||||
expect(normalizeRepo("https://github.com/openclaw/openclaw.git")).toBe("openclaw/openclaw");
|
||||
expect(normalizeRepo("git@github.com:openclaw/openclaw.git")).toBe("openclaw/openclaw");
|
||||
expect(normalizeRepo("invalid")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds a read-only permission subset from granted permissions", () => {
|
||||
expect(
|
||||
buildReadPermissions(
|
||||
{
|
||||
actions: "write",
|
||||
issues: "read",
|
||||
administration: "write",
|
||||
metadata: "read",
|
||||
statuses: null,
|
||||
},
|
||||
["actions", "issues", "metadata", "statuses", "administration"],
|
||||
),
|
||||
).toEqual({
|
||||
administration: "read",
|
||||
actions: "read",
|
||||
issues: "read",
|
||||
metadata: "read",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses permission key overrides", () => {
|
||||
expect(parsePermissionKeys(undefined)).toContain("pull_requests");
|
||||
expect(parsePermissionKeys("actions, contents ,issues")).toEqual([
|
||||
"actions",
|
||||
"contents",
|
||||
"issues",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user