@ravshansbox/pi-github 0.0.0-stage → 1.0.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/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # pi-github
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Watches open issues in a GitHub repo and answers new issues and comments with [pi](https://github.com/earendil-works/pi) agents.
4
+
5
+ ## How it works
6
+
7
+ - Polls the repo of the current directory for new issues and comments every 30 seconds. Pull requests are ignored.
8
+ - Only handles issues and comments created after the first start. After a restart it picks up where it stopped, including runs a crash cut off. A crash right after posting a reply can lead to that reply being posted twice.
9
+ - Each issue gets its own git worktree in `.worktrees/issue-<number>` on branch `issue-<number>`, and its own pi session that continues across comments.
10
+ - Issues are handled in parallel, up to 3 at once. Comments on the same issue are handled one after another.
11
+ - The agent's final message is posted as a comment that starts with `agent:`. Comments that start with `agent:` are ignored, so it does not answer itself.
12
+ - If the agent made commits, the branch is pushed and a pull request is opened once. Later commits update the same pull request.
13
+ - State and sessions are kept in `.git/pi-github`.
14
+
15
+ ## Requirements
16
+
17
+ - Node.js 24 or later
18
+ - [GitHub CLI](https://cli.github.com) logged in (`gh auth login`), or a `GH_TOKEN` environment variable
19
+ - pi set up with a model and credentials (`~/.pi/agent`)
20
+
21
+ ## Use
22
+
23
+ ```sh
24
+ cd path/to/your/repo
25
+ npx -y @ravshansbox/pi-github [--interval <seconds>] [--concurrency <number>]
26
+ ```
27
+
28
+ Press Ctrl+C to stop after running agents finish, or press it twice to quit at once.
29
+
30
+ ## Security
31
+
32
+ Anyone who can comment on the repo can instruct an agent that has full tools on your machine and your GitHub token. The worktree only separates git changes; it is not a sandbox. Run it in a container or as a separate user with a token limited to the repo if the repo is public.
package/dist/cli.js ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { join } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { git } from "./git.js";
6
+ import { createGitHub } from "./github.js";
7
+ import { createPiAgent } from "./pi-agent.js";
8
+ import { parseRepo, watch } from "./watch.js";
9
+ const { values } = parseArgs({
10
+ options: {
11
+ interval: { type: "string", default: "30" },
12
+ concurrency: { type: "string", default: "3" },
13
+ help: { type: "boolean", short: "h", default: false },
14
+ },
15
+ });
16
+ if (values.help) {
17
+ console.log(`Usage: pi-github [--interval <seconds>] [--concurrency <number>]
18
+
19
+ Watches open issues in the GitHub repo of the current directory and answers new issues and comments with pi agents.
20
+
21
+ Options:
22
+ --interval <seconds> Time between polls (default: 30)
23
+ --concurrency <number> Most issues handled at once (default: 3)`);
24
+ process.exit(0);
25
+ }
26
+ const repoRoot = await git(process.cwd(), "rev-parse", "--show-toplevel");
27
+ const repo = parseRepo(await git(repoRoot, "remote", "get-url", "origin"));
28
+ const token = process.env.GH_TOKEN ?? execFileSync("gh", ["auth", "token"], { encoding: "utf8" }).trim();
29
+ const dataDir = join(await git(repoRoot, "rev-parse", "--path-format=absolute", "--git-common-dir"), "pi-github");
30
+ const github = createGitHub({ baseUrl: "https://api.github.com", token, repo });
31
+ const agent = createPiAgent({ github, repo, sessionsDir: join(dataDir, "sessions") });
32
+ const controller = new AbortController();
33
+ process.on("SIGINT", () => {
34
+ if (controller.signal.aborted)
35
+ process.exit(130);
36
+ console.log("Stopping after running agents finish. Press Ctrl+C again to quit now.");
37
+ controller.abort();
38
+ });
39
+ const log = (message) => console.log(`${new Date().toISOString()} ${message}`);
40
+ log(`Watching ${repo}`);
41
+ await watch({
42
+ github,
43
+ agent,
44
+ repoRoot,
45
+ stateFile: join(dataDir, "state.json"),
46
+ intervalMs: Number(values.interval) * 1000,
47
+ concurrency: Number(values.concurrency),
48
+ signal: controller.signal,
49
+ log,
50
+ });
@@ -0,0 +1,50 @@
1
+ export function createDispatcher({ concurrency, handle, onError = () => { }, }) {
2
+ const queues = new Map();
3
+ const waiting = [];
4
+ const active = new Set();
5
+ let idleWaiters = [];
6
+ function pump() {
7
+ while (active.size < concurrency && waiting.length > 0) {
8
+ const issueNumber = waiting.shift();
9
+ active.add(issueNumber);
10
+ void drain(issueNumber);
11
+ }
12
+ if (active.size === 0 && waiting.length === 0) {
13
+ for (const resolve of idleWaiters)
14
+ resolve();
15
+ idleWaiters = [];
16
+ }
17
+ }
18
+ async function drain(issueNumber) {
19
+ const queue = queues.get(issueNumber);
20
+ while (queue.length > 0) {
21
+ const trigger = queue.shift();
22
+ try {
23
+ await handle(trigger);
24
+ }
25
+ catch (error) {
26
+ onError(trigger, error);
27
+ }
28
+ }
29
+ queues.delete(issueNumber);
30
+ active.delete(issueNumber);
31
+ pump();
32
+ }
33
+ return {
34
+ dispatch(trigger) {
35
+ const queue = queues.get(trigger.issueNumber);
36
+ if (queue) {
37
+ queue.push(trigger);
38
+ return;
39
+ }
40
+ queues.set(trigger.issueNumber, [trigger]);
41
+ waiting.push(trigger.issueNumber);
42
+ pump();
43
+ },
44
+ idle() {
45
+ if (active.size === 0 && waiting.length === 0)
46
+ return Promise.resolve();
47
+ return new Promise((resolve) => idleWaiters.push(resolve));
48
+ },
49
+ };
50
+ }
package/dist/git.js ADDED
@@ -0,0 +1,45 @@
1
+ import { execFile } from "node:child_process";
2
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ export async function git(cwd, ...args) {
8
+ const { stdout } = await execFileAsync("git", args, { cwd });
9
+ return stdout.trim();
10
+ }
11
+ export const branchFor = (issueNumber) => `issue-${issueNumber}`;
12
+ async function excludeWorktrees(repoRoot) {
13
+ const exclude = join(await git(repoRoot, "rev-parse", "--path-format=absolute", "--git-common-dir"), "info", "exclude");
14
+ const current = existsSync(exclude) ? await readFile(exclude, "utf8") : "";
15
+ if (current.split("\n").includes("/.worktrees/"))
16
+ return;
17
+ await mkdir(join(exclude, ".."), { recursive: true });
18
+ await appendFile(exclude, `${current === "" || current.endsWith("\n") ? "" : "\n"}/.worktrees/\n`);
19
+ }
20
+ export async function ensureWorktree(repoRoot, issueNumber, base) {
21
+ const path = join(repoRoot, ".worktrees", `issue-${issueNumber}`);
22
+ if (existsSync(path))
23
+ return path;
24
+ await excludeWorktrees(repoRoot);
25
+ const branch = branchFor(issueNumber);
26
+ await git(repoRoot, "fetch", "-q", "origin", base);
27
+ const branchExists = (await git(repoRoot, "branch", "--list", branch)) !== "";
28
+ if (branchExists)
29
+ await git(repoRoot, "worktree", "add", "-q", path, branch);
30
+ else
31
+ await git(repoRoot, "worktree", "add", "-q", "-b", branch, path, `origin/${base}`);
32
+ return path;
33
+ }
34
+ export async function hasUnpushedCommits(worktree, base) {
35
+ const branch = await git(worktree, "branch", "--show-current");
36
+ const remoteBranch = (await git(worktree, "ls-remote", "--heads", "origin", branch)) !== "";
37
+ const compareWith = remoteBranch ? `origin/${branch}` : `origin/${base}`;
38
+ if (remoteBranch)
39
+ await git(worktree, "fetch", "-q", "origin", branch);
40
+ return Number(await git(worktree, "rev-list", "--count", `${compareWith}..HEAD`)) > 0;
41
+ }
42
+ export async function pushBranch(worktree) {
43
+ const branch = await git(worktree, "branch", "--show-current");
44
+ await git(worktree, "push", "-q", "-u", "origin", branch);
45
+ }
package/dist/github.js ADDED
@@ -0,0 +1,61 @@
1
+ export function createGitHub({ baseUrl, token, repo }) {
2
+ const cache = new Map();
3
+ async function request(method, url, body, headers = {}) {
4
+ const response = await fetch(url.startsWith("http") ? url : `${baseUrl}${url}`, {
5
+ method,
6
+ headers: {
7
+ accept: "application/vnd.github+json",
8
+ authorization: `Bearer ${token}`,
9
+ "x-github-api-version": "2022-11-28",
10
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
11
+ ...headers,
12
+ },
13
+ body: body === undefined ? undefined : JSON.stringify(body),
14
+ });
15
+ if (!response.ok && response.status !== 304) {
16
+ throw new Error(`GitHub ${method} ${url} failed: ${response.status} ${await response.text()}`);
17
+ }
18
+ return response;
19
+ }
20
+ async function list(path) {
21
+ const items = [];
22
+ let url = path;
23
+ while (url) {
24
+ const cached = cache.get(url);
25
+ const response = await request("GET", url, undefined, cached ? { "if-none-match": cached.etag } : {});
26
+ if (response.status === 304 && cached) {
27
+ items.push(...cached.data);
28
+ url = cached.next;
29
+ continue;
30
+ }
31
+ const data = (await response.json());
32
+ const next = response.headers.get("link")?.match(/<([^>]+)>;\s*rel="next"/)?.[1];
33
+ const etag = response.headers.get("etag");
34
+ if (etag)
35
+ cache.set(url, { etag, data, next });
36
+ items.push(...data);
37
+ url = next;
38
+ }
39
+ return items;
40
+ }
41
+ return {
42
+ listIssues: (since) => list(`/repos/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&since=${encodeURIComponent(since)}`),
43
+ listIssueComments: (since) => list(`/repos/${repo}/issues/comments?sort=updated&direction=asc&per_page=100&since=${encodeURIComponent(since)}`),
44
+ postComment: async (issueNumber, body) => {
45
+ await request("POST", `/repos/${repo}/issues/${issueNumber}/comments`, { body });
46
+ },
47
+ getDefaultBranch: async () => {
48
+ const data = (await (await request("GET", `/repos/${repo}`)).json());
49
+ return data.default_branch;
50
+ },
51
+ getIssue: async (issueNumber) => (await (await request("GET", `/repos/${repo}/issues/${issueNumber}`)).json()),
52
+ hasOpenPullRequest: async (branch) => {
53
+ const owner = repo.split("/")[0];
54
+ const response = await request("GET", `/repos/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${branch}`)}`);
55
+ return (await response.json()).length > 0;
56
+ },
57
+ createPullRequest: async (pull) => {
58
+ await request("POST", `/repos/${repo}/pulls`, pull);
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,21 @@
1
+ import { branchFor, ensureWorktree, hasUnpushedCommits, pushBranch } from "./git.js";
2
+ import { agentPrefix } from "./poller.js";
3
+ export async function handleTrigger(trigger, { github, agent, repoRoot }) {
4
+ const base = await github.getDefaultBranch();
5
+ const worktree = await ensureWorktree(repoRoot, trigger.issueNumber, base);
6
+ const answer = await agent.run(trigger, { cwd: worktree });
7
+ if (await hasUnpushedCommits(worktree, base)) {
8
+ await pushBranch(worktree);
9
+ const branch = branchFor(trigger.issueNumber);
10
+ if (!(await github.hasOpenPullRequest(branch))) {
11
+ const issue = await github.getIssue(trigger.issueNumber);
12
+ await github.createPullRequest({
13
+ head: branch,
14
+ base,
15
+ title: issue.title,
16
+ body: `${agentPrefix} Closes #${trigger.issueNumber}`,
17
+ });
18
+ }
19
+ }
20
+ await github.postComment(trigger.issueNumber, `${agentPrefix} ${answer}`);
21
+ }
@@ -0,0 +1,20 @@
1
+ import { join } from "node:path";
2
+ import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import { buildPrompt } from "./prompt.js";
4
+ export function createPiAgent({ github, repo, sessionsDir }) {
5
+ return {
6
+ async run(trigger, { cwd }) {
7
+ const sessionManager = SessionManager.continueRecent(cwd, join(sessionsDir, `issue-${trigger.issueNumber}`));
8
+ const { session } = await createAgentSession({ cwd, sessionManager });
9
+ try {
10
+ const issue = await github.getIssue(trigger.issueNumber);
11
+ const firstInSession = session.messages.length === 0;
12
+ await session.prompt(buildPrompt({ repo, issue, trigger, firstInSession }));
13
+ return session.getLastAssistantText() ?? "";
14
+ }
15
+ finally {
16
+ session.dispose();
17
+ }
18
+ },
19
+ };
20
+ }
package/dist/poller.js ADDED
@@ -0,0 +1,49 @@
1
+ export const agentPrefix = "agent:";
2
+ export function initialPollState(now) {
3
+ const startedAt = now.toISOString();
4
+ return { startedAt, issuesSince: startedAt, commentsSince: startedAt, seen: [] };
5
+ }
6
+ const newest = (since, items) => items.reduce((latest, item) => (item.updated_at > latest ? item.updated_at : latest), since);
7
+ export async function poll(github, state) {
8
+ const [issues, comments] = await Promise.all([
9
+ github.listIssues(state.issuesSince),
10
+ github.listIssueComments(state.commentsSince),
11
+ ]);
12
+ const candidates = [
13
+ ...issues
14
+ .filter((issue) => !issue.pull_request)
15
+ .map((issue) => ({
16
+ key: `issue:${issue.number}`,
17
+ kind: "issue",
18
+ issueNumber: issue.number,
19
+ author: issue.user.login,
20
+ body: issue.body ?? "",
21
+ createdAt: issue.created_at,
22
+ })),
23
+ ...comments
24
+ .filter((comment) => !comment.html_url.includes("/pull/"))
25
+ .map((comment) => ({
26
+ key: `comment:${comment.id}`,
27
+ kind: "comment",
28
+ issueNumber: Number(comment.issue_url.split("/").pop()),
29
+ author: comment.user.login,
30
+ body: comment.body,
31
+ createdAt: comment.created_at,
32
+ })),
33
+ ];
34
+ const seen = new Set(state.seen);
35
+ const triggers = candidates
36
+ .filter((trigger) => trigger.createdAt >= state.startedAt)
37
+ .filter((trigger) => !trigger.body.trimStart().startsWith(agentPrefix))
38
+ .filter((trigger) => !seen.has(trigger.key))
39
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt));
40
+ return {
41
+ triggers,
42
+ state: {
43
+ ...state,
44
+ issuesSince: newest(state.issuesSince, issues),
45
+ commentsSince: newest(state.commentsSince, comments),
46
+ seen: [...state.seen, ...triggers.map((trigger) => trigger.key)],
47
+ },
48
+ };
49
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,14 @@
1
+ import { branchFor } from "./git.js";
2
+ export function buildPrompt({ repo, issue, trigger, firstInSession, }) {
3
+ const message = `${trigger.kind === "issue" ? "New issue" : "New comment"} from @${trigger.author}:\n\n${trigger.body}`;
4
+ if (!firstInSession)
5
+ return message;
6
+ return [
7
+ `You are handling GitHub issue #${issue.number} in ${repo}: ${issue.title}`,
8
+ `Your working directory is a git worktree on branch ${branchFor(issue.number)}.`,
9
+ "If the request needs code changes, make them and commit them. Do not push and do not open pull requests; that happens automatically after you finish.",
10
+ "Your final message is posted as a comment on the issue, so write it for the person who asked.",
11
+ ...(trigger.kind === "comment" ? [`Issue description:\n\n${issue.body ?? ""}`] : []),
12
+ message,
13
+ ].join("\n\n");
14
+ }
package/dist/watch.js ADDED
@@ -0,0 +1,68 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { createDispatcher } from "./dispatcher.js";
5
+ import { handleTrigger } from "./handler.js";
6
+ import { initialPollState, poll } from "./poller.js";
7
+ export function parseRepo(remoteUrl) {
8
+ const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
9
+ if (!match)
10
+ throw new Error(`${remoteUrl} is not a GitHub remote`);
11
+ return `${match[1]}/${match[2]}`;
12
+ }
13
+ async function loadState(stateFile) {
14
+ try {
15
+ return JSON.parse(await readFile(stateFile, "utf8"));
16
+ }
17
+ catch (error) {
18
+ if (error.code === "ENOENT")
19
+ return initialPollState(new Date());
20
+ throw error;
21
+ }
22
+ }
23
+ async function saveState(stateFile, state) {
24
+ await mkdir(dirname(stateFile), { recursive: true });
25
+ const temporaryFile = `${stateFile}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
26
+ await writeFile(temporaryFile, `${JSON.stringify(state, null, 2)}\n`);
27
+ await rename(temporaryFile, stateFile);
28
+ }
29
+ export async function watch({ github, agent, repoRoot, stateFile, intervalMs, concurrency, signal, log, }) {
30
+ let { pending = [], ...pollState } = await loadState(stateFile);
31
+ let saving = Promise.resolve();
32
+ const save = () => (saving = saving.then(() => saveState(stateFile, { ...pollState, pending })));
33
+ const dispatcher = createDispatcher({
34
+ concurrency,
35
+ handle: async (trigger) => {
36
+ try {
37
+ await handleTrigger(trigger, { github, agent, repoRoot });
38
+ log(`#${trigger.issueNumber}: answered @${trigger.author}`);
39
+ }
40
+ finally {
41
+ pending = pending.filter((candidate) => candidate.key !== trigger.key);
42
+ await save();
43
+ }
44
+ },
45
+ onError: (trigger, error) => log(`#${trigger.issueNumber}: failed: ${error instanceof Error ? error.message : error}`),
46
+ });
47
+ for (const trigger of pending) {
48
+ log(`#${trigger.issueNumber}: resuming ${trigger.kind} from @${trigger.author}`);
49
+ dispatcher.dispatch(trigger);
50
+ }
51
+ while (!signal.aborted) {
52
+ try {
53
+ const result = await poll(github, pollState);
54
+ pollState = result.state;
55
+ pending = [...pending, ...result.triggers];
56
+ await save();
57
+ for (const trigger of result.triggers) {
58
+ log(`#${trigger.issueNumber}: new ${trigger.kind} from @${trigger.author}`);
59
+ dispatcher.dispatch(trigger);
60
+ }
61
+ }
62
+ catch (error) {
63
+ log(`poll failed: ${error instanceof Error ? error.message : error}`);
64
+ }
65
+ await sleep(intervalMs, undefined, { signal }).catch(() => { });
66
+ }
67
+ await dispatcher.idle();
68
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@ravshansbox/pi-github",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Watch GitHub issues and answer them with pi agents",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "pi-github": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "engines": {
14
+ "node": ">=24"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "node --test 'test/**/*.test.ts'",
20
+ "prepare": "tsc"
21
+ },
22
+ "dependencies": {
23
+ "@earendil-works/pi-coding-agent": "^1.4.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^26.6.6",
27
+ "semantic-release": "^25.0.9",
28
+ "typescript": "^7.0.2"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/ravshansbox/pi-github.git"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }