diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS new file mode 100644 index 0000000000000000000000000000000000000000..4139119a361c83428d665d553ca23db9642afcc1 --- /dev/null +++ b/.github/APPROVED_CONTRIBUTORS @@ -0,0 +1,395 @@ +# GitHub handles approved to bypass contribution auto-close +# Format: +# capability: +# issue future issues stay open +# pr future issues and PRs stay open + +herrnel pr +julien-c pr +barapa pr +alasano pr +aadishv pr +airtonix pr +aliou pr +aos pr +austinm911 pr +banteg pr +ben-vargas pr +butelo pr +can1357 pr +CarlosGtrz pr +cau1k pr +cmf pr +crcatala pr +Cursivez pr +cv pr +dannote pr +default-anton pr +dnouri pr +DronNick pr +enisdenjo pr +ferologics pr +fightbulc pr +ghoulr pr +gnattu pr +HACKE-RC pr +hewliyang pr +hjanuschka pr +iamd3vil pr +jblwilliams pr +joshp123 pr +jsinge97 pr +justram pr +kaofelix pr +kiliman pr +kim0 pr +lockmeister pr +LukeFost pr +lukele pr +m-box-mr pr +marckrenn pr +markusylisiurunen pr +mcinteerj pr +melihmucuk pr +mitsuhiko pr +mrexodia pr +nathyong pr +nickseelert pr +nicobailon pr +ninlds pr +ogulcancelik pr +patrick-kidger pr +paulbettner pr +Perlence pr +pjtf93 pr +prateekmedia pr +prathamdby pr +ribelo pr +richardgill pr +robinwander pr +ronyrus pr +roshanasingh4 pr +scutifer pr +skuridin pr +steipete pr +svkozak pr +tallshort pr +theBucky pr +thomasmhr pr +tiagoefreitas pr +timolins pr +tmustier pr +tudoroancea pr +unexge pr +vaayne pr +VaclavSynacek pr +vsabavat pr +w-winter pr +Whamp pr +WismutHansen pr +XesGaDeus pr +yevhen pr +badlogictest pr +terrorobe pr +zedrdave pr +mrud pr +toorusr pr +andresaraujo pr +lightningRalf pr +williballenthin pr +masonc15 pr +4h9fbZ pr +haoqixu pr +Graffioh pr +charles-cooper pr +emanuelst pr +juanibiapina pr +liby pr +pasky pr +odysseus0 pr +giuseppeg pr +michaelpersonal pr +academo pr +PriNova pr +semtexzv pr +jasonish pr +markusn pr +SamFold pr +Soleone pr +virtuald pr +NateSmyth pr +7Sageer pr +MatthieuBizien pr +sumeet pr +marchellodev pr +vedang pr +lucemia pr +mcollina pr +lajarre pr +smithbm2316 pr +drewburr pr +gordonhwc pr +deybhayden pr +tintinweb pr +asoules pr +zhahaoyu pr +in0vik pr +jtac pr +yzhg1983 pr +smcllns pr +dmmulroy pr +zmberber pr +andresvi94 pr +sudosubin pr +Mic92 pr +pmateusz pr +wirjo pr +jay-aye-see-kay pr +lucasmeijer pr +Evizero pr + +ofa1 pr + +crisog issue + +mpazik pr + +vekexasia pr + +Michaelliv pr + +cmraible pr + +dljsjr pr + +drio pr + +jlaneve pr + +tantara pr + +Nutlope pr + +xl0 pr + +mdsjip pr + +Exrun94 pr + +marcbloech pr + +pidalf pr + +injaneity pr + +thirtythreeforty pr + +justinpbarnett pr + +cristinaponcela pr + +LooSik pr + +mchenco pr + +Phoen1xCode pr + +louis030195 pr + +technocidal pr + +pandada8 pr + +npupko issue + +chrisvariety pr + +maximilianzuern pr + +brianmichel pr + +abhinavmathur-atlan pr + +mattiacerutti pr + +josephyoung pr + +mbazso pr + +AJM10565 pr + +DanielThomas pr + +MichaelYochpaz pr + +stephanmck pr + +rolfvreijdenberger pr + +psoukie pr + +vastxie pr + +ItsumoSeito pr + +davidlifschitz pr + +vdxz pr + +dangooddd pr + +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 + +aaronkyriesenbach pr + +farid-fari pr + +petrroll pr + +vibeinging pr + +DivineDominion pr + +ananthakumaran pr + +andrebreijao pr + +anh-chu pr + +rsaryev pr + +QuintinShaw pr + +R-Taneja pr + +zaycruz pr + +mteam88 pr + +christianbasch pr + +cpacker pr + +rgarcia pr + +renaudhartert-db pr + +HyeokjaeLee pr + +arajkumar pr + +brianstanley pr + +scruffymongrel pr + +SI-RUI-ZHANG pr + +sunnyyoung pr + +muyiyr pr + +hi-neason pr + +acmerfight pr + +tizmagik pr + +jingtao-wisdomgraph pr + +XRX193 pr + +autopeasant pr + +Snail-Turbo pr + +futile pr + +arasovic pr + +xXJSONDeruloXx pr + +Marvae pr + +skkdevcraft pr + +PierrunoYT pr + +zhichli pr + +wesleyzhangwq pr + +dgokeeffe pr + +vipentti pr + +midastruth pr + +Maximo-Guk pr + +bigoldcat123 pr + +johnatbasicas pr + +powerfooI pr + +yearth pr + +pablasso pr + +bilby91 pr + +giannisCKS pr + +Panoplos pr + +haoyongchun1125-maker pr + +gwokhou pr + +gaoyk19 pr + +cad0p pr + +Jaaneek pr + +CaiJichang212 pr + +Mallikarjun-0 pr + +wutongyuonce pr + +Terminator666666 pr + +y-nk pr + +edverma pr + +Charlie0113-T pr + +RedBeard0531 pr + +metaist pr diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000000000000000000000000000000000000..b486675bde3d9e0e90a0940aa55105c015567d08 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,45 @@ +name: Bug Report +description: Report something that's broken +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + **Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md). + + New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. + + Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. + + **Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded. + + - type: textarea + id: description + attributes: + label: What happened? + description: Be specific. Include error messages if any. + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: Minimal steps to trigger the bug. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: false + + - type: input + id: version + attributes: + label: Version + description: e.g. 0.49.0 + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..66b6798c35a276194d69e435704f9649ffff22e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions + url: https://discord.com/invite/3cU7Bz4UPx + about: Ask questions on Discord instead of opening an issue diff --git a/.github/ISSUE_TEMPLATE/contribution.yml b/.github/ISSUE_TEMPLATE/contribution.yml new file mode 100644 index 0000000000000000000000000000000000000000..1a36a1a35b96c629fdb29033b32acb8db1890c75 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/contribution.yml @@ -0,0 +1,36 @@ +name: Contribution Proposal +description: Propose a change or feature (required for new contributors before submitting a PR) +labels: [] +body: + - type: markdown + attributes: + value: | + **Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md). + + New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. + + Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. + + - type: textarea + id: what + attributes: + label: What do you want to change? + description: Be specific and concise. + validations: + required: true + + - type: textarea + id: why + attributes: + label: Why? + description: What problem does this solve? + validations: + required: true + + - type: textarea + id: how + attributes: + label: How? (optional) + description: Brief technical approach if you have one in mind. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/package-report.yml b/.github/ISSUE_TEMPLATE/package-report.yml new file mode 100644 index 0000000000000000000000000000000000000000..dfd5c80d4723f5ebaf9ec828c5045dee965e1014 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-report.yml @@ -0,0 +1,49 @@ +name: Package Report +description: Report a problematic Pi package listed on pi.dev +labels: ["package-report"] +body: + - type: markdown + attributes: + value: | + Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead. + + New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. + + Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. + + - type: input + id: package-name + attributes: + label: Package name + description: The npm package name from pi.dev. + placeholder: "@scope/package" + validations: + required: true + + - type: input + id: package-version + attributes: + label: Version + description: The package version shown on pi.dev. + placeholder: "0.1.0" + validations: + required: false + + - type: dropdown + id: report-type + attributes: + label: What are you reporting? + options: + - Malicious or unsafe behavior + - Impersonation + - Trademark / TOS Violations + validations: + required: true + + - type: textarea + id: details + attributes: + label: Details + description: Describe the concern and include links, logs, or screenshots if helpful. + validations: + required: true diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml new file mode 100644 index 0000000000000000000000000000000000000000..debe87a72b8cdf1f15e9d51d8fec07f174760a96 --- /dev/null +++ b/.github/workflows/approve-contributor.yml @@ -0,0 +1,223 @@ +name: Approve Contributor + +on: + issue_comment: + types: [created] + +jobs: + approve: + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Update contributor approval + id: update + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + + const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS'; + const VALID_CAPABILITIES = new Set(['issue', 'pr']); + const issueAuthor = context.payload.issue.user.login; + const commenter = context.payload.comment.user.login; + const commentBody = (context.payload.comment.body || '').trim(); + + const approvalAtStartPattern = /^[\s.]*(?:@[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\s*,\s*|[.:]\s*|\s+))*(lgtmi|lgtm)(?=$|[\s]|[^\p{L}\p{N}_\s])/iu; + const approvalAtEndPattern = /(?:^|[\s.])(lgtmi|lgtm)\s*(?:[^\p{L}\p{N}_\s])?\s*$/iu; + const approvalMatch = commentBody.match(approvalAtStartPattern) ?? commentBody.match(approvalAtEndPattern); + + if (!approvalMatch) { + console.log('Comment does not start or end with lgtm or lgtmi'); + core.setOutput('status', 'skipped'); + return; + } + + const targetCapability = approvalMatch[1].toLowerCase() === 'lgtmi' ? 'issue' : 'pr'; + + try { + const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: commenter, + }); + + if (!['admin', 'maintain', 'write'].includes(permissionLevel.permission)) { + console.log(`${commenter} does not have write access`); + core.setOutput('status', 'skipped'); + return; + } + } catch { + console.log(`${commenter} does not have collaborator access`); + core.setOutput('status', 'skipped'); + return; + } + + function parseMentionedUsers(body) { + const users = []; + const seenUsers = new Set(); + const mentionPattern = /(^|[^A-Za-z0-9_])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)(?![A-Za-z0-9-]|\/)/g; + + for (const match of body.matchAll(mentionPattern)) { + const username = match[2]; + const normalizedUser = username.toLowerCase(); + if (seenUsers.has(normalizedUser)) { + continue; + } + seenUsers.add(normalizedUser); + users.push(username); + } + + return users; + } + + function parseApprovedUsers(content) { + const lines = content.split('\n'); + const entries = []; + const users = new Map(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + entries.push({ type: 'other', line }); + continue; + } + + const parts = trimmed.split(/\s+/); + if (parts.length !== 2) { + console.log(`Skipping malformed line: ${line}`); + entries.push({ type: 'other', line }); + continue; + } + + const [username, capability] = parts; + const normalizedCapability = capability.toLowerCase(); + if (!VALID_CAPABILITIES.has(normalizedCapability)) { + console.log(`Skipping line with invalid capability: ${line}`); + entries.push({ type: 'other', line }); + continue; + } + + const normalizedUser = username.toLowerCase(); + const entry = { type: 'user', username, normalizedUser, capability: normalizedCapability }; + entries.push(entry); + users.set(normalizedUser, entry); + } + + return { entries, users }; + } + + function stringifyApprovedUsers(entries) { + const normalizedEntries = [...entries]; + + while (normalizedEntries.length > 0) { + const lastEntry = normalizedEntries[normalizedEntries.length - 1]; + if (lastEntry.type !== 'other' || lastEntry.line.trim() !== '') { + break; + } + normalizedEntries.pop(); + } + + return `${normalizedEntries + .map((entry) => (entry.type === 'user' ? `${entry.username} ${entry.capability}` : entry.line)) + .join('\n')}\n`; + } + + const content = fs.readFileSync(APPROVED_FILE, 'utf8'); + const { entries, users } = parseApprovedUsers(content); + const mentionedUsers = parseMentionedUsers(commentBody); + const approvalTargets = mentionedUsers.length > 0 ? mentionedUsers : [issueAuthor]; + const changedTargets = []; + const alreadyTargets = []; + + for (const username of approvalTargets) { + const normalizedUser = username.toLowerCase(); + const existingEntry = users.get(normalizedUser); + const existingCapability = existingEntry?.capability ?? null; + + if (existingCapability === 'pr' || existingCapability === targetCapability) { + alreadyTargets.push(existingEntry?.username ?? username); + console.log(`${username} is already approved for ${existingCapability}`); + continue; + } + + if (existingEntry) { + existingEntry.capability = targetCapability; + changedTargets.push(existingEntry.username); + } else { + const entry = { type: 'user', username, normalizedUser, capability: targetCapability }; + entries.push(entry); + users.set(normalizedUser, entry); + changedTargets.push(username); + } + + console.log(`Set ${username} capability to ${targetCapability}`); + } + + core.setOutput('capability', targetCapability); + core.setOutput('changed_targets', JSON.stringify(changedTargets)); + core.setOutput('already_targets', JSON.stringify(alreadyTargets)); + + if (changedTargets.length === 0) { + core.setOutput('status', 'already'); + return; + } + + fs.writeFileSync(APPROVED_FILE, stringifyApprovedUsers(entries)); + core.setOutput('status', 'changed'); + + - name: Commit and push + if: steps.update.outputs.status == 'changed' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .github/APPROVED_CONTRIBUTORS + git diff --staged --quiet || git commit -m "chore: approve contributors from issue #${{ github.event.issue.number }}" + git push + + - name: Comment on issue + if: steps.update.outputs.status == 'changed' || steps.update.outputs.status == 'already' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + CAPABILITY: ${{ steps.update.outputs.capability }} + CHANGED_TARGETS: ${{ steps.update.outputs.changed_targets }} + ALREADY_TARGETS: ${{ steps.update.outputs.already_targets }} + with: + script: | + const capability = process.env.CAPABILITY; + const changedTargets = JSON.parse(process.env.CHANGED_TARGETS || '[]'); + const alreadyTargets = JSON.parse(process.env.ALREADY_TARGETS || '[]'); + const defaultBranch = context.payload.repository.default_branch; + const formatTargets = (targets) => targets.map((target) => `@${target}`).join(', '); + const bodyLines = []; + + if (changedTargets.length > 0) { + if (capability === 'issue') { + bodyLines.push(`${formatTargets(changedTargets)} approved for issues. Future issues will not be auto-closed. PRs still require \`lgtm\` at the start of a maintainer reply (optionally after one or more \`@username\` mentions) or at the end.`); + } else { + bodyLines.push(`${formatTargets(changedTargets)} approved for issues and PRs. Future issues and PRs will not be auto-closed.`); + } + } + + if (alreadyTargets.length > 0) { + const verb = alreadyTargets.length === 1 ? 'is' : 'are'; + bodyLines.push(`${formatTargets(alreadyTargets)} ${verb} already approved.`); + } + + bodyLines.push('', `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`); + const body = bodyLines.join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml new file mode 100644 index 0000000000000000000000000000000000000000..03f06609fe66df2f1f499f9f19b61a86b3bd7d99 --- /dev/null +++ b/.github/workflows/build-binaries.yml @@ -0,0 +1,444 @@ +name: Build Binaries + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to build (e.g., v0.12.0)' + required: true + type: string + source_ref: + description: 'Source ref to build/publish (defaults to tag; use only for release recovery)' + required: false + type: string + +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: 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 }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: Hydrate release model data + run: npm run hydrate:model-data + + - name: Create source archive + run: | + set -euo pipefail + + VERSION="${RELEASE_TAG#v}" + mkdir -p release-assets + ./scripts/create-source-archive.sh \ + --version "${VERSION}" \ + --ref HEAD \ + --out "release-assets/pi-${VERSION}-source.tar.gz" + + - name: Build binaries from source archive + run: | + set -euo pipefail + + VERSION="${RELEASE_TAG#v}" + build_root="$(mktemp -d)" + trap 'rm -rf "${build_root}"' EXIT + tar -xzf "release-assets/pi-${VERSION}-source.tar.gz" -C "${build_root}" + "${build_root}/pi-${VERSION}/scripts/build-binaries.sh" \ + --offline-model-data \ + --out "${GITHUB_WORKSPACE}/packages/coding-agent/binaries" + + - 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 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 + + 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" + source_asset="pi-${VERSION}-source.tar.gz" + release_assets=( + "${source_asset}" + 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 + + smoke-test-binaries: + runs-on: ${{ matrix.runner }} + needs: build + strategy: + fail-fast: false + matrix: + runner: + - ubuntu-latest + - macos-latest + - windows-latest + permissions: + actions: read + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - name: Download binary archives + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-assets-${{ env.RELEASE_TAG }} + path: release-assets + + - name: Extract current-platform binary + id: binary + shell: bash + run: | + set -euo pipefail + + runtime_platform="$(node -p '`${process.platform}-${process.arch}`')" + case "${runtime_platform}" in + darwin-arm64|darwin-x64|linux-arm64|linux-x64) + platform="${runtime_platform}" + archive="release-assets/pi-${platform}.tar.gz" + root="extracted/pi" + binary="${root}/pi" + ;; + win32-arm64) + platform="windows-arm64" + archive="release-assets/pi-${platform}.zip" + root="extracted" + binary="${root}/pi.exe" + ;; + win32-x64) + platform="windows-x64" + archive="release-assets/pi-${platform}.zip" + root="extracted" + binary="${root}/pi.exe" + ;; + *) + echo "::error::Unsupported runner platform ${runtime_platform}" + exit 1 + ;; + esac + + mkdir -p extracted + if [[ "${archive}" == *.zip ]]; then + export ARCHIVE_PATH="$(cygpath -w "${archive}")" + export DESTINATION_PATH="$(cygpath -w extracted)" + powershell.exe -NoProfile -NonInteractive -Command \ + 'Expand-Archive -LiteralPath $env:ARCHIVE_PATH -DestinationPath $env:DESTINATION_PATH -Force' + else + tar -xf "${archive}" -C extracted + fi + echo "root=${root}" >> "${GITHUB_OUTPUT}" + echo "binary=${binary}" >> "${GITHUB_OUTPUT}" + + - name: Smoke-test binary + shell: bash + run: | + "${{ steps.binary.outputs.binary }}" --help + "${{ steps.binary.outputs.binary }}" --version + + stage-github-release: + runs-on: ubuntu-latest + needs: + - build + - smoke-test-binaries + 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 + + VERSION="${RELEASE_TAG#v}" + source_asset="pi-${VERSION}-source.tar.gz" + expected_assets=( + "${source_asset}" + 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 assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + cd release-assets + + VERSION="${RELEASE_TAG#v}" + source_asset="pi-${VERSION}-source.tar.gz" + release_assets=( + "${source_asset}" + 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 + ) + + 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: stage-github-release + environment: npm-publish + permissions: + contents: read + id-token: write + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + cache: npm + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep + sudo ln -s $(which fdfind) /usr/local/bin/fd + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Build + run: npm run build + + - name: Check + run: npm run check + + - name: Test + run: npm test + + - name: Smoke-test packed npm consumer + run: npm run check:package-install + + - name: Upgrade npm for trusted publishing + run: | + npm install -g npm@11.16.0 --ignore-scripts + npm --version + + - name: Publish npm packages + run: node scripts/publish.mjs + + announce-pi-dev-release: + runs-on: ubuntu-latest + needs: publish-npm + environment: pi-model-upload + concurrency: + group: announce-pi-dev-release + cancel-in-progress: false + permissions: + 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 }} + AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: 'true' + R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Verify AWS CLI + run: aws --version + + - name: Announce verified release on pi.dev + run: | + node scripts/publish-release-announcement.mjs \ + --version "${RELEASE_TAG#v}" \ + --bucket pi-artifacts \ + --endpoint "$R2_ENDPOINT" \ + --installer-package-json packages/coding-agent/install-lock/package.json \ + --installer-package-lock packages/coding-agent/install-lock/package-lock.json + + publish-github-release: + runs-on: ubuntu-latest + needs: + - stage-github-release + - publish-npm + - announce-pi-dev-release + 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 + - announce-pi-dev-release + - publish-github-release + if: ${{ always() && needs.stage-github-release.result != 'skipped' && (needs.stage-github-release.result != 'success' || needs.publish-npm.result != 'success' || needs.announce-pi-dev-release.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/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..ce0cfef9e29a3453143595c20896544f22d2fb11 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-check-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep + sudo ln -s $(which fdfind) /usr/local/bin/fd + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Build + run: npm run build + + - name: Check + run: npm run check + + - name: Test + run: npm test diff --git a/.github/workflows/issue-analysis.yml b/.github/workflows/issue-analysis.yml new file mode 100644 index 0000000000000000000000000000000000000000..881907608456eb0ef377275073162d69b5785e98 --- /dev/null +++ b/.github/workflows/issue-analysis.yml @@ -0,0 +1,688 @@ +# 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. Fine-grained +# tokens need repository permission `Environments: read and write` on this +# repo; classic tokens need the `repo` scope. The `Secrets` permission is +# not enough: it only covers repository-level Actions secrets, while every +# `/repos/{owner}/{repo}/environments/{env}/secrets/*` endpoint is governed +# by `Environments`. Without it `gh secret set` fails with "Resource not +# accessible by personal access token" and the stored credential goes stale +# until its refresh token is rejected. +# +# `PI_AUTH_JSON` must come from a login dedicated to this workflow. OpenAI Codex +# rotates refresh tokens on every refresh, so sharing one auth.json with a +# developer machine invalidates whichever copy refreshes second, and analysis +# then fails with "OAuth refresh failed for openai-codex". +# +# 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const crypto = require('crypto'); + core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`); + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: ${{ steps.workdir.outputs.name }} + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + 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 + id: run_pi + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.PI_AUTH_UPDATE_TOKEN }} + PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }} + 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'); + } + + if (authJson === process.env.PI_AUTH_JSON) { + console.log('auth.json is unchanged; skipping secret update'); + return; + } + + 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', 'pipe'] }, + ); + const stderrChunks = []; + child.stderr.on('data', (chunk) => { + stderrChunks.push(chunk); + process.stderr.write(chunk); + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + const details = Buffer.concat(stderrChunks).toString().trim(); + reject( + new Error( + `gh secret set failed with exit code ${code}${details ? `: ${details}` : ''}. ` + + 'PI_AUTH_UPDATE_TOKEN needs repository permission "Environments: read and write" on this repo.', + ), + ); + }); + child.stdin.end(authJson); + }); + + - name: Export session files + id: export_session_files + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + 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.run_pi.outcome == 'success' && steps.gist.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + 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: Comment on analysis failure + if: always() && steps.run_pi.outcome == 'failure' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GIST_URL: ${{ steps.gist.outputs.url }} + SHARE_URL: ${{ steps.gist.outputs.share_url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + script: | + const lines = ['Pi issue analysis failed; no analysis was produced.', '', `Run: ${process.env.RUN_URL}`]; + if (process.env.SHARE_URL) { + lines.push(`Partial session: ${process.env.SHARE_URL}`); + } + if (process.env.GIST_URL) { + lines.push(`Gist: ${process.env.GIST_URL}`); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: lines.join('\n'), + }); + + - name: Remove trigger label + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + 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 new file mode 100644 index 0000000000000000000000000000000000000000..03372acaead4a5ae022e39e0d3b6a524f9036701 --- /dev/null +++ b/.github/workflows/issue-gate.yml @@ -0,0 +1,129 @@ +name: Issue Gate + +on: + issues: + types: [opened] + +jobs: + check-contributor: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Check issue author + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + 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 (TRUSTED_BOT_AUTHORS.has(issueAuthor)) { + console.log(`Skipping trusted bot: ${issueAuthor}`); + return; + } + + async function getPermission(username) { + try { + const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + return permissionLevel.permission; + } catch { + return null; + } + } + + async function getTextFile(path) { + const { data: fileContent } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path, + ref: defaultBranch, + }); + + if (!('content' in fileContent) || typeof fileContent.content !== 'string') { + throw new Error(`Expected file content for ${path}`); + } + + return Buffer.from(fileContent.content, 'base64').toString('utf8'); + } + + function parseApprovedUsers(content) { + const users = 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) { + console.log(`Skipping malformed line: ${rawLine}`); + continue; + } + + const [username, capability] = parts; + const normalizedCapability = capability.toLowerCase(); + if (!VALID_CAPABILITIES.has(normalizedCapability)) { + console.log(`Skipping line with invalid capability: ${rawLine}`); + continue; + } + + users.set(username.toLowerCase(), normalizedCapability); + } + + return users; + } + + const permission = await getPermission(issueAuthor); + if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) { + console.log(`${issueAuthor} is a collaborator with ${permission} access`); + return; + } + + const approvedContent = await getTextFile(APPROVED_FILE); + const approvedUsers = parseApprovedUsers(approvedContent); + const capability = approvedUsers.get(issueAuthor.toLowerCase()); + + if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) { + console.log(`${issueAuthor} is approved for ${capability}`); + return; + } + + const message = [ + 'This issue was auto-closed. All issues from new contributors are auto-closed by default.', + '', + `Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`, + '', + 'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.', + '', + `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`, + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: message, + }); + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['untriaged'], + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'closed', + state_reason: 'not_planned', + }); diff --git a/.github/workflows/issue-triage-labels.yml b/.github/workflows/issue-triage-labels.yml new file mode 100644 index 0000000000000000000000000000000000000000..1a44253f51b5670527325c6cdf418216145e9bbb --- /dev/null +++ b/.github/workflows/issue-triage-labels.yml @@ -0,0 +1,142 @@ +name: Issue Triage Labels + +on: + issues: + types: [reopened, labeled] + +jobs: + update-labels: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Update triage labels + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const UNTRIAGED_LABEL = 'untriaged'; + const NO_ACTION_LABEL = 'no-action'; + const LAST_READ_LABEL = 'last-read'; + const TO_DISCUSS_LABEL = 'to-discuss'; + const INPROGRESS_LABEL = 'inprogress'; + + function issueHasLabel(issue, labelName) { + return (issue.labels ?? []).some((label) => label.name === labelName); + } + + async function removeLabelIfPresent(issueNumber, issue, labelName) { + if (!issueHasLabel(issue, labelName)) { + console.log(`Issue #${issueNumber} does not have ${labelName}`); + return; + } + + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: labelName, + }); + console.log(`Removed ${labelName} from #${issueNumber}`); + } catch (error) { + if (error.status === 404) { + console.log(`Label ${labelName} was already absent from #${issueNumber}`); + return; + } + throw error; + } + } + + if (context.payload.action === 'reopened') { + await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL); + await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL); + return; + } + + if (context.payload.action === 'labeled' && context.payload.label?.name === NO_ACTION_LABEL) { + await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL); + return; + } + + if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) { + console.log('Not a last-read label event'); + return; + } + + const currentIssueNumber = context.issue.number; + const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: LAST_READ_LABEL, + per_page: 100, + }); + + const previousIssueNumbers = lastReadIssues + .filter((issue) => !issue.pull_request) + .map((issue) => issue.number) + .filter((issueNumber) => issueNumber !== currentIssueNumber); + + if (previousIssueNumbers.length === 0) { + console.log('No previous last-read issue found'); + return; + } + + const previousIssueNumber = Math.max(...previousIssueNumbers); + if (currentIssueNumber <= previousIssueNumber) { + console.log( + `Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`, + ); + return; + } + + const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + labels: UNTRIAGED_LABEL, + per_page: 100, + }); + + const issuesToMark = untriagedIssues + .filter((issue) => !issue.pull_request) + .filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber) + .sort((a, b) => a.number - b.number); + + if (issuesToMark.length === 0) { + console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`); + return; + } + + for (const issue of issuesToMark) { + if (issueHasLabel(issue, TO_DISCUSS_LABEL)) { + console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`); + } else { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [NO_ACTION_LABEL], + }); + console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`); + } + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); + console.log(`Closed #${issue.number} as not planned`); + + await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL); + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: UNTRIAGED_LABEL, + }); + console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`); + } diff --git a/.github/workflows/npm-audit.yml b/.github/workflows/npm-audit.yml new file mode 100644 index 0000000000000000000000000000000000000000..bca7b0e57b834c84d36323a16861bc339191125c --- /dev/null +++ b/.github/workflows/npm-audit.yml @@ -0,0 +1,31 @@ +name: npm audit + +on: + schedule: + - cron: '37 7 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies without lifecycle scripts + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Audit production vulnerabilities + run: npm audit --omit=dev --audit-level=moderate + + - name: Verify registry signatures + run: npm audit signatures --omit=dev diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 0000000000000000000000000000000000000000..bc6f7eef11d86b8f6afae61de3179852507937d8 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,128 @@ +name: PR Gate + +on: + pull_request_target: + types: [opened] + +jobs: + check-contributor: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Check if contributor is approved + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + 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 (TRUSTED_BOT_AUTHORS.has(prAuthor)) { + console.log(`Skipping trusted bot: ${prAuthor}`); + return; + } + + async function getPermission(username) { + try { + const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + return permissionLevel.permission; + } catch { + return null; + } + } + + async function getTextFile(path) { + const { data: fileContent } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path, + ref: defaultBranch, + }); + + if (!('content' in fileContent) || typeof fileContent.content !== 'string') { + throw new Error(`Expected file content for ${path}`); + } + + return Buffer.from(fileContent.content, 'base64').toString('utf8'); + } + + function parseApprovedUsers(content) { + const users = 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) { + console.log(`Skipping malformed line: ${rawLine}`); + continue; + } + + const [username, capability] = parts; + const normalizedCapability = capability.toLowerCase(); + if (!VALID_CAPABILITIES.has(normalizedCapability)) { + console.log(`Skipping line with invalid capability: ${rawLine}`); + continue; + } + + users.set(username.toLowerCase(), normalizedCapability); + } + + return users; + } + + async function closePullRequest(message) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: message, + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + state: 'closed', + }); + } + + const permission = await getPermission(prAuthor); + if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) { + console.log(`${prAuthor} is a collaborator with ${permission} access`); + return; + } + + const approvedContent = await getTextFile(APPROVED_FILE); + const approvedUsers = parseApprovedUsers(approvedContent); + const capability = approvedUsers.get(prAuthor.toLowerCase()); + + if (!isBotAuthor && capability === 'pr') { + console.log(`${prAuthor} is approved for PRs`); + return; + } + + console.log(`${prAuthor} is not approved, closing PR`); + + const message = [ + 'This PR was auto-closed. Only contributors approved with `lgtm` can open PRs. Open an issue first and ask a maintainer for approval.', + '', + `Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`, + '', + 'If a maintainer replies `lgtmi`, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.', + '', + `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`, + ].join('\n'); + + await closePullRequest(message); diff --git a/.github/workflows/publish-model-catalog.yml b/.github/workflows/publish-model-catalog.yml new file mode 100644 index 0000000000000000000000000000000000000000..729defbe70ee6f8b50bfea60b0a880ab3f3dc2f2 --- /dev/null +++ b/.github/workflows/publish-model-catalog.yml @@ -0,0 +1,146 @@ +name: Publish Model Catalog + +on: + workflow_run: + workflows: + - CI + types: + - completed + pull_request: + paths: + - '.github/workflows/publish-model-catalog.yml' + - '.gitignore' + - 'package.json' + - 'packages/ai/**' + - 'scripts/publish-model-catalog.mjs' + # GitHub schedules use UTC. Run hourly candidates across the CET/CEST + # boundaries; the publish job only uploads at 10:17, 12:17, and 14:17 + # Europe/Vienna time. + schedule: + - cron: '17 8-13 * * 1-5' + workflow_dispatch: + inputs: + source_ref: + description: 'Commit, branch, or tag to generate from' + required: false + default: 'main' + type: string + publish: + description: 'Upload the generated catalog to production R2' + required: true + default: false + type: boolean + +permissions: + contents: read + +jobs: + generate: + if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }} + runs-on: ubuntu-latest + env: + SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Generate model catalog JSON + run: npm run generate:model-catalog + + - name: Validate model catalog JSON + run: npm run check:model-catalog + + - name: Upload model catalog JSON + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: model-catalog-json + path: .artifacts/model-catalog + if-no-files-found: error + retention-days: 14 + + publish: + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && inputs.publish) }} + needs: generate + runs-on: ubuntu-latest + environment: pi-model-upload + concurrency: + group: publish-model-catalog-r2 + cancel-in-progress: true + env: + SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }} + AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: 'true' + R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Download model catalog JSON + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: model-catalog-json + path: .artifacts/model-catalog + + - name: Verify AWS CLI + run: aws --version + + - name: Check publication window + id: publication-window + env: + EVENT_NAME: ${{ github.event_name }} + MANUAL_PUBLISH: ${{ inputs.publish || false }} + run: | + set -euo pipefail + + local_day="$(TZ=Europe/Vienna date +%u)" + local_hour_text="$(TZ=Europe/Vienna date +%H)" + local_hour="$((10#$local_hour_text))" + local_time="$(TZ=Europe/Vienna date '+%Y-%m-%d %H:%M:%S %Z')" + allowed=false + reason="outside the Monday-Friday 10:00-15:00 Europe/Vienna publication window" + + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$MANUAL_PUBLISH" == "true" ]]; then + allowed=true + reason="manual publication" + elif (( local_day <= 5 && local_hour >= 10 && local_hour < 15 )); then + if [[ "$EVENT_NAME" != "schedule" ]] || (( (local_hour - 10) % 2 == 0 )); then + allowed=true + reason="business-hours publication" + else + reason="not a scheduled 10:17, 12:17, or 14:17 Europe/Vienna publication" + fi + fi + + echo "allowed=$allowed" >> "$GITHUB_OUTPUT" + echo "R2 publication allowed: $allowed ($reason; local time: $local_time)" + + - name: Publish model catalog to R2 + if: steps.publication-window.outputs.allowed == 'true' + run: | + node scripts/publish-model-catalog.mjs \ + --input .artifacts/model-catalog \ + --bucket pi-artifacts \ + --endpoint "$R2_ENDPOINT" \ + --source-commit "$(git rev-parse HEAD)" diff --git a/.github/workflows/remove-inprogress-on-close.yml b/.github/workflows/remove-inprogress-on-close.yml new file mode 100644 index 0000000000000000000000000000000000000000..4904067e9cc3adce5fb49fc73706f0aeb6ffb44e --- /dev/null +++ b/.github/workflows/remove-inprogress-on-close.yml @@ -0,0 +1,31 @@ +name: Remove In Progress Label On Close + +on: + issues: + types: [closed] + +jobs: + remove-label: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Remove inprogress label + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const labelName = 'inprogress'; + const labels = context.payload.issue.labels ?? []; + const hasLabel = labels.some((label) => label.name === labelName); + + if (!hasLabel) { + console.log(`Issue does not have ${labelName} label`); + return; + } + + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: labelName, + }); diff --git a/.pi/extensions/import-repro.ts b/.pi/extensions/import-repro.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb11ca049b09a63002c54e50eb71158950da1cc8 --- /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 = / + + + +
+

