Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,30 @@ app/
├── page.tsx
├── dashboard/
│ ├── page.tsx
│ └── components/
│ └── MetricsChart/
│ ├── MetricsChart.tsx
│ ├── MetricsChart.test.tsx # Tests co-located
│ ├── index.ts
│ ├── useMetricsData.ts # Hook used only here
│ └── constants.ts
│ ├── components/
│ │ └── MetricsChart/
│ │ ├── MetricsChart.tsx
│ │ ├── MetricsChart.test.tsx # Tests co-located
│ │ ├── index.ts
│ │ └── constants.ts
│ ├── hooks/ # Hooks used only in dashboard
│ │ └── useMetrics/
│ │ ├── useMetrics.ts
│ │ ├── useMetrics.test.ts
│ │ └── index.ts
│ ├── utils/ # Utils used only in dashboard
│ │ └── formatData/
│ │ ├── formatData.ts
│ │ ├── formatData.test.ts
│ │ └── index.ts
│ ├── stores/ # Stores used only in dashboard
│ │ └── dashboardStore/
│ │ ├── dashboardStore.ts
│ │ └── index.ts
│ └── providers/ # Providers for dashboard context
│ └── DashboardProvider/
│ ├── DashboardProvider.tsx
│ └── index.ts
└── components/
├── Sidebar/
│ ├── Sidebar.tsx
Expand Down Expand Up @@ -121,6 +138,10 @@ components/ # Used in 2+ pages (last resort)
3. **One component per file**: No multi-component files
4. **Co-locate dependencies**: Utils, hooks, constants, config, tests, stories live next to the file using them

### Exception: shadcn/ui Components

The `src/components/ui/`, `src/components/ai-elements`, and `src/components/react-flow/` directories contain shadcn/ui components. These use **kebab-case single files** (e.g., `button.tsx`, `base-node.tsx`) instead of the folder structure above. This is intentional—shadcn CLI expects this format for updates via `bunx shadcn@latest add`.

## Database Rules

- Schema in `packages/db/src/`
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dnd-core": "^16.0.1",
"dotenv": "^17.2.3",
"electron-router-dom": "^2.1.0",
Expand Down
219 changes: 219 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { CheckItem, GitHubStatus } from "main/lib/db/schemas";
import {
type GHPRResponse,
GHPRResponseSchema,
GHRepoResponseSchema,
} from "./types";

const execFileAsync = promisify(execFile);

// Cache for GitHub status (10 second TTL)
const cache = new Map<string, { data: GitHubStatus; timestamp: number }>();
const CACHE_TTL_MS = 10_000;

/**
* Fetches GitHub PR status for a worktree using the `gh` CLI.
* Returns null if `gh` is not installed, not authenticated, or on error.
* Results are cached for 30 seconds.
*/
export async function fetchGitHubPRStatus(
worktreePath: string,
): Promise<GitHubStatus | null> {
// Check cache first
const cached = cache.get(worktreePath);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.data;
}

try {
// First, get the repo URL
const repoUrl = await getRepoUrl(worktreePath);
if (!repoUrl) {
return null;
}

// Try to get PR info for current branch
const prInfo = await getPRForCurrentBranch(worktreePath);

const result: GitHubStatus = {
pr: prInfo,
repoUrl,
lastRefreshed: Date.now(),
};

// Cache the result
cache.set(worktreePath, { data: result, timestamp: Date.now() });

return result;
} catch {
// Any error (gh not installed, not auth'd, etc.) - return null
return null;
}
}

async function getRepoUrl(worktreePath: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync(
"gh",
["repo", "view", "--json", "url"],
{
cwd: worktreePath,
},
);
const raw = JSON.parse(stdout);
const result = GHRepoResponseSchema.safeParse(raw);
if (!result.success) {
console.error("[GitHub] Repo schema validation failed:", result.error);
console.error("[GitHub] Raw data:", JSON.stringify(raw, null, 2));
return null;
}
return result.data.url;
} catch {
return null;
}
}

async function getPRForCurrentBranch(
worktreePath: string,
): Promise<GitHubStatus["pr"]> {
try {
// Get the current branch name explicitly (worktrees don't work well with gh's auto-detection)
const { stdout: branchName } = await execFileAsync(
"git",
["rev-parse", "--abbrev-ref", "HEAD"],
{ cwd: worktreePath },
);
const branch = branchName.trim();

// Use execFile with args array to prevent command injection
const { stdout } = await execFileAsync(
"gh",
[
"pr",
"view",
branch,
"--json",
"number,title,url,state,isDraft,mergedAt,additions,deletions,reviewDecision,statusCheckRollup",
],
{ cwd: worktreePath },
);
const raw = JSON.parse(stdout);
const result = GHPRResponseSchema.safeParse(raw);
if (!result.success) {
console.error("[GitHub] PR schema validation failed:", result.error);
console.error("[GitHub] Raw data:", JSON.stringify(raw, null, 2));
throw new Error("PR schema validation failed");
}
const data = result.data;

const checks = parseChecks(data.statusCheckRollup);

return {
number: data.number,
title: data.title,
url: data.url,
state: mapPRState(data.state, data.isDraft),
mergedAt: data.mergedAt ? new Date(data.mergedAt).getTime() : undefined,
additions: data.additions,
deletions: data.deletions,
reviewDecision: mapReviewDecision(data.reviewDecision),
checksStatus: computeChecksStatus(data.statusCheckRollup),
checks,
};
} catch (error) {
// "no pull requests found" is not an error - just no PR
if (
error instanceof Error &&
error.message.includes("no pull requests found")
) {
return null;
}
// Re-throw other errors to be caught by parent
throw error;
}
}

