Skip to content
Open
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
63 changes: 63 additions & 0 deletions .github/workflows/backlog-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Backlog lint

# Mechanical consistency checks over the issue backlog and project board #1.
# Facts only -- no judgment about what matters. See tools/backlog-lint/README.md.
#
# Gates rather than reports: findings are compared against the committed baseline
# (tools/backlog-lint/baseline.json) and the job fails only on findings NOT in it.
# A scheduled job that merely posts a report gets ignored -- this repo learned that
# with the BMDB nightly.
#
# REQUIRES the repository secret GH_PROJECT_TOKEN: a PAT with `read:project`
# (add `project` too if you ever run with fix_board). GITHUB_TOKEN cannot read
# organization ProjectV2 boards, so without this secret the job cannot run.

on:
schedule:
- cron: "0 8 * * 1" # Mondays 08:00 UTC
workflow_dispatch:
inputs:
strict:
description: "Fail on ANY finding, ignoring the baseline"
type: boolean
default: false
fix_board:
description: "Also ADD unboarded issues to the board (needs `project` scope)"
type: boolean
default: false
pull_request:
paths:
- "tools/backlog-lint/**"
- ".github/workflows/backlog-lint.yml"

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.10"

- name: Check the token secret is present
env:
TOKEN: ${{ secrets.GH_PROJECT_TOKEN }}
run: |
if [ -z "$TOKEN" ]; then
echo "::error::GH_PROJECT_TOKEN is not set. Add a PAT with read:project scope."
echo "GITHUB_TOKEN cannot read organization ProjectV2 boards." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi

- name: Run backlog lint
env:
GH_PROJECT_TOKEN: ${{ secrets.GH_PROJECT_TOKEN }}
run: |
args=""
if [ "${{ inputs.strict }}" = "true" ]; then args="$args --strict"; fi
if [ "${{ inputs.fix_board }}" = "true" ]; then args="$args --fix-board"; fi
python3 tools/backlog-lint/backlog_lint.py $args
115 changes: 115 additions & 0 deletions tools/backlog-lint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# backlog-lint

Mechanical consistency checks over the open issue backlog and
[project board #1](https://github.com/orgs/virtualcell/projects/1).

**It checks facts, never judgment.** Every rule is decidable from metadata alone: an issue on
no board, a `Priority` that does not equal the sum it is defined to be, a card marked `Done`
whose issue is still open. Nothing here decides whether an issue *matters* — that stays with
people, and the reasoning behind the current backlog lives in [`docs/backlog/`](../../docs/backlog/).

## Why it gates instead of reporting

A scheduled job that posts a report gets ignored. This repo already learned that with the BMDB
nightly, which was changed to **fail** on changed results rather than post an unread summary.

So findings are compared against a committed baseline of accepted violations
(`baseline.json`), and the run fails only on findings **not** in it. Accepting a new violation
is therefore a deliberate, reviewable act: regenerate the baseline, look at the diff, commit it.

The baseline shrinking is the grooming getting done. The baseline growing is a decision someone
made on purpose.

## The checks

| id | Fails when | Why it matters |
|---|---|---|
| `not-on-board` | An open issue is on no project board | Board-invisible work does not get planned. 55 issues were in this state, skewed toward the *newest and best-described* ones. |
| `priority-formula` | `Priority ≠ Importance + Simplicity` | `Priority` is a derived field. A hand-entered value that disagrees with its inputs is stale arithmetic. |
| `scored-not-ranked` | `Importance` set, `Priority` never computed | The issue was judged, then never entered the ranked queue. Two such issues tied the highest score on the board. |
| `half-scored` | `Simplicity` set, `Importance` not | No `Priority` is possible until someone rates the value. |
| `done-but-open` | Board says `Done`, issue still open | The issue is the source of truth; the card follows it. |
| `stale-active` | `Active` and untouched 30+ days | `Active` is the one status a planner must be able to trust. |
| `shipped-release-label` | Carries a label naming a shipped release | `Next Release` has meant "next release" across dozens of actual releases. |
| `many-assignees` | 3+ assignees | In GitHub this means "interested", not "owns". Nobody is accountable. |
| `queued-thin-body` | `Queued` with a body under 200 chars | Nothing should be *ready* that an outside engineer cannot start from. |

Thresholds and the shipped-release label set are constants at the top of `backlog_lint.py`.

## The scoring model

Board `Priority` is **derived**, not an independent judgment:

```
Priority = Importance + Simplicity
```

- `Importance` — 1–10, higher = more valuable
- `Simplicity` — `Simple (5)` … `Byzantine (1)`, higher = **easier** (parsed from the trailing
number in the option label, so renaming options does not break the check)
- `Priority` — 1–12, **higher = do sooner**

This is a value/cost model: important scores high, easy scores high, important *and* easy scores
highest. One consequence worth knowing — because ease is *added* rather than multiplied, a
`Byzantine (1)` item caps at `Priority` 11 however important it is, so the model is structurally
hostile to large, hard programmes. Work that must happen regardless of cost (a compliance
mandate, say) should be resourced outside the queue rather than ranked inside it.

The formula was reconstructed from the board data (exact on 56 of 57 ranked issues) and is
documented here because it existed nowhere else. **Consider making `Priority` a computed column** —
hand-entry is what let one row drift out of sync in the first place.

## Running it

Needs a PAT in `GH_PROJECT_TOKEN` with **`read:project`** scope. `GITHUB_TOKEN` cannot read
organization ProjectV2 boards, which is why this is not wired to the default token.

```bash
export GH_PROJECT_TOKEN=$(gh auth token) # if your gh token has read:project

python3 tools/backlog-lint/backlog_lint.py # lint; exit 1 on new findings
python3 tools/backlog-lint/backlog_lint.py --strict # fail on ANY finding
python3 tools/backlog-lint/backlog_lint.py --format markdown # markdown report
python3 tools/backlog-lint/backlog_lint.py --update-baseline # accept current state
```

If `gh` reports a missing scope: `gh auth refresh -s read:project`.

### Accepting new findings

```bash
python3 tools/backlog-lint/backlog_lint.py --update-baseline
git diff tools/backlog-lint/baseline.json # read this before committing
```

### Adding unboarded issues to the board

The one write the tool can perform, and it is **opt-in**:

```bash
gh auth refresh -s project # write scope, not just read
python3 tools/backlog-lint/backlog_lint.py --fix-board
```

Issues land in the board's default column and still need triaging into `Pool` and scoring.
Everything else the lint finds is left for a human, on purpose.

## In CI

`.github/workflows/backlog-lint.yml` runs it Mondays at 08:00 UTC, on `workflow_dispatch`
(with `strict` and `fix_board` toggles), and on pull requests that touch this directory so the
tool is exercised by its own changes.

The workflow needs the repository secret **`GH_PROJECT_TOKEN`**; it fails with an explicit
message if that secret is absent rather than reporting a false pass.

## What this deliberately does not do

- **Detect duplicates.** Tested and rejected: TF-IDF similarity over title+body found only 5 of
12 hand-identified duplicate pairs, missed the two most important (`#1008`/`#1199` ranked 579th,
`#1604`/`#1606` ranked 9377th), and its top hit was a *deliberate* split. Lexical similarity
finds sibling issues in a subsystem, which is not the same thing.
- **Close anything, or edit issue content.** Bulk-closing by rule and auto-writing bodies for
under-described issues both produce confident-looking output that is really invention. A blank
body is honestly blank; a generated one is not.
- **Assign `Importance`.** That is a judgment about users and belongs to people.
Loading
Loading