Pi Tool Stats

+

${data.files} session files from ${sessionsDir}. Generated ${data.generatedAt}.

+
+

Estimated result tokens by tool

+

Tool calls

+
+
+
+
+

Tool result token histogram

+ +
+

+ +
+
+
+

Bash result token histogram

+ +
+

+ +
+
+

Tools

+
+

Bash common commands (best effort)

+
+
+
+ + +`; + +mkdirSync(resolve(output, ".."), { recursive: true }); +writeFileSync(output, html); +console.log(`Wrote ${output}`); +openBrowser(output); diff --git a/scripts/update-source-imports-to-ts.sh b/scripts/update-source-imports-to-ts.sh new file mode 100644 index 0000000000000000000000000000000000000000..6030e41d5a05c5f738af6652bb28d1c1466e14d7 --- /dev/null +++ b/scripts/update-source-imports-to-ts.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Rewrites relative source import specifiers in package source directories from .js to .ts. +# TypeScript's rewriteRelativeImportExtensions option rewrites these back to .js in emitted output. +find packages -mindepth 2 -maxdepth 2 -type d -name src -print0 | + while IFS= read -r -d '' src_dir; do + find "$src_dir" -type f -name '*.ts' -print0 + done | + xargs -0 perl -0pi -e 's/(\b(?:from|import)\b\s*\(?\s*["\x27])(\.{1,2}\/[^"\x27\r\n]+)\.js(["\x27]\s*\)?)/$1$2.ts$3/g; s/(\bdeclare\s+module\s+["\x27])(\.{1,2}\/[^"\x27\r\n]+)\.js(["\x27])/$1$2.ts$3/g; s/(\bimportNodeOnlyProvider\(\s*["\x27])(\.{1,2}\/[^"\x27\r\n]+)\.js(["\x27]\s*\))/$1$2.ts$3/g'