function mapPRState(
state: GHPRResponse["state"],
isDraft: boolean,
): NonNullable<GitHubStatus["pr"]>["state"] {
if (state === "MERGED") return "merged";
if (state === "CLOSED") return "closed";
if (isDraft) return "draft";
return "open";
}

function mapReviewDecision(
decision: GHPRResponse["reviewDecision"],
): NonNullable<GitHubStatus["pr"]>["reviewDecision"] {
if (decision === "APPROVED") return "approved";
if (decision === "CHANGES_REQUESTED") return "changes_requested";
return "pending";
}

function parseChecks(rollup: GHPRResponse["statusCheckRollup"]): CheckItem[] {
if (!rollup || rollup.length === 0) {
return [];
}

return rollup.map((ctx) => {
// CheckRun uses 'name', StatusContext uses 'context'
const name = ctx.name || ctx.context || "Unknown check";
// CheckRun uses 'detailsUrl', StatusContext uses 'targetUrl'
const url = ctx.detailsUrl || ctx.targetUrl;
// StatusContext uses 'state', CheckRun uses 'conclusion'
const rawStatus = ctx.state || ctx.conclusion;

let status: CheckItem["status"];
if (rawStatus === "SUCCESS") {
status = "success";
} else if (
rawStatus === "FAILURE" ||
rawStatus === "ERROR" ||
rawStatus === "TIMED_OUT"
) {
status = "failure";
} else if (rawStatus === "SKIPPED" || rawStatus === "NEUTRAL") {
status = "skipped";
} else if (rawStatus === "CANCELLED") {
status = "cancelled";
} else {
status = "pending";
}

return { name, status, url };
});
}

function computeChecksStatus(
rollup: GHPRResponse["statusCheckRollup"],
): NonNullable<GitHubStatus["pr"]>["checksStatus"] {
if (!rollup || rollup.length === 0) {
return "none";
}

let hasFailure = false;
let hasPending = false;

for (const ctx of rollup) {
// StatusContext uses 'state', CheckRun uses 'conclusion'
const status = ctx.state || ctx.conclusion;

if (status === "FAILURE" || status === "ERROR" || status === "TIMED_OUT") {
hasFailure = true;
} else if (
status === "PENDING" ||
status === "" ||
status === null ||
status === undefined
) {
hasPending = true;
}
}

if (hasFailure) return "failure";
if (hasPending) return "pending";
return "success";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { fetchGitHubPRStatus } from "./github";
48 changes: 48 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/github/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { z } from "zod";

// Zod schemas for gh CLI output validation
export const GHCheckContextSchema = z.object({
name: z.string().optional(),
context: z.string().optional(), // StatusContext uses 'context' instead of 'name'
state: z.enum(["SUCCESS", "FAILURE", "PENDING", "ERROR"]).optional(),
status: z.string().optional(), // CheckRun status: COMPLETED, IN_PROGRESS, etc.
conclusion: z
.enum([
"SUCCESS",
"FAILURE",
"CANCELLED",
"SKIPPED",
"TIMED_OUT",
"ACTION_REQUIRED",
"NEUTRAL",
"", // Can be empty string when in progress
])
.optional(),
detailsUrl: z.string().optional(),
targetUrl: z.string().optional(), // StatusContext uses 'targetUrl' instead of 'detailsUrl'
startedAt: z.string().optional(),
completedAt: z.string().optional(),
workflowName: z.string().optional(),
});

export const GHPRResponseSchema = z.object({
number: z.number(),
title: z.string(),
url: z.string(),
state: z.enum(["OPEN", "CLOSED", "MERGED"]),
isDraft: z.boolean(),
mergedAt: z.string().nullable(),
additions: z.number(),
deletions: z.number(),
reviewDecision: z
.enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", ""])
.nullable(),
// statusCheckRollup is an array directly, not { contexts: [...] }
statusCheckRollup: z.array(GHCheckContextSchema).nullable(),
});

export const GHRepoResponseSchema = z.object({
url: z.string(),
});

export type GHPRResponse = z.infer<typeof GHPRResponseSchema>;
Loading