diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 000000000..a51d1017d --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,12 @@ + + +# Automation boundaries + +- PR reminders apply only to open, non-draft PRs; recheck immediately before posting. +- Derive merge readiness from the PR's checks, reviews and mergeability, not a manually maintained Project status. +- Keep elapsed PR age distinct from inactivity and observed merge-readiness duration. +- Public PR comments and workflow summaries must not expose private Project notes or fields. +- Automation documentation describes current behavior and operation; omit development history, machine-specific details and board snapshots. diff --git a/.github/scripts/PR_TRIAGE.md b/.github/scripts/PR_TRIAGE.md new file mode 100644 index 000000000..4e4b0bd3e --- /dev/null +++ b/.github/scripts/PR_TRIAGE.md @@ -0,0 +1,72 @@ + + +# PR triage metrics and reminders + +The [Refresh PR triage workflow](../workflows/project-triage.yml) runs daily at +16:43 UTC from the default branch. It updates open PRs in the configured Project +and repository. Archived items, issues, closed PRs and merged PRs are skipped. +Draft PRs receive metric updates but no reminders. + +## Metrics + +- **Idle**: full 24-hour periods since the latest human comment, submitted + review, inline review comment, or human-linked commit's committer timestamp. + Falls back to PR creation. Bot activity, CI, reminder comments and edits to + existing comments do not reset it. Commit timestamps do not identify push times. +- **Age**: full 24-hour periods since PR creation. +- **Ready days / Ready since**: time since first observing the current head + as non-draft, mergeable, `CLEAN`, review `APPROVED` and check rollup `SUCCESS`. + A new head, an unmet condition, or an observation gap exceeding 48 hours resets + the clock. Daily observations cannot establish readiness before the first run + or detect every transition between runs. +- **Triage state**: automation-managed readiness observations. Invalid values + restart the readiness clock. + +Missing fields are created automatically. Age is added after Idle in table +views that show Idle, unless Age is already visible; existing column order is +preserved. + +## Reminders + +Only open, non-draft PRs receive reminders after either threshold: + +- Observed ready to merge for at least 7 consecutive days. +- Created more than 14 days ago, regardless of recent activity. + +Thresholds use calendar days. Both reasons share one comment and a seven-day +cooldown. Eligibility is checked again before posting. The workflow never merges +or closes PRs. + +Comments mention assignees, otherwise requested reviewers. Merge-ready PRs +fall back to approving reviewers, then the author. Other PRs fall back to the +author. Comments use public PR data only. + +## Operation + +In **Actions → Refresh PR triage → Run workflow**, select the default branch. +Manual runs preview by default; enable `apply` to update fields and additionally +enable `notify` to send reminders. Scheduled runs enable both. GitHub schedules +may be delayed. Failed API requests fail the run; completed updates remain. + +The repository secret `PROJECT_WRITE_TOKEN` requires the `project` scope, +organization authorization and write access to the Project. It is used only for +Project requests. The workflow's `GITHUB_TOKEN` reads PR data and posts comments +with `pull-requests: write`. PR-triggered jobs run tests without Project credentials. + +Run tests from the repository root: + +```sh +python3 -m unittest discover -s tests/python/automation -v +``` + +For a local preview, provide `PROJECT_TOKEN` and `REPO_TOKEN` in the environment: + +```sh +python3 .github/scripts/project_triage.py --report /tmp/triage-report.json +``` + +Add `--apply` to update fields; `--notify` also requires `--apply` and posts reminders. +The preview report contains per-PR metrics; workflow summaries show counts only. diff --git a/.github/scripts/project_triage.py b/.github/scripts/project_triage.py new file mode 100644 index 000000000..abb0b0077 --- /dev/null +++ b/.github/scripts/project_triage.py @@ -0,0 +1,470 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Update PR triage metrics and post weekly reminders.""" + +import argparse +import json +import logging +import os +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path + +LOG = logging.getLogger("isaaccapture.automation.project_triage") +MARKER = "" +FIELDS = { + "Idle": "NUMBER", + "Age": "NUMBER", + "Ready days": "NUMBER", + "Ready since": "DATE", + "Triage state": "TEXT", +} +PR_QUERY = """query($owner:String!,$repo:String!,$number:Int!) { + repository(owner:$owner,name:$repo) { pullRequest(number:$number) { + state isDraft createdAt headRefOid + mergeable mergeStateStatus reviewDecision + commits(last:1) { nodes { commit { statusCheckRollup { state } } } } + } } +}""" + + +def timestamp(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def iso(value): + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def days(now, start): + return max(0, (now - start).days) + + +def human(user): + return ( + bool(user) + and user.get("type") != "Bot" + and not user.get("login", "").endswith("[bot]") + ) + + +def latest_activity(pr, comments, reviews, inline, commits): + dates = [timestamp(pr["createdAt"])] + for entry in comments + inline: + if human(entry.get("user")) and MARKER not in (entry.get("body") or ""): + dates.append(timestamp(entry["created_at"])) + for review in reviews: + if human(review.get("user")) and review.get("submitted_at"): + dates.append(timestamp(review["submitted_at"])) + for commit in commits: + if human((commit.get("author") or {}).get("user")) or human( + (commit.get("committer") or {}).get("user") + ): + dates.append(timestamp(commit["committedDate"])) + return max(dates) + + +def ready(pr): + commits = pr.get("commits", {}).get("nodes", []) + checks = (commits[-1]["commit"].get("statusCheckRollup") or {}) if commits else {} + return ( + pr["state"] == "OPEN" + and not pr["isDraft"] + and pr["mergeable"] == "MERGEABLE" + and pr["mergeStateStatus"] == "CLEAN" + and pr["reviewDecision"] == "APPROVED" + and checks.get("state") == "SUCCESS" + ) + + +def read_state(value): + # Project text fields are editable; invalid state must restart observation. + try: + state = json.loads(value or "{}") + if not isinstance(state, dict) or not all( + isinstance(state.get(key), str) for key in ("head", "since", "observed") + ): + return {} + since, observed = timestamp(state["since"]), timestamp(state["observed"]) + if since.tzinfo is None or observed.tzinfo is None or since > observed: + return {} + return state + except (ValueError, TypeError): + return {} + + +def ready_state(pr, previous, now): + # Daily observation cannot reconstruct earlier Ready periods or unseen transitions. + if not ready(pr): + return {} + if ( + previous.get("head") == pr["headRefOid"] + and previous.get("since") + and previous.get("observed") + and timedelta(0) <= now - timestamp(previous["observed"]) <= timedelta(hours=48) + and timestamp(previous["since"]) <= now + ): + since = previous["since"] + else: + since = iso(now) + return {"head": pr["headRefOid"], "since": since, "observed": iso(now)} + + +def reasons(pr, state, now): + if pr["state"] != "OPEN" or pr["isDraft"]: + return [] + result = [] + if state.get("since") and now - timestamp(state["since"]) >= timedelta(days=7): + result.append( + "This PR has been observed ready to merge for at least 7 consecutive days." + ) + if now - timestamp(pr["createdAt"]) > timedelta(days=14): + result.append("This PR has been open for more than 14 consecutive days.") + return result + + +def due(comments, now): + sent = [ + timestamp(c["created_at"]) for c in comments if MARKER in (c.get("body") or "") + ] + return not sent or now - max(sent) >= timedelta(days=7) + + +def recipients(detail, reviews, is_ready): + people = {u["login"] for u in detail.get("assignees", []) if human(u)} + if not people: + people.update( + u["login"] for u in detail.get("requested_reviewers", []) if human(u) + ) + if not people and is_ready: + people.update( + r["user"]["login"] + for r in reviews + if r["state"] == "APPROVED" and human(r.get("user")) + ) + if not people and human(detail.get("user")): + people.add(detail["user"]["login"]) + return sorted(people) + + +class GitHub: + def __init__(self, token): + self.env = dict(os.environ, GH_TOKEN=token) + + def call(self, endpoint, payload=None, method=None): + args = ["gh", "api", endpoint] + if method: + args += ["--method", method] + if payload is not None: + args += ["--input", "-"] + try: + proc = subprocess.run( + args, + input=json.dumps(payload) if payload is not None else None, + text=True, + capture_output=True, + env=self.env, + check=False, + timeout=90, + ) + except subprocess.TimeoutExpired as error: + raise RuntimeError(f"GitHub request timed out: {endpoint}") from error + if proc.returncode: + raise RuntimeError( + f"GitHub request failed: {endpoint}: {proc.stderr.strip()}" + ) + data = json.loads(proc.stdout) if proc.stdout.strip() else None + if isinstance(data, dict) and data.get("errors"): + raise RuntimeError(f"GraphQL request failed: {data['errors']}") + return data + + def graphql(self, query, **variables): + return self.call("graphql", {"query": query, "variables": variables})["data"] + + def pages(self, endpoint): + result = [] + page = 1 + while True: + rows = self.call(f"{endpoint}?per_page=100&page={page}") + result.extend(rows) + if len(rows) < 100: + return result + page += 1 + + +def project_data(api, org, number): + meta = api.graphql( + """query($org:String!,$number:Int!) { + organization(login:$org) { projectV2(number:$number) { + id viewerCanUpdate fields(first:100) { nodes { + ... on ProjectV2Field { id name dataType } + } } + } } + }""", + org=org, + number=number, + )["organization"]["projectV2"] + if not meta or not meta["viewerCanUpdate"]: + raise RuntimeError("Project is unavailable or not writable") + items = [] + cursor = None + while True: + page = api.graphql( + """query($id:ID!,$cursor:String) { + node(id:$id) { ... on ProjectV2 { items(first:100,after:$cursor) { + pageInfo { hasNextPage endCursor } nodes { + id isArchived content { ... on PullRequest { number state repository { nameWithOwner } } } + fieldValues(first:100) { nodes { + ... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2Field { name } } } + ... on ProjectV2ItemFieldDateValue { date field { ... on ProjectV2Field { name } } } + ... on ProjectV2ItemFieldTextValue { text field { ... on ProjectV2Field { name } } } + } } + } + } } } + }""", + id=meta["id"], + cursor=cursor, + )["node"]["items"] + items.extend(page["nodes"]) + if not page["pageInfo"]["hasNextPage"]: + return meta, items + cursor = page["pageInfo"]["endCursor"] + + +def ensure_fields(api, project, apply): + fields = {f["name"]: f for f in project["fields"]["nodes"] if f} + for name, kind in FIELDS.items(): + if name in fields: + if fields[name]["dataType"] != kind: + raise RuntimeError(f"Unexpected type for {name}") + elif apply: + field = api.graphql( + # Creation accepts CustomFieldType; FieldType also includes built-ins. + """mutation($project:ID!,$name:String!,$type:ProjectV2CustomFieldType!) { + createProjectV2Field(input:{projectId:$project,name:$name,dataType:$type}) { + projectV2Field { ... on ProjectV2Field { id name dataType } } + } + }""", + project=project["id"], + name=name, + type=kind, + )["createProjectV2Field"]["projectV2Field"] + fields[name] = field + return fields + + +def update_field(api, project_id, item_id, field, value): + if value is None: + api.graphql( + """mutation($project:ID!,$item:ID!,$field:ID!) { + clearProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field}) { + projectV2Item { id } + } + }""", + project=project_id, + item=item_id, + field=field["id"], + ) + else: + key = {"NUMBER": "number", "DATE": "date", "TEXT": "text"}[field["dataType"]] + api.graphql( + """mutation($project:ID!,$item:ID!,$field:ID!,$value:ProjectV2FieldValue!) { + updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:$value}) { + projectV2Item { id } + } + }""", + project=project_id, + item=item_id, + field=field["id"], + value={key: value}, + ) + + +def show_age(api, project, fields): + views = api.graphql( + """query($id:ID!) { node(id:$id) { ... on ProjectV2 { + views(first:100) { nodes { id layout configuration { visibleFields(first:100) { nodes { + ... on ProjectV2Field { id } ... on ProjectV2SingleSelectField { id } + ... on ProjectV2IterationField { id } + } } } } } + } } }""", + id=project["id"], + )["node"]["views"]["nodes"] + for view in views: + # view.fields uses project-wide order, not the user's configured column order. + visible = [f["id"] for f in view["configuration"]["visibleFields"]["nodes"]] + idle, age = fields["Idle"]["id"], fields["Age"]["id"] + if view["layout"] != "TABLE_LAYOUT" or idle not in visible or age in visible: + continue + visible.insert(visible.index(idle) + 1, age) + api.graphql( + """mutation($view:ID!,$fields:[ID!]) { + updateProjectV2View(input:{viewId:$view,configuration:{visibleFieldIds:$fields}}) { + projectV2View { id } + } + }""", + view=view["id"], + fields=visible, + ) + + +def snapshot(api, owner, repo, number): + return api.graphql(PR_QUERY, owner=owner, repo=repo, number=number)["repository"][ + "pullRequest" + ] + + +def pr_commits(api, owner, repo, number): + # REST pull-request commits stop at 250; this connection supports the full history. + result, cursor = [], None + while True: + page = api.graphql( + """query($owner:String!,$repo:String!,$number:Int!,$cursor:String) { + repository(owner:$owner,name:$repo) { pullRequest(number:$number) { + commits(first:100,after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { commit { + committedDate author { user { login } } committer { user { login } } + } } + } + } } + }""", + owner=owner, + repo=repo, + number=number, + cursor=cursor, + )["repository"]["pullRequest"]["commits"] + result.extend(node["commit"] for node in page["nodes"]) + if not page["pageInfo"]["hasNextPage"]: + return result + cursor = page["pageInfo"]["endCursor"] + + +def process(project_api, repo_api, meta, fields, item, repo, now, apply, notify): + owner, name = repo.split("/") + number = item["content"]["number"] + pr = snapshot(repo_api, owner, name, number) + if pr["state"] != "OPEN": + return None + root = f"repos/{repo}" + comments = repo_api.pages(f"{root}/issues/{number}/comments") + reviews = repo_api.pages(f"{root}/pulls/{number}/reviews") + inline = repo_api.pages(f"{root}/pulls/{number}/comments") + commits = pr_commits(repo_api, owner, name, number) + existing = {} + for value in item["fieldValues"]["nodes"]: + if value.get("field", {}).get("name") in FIELDS: + existing[value["field"]["name"]] = next( + value[k] for k in ("number", "date", "text") if k in value + ) + previous = read_state(existing.get("Triage state")) + state = ready_state(pr, previous, now) + idle = days(now, latest_activity(pr, comments, reviews, inline, commits)) + updates = { + "Idle": idle, + "Age": days(now, timestamp(pr["createdAt"])), + "Ready days": days(now, timestamp(state["since"])) if state else None, + "Ready since": state["since"][:10] if state else None, + "Triage state": json.dumps(state, sort_keys=True) if state else None, + } + why = reasons(pr, state, now) + reminder = bool(why and due(comments, now)) + if apply: + for field, value in updates.items(): + if existing.get(field) != value: + update_field(project_api, meta["id"], item["id"], fields[field], value) + if notify and reminder: + # Recheck immediately before sending; Project snapshots can outlive a merge or draft toggle. + fresh = snapshot(repo_api, owner, name, number) + fresh_state = ( + state if fresh["headRefOid"] == pr["headRefOid"] and ready(fresh) else {} + ) + why = reasons(fresh, fresh_state, now) + fresh_comments = repo_api.pages(f"{root}/issues/{number}/comments") + if why and due(fresh_comments, now): + detail = repo_api.call(f"{root}/pulls/{number}") + if detail["state"] == "open" and not detail["draft"]: + mentions = " ".join( + "@" + login for login in recipients(detail, reviews, ready(fresh)) + ) + body = ( + f"{MARKER}\nAutomated PR follow-up {mentions}\n\n" + + "\n".join("- " + reason for reason in why) + + "\n\nPlease merge if ready, or share the blocker and next step. " + "This reminder repeats at most once every 7 days while eligible." + ) + repo_api.call(f"{root}/issues/{number}/comments", {"body": body}) + return { + "number": number, + "age": updates["Age"], + "idle": idle, + "draft": pr["isDraft"], + "ready_days": updates["Ready days"], + "reminder_due": reminder, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--org", default="NVIDIA") + parser.add_argument("--project", type=int, default=322) + parser.add_argument("--repo", default="NVIDIA/IsaacCapture") + parser.add_argument("--apply", action="store_true") + parser.add_argument("--notify", action="store_true") + parser.add_argument("--report", type=Path, required=True) + args = parser.parse_args() + if args.notify and not args.apply: + parser.error("--notify requires --apply") + project_api = GitHub(os.environ["PROJECT_TOKEN"]) + repo_api = GitHub(os.environ["REPO_TOKEN"]) + now = datetime.now(timezone.utc) + meta, items = project_data(project_api, args.org, args.project) + fields = ensure_fields(project_api, meta, args.apply) + report, failures = [], [] + for item in items: + content = item.get("content") or {} + if ( + item["isArchived"] + or content.get("state") != "OPEN" + or content.get("repository", {}).get("nameWithOwner") != args.repo + ): + continue + try: + row = process( + project_api, + repo_api, + meta, + fields, + item, + args.repo, + now, + args.apply, + args.notify, + ) + if row: + report.append(row) + except (RuntimeError, ValueError, KeyError) as error: + LOG.error("PR #%s: %s", content["number"], error) + failures.append(content["number"]) + if args.apply: + show_age(project_api, meta, fields) + args.report.write_text( + json.dumps( + { + "checked_at": iso(now), + "applied": args.apply, + "notifications_enabled": args.notify, + "items": report, + "failures": failures, + }, + indent=2, + ) + + "\n" + ) + if failures: + raise SystemExit(1) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + main() diff --git a/.github/workflows/project-triage.yml b/.github/workflows/project-triage.yml new file mode 100644 index 000000000..6624092c1 --- /dev/null +++ b/.github/workflows/project-triage.yml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +name: Refresh PR triage + +on: + schedule: + - cron: "43 16 * * *" + workflow_dispatch: + inputs: + apply: + description: Update Project fields (otherwise preview only) + type: boolean + default: false + notify: + description: Post eligible PR reminders (requires apply) + type: boolean + default: false + pull_request: + paths: + - .github/scripts/project_triage.py + - .github/workflows/project-triage.yml + - tests/python/automation/** + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Test PR triage policy + run: python3 -m unittest discover -s tests/python/automation -v + + refresh: + needs: test + if: >- + github.event_name != 'pull_request' && + github.repository == 'NVIDIA/IsaacCapture' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + pull-requests: write + checks: read + statuses: read + concurrency: + group: project-triage-322 + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Refresh Project metrics and reminders + env: + PROJECT_TOKEN: ${{ secrets.PROJECT_WRITE_TOKEN }} + REPO_TOKEN: ${{ github.token }} + APPLY: ${{ github.event_name == 'schedule' || inputs.apply }} + NOTIFY: ${{ github.event_name == 'schedule' || inputs.notify }} + shell: bash + run: | + set -euo pipefail + args=(--report "$RUNNER_TEMP/triage-report.json") + if [[ "$APPLY" == true ]]; then args+=(--apply); fi + if [[ "$NOTIFY" == true ]]; then args+=(--notify); fi + python3 .github/scripts/project_triage.py "${args[@]}" + - name: Summarize run + if: always() + shell: bash + run: | + if [[ -f "$RUNNER_TEMP/triage-report.json" ]]; then + jq '{checked_at,applied,notifications_enabled,processed:(.items|length),failed:(.failures|length)}' \ + "$RUNNER_TEMP/triage-report.json" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/tests/python/automation/pyproject.toml b/tests/python/automation/pyproject.toml new file mode 100644 index 000000000..71103e62d --- /dev/null +++ b/tests/python/automation/pyproject.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +[project] +name = "isaaccapture-automation-tests" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = [] + +[tool.uv] +package = false diff --git a/tests/python/automation/test_project_triage.py b/tests/python/automation/test_project_triage.py new file mode 100644 index 000000000..abd5b5da5 --- /dev/null +++ b/tests/python/automation/test_project_triage.py @@ -0,0 +1,476 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Policy and side-effect boundary tests without GitHub credentials.""" + +import importlib.util +import json +import subprocess +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + +ROOT = next( + p + for p in Path(__file__).resolve().parents + if (p / ".github/scripts/project_triage.py").is_file() +) +SPEC = importlib.util.spec_from_file_location( + "project_triage", ROOT / ".github/scripts/project_triage.py" +) +triage = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(triage) +NOW = datetime(2026, 10, 10, 16, 43, tzinfo=timezone.utc) +PERSON = {"type": "User", "login": "author"} +BOT = {"type": "Bot", "login": "github-actions[bot]"} + + +def pr(**kwargs): + result = dict( + number=42, + url="https://github.com/example/repo/pull/42", + state="OPEN", + isDraft=False, + createdAt=triage.iso(NOW - timedelta(days=20)), + headRefOid="abc", + mergeable="MERGEABLE", + mergeStateStatus="CLEAN", + reviewDecision="APPROVED", + commits={"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]}, + ) + result.update(kwargs) + return result + + +def comment(when, user=PERSON, body="An update"): + return {"created_at": triage.iso(when), "user": user, "body": body} + + +class PolicyTests(unittest.TestCase): + def test_age_visibility_preserves_configured_column_order(self): + api = triage.GitHub("not-a-real-token") + view = { + "id": "view", + "layout": "TABLE_LAYOUT", + "configuration": { + "visibleFields": { + "nodes": [ + {"id": "custom-first"}, + {"id": "idle"}, + {"id": "custom-last"}, + ] + } + }, + } + fields = {"Idle": {"id": "idle"}, "Age": {"id": "age"}} + with patch.object( + api, "graphql", side_effect=[{"node": {"views": {"nodes": [view]}}}, {}] + ) as call: + triage.show_age(api, {"id": "project"}, fields) + self.assertEqual( + call.call_args.kwargs["fields"], + ["custom-first", "idle", "age", "custom-last"], + ) + + def test_field_creation_uses_custom_field_enum(self): + api = triage.GitHub("not-a-real-token") + project = {"id": "project", "fields": {"nodes": []}} + with patch.object( + api, + "graphql", + side_effect=lambda query, **variables: { + "createProjectV2Field": { + "projectV2Field": { + "id": variables["name"], + "name": variables["name"], + "dataType": variables["type"], + } + } + }, + ) as call: + self.assertEqual( + len(triage.ensure_fields(api, project, True)), len(triage.FIELDS) + ) + self.assertIn("$type:ProjectV2CustomFieldType!", call.call_args.args[0]) + + def test_age_threshold_is_strict_and_not_idle(self): + self.assertEqual( + triage.reasons(pr(createdAt=triage.iso(NOW - timedelta(days=14))), {}, NOW), + [], + ) + self.assertEqual( + len( + triage.reasons( + pr(createdAt=triage.iso(NOW - timedelta(days=14, seconds=1))), + {}, + NOW, + ) + ), + 1, + ) + + def test_ready_threshold_and_combined_reasons(self): + state = {"since": triage.iso(NOW - timedelta(days=7))} + self.assertEqual(len(triage.reasons(pr(), state, NOW)), 2) + state["since"] = triage.iso(NOW - timedelta(days=7) + timedelta(seconds=1)) + self.assertEqual(len(triage.reasons(pr(), state, NOW)), 1) + + def test_closed_merged_and_draft_never_nudged(self): + for data in [pr(state="CLOSED"), pr(state="MERGED"), pr(isDraft=True)]: + with self.subTest(data=data): + self.assertEqual( + triage.reasons(data, {"since": "2026-01-01T00:00:00Z"}, NOW), [] + ) + self.assertEqual(triage.ready_state(data, {}, NOW), {}) + + def test_all_readiness_conditions_required(self): + self.assertTrue(triage.ready(pr())) + for update in [ + dict(mergeable="UNKNOWN"), + dict(mergeStateStatus="BLOCKED"), + dict(mergeStateStatus="UNSTABLE"), + dict(reviewDecision="REVIEW_REQUIRED"), + dict(reviewDecision="CHANGES_REQUESTED"), + dict(commits={"nodes": []}), + dict( + commits={ + "nodes": [{"commit": {"statusCheckRollup": {"state": "PENDING"}}}] + } + ), + ]: + with self.subTest(update=update): + self.assertFalse(triage.ready(pr(**update))) + + def test_ready_clock_starts_at_observation_and_resets(self): + state = triage.ready_state(pr(), {}, NOW) + self.assertEqual(state["since"], triage.iso(NOW)) + next_day = NOW + timedelta(days=1) + self.assertEqual( + triage.ready_state(pr(), state, next_day)["since"], state["since"] + ) + self.assertEqual( + triage.ready_state(pr(headRefOid="new"), state, next_day)["since"], + triage.iso(next_day), + ) + self.assertEqual( + triage.ready_state(pr(), state, NOW + timedelta(days=3))["since"], + triage.iso(NOW + timedelta(days=3)), + ) + + def test_invalid_stored_state_is_discarded(self): + valid = { + "head": "abc", + "since": triage.iso(NOW - timedelta(days=8)), + "observed": triage.iso(NOW - timedelta(days=1)), + } + self.assertEqual(triage.read_state(json.dumps(valid)), valid) + invalid = [ + None, + "{", + "null", + "[]", + "42", + '"text"', + "{}", + json.dumps(dict(valid, since=42)), + json.dumps(dict(valid, observed="invalid")), + json.dumps(dict(valid, since="2026-10-01T00:00:00")), + json.dumps(dict(valid, since=triage.iso(NOW))), + ] + for value in invalid: + with self.subTest(value=value): + self.assertEqual(triage.read_state(value), {}) + + def test_invalid_stored_state_does_not_block_metrics(self): + item = { + "id": "item", + "content": {"number": 42}, + "fieldValues": { + "nodes": [{"field": {"name": "Triage state"}, "text": "[]"}] + }, + } + api = triage.GitHub("not-a-real-token") + fields = {key: {"name": key} for key in triage.FIELDS} + with ( + patch.object(triage, "pr_commits", return_value=[]), + patch.object(triage, "snapshot", return_value=pr()), + patch.object(api, "pages", return_value=[]), + patch.object(triage, "update_field") as update, + ): + result = triage.process( + api, + api, + {"id": "project"}, + fields, + item, + "example/repo", + NOW, + True, + False, + ) + self.assertEqual(result["age"], 20) + self.assertEqual(result["idle"], 20) + self.assertEqual(result["ready_days"], 0) + state = next( + call.args[4] + for call in update.call_args_list + if call.args[3]["name"] == "Triage state" + ) + self.assertEqual(json.loads(state)["since"], triage.iso(NOW)) + + def test_bot_and_reminder_do_not_reset_idle(self): + old = NOW - timedelta(days=10) + comments = [ + comment(old), + comment(NOW, BOT), + comment(NOW, PERSON, triage.MARKER), + ] + self.assertEqual(triage.latest_activity(pr(), comments, [], [], []), old) + + def test_reviews_inline_and_human_commits_count(self): + reviews = [ + {"user": PERSON, "submitted_at": triage.iso(NOW - timedelta(days=4))} + ] + inline = [comment(NOW - timedelta(days=3))] + commits = [ + { + "author": {"user": PERSON}, + "committedDate": triage.iso(NOW - timedelta(days=2)), + } + ] + self.assertEqual( + triage.latest_activity(pr(), [], reviews, inline, commits), + NOW - timedelta(days=2), + ) + + def test_weekly_cooldown_including_uncertain_previous_send(self): + self.assertTrue(triage.due([], NOW)) + self.assertFalse( + triage.due([comment(NOW - timedelta(days=6), BOT, triage.MARKER)], NOW) + ) + self.assertTrue( + triage.due([comment(NOW - timedelta(days=7), BOT, triage.MARKER)], NOW) + ) + + def test_responsibility_fallback(self): + self.assertEqual( + triage.recipients({"assignees": [PERSON]}, [], True), ["author"] + ) + self.assertEqual(triage.recipients({"user": PERSON}, [], False), ["author"]) + self.assertEqual( + triage.recipients( + {"user": PERSON}, + [{"user": {"type": "User", "login": "reviewer"}, "state": "APPROVED"}], + True, + ), + ["reviewer"], + ) + + def test_api_paginates_beyond_first_hundred(self): + api = triage.GitHub("not-a-real-token") + with patch.object( + api, "call", side_effect=[[{}] * 100, [{"last": True}]] + ) as call: + self.assertEqual(len(api.pages("example")), 101) + self.assertIn("page=2", call.call_args.args[0]) + + def test_commit_history_includes_activity_after_250_commits(self): + old = { + "author": {"user": PERSON}, + "committedDate": triage.iso(NOW - timedelta(days=19)), + } + recent = dict(old, committedDate=triage.iso(NOW)) + pages = [] + for index, commits in enumerate( + ([old] * 100, [old] * 100, [old] * 50 + [recent]) + ): + pages.append( + { + "repository": { + "pullRequest": { + "commits": { + "nodes": [{"commit": commit} for commit in commits], + "pageInfo": { + "hasNextPage": index < 2, + "endCursor": str(index), + }, + } + } + } + } + ) + api = triage.GitHub("not-a-real-token") + with patch.object(api, "graphql", side_effect=pages) as query: + commits = triage.pr_commits(api, "example", "repo", 42) + self.assertEqual(len(commits), 251) + self.assertEqual(triage.latest_activity(pr(), [], [], [], commits), NOW) + self.assertEqual( + [call.kwargs["cursor"] for call in query.call_args_list], + [None, "0", "1"], + ) + + def test_bot_and_unlinked_commits_do_not_reset_idle(self): + commits = [ + { + "author": {"user": BOT}, + "committer": None, + "committedDate": triage.iso(NOW), + }, + { + "author": None, + "committer": {"user": None}, + "committedDate": triage.iso(NOW), + }, + ] + self.assertEqual( + triage.latest_activity(pr(), [], [], [], commits), + triage.timestamp(pr()["createdAt"]), + ) + + def test_timeout_is_a_reportable_api_failure_without_retry(self): + api = triage.GitHub("not-a-real-token") + with patch.object( + triage.subprocess, "run", side_effect=subprocess.TimeoutExpired("gh", 90) + ) as run: + with self.assertRaisesRegex(RuntimeError, "GitHub request timed out"): + api.call("repos/example/repo/issues/42/comments", {"body": "reminder"}) + run.assert_called_once() + + def test_dry_run_has_no_mutations(self): + item = {"id": "item", "content": {"number": 42}, "fieldValues": {"nodes": []}} + api = triage.GitHub("not-a-real-token") + with ( + patch.object(triage, "pr_commits", return_value=[]), + patch.object(triage, "snapshot", return_value=pr()), + patch.object(api, "pages", return_value=[]), + patch.object(api, "call") as call, + ): + result = triage.process( + api, api, {"id": "project"}, {}, item, "example/repo", NOW, False, False + ) + self.assertTrue(result["reminder_due"]) + call.assert_not_called() + + def test_draft_transition_before_send_suppresses_comment(self): + item = {"id": "item", "content": {"number": 42}, "fieldValues": {"nodes": []}} + api = triage.GitHub("not-a-real-token") + fields = {key: {} for key in triage.FIELDS} + with ( + patch.object(triage, "pr_commits", return_value=[]), + patch.object(triage, "snapshot", side_effect=[pr(), pr(isDraft=True)]), + patch.object(api, "pages", return_value=[]), + patch.object(triage, "update_field"), + patch.object(api, "call") as call, + ): + triage.process( + api, + api, + {"id": "project"}, + fields, + item, + "example/repo", + NOW, + True, + True, + ) + call.assert_not_called() + + def test_existing_reminder_prevents_send_after_ambiguous_run(self): + item = {"id": "item", "content": {"number": 42}, "fieldValues": {"nodes": []}} + api = triage.GitHub("not-a-real-token") + fields = {key: {} for key in triage.FIELDS} + with ( + patch.object(triage, "pr_commits", return_value=[]), + patch.object(triage, "snapshot", return_value=pr()), + patch.object( + api, + "pages", + side_effect=[[], [], [], [comment(NOW, BOT, triage.MARKER)]], + ), + patch.object(triage, "update_field"), + patch.object(api, "call") as call, + ): + triage.process( + api, + api, + {"id": "project"}, + fields, + item, + "example/repo", + NOW, + True, + True, + ) + call.assert_not_called() + + def test_merge_before_last_rest_check_suppresses_send(self): + item = {"id": "item", "content": {"number": 42}, "fieldValues": {"nodes": []}} + api = triage.GitHub("not-a-real-token") + fields = {key: {} for key in triage.FIELDS} + with ( + patch.object(triage, "pr_commits", return_value=[]), + patch.object(triage, "snapshot", return_value=pr()), + patch.object(api, "pages", return_value=[]), + patch.object(triage, "update_field"), + patch.object( + api, "call", return_value={"state": "closed", "draft": False} + ) as call, + ): + triage.process( + api, + api, + {"id": "project"}, + fields, + item, + "example/repo", + NOW, + True, + True, + ) + call.assert_called_once_with("repos/example/repo/pulls/42") + + def test_live_reminder_combines_rules_and_mentions(self): + previous = { + "head": "abc", + "since": triage.iso(NOW - timedelta(days=8)), + "observed": triage.iso(NOW - timedelta(days=1)), + } + item = { + "id": "item", + "content": {"number": 42}, + "fieldValues": { + "nodes": [ + {"field": {"name": "Triage state"}, "text": json.dumps(previous)} + ] + }, + } + api = triage.GitHub("not-a-real-token") + fields = {key: {} for key in triage.FIELDS} + detail = {"state": "open", "draft": False, "assignees": [PERSON]} + with ( + patch.object(triage, "pr_commits", return_value=[]), + patch.object(triage, "snapshot", return_value=pr()), + patch.object(api, "pages", return_value=[]), + patch.object(triage, "update_field"), + patch.object(api, "call", side_effect=[detail, {}]) as call, + ): + triage.process( + api, + api, + {"id": "project"}, + fields, + item, + "example/repo", + NOW, + True, + True, + ) + body = call.call_args.args[1]["body"] + self.assertIn("@author", body) + self.assertIn("7 consecutive", body) + self.assertIn("14 consecutive", body) + self.assertIn(triage.MARKER, body) + + +if __name__ == "__main__": + unittest.main()