diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 3a4502efcd..44f21916f7 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1640,7 +1640,7 @@ "name": "where-was-i", "source": "plugins/where-was-i", "description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.", - "version": "1.0.2" + "version": "1.1.0" }, { "name": "winappcli", diff --git a/extensions/where-was-i/extension.mjs b/extensions/where-was-i/extension.mjs index c376fa9b43..0e843a2c5b 100644 --- a/extensions/where-was-i/extension.mjs +++ b/extensions/where-was-i/extension.mjs @@ -3,28 +3,16 @@ import { createServer } from "node:http"; import { execFile } from "node:child_process"; -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import { gatherGitContext, getFileDiff } from "./git-context.mjs"; const servers = new Map(); const sseClients = new Map(); // instanceId → Set const contextCache = new Map(); // instanceId → contextData -const isWindows = process.platform === "win32"; - -// Fallback repo root derived from extension location. Only used when the -// session's real working directory is unavailable (see captureCwd below). -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const REPO_ROOT = join(__dirname, "..", "..", ".."); - -// The canvas request context reports the active session's working directory — -// the actual repo checkout or worktree the user opened the canvas in. This is -// what git commands must run against; REPO_ROOT (the extension's install dir) -// and session.workspacePath (the session-state folder) are NOT the repo, which -// is why the board previously showed an empty branch as "detached HEAD". let workspaceCwd = null; function captureCwd(ctx) { @@ -32,52 +20,61 @@ function captureCwd(ctx) { if (typeof dir === "string" && dir.trim()) workspaceCwd = dir; } -function repoCwd() { - return workspaceCwd || REPO_ROOT; +async function activeCwd(ctx) { + captureCwd(ctx); + if (!workspaceCwd && sessionRef) { + const snapshot = await sessionRef.rpc.metadata.snapshot(); + const dir = snapshot?.workingDirectory; + if (typeof dir === "string" && dir.trim()) workspaceCwd = dir; + } + if (!workspaceCwd) { + throw new CanvasError( + "workspace_unavailable", + "No repository working directory is attached to this session.", + ); + } + return workspaceCwd; } -// --- Shell helpers --- - -function run(cmd, cwd) { - const shell = isWindows ? "powershell" : "bash"; - const args = isWindows - ? ["-NoProfile", "-NoLogo", "-Command", cmd] - : ["-c", cmd]; - return new Promise((resolve) => { - execFile(shell, args, { cwd, timeout: 15000, maxBuffer: 1024 * 256 }, (err, stdout) => { - resolve(err ? "" : (stdout || "").trim()); +function runGhJson(cwd, args) { + return new Promise((resolve, reject) => { + execFile("gh", args, { cwd, timeout: 15000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { + reject(new Error((stderr || error.message || "GitHub CLI command failed").trim())); + return; + } + try { + resolve({ + data: JSON.parse(stdout || "[]"), + warning: (stderr || "").trim(), + }); + } catch (parseError) { + reject(new Error(`GitHub CLI returned invalid JSON: ${parseError.message}`)); + } }); }); } async function gatherContext(cwd) { - cwd = cwd || repoCwd(); - const authorCmd = isWindows - ? 'git log --oneline -5 --format="%h %s" --author="$(git config user.name)"' - : 'git log --oneline -5 --format="%h %s" --author="$(git config user.name)"'; - const suppressErr = isWindows ? "2>$null" : "2>/dev/null"; - - const [branch, log, status, diff, prs, issues] = await Promise.all([ - run("git branch --show-current", cwd), - run(authorCmd, cwd), - run("git status --short", cwd), - run("git diff --stat", cwd), - run(`gh pr list --author=@me --state=open --limit=10 --json number,title,url,updatedAt,comments ${suppressErr}`, cwd), - run(`gh issue list --assignee=@me --state=open --limit=10 --json number,title,url,updatedAt ${suppressErr}`, cwd), + const gitContext = await gatherGitContext(cwd); + const [prs, issues] = await Promise.allSettled([ + runGhJson(cwd, [ + "pr", "list", "--author=@me", "--state=open", "--limit=10", + "--json", "number,title,url,updatedAt,comments", + ]), + runGhJson(cwd, [ + "issue", "list", "--assignee=@me", "--state=open", "--limit=10", + "--json", "number,title,url,updatedAt", + ]), ]); - let parsedPrs = []; - let parsedIssues = []; - try { parsedPrs = JSON.parse(prs || "[]"); } catch {} - try { parsedIssues = JSON.parse(issues || "[]"); } catch {} - return { - branch, - recentCommits: log.split("\n").filter(Boolean), - uncommitted: status.split("\n").filter(Boolean), - diffStat: diff, - openPrs: parsedPrs, - assignedIssues: parsedIssues, + ...gitContext, + openPrs: prs.status === "fulfilled" ? prs.value.data : [], + assignedIssues: issues.status === "fulfilled" ? issues.value.data : [], + warnings: [prs, issues] + .map((result) => result.status === "fulfilled" ? result.value.warning : result.reason.message) + .filter(Boolean), gatheredAt: new Date().toISOString(), }; } @@ -87,18 +84,10 @@ async function gatherContext(cwd) { async function saveContext(workspacePath, data) { if (!workspacePath) return; const dir = join(workspacePath, "files"); - try { await mkdir(dir, { recursive: true }); } catch {} + await mkdir(dir, { recursive: true }); await writeFile(join(dir, "where-was-i-context.json"), JSON.stringify(data, null, 2)); } -async function loadContext(workspacePath) { - if (!workspacePath) return null; - try { - const raw = await readFile(join(workspacePath, "files", "where-was-i-context.json"), "utf-8"); - return JSON.parse(raw); - } catch { return null; } -} - // --- SSE --- function broadcast(instanceId, data) { @@ -106,20 +95,22 @@ function broadcast(instanceId, data) { if (!clients) return; const payload = `data: ${JSON.stringify(data)}\n\n`; for (const res of clients) { - try { res.write(payload); } catch {} + try { + res.write(payload); + } catch { + clients.delete(res); + } } } // --- HTML renderer --- -function renderHtml(instanceId) { +function renderHtml(instanceId, scriptNonce) { return ` Where Was I? - -