fix: show security advisories in prompt widget
This commit is contained in:
@@ -1,43 +1,154 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
import { Container, Text } from "@earendil-works/pi-tui";
|
import { Container, Text } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
||||||
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
||||||
|
const ADVISORY_PROMPT_PATTERN = /^\s*Update a GitHub security advisory for publication:\s*(\S+)/im;
|
||||||
|
|
||||||
type PromptMatch = {
|
type PromptMatch = {
|
||||||
kind: "pr" | "issue";
|
kind: "pr" | "issue" | "advisory";
|
||||||
url: string;
|
target: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GhMetadata = {
|
type GhMetadata = {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
detail?: string;
|
||||||
|
displayUrl?: string;
|
||||||
author?: {
|
author?: {
|
||||||
login?: string;
|
login?: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GitHubAdvisoryMetadata = {
|
||||||
|
ghsa_id?: string;
|
||||||
|
summary?: string;
|
||||||
|
severity?: string;
|
||||||
|
state?: string;
|
||||||
|
html_url?: string;
|
||||||
|
cve_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdvisoryRef = {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
ghsaId: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
||||||
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
||||||
if (prMatch?.[1]) {
|
if (prMatch?.[1]) {
|
||||||
return { kind: "pr", url: prMatch[1].trim() };
|
return { kind: "pr", target: prMatch[1].trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
||||||
if (issueMatch?.[1]) {
|
if (issueMatch?.[1]) {
|
||||||
return { kind: "issue", url: issueMatch[1].trim() };
|
return { kind: "issue", target: issueMatch[1].trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const advisoryMatch = prompt.match(ADVISORY_PROMPT_PATTERN);
|
||||||
|
if (advisoryMatch?.[1]) {
|
||||||
|
return { kind: "advisory", target: advisoryMatch[1].trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPromptLabel(kind: PromptMatch["kind"]): string {
|
||||||
|
if (kind === "pr") return "PR";
|
||||||
|
if (kind === "issue") return "Issue";
|
||||||
|
return "Advisory";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAdvisoryUrl(value: string): AdvisoryRef | undefined {
|
||||||
|
const match = value.match(
|
||||||
|
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/security\/advisories\/(GHSA-[A-Za-z0-9-]+)(?:[/?#].*)?$/i,
|
||||||
|
);
|
||||||
|
if (!match?.[1] || !match[2] || !match[3]) return undefined;
|
||||||
|
return {
|
||||||
|
owner: match[1],
|
||||||
|
repo: match[2],
|
||||||
|
ghsaId: match[3],
|
||||||
|
url: `https://github.com/${match[1]}/${match[2]}/security/advisories/${match[3]}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unquoteYamlValue(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (
|
||||||
|
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||||
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||||
|
) {
|
||||||
|
return trimmed.slice(1, -1);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDraftPath(cwd: string, target: string): string {
|
||||||
|
if (target === "~") return homedir();
|
||||||
|
if (target.startsWith("~/")) return resolve(homedir(), target.slice(2));
|
||||||
|
return resolve(cwd, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAdvisoryRefFromDraft(cwd: string, target: string): Promise<AdvisoryRef | undefined> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(resolveDraftPath(cwd, target), "utf8");
|
||||||
|
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||||
|
const body = frontmatter?.[1] ?? content;
|
||||||
|
const urlMatch = body.match(/^advisory_url:\s*(.+)$/m);
|
||||||
|
if (!urlMatch?.[1]) return undefined;
|
||||||
|
return parseAdvisoryUrl(unquoteYamlValue(urlMatch[1]));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAdvisoryDetail(advisory: GitHubAdvisoryMetadata): string | undefined {
|
||||||
|
const parts = [advisory.ghsa_id, advisory.cve_id ?? undefined, advisory.severity, advisory.state]
|
||||||
|
.map((part) => part?.trim())
|
||||||
|
.filter((part): part is string => part !== undefined && part.length > 0);
|
||||||
|
return parts.length > 0 ? parts.join(" · ") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAdvisoryMetadata(pi: ExtensionAPI, cwd: string, target: string): Promise<GhMetadata | undefined> {
|
||||||
|
const advisoryRef = parseAdvisoryUrl(target) ?? (await readAdvisoryRefFromDraft(cwd, target));
|
||||||
|
if (!advisoryRef) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pi.exec("gh", [
|
||||||
|
"api",
|
||||||
|
`repos/${advisoryRef.owner}/${advisoryRef.repo}/security-advisories/${advisoryRef.ghsaId}`,
|
||||||
|
]);
|
||||||
|
if (result.code !== 0 || !result.stdout) return { displayUrl: advisoryRef.url };
|
||||||
|
const advisory = JSON.parse(result.stdout) as GitHubAdvisoryMetadata;
|
||||||
|
return {
|
||||||
|
title: advisory.summary,
|
||||||
|
detail: formatAdvisoryDetail(advisory),
|
||||||
|
displayUrl: advisory.html_url ?? advisoryRef.url,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { displayUrl: advisoryRef.url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchGhMetadata(
|
async function fetchGhMetadata(
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
kind: PromptMatch["kind"],
|
kind: PromptMatch["kind"],
|
||||||
url: string,
|
target: string,
|
||||||
|
cwd: string,
|
||||||
): Promise<GhMetadata | undefined> {
|
): Promise<GhMetadata | undefined> {
|
||||||
|
if (kind === "advisory") {
|
||||||
|
return fetchAdvisoryMetadata(pi, cwd, target);
|
||||||
|
}
|
||||||
|
|
||||||
const args =
|
const args =
|
||||||
kind === "pr" ? ["pr", "view", url, "--json", "title,author"] : ["issue", "view", url, "--json", "title,author"];
|
kind === "pr"
|
||||||
|
? ["pr", "view", target, "--json", "title,author"]
|
||||||
|
: ["issue", "view", target, "--json", "title,author"];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pi.exec("gh", args);
|
const result = await pi.exec("gh", args);
|
||||||
@@ -59,14 +170,18 @@ function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||||
const setWidget = (ctx: ExtensionContext, match: PromptMatch, title?: string, authorText?: string) => {
|
const setWidget = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => {
|
||||||
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
||||||
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
|
const displayTarget = metadata?.displayUrl ?? match.target;
|
||||||
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
|
const titleText = metadata?.title
|
||||||
const urlLine = thm.fg("dim", match.url);
|
? thm.fg("accent", metadata.title)
|
||||||
|
: thm.fg("accent", displayTarget);
|
||||||
|
const detailText = metadata?.detail ?? formatAuthor(metadata?.author);
|
||||||
|
const detailLine = detailText ? thm.fg("muted", detailText) : undefined;
|
||||||
|
const urlLine = thm.fg("dim", displayTarget);
|
||||||
|
|
||||||
const lines = [titleText];
|
const lines = [titleText];
|
||||||
if (authorLine) lines.push(authorLine);
|
if (detailLine) lines.push(detailLine);
|
||||||
lines.push(urlLine);
|
lines.push(urlLine);
|
||||||
|
|
||||||
const container = new Container();
|
const container = new Container();
|
||||||
@@ -76,21 +191,32 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
|
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, metadata?: GhMetadata) => {
|
||||||
const label = match.kind === "pr" ? "PR" : "Issue";
|
const label = getPromptLabel(match.kind);
|
||||||
const trimmedTitle = title?.trim();
|
const displayTarget = metadata?.displayUrl ?? match.target;
|
||||||
const fallbackName = `${label}: ${match.url}`;
|
const trimmedTitle = metadata?.title?.trim();
|
||||||
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
const fallbackName = `${label}: ${match.target}`;
|
||||||
|
const desiredFallbackName = `${label}: ${displayTarget}`;
|
||||||
|
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${displayTarget})` : desiredFallbackName;
|
||||||
const currentName = pi.getSessionName()?.trim();
|
const currentName = pi.getSessionName()?.trim();
|
||||||
if (!currentName) {
|
if (!currentName) {
|
||||||
pi.setSessionName(desiredName);
|
pi.setSessionName(desiredName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentName === match.url || currentName === fallbackName) {
|
if (currentName === match.target || currentName === fallbackName || currentName === desiredFallbackName) {
|
||||||
pi.setSessionName(desiredName);
|
pi.setSessionName(desiredName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updatePromptContext = (ctx: ExtensionContext, match: PromptMatch) => {
|
||||||
|
setWidget(ctx, match);
|
||||||
|
applySessionName(ctx, match);
|
||||||
|
void fetchGhMetadata(pi, match.kind, match.target, ctx.cwd).then((meta) => {
|
||||||
|
setWidget(ctx, match, meta);
|
||||||
|
applySessionName(ctx, match, meta);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
pi.on("before_agent_start", async (event, ctx) => {
|
pi.on("before_agent_start", async (event, ctx) => {
|
||||||
if (!ctx.hasUI) return;
|
if (!ctx.hasUI) return;
|
||||||
const match = extractPromptMatch(event.prompt);
|
const match = extractPromptMatch(event.prompt);
|
||||||
@@ -98,14 +224,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setWidget(ctx, match);
|
updatePromptContext(ctx, match);
|
||||||
applySessionName(ctx, match);
|
|
||||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
|
||||||
const title = meta?.title?.trim();
|
|
||||||
const authorText = formatAuthor(meta?.author);
|
|
||||||
setWidget(ctx, match, title, authorText);
|
|
||||||
applySessionName(ctx, match, title);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("session_switch", async (_event, ctx) => {
|
pi.on("session_switch", async (_event, ctx) => {
|
||||||
@@ -142,14 +261,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setWidget(ctx, match);
|
updatePromptContext(ctx, match);
|
||||||
applySessionName(ctx, match);
|
|
||||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
|
||||||
const title = meta?.title?.trim();
|
|
||||||
const authorText = formatAuthor(meta?.author);
|
|
||||||
setWidget(ctx, match, title, authorText);
|
|
||||||
applySessionName(ctx, match, title);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user