blob: 4f2d960ed159d7779b71995f1c20228a78490791 [file]
#!/usr/bin/env python3
# ***************************************************************************************************************************
# * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file
# * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file
# * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance
# * with the License. You may obtain a copy of the License at
# *
# * http://www.apache.org/licenses/LICENSE-2.0
# *
# * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
# * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
# * specific language governing permissions and limitations under the License.
# ***************************************************************************************************************************
"""
Cross-project wave-candidate survey for the three wave-scope trackers under ~/Project Work/todos/.
Replaces the by-hand "open all three TODO.md files and eyeball the READY/TODO/MAYBE/HOLD lists"
survey that precedes authoring a wave (see @todo-and-waves's "WAVE / clone assignment / harvest"
section) with one invocation that emits the same candidate table: id, title, lifecycle state,
HIPRI marker, runnable-now vs blocked (with why), and a compact status line, sorted HIPRI-and-
runnable-first.
Covers exactly the three WAVE-SCOPE trackers: juneau, juneau-release-manager (slug
"juneau-release-manager"), sandbox-support-console. IRS is deliberately NOT covered -- IRS
(TODO-Innnn, tracker slug "irs") is tracked and waved independently of the Juneau-scope trio;
see @todo-and-waves. See TRACKERS below to add/remove a tracker.
PLACEMENT (why this lives once in apache/juneau/scripts, unlike todo-next-id.py /
todo-status-audit.py): those two scripts are carried byte-for-byte in all three repos because
each COPY only ever scans its OWN repo's tracker -- the project id letter and tracker slug are
the only things that differ, so one script body serves every repo once those are read from the
script's own constants. This script's whole reason to exist is the opposite shape: ONE invocation
surveys ALL THREE trackers, which means it inherently needs to know about all three repos at
once (see TRACKERS below). Carrying identical copies in all three repos would either (a) have
each copy re-declare the same three-tracker table pointlessly, or (b) tempt each copy to instead
scan only its own tracker, which would just reinvent a worse todo-status-audit.py subset. Neither
is a real byte-for-byte carry candidate, so this lives once, here.
Mirrors (does not import -- see below) todo-status-audit.py's tracker-layout understanding:
trackers live at ~/Project Work/todos/<slug>/, item files use TODO-/READY-/MAYBE-/HOLD-<L>nnnn
[<child-letter>]-<slug>.md filenames (a bare numeric id, optionally one lowercase letter-or-digit
child suffix with NO separator before the slug's own hyphen), an "Open questions" section header
with unresolved numbered items marks a file NOT actually ready despite its filename, and a
"-HIPRI-" segment immediately after the id (before the slug, composing with every lifecycle
prefix) marks a high-priority item per @todo-and-waves's "High-priority (HIPRI) TODOs" section.
Unlike that script, the id LETTER here is captured from each filename rather than hardcoded --
one copy of this script has to recognize "J"/"R"/"C" across the three trackers it surveys in a
single run, where each copy of todo-status-audit.py only ever needs its own repo's letter. This
also means the 2026-08-30 consolidation of todo-status-audit.py into a single --project-
parameterized copy (see ~/Project Work/scripts/todo-status-audit.py) changed nothing FILENAME_RE
here needs to track: that script went from one hardcoded letter per repo copy to one letter
resolved per --project run, but the shape it produces for any single letter (e.g. "J") is the
same shape this file's already-generic "[A-Z]" capture already matched -- re-verified at that
migration, not overlooked.
This file deliberately DUPLICATES (in a minimal, single-purpose form) the small pieces of
parsing logic it needs from todo-status-audit.py -- the open-questions-unresolved check and the
status-line/decoration stripping -- rather than importing that module. todo-status-audit.py is a
hyphenated filename (not importable by name without importlib machinery) that is carried
byte-for-byte across three other repos and is not this script's to depend on; a real Python
import would also pull in that module's git/counts machinery this script has no use for.
Lifecycle states surveyed: TODO, READY, MAYBE, HOLD (NEED_INPUT-*.md is not currently populated
in any of the three trackers and is deliberately left out of this first cut -- see the module's
report/follow-up notes; add it to LIFECYCLE_PREFIXES if that changes). "Runnable now" is
deliberately narrow and matches the by-hand survey exactly: a READY-*.md file with no unresolved
"## Open questions" item. Everything else is "blocked", with a one-line reason:
TODO -- not yet promoted to READY (still needs design/decisions)
MAYBE -- parked / speculative, no trigger
HOLD -- committed work parked on indefinite hold
READY -- (only if it still has unresolved open questions) the promotion looks premature
Sort order: HIPRI items first (soft-strong priority, per @todo-and-waves -- this ordering is
informational only and never itself gates a wave), then runnable-now before blocked, then
lifecycle (READY, TODO, MAYBE, HOLD), then tracker (in TRACKERS order), then numeric id.
Usage:
./scripts/wave-survey.py
./scripts/wave-survey.py --json
./scripts/wave-survey.py --runnable-only
./scripts/wave-survey.py --repo juneau --repo sandbox-support-console
./scripts/wave-survey.py --root ~/Project Work
Options:
--root <path> Project Work root (default: ~/Project Work).
--repo <slug> Restrict the survey to one tracker slug (juneau / juneau-release-manager /
sandbox-support-console). Repeatable. Default: all three.
--runnable-only Print (or emit as JSON) only the RUNNABLE NOW items.
--json Emit a machine-readable JSON object instead of the human-readable table.
--help, -h Show this help message.
Exit status:
0 Survey completed (regardless of how many items were found -- this is a report, not a
pass/fail gate; an empty tracker is a legitimate, non-error result).
2 At least one requested tracker directory does not exist. Mirrors todo-status-audit.py's
missing-vs-empty rule: an unfound tracker must not be silently reported as an empty
one, so the other tracker(s) are still surveyed and reported, but the exit code and a
stderr line both say a tracker was skipped rather than legitimately empty.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
PROJECT_WORK = Path.home() / "Project Work"
# The three wave-scope trackers, in the order this script always reports them (also the tie-break
# order in sort_key() below). Add/remove a tracker here -- nowhere else -- to change scope. IRS
# ("irs" slug, TODO-Innnn) is deliberately absent; see the module docstring.
TRACKERS = (
("juneau", "Apache Juneau"),
("juneau-release-manager", "Apache Juneau Release Manager"),
("sandbox-support-console", "Sandbox Support Console"),
)
TRACKER_SLUGS = tuple(slug for slug, _label in TRACKERS)
# See the module docstring's "Lifecycle states surveyed" paragraph for why NEED_INPUT is absent.
LIFECYCLE_PREFIXES = ("READY", "TODO", "MAYBE", "HOLD")
LIFECYCLE_RANK = {prefix: rank for rank, prefix in enumerate(LIFECYCLE_PREFIXES)}
# <prefix>-<LETTER><digits>[<child-suffix>]-<slug>.md. The letter is CAPTURED (group 2), not
# hardcoded, because a single run of this script recognizes J/R/C across all three trackers --
# see the module docstring's "letter captured" paragraph. child-suffix has no separator before
# it (matches todo-next-id.py's own lettered-child shape, e.g. "TODO-C0016a"); anything after the
# next "-" is the slug, which is where an "-o06-"-shaped slug segment (not a child suffix) lands.
FILENAME_RE = re.compile(r"^(TODO|READY|MAYBE|HOLD)-([A-Z])(\d+)([a-z0-9]*)-(.+)\.md$")
# "-HIPRI-" immediately after the id (and any letter-child suffix), before the slug -- see
# @todo-and-waves's "High-priority (HIPRI) TODOs" section. A separate regex (not reusing
# FILENAME_RE's slug capture) for the same reason todo-status-audit.py's HIPRI_MARKER_RE is
# separate from its FILENAME_RE: this is a convenience classification, not a filename-shape gate.
HIPRI_RE = re.compile(r"^(?:TODO|READY|MAYBE|HOLD)-[A-Z]\d+[a-z0-9]*-HIPRI-.*\.md$")
# todo-next-id.py's id-reservation stub ("TODO-<L>nnnn-CLAIMED.md", never lettered -- see that
# script's docstring: "Claim stubs are only ever issued for a bare numeric id"). FILENAME_RE's
# permissive "-(.+)\.md$" slug group would otherwise happily match "CLAIMED" as an ordinary slug
# and surface an in-flight reservation as a bogus survey candidate with placeholder title/status
# text; scan_tracker() checks this first and skips a match instead.
CLAIM_STUB_RE = re.compile(r"^TODO-[A-Z]\d+-CLAIMED\.md$")
# The file's own H1 title line, e.g. "# TODO-J0447: Dogfood / chrome feedback (...)". Only the
# first "# " line in the file is read -- every item file in the corpus opens with exactly one.
HEADING_RE = re.compile(r"^#\s+(.*)$", re.MULTILINE)
# Strips a leading "<PREFIX>-<LETTER><digits>[<child>]: " from that heading, since the id is
# already its own column -- repeating it in the title column would waste table width.
HEADING_ID_PREFIX_RE = re.compile(r"^(?:TODO|READY|MAYBE|HOLD)-[A-Z]\d+[a-z0-9]*:\s*")
# Duplicated (not imported -- see module docstring) from todo-status-audit.py's STATUS_LINE_RE /
# STATUS_DECORATION_RE: the "Current status:" field and the markdown/punctuation decoration the
# corpus commonly wraps its value in (bold, a leading backtick, a leading "-"/em dash).
STATUS_LINE_RE = re.compile(r"^\s*Current status:(.*)$", re.IGNORECASE | re.MULTILINE)
STATUS_DECORATION_RE = re.compile(r"^[\s*_`\"'>-]+")
# Duplicated (not imported) from todo-status-audit.py's SECTION_HEADING_RE / NUMBERED_ITEM_RE /
# RESOLVED_MARKER_RE / extract_section() / open_questions_are_unresolved() -- the minimum needed
# to tell whether a file's own "## Open questions" section still has a live (non-answered)
# numbered item. See that script's module docstring for the fuller rationale; this is the same
# check, not a redesign of it.
SECTION_HEADING_RE = re.compile(r"^##\s(.*)$", re.MULTILINE)
NUMBERED_ITEM_RE = re.compile(r"^\s*\d+[.)]\s(.*)$", re.MULTILINE)
RESOLVED_MARKER_RE = re.compile(r"\b(?:resolved|answered|decided)\b")
# Table-column truncation widths. Full (untruncated) title/status text is always available via
# --json; these only bound the human-readable table so one very long file doesn't blow out every
# row's width.
TITLE_MAX_LEN = 80
STATUS_MAX_LEN = 130
def extract_section(text: str, heading_text: str) -> "str | None":
"""Body text of the first "## <heading_text>" section (case-insensitive substring match), or
None if no such heading exists. See todo-status-audit.py's identically-named function."""
headings = list(SECTION_HEADING_RE.finditer(text))
for i, m in enumerate(headings):
if heading_text.lower() in m.group(1).strip().lower():
start = m.end()
end = headings[i + 1].start() if i + 1 < len(headings) else len(text)
return text[start:end]
return None
def open_questions_are_unresolved(section_body: str) -> bool:
"""True if the "## Open questions" section has at least one numbered item that doesn't look
answered/resolved. See todo-status-audit.py's identically-named function."""
items = list(NUMBERED_ITEM_RE.finditer(section_body))
if not items:
return False
for i, m in enumerate(items):
start = m.start()
end = items[i + 1].start() if i + 1 < len(items) else len(section_body)
block = section_body[start:end].lower()
if not RESOLVED_MARKER_RE.search(block):
return True
return False
def has_unresolved_open_questions(text: str) -> bool:
"""True if this file's "## Open questions" section (if any) still has a live item."""
section = extract_section(text, "Open questions")
return section is not None and open_questions_are_unresolved(section)
def truncate(text: str, max_len: int) -> str:
"""Collapse to one line (whitespace/newlines squashed) and truncate to max_len with an
ellipsis. The status line is already single-line by construction (STATUS_LINE_RE's `.` does
not match a newline), but titles occasionally wrap, so this is applied to both."""
collapsed = " ".join(text.split())
if len(collapsed) <= max_len:
return collapsed
return collapsed[: max_len - 1].rstrip() + "…"
def extract_title(text: str) -> str:
"""The file's H1 heading with a leading "<id>: " prefix stripped (the id is its own column)."""
m = HEADING_RE.search(text)
if m is None:
return "(no title heading)"
heading = m.group(1).strip()
stripped = HEADING_ID_PREFIX_RE.sub("", heading, count=1).strip()
return stripped or heading
def extract_status(text: str) -> str:
"""The "Current status:" field value, decoration-stripped, or a named placeholder if the file
has none / an empty one. Named rather than blank so an absent header is never confused with a
file that legitimately said nothing -- the same reason todo-status-audit.py's
empty_status_value check exists, though this script only reports it, it does not flag it."""
m = STATUS_LINE_RE.search(text)
if m is None:
return "(no 'Current status:' line)"
value = STATUS_DECORATION_RE.sub("", m.group(1)).strip()
return value or "(empty status value)"
def classify(lifecycle: str, unresolved_open_questions: bool) -> "tuple[bool, str]":
"""(runnable, block_reason) for one item. See the module docstring's "Lifecycle states
surveyed" paragraph for the rule; block_reason is "" iff runnable is True."""
if lifecycle == "READY":
if unresolved_open_questions:
return False, "open questions unresolved despite READY"
return True, ""
if lifecycle == "TODO":
return False, "not yet promoted to READY"
if lifecycle == "MAYBE":
return False, "parked (MAYBE)"
if lifecycle == "HOLD":
return False, "on hold (HOLD)"
raise ValueError(f"unrecognized lifecycle {lifecycle!r}") # unreachable -- gated by FILENAME_RE's alternation
def parse_item(path: Path, repo_slug: str, repo_label: str) -> dict:
"""One survey row for a single tracker item file. Reads the file once."""
text = path.read_text(encoding="utf-8")
match = FILENAME_RE.match(path.name)
prefix, letter, digits, child_suffix, _slug = match.groups()
hipri = bool(HIPRI_RE.match(path.name))
unresolved = has_unresolved_open_questions(text)
runnable, block_reason = classify(prefix, unresolved)
return {
"repo": repo_slug,
"repo_label": repo_label,
"id": f"{prefix}-{letter}{digits}{child_suffix}",
"numeric_id": int(digits),
"child_suffix": child_suffix,
"lifecycle": prefix,
"hipri": hipri,
"runnable": runnable,
"block_reason": block_reason,
"title": extract_title(text),
"status": extract_status(text),
"path": str(path),
}
def scan_tracker(todo_dir: Path, repo_slug: str, repo_label: str) -> "list | None":
"""Every recognized item file directly under todo_dir, or None if todo_dir does not exist
(the caller distinguishes that from a legitimately empty, present directory)."""
if not todo_dir.is_dir():
return None
items = []
for entry in sorted(todo_dir.glob("*.md")):
if not entry.is_file():
continue
if CLAIM_STUB_RE.match(entry.name):
continue
if not FILENAME_RE.match(entry.name):
continue
items.append(parse_item(entry, repo_slug, repo_label))
return items
def sort_key(item: dict) -> tuple:
"""Runnable-now first (the primary split -- see the module docstring's "clearly split" design
goal), then HIPRI first within each of those two groups (soft-strong priority), then lifecycle
(READY/TODO/MAYBE/HOLD), then tracker (TRACKERS order), then numeric id, then child suffix.
Runnable outranks HIPRI here so the flat/JSON list order matches the human table's two
sections exactly -- a HIPRI TODO is still surfaced ahead of every other blocked item, but
never ahead of a runnable item, which would misread as "start this instead of the READY one"."""
return (
0 if item["runnable"] else 1,
0 if item["hipri"] else 1,
LIFECYCLE_RANK[item["lifecycle"]],
TRACKER_SLUGS.index(item["repo"]),
item["numeric_id"],
item["child_suffix"],
)
def collect(project_work: Path, repo_slugs: "tuple | None" = None) -> "tuple[list, list]":
"""(items, missing_dirs) across the requested tracker slugs (default: all of TRACKER_SLUGS),
sorted per sort_key(). missing_dirs holds the todo_dir path (as a string) for every requested
tracker whose directory does not exist -- see scan_tracker()'s missing-vs-empty distinction."""
wanted = repo_slugs if repo_slugs else TRACKER_SLUGS
items = []
missing = []
for slug, label in TRACKERS:
if slug not in wanted:
continue
todo_dir = project_work / "todos" / slug
found = scan_tracker(todo_dir, slug, label)
if found is None:
missing.append(str(todo_dir))
continue
items.extend(found)
items.sort(key=sort_key)
return items, missing
def _section_table(title: str, rows: list, *, show_reason: bool) -> str:
"""One "== <title> (N) ==" banner plus a padded, human-readable table of rows (or "(none)")."""
out = [f"== {title} ({len(rows)}) =="]
if not rows:
out.append(" (none)")
out.append("")
return "\n".join(out)
headers = ["HIPRI", "ID", "REPO", "STATE", "TITLE"]
if show_reason:
headers.append("REASON")
headers.append("STATUS")
table_rows = []
for item in rows:
row = [
"HIPRI" if item["hipri"] else "",
item["id"],
item["repo"],
item["lifecycle"],
truncate(item["title"], TITLE_MAX_LEN),
]
if show_reason:
row.append(truncate(item["block_reason"], 45))
row.append(truncate(item["status"], STATUS_MAX_LEN))
table_rows.append(row)
widths = [
max(len(headers[col]), *(len(row[col]) for row in table_rows))
for col in range(len(headers))
]
def fmt_row(row):
return " ".join(cell.ljust(width) for cell, width in zip(row, widths))
out.append(fmt_row(headers))
out.append(fmt_row(["-" * w for w in widths]))
out.extend(fmt_row(row) for row in table_rows)
out.append("")
return "\n".join(out)
def format_table(items: list, *, include_blocked: bool = True) -> str:
"""The full human-readable report: a RUNNABLE NOW section, optionally a BLOCKED / NOT READY
section (see the module docstring's "clearly split" design goal), and a one-line summary."""
if not items:
return "(no candidate items found across the surveyed tracker(s))"
runnable = [i for i in items if i["runnable"]]
blocked = [i for i in items if not i["runnable"]]
lines = [_section_table("RUNNABLE NOW (READY, no unresolved open questions)", runnable, show_reason=False)]
if include_blocked:
lines.append(_section_table("BLOCKED / NOT READY", blocked, show_reason=True))
hipri_count = sum(1 for i in items if i["hipri"])
repos_seen = len({i["repo"] for i in items})
summary = f"{len(items)} item(s) across {repos_seen} tracker(s): {len(runnable)} runnable now"
if include_blocked:
summary += f", {len(blocked)} blocked"
summary += f"; {hipri_count} HIPRI."
lines.append(summary)
return "\n".join(lines)
def to_json_row(item: dict) -> dict:
"""The JSON-emitted shape of one row -- drops fields that exist only to drive sort_key()."""
return {k: v for k, v in item.items() if k not in ("numeric_id", "child_suffix")}
def main() -> int:
parser = argparse.ArgumentParser(
description="Survey the wave-scope trackers (juneau, juneau-release-manager, "
"sandbox-support-console -- NOT irs) and emit the cross-project wave-candidate table "
"(see @todo-and-waves).",
epilog=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--root", metavar="PATH", help="Project Work root (default: ~/Project Work).")
parser.add_argument(
"--repo",
metavar="SLUG",
action="append",
default=[],
choices=TRACKER_SLUGS,
help="Restrict the survey to one tracker slug. Repeatable. Default: all three.",
)
parser.add_argument("--runnable-only", action="store_true", help="Report only the RUNNABLE NOW items.")
parser.add_argument("--json", action="store_true", help="Emit a machine-readable JSON object instead of a table.")
args = parser.parse_args()
project_work = Path(args.root).expanduser().resolve() if args.root else PROJECT_WORK
repo_slugs = tuple(args.repo) if args.repo else None
scanned = [project_work / "todos" / slug for slug in (repo_slugs or TRACKER_SLUGS)]
print(f"[wave-survey] scanning {', '.join(str(p) for p in scanned)}", file=sys.stderr)
items, missing = collect(project_work, repo_slugs)
for missing_dir in missing:
print(
f"ERROR: {missing_dir} does not exist -- skipped (not scanned as empty; the other "
f"tracker(s) are still reported below).",
file=sys.stderr,
)
if args.runnable_only:
items = [i for i in items if i["runnable"]]
if args.json:
print(json.dumps({"items": [to_json_row(i) for i in items], "missing_trackers": missing}, indent=2))
else:
print(format_table(items, include_blocked=not args.runnable_only))
return 2 if missing else 0
if __name__ == "__main__":
sys.exit(main())