diff --git a/.github/workflows/issue-analysis.yml b/.github/workflows/issue-analysis.yml index 7a6438b02..8c8cf8e1a 100644 --- a/.github/workflows/issue-analysis.yml +++ b/.github/workflows/issue-analysis.yml @@ -1,5 +1,13 @@ # Runs the repo's /is prompt against an issue when the `pi-analyze` label is added -# or when a staff member comments `@issuron analyze` on an issue. +# or when a staff member comments `@issuron analyze` anywhere on an issue. +# +# Comment triggers can include one `#run-on-*` tag anywhere in the text: +# @issuron analyze #run-on-linux -> ubuntu-latest (default) +# @issuron analyze #run-on-windows -> windows-latest +# @issuron analyze #run-on-mac -> macos-latest +# +# Label triggers always run on the default Linux runner. Runner selection is +# intentionally restricted to hardcoded aliases in the authorization step. # # Setup required before this works: # 1. Create a `pi-analyze` GitHub environment on the repo and add a @@ -13,6 +21,10 @@ # creation permission. The analysis job uses it to upload the exported # session gist. # +# The selected runner must have Node.js support plus fd and ripgrep. GitHub +# hosted runners are bootstrapped below; future self-hosted aliases should have +# those dependencies preinstalled or installable by the setup steps. +# # 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 # /ir extension command (.pi/extensions/import-repro.ts): @@ -40,6 +52,9 @@ jobs: outputs: should_run: ${{ steps.verify.outputs.should_run }} extra_instructions: ${{ steps.verify.outputs.extra_instructions }} + runs_on: ${{ steps.verify.outputs.runs_on }} + runner_os: ${{ steps.verify.outputs.runner_os }} + runner_profile: ${{ steps.verify.outputs.runner_profile }} steps: - name: Verify sender permission id: verify @@ -49,11 +64,35 @@ jobs: with: script: | const ANALYZE_LABEL = 'pi-analyze'; + const TRIGGER_RE = /@issuron\s+analyze\b/i; + const RUN_ON_TAG_RE = /#run-on-([a-z0-9][a-z0-9_-]*)\b/gi; + const RUNNER_PROFILES = { + linux: { runsOn: 'ubuntu-latest', os: 'linux' }, + windows: { runsOn: 'windows-latest', os: 'windows' }, + mac: { runsOn: 'macos-latest', os: 'macos' }, + }; + const RUN_ON_ALIASES = { + linux: 'linux', + ubuntu: 'linux', + 'ubuntu-latest': 'linux', + windows: 'windows', + win: 'windows', + 'windows-latest': 'windows', + mac: 'mac', + macos: 'mac', + darwin: 'mac', + 'macos-latest': 'mac', + }; + const username = context.payload.sender.login; let extraInstructions = ''; + let runnerProfile = 'linux'; core.setOutput('should_run', 'false'); core.setOutput('extra_instructions', ''); + core.setOutput('runs_on', JSON.stringify(RUNNER_PROFILES.linux.runsOn)); + core.setOutput('runner_os', RUNNER_PROFILES.linux.os); + core.setOutput('runner_profile', runnerProfile); if (context.eventName === 'issues') { if (context.payload.action !== 'labeled' || context.payload.label?.name !== ANALYZE_LABEL) { @@ -66,12 +105,38 @@ jobs: return; } const body = context.payload.comment.body || ''; - const match = body.match(/^\s*@issuron\s+analyze\b([\s\S]*)$/i); - if (!match) { - console.log('Comment is not an @issuron analyze trigger'); + if (!TRIGGER_RE.test(body)) { + console.log('Comment does not contain an @issuron analyze trigger'); return; } - extraInstructions = match[1].trim(); + + const resolvedProfiles = new Set(); + const unknownTags = []; + for (const match of body.matchAll(RUN_ON_TAG_RE)) { + const tag = match[1].toLowerCase(); + const resolved = RUN_ON_ALIASES[tag]; + if (!resolved) { + unknownTags.push(tag); + } else { + resolvedProfiles.add(resolved); + } + } + + if (unknownTags.length > 0) { + core.setFailed(`Unknown issue analysis runner tag(s): ${unknownTags.map((tag) => `#run-on-${tag}`).join(', ')}`); + return; + } + if (resolvedProfiles.size > 1) { + core.setFailed( + `Conflicting issue analysis runner tags: ${Array.from(resolvedProfiles) + .map((profile) => `#run-on-${profile}`) + .join(', ')}`, + ); + return; + } + + runnerProfile = Array.from(resolvedProfiles)[0] || 'linux'; + extraInstructions = body.replace(TRIGGER_RE, ' ').replace(RUN_ON_TAG_RE, ' ').trim(); } else { console.log(`Unsupported event: ${context.eventName}`); return; @@ -163,13 +228,18 @@ jobs: }); } + const profile = RUNNER_PROFILES[runnerProfile]; + console.log(`Selected issue analysis runner profile: ${runnerProfile} (${JSON.stringify(profile.runsOn)})`); core.setOutput('should_run', 'true'); core.setOutput('extra_instructions', extraInstructions); + core.setOutput('runs_on', JSON.stringify(profile.runsOn)); + core.setOutput('runner_os', profile.os); + core.setOutput('runner_profile', runnerProfile); analyze: needs: authorize if: needs.authorize.outputs.should_run == 'true' - runs-on: ubuntu-latest + runs-on: ${{ fromJSON(needs.authorize.outputs.runs_on) }} environment: pi-analyze timeout-minutes: 45 env: @@ -178,7 +248,11 @@ jobs: steps: - name: Create high-entropy working directory name id: workdir - run: echo "name=pi-ci-$(openssl rand -hex 16)" >> "$GITHUB_OUTPUT" + uses: actions/github-script@v7 + with: + script: | + const crypto = require('crypto'); + core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`); - name: Checkout uses: actions/checkout@v4 @@ -192,83 +266,218 @@ jobs: cache: npm cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json - - name: Install system dependencies + - name: Install system dependencies (Linux) + if: needs.authorize.outputs.runner_os == 'linux' run: | sudo apt-get update sudo apt-get install -y fd-find ripgrep - sudo ln -s "$(which fdfind)" /usr/local/bin/fd + sudo ln -sf "$(which fdfind)" /usr/local/bin/fd + + - name: Install system dependencies (macOS) + if: needs.authorize.outputs.runner_os == 'macos' + run: | + if ! command -v fd >/dev/null 2>&1; then + brew install fd + fi + if ! command -v rg >/dev/null 2>&1; then + brew install ripgrep + fi + + - name: Install system dependencies (Windows) + if: needs.authorize.outputs.runner_os == 'windows' + shell: pwsh + run: | + $packages = @() + if (-not (Get-Command fd -ErrorAction SilentlyContinue)) { + $packages += "fd" + } + if (-not (Get-Command rg -ErrorAction SilentlyContinue)) { + $packages += "ripgrep" + } + if ($packages.Count -gt 0) { + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + throw "fd and ripgrep must be installed on Windows runners, or Chocolatey must be available to install them." + } + choco install $packages -y --no-progress + } + fd --version + rg --version - name: Install dependencies working-directory: ${{ steps.workdir.outputs.name }} run: npm ci --ignore-scripts + - name: Build + working-directory: ${{ steps.workdir.outputs.name }} + run: npm run build + - name: Write auth.json + uses: actions/github-script@v7 env: PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }} - run: | - if [ -z "$PI_AUTH_JSON" ]; then - echo "PI_AUTH_JSON secret is not configured for the pi-analyze environment" >&2 - exit 1 - fi - mkdir -p "$RUNNER_TEMP/pi-agent" - printf '%s' "$PI_AUTH_JSON" > "$RUNNER_TEMP/pi-agent/auth.json" - chmod 600 "$RUNNER_TEMP/pi-agent/auth.json" + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const authJson = process.env.PI_AUTH_JSON; + if (!authJson) { + throw new Error('PI_AUTH_JSON secret is not configured for the pi-analyze environment'); + } + + const agentDir = path.join(process.env.RUNNER_TEMP, 'pi-agent'); + fs.mkdirSync(agentDir, { recursive: true }); + const authPath = path.join(agentDir, 'auth.json'); + fs.writeFileSync(authPath, authJson, { mode: 0o600 }); + if (process.platform !== 'win32') { + fs.chmodSync(authPath, 0o600); + } - name: Run pi /is - shell: bash - working-directory: ${{ steps.workdir.outputs.name }} + uses: actions/github-script@v7 env: PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent GH_TOKEN: ${{ github.token }} ISSUE_URL: ${{ github.event.issue.html_url }} EXTRA_INSTRUCTIONS: ${{ needs.authorize.outputs.extra_instructions }} - run: | - mkdir -p "$RUNNER_TEMP/pi-out/session" - prompt="/is $ISSUE_URL" - if [ -n "$EXTRA_INSTRUCTIONS" ]; then - prompt+=$'\n\nAdditional instructions from @issuron analyze comment:\n' - prompt+="$EXTRA_INSTRUCTIONS" - fi - ./pi-test.sh \ - -p \ - --approve \ - --session-dir "$RUNNER_TEMP/pi-out/session" \ - --model "$ISSUE_ANALYSIS_MODEL" \ - --thinking "$ISSUE_ANALYSIS_THINKING" \ - "$prompt" | tee "$RUNNER_TEMP/pi-out/output.md" + WORKDIR: ${{ steps.workdir.outputs.name }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const { spawn } = require('child_process'); + + const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR); + const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out'); + const sessionDir = path.join(outDir, 'session'); + fs.mkdirSync(sessionDir, { recursive: true }); + + let prompt = `/is ${process.env.ISSUE_URL}`; + if (process.env.EXTRA_INSTRUCTIONS) { + prompt += '\n\nAdditional instructions from @issuron analyze comment:\n'; + prompt += process.env.EXTRA_INSTRUCTIONS; + } + + const outputPath = path.join(outDir, 'output.md'); + const output = fs.createWriteStream(outputPath); + const args = [ + 'packages/coding-agent/src/cli.ts', + '-p', + '--approve', + '--session-dir', + sessionDir, + '--model', + process.env.ISSUE_ANALYSIS_MODEL, + '--thinking', + process.env.ISSUE_ANALYSIS_THINKING, + prompt, + ]; + + const exitCode = await new Promise((resolve, reject) => { + const child = spawn('node', args, { + cwd: workdir, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', (chunk) => { + process.stdout.write(chunk); + output.write(chunk); + }); + child.stderr.on('data', (chunk) => { + process.stderr.write(chunk); + }); + child.on('error', reject); + child.on('close', resolve); + }); + await new Promise((resolve) => output.end(resolve)); + + if (exitCode !== 0) { + throw new Error(`pi /is failed with exit code ${exitCode}`); + } - name: Export session files id: export_session_files if: always() - shell: bash - working-directory: ${{ steps.workdir.outputs.name }} + uses: actions/github-script@v7 env: PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent - run: | - session_file="$(find "$RUNNER_TEMP/pi-out/session" -type f -name '*.jsonl' | head -n 1)" - if [ -z "$session_file" ]; then - echo "No session jsonl file found" >&2 - exit 1 - fi - cp "$session_file" "$RUNNER_TEMP/pi-out/session.jsonl" - ./pi-test.sh --no-extensions --export "$RUNNER_TEMP/pi-out/session.jsonl" "$RUNNER_TEMP/pi-out/session.html" + WORKDIR: ${{ steps.workdir.outputs.name }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const { spawn } = require('child_process'); + + function findFirstJsonl(dir) { + if (!fs.existsSync(dir)) return undefined; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nested = findFirstJsonl(entryPath); + if (nested) return nested; + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + return entryPath; + } + } + return undefined; + } + + const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out'); + const sessionFile = findFirstJsonl(path.join(outDir, 'session')); + if (!sessionFile) { + throw new Error('No session jsonl file found'); + } + + const sessionJsonl = path.join(outDir, 'session.jsonl'); + const sessionHtml = path.join(outDir, 'session.html'); + fs.copyFileSync(sessionFile, sessionJsonl); + + const workdir = path.join(process.env.GITHUB_WORKSPACE, process.env.WORKDIR); + const exitCode = await new Promise((resolve, reject) => { + const child = spawn( + 'node', + ['packages/coding-agent/src/cli.ts', '--no-extensions', '--export', sessionJsonl, sessionHtml], + { cwd: workdir, env: process.env, stdio: 'inherit' }, + ); + child.on('error', reject); + child.on('close', resolve); + }); + if (exitCode !== 0) { + throw new Error(`session export failed with exit code ${exitCode}`); + } - name: Upload session gist id: gist if: always() && steps.export_session_files.outcome == 'success' - shell: bash + uses: actions/github-script@v7 env: - GH_TOKEN: ${{ secrets.PI_GIST_TOKEN }} - run: | - if [ -z "$GH_TOKEN" ]; then - echo "PI_GIST_TOKEN is not configured" >&2 - exit 1 - fi - gist_url="$(gh gist create --public=false "$RUNNER_TEMP/pi-out/session.html" "$RUNNER_TEMP/pi-out/session.jsonl")" - gist_id="${gist_url##*/}" - echo "url=$gist_url" >> "$GITHUB_OUTPUT" - echo "id=$gist_id" >> "$GITHUB_OUTPUT" - echo "share_url=https://pi.dev/session/#$gist_id" >> "$GITHUB_OUTPUT" + PI_GIST_TOKEN: ${{ secrets.PI_GIST_TOKEN }} + with: + github-token: ${{ secrets.PI_GIST_TOKEN }} + script: | + const fs = require('fs'); + const path = require('path'); + + if (!process.env.PI_GIST_TOKEN) { + throw new Error('PI_GIST_TOKEN is not configured'); + } + + const outDir = path.join(process.env.RUNNER_TEMP, 'pi-out'); + const files = {}; + for (const filename of ['session.html', 'session.jsonl']) { + files[filename] = { content: fs.readFileSync(path.join(outDir, filename), 'utf8') }; + } + + const response = await github.rest.gists.create({ + public: false, + files, + }); + const gistUrl = response.data.html_url; + const gistId = response.data.id; + core.setOutput('url', gistUrl); + core.setOutput('id', gistId); + core.setOutput('share_url', `https://pi.dev/session/#${gistId}`); - name: Comment with session import instructions if: always() && steps.gist.outcome == 'success' diff --git a/.pi/extensions/import-repro.ts b/.pi/extensions/import-repro.ts index a0096f611..cb11ca049 100644 --- a/.pi/extensions/import-repro.ts +++ b/.pi/extensions/import-repro.ts @@ -117,11 +117,106 @@ function decodeExportedHtml(html: string): { header: SessionHeader; jsonl: strin return { header, jsonl: `${lines.join("\n")}\n` }; } -/** Rewrite all occurrences of the recorded cwd (JSON-escaped) to the target cwd. */ +type SessionPlatform = "windows" | "unix" | "unknown"; + +function escapeJsonString(value: string): string { + return JSON.stringify(value).slice(1, -1); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function trimTrailingPathSeparators(value: string): string { + return value.replace(/[\\/]+$/, ""); +} + +function getPathTailName(value: string): string { + const trimmed = trimTrailingPathSeparators(value); + return trimmed.split(/[\\/]/).filter(Boolean).at(-1) ?? ""; +} + +function getWindowsDrivePathParts(value: string): { drive: string; rest: string } | undefined { + const trimmed = trimTrailingPathSeparators(value); + const driveMatch = trimmed.match(/^([A-Za-z]):[\\/](.*)$/); + if (driveMatch) { + return { drive: driveMatch[1].toUpperCase(), rest: driveMatch[2].replace(/[\\/]+/g, "/") }; + } + + const msysMatch = trimmed.match(/^\/([A-Za-z])\/(.*)$/); + if (msysMatch) { + return { drive: msysMatch[1].toUpperCase(), rest: msysMatch[2].replace(/[\\/]+/g, "/") }; + } + + return undefined; +} + +function getCwdRewriteVariants(sourceCwd: string): string[] { + const trimmed = trimTrailingPathSeparators(sourceCwd); + const variants = new Set(); + if (trimmed) variants.add(trimmed); + + const driveParts = getWindowsDrivePathParts(trimmed); + if (driveParts) { + const rest = driveParts.rest.replace(/^\/+|\/+$/g, ""); + const backslashRest = rest.replace(/\//g, "\\"); + variants.add(`${driveParts.drive}:\\${backslashRest}`); + variants.add(`${driveParts.drive}:/${rest}`); + variants.add(`/${driveParts.drive.toLowerCase()}/${rest}`); + variants.add(`/${driveParts.drive}/${rest}`); + } + + return Array.from(variants).filter(Boolean).sort((a, b) => b.length - a.length); +} + +function getCiWorkdirName(sourceCwd: string): string | undefined { + const name = getPathTailName(sourceCwd); + return /^pi-ci-[0-9a-f]{32}$/i.test(name) ? name : undefined; +} + +function detectSessionPlatform(cwd: string): SessionPlatform { + if (/^[A-Za-z]:[\\/]/.test(cwd) || /^\/[A-Za-z]\//.test(cwd)) return "windows"; + if (cwd.startsWith("/")) return "unix"; + return "unknown"; +} + +function getLocalPlatform(): Exclude { + return process.platform === "win32" ? "windows" : "unix"; +} + +function getPlatformContinuationNotice(sourceCwd: string): string | undefined { + const sourcePlatform = detectSessionPlatform(sourceCwd); + const localPlatform = getLocalPlatform(); + if (sourcePlatform === "unknown" || sourcePlatform === localPlatform) return undefined; + if (localPlatform === "unix") { + return "This session was continued on a non-Windows machine; paths are now Unix style."; + } + return "This session was continued on a Windows machine; paths are now Windows style."; +} + +/** Rewrite occurrences of the recorded CI cwd (JSON-escaped) to the target cwd. */ function rewriteSessionCwd(raw: string, sourceCwd: string, targetCwd: string): string { - if (sourceCwd === targetCwd) return raw; - const escapeJson = (value: string) => JSON.stringify(value).slice(1, -1); - return raw.split(escapeJson(sourceCwd)).join(escapeJson(targetCwd)); + const target = escapeJsonString(targetCwd); + let rewritten = raw; + + for (const sourceVariant of getCwdRewriteVariants(sourceCwd)) { + if (sourceVariant === targetCwd) continue; + rewritten = rewritten.split(escapeJsonString(sourceVariant)).join(target); + } + + const ciWorkdirName = getCiWorkdirName(sourceCwd); + if (ciWorkdirName) { + const escapedName = escapeRegExp(ciWorkdirName); + const windowsPathPatterns = [ + new RegExp(`[A-Za-z]:(?:[^"\\r\\n])*?${escapedName}`, "g"), + new RegExp(`/[A-Za-z]/(?:[^"\\r\\n])*?${escapedName}`, "g"), + ]; + for (const pattern of windowsPathPatterns) { + rewritten = rewritten.replace(pattern, target); + } + } + + return rewritten; } async function fetchText(url: string): Promise { @@ -218,6 +313,7 @@ export default function (pi: ExtensionAPI) { sourceName = basename(parsedRef.path).replace(/\.html$/, ".jsonl"); } + const platformNotice = getPlatformContinuationNotice(decoded.header.cwd); const rewritten = rewriteSessionCwd(decoded.jsonl, decoded.header.cwd, targetCwd); const destination = join(sessionDir, sourceName); if (existsSync(destination)) { @@ -233,7 +329,20 @@ export default function (pi: ExtensionAPI) { writeFileSync(destination, rewritten); ctx.ui.notify(`Imported session ${decoded.header.id} (cwd ${decoded.header.cwd} -> ${targetCwd})`, "info"); - await ctx.switchSession(destination); + await ctx.switchSession(destination, { + withSession: async (nextCtx) => { + if (!platformNotice) return; + await nextCtx.sendMessage( + { + customType: "import-repro", + content: platformNotice, + display: true, + details: { sourceCwd: decoded.header.cwd, targetCwd }, + }, + { triggerTurn: false }, + ); + }, + }); } catch (error) { ctx.ui.notify(`ir: ${error instanceof Error ? error.message : String(error)}`, "error"); }