diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index aa9b24000f75..66cab3e6d872 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -2,3 +2,16 @@ self-hosted-runner: labels: - macos-15-intel + +# actionlint does not yet know the `concurrency.queue` key, which GitHub added +# and tend's generated `tend-review.yaml` uses. Upstream tracks it in +# rhysd/actionlint#654 and #657; the PR adding support (#661) was closed +# unmerged and the latest release (v1.7.12) still rejects it, so the nightly +# `uvx tend init` regeneration would otherwise land permanently red here. +# +# Scoped to the generated `tend-*` workflows so a genuine typo in a +# hand-written workflow is still caught. Remove once actionlint ships support. +paths: + .github/workflows/tend-*.yaml: + ignore: + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/workflows/tend-ci-fix.yaml b/.github/workflows/tend-ci-fix.yaml index 7f9e059049f6..79a896f03dea 100644 --- a/.github/workflows/tend-ci-fix.yaml +++ b/.github/workflows/tend-ci-fix.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.20. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.22. 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, @@ -34,7 +34,7 @@ jobs: - uses: ./.github/actions/tend-setup - - uses: max-sixty/tend/claude@0.1.20 + - uses: max-sixty/tend/claude@0.1.22 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 fdb0c4174de2..223f8137e4ed 100644 --- a/.github/workflows/tend-mention.yaml +++ b/.github/workflows/tend-mention.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.20. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.22. 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, @@ -420,7 +420,7 @@ jobs: # 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.20 + - uses: max-sixty/tend/claude@0.1.22 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/tend-nightly.yaml b/.github/workflows/tend-nightly.yaml index 14ff2dfd0a57..a905bc3cd178 100644 --- a/.github/workflows/tend-nightly.yaml +++ b/.github/workflows/tend-nightly.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.20. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.22. 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, @@ -34,7 +34,7 @@ jobs: - uses: ./.github/actions/tend-setup - - uses: max-sixty/tend/claude@0.1.20 + - uses: max-sixty/tend/claude@0.1.22 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 ee342a6d8ff8..438a3eba5bfd 100644 --- a/.github/workflows/tend-notifications.yaml +++ b/.github/workflows/tend-notifications.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.20. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.22. 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, @@ -14,6 +14,9 @@ on: jobs: notifications: + concurrency: + group: tend-notifications + cancel-in-progress: false if: github.repository_owner == 'PRQL' runs-on: ubuntu-24.04 environment: @@ -29,113 +32,47 @@ jobs: id: check env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - BOT_NAME: prql-bot run: | # shellcheck shell=bash - # Pre-check for tend-notifications: decide whether the agent needs to boot at - # all, and clear inbox noise no agent run is needed for. + # Establish the repository's notification recovery queue and decide whether the + # agent needs to boot. Inlined into the generated workflow: env in, + # GITHUB_OUTPUT out. # - # Inlined into the generated workflow (adopter repos have no copy of this - # file), so it stays self-contained: env in, GITHUB_OUTPUT out. - # - # env: BOT_NAME, GITHUB_REPOSITORY, GITHUB_OUTPUT, GITHUB_TOKEN - - # 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 + # env: GITHUB_REPOSITORY, GITHUB_OUTPUT, GITHUB_TOKEN - COUNT=$(echo "$NOTIFS" | jq 'length') - if [ "$COUNT" = "0" ]; then - echo "count=0" >> "$GITHUB_OUTPUT" - echo "No unread notifications — skipping" - exit 0 - fi + # Activity newer than this belongs to an event workflow that may still be + # running. The same cutoff is passed to the agent and, once every older item has + # a semantic outcome, to GitHub's repository-level mark-read endpoint. Newer + # activity therefore cannot be acknowledged by this run. + CUTOFF=$(date -u -d '10 minutes ago' +%Y-%m-%dT%H:%M:%SZ) + echo "cutoff=$CUTOFF" >> "$GITHUB_OUTPUT" - # --- Layer B: drop notifications shadowed by recent dedicated runs --- - # Event workflows mark their own notifications read via the harness - # action's post-step on success; this sweeps the case where Claude failed - # (post-step is gated by `if: success()`) so the notification still - # gets cleared without burning Claude turns to rediscover it. - SINCE=$(date -u -d '30 minutes ago' +%Y-%m-%dT%H:%M:%SZ) - 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) + # Watching makes a new issue or PR visible before the bot has participated in + # its thread. The installer sets this too; every poll repeats the idempotent PUT + # so a later settings change is repaired without additional state. + gh api "repos/$GITHUB_REPOSITORY/subscription" -X PUT \ + -F subscribed=true -F ignored=false --silent \ + || echo "::warning::could not enable repository watching; retrying next cycle" - if [ -n "$RECENT_PRS" ]; then - 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 - gh api "notifications/threads/$tid" -X PATCH || true - done - done + # Capture every unread page at the cutoff. GitHub occasionally returns an HTML + # error page even with a successful status, so validate the slurped page shape. + # A failed fetch leaves the queue untouched for the next scheduled cycle. + ENDPOINT="notifications?before=$CUTOFF&per_page=100" + if PAGES=$(gh api "$ENDPOINT" --paginate --slurp 2>/dev/null) \ + && NOTIFS=$(echo "$PAGES" | jq -ce \ + 'if type == "array" and all(.[]; type == "array") then add // [] else error("invalid pages") end'); then + COUNT=$(echo "$NOTIFS" | jq 'length') + else + COUNT=0 + echo "::warning::notifications fetch failed; queue left for the next cycle" fi - # --- 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. - # 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') - PR_INFO=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUM" --jq '"\(.user.login) \(.state)"' 2>/dev/null) || continue - PR_AUTHOR=${PR_INFO%% *} - PR_STATE=${PR_INFO##* } - if [ "$PR_AUTHOR" = "$BOT_NAME" ] && [ "$PR_STATE" = "closed" ]; then - gh api "notifications/threads/$tid" -X PATCH || true - fi - done - - # --- Layer D: count processable notifications --- - # Same-repo notifications younger than 10 minutes are deferred: a - # dedicated workflow (tend-review/mention/triage/ci-fix) is likely - # still starting up or mid-flight and hasn't posted its response yet. - # 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) - # 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 - TOTAL=$(echo "$REMAINING" | jq 'length') - if [ "$TOTAL" = "0" ]; then - echo "All notifications handled by pre-checks — skipping" - else - echo "$TOTAL notification(s) remain but all are fresh same-repo (deferred) — skipping" - fi + echo "No notification work before $CUTOFF — skipping" else - echo "$COUNT processable notification(s) — proceeding" + echo "$COUNT notification task(s) — proceeding" fi - uses: actions/checkout@v7 @@ -148,7 +85,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.20 + - uses: max-sixty/tend/claude@0.1.22 if: steps.check.outputs.count != '0' || github.event_name == 'workflow_dispatch' with: github_token: ${{ secrets.TEND_BOT_TOKEN }} @@ -158,3 +95,5 @@ jobs: model: opus prompt: | /tend-ci-runner:notifications + + Notification snapshot cutoff: ${{ steps.check.outputs.cutoff }} diff --git a/.github/workflows/tend-review-runs.yaml b/.github/workflows/tend-review-runs.yaml index 4a41b655c438..67144ae4fe2b 100644 --- a/.github/workflows/tend-review-runs.yaml +++ b/.github/workflows/tend-review-runs.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.20. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.22. 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, @@ -34,7 +34,7 @@ jobs: - uses: ./.github/actions/tend-setup - - uses: max-sixty/tend/claude@0.1.20 + - uses: max-sixty/tend/claude@0.1.22 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 2c63eaf6da1a..d444750fac7c 100644 --- a/.github/workflows/tend-review.yaml +++ b/.github/workflows/tend-review.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.20. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.22. 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, @@ -15,10 +15,11 @@ jobs: review: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - # A push mid-review queues a replacement run rather than killing the - # session: the running review folds the push in and stamps the commits - # it examined, and the gate step below lets the queued run exit without - # booting an agent when its HEAD is already covered. + # Preserve pending PR events within GitHub's queue limit: a push must not + # replace a ready-for-review run. Runs stay serial so outward actions + # cannot race. An override setting `cancel-in-progress: true` must set + # `queue: null` too — GitHub rejects the pair as an invalid workflow file. + queue: max cancel-in-progress: false runs-on: ubuntu-24.04 environment: @@ -30,75 +31,7 @@ jobs: actions: read issues: write steps: - - name: Skip when the live HEAD is already examined - id: gate - env: - GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} - PR: ${{ github.event.pull_request.number }} - EVENT_ACTION: ${{ github.event.action }} - run: | - # shellcheck shell=bash - # Pre-check for tend-review: decide whether the agent needs to boot at all. - # - # The review job runs without cancel-in-progress, so a push mid-review queues - # a replacement run while the live session keeps going, folds the push in, and - # stamps each commit it examined with a `tend-review/` commit status (review - # skill, "Stamp examined HEADs"). The concurrency group holds this run until - # that session ends; by then the live HEAD is usually stamped and there is - # nothing left to do. Judged against the live PR, not the event payload — a - # queued run's payload is stale by construction. - # - # Inlined into the generated workflow (adopter repos have no copy of this - # file), so it stays self-contained: env in, GITHUB_OUTPUT out. Any write-scoped - # actor could forge the stamp to suppress a review; that actor can already - # cancel the run itself, so the merge gate — not this check — remains the - # security boundary. - # - # env: PR, EVENT_ACTION, GITHUB_REPOSITORY, GITHUB_OUTPUT, GITHUB_TOKEN - - # Only `synchronize` can be a stale duplicate of an examination that already - # happened: `opened` has no prior run, and `reopened` / `ready_for_review` - # ask for a fresh pass even on a stamped commit. - if [ "$EVENT_ACTION" != "synchronize" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Fail open on API errors: a redundant agent run beats a silently skipped - # review. The parse belongs inside the guard — GitHub sometimes returns an - # HTML error page with a 200 during a blip, so a zero `gh` exit doesn't mean - # the body is JSON, and an unguarded `jq` under the run block's `bash -e` - # would fail the step (fail-closed) instead. - if ! PR_INFO=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" 2>/dev/null) \ - || ! STATE=$(echo "$PR_INFO" | jq -re '.state'); then - echo "PR #$PR fetch failed — proceeding without the pre-check" - echo "should_run=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "$STATE" != "open" ]; then - echo "PR #$PR is $STATE — skipping" - echo "should_run=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # The stamp context carries the PR number: one branch can be two open PRs - # (same head, different base), and each base means a different diff, so an - # examination of one must not gate the other. - HEAD=$(echo "$PR_INFO" | jq -r '.head.sha') - STAMPED=$(gh api "repos/$GITHUB_REPOSITORY/commits/$HEAD/status?per_page=100" 2>/dev/null \ - | jq --arg ctx "tend-review/$PR" \ - '[.statuses[]? | select(.context == $ctx and .state == "success")] | length' \ - || echo 0) - if [ "${STAMPED:-0}" -gt 0 ]; then - echo "HEAD $HEAD already examined (tend-review/$PR) — skipping" - echo "should_run=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "should_run=true" >> "$GITHUB_OUTPUT" - - name: React with eyes - if: steps.gate.outputs.should_run == 'true' run: | gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ || echo "::warning::could not add the eyes reaction" @@ -111,14 +44,12 @@ jobs: # 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 - if: steps.gate.outputs.should_run == 'true' with: fetch-depth: 0 fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - uses: ./.github/actions/tend-setup - if: steps.gate.outputs.should_run == 'true' # GitHub only materializes refs/pull/N/merge for mergeable PRs — on # conflicting PRs it 404s and every downstream step cascades as skipped. @@ -127,7 +58,6 @@ jobs: # tree. - name: Resolve PR checkout ref id: pr_ref - if: steps.gate.outputs.should_run == 'true' env: GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} PR: ${{ github.event.pull_request.number }} @@ -139,7 +69,6 @@ jobs: echo "::notice::refs/pull/$PR/merge unavailable (likely merge conflict); falling back to /head" fi - uses: actions/checkout@v7 - if: steps.gate.outputs.should_run == 'true' with: ref: ${{ steps.pr_ref.outputs.ref }} allow-unsafe-pr-checkout: true @@ -148,8 +77,7 @@ jobs: fetch-tags: true token: ${{ secrets.TEND_BOT_TOKEN }} - - uses: max-sixty/tend/claude@0.1.20 - if: steps.gate.outputs.should_run == 'true' + - uses: max-sixty/tend/claude@0.1.22 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -160,14 +88,14 @@ jobs: ${{ format('/tend-ci-runner:review {0}', github.event.pull_request.number) }} - name: Restore local setup actions for POST cleanup - if: always() && (steps.gate.outputs.should_run == 'true') + if: always() run: | dir=.github/actions/tend-setup git checkout "$GITHUB_SHA" -- "$dir" || echo "::warning::could not restore $dir from $GITHUB_SHA; POST cleanup of the local action may fail" - name: Remove the eyes reaction - if: always() && (steps.gate.outputs.should_run == 'true') + if: always() run: | REACTION_ID=$(gh api --paginate \ "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ diff --git a/.github/workflows/tend-triage.yaml b/.github/workflows/tend-triage.yaml index 13fbb7714dc4..0eb184285398 100644 --- a/.github/workflows/tend-triage.yaml +++ b/.github/workflows/tend-triage.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.20. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.22. 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, @@ -46,7 +46,7 @@ jobs: - uses: ./.github/actions/tend-setup - - uses: max-sixty/tend/claude@0.1.20 + - uses: max-sixty/tend/claude@0.1.22 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 504537ec0fe4..ac1522aa2547 100644 --- a/.github/workflows/tend-weekly.yaml +++ b/.github/workflows/tend-weekly.yaml @@ -1,4 +1,4 @@ -# Generated by tend 0.1.20. Regenerate with: uvx tend@latest init +# Generated by tend 0.1.22. 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, @@ -34,7 +34,7 @@ jobs: - uses: ./.github/actions/tend-setup - - uses: max-sixty/tend/claude@0.1.20 + - uses: max-sixty/tend/claude@0.1.22 with: github_token: ${{ secrets.TEND_BOT_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}