From b40b3acd1db184ab937107290a1c8cd941bc955f Mon Sep 17 00:00:00 2001 From: Steve Loeppky Date: Wed, 26 Aug 2026 12:08:36 -0700 Subject: [PATCH 1/4] feat: enforce R-FC-014 (recently-Done item without Cycle -> current cycle) hourly Codifies the "recently completed items need a Cycle" sweep-playbook check into a numbered rule with mechanical enforcement, mirroring R-FC-012 but scoped to items moved to Done in the last day instead of active items updated in the last 3 days. DoneCycleRule subclasses CycleRule, reusing its apply_one and mutation-log guard, and only overrides select()'s query. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AkjQydxZny5DgxZi7BVmU2 --- foc-board-rules/field-completeness.md | 10 +++++ foc-mechanical-rules/README.md | 2 + .../foc_mechanical_rules/registry.py | 10 ++++- .../foc_mechanical_rules/rules/cycle.py | 37 +++++++++++++++++- foc-mechanical-rules/tests/test_cli.py | 2 +- foc-mechanical-rules/tests/test_cycle_rule.py | 39 ++++++++++++++++++- 6 files changed, 95 insertions(+), 5 deletions(-) diff --git a/foc-board-rules/field-completeness.md b/foc-board-rules/field-completeness.md index 566e7f1..ddacd52 100644 --- a/foc-board-rules/field-completeness.md +++ b/foc-board-rules/field-completeness.md @@ -174,3 +174,13 @@ If no reasonable inference can be made, **leave Cycle Theme blank** — do not i **Scope:** Only applies when the item's Cycle is a *past* iteration. An item with no Cycle at all is [R-FC-012](#r-fc-012-recently-active-items-without-a-cycle-get-the-current-cycle)'s concern, not this one's. An item whose Cycle is a *future* iteration (deliberately planned ahead) is left alone. **Skip if a human has since moved it back:** If this rule (or any mutation this tool made under this rule's id) previously moved the item's Cycle *away from* the past-cycle value it currently holds, do not re-apply — flag for human review instead. A human moving an item back to a past cycle after this tool moved it forward is a deliberate signal (e.g. correcting a mistaken auto-move, or intentionally leaving it attributed to the cycle where the work actually happened) that automation shouldn't fight. See [`foc-mechanical-rules`'s "Mutation log" section](../foc-mechanical-rules/README.md#mutation-log) for how this is tracked and its limits (only reversions this tool's own history witnessed are caught). **Why:** An item still open once its cycle has ended almost always means the cycle ended before the work did — the Cycle value is now stale and understates what's actually in flight this cycle. Leaving it in the old iteration hides the work from current cycle planning and reporting. + +## R-FC-014: Recently-completed items without a Cycle get the current cycle + +**Enforced mechanically, hourly:** this rule runs automatically via [`foc-mechanical-rules`](../foc-mechanical-rules/) (see [its implementation](../foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py)), scheduled by [`.github/workflows/foc-board-mechanical-rules.yml`](../.github/workflows/foc-board-mechanical-rules.yml). The prose below stays canonical for *what* and *why*; the linked module is canonical for exactly how it's evaluated. + +**When:** Any **issue or PR** on the board in "🎉 Done" has no Cycle set and was updated within the last day (`updated:>@today-1d`). +**Action:** Set the Cycle to the current active cycle (the iteration whose date range contains today). +**Relationship to [R-FC-012](#r-fc-012-recently-active-items-without-a-cycle-get-the-current-cycle):** Same "no cycle -> assign current cycle" logic, but R-FC-012 explicitly excludes Done items — this rule is that gap's Done-side counterpart, catching items that moved straight to Done without ever getting a Cycle (e.g. via R-PR-008/R-PR-009's merged/closed-to-Done moves). +**Why a 1-day window, not 3:** An item moved to Done is finished — the window only needs to be wide enough to catch the mechanical Done-move rules' own hourly cadence, not to account for ongoing activity the way R-FC-012's 3-day window does for still-open items. +**Why:** A completed item with no Cycle is invisible in cycle-based reporting (velocity, burndown, "what shipped this cycle") even though the work is done and attributable. Left uncorrected, this silently undercounts every cycle's completed work. diff --git a/foc-mechanical-rules/README.md b/foc-mechanical-rules/README.md index 3618c91..40754c4 100644 --- a/foc-mechanical-rules/README.md +++ b/foc-mechanical-rules/README.md @@ -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-FC-014](../foc-board-rules/field-completeness.md#r-fc-014-recently-completed-items-without-a-cycle-get-the-current-cycle) | cycle | Issues/PRs moved to Done in the last day with no Cycle get the current cycle (same guard against re-adding a human-cleared cycle as R-FC-012) | | [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 @@ -49,6 +50,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-FC-014 (cycle) | 1 paginated board query (Done items only, 1-day window) | 1 GraphQL query (iterations, shared helper with R-FC-012) | none | Batched, same mechanism as R-FC-012 (`DoneCycleRule` subclasses `CycleRule`, overriding only `select()`) | | 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. diff --git a/foc-mechanical-rules/foc_mechanical_rules/registry.py b/foc-mechanical-rules/foc_mechanical_rules/registry.py index ba5b4b4..126b66a 100644 --- a/foc-mechanical-rules/foc_mechanical_rules/registry.py +++ b/foc-mechanical-rules/foc_mechanical_rules/registry.py @@ -9,9 +9,15 @@ from .rule import Rule from .rules.assignee import AssigneeRule -from .rules.cycle import CycleRule, PastCycleRule +from .rules.cycle import CycleRule, DoneCycleRule, PastCycleRule from .rules.pr_status import PRStatusRule def default_rules() -> List[Rule]: - return [AssigneeRule(), CycleRule(), PastCycleRule(), PRStatusRule()] + return [ + AssigneeRule(), + CycleRule(), + PastCycleRule(), + DoneCycleRule(), + PRStatusRule(), + ] diff --git a/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py b/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py index 4184956..96b8f70 100644 --- a/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py +++ b/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py @@ -1,8 +1,9 @@ -"""R-FC-012 / R-FC-013: keep an item's Cycle pointed at the current iteration. +"""R-FC-012 / R-FC-013 / R-FC-014: keep an item's Cycle pointed at the current iteration. Canonical English rules: - foc-board-rules/field-completeness.md#r-fc-012-recently-active-items-without-a-cycle-get-the-current-cycle - foc-board-rules/field-completeness.md#r-fc-013-open-items-in-a-past-cycle-should-move-to-the-current-cycle +- foc-board-rules/field-completeness.md#r-fc-014-recently-completed-items-without-a-cycle-get-the-current-cycle This module is that rule's canonical implementation — see rules/assignee.py's docstring for why the markdown and this module link back to each other. @@ -278,6 +279,40 @@ def apply_one( ) +class DoneCycleRule(CycleRule): + """Same "no cycle -> assign current cycle" logic as R-FC-012, scoped to + items that just moved to Done instead of items that are still active. + + Subclasses ``CycleRule`` rather than ``_CycleFieldRule`` directly since + ``apply_one`` is identical -- only ``select``'s query differs (Done + instead of not-Done, a 1-day window instead of 3 days). + """ + + id = "R-FC-014" + doc_url = ( + "https://github.com/FilOzone/tpm-utils/blob/master/foc-board-rules/" + "field-completeness.md#r-fc-014-recently-completed-items-without-a-cycle-get-the-current-cycle" + ) + + def select(self, session: requests.Session) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + cursor: Optional[str] = None + while True: + result = list_items( + session, + org=self.org, + project_number=self.project_number, + query='status:"🎉 Done" no:cycle updated:>@today-1d', + fields=["Repository", "Id", "Title", "url"], + cursor=cursor, + ) + items.extend(result["items"]) + if not result["has_more"]: + break + cursor = result["next_cursor"] + return items + + class PastCycleRule(_CycleFieldRule): id = "R-FC-013" doc_url = ( diff --git a/foc-mechanical-rules/tests/test_cli.py b/foc-mechanical-rules/tests/test_cli.py index 6bd713b..4a8bba1 100644 --- a/foc-mechanical-rules/tests/test_cli.py +++ b/foc-mechanical-rules/tests/test_cli.py @@ -28,7 +28,7 @@ def test_no_rule_flag_runs_every_registered_rule(capsys): _run_cli(["--dry-run", "--token", "x"], run_all_mock) ran_ids = {rule.id for rule in run_all_mock.call_args.args[1]} - assert ran_ids == {"R-PR-001", "R-FC-012", "R-FC-013", "R-PR-010"} + assert ran_ids == {"R-PR-001", "R-FC-012", "R-FC-013", "R-FC-014", "R-PR-010"} def test_rule_flag_filters_to_the_named_rule(capsys): diff --git a/foc-mechanical-rules/tests/test_cycle_rule.py b/foc-mechanical-rules/tests/test_cycle_rule.py index 28b9970..f5234ba 100644 --- a/foc-mechanical-rules/tests/test_cycle_rule.py +++ b/foc-mechanical-rules/tests/test_cycle_rule.py @@ -7,7 +7,11 @@ from foc_mechanical_rules.mutation_log import MutationLog, MutationRecord from foc_mechanical_rules.rule import ActionResult, Rule -from foc_mechanical_rules.rules.cycle import CycleRule, get_current_cycle_title +from foc_mechanical_rules.rules.cycle import ( + CycleRule, + DoneCycleRule, + get_current_cycle_title, +) ITEM = { "Repository": "FilOzone/dealbot", @@ -204,3 +208,36 @@ def test_mutate_pending_reports_per_item_failure(mock_bulk): def test_cycle_rule_is_a_rule(): assert isinstance(CycleRule(), Rule) + + +@patch("foc_mechanical_rules.rules.cycle.list_items") +def test_done_cycle_rule_queries_done_items_with_a_one_day_window(mock_list_items): + mock_list_items.return_value = {"items": [], "has_more": False, "next_cursor": None} + + DoneCycleRule().select(MagicMock()) + + query = mock_list_items.call_args.kwargs["query"] + assert 'status:"🎉 Done"' in query + assert "no:cycle" in query + assert "updated:>@today-1d" in query + + +@patch( + "foc_mechanical_rules.rules.cycle.get_current_cycle_title", return_value="202608-2" +) +def test_done_cycle_rule_reuses_cycle_rules_apply_one(mock_get_cycle): + # DoneCycleRule only overrides select() -- apply_one's behavior (queue a + # pending mutation for a candidate with no prior history) should match + # CycleRule's exactly. + rule = DoneCycleRule() + result = rule.apply_one( + MagicMock(), ITEM, dry_run=False, mutation_log=MutationLog() + ) + + assert result.status == "pending" + assert result.new_value == "202608-2" + assert rule.id == "R-FC-014" + + +def test_done_cycle_rule_is_a_rule(): + assert isinstance(DoneCycleRule(), Rule) From aae8f8ac39de30f3348e4d09907ca5e578075c95 Mon Sep 17 00:00:00 2001 From: Steve Loeppky Date: Wed, 26 Aug 2026 12:38:28 -0700 Subject: [PATCH 2/4] refactor: share R-FC-012/014's window and query shape via a status filter attribute Match R-FC-014's lookback window to R-FC-012's (3 days instead of 1) so the two rules differ only in which side of Done they target. DoneCycleRule now overrides a single _STATUS_FILTER class attribute instead of duplicating select() entirely. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AkjQydxZny5DgxZi7BVmU2 --- foc-board-rules/field-completeness.md | 5 ++- foc-mechanical-rules/README.md | 4 +-- .../foc_mechanical_rules/rules/cycle.py | 35 +++++++------------ foc-mechanical-rules/tests/test_cycle_rule.py | 7 ++-- 4 files changed, 22 insertions(+), 29 deletions(-) diff --git a/foc-board-rules/field-completeness.md b/foc-board-rules/field-completeness.md index ddacd52..4054e26 100644 --- a/foc-board-rules/field-completeness.md +++ b/foc-board-rules/field-completeness.md @@ -179,8 +179,7 @@ If no reasonable inference can be made, **leave Cycle Theme blank** — do not i **Enforced mechanically, hourly:** this rule runs automatically via [`foc-mechanical-rules`](../foc-mechanical-rules/) (see [its implementation](../foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py)), scheduled by [`.github/workflows/foc-board-mechanical-rules.yml`](../.github/workflows/foc-board-mechanical-rules.yml). The prose below stays canonical for *what* and *why*; the linked module is canonical for exactly how it's evaluated. -**When:** Any **issue or PR** on the board in "🎉 Done" has no Cycle set and was updated within the last day (`updated:>@today-1d`). +**When:** Any **issue or PR** on the board in "🎉 Done" has no Cycle set and was updated within the last 3 days (`updated:>@today-3d`). **Action:** Set the Cycle to the current active cycle (the iteration whose date range contains today). -**Relationship to [R-FC-012](#r-fc-012-recently-active-items-without-a-cycle-get-the-current-cycle):** Same "no cycle -> assign current cycle" logic, but R-FC-012 explicitly excludes Done items — this rule is that gap's Done-side counterpart, catching items that moved straight to Done without ever getting a Cycle (e.g. via R-PR-008/R-PR-009's merged/closed-to-Done moves). -**Why a 1-day window, not 3:** An item moved to Done is finished — the window only needs to be wide enough to catch the mechanical Done-move rules' own hourly cadence, not to account for ongoing activity the way R-FC-012's 3-day window does for still-open items. +**Relationship to [R-FC-012](#r-fc-012-recently-active-items-without-a-cycle-get-the-current-cycle):** Same "no cycle -> assign current cycle" logic and the same 3-day window, but R-FC-012 explicitly excludes Done items — this rule is that gap's Done-side counterpart, catching items that moved straight to Done without ever getting a Cycle (e.g. via R-PR-008/R-PR-009's merged/closed-to-Done moves). **Why:** A completed item with no Cycle is invisible in cycle-based reporting (velocity, burndown, "what shipped this cycle") even though the work is done and attributable. Left uncorrected, this silently undercounts every cycle's completed work. diff --git a/foc-mechanical-rules/README.md b/foc-mechanical-rules/README.md index 40754c4..cdbe82c 100644 --- a/foc-mechanical-rules/README.md +++ b/foc-mechanical-rules/README.md @@ -33,7 +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-FC-014](../foc-board-rules/field-completeness.md#r-fc-014-recently-completed-items-without-a-cycle-get-the-current-cycle) | cycle | Issues/PRs moved to Done in the last day with no Cycle get the current cycle (same guard against re-adding a human-cleared cycle as R-FC-012) | +| [R-FC-014](../foc-board-rules/field-completeness.md#r-fc-014-recently-completed-items-without-a-cycle-get-the-current-cycle) | cycle | Issues/PRs moved to Done and updated in the last 3 days with no Cycle get the current cycle (same guard against re-adding a human-cleared cycle as R-FC-012) | | [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 @@ -50,7 +50,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-FC-014 (cycle) | 1 paginated board query (Done items only, 1-day window) | 1 GraphQL query (iterations, shared helper with R-FC-012) | none | Batched, same mechanism as R-FC-012 (`DoneCycleRule` subclasses `CycleRule`, overriding only `select()`) | +| R-FC-014 (cycle) | 1 paginated board query (Done items only, same 3-day window as R-FC-012) | 1 GraphQL query (iterations, shared helper with R-FC-012) | none | Batched, same mechanism as R-FC-012 (`DoneCycleRule` subclasses `CycleRule`, overriding only the `_STATUS_FILTER` class attribute) | | 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. diff --git a/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py b/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py index 96b8f70..7cd633e 100644 --- a/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py +++ b/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py @@ -180,11 +180,19 @@ def mutate_pending( class CycleRule(_CycleFieldRule): + """Recently-active items with no Cycle get the current cycle. + + ``_STATUS_FILTER`` is the only thing ``DoneCycleRule`` (R-FC-014) + overrides -- both rules share the same "no cycle in the last 3 days" + query shape, just scoped to Done vs. not-Done items. + """ + id = "R-FC-012" doc_url = ( "https://github.com/FilOzone/tpm-utils/blob/master/foc-board-rules/" "field-completeness.md#r-fc-012-recently-active-items-without-a-cycle-get-the-current-cycle" ) + _STATUS_FILTER = '-status:"🎉 Done"' def __init__(self, org: str = FILOZ_ORG, project_number: int = PROJECT_NUMBER): self.org = org @@ -208,7 +216,7 @@ def select(self, session: requests.Session) -> List[Dict[str, Any]]: session, org=self.org, project_number=self.project_number, - query='-status:"🎉 Done" no:cycle updated:>@today-3d', + query=f"{self._STATUS_FILTER} no:cycle updated:>@today-3d", fields=["Repository", "Id", "Title", "url"], cursor=cursor, ) @@ -283,9 +291,9 @@ class DoneCycleRule(CycleRule): """Same "no cycle -> assign current cycle" logic as R-FC-012, scoped to items that just moved to Done instead of items that are still active. - Subclasses ``CycleRule`` rather than ``_CycleFieldRule`` directly since - ``apply_one`` is identical -- only ``select``'s query differs (Done - instead of not-Done, a 1-day window instead of 3 days). + Subclasses ``CycleRule`` and only overrides ``_STATUS_FILTER`` -- + ``select()``'s query shape, ``apply_one``, and the mutation-log guard + are otherwise identical between the two rules. """ id = "R-FC-014" @@ -293,24 +301,7 @@ class DoneCycleRule(CycleRule): "https://github.com/FilOzone/tpm-utils/blob/master/foc-board-rules/" "field-completeness.md#r-fc-014-recently-completed-items-without-a-cycle-get-the-current-cycle" ) - - def select(self, session: requests.Session) -> List[Dict[str, Any]]: - items: List[Dict[str, Any]] = [] - cursor: Optional[str] = None - while True: - result = list_items( - session, - org=self.org, - project_number=self.project_number, - query='status:"🎉 Done" no:cycle updated:>@today-1d', - fields=["Repository", "Id", "Title", "url"], - cursor=cursor, - ) - items.extend(result["items"]) - if not result["has_more"]: - break - cursor = result["next_cursor"] - return items + _STATUS_FILTER = 'status:"🎉 Done"' class PastCycleRule(_CycleFieldRule): diff --git a/foc-mechanical-rules/tests/test_cycle_rule.py b/foc-mechanical-rules/tests/test_cycle_rule.py index f5234ba..0c3374b 100644 --- a/foc-mechanical-rules/tests/test_cycle_rule.py +++ b/foc-mechanical-rules/tests/test_cycle_rule.py @@ -211,15 +211,18 @@ def test_cycle_rule_is_a_rule(): @patch("foc_mechanical_rules.rules.cycle.list_items") -def test_done_cycle_rule_queries_done_items_with_a_one_day_window(mock_list_items): +def test_done_cycle_rule_queries_done_items_with_the_same_window_as_cycle_rule( + mock_list_items, +): mock_list_items.return_value = {"items": [], "has_more": False, "next_cursor": None} DoneCycleRule().select(MagicMock()) query = mock_list_items.call_args.kwargs["query"] assert 'status:"🎉 Done"' in query + assert '-status:"🎉 Done"' not in query assert "no:cycle" in query - assert "updated:>@today-1d" in query + assert "updated:>@today-3d" in query @patch( From 7b129f52e5dc9d93b037312511abad450c26c374 Mon Sep 17 00:00:00 2001 From: Steve Loeppky Date: Wed, 26 Aug 2026 12:43:26 -0700 Subject: [PATCH 3/4] docs: fix R-FC-014 wording to match its actual status+recency filter Per Copilot review feedback: the rule doesn't detect a Done transition event, it filters on current status plus updated:>@today-3d, same as R-FC-012 does for its own status. "moved to Done" overclaimed what the query actually checks. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AkjQydxZny5DgxZi7BVmU2 --- foc-mechanical-rules/README.md | 2 +- foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/foc-mechanical-rules/README.md b/foc-mechanical-rules/README.md index cdbe82c..258abef 100644 --- a/foc-mechanical-rules/README.md +++ b/foc-mechanical-rules/README.md @@ -33,7 +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-FC-014](../foc-board-rules/field-completeness.md#r-fc-014-recently-completed-items-without-a-cycle-get-the-current-cycle) | cycle | Issues/PRs moved to Done and updated in the last 3 days with no Cycle get the current cycle (same guard against re-adding a human-cleared cycle as R-FC-012) | +| [R-FC-014](../foc-board-rules/field-completeness.md#r-fc-014-recently-completed-items-without-a-cycle-get-the-current-cycle) | cycle | Issues/PRs in Done, updated in the last 3 days, with no Cycle get the current cycle (same guard against re-adding a human-cleared cycle as R-FC-012) | | [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 diff --git a/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py b/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py index 7cd633e..89ab9af 100644 --- a/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py +++ b/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py @@ -289,7 +289,7 @@ def apply_one( class DoneCycleRule(CycleRule): """Same "no cycle -> assign current cycle" logic as R-FC-012, scoped to - items that just moved to Done instead of items that are still active. + items currently in Done instead of items that are still active. Subclasses ``CycleRule`` and only overrides ``_STATUS_FILTER`` -- ``select()``'s query shape, ``apply_one``, and the mutation-log guard From 6063504d8f1501c8e6a92552880bf857f17cc0a5 Mon Sep 17 00:00:00 2001 From: Steve Loeppky Date: Wed, 26 Aug 2026 12:45:09 -0700 Subject: [PATCH 4/4] docs: tighten DoneCycleRule override wording per Copilot review _STATUS_FILTER isn't literally the only class attribute DoneCycleRule overrides (id/doc_url also differ) -- clarify these docstrings and the test comment refer to the selection-query behavior specifically. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AkjQydxZny5DgxZi7BVmU2 --- foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py | 8 +++++--- foc-mechanical-rules/tests/test_cycle_rule.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py b/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py index 89ab9af..687f6ff 100644 --- a/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py +++ b/foc-mechanical-rules/foc_mechanical_rules/rules/cycle.py @@ -183,8 +183,9 @@ class CycleRule(_CycleFieldRule): """Recently-active items with no Cycle get the current cycle. ``_STATUS_FILTER`` is the only thing ``DoneCycleRule`` (R-FC-014) - overrides -- both rules share the same "no cycle in the last 3 days" - query shape, just scoped to Done vs. not-Done items. + overrides to change its selection query -- both rules share the same + "no cycle in the last 3 days" query shape, just scoped to Done vs. + not-Done items. """ id = "R-FC-012" @@ -291,7 +292,8 @@ class DoneCycleRule(CycleRule): """Same "no cycle -> assign current cycle" logic as R-FC-012, scoped to items currently in Done instead of items that are still active. - Subclasses ``CycleRule`` and only overrides ``_STATUS_FILTER`` -- + Subclasses ``CycleRule`` and only overrides ``_STATUS_FILTER`` to + change its selection query (plus ``id``/``doc_url`` for identity) -- ``select()``'s query shape, ``apply_one``, and the mutation-log guard are otherwise identical between the two rules. """ diff --git a/foc-mechanical-rules/tests/test_cycle_rule.py b/foc-mechanical-rules/tests/test_cycle_rule.py index 0c3374b..7c440f8 100644 --- a/foc-mechanical-rules/tests/test_cycle_rule.py +++ b/foc-mechanical-rules/tests/test_cycle_rule.py @@ -229,9 +229,9 @@ def test_done_cycle_rule_queries_done_items_with_the_same_window_as_cycle_rule( "foc_mechanical_rules.rules.cycle.get_current_cycle_title", return_value="202608-2" ) def test_done_cycle_rule_reuses_cycle_rules_apply_one(mock_get_cycle): - # DoneCycleRule only overrides select() -- apply_one's behavior (queue a - # pending mutation for a candidate with no prior history) should match - # CycleRule's exactly. + # DoneCycleRule only overrides the status filter used by select() -- + # apply_one's behavior (queue a pending mutation for a candidate with no + # prior history) should match CycleRule's exactly. rule = DoneCycleRule() result = rule.apply_one( MagicMock(), ITEM, dry_run=False, mutation_log=MutationLog()