feat(coding-agent): trigger issue analysis from comments
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
# Runs the repo's /is prompt against an issue when the `pi-analyze` label is added.
|
# Runs the repo's /is prompt against an issue when the `pi-analyze` label is added
|
||||||
|
# or when a staff member comments `@issueron analyze` on an issue.
|
||||||
#
|
#
|
||||||
# Setup required before this works:
|
# Setup required before this works:
|
||||||
# 1. Create a `pi-analyze` GitHub environment on the repo and add a
|
# 1. Create a `pi-analyze` GitHub environment on the repo and add a
|
||||||
@@ -14,14 +15,16 @@
|
|||||||
#
|
#
|
||||||
# The session runs in a high-entropy checkout directory so the recorded cwd is
|
# The session runs in a high-entropy checkout directory so the recorded cwd is
|
||||||
# a unique string. Import the session into a local checkout with the
|
# a unique string. Import the session into a local checkout with the
|
||||||
# import-repro extension (.pi/extensions/import-repro.ts):
|
# /ir extension command (.pi/extensions/import-repro.ts):
|
||||||
# pi "/import-repro <gist-id | gist-url | pi.dev/session URL>"
|
# pi "/ir <gist-id | gist-url | pi.dev/session URL>"
|
||||||
|
|
||||||
name: Issue Analysis
|
name: Issue Analysis
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [labeled]
|
types: [labeled]
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -33,24 +36,55 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
authorize:
|
authorize:
|
||||||
if: github.event.label.name == 'pi-analyze'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_run: ${{ steps.verify.outputs.should_run }}
|
||||||
|
extra_instructions: ${{ steps.verify.outputs.extra_instructions }}
|
||||||
steps:
|
steps:
|
||||||
- name: Verify sender permission
|
- name: Verify sender permission
|
||||||
|
id: verify
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
env:
|
env:
|
||||||
ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }}
|
ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }}
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
|
const ANALYZE_LABEL = 'pi-analyze';
|
||||||
const username = context.payload.sender.login;
|
const username = context.payload.sender.login;
|
||||||
|
let extraInstructions = '';
|
||||||
|
|
||||||
|
core.setOutput('should_run', 'false');
|
||||||
|
core.setOutput('extra_instructions', '');
|
||||||
|
|
||||||
|
if (context.eventName === 'issues') {
|
||||||
|
if (context.payload.action !== 'labeled' || context.payload.label?.name !== ANALYZE_LABEL) {
|
||||||
|
console.log('Not a pi-analyze label event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (context.eventName === 'issue_comment') {
|
||||||
|
if (context.payload.issue.pull_request) {
|
||||||
|
console.log('Ignoring pull request comment');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = context.payload.comment.body || '';
|
||||||
|
const match = body.match(/^\s*@issueron\s+analyze\b([\s\S]*)$/i);
|
||||||
|
if (!match) {
|
||||||
|
console.log('Comment is not an @issueron analyze trigger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
extraInstructions = match[1].trim();
|
||||||
|
} else {
|
||||||
|
console.log(`Unsupported event: ${context.eventName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
async function removeTriggerLabel() {
|
async function removeTriggerLabel() {
|
||||||
|
if (context.eventName !== 'issues') return;
|
||||||
try {
|
try {
|
||||||
await github.rest.issues.removeLabel({
|
await github.rest.issues.removeLabel({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: context.issue.number,
|
issue_number: context.issue.number,
|
||||||
name: 'pi-analyze',
|
name: ANALYZE_LABEL,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status !== 404) throw error;
|
if (error.status !== 404) throw error;
|
||||||
@@ -117,11 +151,24 @@ jobs:
|
|||||||
core.setFailed(
|
core.setFailed(
|
||||||
`@${username} has '${data.permission}' permission; write or admin is required to trigger issue analysis.`,
|
`@${username} has '${data.permission}' permission; write or admin is required to trigger issue analysis.`,
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context.eventName === 'issue_comment') {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
labels: [ANALYZE_LABEL],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput('should_run', 'true');
|
||||||
|
core.setOutput('extra_instructions', extraInstructions);
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
needs: authorize
|
needs: authorize
|
||||||
if: needs.authorize.result == 'success'
|
if: needs.authorize.outputs.should_run == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: pi-analyze
|
environment: pi-analyze
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
@@ -173,14 +220,20 @@ jobs:
|
|||||||
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||||
|
EXTRA_INSTRUCTIONS: ${{ needs.authorize.outputs.extra_instructions }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p "$RUNNER_TEMP/pi-out/session"
|
mkdir -p "$RUNNER_TEMP/pi-out/session"
|
||||||
|
prompt="/is $ISSUE_URL"
|
||||||
|
if [ -n "$EXTRA_INSTRUCTIONS" ]; then
|
||||||
|
prompt+=$'\n\nAdditional instructions from @issueron analyze comment:\n'
|
||||||
|
prompt+="$EXTRA_INSTRUCTIONS"
|
||||||
|
fi
|
||||||
./pi-test.sh \
|
./pi-test.sh \
|
||||||
-p \
|
-p \
|
||||||
--approve \
|
--approve \
|
||||||
--session-dir "$RUNNER_TEMP/pi-out/session" \
|
--session-dir "$RUNNER_TEMP/pi-out/session" \
|
||||||
--model "$ISSUE_ANALYSIS_MODEL" \
|
--model "$ISSUE_ANALYSIS_MODEL" \
|
||||||
"/is $ISSUE_URL" | tee "$RUNNER_TEMP/pi-out/output.md"
|
"$prompt" | tee "$RUNNER_TEMP/pi-out/output.md"
|
||||||
|
|
||||||
- name: Export session files
|
- name: Export session files
|
||||||
id: export_session_files
|
id: export_session_files
|
||||||
@@ -236,7 +289,7 @@ jobs:
|
|||||||
'Continue locally from a checkout with:',
|
'Continue locally from a checkout with:',
|
||||||
'',
|
'',
|
||||||
'```sh',
|
'```sh',
|
||||||
`pi "/import-repro ${gistId}"`,
|
`pi "/ir ${gistId}"`,
|
||||||
'```',
|
'```',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
* current session directory, and switches to it.
|
* current session directory, and switches to it.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* /import-repro b4d100022aefb12f25dd2d8485e0a82a
|
* /ir b4d100022aefb12f25dd2d8485e0a82a
|
||||||
* /import-repro https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a
|
* /ir https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a
|
||||||
* /import-repro https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a
|
* /ir https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a
|
||||||
|
* /ir https://github.com/earendil-works/pi/issues/123
|
||||||
*
|
*
|
||||||
* pi "/import-repro <gist-id>"
|
* pi "/ir <gist-id>"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Buffer } from "node:buffer";
|
import { Buffer } from "node:buffer";
|
||||||
@@ -22,6 +23,8 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|||||||
const GIST_ID_RE = /^[0-9a-fA-F]{20,}$/;
|
const GIST_ID_RE = /^[0-9a-fA-F]{20,}$/;
|
||||||
const GIST_URL_RE = /^https:\/\/gist\.github\.com\/(?:[^/]+\/)?([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
|
const GIST_URL_RE = /^https:\/\/gist\.github\.com\/(?:[^/]+\/)?([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
|
||||||
const SHARE_URL_RE = /^https:\/\/pi\.dev\/session\/#([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
|
const SHARE_URL_RE = /^https:\/\/pi\.dev\/session\/#([0-9a-fA-F]{20,})(?:[/#?].*)?$/;
|
||||||
|
const ISSUE_URL_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:[/#?].*)?$/;
|
||||||
|
const GIST_URL_IN_TEXT_RE = /https:\/\/gist\.github\.com\/(?:[^/\s]+\/)?([0-9a-fA-F]{20,})\b/g;
|
||||||
const SESSION_DATA_RE = /<script id="session-data" type="application\/json">([^<]+)<\/script>/;
|
const SESSION_DATA_RE = /<script id="session-data" type="application\/json">([^<]+)<\/script>/;
|
||||||
|
|
||||||
interface SessionHeader {
|
interface SessionHeader {
|
||||||
@@ -47,7 +50,15 @@ interface GistResponse {
|
|||||||
files?: Record<string, GistFile>;
|
files?: Record<string, GistFile>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRef(ref: string, cwd: string): { type: "gist"; id: string } | { type: "file"; path: string } {
|
interface IssueComment {
|
||||||
|
body?: string | null;
|
||||||
|
user?: { login?: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRef(
|
||||||
|
ref: string,
|
||||||
|
cwd: string,
|
||||||
|
): { type: "gist"; id: string } | { type: "file"; path: string } | { type: "issue"; owner: string; repo: string; issue: string } {
|
||||||
if (ref.endsWith(".html") || ref.endsWith(".jsonl")) {
|
if (ref.endsWith(".html") || ref.endsWith(".jsonl")) {
|
||||||
return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) };
|
return { type: "file", path: isAbsolute(ref) ? ref : resolve(cwd, ref) };
|
||||||
}
|
}
|
||||||
@@ -58,9 +69,12 @@ function parseRef(ref: string, cwd: string): { type: "gist"; id: string } | { ty
|
|||||||
const gistMatch = ref.match(GIST_URL_RE);
|
const gistMatch = ref.match(GIST_URL_RE);
|
||||||
if (gistMatch) return { type: "gist", id: gistMatch[1] };
|
if (gistMatch) return { type: "gist", id: gistMatch[1] };
|
||||||
|
|
||||||
|
const issueMatch = ref.match(ISSUE_URL_RE);
|
||||||
|
if (issueMatch) return { type: "issue", owner: issueMatch[1], repo: issueMatch[2], issue: issueMatch[3] };
|
||||||
|
|
||||||
if (GIST_ID_RE.test(ref)) return { type: "gist", id: ref };
|
if (GIST_ID_RE.test(ref)) return { type: "gist", id: ref };
|
||||||
|
|
||||||
throw new Error(`expected a gist ID, gist URL, pi.dev share URL, .html file, or .jsonl file: ${ref}`);
|
throw new Error(`expected a gist ID, gist URL, pi.dev share URL, issue URL, .html file, or .jsonl file: ${ref}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSessionJsonl(raw: string): { header: SessionHeader; jsonl: string } {
|
function parseSessionJsonl(raw: string): { header: SessionHeader; jsonl: string } {
|
||||||
@@ -124,6 +138,33 @@ async function readGistFile(file: GistFile): Promise<string> {
|
|||||||
return await fetchText(file.raw_url);
|
return await fetchText(file.raw_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findIssueGistId(owner: string, repo: string, issue: string): Promise<string> {
|
||||||
|
const gistIds: string[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${encodeURIComponent(issue)}/comments?per_page=100&page=${page}`,
|
||||||
|
{ headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`failed to fetch issue comments: HTTP ${response.status}`);
|
||||||
|
|
||||||
|
const comments = (await response.json()) as IssueComment[];
|
||||||
|
for (const comment of comments) {
|
||||||
|
if (comment.user?.login !== "github-actions[bot]") continue;
|
||||||
|
for (const match of (comment.body ?? "").matchAll(GIST_URL_IN_TEXT_RE)) {
|
||||||
|
gistIds.push(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comments.length < 100) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gistId = gistIds.at(-1);
|
||||||
|
if (!gistId) throw new Error(`no github-actions gist link found in comments on ${owner}/${repo}#${issue}`);
|
||||||
|
return gistId;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader; jsonl: string }> {
|
async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader; jsonl: string }> {
|
||||||
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
|
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -145,12 +186,12 @@ async function fetchGistSession(gistId: string): Promise<{ header: SessionHeader
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
pi.registerCommand("import-repro", {
|
pi.registerCommand("ir", {
|
||||||
description: "Import a CI issue-analysis session from a gist ID or pi.dev session URL and switch to it",
|
description: "Import a CI issue-analysis session from a gist ID, share URL, or issue URL and switch to it",
|
||||||
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
||||||
const ref = args.trim();
|
const ref = args.trim();
|
||||||
if (!ref) {
|
if (!ref) {
|
||||||
ctx.ui.notify("Usage: /import-repro <gist-id | gist-url | pi.dev/session URL>", "error");
|
ctx.ui.notify("Usage: /ir <gist-id | gist-url | pi.dev/session URL | issue URL>", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +207,10 @@ export default function (pi: ExtensionAPI) {
|
|||||||
if (parsedRef.type === "gist") {
|
if (parsedRef.type === "gist") {
|
||||||
decoded = await fetchGistSession(parsedRef.id);
|
decoded = await fetchGistSession(parsedRef.id);
|
||||||
sourceName = `${parsedRef.id}.jsonl`;
|
sourceName = `${parsedRef.id}.jsonl`;
|
||||||
|
} else if (parsedRef.type === "issue") {
|
||||||
|
const gistId = await findIssueGistId(parsedRef.owner, parsedRef.repo, parsedRef.issue);
|
||||||
|
decoded = await fetchGistSession(gistId);
|
||||||
|
sourceName = `${gistId}.jsonl`;
|
||||||
} else {
|
} else {
|
||||||
if (!existsSync(parsedRef.path)) throw new Error(`session file not found: ${parsedRef.path}`);
|
if (!existsSync(parsedRef.path)) throw new Error(`session file not found: ${parsedRef.path}`);
|
||||||
const raw = readFileSync(parsedRef.path, "utf8");
|
const raw = readFileSync(parsedRef.path, "utf8");
|
||||||
@@ -190,7 +235,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
ctx.ui.notify(`Imported session ${decoded.header.id} (cwd ${decoded.header.cwd} -> ${targetCwd})`, "info");
|
ctx.ui.notify(`Imported session ${decoded.header.id} (cwd ${decoded.header.cwd} -> ${targetCwd})`, "info");
|
||||||
await ctx.switchSession(destination);
|
await ctx.switchSession(destination);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.ui.notify(`import-repro: ${error instanceof Error ? error.message : String(error)}`, "error");
|
ctx.ui.notify(`ir: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user