Workspaces 9/10: dor workspace move, dor list --window, iframe move gate #4117
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.5. 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-mention | |
| on: | |
| issues: | |
| types: [edited] | |
| issue_comment: | |
| types: [created, edited] | |
| # 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* | |
| # pull_request_review and pull_request_review_comment for every newly-created | |
| # 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 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 | |
| # edits), so we still need to listen for `edited` to catch edit-to-summon | |
| # ("@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: | |
| - 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 | |
| # 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 | |
| if: steps.tend_enabled.outputs.enabled == 'true' | |
| 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: | |
| # Skip comments on the issues tend files about its own health: the action | |
| # auto-comments on those when a run fails or is refused, and without this | |
| # guard those comments re-trigger tend-mention, producing a | |
| # self-sustaining ~1 run/minute loop until the underlying condition | |
| # 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, '@dormouse-bot')) || | |
| (github.event_name == 'issue_comment' && | |
| contains(github.event.issue.labels.*.name, 'tend-outage') == false && contains(github.event.issue.labels.*.name, 'tend-rate-limit') == false) | |
| runs-on: ubuntu-24.04 | |
| environment: | |
| name: tend | |
| deployment: false | |
| permissions: | |
| contents: read | |
| 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: 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.0.1 | |
| if: steps.tend_enabled.outputs.enabled == 'true' | |
| with: | |
| version: "0.12.10" | |
| ignore-empty-workdir: true | |
| - name: Verify bot engagement | |
| id: check | |
| if: steps.tend_enabled.outputs.enabled == 'true' | |
| run: | | |
| uv run --script - <<'TEND_PY' | |
| # /// script | |
| # requires-python = ">=3.12" | |
| # dependencies = [] | |
| # /// | |
| """Decide whether a mention event should start an agent session.""" | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| from typing import Any | |
| def gh(*args: str, quiet: bool = False) -> str: | |
| result = subprocess.run(["gh", *args], capture_output=True, text=True, 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 gh_json(*args: str, quiet: bool = False) -> Any: | |
| return json.loads(gh(*args, quiet=quiet)) | |
| def gh_paginated(path: str) -> list[dict[str, Any]]: | |
| text = gh("api", "--paginate", path) | |
| decoder = json.JSONDecoder() | |
| position = 0 | |
| items: list[dict[str, Any]] = [] | |
| 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) | |
| if not isinstance(page, list): | |
| raise TypeError("paginated GitHub response was not an array") | |
| items.extend(page) | |
| return items | |
| def actor_login(actor: object) -> str: | |
| """Return a GitHub actor login, including for deleted-account records.""" | |
| if not isinstance(actor, dict): | |
| return "" | |
| return str(actor.get("login") or "") | |
| def output(name: str, value: str | bool) -> None: | |
| rendered = str(value).lower() if isinstance(value, bool) else value | |
| with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: | |
| stream.write(f"{name}={rendered}\n") | |
| def verdict(should_run: bool, reason: str = "") -> int: | |
| output("should_run", should_run) | |
| if reason: | |
| output("reason", reason) | |
| return 0 | |
| def main() -> int: | |
| env = os.environ | |
| bot = env.get("BOT_NAME", "") | |
| repo = env.get("GITHUB_REPOSITORY", "") | |
| kind = env.get("EVENT_NAME", "") | |
| comment_body = env.get("COMMENT_BODY", "") | |
| comment_author = env.get("COMMENT_AUTHOR", "") | |
| review_author = "" | |
| review_state = "" | |
| inline: list[dict[str, Any]] = [] | |
| fresh_inline = 0 | |
| if kind == "repository_dispatch": | |
| kind = env.get("PAYLOAD_KIND", "") | |
| pr = env.get("PAYLOAD_PR", "") | |
| item_id = env.get("PAYLOAD_ID", "") | |
| if not pr.isdigit() or not item_id.isdigit(): | |
| print("malformed dispatch payload — skipping") | |
| return verdict(False) | |
| if kind == "pull_request_review": | |
| try: | |
| review = gh_json( | |
| "api", f"repos/{repo}/pulls/{pr}/reviews/{item_id}", quiet=True | |
| ) | |
| except (subprocess.CalledProcessError, json.JSONDecodeError): | |
| print(f"review {item_id} not found on PR {pr} — skipping") | |
| return verdict(False) | |
| review_author = actor_login(review.get("user")) | |
| review_state = str(review["state"]).lower() | |
| comment_body = review.get("body") or "" | |
| output("url", review["html_url"]) | |
| output("ts", review.get("submitted_at") or "") | |
| elif kind == "pull_request_review_comment": | |
| try: | |
| comment = gh_json( | |
| "api", f"repos/{repo}/pulls/comments/{item_id}", quiet=True | |
| ) | |
| except (subprocess.CalledProcessError, json.JSONDecodeError): | |
| print(f"comment {item_id} not found — skipping") | |
| return verdict(False) | |
| expected_pr = f"https://api.github.com/repos/{repo}/pulls/{pr}" | |
| if comment.get("pull_request_url") != expected_pr: | |
| print(f"comment {item_id} does not belong to PR {pr} — skipping") | |
| return verdict(False) | |
| comment_author = actor_login(comment.get("user")) | |
| comment_body = comment.get("body") or "" | |
| output("url", comment["html_url"]) | |
| output("ts", comment["updated_at"]) | |
| else: | |
| print(f"unknown dispatch kind '{kind}' — skipping") | |
| return verdict(False) | |
| if kind == "issues": | |
| return verdict(True) | |
| if ( | |
| kind in {"issue_comment", "pull_request_review_comment"} | |
| and comment_author == bot | |
| ): | |
| return verdict(False) | |
| if comment_body and f"@{bot}" in comment_body: | |
| return verdict(True, "mention") | |
| if kind == "issue_comment" and env.get("COMMENT_AUTHOR_TYPE") == "Bot": | |
| return verdict(False) | |
| if kind == "pull_request_review": | |
| inline = gh_paginated( | |
| f"repos/{repo}/pulls/{env.get('PAYLOAD_PR', '')}/reviews/" | |
| f"{env.get('PAYLOAD_ID', '')}/comments" | |
| ) | |
| if any(f"@{bot}" in (comment.get("body") or "") for comment in inline): | |
| return verdict(True, "mention") | |
| fresh_inline = sum(comment.get("in_reply_to_id") is None for comment in inline) | |
| if review_state == "approved" and not comment_body and not inline: | |
| return verdict(False) | |
| if kind == "issue_comment": | |
| issue_number = env.get("ISSUE_OR_PR_NUMBER", "") | |
| if not env.get("PR_URL"): | |
| if env.get("ISSUE_AUTHOR") == bot or f"@{bot}" in env.get("ISSUE_BODY", ""): | |
| return verdict(True) | |
| comments = gh_paginated(f"repos/{repo}/issues/{issue_number}/comments") | |
| return verdict( | |
| any(actor_login(comment.get("user")) == bot for comment in comments) | |
| ) | |
| pr_number = issue_number | |
| else: | |
| pr_number = env.get("PAYLOAD_PR", "") | |
| pr = gh_json("pr", "view", pr_number, "--repo", repo, "--json", "author") | |
| pr_author = actor_login(pr.get("author")) | |
| if kind == "pull_request_review" and review_author == bot: | |
| if pr_author == bot and (comment_body or fresh_inline > 0): | |
| return verdict(True, "participation") | |
| return verdict(False) | |
| if pr_author == bot: | |
| return verdict(True, "participation") | |
| reviews = gh_paginated(f"repos/{repo}/pulls/{pr_number}/reviews") | |
| if any(actor_login(review.get("user")) == bot for review in reviews): | |
| return verdict(True, "participation") | |
| comments = gh_paginated(f"repos/{repo}/issues/{pr_number}/comments") | |
| if any(actor_login(comment.get("user")) == bot for comment in comments): | |
| return verdict(True, "participation") | |
| return verdict(False) | |
| if __name__ == "__main__": | |
| try: | |
| raise SystemExit(main()) | |
| except subprocess.CalledProcessError as error: | |
| raise SystemExit(error.returncode or 1) from None | |
| TEND_PY | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} | |
| BOT_NAME: dormouse-bot | |
| EVENT_NAME: ${{ github.event_name }} | |
| 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 }} | |
| PAYLOAD_KIND: ${{ github.event.client_payload.kind }} | |
| PAYLOAD_PR: ${{ github.event.client_payload.pr }} | |
| PAYLOAD_ID: ${{ github.event.client_payload.id }} | |
| handle: | |
| needs: verify | |
| if: needs.verify.outputs.should_run == 'true' | |
| concurrency: | |
| group: ${{ github.workflow }}-handle-${{ github.event.issue.number || github.event.client_payload.pr }} | |
| cancel-in-progress: false | |
| 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 | |
| # Both halves of the reaction belong to this job, so the eyes can only | |
| # go on once the job that takes them off has started. Put them in | |
| # `verify` and `handle` respectively and the routine burst case strands | |
| # them: a third mention on one thread evicts the second's pending | |
| # `handle` — a job cancelled while queued allocates no runner and runs | |
| # no steps, `always()` included — while its `verify` already reacted. | |
| # | |
| # 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. The job's own `if` already carries `should_run`. | |
| - name: React with eyes | |
| if: | | |
| (steps.tend_enabled.outputs.enabled == 'true') && (((github.event.comment && contains(github.event.comment.body, '@dormouse-bot')) | |
| || (github.event.client_payload.kind == 'pull_request_review_comment' | |
| && needs.verify.outputs.reason == 'mention'))) | |
| run: | | |
| gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ | |
| || echo "::warning::could not add the eyes reaction" | |
| env: | |
| REPO: ${{ github.repository }} | |
| TARGET: ${{ github.event_name == 'issue_comment' | |
| && format('issues/comments/{0}', github.event.comment.id) | |
| || format('pulls/comments/{0}', github.event.comment.id || github.event.client_payload.id) }} | |
| GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} | |
| - uses: actions/checkout@v7 | |
| if: steps.tend_enabled.outputs.enabled == 'true' | |
| with: | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| token: ${{ secrets.TEND_BOT_TOKEN }} | |
| - name: Compute queue delay | |
| id: delay | |
| if: steps.tend_enabled.outputs.enabled == 'true' | |
| run: | | |
| if [ -z "$EVENT_TS" ]; then | |
| echo "seconds=" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| event_epoch=$(date -d "$EVENT_TS" +%s) | |
| echo "seconds=$(( $(date +%s) - event_epoch ))" >> "$GITHUB_OUTPUT" | |
| env: | |
| # 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.2.5 | |
| if: steps.tend_enabled.outputs.enabled == 'true' | |
| 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: dormouse-bot | |
| model: opus | |
| checkout_mode: mention | |
| base_branch: ${{ github.event.repository.default_branch }} | |
| prompt: >- | |
| ${{ steps.delay.outputs.seconds | |
| && format('This job started {0}s after the triggering event (over ~40s means it was queued). ', | |
| steps.delay.outputs.seconds) || '' }}Before acting, | |
| check recent comments: exit silently if the bot already responded | |
| to the trigger; handle any other unaddressed comments too. | |
| ${{ 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.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, '@dormouse-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) | |
| }} | |
| - name: Remove the eyes reaction | |
| if: | | |
| always() | |
| && (steps.tend_enabled.outputs.enabled == 'true') | |
| && (((github.event.comment && contains(github.event.comment.body, '@dormouse-bot')) | |
| || (github.event.client_payload.kind == 'pull_request_review_comment' | |
| && needs.verify.outputs.reason == 'mention'))) | |
| run: | | |
| REACTION_ID=$(gh api --paginate \ | |
| "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ | |
| --jq ".[] | select(.user.login == \"$BOT_NAME\") | .id" | head -n1) | |
| if [ -n "$REACTION_ID" ]; then | |
| gh api -X DELETE "repos/$REPO/$TARGET/reactions/$REACTION_ID" --silent \ | |
| || echo "::warning::could not remove the eyes reaction" | |
| fi | |
| env: | |
| REPO: ${{ github.repository }} | |
| TARGET: ${{ github.event_name == 'issue_comment' | |
| && format('issues/comments/{0}', github.event.comment.id) | |
| || format('pulls/comments/{0}', github.event.comment.id || github.event.client_payload.id) }} | |
| BOT_NAME: dormouse-bot | |
| GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} |