Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions foc-board-rules/pr-hygiene.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,32 @@ Rules for keeping pull request items on the FOC board well-formed.

## R-PR-005: Draft PRs should be In Progress

**Enforced mechanically for the Triage case, hourly:** see [R-PR-010](#r-pr-010-triage-prs-should-be-routed-to-the-correct-status) below — a sweep no longer needs to move a draft PR out of Triage by hand. Non-Triage cases (Awaiting review, Approved, Issue awaiting PR merge) are still sweep-applied.

**When:** A PR is a draft and its status is "📌 Triage", "🔎 Awaiting review", "✔️ Approved by reviewer", or "⌚️ Issue awaiting PR merge".
**Action:** Set Status to `⌨️ In Progress`.
**Why:** A draft PR is not ready for review or approval. If it's in Triage, it should move to In Progress since someone is actively working on it. If it's in Awaiting Review or later, the author likely converted it back to draft after feedback — the board should reflect that it's back in active development. Draft PRs in Todo or In Progress are fine as-is.

## R-PR-006: Non-draft, non-bot PRs in Triage or In Progress — determine correct status

**Enforced mechanically for the Triage case, hourly:** see [R-PR-010](#r-pr-010-triage-prs-should-be-routed-to-the-correct-status) below. The In Progress re-evaluation case is still sweep-applied.

**When:** A PR is not a draft, not authored by a bot, not a release PR, and its status is "📌 Triage" or "⌨️ In Progress".
**Action:** Compute the derived inputs and apply the [PR status determination table](pr-status-table.md); it is the canonical routing logic (this rule's former prose cases 1-4, the comments-count-as-reviews paragraph, and the carve-outs from pdp-explorer#118, dealbot#638, and filecoin-services#522 all live there now, alongside the R-SL-001/007 routing they interact with).
**How to check:** Use `gh pr view -R <repo> <number> --json reviews,commits,comments --jq '{reviews: [.reviews[] | {author: .author.login, state: .state, submittedAt: .submittedAt}], comments: [.comments[] | {author: .author.login, authorAssociation: .authorAssociation, body: .body, createdAt: .createdAt}], lastCommit: .commits[-1].committedDate}'` to get the timestamps and comment data the table's `last_feedback` input needs — Phase 1 (`gh pr list`) never includes `comments`, so this Phase 2 call is required for every R-PR-006 candidate, not just ones where Phase 1 shows formal review engagement. A PR with only substantive comments and no formal review looks identical to a PR with zero engagement in Phase 1 data, and skipping Phase 2 there would miss it.
**Why:** Non-draft, non-bot PRs should always leave Triage, and In Progress PRs may need re-evaluation. But the destination depends on review state; not every PR goes to Awaiting Review. A PR with unaddressed feedback belongs in In Progress, a PR with a merge-authority approval belongs in Approved, and a PR with no feedback or addressed feedback belongs in Awaiting Review. This is the counterpart to R-PR-005: when a draft PR becomes non-draft, it advances, but the destination depends on what reviewers have already said.

## R-PR-010: Triage PRs should be routed to the correct status

**Enforced mechanically, hourly:** this rule is a pure function of observable state, so it runs automatically via [`foc-mechanical-rules`](../foc-mechanical-rules/) (see [R-PR-010's implementation](../foc-mechanical-rules/foc_mechanical_rules/rules/pr_status.py)), scheduled by [`.github/workflows/foc-board-mechanical-rules.yml`](../.github/workflows/foc-board-mechanical-rules.yml). A sweep no longer needs to apply the Triage case of R-PR-005/R-PR-006 by hand — the prose there stays canonical for what those rules do and why; this module is canonical for exactly how the Triage case is evaluated.

**When:** A PR on the board has status "📌 Triage".
**Action:** Compute the [PR status determination table](pr-status-table.md)'s target status (row 1: draft → In Progress; row 2: bot/release → Todo; row 3: authoritative approval and no blocking changes-requested → Approved by reviewer; rows 4-6: based on formal reviews vs. last commit → In Progress or Awaiting review) and set Status to it.
**Guard — respect an explicit return to Triage:** Before computing a target, check the PR's Status field change history (GitHub's `ProjectV2ItemStatusChangedEvent` timeline — the one project field with real GitHub-provided history; see [R-FC-012](field-completeness.md#r-fc-012-recently-active-items-without-a-cycle-get-the-current-cycle)'s note on why Cycle needs its own log but Status doesn't). If the most recent status change moved the item *into* Triage from some other status (Todo, In Progress, Awaiting review, etc.) rather than the initial add-to-project default, a human deliberately moved it back — flag instead of auto-routing it back out.
**Simplification vs. the full table — comments:** unlike the full table, this rule never treats informal PR *comments* as feedback (`last_feedback` only comes from formal reviews: APPROVED / CHANGES_REQUESTED / COMMENTED) — judging whether a comment is substantive feedback or coordination chatter requires reading it, which isn't a pure function of structured state. If a Triage PR has qualifying human comments after its last commit, this rule flags it instead of auto-routing so a human applies R-PR-006/R-SL-010 judgment.
**Simplification vs. R-SL-001 — approval language:** `authoritative_approval` doesn't parse approval text for conditional language ("approving assuming you address X"); any qualifying APPROVED review counts. R-SL-001's language-based superseding nuance is a judgment call, not a fact lookup, so it's out of scope here.
**Why:** Triage PRs should never sit there once someone starts real work on them (draft) or once they're ready for eyes (non-draft) — see R-PR-005/R-PR-006. But automation shouldn't fight a human who deliberately re-triaged something; the status-history guard makes that distinction on real GitHub data instead of guessing from board state alone.

## R-PR-007: Awaiting Review PRs must have human reviewer engagement

**When:** A PR has status "🔎 Awaiting review" (including PRs just routed there by R-PR-006) but has no human reviewer — neither a pending request nor a submitted review from a human.
Expand Down
2 changes: 2 additions & 0 deletions foc-mechanical-rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Neither `mutation_log.py` nor any rule module knows or cares how the log survive
| [R-PR-001](../foc-board-rules/pr-hygiene.md#r-pr-001-unassigned-prs-should-be-assigned-to-their-author) | assignee | Unassigned open PRs are assigned to their author (skipping bots, with a merged-release-PR carve-out, and skipping PRs where a human explicitly removed the assignee) |
| [R-FC-012](../foc-board-rules/field-completeness.md#r-fc-012-recently-active-items-without-a-cycle-get-the-current-cycle) | cycle | Issues/PRs updated in the last 3 days with no Cycle get the current cycle, unless this tool previously set that item's Cycle to the current one and a human has since cleared it |
| [R-FC-013](../foc-board-rules/field-completeness.md#r-fc-013-open-items-in-a-past-cycle-should-move-to-the-current-cycle) | cycle | Open issues/PRs whose Cycle is a past iteration move to the current cycle, unless this tool previously moved that item off the same past cycle and a human has since moved it back |
| [R-PR-010](../foc-board-rules/pr-hygiene.md#r-pr-010-triage-prs-should-be-routed-to-the-correct-status) | status | PRs in Triage are routed to In Progress / Todo / Approved by reviewer / Awaiting review per the [PR status determination table](../foc-board-rules/pr-status-table.md), unless GitHub's real Status-field history shows a human explicitly moved the PR back to Triage |

## API call pattern per rule

Expand All @@ -48,6 +49,7 @@ Board size (~180 open items and growing) makes it easy for a rule to accidentall
| R-PR-001 (assignee) | 1 paginated board query | — | 1 REST `GET` (PR metadata) per candidate; **+1 REST `GET`** (issue events, paginated) unless skipped as a bot author | 1 REST `POST` per applied item (not batched — see gap below) |
| R-FC-012 (cycle) | 1 paginated board query | 1 GraphQL query (iterations) | none | Batched: all applied items in the run share 1 GraphQL mutation per 25 items (`_CycleFieldRule.mutate_pending`) |
| R-FC-013 (cycle) | 1 paginated board query (Cycle field included, so no separate read is needed to know an item's current cycle) | 1 GraphQL query (iterations, shared helper with R-FC-012) | none | Batched, same mechanism as R-FC-012 (via the shared `_CycleFieldRule.mutate_pending`) |
| R-PR-010 (status) | 1 paginated board query (Triage items only) | — | 1 GraphQL query per candidate (`get_pr_review_context`: draft/author/commits/reviews/reviewRequests/comments/status history in one round trip); **+1 REST `GET`** (collaborator permission) per unique reviewer login with a qualifying review, cached per (owner, repo, login) for the run | Batched: all applied items grouped by target status, 1 GraphQL mutation per 25 items per group (same `mutate_pending` pattern as `_CycleFieldRule`) |

**Known gap, not yet worth fixing:** R-PR-001's 1-2 REST reads per candidate PR are real per-item calls with no batched equivalent used today, unlike the two cycle rules. At current volume (dozens of candidates per hourly run, most REST GETs) it's well within GitHub's rate limits and not worth the complexity, but if candidate volume grows a lot, `github_projects_client`'s `nodes(ids: [ID!]!)` batching pattern (used by `set_field_value_bulk`'s old-value fetch) generalizes: a GraphQL `nodes()` query keyed by PR node IDs could fetch author + merge state for many PRs in one call, cutting the metadata `GET` to near-zero; issue-events (used only to detect a human `unassigned` event) would need a similar `timelineItems` batch to fully close the gap.

Expand Down
103 changes: 103 additions & 0 deletions foc-mechanical-rules/foc_mechanical_rules/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@

from __future__ import annotations

import re
from typing import Any, Dict, List, Optional

import requests
from github_projects_client import graphql_query

# Orgs where we have write access to manage assignees, milestones, reviewers,
# etc. Items from repos outside these orgs are "external items" (see
Expand All @@ -19,6 +21,36 @@
FILOZ_ORG = "FilOzone"
PROJECT_NUMBER = 14

# Matches R-PR-004's release-PR detection regex. Shared by any rule that
# needs to tell a release PR apart from a regular one (e.g. R-PR-001,
# R-PR-010).
RELEASE_PR_TITLE_RE = re.compile(r"^chore\((master|main)\):?\s*release|^chore: release")

BOT_LOGINS = {"dependabot", "filozzy"}


def is_bot_author(login: str) -> bool:
"""True if a REST-style login (e.g. a PR author) belongs to a bot.

Covers dependabot/FilOzzy by name, any `app/*` author, and the `[bot]`
suffix GitHub's REST API appends to bot logins. GraphQL results instead
expose an `author { __typename }` field ("Bot" vs "User") that's more
reliable when available -- see `is_bot_actor`.
"""
lower = login.lower()
return lower in BOT_LOGINS or lower.startswith("app/") or lower.endswith("[bot]")


def is_bot_actor(login: str, typename: str) -> bool:
"""True if a GraphQL actor (review/comment author, timeline actor, ...) is a bot.

Prefer this over `is_bot_author` for GraphQL results: `__typename ==
"Bot"` is authoritative (e.g. catches `copilot-pull-request-reviewer`,
which has no `[bot]` suffix on GraphQL), and the login-pattern check
still catches anything `__typename` alone might miss.
"""
return typename == "Bot" or is_bot_author(login)


def build_session(token: str) -> requests.Session:
"""Build a requests.Session authenticated with the given token."""
Expand Down Expand Up @@ -79,3 +111,74 @@ def add_assignee(
timeout=30,
)
resp.raise_for_status()


def get_collaborator_permission(
session: requests.Session, *, owner: str, repo: str, username: str
) -> Optional[str]:
"""Return a collaborator's permission level on a repo ("admin"/"write"/"maintain"/"triage"/"read").

Used to verify a reviewer actually has merge authority before treating
their review as authoritative (see R-SL-001's verification step). Returns
None if the lookup fails (e.g. the token lacks access to an external
repo, or the user isn't a collaborator) -- callers should treat that as
"not verified as write access", not as "read access confirmed".
"""
resp = session.get(
f"https://api.github.com/repos/{owner}/{repo}/collaborators/{username}/permission",
timeout=30,
)
if not resp.ok:
return None
return resp.json().get("permission")


PR_REVIEW_CONTEXT_QUERY = """
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
isDraft
author { login }
commits(last: 1) { nodes { commit { committedDate } } }
reviews(first: 100) {
nodes { author { login __typename } state submittedAt }
}
reviewRequests(first: 20) {
nodes { requestedReviewer { ... on User { login } } }
}
comments(last: 30) {
nodes { author { login __typename } createdAt }
}
timelineItems(last: 1, itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT]) {
nodes {
... on ProjectV2ItemStatusChangedEvent {
createdAt
previousStatus
status
}
}
}
}
}
}
"""


def get_pr_review_context(
session: requests.Session, *, owner: str, repo: str, number: str
) -> Dict[str, Any]:
"""Fetch everything R-PR-010 needs about a PR in one GraphQL round trip.

Draft state, author, last commit timestamp, reviews, pending review
requests, recent comments, and its Status field's change history
(`ProjectV2ItemStatusChangedEvent` -- see foc-mechanical-rules/README.md's
"Mutation log" section for why Status, uniquely among project fields, has
real GitHub-provided history instead of needing this tool's own log).
"""
data = graphql_query(
session,
PR_REVIEW_CONTEXT_QUERY,
{"owner": owner, "repo": repo, "number": int(number)},
)
pr = ((data.get("repository") or {}).get("pullRequest")) or {}
return pr
3 changes: 2 additions & 1 deletion foc-mechanical-rules/foc_mechanical_rules/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from .rule import Rule
from .rules.assignee import AssigneeRule
from .rules.cycle import CycleRule, PastCycleRule
from .rules.pr_status import PRStatusRule


def default_rules() -> List[Rule]:
return [AssigneeRule(), CycleRule(), PastCycleRule()]
return [AssigneeRule(), CycleRule(), PastCycleRule(), PRStatusRule()]
15 changes: 3 additions & 12 deletions foc-mechanical-rules/foc_mechanical_rules/rules/assignee.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

from __future__ import annotations

import re
from typing import Any, Dict, List, Optional

import requests
Expand All @@ -23,24 +22,16 @@
BLESSED_ORGS,
FILOZ_ORG,
PROJECT_NUMBER,
RELEASE_PR_TITLE_RE,
add_assignee,
get_issue_events,
get_pull_request,
is_bot_author,
parse_repo_ref,
)
from ..mutation_log import MutationLog
from ..rule import ActionResult, Rule

# Matches R-PR-004's release-PR detection regex.
RELEASE_PR_TITLE_RE = re.compile(r"^chore\((master|main)\):?\s*release|^chore: release")

BOT_LOGINS = {"dependabot", "filozzy"}


def _is_bot_author(login: str) -> bool:
lower = login.lower()
return lower in BOT_LOGINS or lower.startswith("app/") or lower.endswith("[bot]")


class AssigneeRule(Rule):
id = "R-PR-001"
Expand Down Expand Up @@ -112,7 +103,7 @@ def apply_one(
merged_by = (pr.get("merged_by") or {}).get("login")
is_release_pr = bool(RELEASE_PR_TITLE_RE.match(title))

if _is_bot_author(author):
if is_bot_author(author):
if merged and is_release_pr:
if not merged_by:
return ActionResult(
Expand Down
Loading
Loading