From 2a9c40acb23433bf7c2046d8d2f745a63bfa13fd Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 2 Aug 2026 22:50:26 -0700 Subject: [PATCH] chore: regenerate tend workflows with 0.1.13 --- .github/workflows/tend-ci-fix.yaml | 5 +- .github/workflows/tend-mention.yaml | 251 ++++++++++++++++------ .github/workflows/tend-nightly.yaml | 5 +- .github/workflows/tend-notifications.yaml | 50 ++++- .github/workflows/tend-review-runs.yaml | 5 +- .github/workflows/tend-review.yaml | 19 +- .github/workflows/tend-triage.yaml | 5 +- .github/workflows/tend-weekly.yaml | 5 +- 8 files changed, 263 insertions(+), 82 deletions(-) diff --git a/.github/workflows/tend-ci-fix.yaml b/.github/workflows/tend-ci-fix.yaml index be674c5769ed..0b48d1e0e76d 100644 --- a/.github/workflows/tend-ci-fix.yaml +++ b/.github/workflows/tend-ci-fix.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.11. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.13. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -17,6 +17,7 @@ jobs: fix-ci: if: github.repository_owner == 'PRQL' && github.event.workflow_run.conclusion == 'failure' runs-on: ubuntu-24.04 + environment: tend permissions: contents: write pull-requests: write @@ -32,7 +33,7 @@ jobs: - uses: ./.github/actions/tend-setup - - uses: max-sixty/tend/claude@0.1.11 + - uses: max-sixty/tend/claude@0.1.13 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/tend-mention.yaml b/.github/workflows/tend-mention.yaml index 49d5620cae13..9ba1f7c3650c 100644 --- a/.github/workflows/tend-mention.yaml +++ b/.github/workflows/tend-mention.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.11. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.13. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -12,7 +12,11 @@ on: types: [edited] issue_comment: types: [created, edited] - # Works for same-repo PRs only; secrets unavailable on fork PRs (no _target variant exists) + # The review events reach only the `relay` job below: their runs carry + # `refs/pull/N/merge`, which the operational secrets' environment does not + # admit, so the secret-bearing jobs see them re-entered as a + # `repository_dispatch` instead. Same-repo PRs only — the notifications poll + # covers fork PRs. pull_request_review: types: [submitted] # `created` is intentionally absent. Modern GitHub fires *both* @@ -20,8 +24,8 @@ on: # inline comment (the standalone POST /pulls/PR/comments endpoint, the # /replies endpoint, the "Add single comment" UI button, and reviews # submitted with inline comments — all empirically verified). Subscribing to - # `created` would produce a duplicate workflow run that collides on the - # tend-mention-handle-PR concurrency group, with the loser cancelled and + # `created` would produce a duplicate dispatch whose handle runs collide on + # the tend-mention-handle-PR concurrency group, with the loser cancelled and # posted as a CANCELLED check_run on the PR head SHA — which renders the # PR's statusCheckRollup as FAILURE even though the bot did its job from the # sibling run. Edits have no sibling event (review submissions don't fire on @@ -29,35 +33,147 @@ on: # ("@bot" added to an existing comment after the fact). pull_request_review_comment: types: [edited] + # The relay's re-entry point. GitHub creates this run even though a + # `GITHUB_TOKEN` triggered it — `workflow_dispatch` and `repository_dispatch` + # are the two events exempt from the rule that token-triggered events start + # no workflow — and it carries the default branch, which the environment + # admits. + repository_dispatch: + types: [tend-mention-review] jobs: + # The secretless half of the review path: move the event onto a ref the + # environment admits, and nothing else. The merge ref itself can never be + # admitted — a same-repo `pull_request` run executes the PR head's own + # workflow files on that same ref, so admitting it would hand a pushed + # workflow the secrets the environment exists to deny. All judgement lives + # in `verify`, which re-reads the review or comment from the API, so a + # skipped event costs one extra short run rather than a second copy of the + # heuristics here. + relay: + # Fork PRs are excluded to hold long-standing behaviour: the notifications + # poll covers them, and it applies author-association tiers that this path + # does not. What the filter buys is that no fork head is checked out into a + # secret-bearing run; it is not an authorship gate, since a mention from + # any user with read access already wakes `handle` on a same-repo PR. + # Widening this is a separate decision, and moot while a fork run's token + # is refused the dispatch POST (403, probed). A dispatched run fails the + # event check, so the relay cannot re-enter itself. + if: | + (github.event_name == 'pull_request_review' || + github.event_name == 'pull_request_review_comment') && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + # No secrets here. `contents: write` is what the dispatch POST needs, and + # is the whole of what this token can do — probed both ways on a live + # repo: an otherwise identical same-repo run declaring `contents: read` + # is refused with 403, so a narrower token would leave every review + # mention unanswered rather than merely unprivileged. It grants nobody a + # new capability: only someone who can already push a branch can open the + # same-repo PR whose workflow file this is, and they could declare any + # permissions they liked in it. The merge restriction is what bounds that, + # here as everywhere. + permissions: + contents: write + steps: + # Identifiers only: `verify` re-reads the review or comment from the + # API, so the words the bot weighs and acts on are the ones GitHub + # holds, and a forged dispatch faces the same scrutiny as a relayed one. + - name: Re-enter on an admitted ref + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api "repos/$GITHUB_REPOSITORY/dispatches" \ + -f event_type=tend-mention-review \ + -f "client_payload[kind]=${{ github.event_name }}" \ + -f "client_payload[pr]=${{ github.event.pull_request.number }}" \ + -f "client_payload[id]=${{ github.event.review.id || github.event.comment.id }}" + verify: - # Filter out fork PRs for review events — secrets are unavailable there - # (no _target variant exists). The notifications workflow polls for these. # Skip comments on `tend-outage` issues: the action's Report-failure step # auto-comments on those when Claude invocation fails, and without this # guard those comments re-trigger tend-mention during a persistent outage # (e.g. Anthropic 401), producing a self-sustaining ~1 run/minute loop # until the outage clears. The prompt's self-loop guard can't help here # because the model never executes — the action fails before Claude starts. + # A relayed review enters as `repository_dispatch` and is judged in the + # check step below, against the record the API holds. if: | + github.event_name == 'repository_dispatch' || (github.event_name == 'issues' && contains(github.event.issue.body, '@prql-bot')) || (github.event_name == 'issue_comment' && - !contains(github.event.issue.labels.*.name, 'tend-outage')) || - (github.event_name == 'pull_request_review_comment' && - github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'pull_request_review' && - github.event.pull_request.head.repo.full_name == github.repository) + !contains(github.event.issue.labels.*.name, 'tend-outage')) runs-on: ubuntu-24.04 + environment: tend outputs: should_run: ${{ steps.check.outputs.should_run }} + reason: ${{ steps.check.outputs.reason }} + url: ${{ steps.check.outputs.url }} + ts: ${{ steps.check.outputs.ts }} steps: - name: Verify bot engagement id: check run: | + # A relayed review event arrives as identifiers only ({kind, pr, + # id}): resolve them against the API before judging anything, so the + # words weighed below are the ones GitHub holds rather than whatever + # the payload carried. Any write-scoped actor can POST a dispatch, + # so a forged payload faces the same checks a real event does — and + # fetching by PR and id binds the two, so a payload pairing a real + # review with some other PR dies here instead of steering the + # handle job. The ids are spliced into API paths here and into the + # prompt later, so reject anything but digits at this edge. + KIND="$EVENT_NAME" + if [ "$EVENT_NAME" = "repository_dispatch" ]; then + KIND="$PAYLOAD_KIND" + if ! [[ "$PAYLOAD_PR" =~ ^[0-9]+$ && "$PAYLOAD_ID" =~ ^[0-9]+$ ]]; then + echo "malformed dispatch payload — skipping" + echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + case "$KIND" in + pull_request_review) + if ! REVIEW=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PAYLOAD_PR/reviews/$PAYLOAD_ID"); then + echo "review $PAYLOAD_ID not found on PR $PAYLOAD_PR — skipping" + echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + REVIEW_AUTHOR=$(echo "$REVIEW" | jq -r '.user.login') + # REST reports the state uppercase (a webhook payload's is + # lowercase); normalize so the terminal-approval gate below + # reads one shape. + REVIEW_STATE=$(echo "$REVIEW" | jq -r '.state | ascii_downcase') + COMMENT_BODY=$(echo "$REVIEW" | jq -r '.body // ""') + echo "url=$(echo "$REVIEW" | jq -r '.html_url')" >> "$GITHUB_OUTPUT" + # A review without `submitted_at` (a PENDING one, which only a + # forged dispatch can name) would otherwise write the string + # `null`, which handle's `date -d` rejects — failing the job + # red where the empty-value guard would have skipped it. + echo "ts=$(echo "$REVIEW" | jq -r '.submitted_at // empty')" >> "$GITHUB_OUTPUT" + ;; + pull_request_review_comment) + if ! COMMENT=$(gh api "repos/$GITHUB_REPOSITORY/pulls/comments/$PAYLOAD_ID"); then + echo "comment $PAYLOAD_ID not found — skipping" + echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + # Comments fetch by id alone, so bind the PR explicitly. + if [ "$(echo "$COMMENT" | jq -r '.pull_request_url')" != "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls/$PAYLOAD_PR" ]; then + echo "comment $PAYLOAD_ID does not belong to PR $PAYLOAD_PR — skipping" + echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + COMMENT_AUTHOR=$(echo "$COMMENT" | jq -r '.user.login') + COMMENT_BODY=$(echo "$COMMENT" | jq -r '.body // ""') + echo "url=$(echo "$COMMENT" | jq -r '.html_url')" >> "$GITHUB_OUTPUT" + echo "ts=$(echo "$COMMENT" | jq -r '.updated_at')" >> "$GITHUB_OUTPUT" + ;; + *) + echo "unknown dispatch kind '$KIND' — skipping" + echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0 + ;; + esac + fi + # Mentions always run - if [ "$EVENT_NAME" = "issues" ]; then + if [ "$KIND" = "issues" ]; then echo "should_run=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -71,15 +187,15 @@ jobs: # self-summons: a bot comment that quotes a prior @-mention would # re-match the mention check below and escape an after-placed guard, # so this runs *before* the mention check. Covers issue_comment and - # pull_request_review_comment (both carry comment.user.login). Fires - # on, e.g., the monthly tracking-issue rollover where the bot posts - # evidence-gist links on its own issue. Scoped to comments on - # purpose: the pull_request_review *submission* event is deliberately - # left out, since a review the bot leaves on its own PR is its - # reviewer role (the prompt is told to action it), not a self-loop. - # The only self-review skip is the terminal empty-body APPROVED gate - # below. - if { [ "$EVENT_NAME" = "issue_comment" ] || [ "$EVENT_NAME" = "pull_request_review_comment" ]; } \ + # pull_request_review_comment (the latter resolved from the dispatch + # above). Fires on, e.g., the monthly tracking-issue rollover where + # the bot posts evidence-gist links on its own issue. Scoped to + # comments on purpose: the pull_request_review *submission* kind is + # deliberately left out, since a review the bot leaves on its own PR + # is its reviewer role (the prompt is told to action it), not a + # self-loop. The only self-review skip is the terminal empty-body + # APPROVED gate below. + if { [ "$KIND" = "issue_comment" ] || [ "$KIND" = "pull_request_review_comment" ]; } \ && [ "$COMMENT_AUTHOR" = "prql-bot" ]; then echo "should_run=false" >> "$GITHUB_OUTPUT" exit 0 @@ -87,6 +203,7 @@ jobs: if [ -n "$COMMENT_BODY" ] && printf '%s\n' "$COMMENT_BODY" | grep -qF '@prql-bot'; then echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "reason=mention" >> "$GITHUB_OUTPUT" exit 0 fi @@ -98,7 +215,7 @@ jobs: # this blanket skip with content-aware filtering (only drop comments # that have no actionable signal), so we don't miss bot comments that # actually warrant a response. - if [ "$EVENT_NAME" = "issue_comment" ] && [ "$COMMENT_AUTHOR_TYPE" = "Bot" ]; then + if [ "$KIND" = "issue_comment" ] && [ "$COMMENT_AUTHOR_TYPE" = "Bot" ]; then echo "should_run=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -110,10 +227,9 @@ jobs: # bot CHANGES_REQUESTED/COMMENTED review keeps actionable signal) and # an empty *body* (an approval carrying nits in its body may warrant # follow-up changes, so let it fire). Together these preserve #166's - # actionable bot self-reviews. review.state arrives lowercase in the - # webhook payload (REST returns uppercase), hence "approved". - # COMMENT_BODY holds review.body for a pull_request_review event. - if [ "$EVENT_NAME" = "pull_request_review" ] \ + # actionable bot self-reviews. COMMENT_BODY holds the review body + # for a review kind. + if [ "$KIND" = "pull_request_review" ] \ && [ "$REVIEW_AUTHOR" = "prql-bot" ] \ && [ "$REVIEW_STATE" = "approved" ] \ && [ -z "$COMMENT_BODY" ]; then @@ -121,20 +237,21 @@ jobs: exit 0 fi - # pull_request_review payloads include review.body (checked above) - # but NOT the bodies of inline comments attached to the review. - # Fetch them so a first-contact @-mention inside an inline comment - # is detected on PRs where the bot has no prior engagement. - if [ "$EVENT_NAME" = "pull_request_review" ] && [ -n "$REVIEW_ID" ]; then - if gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$EVENT_PR_NUMBER/reviews/$REVIEW_ID/comments" \ + # A review's record includes review.body (checked above) but NOT the + # bodies of the inline comments attached to the review. Fetch them + # so a first-contact @-mention inside an inline comment is detected + # on PRs where the bot has no prior engagement. + if [ "$KIND" = "pull_request_review" ]; then + if gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PAYLOAD_PR/reviews/$PAYLOAD_ID/comments" \ --jq '.[].body' | grep -qF '@prql-bot'; then echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "reason=mention" >> "$GITHUB_OUTPUT" exit 0 fi fi # Non-mention: check bot engagement - if [ "$EVENT_NAME" = "issue_comment" ]; then + if [ "$KIND" = "issue_comment" ]; then ISSUE_NUMBER="$ISSUE_OR_PR_NUMBER" if [ -z "$PR_URL" ]; then @@ -154,62 +271,70 @@ jobs: PR_NUMBER="$ISSUE_NUMBER" else - PR_NUMBER="$EVENT_PR_NUMBER" + PR_NUMBER="$PAYLOAD_PR" fi PR_AUTHOR=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json author --jq '.author.login') if [ "$PR_AUTHOR" = "prql-bot" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT"; exit 0 + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0 fi BOT_REVIEWS=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" \ --jq '[.[] | select(.user.login == "prql-bot")] | length') if [ "$BOT_REVIEWS" -gt "0" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT"; exit 0 + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0 fi BOT_COMMENTS=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \ --jq '[.[] | select(.user.login == "prql-bot")] | length') if [ "$BOT_COMMENTS" -gt "0" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT"; exit 0 + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0 fi echo "should_run=false" >> "$GITHUB_OUTPUT" env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} EVENT_NAME: ${{ github.event_name }} - COMMENT_BODY: ${{ github.event.comment.body || github.event.review.body }} + COMMENT_BODY: ${{ github.event.comment.body }} COMMENT_AUTHOR: ${{ github.event.comment.user.login }} COMMENT_AUTHOR_TYPE: ${{ github.event.comment.user.type }} ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_OR_PR_NUMBER: ${{ github.event.issue.number }} ISSUE_AUTHOR: ${{ github.event.issue.user.login }} PR_URL: ${{ github.event.issue.pull_request.url }} - EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} - REVIEW_ID: ${{ github.event.review.id }} - REVIEW_AUTHOR: ${{ github.event.review.user.login }} - REVIEW_STATE: ${{ github.event.review.state }} + PAYLOAD_KIND: ${{ github.event.client_payload.kind }} + PAYLOAD_PR: ${{ github.event.client_payload.pr }} + PAYLOAD_ID: ${{ github.event.client_payload.id }} + # The dispatch arm covers a relayed inline comment, whose id the check + # step verified belongs to this PR; a review *submission* has no single + # comment to react to, so it gets no eyes — same as when the events + # arrived directly. - name: React to mention if: | steps.check.outputs.should_run == 'true' - && github.event.comment - && contains(github.event.comment.body, '@prql-bot') + && ((github.event.comment && contains(github.event.comment.body, '@prql-bot')) + || (github.event.client_payload.kind == 'pull_request_review_comment' + && steps.check.outputs.reason == 'mention')) run: | gh api "repos/$REPO/issues/comments/$COMMENT_ID/reactions" -f content=eyes 2>/dev/null || \ gh api "repos/$REPO/pulls/comments/$COMMENT_ID/reactions" -f content=eyes 2>/dev/null || true env: REPO: ${{ github.repository }} - COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_ID: ${{ github.event.comment.id || github.event.client_payload.id }} GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} handle: needs: verify if: needs.verify.outputs.should_run == 'true' concurrency: - group: ${{ github.workflow }}-handle-${{ github.event.issue.number || github.event.pull_request.number }} + group: ${{ github.workflow }}-handle-${{ github.event.issue.number || github.event.client_payload.pr }} cancel-in-progress: false runs-on: ubuntu-24.04 + environment: tend permissions: contents: write pull-requests: write @@ -228,8 +353,7 @@ jobs: - name: Check out PR branch if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') || - github.event_name == 'pull_request_review_comment' || - github.event_name == 'pull_request_review' + github.event_name == 'repository_dispatch' run: | PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state') if [ "$PR_STATE" = "OPEN" ]; then @@ -239,7 +363,7 @@ jobs: fi env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - PR_NUMBER: ${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event.pull_request.number }} + PR_NUMBER: ${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event.client_payload.pr }} - name: Compute queue delay id: delay @@ -251,9 +375,11 @@ jobs: event_epoch=$(date -d "$EVENT_TS" +%s) echo "seconds=$(( $(date +%s) - event_epoch ))" >> "$GITHUB_OUTPUT" env: - EVENT_TS: ${{ github.event.comment.updated_at || github.event.review.submitted_at || github.event.issue.updated_at }} + # A relayed event's timestamp comes from verify, which read it off + # the API record — the dispatch payload never carries one to spoof. + EVENT_TS: ${{ github.event.comment.updated_at || needs.verify.outputs.ts || github.event.issue.updated_at }} - - uses: max-sixty/tend/claude@0.1.11 + - uses: max-sixty/tend/claude@0.1.13 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -269,14 +395,14 @@ jobs: ${{ github.event_name == 'issues' && format('An issue was updated with a mention of you ({0}). Read it and respond.', github.event.issue.html_url) - || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@prql-bot') - && format('You were mentioned in an inline review comment on PR #{0} ({1}, comment ID {2}). Read the full context, then respond. If changes are requested, make them, commit, and push.', (github.event_name == 'issue_comment' && github.event.issue.number || github.event.pull_request.number), github.event.comment.html_url, github.event.comment.id)) - || (github.event_name == 'pull_request_review_comment' - && format('An inline review comment was posted on a PR where you previously participated (PR #{0}, {1}, comment ID {2}). Read the full context. Only respond if the comment is directed at you or requests changes.', (github.event_name == 'issue_comment' && github.event.issue.number || github.event.pull_request.number), github.event.comment.html_url, github.event.comment.id)) - || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@prql-bot') - && format('A review was submitted on PR #{0} that mentions you ({1}, review ID {2}). Read the review and full context, then respond. If changes were requested, make them, commit, and push.', github.event.pull_request.number, github.event.review.html_url, github.event.review.id)) - || (github.event_name == 'pull_request_review' - && format('A review was submitted on a PR where you previously participated (PR #{0}, {1}, review ID {2}). Read the review and full context. If it requests changes or asks questions, respond appropriately — including when you authored the review: a review your review workflow left on your own PR is your reviewer role speaking, not a self-loop, so action it. Exit silently for a plain approval, a review with no actionable content, or one between other participants.', github.event.pull_request.number, github.event.review.html_url, github.event.review.id)) + || (github.event.client_payload.kind == 'pull_request_review_comment' && needs.verify.outputs.reason == 'mention' + && format('You were mentioned in an inline review comment on PR #{0} ({1}, comment ID {2}). Read the full context, then respond. If changes are requested, make them, commit, and push.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id)) + || (github.event.client_payload.kind == 'pull_request_review_comment' + && format('An inline review comment was posted on a PR where you previously participated (PR #{0}, {1}, comment ID {2}). Read the full context. Only respond if the comment is directed at you or requests changes.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id)) + || (github.event.client_payload.kind == 'pull_request_review' && needs.verify.outputs.reason == 'mention' + && format('A review was submitted on PR #{0} that mentions you ({1}, review ID {2}). Read the review and full context, then respond. If changes were requested, make them, commit, and push.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id)) + || (github.event.client_payload.kind == 'pull_request_review' + && format('A review was submitted on a PR where you previously participated (PR #{0}, {1}, review ID {2}). Read the review and full context. If it requests changes or asks questions, respond appropriately — including when you authored the review: a review your review workflow left on your own PR is your reviewer role speaking, not a self-loop, so action it. Exit silently for a plain approval, a review with no actionable content, or one between other participants.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id)) || (contains(github.event.comment.body, '@prql-bot') && format('You were mentioned in a comment ({0}). Read the full context and respond. If changes are requested, make them, commit, and push.', github.event.comment.html_url)) || format('A user commented on an issue/PR where you previously participated ({0}). Read the full context. Only respond if the comment is directed at you, asks a question you can help with, or requests changes you can make. If the conversation is between other participants, exit silently.', github.event.comment.html_url) @@ -285,8 +411,9 @@ jobs: - name: Remove eyes reaction if: | always() - && github.event.comment - && contains(github.event.comment.body, '@prql-bot') + && ((github.event.comment && contains(github.event.comment.body, '@prql-bot')) + || (github.event.client_payload.kind == 'pull_request_review_comment' + && needs.verify.outputs.reason == 'mention')) run: | for KIND in issues pulls; do REACTION_ID=$(gh api "repos/$REPO/$KIND/comments/$COMMENT_ID/reactions?content=eyes" \ @@ -297,6 +424,6 @@ jobs: done env: REPO: ${{ github.repository }} - COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_ID: ${{ github.event.comment.id || github.event.client_payload.id }} BOT_NAME: prql-bot GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} diff --git a/.github/workflows/tend-nightly.yaml b/.github/workflows/tend-nightly.yaml index d08755dc058a..aa7bbdb465cd 100644 --- a/.github/workflows/tend-nightly.yaml +++ b/.github/workflows/tend-nightly.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.11. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.13. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -16,6 +16,7 @@ jobs: nightly: if: github.repository_owner == 'PRQL' runs-on: ubuntu-24.04 + environment: tend permissions: contents: write pull-requests: write @@ -32,7 +33,7 @@ jobs: - uses: ./.github/actions/tend-setup - - uses: max-sixty/tend/claude@0.1.11 + - uses: max-sixty/tend/claude@0.1.13 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/tend-notifications.yaml b/.github/workflows/tend-notifications.yaml index efd9b28b7780..531486dadeb0 100644 --- a/.github/workflows/tend-notifications.yaml +++ b/.github/workflows/tend-notifications.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.11. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.13. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -16,6 +16,7 @@ jobs: notifications: if: github.repository_owner == 'PRQL' runs-on: ubuntu-24.04 + environment: tend permissions: contents: write pull-requests: write @@ -26,7 +27,39 @@ jobs: - name: Check for unread notifications id: check run: | - COUNT=$(gh api notifications --jq 'length') + # Fetch notifications once, tolerating transient non-JSON responses. + # GitHub occasionally returns an HTML error page (even with a 200) + # during a brief API blip; under `bash -e` an untolerated `gh api` + # failure would abort the whole step red. A failed fetch just means + # this cycle can't enumerate — the next scheduled cycle picks up + # anything missed — so retry briefly, then skip cleanly. + # + # On every failed attempt, log what actually came back: the plain + # fetch hides the body behind `2>/dev/null`, and a transient blip + # that recovers on retry would otherwise leave no trace of the bad + # response. `gh api -i` re-fetches with the HTTP status line + + # headers ahead of the body, so a recurring failure shows the real + # status code and body — not just jq's parse complaint. It exits + # non-zero on an error status, hence `|| true`. The extra call runs + # only on the (rare) failing attempt; the happy path is one fetch. + NOTIFS="" + for attempt in 1 2 3; do + if NOTIFS=$(gh api notifications 2>/dev/null) && echo "$NOTIFS" | jq -e . >/dev/null 2>&1; then + break + fi + NOTIFS="" + echo "--- notifications fetch attempt $attempt failed; actual response (status + body head) ---" + gh api notifications -i 2>&1 | head -c 1000 || true + echo + [ "$attempt" -lt 3 ] && sleep "$attempt" + done + if [ -z "$NOTIFS" ]; then + echo "count=0" >> "$GITHUB_OUTPUT" + echo "notifications fetch failed after retries — skipping this cycle" + exit 0 + fi + + COUNT=$(echo "$NOTIFS" | jq 'length') if [ "$COUNT" = "0" ]; then echo "count=0" >> "$GITHUB_OUTPUT" echo "No unread notifications — skipping" @@ -42,7 +75,6 @@ jobs: RECENT_PRS=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?created=>=$SINCE&per_page=50" --jq '[.workflow_runs[] | select(.name | test("^(tend-review|tend-mention|tend-triage|tend-ci-fix)$")) | .pull_requests[]?.number] | unique | .[]' || true) if [ -n "$RECENT_PRS" ]; then - NOTIFS=$(gh api notifications) for pr in $RECENT_PRS; do echo "$NOTIFS" | jq -r --arg repo "$GITHUB_REPOSITORY" --arg pr "$pr" '.[] | select(.subject.url == "https://api.github.com/repos/" + $repo + "/pulls/" + $pr or .subject.url == "https://api.github.com/repos/" + $repo + "/issues/" + $pr) | .id' | while read -r tid; do [ -n "$tid" ] || continue @@ -54,7 +86,8 @@ jobs: # --- Layer C: drop notifications on bot-authored closed PRs --- # The bot auto-subscribes to its own PRs. After merge/close, leftover # subscription notifications are pure noise — no action needed. - NOTIFS=$(gh api notifications) + # Reuses the snapshot fetched above; marking a thread read is + # idempotent, so a stale snapshot at worst re-PATCHes a read thread. echo "$NOTIFS" | jq -r --arg repo "$GITHUB_REPOSITORY" '.[] | select(.repository.full_name == $repo and .subject.type == "PullRequest") | .id' | while read -r tid; do [ -n "$tid" ] || continue PR_NUM=$(echo "$NOTIFS" | jq -r --arg tid "$tid" '.[] | select(.id == $tid) | .subject.url | split("/") | last') @@ -73,7 +106,12 @@ jobs: # Processing them now risks duplicating work. Cross-repo notifications # are exempt — no dedicated workflow handles them. CUTOFF=$(date -u -d '10 minutes ago' +%Y-%m-%dT%H:%M:%SZ) - REMAINING=$(gh api notifications) + # Re-fetch so the count reflects threads marked read in Layers B/C. + # Tolerate a transient failure by falling back to the pre-mutation + # snapshot — an over-count at worst spends one agent run, never fails. + if ! REMAINING=$(gh api notifications 2>/dev/null) || ! echo "$REMAINING" | jq -e . >/dev/null 2>&1; then + REMAINING="$NOTIFS" + fi COUNT=$(echo "$REMAINING" | jq --arg repo "$GITHUB_REPOSITORY" --arg cutoff "$CUTOFF" '[.[] | select(.repository.full_name != $repo or .updated_at <= $cutoff)] | length') echo "count=$COUNT" >> "$GITHUB_OUTPUT" if [ "$COUNT" = "0" ]; then @@ -99,7 +137,7 @@ jobs: - uses: ./.github/actions/tend-setup if: steps.check.outputs.count != '0' || github.event_name == 'workflow_dispatch' - - uses: max-sixty/tend/claude@0.1.11 + - uses: max-sixty/tend/claude@0.1.13 if: steps.check.outputs.count != '0' || github.event_name == 'workflow_dispatch' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} diff --git a/.github/workflows/tend-review-runs.yaml b/.github/workflows/tend-review-runs.yaml index 3f5e99a03fef..ad7ee57addb2 100644 --- a/.github/workflows/tend-review-runs.yaml +++ b/.github/workflows/tend-review-runs.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.11. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.13. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -16,6 +16,7 @@ jobs: review-runs: if: github.repository_owner == 'PRQL' runs-on: ubuntu-24.04 + environment: tend permissions: contents: write pull-requests: write @@ -32,7 +33,7 @@ jobs: - uses: ./.github/actions/tend-setup - - uses: max-sixty/tend/claude@0.1.11 + - uses: max-sixty/tend/claude@0.1.13 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/tend-review.yaml b/.github/workflows/tend-review.yaml index 3a408820476f..6e2a507afe51 100644 --- a/.github/workflows/tend-review.yaml +++ b/.github/workflows/tend-review.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.11. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.13. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -17,6 +17,7 @@ jobs: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true runs-on: ubuntu-24.04 + environment: tend permissions: contents: write pull-requests: write @@ -24,6 +25,17 @@ jobs: actions: read issues: write steps: + # Two checkouts: `setup:` runs against the base tree, and the PR's own + # tree lands after it. Setup executes as the runner user, outside the + # containment the harness builds for the contributor's code. + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.TEND_BOT_TOKEN }} + + - uses: ./.github/actions/tend-setup + # GitHub only materializes refs/pull/N/merge for mergeable PRs — on # conflicting PRs it 404s and every downstream step cascades as skipped. # Probe first and fall back to /head so review always runs; on fallback @@ -45,13 +57,12 @@ jobs: with: ref: ${{ steps.pr_ref.outputs.ref }} allow-unsafe-pr-checkout: true + clean: false fetch-depth: 0 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - - uses: ./.github/actions/tend-setup - - - uses: max-sixty/tend/claude@0.1.11 + - uses: max-sixty/tend/claude@0.1.13 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/tend-triage.yaml b/.github/workflows/tend-triage.yaml index cad38c9b6224..85231c53ef92 100644 --- a/.github/workflows/tend-triage.yaml +++ b/.github/workflows/tend-triage.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.11. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.13. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -19,6 +19,7 @@ jobs: triage: if: github.repository_owner == 'PRQL' && contains(github.event.issue.labels.*.name, 'tend-outage') == false runs-on: ubuntu-24.04 + environment: tend permissions: contents: write pull-requests: write @@ -35,7 +36,7 @@ jobs: - uses: ./.github/actions/tend-setup - - uses: max-sixty/tend/claude@0.1.11 + - uses: max-sixty/tend/claude@0.1.13 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/tend-weekly.yaml b/.github/workflows/tend-weekly.yaml index c17cda907e76..944cd6dbd9bb 100644 --- a/.github/workflows/tend-weekly.yaml +++ b/.github/workflows/tend-weekly.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.11. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.13. Regenerate with: uvx tend@latest init # # Do not edit this file directly — it will be overwritten on regeneration. # To customize behavior, edit the relevant skill (for example, @@ -16,6 +16,7 @@ jobs: weekly: if: github.repository_owner == 'PRQL' runs-on: ubuntu-24.04 + environment: tend permissions: contents: write pull-requests: write @@ -32,7 +33,7 @@ jobs: - uses: ./.github/actions/tend-setup - - uses: max-sixty/tend/claude@0.1.11 + - uses: max-sixty/tend/claude@0.1.13 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}