diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 7695e96f1..54bdb1cfe 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -243,3 +243,23 @@ Mearman pr dodiego pr any-victor pr + +geraschenko pr + +skhoroshavin pr + +cyzlmh pr + +xz-dev pr + +rajp152k pr + +affanali2k3 pr + +ArcadiaLin pr + +anilgulecha pr + +DeviosLang pr + +HarrodRen pr diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 355ad1cdb..9bab5e082 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -17,11 +17,17 @@ on: permissions: {} +concurrency: + group: build-binaries-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + jobs: + # Keep the public GitHub Release publication last. Binary assets are staged in + # a draft release first; cleanup removes the draft if later publishing fails. build: runs-on: ubuntu-latest permissions: - contents: write + contents: read env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} @@ -46,18 +52,105 @@ jobs: - name: Build binaries run: ./scripts/build-binaries.sh - - name: Extract changelog for this version - id: changelog + - name: Prepare GitHub release payload run: | + set -euo pipefail + + mkdir -p release-assets + VERSION="${RELEASE_TAG}" VERSION="${VERSION#v}" # Remove 'v' prefix - node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md + node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out release-assets/RELEASE_NOTES.md + node scripts/generate-coding-agent-install-lock.mjs --check + cp packages/coding-agent/install-lock/package.json release-assets/pi-coding-agent-install-package.json + cp packages/coding-agent/install-lock/package-lock.json release-assets/pi-coding-agent-install-package-lock.json - - name: Create GitHub Release and upload binaries + cd packages/coding-agent/binaries + + binary_assets=( + pi-darwin-arm64.tar.gz + pi-darwin-x64.tar.gz + pi-linux-x64.tar.gz + pi-linux-arm64.tar.gz + pi-windows-x64.zip + pi-windows-arm64.zip + ) + + for asset in "${binary_assets[@]}"; do + test -f "${asset}" + done + + cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/" + + cd "${GITHUB_WORKSPACE}/release-assets" + release_assets=( + pi-darwin-arm64.tar.gz + pi-darwin-x64.tar.gz + pi-linux-x64.tar.gz + pi-linux-arm64.tar.gz + pi-windows-x64.zip + pi-windows-arm64.zip + pi-coding-agent-install-package.json + pi-coding-agent-install-package-lock.json + ) + sha256sum "${release_assets[@]}" > SHA256SUMS + + - name: Upload GitHub release payload + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-assets-${{ env.RELEASE_TAG }} + path: release-assets/* + if-no-files-found: error + retention-days: 14 + + stage-github-release: + runs-on: ubuntu-latest + needs: build + permissions: + actions: read + contents: write + env: + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - name: Download GitHub release payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-assets-${{ env.RELEASE_TAG }} + path: release-assets + + - name: Validate GitHub release payload + run: | + set -euo pipefail + + cd release-assets + + expected_assets=( + pi-darwin-arm64.tar.gz + pi-darwin-x64.tar.gz + pi-linux-x64.tar.gz + pi-linux-arm64.tar.gz + pi-windows-x64.zip + pi-windows-arm64.zip + pi-coding-agent-install-package.json + pi-coding-agent-install-package-lock.json + SHA256SUMS + RELEASE_NOTES.md + ) + + for asset in "${expected_assets[@]}"; do + test -f "${asset}" + done + + sha256sum -c SHA256SUMS + + - name: Create draft GitHub Release and upload binaries env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - cd packages/coding-agent/binaries + set -euo pipefail + + cd release-assets release_assets=( pi-darwin-arm64.tar.gz @@ -66,25 +159,39 @@ jobs: pi-linux-arm64.tar.gz pi-windows-x64.zip pi-windows-arm64.zip + pi-coding-agent-install-package.json + pi-coding-agent-install-package-lock.json + SHA256SUMS ) - sha256sum "${release_assets[@]}" > SHA256SUMS - release_assets+=(SHA256SUMS) - if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then - gh release edit "${RELEASE_TAG}" \ - --title "${RELEASE_TAG}" \ - --notes-file /tmp/release-notes.md - gh release upload "${RELEASE_TAG}" "${release_assets[@]}" --clobber - else - gh release create "${RELEASE_TAG}" \ - --title "${RELEASE_TAG}" \ - --notes-file /tmp/release-notes.md \ - "${release_assets[@]}" + existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)" + if [[ "${existing_release}" == "false" ]]; then + echo "::error::GitHub Release ${RELEASE_TAG} is already published. Refusing to mutate a public release." + exit 1 + fi + if [[ "${existing_release}" == "true" ]]; then + gh release delete "${RELEASE_TAG}" --yes + fi + + gh release create "${RELEASE_TAG}" \ + --verify-tag \ + --draft \ + --title "${RELEASE_TAG}" \ + --notes-file RELEASE_NOTES.md \ + "${release_assets[@]}" + + expected_asset_names="$(printf '%s\n' "${release_assets[@]}" | sort)" + actual_asset_names="$(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' | sort)" + + if [[ "${actual_asset_names}" != "${expected_asset_names}" ]]; then + echo "::error::Draft GitHub Release asset set does not match expected files." + diff -u <(printf '%s\n' "${expected_asset_names}") <(printf '%s\n' "${actual_asset_names}") || true + exit 1 fi publish-npm: runs-on: ubuntu-latest - needs: build + needs: stage-github-release environment: npm-publish permissions: contents: read @@ -124,9 +231,6 @@ jobs: - name: Test run: npm test - - name: Verify release artifacts are committed - run: git diff --exit-code - - name: Upgrade npm for trusted publishing run: | npm install -g npm@11.16.0 --ignore-scripts @@ -134,3 +238,57 @@ jobs: - name: Publish npm packages run: node scripts/publish.mjs + + publish-github-release: + runs-on: ubuntu-latest + needs: + - stage-github-release + - publish-npm + permissions: + contents: write + env: + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - name: Publish staged GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)" + if [[ "${existing_release}" == "" ]]; then + echo "::error::Draft GitHub Release ${RELEASE_TAG} does not exist." + exit 1 + fi + if [[ "${existing_release}" == "false" ]]; then + echo "::error::GitHub Release ${RELEASE_TAG} is already published." + exit 1 + fi + + gh release edit "${RELEASE_TAG}" --draft=false + + cleanup-draft-github-release: + runs-on: ubuntu-latest + needs: + - build + - stage-github-release + - publish-npm + - publish-github-release + if: ${{ always() && needs.stage-github-release.result != 'skipped' && (needs.stage-github-release.result != 'success' || needs.publish-npm.result != 'success' || needs.publish-github-release.result != 'success') }} + permissions: + contents: write + env: + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - name: Delete draft GitHub Release after failure + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + existing_release="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft 2>/dev/null || true)" + if [[ "${existing_release}" == "true" ]]; then + gh release delete "${RELEASE_TAG}" --yes + fi diff --git a/.github/workflows/issue-analysis.yml b/.github/workflows/issue-analysis.yml new file mode 100644 index 000000000..c4d0a1fb5 --- /dev/null +++ b/.github/workflows/issue-analysis.yml @@ -0,0 +1,634 @@ +# Runs the repo's /is prompt against an issue when the `pi-analyze` label is added +# 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 +# `PI_AUTH_JSON` secret containing the contents of a pi auth.json +# (~/.pi/agent/auth.json). +# 2. Create the `pi-analyze` label. +# 3. Add a repository secret `EARENDIL_ORG_READ_TOKEN` with permission to +# read `earendil-works` org membership. The authorization job uses it to +# verify that the label actor is an active member of `earendil-works/staff`. +# 4. Add an environment secret `PI_GIST_TOKEN` on `pi-analyze` with gist +# creation permission. The analysis job uses it to upload the exported +# session gist. +# 5. Add an environment secret `PI_AUTH_UPDATE_TOKEN` on `pi-analyze` with +# permission to update this repo's environment secrets. The analysis job +# uses it to write back refreshed `PI_AUTH_JSON` contents. +# +# The selected runner must have Node.js support plus gh, 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): +# pi "/ir " + +name: Issue Analysis + +on: + issues: + types: [labeled] + issue_comment: + types: [created] + +permissions: + contents: read + issues: write + +concurrency: + group: issue-analysis-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + authorize: + runs-on: ubuntu-latest + 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 + uses: actions/github-script@v7 + env: + ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }} + 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) { + 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 || ''; + if (!TRIGGER_RE.test(body)) { + console.log('Comment does not contain an @issuron analyze trigger'); + return; + } + + 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; + } + + async function removeTriggerLabel() { + if (context.eventName !== 'issues') return; + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: ANALYZE_LABEL, + }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + + if (!process.env.ORG_READ_TOKEN) { + await removeTriggerLabel(); + core.setFailed('EARENDIL_ORG_READ_TOKEN is not configured; refusing to run issue analysis.'); + return; + } + + try { + const response = await fetch( + `https://api.github.com/orgs/earendil-works/teams/staff/memberships/${encodeURIComponent(username)}`, + { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${process.env.ORG_READ_TOKEN}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + ); + + if (response.status === 404) { + await removeTriggerLabel(); + core.setFailed(`@${username} is not an active earendil-works/staff member.`); + return; + } + + if (!response.ok) { + const body = await response.text(); + await removeTriggerLabel(); + core.setFailed( + `Could not verify earendil-works/staff membership for @${username}: HTTP ${response.status} ${body}`, + ); + return; + } + + const membership = await response.json(); + if (membership.state !== 'active') { + await removeTriggerLabel(); + core.setFailed(`@${username} is not an active earendil-works/staff member.`); + return; + } + console.log(`earendil-works/staff membership for @${username}: ${membership.state}`); + } catch (error) { + await removeTriggerLabel(); + core.setFailed( + `Could not verify earendil-works/staff membership for @${username}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return; + } + + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + if (!['admin', 'write'].includes(data.permission)) { + await removeTriggerLabel(); + core.setFailed( + `@${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], + }); + } + + 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: ${{ fromJSON(needs.authorize.outputs.runs_on) }} + environment: pi-analyze + timeout-minutes: 45 + concurrency: + group: issue-analysis-pi-auth + cancel-in-progress: false + env: + ISSUE_ANALYSIS_MODEL: openai-codex/gpt-5.5 + ISSUE_ANALYSIS_THINKING: high + steps: + - name: Create high-entropy working directory name + id: workdir + 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 + with: + path: ${{ steps.workdir.outputs.name }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: ${{ steps.workdir.outputs.name }}/package-lock.json + + - 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 -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 + id: write_auth + uses: actions/github-script@v7 + env: + PI_AUTH_JSON: ${{ secrets.PI_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 + 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 }} + 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: Persist refreshed auth.json + if: always() && steps.write_auth.outcome == 'success' + uses: actions/github-script@v7 + env: + GH_TOKEN: ${{ secrets.PI_AUTH_UPDATE_TOKEN }} + PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent + with: + script: | + const fs = require('fs'); + const path = require('path'); + const { spawn } = require('child_process'); + + if (!process.env.GH_TOKEN) { + throw new Error('PI_AUTH_UPDATE_TOKEN is not configured for the pi-analyze environment'); + } + + const authPath = path.join(process.env.PI_CODING_AGENT_DIR, 'auth.json'); + if (!fs.existsSync(authPath)) { + core.warning('auth.json was not created; skipping auth persistence'); + return; + } + + const authJson = fs.readFileSync(authPath, 'utf8'); + let parsed; + try { + parsed = JSON.parse(authJson); + } catch (error) { + throw new Error(`Refusing to persist malformed auth.json: ${error instanceof Error ? error.message : String(error)}`); + } + + const codexAuth = parsed['openai-codex']; + if (codexAuth?.type !== 'oauth' || typeof codexAuth.refresh !== 'string' || codexAuth.refresh.length === 0) { + throw new Error('Refusing to persist auth.json without openai-codex OAuth refresh credentials'); + } + + await new Promise((resolve, reject) => { + const child = spawn( + 'gh', + ['secret', 'set', 'PI_AUTH_JSON', '--env', 'pi-analyze', '--repo', process.env.GITHUB_REPOSITORY], + { env: process.env, stdio: ['pipe', 'inherit', 'inherit'] }, + ); + child.stdin.end(authJson); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`gh secret set failed with exit code ${code}`)); + } + }); + }); + + - name: Export session files + id: export_session_files + if: always() + uses: actions/github-script@v7 + env: + PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent + 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' + uses: actions/github-script@v7 + env: + 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' + uses: actions/github-script@v7 + env: + GIST_URL: ${{ steps.gist.outputs.url }} + GIST_ID: ${{ steps.gist.outputs.id }} + SHARE_URL: ${{ steps.gist.outputs.share_url }} + SESSION_JSONL: ${{ runner.temp }}/pi-out/session.jsonl + with: + script: | + const fs = require('fs'); + + function extractLastAgentMessage(sessionPath) { + const lines = fs.readFileSync(sessionPath, 'utf8').split(/\r?\n/).filter(Boolean); + let lastText = ''; + + for (const line of lines) { + let entry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + + if (entry.type !== 'message' || entry.message?.role !== 'assistant') continue; + + const content = entry.message.content; + const parts = []; + if (typeof content === 'string') { + parts.push(content); + } else if (Array.isArray(content)) { + for (const block of content) { + if (block?.type === 'text' && typeof block.text === 'string') { + parts.push(block.text); + } + } + } + + const text = parts.join('\n\n').trim(); + if (text) lastText = text; + } + + if (!lastText) return '_No assistant output found._'; + const maxLength = 55000; + if (lastText.length <= maxLength) return lastText; + return `${lastText.slice(0, maxLength)}\n\n_[truncated]_`; + } + + const gistUrl = process.env.GIST_URL; + const gistId = process.env.GIST_ID; + const shareUrl = process.env.SHARE_URL; + const lastAgentMessage = extractLastAgentMessage(process.env.SESSION_JSONL); + const body = [ + 'Pi issue analysis finished.', + '', + `Share URL: ${shareUrl}`, + `Gist: ${gistUrl}`, + '', + 'Continue locally from a checkout with:', + '', + '```sh', + `pi "/ir ${gistId}"`, + '```', + '', + '
', + 'Agent analysis summary', + '', + lastAgentMessage, + '', + '
', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + + - name: Remove trigger label + if: always() + uses: actions/github-script@v7 + with: + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'pi-analyze', + }); + } catch (error) { + if (error.status !== 404) throw error; + } diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index d2bf830ec..6fcbfa210 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -17,11 +17,13 @@ jobs: script: | const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; const VALID_CAPABILITIES = new Set(['issue', 'pr']); + const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']); const issueAuthor = context.payload.issue.user.login; const defaultBranch = context.payload.repository.default_branch; + const isBotAuthor = issueAuthor.endsWith('[bot]'); - if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') { - console.log(`Skipping bot: ${issueAuthor}`); + if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) { + console.log(`Skipping trusted bot: ${issueAuthor}`); return; } @@ -80,7 +82,7 @@ jobs: } const permission = await getPermission(issueAuthor); - if (['admin', 'maintain', 'write'].includes(permission)) { + if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) { console.log(`${issueAuthor} is a collaborator with ${permission} access`); return; } @@ -89,7 +91,7 @@ jobs: const approvedUsers = parseApprovedUsers(approvedContent); const capability = approvedUsers.get(issueAuthor.toLowerCase()); - if (capability === 'issue' || capability === 'pr') { + if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) { console.log(`${issueAuthor} is approved for ${capability}`); return; } diff --git a/.github/workflows/openclaw-gate.yml b/.github/workflows/openclaw-gate.yml deleted file mode 100644 index 126d4ebf2..000000000 --- a/.github/workflows/openclaw-gate.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: OpenClaw Gate - -on: - issues: - types: [opened] - pull_request_target: - types: [opened] - -jobs: - check-contributor: - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - pull-requests: write - steps: - - name: Check contributor - uses: actions/github-script@v7 - with: - script: | - const isPR = !!context.payload.pull_request; - const author = isPR - ? context.payload.pull_request.user.login - : context.payload.issue.user.login; - const number = isPR - ? context.payload.pull_request.number - : context.payload.issue.number; - const defaultBranch = context.payload.repository.default_branch; - - if (author.endsWith('[bot]') || author === 'dependabot[bot]') { - console.log(`Skipping bot: ${author}`); - return; - } - - const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; - const VALID_CAPABILITIES = new Set(['issue', 'pr']); - - // --- Check APPROVED_CONTRIBUTORS --- - async function getTextFile(path) { - const { data } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path, - ref: defaultBranch, - }); - if (!('content' in data) || typeof data.content !== 'string') { - throw new Error(`Expected file content for ${path}`); - } - return Buffer.from(data.content, 'base64').toString('utf8'); - } - - try { - const content = await getTextFile(APPROVED_FILE); - const approved = new Map(); - for (const rawLine of content.split('\n')) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) continue; - - const parts = line.split(/\s+/); - if (parts.length !== 2) continue; - - const [username, capability] = parts; - const normalizedCapability = capability.toLowerCase(); - if (!VALID_CAPABILITIES.has(normalizedCapability)) continue; - - approved.set(username.toLowerCase(), normalizedCapability); - } - - if (approved.has(author.toLowerCase())) { - console.log(`${author} is in APPROVED_CONTRIBUTORS, passing`); - return; - } - } catch (err) { - console.log(`Could not read APPROVED_CONTRIBUTORS: ${err.message}`); - } - - // --- Also pass collaborators with write+ access --- - try { - const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: author, - }); - if (['admin', 'maintain', 'write'].includes(perm.permission)) { - console.log(`${author} is a collaborator (${perm.permission}), passing`); - return; - } - } catch { - // not a collaborator - } - - // --- Check if user opened issues/PRs on openclaw/openclaw --- - async function hasOpenClawActivity(username) { - try { - const { data } = await github.rest.search.issuesAndPullRequests({ - q: `repo:openclaw/openclaw author:${username}`, - per_page: 1, - }); - if (data.total_count > 0) { - console.log(`${username} has opened ${data.total_count} issues/PRs on openclaw/openclaw`); - return true; - } - } catch (err) { - console.log(`Search failed: ${err.message}`); - } - return false; - } - - const hasActivity = await hasOpenClawActivity(author); - if (!hasActivity) { - console.log(`${author} has no openclaw/openclaw activity, passing`); - return; - } - - // --- Add openclaw label --- - console.log(`${author} has openclaw/openclaw activity, adding label`); - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: number, - labels: ['possibly-openclaw-clanker'], - }); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index a62b4afb5..669fd95b1 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -18,11 +18,13 @@ jobs: script: | const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; const VALID_CAPABILITIES = new Set(['issue', 'pr']); + const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']); const prAuthor = context.payload.pull_request.user.login; const defaultBranch = context.payload.repository.default_branch; + const isBotAuthor = prAuthor.endsWith('[bot]'); - if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') { - console.log(`Skipping bot: ${prAuthor}`); + if (TRUSTED_BOT_AUTHORS.has(prAuthor)) { + console.log(`Skipping trusted bot: ${prAuthor}`); return; } @@ -97,7 +99,7 @@ jobs: } const permission = await getPermission(prAuthor); - if (['admin', 'maintain', 'write'].includes(permission)) { + if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) { console.log(`${prAuthor} is a collaborator with ${permission} access`); return; } @@ -106,7 +108,7 @@ jobs: const approvedUsers = parseApprovedUsers(approvedContent); const capability = approvedUsers.get(prAuthor.toLowerCase()); - if (capability === 'pr') { + if (!isBotAuthor && capability === 'pr') { console.log(`${prAuthor} is approved for PRs`); return; } diff --git a/.pi/extensions/import-repro.ts b/.pi/extensions/import-repro.ts new file mode 100644 index 000000000..cb11ca049 --- /dev/null +++ b/.pi/extensions/import-repro.ts @@ -0,0 +1,351 @@ +/** + * Import a pi session shared as a gist by the issue-analysis CI workflow + * (.github/workflows/issue-analysis.yml) and switch to it. + * + * The CI job runs in a high-entropy checkout directory; this command rewrites + * the recorded cwd to the local checkout, installs the session file into the + * current session directory, and switches to it. + * + * Usage: + * /ir b4d100022aefb12f25dd2d8485e0a82a + * /ir https://gist.github.com/mitsuhiko/b4d100022aefb12f25dd2d8485e0a82a + * /ir https://pi.dev/session/#b4d100022aefb12f25dd2d8485e0a82a + * /ir https://github.com/earendil-works/pi/issues/123 + * + * pi "/ir " + */ + +import { Buffer } from "node:buffer"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, isAbsolute, join, resolve } from "node:path"; +import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; + +const GIST_ID_RE = /^[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 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 = /