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
9 changes: 9 additions & 0 deletions .github/workflows/foc-board-mechanical-rules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ on:
- 'true'
- 'false'

# Serialize runs rather than letting them overlap: two concurrent runs would
# both restore the same mutation-log cache snapshot, could both move the same
# item, and the later cache save could silently drop the other run's
# mutations. cancel-in-progress stays false so a run in flight (which may
# already be mid-mutation) always finishes rather than being cut off.
concurrency:
group: foc-board-mechanical-rules
cancel-in-progress: false

jobs:
apply-rules:
runs-on: ubuntu-latest
Expand Down
10 changes: 10 additions & 0 deletions foc-board-rules/field-completeness.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,13 @@ If no reasonable inference can be made, **leave Cycle Theme blank** — do not i
**How the removal check works, and its limit:** see [`foc-mechanical-rules`'s "Mutation log" section](../foc-mechanical-rules/README.md#mutation-log) for why GitHub can't answer this directly and how the tool tracks it instead. The short version: this rule can only detect a removal it itself witnessed. A cycle a human cleared *before* this rule ever set it (or before that history existed) won't be caught — the item will just look like any other item missing a Cycle and will get (re-)assigned.
**Relationship to [R-FC-006](#r-fc-006-in-flight-prs-without-a-cycle-should-be-in-the-current-cycle) / [R-FC-009](#r-fc-009-in-flight-items-in-active-milestones-should-have-a-cycle):** Those rules are status/milestone-gated and remain the sweep's judgment-call fallback for items this rule's 3-day activity window doesn't reach (e.g. an in-flight item that's gone quiet for a week). This rule is the broader, simpler, purely time-based mechanical subset — any issue or PR, not just in-flight PRs or active-milestone items.
**Why:** Items with recent activity and no Cycle are a planning gap — they're clearly live work but invisible in cycle views. A 3-day activity window catches this quickly without pulling in stale backlog the way an unconditional "everything gets a cycle" rule would.

## R-FC-013: Open items in a past cycle should move to 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 (any status except "🎉 Done") has its Cycle set to an iteration whose date range has already ended (a "past cycle").
**Action:** Set the Cycle to the current active cycle (the iteration whose date range contains today).
**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.
10 changes: 9 additions & 1 deletion foc-board-rules/future-ideas.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Ideas for improving the FOC board tooling, collected during rule application ses

## Move the mechanical rules out of the LLM entirely

**Status: In progress.** [`foc-mechanical-rules`](../foc-mechanical-rules/) now runs hourly via GitHub Actions: R-PR-001 (unassigned PR -> author) and R-FC-012 (recently-active item with no Cycle -> current cycle). The rule-per-field, decision-table design is meant to grow: R-PR-002/003/004 (dependabot/release-PR theme+status) and R-PR-008/009 (merged/closed -> Done) are the natural next slice, following the same `Rule` pattern (`select` + `apply_one`, registered in `registry.py`).
**Status: In progress.** [`foc-mechanical-rules`](../foc-mechanical-rules/) now runs hourly via GitHub Actions: R-PR-001 (unassigned PR -> author), R-FC-012 (recently-active item with no Cycle -> current cycle), and R-FC-013 (open item in a past cycle -> current cycle). The rule-per-field, decision-table design is meant to grow: R-PR-002/003/004 (dependabot/release-PR theme+status) and R-PR-008/009 (merged/closed -> Done) are the natural next slice, following the same `Rule` pattern (`select` + `apply_one`, registered in `registry.py`).

**New sub-problem this surfaced:** rules that need their own mutation history (see [`foc-mechanical-rules`'s "Mutation log" section](../foc-mechanical-rules/README.md#mutation-log) for why) currently get it persisted via GitHub Actions cache, which isn't a truly durable store (eviction after ~7 days unused, no cross-repo/cross-workflow access). That's an acceptable v1 tradeoff since the library itself doesn't assume any particular persistence mechanism — only the CLI/workflow-level wiring would need to change. If more rules end up depending on this history, or cache eviction ever causes a visible miss, worth moving it somewhere durable: a small persisted store the REST API server owns, for example.

Expand All @@ -16,6 +16,14 @@ Roughly 60% of the 2026-07-07 sweep's ~395 mutations (dependabot theme/status/cy

**Triggered by:** agent feedback after the 2026-07-07 full sweep (~395 mutations across 6 stages).

## Batch mutations in foc-mechanical-rules

**Status: Not started.** `github_projects_client`'s `set_field_value_bulk` already batches GraphQL mutations 25-at-a-time via aliased queries — but every rule calls `set_field_value`, a thin wrapper that always passes a 1-item list, so the batching path never actually batches anything today. A live R-FC-013 dry-run against the real board (2026-08-22) found 179 items needing a mutation; at 1 GraphQL request per item that's 179 round trips this run alone, versus ~8 if they were batched.

**Why it's not just a drop-in fix:** the shared `Rule.run()`/`apply_one` contract (see README.md's "Design" section) interleaves per-item decision logic (skip/flag/error, mutation-log guards) with the actual mutation call, one item at a time. Batching means splitting that into two phases — decide which items to mutate (unchanged, still per-item), then mutate all of them in one `set_field_value_bulk` call — which is a change to the shared base class every rule (current and future) goes through, not something scoped to one rule. `AssigneeRule` doesn't call `set_field_value` at all (it's REST issue/PR endpoints, not board-field mutations), so it gets no benefit but needs to keep working under whatever the new shape is.

**Triggered by:** implementing R-FC-013 and auditing its API call pattern (see README.md's "API call pattern per rule" section) after a live dry-run.

## Sweep journal for incremental sweeps

Each sweep re-derives the whole board from scratch. Persist the final item-state snapshot at the end of each sweep (one JSON file per sweep, ~200 bytes/item: ref, status, cycle, theme, assignee, board `updated`, GitHub `updatedAt`, flags raised). The next sweep can then run incrementally: only items whose GitHub/board `updated` changed since the snapshot need evaluation.
Expand Down
21 changes: 20 additions & 1 deletion foc-mechanical-rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Every `applied`, `flagged`, or `error` outcome is written to the shared [`action

## Mutation log

Some rules need to know what this tool has already done to an item — R-FC-012 (cycle) is the first example: it won't re-add a cycle it previously set if the item now has no cycle, because that's a human's deliberate signal to descope it, not something to silently override. Getting that from GitHub directly isn't possible: GitHub has no change-history API for Projects v2 custom fields other than Status (confirmed by GraphQL schema introspection — `ProjectV2ItemStatusChangedEvent` is the only such timeline event that exists; a separate, similarly-named `IssueFieldChangedEvent` family turned out to belong to an unrelated "Issue Fields" GitHub feature and returned nothing when checked against a real item with a multi-cycle history).
Some rules need to know what this tool has already done to an item — R-FC-012 (cycle) is the first example: it won't re-add a cycle it previously set if the item now has no cycle, because that's a human's deliberate signal to descope it, not something to silently override. R-FC-013 uses the same log the other direction: it won't re-move an item off a past cycle if this tool already moved it off that exact cycle once and the item is back there now — that's a human's deliberate signal to leave it, not something to fight. Getting that from GitHub directly isn't possible: GitHub has no change-history API for Projects v2 custom fields other than Status (confirmed by GraphQL schema introspection — `ProjectV2ItemStatusChangedEvent` is the only such timeline event that exists; a separate, similarly-named `IssueFieldChangedEvent` family turned out to belong to an unrelated "Issue Fields" GitHub feature and returned nothing when checked against a real item with a multi-cycle history).

So this tool keeps its own record instead, and treats it explicitly as an *input* to rules rather than an assumption baked into how they run: `mutation_log.py` defines `MutationLog`, an item -> mutations multimap (`for_item(item_ref)` for O(1) lookup, `record(...)` to append). The base `Rule.run()` builds one record per real (non-dry-run) `applied` outcome automatically and adds it to whatever `MutationLog` it's given; any rule's `apply_one` can read it back via `mutation_log.for_item(...)` (see `rules/cycle.py`). **It is not guaranteed comprehensive** — it only knows what was fed in plus what's happened this run — so a rule using it should treat a miss as "no known history," not as proof nothing happened.

Expand All @@ -31,13 +31,32 @@ 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 |

## API call pattern per rule

Board size (~180 open items and growing) makes it easy for a rule to accidentally turn an O(1)-per-run cost into an O(items) one. Three conventions keep that in check, and every rule should follow them:

1. **`select()` issues exactly one board query**, paginated via `list_items`'s cursor — never a query per candidate.
2. **Anything that's the same for the whole run (e.g. "what's the current cycle?") is resolved once and memoized** on the rule instance (see `CycleRule`/`PastCycleRule`'s `_resolve*` methods), not refetched in every `apply_one` call.
3. **Mutations pass the item's node ID** (`item.get("_node_id")` — `list_items` always includes it, regardless of the requested `fields`), not an `"owner/repo#number"` ref. `github_projects_client`'s `set_field_value`/`set_field_value_bulk` silently does an extra `get_item` read per plain ref to resolve it to a node ID; a node ID skips that lookup entirely and also gets its `old_value` from a batched API read instead of whatever `select()` saw earlier (which can be stale by the time the mutation runs).

| Rule | `select()` (once per run) | Per-run, memoized | Per-item reads | Per-item writes |
| --- | --- | --- | --- | --- |
| 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 |
| R-FC-012 (cycle) | 1 paginated board query | 1 GraphQL query (iterations) | none | 1 GraphQL mutation per applied item (node ID) |
| 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 | 1 GraphQL mutation per applied item (node ID) |

**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.

## Usage

```bash
uv run foc-mechanical-rules --dry-run # preview, no mutations
uv run foc-mechanical-rules # apply
uv run foc-mechanical-rules -o "$GITHUB_STEP_SUMMARY"
uv run foc-mechanical-rules --dry-run --rule R-FC-013 # only this rule
uv run foc-mechanical-rules --dry-run --rule R-FC-013 --rule R-PR-001 # or a few
```

Requires a `GITHUB_TOKEN` (or `--token`) with `read:project` (board reads) and issue/PR write access (`repo` scope, or fine-grained `Issues: write` + `Pull requests: write`) on the blessed orgs. CI uses the org's `FILOZZY_CI_ADD_TO_PROJECT` secret (also used by [`add-issues-and-prs-to-fs-project-board.yml`](../.github/workflows/add-issues-and-prs-to-fs-project-board.yml)).
Expand Down
20 changes: 20 additions & 0 deletions foc-mechanical-rules/foc_mechanical_rules/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ def main() -> None:
help="Path to the persisted mutation-history TSV, read at start and "
"written at end (default: %(default)s)",
)
parser.add_argument(
"--rule",
action="append",
metavar="RULE_ID",
help="Only run this rule (e.g. R-FC-013). Repeatable to run several. "
"Default: run every registered rule.",
)
args = parser.parse_args()

logging.basicConfig(
Expand All @@ -77,6 +84,19 @@ def main() -> None:

session = build_session(token)
rules = default_rules()

if args.rule:
known_ids = {rule.id for rule in rules}
unknown = sorted(set(args.rule) - known_ids)
if unknown:
print(
f"Error: unknown rule id(s): {', '.join(unknown)}. "
f"Known rules: {', '.join(sorted(known_ids))}",
file=sys.stderr,
)
sys.exit(1)
rules = [rule for rule in rules if rule.id in args.rule]

for rule in rules:
rule.org = args.org
rule.project_number = args.project_number
Expand Down
4 changes: 2 additions & 2 deletions foc-mechanical-rules/foc_mechanical_rules/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

from .rule import Rule
from .rules.assignee import AssigneeRule
from .rules.cycle import CycleRule
from .rules.cycle import CycleRule, PastCycleRule


def default_rules() -> List[Rule]:
return [AssigneeRule(), CycleRule()]
return [AssigneeRule(), CycleRule(), PastCycleRule()]
Comment thread
BigLep marked this conversation as resolved.
3 changes: 3 additions & 0 deletions foc-mechanical-rules/foc_mechanical_rules/rules/assignee.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
This module is that rule's canonical implementation — the markdown links
back here, and this docstring links back to the markdown, so the two stay
in sync instead of drifting apart silently.

API call pattern: see README.md's "API call pattern per rule" table. If you
change what this rule reads or writes per item, update that table too.
"""

from __future__ import annotations
Expand Down
Loading
Loading