tend-notifications #6975
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.2.7. 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, | |
| # `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 == 'max-sixty' | |
| runs-on: ubuntu-24.04 | |
| environment: | |
| name: tend | |
| deployment: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| actions: read | |
| issues: write | |
| steps: | |
| - name: Check whether tend is enabled | |
| id: tend_enabled | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| gh api \ | |
| -H "Accept: application/vnd.github.raw+json" \ | |
| "repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \ | |
| > "$RUNNER_TEMP/tend.yaml" | |
| ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT" | |
| # Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off) | |
| # do not diverge from the YAML 1.2 parser used by `tend init`. | |
| require "psych" | |
| path = ARGV.fetch(0) | |
| documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children | |
| unless documents.length == 1 | |
| abort "tend config must contain exactly one YAML document" | |
| end | |
| mapping = documents.first.root | |
| unless mapping.is_a?(Psych::Nodes::Mapping) | |
| abort "tend config must contain a YAML mapping" | |
| end | |
| def has_yaml_merge_key?(node) | |
| case node | |
| when Psych::Nodes::Mapping | |
| node.children.each_slice(2).any? do |key, value| | |
| (key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") || | |
| has_yaml_merge_key?(key) || has_yaml_merge_key?(value) | |
| end | |
| when Psych::Nodes::Sequence | |
| node.children.any? { |value| has_yaml_merge_key?(value) } | |
| else | |
| false | |
| end | |
| end | |
| if has_yaml_merge_key?(mapping) | |
| abort "tend config: YAML merge keys (<<) are not supported" | |
| end | |
| matches = mapping.children.each_slice(2).select do |key, _value| | |
| key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled" | |
| end | |
| abort "tend config: enabled must appear at most once" if matches.length > 1 | |
| value = matches.dig(0, 1) | |
| enabled = true | |
| if value | |
| bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool" | |
| literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar) | |
| unless value.is_a?(Psych::Nodes::Scalar) && | |
| (value.plain || bool_tag) && | |
| ["true", "false"].include?(literal) | |
| abort "tend config: enabled must be true or false" | |
| end | |
| enabled = literal == "true" | |
| end | |
| puts "enabled=#{enabled}" | |
| unless enabled | |
| warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job" | |
| end | |
| RUBY | |
| - uses: astral-sh/setup-uv@v10.1.0 | |
| if: steps.tend_enabled.outputs.enabled == 'true' | |
| with: | |
| version: "0.12.10" | |
| ignore-empty-workdir: true | |
| - name: Check for unread notifications and conflicted PRs | |
| id: check | |
| if: steps.tend_enabled.outputs.enabled == 'true' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} | |
| run: | | |
| 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: | |
| result = subprocess.run( | |
| ["gh", *args], | |
| capture_output=True, | |
| text=True, | |
| env=os.environ.copy(), | |
| 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") | |
| return sum( | |
| pr.get("mergeable") != "MERGEABLE" 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.tend_enabled.outputs.enabled == 'true') && (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.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') | |
| - uses: max-sixty/tend/claude@0.2.7 | |
| if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch') | |
| with: | |
| github_token: ${{ secrets.TEND_BOT_TOKEN }} | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| bot_name: worktrunk-bot | |
| model: opus | |
| checkout_mode: base | |
| base_branch: ${{ github.event.repository.default_branch }} | |
| sandbox_setup: |2 | |
| mkdir -p ~/.local/bin | |
| uv tool install --quiet pre-commit | |
| for t in cargo-insta cargo-nextest pre-commit nu nix; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; see Sandbox toolchain in .github/CLAUDE.md"; exit 1; }; done | |
| prompt: |2 | |
| /tend-ci-runner:notifications | |
| Notification snapshot cutoff: ${{ steps.check.outputs.cutoff }} | |
| Possible conflicted bot PR count: ${{ steps.check.outputs.conflict_count }} |