Commit 8d665451e1bb
Changed files (6)
dots/pi/agent/extensions/github/actions/checks.ts
@@ -4,7 +4,7 @@
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { GhDetails } from "../types";
-import { parseChecks, parseRunList, getErrorMessage, getCheckIcon, getRunStatusIcon } from "../utils";
+import { parseChecks, parseRunList, getErrorMessage, getCheckIcon, getRunStatusIcon, execGh } from "../utils";
/**
* Show check status for a PR
@@ -26,8 +26,7 @@ export async function handleChecks(
onUpdate?.({ content: [{ type: "text", text: `Fetching checks for PR #${params.number}...` }] });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
["pr", "view", String(params.number), "--json", "statusCheckRollup"],
{ signal, timeout: 30000 },
);
@@ -95,7 +94,7 @@ export async function handleChecksLog(
onUpdate?.({ content: [{ type: "text", text: `Fetching logs for run ${params.runId}...` }] });
- const result = await pi.exec("gh", ["run", "view", String(params.runId), "--log-failed"], {
+ const result = await execGh(pi, ctx, ["run", "view", String(params.runId), "--log-failed"], {
signal,
timeout: 60000,
});
@@ -166,7 +165,7 @@ export async function handleChecksRestart(
onUpdate?.({ content: [{ type: "text", text: `Restarting run ${params.runId}...` }] });
- const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
if (result.code !== 0) {
return {
@@ -207,7 +206,7 @@ export async function handleRunList(
onUpdate?.({ content: [{ type: "text", text: "Fetching workflow runs..." }] });
- const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
if (result.code !== 0) {
return {
@@ -262,8 +261,7 @@ export async function handleRunView(
onUpdate?.({ content: [{ type: "text", text: `Fetching run ${params.runId}...` }] });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
[
"run",
"view",
dots/pi/agent/extensions/github/actions/issue.ts
@@ -12,6 +12,7 @@ import {
buildIssueCreateConfirmation,
buildCommentConfirmation,
truncate,
+ execGh,
} from "../utils";
const ISSUE_LIST_FIELDS = "number,title,state,author,url,labels,assignees,createdAt,updatedAt,body,comments";
@@ -40,7 +41,7 @@ export async function handleIssueList(
onUpdate?.({ content: [{ type: "text", text: "Fetching issues..." }] });
- const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
if (result.code !== 0) {
return {
@@ -93,8 +94,7 @@ export async function handleIssueView(
onUpdate?.({ content: [{ type: "text", text: `Fetching issue #${params.number}...` }] });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
["issue", "view", String(params.number), "--json", `${ISSUE_LIST_FIELDS},milestone`],
{ signal, timeout: 20000 },
);
@@ -142,8 +142,7 @@ export async function handleIssueView(
if (commentsCount > 0) {
output += `\n## Comments (${commentsCount})\n`;
// Fetch comments separately via gh issue view --comments
- const commentsResult = await pi.exec(
- "gh",
+ const commentsResult = await execGh(pi, ctx,
["issue", "view", String(params.number), "--comments", "--json", "comments"],
{ signal, timeout: 20000 },
);
@@ -215,7 +214,7 @@ export async function handleIssueCreate(
onUpdate?.({ content: [{ type: "text", text: "Creating issue..." }] });
- const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
if (result.code !== 0) {
return {
@@ -276,7 +275,7 @@ export async function handleIssueClose(
const args = ["issue", "close", String(params.number)];
if (params.reason) args.push("--reason", params.reason);
- const result = await pi.exec("gh", args, { signal, timeout: 20000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 20000 });
if (result.code !== 0) {
return {
@@ -333,8 +332,7 @@ export async function handleIssueComment(
onUpdate?.({ content: [{ type: "text", text: `Adding comment to issue #${params.number}...` }] });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
["issue", "comment", String(params.number), "--body", params.body],
{ signal, timeout: 20000 },
);
@@ -422,7 +420,7 @@ export async function handleIssueEdit(
onUpdate?.({ content: [{ type: "text", text: `Editing issue #${params.number}...` }] });
- const result = await pi.exec("gh", args, { signal, timeout: 20000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 20000 });
if (result.code !== 0) {
return {
dots/pi/agent/extensions/github/actions/pr.ts
@@ -24,6 +24,7 @@ import {
truncate,
getReviewDecisionText,
formatRelativeDate,
+ execGh,
} from "../utils";
const PR_LIST_FIELDS = "number,title,state,author,headRefName,baseRefName,url,isDraft,labels,reviewDecision,additions,deletions,changedFiles,createdAt,updatedAt";
@@ -52,7 +53,7 @@ export async function handlePRList(
onUpdate?.({ content: [{ type: "text", text: "Fetching PRs..." }] });
- const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
if (result.code !== 0) {
return {
@@ -107,7 +108,7 @@ export async function handlePRView(
onUpdate?.({ content: [{ type: "text", text: `Fetching PR #${params.number}...` }] });
const fields = `${PR_LIST_FIELDS},body,mergeStateStatus,statusCheckRollup,reviews,comments`;
- const result = await pi.exec("gh", ["pr", "view", String(params.number), "--json", fields], {
+ const result = await execGh(pi, ctx, ["pr", "view", String(params.number), "--json", fields], {
signal,
timeout: 30000,
});
@@ -200,7 +201,7 @@ export async function handlePRDiff(
onUpdate?.({ content: [{ type: "text", text: `Fetching diff for PR #${params.number}...` }] });
- const result = await pi.exec("gh", ["pr", "diff", String(params.number)], { signal, timeout: 30000 });
+ const result = await execGh(pi, ctx, ["pr", "diff", String(params.number)], { signal, timeout: 30000 });
if (result.code !== 0) {
return {
@@ -266,7 +267,7 @@ export async function handlePRCreate(
onUpdate?.({ content: [{ type: "text", text: "Creating PR..." }] });
- const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
if (result.code !== 0) {
return {
@@ -305,7 +306,7 @@ export async function handlePRCheckout(
onUpdate?.({ content: [{ type: "text", text: `Checking out PR #${params.number}...` }] });
- const result = await pi.exec("gh", ["pr", "checkout", String(params.number)], { signal, timeout: 30000 });
+ const result = await execGh(pi, ctx, ["pr", "checkout", String(params.number)], { signal, timeout: 30000 });
if (result.code !== 0) {
return {
@@ -363,7 +364,7 @@ export async function handlePRMerge(
onUpdate?.({ content: [{ type: "text", text: `Merging PR #${params.number}...` }] });
- const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
if (result.code !== 0) {
return {
@@ -436,7 +437,7 @@ export async function handlePRReview(
onUpdate?.({ content: [{ type: "text", text: `Submitting review on PR #${params.number}...` }] });
- const result = await pi.exec("gh", args, { signal, timeout: 30000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
if (result.code !== 0) {
return {
@@ -498,7 +499,7 @@ export async function handlePRComment(
onUpdate?.({ content: [{ type: "text", text: `Adding comment to PR #${params.number}...` }] });
- const result = await pi.exec("gh", ["pr", "comment", String(params.number), "--body", params.body], {
+ const result = await execGh(pi, ctx, ["pr", "comment", String(params.number), "--body", params.body], {
signal,
timeout: 20000,
});
@@ -556,7 +557,7 @@ export async function handlePRReady(
onUpdate?.({ content: [{ type: "text", text: `Marking PR #${params.number} as ready...` }] });
- const result = await pi.exec("gh", ["pr", "ready", String(params.number)], { signal, timeout: 20000 });
+ const result = await execGh(pi, ctx, ["pr", "ready", String(params.number)], { signal, timeout: 20000 });
if (result.code !== 0) {
return {
@@ -609,7 +610,7 @@ export async function handlePRClose(
}
}
- const result = await pi.exec("gh", ["pr", "close", String(params.number)], { signal, timeout: 20000 });
+ const result = await execGh(pi, ctx, ["pr", "close", String(params.number)], { signal, timeout: 20000 });
if (result.code !== 0) {
return {
@@ -633,8 +634,7 @@ async function getPRTitle(
prNumber: number,
signal?: AbortSignal,
): Promise<string | null> {
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
["pr", "view", String(prNumber), "--json", "title", "--jq", ".title"],
{ signal, timeout: 15000 },
);
@@ -650,8 +650,7 @@ async function getPRHeadSha(
prNumber: number,
signal?: AbortSignal,
): Promise<string | null> {
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
["pr", "view", String(prNumber), "--json", "headRefOid", "--jq", ".headRefOid"],
{ signal, timeout: 15000 },
);
@@ -744,8 +743,7 @@ export async function handlePRLineComment(
const tmpFile = `/tmp/gh-line-comment-${Date.now()}.json`;
await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${JSON.stringify(payload)}\nGHEOF`], { signal });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
[
"api",
"repos/{owner}/{repo}/pulls/" + params.number + "/comments",
@@ -877,8 +875,7 @@ export async function handlePRReviewWithComments(
const tmpFile = `/tmp/gh-review-${Date.now()}.json`;
await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${JSON.stringify(payload)}\nGHEOF`], { signal });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
[
"api",
"repos/{owner}/{repo}/pulls/" + params.number + "/reviews",
@@ -943,8 +940,7 @@ export async function handlePRReviewsList(
onUpdate?.({ content: [{ type: "text", text: `Fetching reviews for PR #${params.number}...` }] });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
["api", `repos/{owner}/{repo}/pulls/${params.number}/reviews`, "--paginate"],
{ signal, timeout: 15000 },
);
@@ -1031,8 +1027,7 @@ export async function handlePRReviewEdit(
const tmpFile = `/tmp/gh-review-edit-${Date.now()}.json`;
await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${payload}\nGHEOF`], { signal });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
[
"api",
`repos/{owner}/{repo}/pulls/${params.number}/reviews/${params.reviewId}`,
@@ -1089,8 +1084,7 @@ export async function handlePRReviewCommentsList(
onUpdate?.({ content: [{ type: "text", text: `Fetching review comments for PR #${params.number}...` }] });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
["api", `repos/{owner}/{repo}/pulls/${params.number}/comments`, "--paginate"],
{ signal, timeout: 15000 },
);
@@ -1171,8 +1165,7 @@ export async function handlePRReviewCommentEdit(
const tmpFile = `/tmp/gh-comment-edit-${Date.now()}.json`;
await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${payload}\nGHEOF`], { signal });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
[
"api",
`repos/{owner}/{repo}/pulls/comments/${params.commentId}`,
@@ -1238,8 +1231,7 @@ export async function handlePRReviewCommentDelete(
onUpdate?.({ content: [{ type: "text", text: `Deleting comment ${params.commentId}...` }] });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
[
"api",
`repos/{owner}/{repo}/pulls/comments/${params.commentId}`,
dots/pi/agent/extensions/github/actions/repo.ts
@@ -4,7 +4,7 @@
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { GhDetails } from "../types";
-import { parseRepo, parseReleaseList, getErrorMessage, formatRelativeDate } from "../utils";
+import { parseRepo, parseReleaseList, getErrorMessage, formatRelativeDate, execGh } from "../utils";
/**
* View repository info
@@ -18,8 +18,7 @@ export async function handleRepoView(
): Promise<any> {
onUpdate?.({ content: [{ type: "text", text: "Fetching repo info..." }] });
- const result = await pi.exec(
- "gh",
+ const result = await execGh(pi, ctx,
[
"repo",
"view",
@@ -82,7 +81,7 @@ export async function handleReleaseList(
onUpdate?.({ content: [{ type: "text", text: "Fetching releases..." }] });
- const result = await pi.exec("gh", args, { signal, timeout: 20000 });
+ const result = await execGh(pi, ctx, args, { signal, timeout: 20000 });
if (result.code !== 0) {
return {
dots/pi/agent/extensions/github/index.ts
@@ -65,6 +65,8 @@ import {
getRunStatusIcon,
getReviewDecisionText,
formatRelativeDate,
+ resetGitRoot,
+ execGh,
} from "./utils";
export default function (pi: ExtensionAPI) {
@@ -80,6 +82,7 @@ export default function (pi: ExtensionAPI) {
currentUser = "";
recentPRs = [];
recentIssues = [];
+ resetGitRoot(); // Reset git root detection on session change
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
@@ -125,9 +128,9 @@ export default function (pi: ExtensionAPI) {
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
// Helper: fetch current user lazily
- async function ensureCurrentUser(signal?: AbortSignal): Promise<string> {
+ async function ensureCurrentUser(ctx: ExtensionContext, signal?: AbortSignal): Promise<string> {
if (currentUser) return currentUser;
- const result = await pi.exec("gh", ["api", "user", "--jq", ".login"], { signal, timeout: 10000 });
+ const result = await execGh(pi, ctx, ["api", "user", "--jq", ".login"], { signal, timeout: 10000 });
if (result.code === 0) {
currentUser = result.stdout.trim();
}
@@ -513,9 +516,10 @@ export default function (pi: ExtensionAPI) {
return;
}
- const user = await ensureCurrentUser();
- const result = await pi.exec(
- "gh",
+ const user = await ensureCurrentUser(ctx);
+ const result = await execGh(
+ pi,
+ ctx,
[
"pr",
"list",
@@ -578,8 +582,9 @@ export default function (pi: ExtensionAPI) {
return;
}
- const result = await pi.exec(
- "gh",
+ const result = await execGh(
+ pi,
+ ctx,
[
"pr",
"list",
@@ -658,8 +663,9 @@ export default function (pi: ExtensionAPI) {
return;
}
- const result = await pi.exec(
- "gh",
+ const result = await execGh(
+ pi,
+ ctx,
["pr", "view", String(number), "--json", "number,title,state,author,headRefName,baseRefName,isDraft,url,reviewDecision,additions,deletions,changedFiles,body,statusCheckRollup"],
{ timeout: 30000 },
);
@@ -740,8 +746,9 @@ export default function (pi: ExtensionAPI) {
return;
}
- const result = await pi.exec(
- "gh",
+ const result = await execGh(
+ pi,
+ ctx,
["pr", "view", String(number), "--json", "statusCheckRollup"],
{ timeout: 30000 },
);
@@ -791,8 +798,9 @@ export default function (pi: ExtensionAPI) {
return;
}
- const result = await pi.exec(
- "gh",
+ const result = await execGh(
+ pi,
+ ctx,
["issue", "list", "--state", "open", "--json", "number,title,state,labels,assignees,url", "--limit", "20"],
{ timeout: 30000 },
);
@@ -845,8 +853,9 @@ export default function (pi: ExtensionAPI) {
return;
}
- const result = await pi.exec(
- "gh",
+ const result = await execGh(
+ pi,
+ ctx,
["run", "list", "--json", "databaseId,name,displayTitle,status,conclusion,headBranch,url,createdAt", "--limit", "15"],
{ timeout: 30000 },
);
dots/pi/agent/extensions/github/utils.ts
@@ -2,9 +2,56 @@
* Utility functions for GitHub extension
*/
-import type { Theme } from "@mariozechner/pi-coding-agent";
+import type { Theme, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhReviewSummary, GhRelease, GhRepo } from "./types";
+// ============================================================================
+// Git Repository Detection (shared with handlers)
+// ============================================================================
+
+let cachedGitRoot: string | null = null;
+
+/**
+ * Find the git repository root directory.
+ * Returns null if not in a git repository.
+ */
+async function findGitRoot(pi: ExtensionAPI, cwd: string): Promise<string | null> {
+ if (cachedGitRoot !== null) return cachedGitRoot;
+
+ try {
+ const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 5000 });
+ if (result.code === 0 && result.stdout.trim()) {
+ cachedGitRoot = result.stdout.trim();
+ return cachedGitRoot;
+ }
+ } catch {
+ // Ignore errors
+ }
+ return null;
+}
+
+/**
+ * Reset cached git root (call on session change)
+ */
+export function resetGitRoot() {
+ cachedGitRoot = null;
+}
+
+/**
+ * Execute gh command with correct working directory.
+ * Automatically uses git repository root if available.
+ */
+export async function execGh(
+ pi: ExtensionAPI,
+ ctx: ExtensionContext,
+ args: string[],
+ options?: { signal?: AbortSignal; timeout?: number }
+) {
+ const root = await findGitRoot(pi, ctx.cwd);
+ const cwd = root ?? ctx.cwd;
+ return await pi.exec("gh", args, { ...options, cwd });
+}
+
// ============================================================================
// Parsing: gh CLI JSON output → typed objects
// ============================================================================