Skip to content

Commit b65ab3a

Browse files
committed
feat: add @array/core GitHub integration
1 parent ac67cf8 commit b65ab3a

5 files changed

Lines changed: 684 additions & 0 deletions

File tree

packages/core/src/github/branch.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { createError, err, type Result } from "../result";
2+
import { withGitHub } from "./client";
3+
4+
export function isProtectedBranch(branchName: string): boolean {
5+
const protectedBranches = ["main", "master", "trunk", "develop"];
6+
const lower = branchName.toLowerCase();
7+
return (
8+
protectedBranches.includes(branchName) || protectedBranches.includes(lower)
9+
);
10+
}
11+
12+
export async function deleteBranch(
13+
branchName: string,
14+
cwd = process.cwd(),
15+
): Promise<Result<void>> {
16+
if (isProtectedBranch(branchName)) {
17+
return err(
18+
createError(
19+
"INVALID_STATE",
20+
`Cannot delete protected branch: ${branchName}`,
21+
),
22+
);
23+
}
24+
25+
return withGitHub(cwd, "delete branch", async ({ octokit, owner, repo }) => {
26+
try {
27+
await octokit.git.deleteRef({
28+
owner,
29+
repo,
30+
ref: `heads/${branchName}`,
31+
});
32+
} catch (e) {
33+
const error = e as Error & { status?: number };
34+
// 422 means branch doesn't exist, which is fine
35+
if (error.status !== 422) {
36+
throw e;
37+
}
38+
}
39+
});
40+
}

packages/core/src/github/client.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Octokit } from "@octokit/rest";
2+
import { shellExecutor } from "../executor";
3+
import { createError, err, ok, type Result } from "../result";
4+
5+
export interface RepoInfo {
6+
owner: string;
7+
repo: string;
8+
}
9+
10+
// Module-level caches (keyed by cwd)
11+
const tokenCache = new Map<string, string>();
12+
const repoCache = new Map<string, RepoInfo>();
13+
const octokitCache = new Map<string, Octokit>();
14+
15+
export async function getToken(cwd: string): Promise<string> {
16+
const cached = tokenCache.get(cwd);
17+
if (cached) return cached;
18+
19+
const result = await shellExecutor.execute("gh", ["auth", "token"], { cwd });
20+
if (result.exitCode !== 0) {
21+
throw new Error(`Failed to get GitHub token: ${result.stderr}`);
22+
}
23+
const token = result.stdout.trim();
24+
tokenCache.set(cwd, token);
25+
return token;
26+
}
27+
28+
export async function getRepoInfo(cwd: string): Promise<Result<RepoInfo>> {
29+
const cached = repoCache.get(cwd);
30+
if (cached) return ok(cached);
31+
32+
try {
33+
const result = await shellExecutor.execute(
34+
"git",
35+
["config", "--get", "remote.origin.url"],
36+
{ cwd },
37+
);
38+
39+
if (result.exitCode !== 0) {
40+
return err(createError("COMMAND_FAILED", "No git remote found"));
41+
}
42+
43+
const url = result.stdout.trim();
44+
const match = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
45+
if (!match) {
46+
return err(
47+
createError(
48+
"COMMAND_FAILED",
49+
"Could not parse GitHub repo from remote URL",
50+
),
51+
);
52+
}
53+
54+
const info = { owner: match[1], repo: match[2] };
55+
repoCache.set(cwd, info);
56+
return ok(info);
57+
} catch (e) {
58+
return err(createError("COMMAND_FAILED", `Failed to get repo info: ${e}`));
59+
}
60+
}
61+
62+
export async function getOctokit(cwd: string): Promise<Octokit> {
63+
const cached = octokitCache.get(cwd);
64+
if (cached) return cached;
65+
66+
const token = await getToken(cwd);
67+
const octokit = new Octokit({ auth: token });
68+
octokitCache.set(cwd, octokit);
69+
return octokit;
70+
}
71+
72+
export interface GitHubContext {
73+
octokit: Octokit;
74+
owner: string;
75+
repo: string;
76+
}
77+
78+
/**
79+
* Helper to reduce boilerplate for GitHub API calls.
80+
* Handles repo info lookup, octokit creation, and error wrapping.
81+
*/
82+
export async function withGitHub<T>(
83+
cwd: string,
84+
operation: string,
85+
fn: (ctx: GitHubContext) => Promise<T>,
86+
): Promise<Result<T>> {
87+
const repoResult = await getRepoInfo(cwd);
88+
if (!repoResult.ok) return repoResult;
89+
90+
const { owner, repo } = repoResult.value;
91+
92+
try {
93+
const octokit = await getOctokit(cwd);
94+
const result = await fn({ octokit, owner, repo });
95+
return ok(result);
96+
} catch (e) {
97+
return err(createError("COMMAND_FAILED", `Failed to ${operation}: ${e}`));
98+
}
99+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { ok, type Result } from "../result";
2+
import { withGitHub } from "./client";
3+
4+
const STACK_COMMENT_MARKER = "<!-- array-stack-comment -->";
5+
6+
export interface GitHubComment {
7+
id: number;
8+
body: string;
9+
createdAt: string;
10+
updatedAt: string;
11+
}
12+
13+
function listComments(
14+
prNumber: number,
15+
cwd = process.cwd(),
16+
): Promise<Result<GitHubComment[]>> {
17+
return withGitHub(cwd, "list comments", async ({ octokit, owner, repo }) => {
18+
const { data } = await octokit.issues.listComments({
19+
owner,
20+
repo,
21+
issue_number: prNumber,
22+
});
23+
24+
return data.map((c) => ({
25+
id: c.id,
26+
body: c.body ?? "",
27+
createdAt: c.created_at,
28+
updatedAt: c.updated_at,
29+
}));
30+
});
31+
}
32+
33+
function createComment(
34+
prNumber: number,
35+
body: string,
36+
cwd = process.cwd(),
37+
): Promise<Result<GitHubComment>> {
38+
return withGitHub(cwd, "create comment", async ({ octokit, owner, repo }) => {
39+
const { data } = await octokit.issues.createComment({
40+
owner,
41+
repo,
42+
issue_number: prNumber,
43+
body,
44+
});
45+
46+
return {
47+
id: data.id,
48+
body: data.body ?? "",
49+
createdAt: data.created_at,
50+
updatedAt: data.updated_at,
51+
};
52+
});
53+
}
54+
55+
function updateComment(
56+
commentId: number,
57+
body: string,
58+
cwd = process.cwd(),
59+
): Promise<Result<void>> {
60+
return withGitHub(cwd, "update comment", async ({ octokit, owner, repo }) => {
61+
await octokit.issues.updateComment({
62+
owner,
63+
repo,
64+
comment_id: commentId,
65+
body,
66+
});
67+
});
68+
}
69+
70+
async function findStackComment(
71+
prNumber: number,
72+
cwd = process.cwd(),
73+
): Promise<Result<GitHubComment | null>> {
74+
const commentsResult = await listComments(prNumber, cwd);
75+
if (!commentsResult.ok) return commentsResult;
76+
77+
const stackComment = commentsResult.value.find((c) =>
78+
c.body.includes(STACK_COMMENT_MARKER),
79+
);
80+
return ok(stackComment ?? null);
81+
}
82+
83+
export async function upsertStackComment(
84+
prNumber: number,
85+
body: string,
86+
cwd = process.cwd(),
87+
): Promise<Result<GitHubComment>> {
88+
const markedBody = `${STACK_COMMENT_MARKER}\n${body}`;
89+
90+
const existingResult = await findStackComment(prNumber, cwd);
91+
if (!existingResult.ok) return existingResult;
92+
93+
if (existingResult.value) {
94+
const updateResult = await updateComment(
95+
existingResult.value.id,
96+
markedBody,
97+
cwd,
98+
);
99+
if (!updateResult.ok) return updateResult;
100+
return ok({ ...existingResult.value, body: markedBody });
101+
}
102+
103+
return createComment(prNumber, markedBody, cwd);
104+
}

0 commit comments

Comments
 (0)