diff --git a/.github/workflows/backlog-lint.yml b/.github/workflows/backlog-lint.yml new file mode 100644 index 0000000000..38e31773ac --- /dev/null +++ b/.github/workflows/backlog-lint.yml @@ -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 diff --git a/tools/backlog-lint/README.md b/tools/backlog-lint/README.md new file mode 100644 index 0000000000..a72d5a2bf2 --- /dev/null +++ b/tools/backlog-lint/README.md @@ -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. diff --git a/tools/backlog-lint/backlog_lint.py b/tools/backlog-lint/backlog_lint.py new file mode 100755 index 0000000000..9f18f0e783 --- /dev/null +++ b/tools/backlog-lint/backlog_lint.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""backlog-lint — mechanical consistency checks over the VCell issue backlog. + +Checks facts, never judgment. Every rule here is something that can be decided from +metadata alone: an issue that is 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 in here decides +whether an issue matters — that stays with people. + +Gating, not reporting. A scheduled job that merely posts a report gets ignored; this repo +learned that with the BMDB nightly. So findings are compared against a committed baseline +(baseline.json) of accepted violations, and the run fails only on findings that are NOT in +it. Accept the current state deliberately with --update-baseline, review the diff, commit. + +Reads the org ProjectV2 board, which GITHUB_TOKEN cannot do: supply a PAT in +GH_PROJECT_TOKEN with `read:project` scope (plus `project` if you enable --fix-board). + +Usage: + backlog_lint.py # lint, exit 1 on new findings + backlog_lint.py --strict # exit 1 on ANY finding, baseline ignored + backlog_lint.py --update-baseline # accept current findings into baseline.json + backlog_lint.py --fix-board # also ADD unboarded issues to the board (write) + backlog_lint.py --format markdown # report as markdown (default: text) +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request +from datetime import datetime, timedelta, timezone +from pathlib import Path + +OWNER = "virtualcell" +REPO = "vcell" +PROJECT_NUMBER = 1 + +BASELINE = Path(__file__).with_name("baseline.json") +API = "https://api.github.com/graphql" + +# --- thresholds ------------------------------------------------------------------ +STALE_ACTIVE_DAYS = 30 # "Active" must mean someone is working on it now +MANY_ASSIGNEES = 3 # 3+ assignees means nobody owns it +THIN_BODY_CHARS = 200 # a Queued issue must be startable from its body +POOL_STATUS = "Pool" # where --fix-board files newly-added issues + +# Labels naming a release that has shipped. Release planning belongs in board status, +# not in labels that outlive the release by years. +SHIPPED_RELEASE_LABELS = { + "Next Release", "VCell-7.5.0", "VCell-7.5.1", "VCell-7.6.0", +} + + +# --- GraphQL --------------------------------------------------------------------- + +def gql(query: str, variables: dict, token: str) -> dict: + body = json.dumps({"query": query, "variables": variables}).encode() + req = urllib.request.Request( + API, data=body, + headers={ + "Authorization": f"bearer {token}", + "Content-Type": "application/json", + "User-Agent": "vcell-backlog-lint", + }, + ) + try: + with urllib.request.urlopen(req, timeout=60) as r: + payload = json.load(r) + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace")[:500] + raise SystemExit(f"GitHub API {e.code}: {detail}") + if "errors" in payload: + raise SystemExit("GraphQL errors: " + json.dumps(payload["errors"])[:800]) + return payload["data"] + + +ISSUES_Q = """ +query($owner:String!, $repo:String!, $cursor:String) { + repository(owner:$owner, name:$repo) { + issues(first:100, after:$cursor, states:OPEN) { + pageInfo { hasNextPage endCursor } + nodes { + id number title body updatedAt + labels(first:30) { nodes { name } } + assignees(first:10) { nodes { login } } + } + } + } +} +""" + +PROJECT_Q = """ +query($owner:String!, $number:Int!, $cursor:String) { + organization(login:$owner) { + projectV2(number:$number) { + id + items(first:100, after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + content { __typename ... on Issue { number state repository { name } } } + fieldValues(first:30) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + name field { ... on ProjectV2SingleSelectField { name } } } + ... on ProjectV2ItemFieldNumberValue { + number field { ... on ProjectV2FieldCommon { name } } } + } + } + } + } + } + } +} +""" + + +def fetch_issues(token: str) -> dict[int, dict]: + out, cursor = {}, None + while True: + d = gql(ISSUES_Q, {"owner": OWNER, "repo": REPO, "cursor": cursor}, token) + page = d["repository"]["issues"] + for n in page["nodes"]: + out[n["number"]] = { + "id": n["id"], + "number": n["number"], + "title": n["title"], + "body": n["body"] or "", + "updatedAt": n["updatedAt"], + "labels": {l["name"] for l in n["labels"]["nodes"]}, + "assignees": [a["login"] for a in n["assignees"]["nodes"]], + } + if not page["pageInfo"]["hasNextPage"]: + return out + cursor = page["pageInfo"]["endCursor"] + + +def fetch_board(token: str) -> tuple[str, dict[int, dict]]: + """Returns (project node id, {issue number: {field name: value}}).""" + out, cursor, project_id = {}, None, None + while True: + d = gql(PROJECT_Q, {"owner": OWNER, "number": PROJECT_NUMBER, "cursor": cursor}, token) + proj = d["organization"]["projectV2"] + project_id = proj["id"] + page = proj["items"] + for n in page["nodes"]: + c = n.get("content") or {} + if c.get("__typename") != "Issue": + continue + if (c.get("repository") or {}).get("name") != REPO or c.get("state") != "OPEN": + continue + fv = {} + for f in n["fieldValues"]["nodes"]: + fname = (f.get("field") or {}).get("name") + if not fname: + continue + fv[fname] = f.get("name") if f.get("name") is not None else f.get("number") + out[c["number"]] = fv + if not page["pageInfo"]["hasNextPage"]: + return project_id, out + cursor = page["pageInfo"]["endCursor"] + + +def simplicity_value(label: str | None) -> int | None: + """'Simple (5)' -> 5. Parsed, not hardcoded, so option renames do not break the check.""" + if not label: + return None + m = re.search(r"\((\d+)\)\s*$", label) + return int(m.group(1)) if m else None + + +# --- checks ---------------------------------------------------------------------- +# Each returns a list of (issue_number, detail). Keep every check decidable from +# metadata alone; anything needing judgment belongs in docs/backlog/, not here. + +CHECKS: dict[str, str] = { + "not-on-board": "Open issue is not on the project board", + "priority-formula": "Priority != Importance + Simplicity", + "scored-not-ranked": "Importance is set but Priority was never computed", + "half-scored": "Simplicity is set but Importance is not, so no Priority is possible", + "done-but-open": "Board says Done but the issue is still open", + "stale-active": f"Status is Active but untouched for {STALE_ACTIVE_DAYS}+ days", + "shipped-release-label": "Carries a label naming a release that has already shipped", + "many-assignees": f"{MANY_ASSIGNEES}+ assignees, so nobody owns it", + "queued-thin-body": f"Queued but body is under {THIN_BODY_CHARS} chars", +} + + +def run_checks(issues: dict[int, dict], board: dict[int, dict]) -> dict[str, list[tuple[int, str]]]: + found: dict[str, list[tuple[int, str]]] = {k: [] for k in CHECKS} + cutoff = datetime.now(timezone.utc) - timedelta(days=STALE_ACTIVE_DAYS) + + for num, iss in sorted(issues.items()): + fields = board.get(num) + + if fields is None: + found["not-on-board"].append((num, iss["title"])) + + stale_labels = sorted(iss["labels"] & SHIPPED_RELEASE_LABELS) + if stale_labels: + found["shipped-release-label"].append((num, ", ".join(stale_labels))) + + if len(iss["assignees"]) >= MANY_ASSIGNEES: + found["many-assignees"].append( + (num, f"{len(iss['assignees'])}: {', '.join(iss['assignees'])}")) + + if fields is None: + continue + + status = fields.get("Status") + imp = fields.get("Importance") + pri = fields.get("Priority") + simp = simplicity_value(fields.get("Simplicity")) + + if status == "Done": + found["done-but-open"].append((num, iss["title"])) + + if status == "Active": + updated = datetime.fromisoformat(iss["updatedAt"].replace("Z", "+00:00")) + if updated < cutoff: + found["stale-active"].append((num, f"last updated {iss['updatedAt'][:10]}")) + + if status == "Queued": + n = len(re.sub(r"\s+", " ", iss["body"]).strip()) + if n < THIN_BODY_CHARS: + found["queued-thin-body"].append((num, f"{n} chars")) + + if imp is not None and simp is not None and pri is not None: + expected = int(imp) + simp + if int(pri) != expected: + found["priority-formula"].append( + (num, f"Priority={int(pri)} but Importance {int(imp)} + Simplicity {simp} = {expected}")) + + if imp is not None and pri is None: + hint = f", would be {int(imp) + simp}" if simp is not None else "" + found["scored-not-ranked"].append((num, f"Importance={int(imp)}{hint}")) + + if simp is not None and imp is None: + found["half-scored"].append((num, f"Simplicity={fields.get('Simplicity')}")) + + return found + + +# --- baseline -------------------------------------------------------------------- + +def load_baseline() -> dict[str, set[int]]: + if not BASELINE.exists(): + return {} + raw = json.loads(BASELINE.read_text()) + return {k: set(v) for k, v in raw.get("accepted", {}).items()} + + +def write_baseline(found: dict[str, list[tuple[int, str]]]) -> None: + doc = { + "_comment": [ + "Accepted backlog-lint findings. The lint fails only on findings NOT listed here.", + "Regenerate with: python3 tools/backlog-lint/backlog_lint.py --update-baseline", + "Review the diff before committing -- shrinking lists are progress, growing ones", + "are a decision to accept something.", + ], + "checks": CHECKS, + "accepted": {k: sorted(n for n, _ in v) for k, v in found.items() if v}, + } + BASELINE.write_text(json.dumps(doc, indent=2) + "\n") + + +# --- reporting ------------------------------------------------------------------- + +def report(found, baseline, strict, fmt) -> tuple[str, int]: + lines: list[str] = [] + new_total = 0 + md = fmt == "markdown" + + def h(text, level=2): + lines.append(("#" * level + " " + text) if md else "\n" + text) + + h("Backlog lint", 2) + total = sum(len(v) for v in found.values()) + lines.append(f"{total} finding(s) across {len(CHECKS)} checks." + + ("" if strict else " Baseline-accepted findings are not failures.")) + + for check, hits in found.items(): + if not hits: + continue + accepted = set() if strict else baseline.get(check, set()) + new = [(n, d) for n, d in hits if n not in accepted] + new_total += len(new) + flag = f"**{len(new)} new**" if (new and md) else f"{len(new)} new" + h(f"{check} — {CHECKS[check]}", 3) + lines.append(f"{len(hits)} total, {flag}." if new else f"{len(hits)} total, all accepted.") + if new: + if md: + lines.append("") + lines.append("| # | Detail |") + lines.append("|---|---|") + for n, d in new[:40]: + url = f"https://github.com/{OWNER}/{REPO}/issues/{n}" + lines.append(f"| [#{n}]({url}) | {d} |" if md else f" #{n} {d}") + if len(new) > 40: + lines.append(f"| … | {len(new) - 40} more not listed |" if md + else f" … {len(new) - 40} more not listed") + + h("Result", 2) + if new_total: + lines.append(f"FAIL — {new_total} finding(s) not in the baseline.") + lines.append("Fix them, or accept deliberately with --update-baseline and commit the diff.") + else: + lines.append("PASS — no findings outside the baseline.") + return "\n".join(lines), new_total + + +# --- board write (opt-in) -------------------------------------------------------- + +ADD_M = """ +mutation($project:ID!, $content:ID!) { + addProjectV2ItemById(input:{projectId:$project, contentId:$content}) { item { id } } +} +""" + + +def add_to_board(project_id: str, issues, numbers, token) -> list[int]: + added = [] + for n in numbers: + gql(ADD_M, {"project": project_id, "content": issues[n]["id"]}, token) + added.append(n) + return added + + +# --- main ------------------------------------------------------------------------ + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--update-baseline", action="store_true", + help="accept all current findings into baseline.json") + ap.add_argument("--strict", action="store_true", + help="fail on any finding, ignoring the baseline") + ap.add_argument("--fix-board", action="store_true", + help="ADD unboarded open issues to the board (requires `project` scope)") + ap.add_argument("--format", choices=["text", "markdown"], default="text") + args = ap.parse_args() + + token = os.environ.get("GH_PROJECT_TOKEN") or os.environ.get("GITHUB_TOKEN") + if not token: + print("error: set GH_PROJECT_TOKEN to a PAT with `read:project` scope.\n" + "GITHUB_TOKEN cannot read organization ProjectV2 boards.", file=sys.stderr) + return 2 + + issues = fetch_issues(token) + project_id, board = fetch_board(token) + found = run_checks(issues, board) + + if args.fix_board: + unboarded = [n for n, _ in found["not-on-board"]] + if unboarded: + added = add_to_board(project_id, issues, unboarded, token) + print(f"added {len(added)} issue(s) to the board: " + + ", ".join(f"#{n}" for n in added)) + print(f"note: they land in the board's default column; triage them into " + f"{POOL_STATUS!r} and score them.") + found["not-on-board"] = [] + + if args.update_baseline: + write_baseline(found) + print(f"baseline written to {BASELINE}") + print("review the diff before committing.") + return 0 + + text, new_total = report(found, load_baseline(), args.strict, args.format) + print(text) + + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + md, _ = report(found, load_baseline(), args.strict, "markdown") + with open(summary, "a") as fh: + fh.write(md + "\n") + + return 1 if new_total else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/backlog-lint/baseline.json b/tools/backlog-lint/baseline.json new file mode 100644 index 0000000000..1afcec8c5a --- /dev/null +++ b/tools/backlog-lint/baseline.json @@ -0,0 +1,268 @@ +{ + "_comment": [ + "Accepted backlog-lint findings. The lint fails only on findings NOT listed here.", + "Regenerate with: python3 tools/backlog-lint/backlog_lint.py --update-baseline", + "Review the diff before committing -- shrinking lists are progress, growing ones", + "are a decision to accept something." + ], + "checks": { + "not-on-board": "Open issue is not on the project board", + "priority-formula": "Priority != Importance + Simplicity", + "scored-not-ranked": "Importance is set but Priority was never computed", + "half-scored": "Simplicity is set but Importance is not, so no Priority is possible", + "done-but-open": "Board says Done but the issue is still open", + "stale-active": "Status is Active but untouched for 30+ days", + "shipped-release-label": "Carries a label naming a release that has already shipped", + "many-assignees": "3+ assignees, so nobody owns it", + "queued-thin-body": "Queued but body is under 200 chars" + }, + "accepted": { + "not-on-board": [ + 1262, + 1268, + 1330, + 1338, + 1341, + 1365, + 1423, + 1494, + 1577, + 1578, + 1598, + 1609, + 1644, + 1712, + 1718, + 1719, + 1720, + 1721, + 1722, + 1723, + 1724, + 1725, + 1726, + 1727, + 1728, + 1729, + 1730, + 1731, + 1732, + 1734, + 1747, + 1751, + 1777, + 1786, + 1803, + 1848, + 1849, + 1859, + 1867, + 1874, + 1875, + 1876, + 1879, + 1888, + 1894, + 1905, + 1921, + 1922, + 1926, + 1964, + 1978, + 1980, + 1981, + 1984, + 1994 + ], + "half-scored": [ + 91, + 138, + 146, + 153, + 174, + 175, + 176, + 178, + 181, + 182, + 184, + 185, + 187, + 192, + 201, + 214, + 221, + 222, + 289, + 475, + 574, + 575, + 648, + 708, + 877, + 956, + 972, + 1024, + 1048, + 1068, + 1074, + 1078, + 1115, + 1147, + 1152, + 1153, + 1260, + 1339, + 1340, + 1352, + 1385, + 1469, + 1470, + 1472, + 1474, + 1482, + 1486, + 1487, + 1488, + 1552, + 1599, + 1686, + 1785 + ], + "done-but-open": [ + 166, + 748, + 1646, + 1647 + ], + "stale-active": [ + 870, + 1035, + 1037, + 1040, + 1534, + 1542, + 1543, + 1599, + 1654, + 1655, + 1657, + 1658, + 1686, + 1706, + 1708 + ], + "shipped-release-label": [ + 166, + 167, + 191, + 221, + 304, + 356, + 429, + 430, + 475, + 490, + 506, + 515, + 522, + 523, + 537, + 551, + 562, + 563, + 566, + 574, + 575, + 611, + 642, + 648, + 708, + 718, + 719, + 728, + 748, + 772, + 792, + 804, + 832, + 835, + 840, + 863, + 890, + 891, + 898, + 912, + 942, + 950, + 986, + 1024, + 1601 + ], + "many-assignees": [ + 138, + 146, + 167, + 168, + 169, + 189, + 191, + 192, + 194, + 215, + 475, + 537, + 566, + 611, + 708, + 792, + 832, + 956, + 1244, + 1385, + 1440, + 1441, + 1464, + 1473, + 1565, + 1572, + 1591, + 1593, + 1652, + 1701, + 1738, + 1884, + 1886 + ], + "queued-thin-body": [ + 158, + 167, + 189, + 191, + 194, + 498, + 563, + 804, + 835, + 890, + 891, + 1082, + 1292, + 1299, + 1320, + 1343, + 1344, + 1440, + 1441, + 1449, + 1503, + 1548, + 1549, + 1564, + 1569, + 1605, + 1606, + 1607 + ] + } +}