tend-notifications #7005
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Generated by tend 0.3.4. Regenerate with: uvx tend@latest init | |
| # | |
| # Do not edit this file directly — it will be overwritten on regeneration. | |
| # To change this workflow, edit .config/tend.yaml. To change what the agent | |
| # does, edit the relevant skill (for example, `running-tend`) in this repo's | |
| # .claude/skills/ directory, or open an issue at | |
| # https://github.com/max-sixty/tend/issues for changes that need to | |
| # happen upstream in the tend-ci-runner plugin. | |
| name: tend-notifications | |
| on: | |
| schedule: | |
| - cron: "*/15 * * * *" | |
| workflow_dispatch: | |
| jobs: | |
| notifications: | |
| concurrency: | |
| group: tend-notifications | |
| cancel-in-progress: false | |
| if: github.repository_owner == 'PRQL' && vars.TEND_ENABLED != 'false' | |
| runs-on: ubuntu-24.04 | |
| environment: | |
| name: tend | |
| deployment: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| actions: read | |
| issues: write | |
| steps: | |
| - uses: astral-sh/setup-uv@v10.2.0 | |
| id: tend_uv | |
| env: | |
| UV_NO_MODIFY_PATH: "1" | |
| with: | |
| version: "0.12.17" | |
| enable-cache: false | |
| ignore-empty-workdir: true | |
| - name: Check for unread notifications and conflicted PRs | |
| id: check | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} | |
| TEND_UV: ${{ steps.tend_uv.outputs.uv-path }} | |
| run: | | |
| "$TEND_UV" run --script - <<'TEND_PY' | |
| # /// script | |
| # requires-python = ">=3.12" | |
| # dependencies = [] | |
| # /// | |
| """Decide whether the notifications workflow has work for an agent.""" | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| from datetime import UTC, datetime, timedelta | |
| from pathlib import Path | |
| from typing import Any | |
| GRAPHQL_QUERY = """ | |
| query($q: String!) { | |
| search(query: $q, type: ISSUE, first: 100) { | |
| nodes { ... on PullRequest { | |
| mergeable headRefOid | |
| comments(last: 100) { nodes { author { login } body } } | |
| } } | |
| } | |
| } | |
| """ | |
| def _gh(*args: str, quiet: bool = False) -> str: | |
| # `gh` colorizes a piped `--json`/`--jq` response when the job's | |
| # environment forces color, and the ANSI codes land inside the body | |
| # `_json` parses. `CLICOLOR_FORCE=0` is the setting that defeats it: | |
| # `gh` ranks a forced value above `NO_COLOR`, so `NO_COLOR` alone loses. | |
| env = os.environ.copy() | |
| env.update(NO_COLOR="1", CLICOLOR_FORCE="0") | |
| result = subprocess.run( | |
| ["gh", *args], | |
| capture_output=True, | |
| text=True, | |
| env=env, | |
| check=False, | |
| ) | |
| if result.returncode: | |
| if result.stderr and not quiet: | |
| sys.stderr.write(result.stderr) | |
| raise subprocess.CalledProcessError( | |
| result.returncode, result.args, result.stdout, result.stderr | |
| ) | |
| return result.stdout | |
| def _json(*args: str, quiet: bool = False) -> Any: | |
| return json.loads(_gh(*args, quiet=quiet)) | |
| def _paginated(path: str) -> list[Any]: | |
| text = _gh("api", path, "--paginate", quiet=True) | |
| decoder = json.JSONDecoder() | |
| pages: list[Any] = [] | |
| position = 0 | |
| saw_page = False | |
| while position < len(text): | |
| while position < len(text) and text[position].isspace(): | |
| position += 1 | |
| if position == len(text): | |
| break | |
| page, position = decoder.raw_decode(text, position) | |
| saw_page = True | |
| if not isinstance(page, list): | |
| raise TypeError("paginated GitHub response was not an array") | |
| pages.extend(page) | |
| if not saw_page: | |
| raise ValueError("paginated GitHub response was empty") | |
| return pages | |
| def _output(name: str, value: str | int) -> None: | |
| with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: | |
| stream.write(f"{name}={value}\n") | |
| def _notifications(cutoff: str) -> int: | |
| try: | |
| return len(_paginated(f"notifications?before={cutoff}&per_page=100")) | |
| except ( | |
| json.JSONDecodeError, | |
| subprocess.CalledProcessError, | |
| TypeError, | |
| ValueError, | |
| ): | |
| print("::warning::notifications fetch failed; queue left for the next cycle") | |
| return 0 | |
| def _actor_login(actor: object) -> str: | |
| if not isinstance(actor, dict): | |
| return "" | |
| return str(actor.get("login") or "") | |
| def _is_deferred(pr: dict[str, Any], bot: str) -> bool: | |
| marker = f"<!-- tend-conflict-deferred head={pr.get('headRefOid', '')} -->" | |
| comments = pr.get("comments") | |
| nodes = comments.get("nodes", []) if isinstance(comments, dict) else [] | |
| return any( | |
| _actor_login(comment.get("author")) == bot | |
| and str(comment.get("body") or "").rstrip().split("\n")[-1] == marker | |
| for comment in nodes | |
| if isinstance(comment, dict) | |
| ) | |
| def _conflicts(repo: str) -> int: | |
| try: | |
| bot = _gh("api", "user", "--jq", ".login", quiet=True).strip() | |
| if not bot: | |
| raise ValueError("authenticated GitHub login was empty") | |
| response = _json( | |
| "api", | |
| "graphql", | |
| "-f", | |
| f"query={GRAPHQL_QUERY}", | |
| "-f", | |
| f"q=repo:{repo} author:{bot} is:pr is:open", | |
| quiet=True, | |
| ) | |
| nodes = response["data"]["search"]["nodes"] | |
| if not isinstance(nodes, list): | |
| raise TypeError("GraphQL search nodes were not an array") | |
| # `mergeable` is computed lazily: the first read after the base branch | |
| # moves returns `UNKNOWN` and enqueues the computation, so counting | |
| # anything but a settled `CONFLICTING` boots a session after every merge | |
| # into the default branch. A conflict behind an `UNKNOWN` is counted on | |
| # the next poll, which reads the value this one enqueued; the resolver | |
| # test-merges locally either way. | |
| return sum( | |
| pr.get("mergeable") == "CONFLICTING" and not _is_deferred(pr, bot) | |
| for pr in nodes | |
| if isinstance(pr, dict) | |
| ) | |
| except ( | |
| json.JSONDecodeError, | |
| KeyError, | |
| subprocess.CalledProcessError, | |
| TypeError, | |
| ValueError, | |
| ): | |
| print("::warning::bot PR conflict scan failed; retrying next cycle") | |
| return 0 | |
| def main(*, now: datetime | None = None) -> int: | |
| repo = os.environ["GITHUB_REPOSITORY"] | |
| cutoff = ((now or datetime.now(UTC)) - timedelta(minutes=10)).strftime( | |
| "%Y-%m-%dT%H:%M:%SZ" | |
| ) | |
| _output("cutoff", cutoff) | |
| try: | |
| _gh( | |
| "api", | |
| f"repos/{repo}/subscription", | |
| "-X", | |
| "PUT", | |
| "-F", | |
| "subscribed=true", | |
| "-F", | |
| "ignored=false", | |
| "--silent", | |
| quiet=True, | |
| ) | |
| except subprocess.CalledProcessError: | |
| print("::warning::could not enable repository watching; retrying next cycle") | |
| count = _notifications(cutoff) | |
| _output("count", count) | |
| conflict_count = _conflicts(repo) | |
| _output("conflict_count", conflict_count) | |
| if count == 0 and conflict_count == 0: | |
| print("No notification or conflict work — skipping") | |
| else: | |
| if count: | |
| print(f"{count} notification task(s) — proceeding") | |
| if conflict_count: | |
| print(f"{conflict_count} possible conflicted bot PR(s) — proceeding") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |
| TEND_PY | |
| - uses: actions/checkout@v7 | |
| if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' | |
| with: | |
| ref: main | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| token: ${{ secrets.TEND_BOT_TOKEN }} | |
| - uses: ./.github/actions/tend-setup | |
| if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' | |
| - uses: baptiste0928/cargo-install@v3 | |
| if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' | |
| with: | |
| crate: cargo-llvm-cov | |
| - run: |- | |
| mkdir -p ~/.local/bin | |
| curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C ~/.local/bin | |
| cargo-nextest --version | |
| curl -LsSf https://github.com/mitsuhiko/insta/releases/latest/download/cargo-insta-installer.sh | CARGO_HOME=~/.local sh | |
| cargo-insta --version | |
| uv tool install pre-commit | |
| pre-commit --version | |
| cargo --version | |
| cargo llvm-cov --version | |
| if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' | |
| shell: bash | |
| - uses: max-sixty/tend/claude@0.3.4 | |
| if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch' | |
| with: | |
| github_token: ${{ secrets.TEND_BOT_TOKEN }} | |
| merge: restricted | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| bot_name: prql-bot | |
| model: opus | |
| checkout_mode: base | |
| base_branch: ${{ github.event.repository.default_branch }} | |
| prompt: |2 | |
| /tend-ci-runner:notifications | |
| Notification snapshot cutoff: ${{ steps.check.outputs.cutoff }} | |
| Possible conflicted bot PR count: ${{ steps.check.outputs.conflict_count }} |