diff --git a/README.md b/README.md index 4cb9423..e4f9cf6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Spring Clean never changes GitHub. It does not delete branches, close pull reque - Lists branches, latest activity, protection/default status, and associated pull requests. - Lists currently open pull requests, including drafts. - Adds cleanup status and reason columns to guide review. -- Adds blank `review_action` and `review_comment` columns for team notes. +- Adds editable `review_action` and `review_comment` columns for team decisions. - Writes timestamped local files so reports do not overwrite each other. - Can list repositories available to your GitHub token. @@ -82,6 +82,7 @@ Spring Clean opens on the reports list. From there you can: - list repositories available to your token - run a new audit - search and filter rows +- tag branch and pull request rows for follow-up - delete old local reports after confirmation Keyboard controls: @@ -93,7 +94,11 @@ Keyboard controls: | `p` | Show pull request report | | `g` | Enter a GitHub repo or URL and run a new audit | | `l` | List repositories available to `GITHUB_TOKEN` | -| `d` | Delete the selected local report after confirmation | +| `d` | Delete the selected local report, or mark a branch for deletion / PR for closure | +| `k` | Mark the selected branch or PR as keep | +| `r` | Mark the selected branch or PR as review | +| `c` | Add or edit a short review comment | +| `u` | Clear the selected review action and comment | | `enter` | Load the selected report or audit the selected GitHub repo | | `/` | Focus search | | `s` | Cycle filters | @@ -103,6 +108,8 @@ Keyboard controls: Delete only removes local Spring Clean report files. GitHub is not changed. +Review tags are saved back into the selected branch or pull request CSV. They do not call the GitHub API. + ## Running Audits From The Command Line The browser is the main workflow, but the CLI is useful for repeatable runs or scripts. @@ -182,7 +189,7 @@ The branch CSV includes: - GitHub-like activity bucket - associated pull request numbers, authors, states, and URLs - cleanup status and reason -- blank `review_action` and `review_comment` columns +- editable `review_action` and `review_comment` columns GitHub does not expose a durable branch creator field through the branch API. `branch_created_by` is inferred in this order: @@ -225,7 +232,7 @@ It includes: - age columns - GitHub URL - cleanup status and reason -- blank `review_action` and `review_comment` columns +- editable `review_action` and `review_comment` columns Pull request cleanup statuses: @@ -252,7 +259,7 @@ Use it as a quick overview before reviewing rows in the browser or CSV. `Missing GITHUB_TOKEN` -Add `GITHUB_TOKEN` to `.env`, or export it in the shell where you run Spring Clean. +Add `GITHUB_TOKEN` to `.env`, or set it in the shell where you run Spring Clean. `Could not access owner/repo` diff --git a/src/springclean/cli.py b/src/springclean/cli.py index 7fb7d63..8944a65 100644 --- a/src/springclean/cli.py +++ b/src/springclean/cli.py @@ -31,7 +31,7 @@ def main() -> int: owner, repo = parse_repo(args.repo) token = github_token() if not token: - raise SpringCleanError("Missing GITHUB_TOKEN. Add it to .env or export it in your shell.") + raise SpringCleanError("Missing GITHUB_TOKEN. Add it to .env or set it in your shell.") client = GitHubClient(token=token) audit_repo( client=client, diff --git a/src/springclean/tui.py b/src/springclean/tui.py index eb67f06..abde44b 100644 --- a/src/springclean/tui.py +++ b/src/springclean/tui.py @@ -44,6 +44,7 @@ class ReportData: kind: str path: Path rows: list[dict[str, str]] + fieldnames: list[str] | None = None @dataclass(frozen=True) @@ -53,6 +54,14 @@ class AuditResult: reports: list[ReportData] +@dataclass(frozen=True) +class ReviewTarget: + kind: str + label: str + report: ReportData + row: dict[str, str] + + def run_browser(reports_dir: Path) -> None: SpringCleanBrowser(reports_dir=reports_dir).run() @@ -150,8 +159,9 @@ def load_csv_report(path: Path, expected_kind: str | None = None) -> ReportData: reader = csv.DictReader(handle) rows = [dict(row) for row in reader] - kind = expected_kind or detect_report_kind(path, reader.fieldnames or []) - return ReportData(kind=kind, path=path, rows=rows) + fieldnames = reader.fieldnames or [] + kind = expected_kind or detect_report_kind(path, fieldnames) + return ReportData(kind=kind, path=path, rows=rows, fieldnames=fieldnames) def detect_report_kind(path: Path, fields: list[str]) -> str: @@ -315,7 +325,11 @@ class SpringCleanBrowser(App[None]): ("p", "show_prs", "PRs"), ("g", "github_input", "Audit Repo"), ("l", "list_repos", "GitHub Repos"), - ("d", "delete_report", "Delete Report"), + ("d", "delete_report", "Delete/Close"), + ("k", "mark_keep", "Keep"), + ("r", "mark_review", "Review"), + ("c", "comment_review", "Comment"), + ("u", "clear_review", "Clear Tag"), ("s", "cycle_filter", "Filter"), ("a", "show_all", "All"), ("/", "focus_search", "Search"), @@ -341,6 +355,7 @@ def __init__( self.visible_sources: list[ReportSource] = [] self.message = "" self.loading_message = "" + self.pending_review_target: ReviewTarget | None = None def compose(self) -> ComposeResult: yield Static("", id="title") @@ -396,7 +411,7 @@ def action_list_repos(self) -> None: try: token = github_token() if not token: - raise SpringCleanError("Missing GITHUB_TOKEN. Add it to .env or export it in your shell.") + raise SpringCleanError("Missing GITHUB_TOKEN. Add it to .env or set it in your shell.") except SpringCleanError as exc: self.message = str(exc) self.update_status() @@ -420,6 +435,10 @@ def action_list_repos(self) -> None: ) def action_delete_report(self) -> None: + if self.active_kind in {BRANCH_KIND, PR_KIND}: + self.mark_selected_review(delete_action(self.active_kind)) + return + if self.active_kind != SOURCE_KIND: self.message = "Open reports with o before deleting a report." self.update_status() @@ -436,6 +455,41 @@ def action_delete_report(self) -> None: callback=lambda confirmed, source=source: self.delete_report_after_confirmation(source, confirmed), ) + def action_mark_keep(self) -> None: + self.mark_selected_review("keep") + + def action_mark_review(self) -> None: + self.mark_selected_review("review") + + def action_clear_review(self) -> None: + target = self.selected_review_target() + if target is None: + self.message = "Select a branch or pull request row first." + self.update_status() + return + + target.row["review_action"] = "" + target.row["review_comment"] = "" + save_csv_report(target.report) + self.message = f"Cleared review tag for {target.label}." + self.refresh_report() + + def action_comment_review(self) -> None: + target = self.selected_review_target() + if target is None: + self.message = "Select a branch or pull request row first." + self.update_status() + return + + self.pending_review_target = target + self.command_mode = "review_comment" + command = self.query_one("#search", Input) + command.value = target.row.get("review_comment", "") + command.placeholder = "Enter a review comment, then press Enter" + command.focus() + self.message = f"Editing comment for {target.label}." + self.update_status() + def action_cycle_filter(self) -> None: modes = filter_modes(self.active_kind) index = modes.index(self.filter_mode) if self.filter_mode in modes else 0 @@ -452,6 +506,7 @@ def action_focus_search(self) -> None: def action_clear_search(self) -> None: self.command_mode = None + self.pending_review_target = None self.search_text = "" self.reset_input(input_placeholder(self.active_kind)) self.refresh_active_view() @@ -465,13 +520,24 @@ def on_input_changed(self, event: Input.Changed) -> None: if event.input.id != "search" or self.command_mode: return self.search_text = event.value - self.refresh_active_view() + try: + self.refresh_active_view() + except NoMatches: + return def on_input_submitted(self, event: Input.Submitted) -> None: - if event.input.id != "search" or self.command_mode != "audit_repo": + if event.input.id != "search": return - self.command_mode = None - self.run_audit(event.value) + if self.command_mode == "audit_repo": + self.command_mode = None + self.run_audit(event.value) + elif self.command_mode == "review_comment": + target = self.pending_review_target + self.command_mode = None + self.pending_review_target = None + self.apply_review_comment(target, event.value) + self.reset_input(input_placeholder(self.active_kind), self.search_text) + self.query_one("#results", DataTable).focus() def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: self.update_detail_for_index(event.cursor_row) @@ -642,6 +708,45 @@ def selected_report_source(self) -> ReportSource | None: index = min(max(table.cursor_row, 0), len(self.visible_sources) - 1) return self.visible_sources[index] + def selected_review_target(self) -> ReviewTarget | None: + if self.active_kind not in {BRANCH_KIND, PR_KIND} or not self.visible_rows: + return None + table = self.query_one("#results", DataTable) + index = min(max(table.cursor_row, 0), len(self.visible_rows) - 1) + row = self.visible_rows[index] + report = self.reports[self.active_kind] + return ReviewTarget( + kind=self.active_kind, + label=review_target_label(self.active_kind, row), + report=report, + row=row, + ) + + def mark_selected_review(self, action: str) -> None: + target = self.selected_review_target() + if target is None: + self.message = "Select a branch or pull request row first." + self.update_status() + return + + target.row["review_action"] = action + target.row.setdefault("review_comment", "") + save_csv_report(target.report) + self.message = f"Marked {target.label} as {action}." + self.refresh_report() + + def apply_review_comment(self, target: ReviewTarget | None, comment: str) -> None: + if target is None: + self.message = "No branch or pull request row selected." + self.update_status() + return + + target.row.setdefault("review_action", "") + target.row["review_comment"] = comment + save_csv_report(target.report) + self.message = f"Updated comment for {target.label}." + self.refresh_report() + def delete_report_after_confirmation(self, source: ReportSource, confirmed: bool | None) -> None: if not confirmed: self.message = "Delete cancelled." @@ -671,7 +776,7 @@ def run_audit(self, repo_ref: str) -> None: owner, repo = parse_repo_reference(repo_ref) token = github_token() if not token: - raise SpringCleanError("Missing GITHUB_TOKEN. Add it to .env or export it in your shell.") + raise SpringCleanError("Missing GITHUB_TOKEN. Add it to .env or set it in your shell.") except SpringCleanError as exc: self.message = str(exc) self.update_status() @@ -686,9 +791,9 @@ def run_audit(self, repo_ref: str) -> None: thread=True, ) - def reset_input(self, placeholder: str) -> None: + def reset_input(self, placeholder: str, value: str = "") -> None: command = self.query_one("#search", Input) - command.value = "" + command.value = value command.placeholder = placeholder self.command_mode = None @@ -703,8 +808,8 @@ def table_columns(kind: str) -> list[str]: if kind == GITHUB_REPO_KIND: return ["Repository", "Private", "Archived", "Updated", "Default"] if kind == BRANCH_KIND: - return ["Branch", "Age", "Bucket", "Status", "Owner", "PRs", "Protected"] - return ["PR", "Title", "State", "Draft", "Updated", "Author", "Status"] + return ["Branch", "Age", "Bucket", "Status", "Review", "Owner", "PRs", "Protected"] + return ["PR", "Title", "State", "Draft", "Updated", "Author", "Status", "Review"] def row_cells(kind: str, row: dict[str, Any]) -> list[str]: @@ -730,6 +835,7 @@ def row_cells(kind: str, row: dict[str, Any]) -> list[str]: str(row.get("days_since_last_commit", "")), str(row.get("github_branch_bucket", "")), str(row.get("cleanup_status", "")), + str(row.get("review_action", "")), str(row.get("branch_created_by", "")), str(row.get("associated_pr_numbers", "")), str(row.get("protected", "")), @@ -742,9 +848,43 @@ def row_cells(kind: str, row: dict[str, Any]) -> list[str]: str(row.get("days_since_updated", "")), str(row.get("created_by", "")), str(row.get("cleanup_status", "")), + str(row.get("review_action", "")), ] +def save_csv_report(report: ReportData) -> None: + fields = list(report.fieldnames or csv_fieldnames(report.path)) + for field in ("review_action", "review_comment"): + if field not in fields: + fields.append(field) + for row in report.rows: + for field in row: + if field not in fields: + fields.append(field) + + tmp_path = report.path.with_name(f"{report.path.name}.tmp") + with tmp_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore") + writer.writeheader() + writer.writerows(report.rows) + tmp_path.replace(report.path) + + +def csv_fieldnames(path: Path) -> list[str]: + with path.open(newline="", encoding="utf-8") as handle: + return csv.DictReader(handle).fieldnames or [] + + +def delete_action(kind: str) -> str: + return "close" if kind == PR_KIND else "delete" + + +def review_target_label(kind: str, row: dict[str, Any]) -> str: + if kind == BRANCH_KIND: + return str(row.get("branch", "branch")) + return prefixed_pr_number(str(row.get("number", ""))) or "pull request" + + def report_source_row(source: ReportSource) -> dict[str, str]: reports = source_report_names(source) return { @@ -956,10 +1096,10 @@ def title_text(report: ReportData) -> str: def status_text(active_kind: str, visible_count: int, total_count: int, filter_mode: str) -> str: mode = filter_mode.replace("_", " ") - return ( - f"{visible_count}/{total_count} rows | view: {view_name(active_kind)} | filter: {mode} | " - "o reports | g audit | l repos | b branches | p PRs | / search | s filter | q quit" - ) + shortcuts = "o reports | g audit | l repos | b branches | p PRs | / search | s filter | q quit" + if active_kind in {BRANCH_KIND, PR_KIND}: + shortcuts = f"d {delete_action(active_kind)} | k keep | r review | c comment | u clear | {shortcuts}" + return f"{visible_count}/{total_count} rows | view: {view_name(active_kind)} | filter: {mode} | {shortcuts}" def total_count( diff --git a/tests/test_tui.py b/tests/test_tui.py index bc01fd9..05100e6 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -9,7 +9,7 @@ from unittest.mock import patch import pytest -from textual.widgets import Button, DataTable, LoadingIndicator +from textual.widgets import Button, DataTable, Input, LoadingIndicator from springclean.errors import SpringCleanError from springclean.reports import BRANCH_FIELDS, PR_FIELDS @@ -45,6 +45,7 @@ report_sources, row_cells, run_browser, + save_csv_report, status_text, table_columns, total_count, @@ -477,12 +478,78 @@ def test_delete_report_confirm_button_paths(tmp_path: Path) -> None: assert [call.args[0] for call in dismiss.call_args_list] == [True, False, True, False] -def test_browser_mounts_and_handles_actions() -> None: +def test_save_csv_report_adds_review_fields_to_older_csv(tmp_path: Path) -> None: + path = tmp_path / "legacy_branches.csv" + write_report(path, ["repo", "branch", "github_branch_bucket"], [{"repo": "owner/repo", "branch": "old"}]) + report = ReportData( + kind=BRANCH_KIND, + path=path, + rows=[ + { + "repo": "owner/repo", + "branch": "old", + "github_branch_bucket": "stale", + "review_action": "delete", + "review_comment": "safe to remove", + "extra_context": "kept", + } + ], + fieldnames=["repo", "branch", "github_branch_bucket"], + ) + + save_csv_report(report) + + with path.open(newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + row = next(reader) + + assert reader.fieldnames == [ + "repo", + "branch", + "github_branch_bucket", + "review_action", + "review_comment", + "extra_context", + ] + assert row["review_action"] == "delete" + assert row["review_comment"] == "safe to remove" + assert row["extra_context"] == "kept" + + +def test_browser_mounts_and_handles_actions(tmp_path: Path) -> None: + branch_path = tmp_path / "branches.csv" + pr_path = tmp_path / "pull_requests.csv" + write_report( + branch_path, + BRANCH_FIELDS, + [ + { + "repo": "owner/repo", + "branch": "feature/example", + "github_branch_bucket": "stale", + "cleanup_status": "candidate_stale_no_pr", + "associated_pr_numbers": "", + } + ], + ) + write_report( + pr_path, + PR_FIELDS, + [ + { + "repo": "owner/repo", + "number": "42", + "title": "Review this", + "draft": "True", + "cleanup_status": "draft_stale", + } + ], + ) app = SpringCleanBrowser( reports=[ ReportData( kind=BRANCH_KIND, - path=Path("branches.csv"), + path=branch_path, rows=[ { "repo": "owner/repo", @@ -495,7 +562,7 @@ def test_browser_mounts_and_handles_actions() -> None: ), ReportData( kind=PR_KIND, - path=Path("pull_requests.csv"), + path=pr_path, rows=[ { "repo": "owner/repo", @@ -518,12 +585,29 @@ async def run_app() -> None: app.action_show_prs() assert app.active_kind == PR_KIND app.action_delete_report() - assert "Open reports" in app.message + assert app.reports[PR_KIND].rows[0]["review_action"] == "close" app.action_cycle_filter() assert app.filter_mode == "stale" app.action_show_branches() assert app.active_kind == BRANCH_KIND + app.action_delete_report() + assert app.reports[BRANCH_KIND].rows[0]["review_action"] == "delete" + app.action_mark_keep() + assert app.reports[BRANCH_KIND].rows[0]["review_action"] == "keep" + app.action_mark_review() + assert app.reports[BRANCH_KIND].rows[0]["review_action"] == "review" + app.on_input_changed(SimpleNamespace(input=SimpleNamespace(id="search"), value="feature")) + assert app.search_text == "feature" + app.action_comment_review() + assert app.command_mode == "review_comment" + app.on_input_submitted(SimpleNamespace(input=SimpleNamespace(id="search"), value="ask Mario")) + assert app.reports[BRANCH_KIND].rows[0]["review_comment"] == "ask Mario" + assert app.search_text == "feature" + assert app.query_one("#search", Input).value == "feature" + app.action_clear_review() + assert app.reports[BRANCH_KIND].rows[0]["review_action"] == "" + assert app.reports[BRANCH_KIND].rows[0]["review_comment"] == "" app.action_cycle_filter() assert app.filter_mode == "stale" @@ -545,6 +629,47 @@ async def run_app() -> None: asyncio.run(run_app()) + with branch_path.open(newline="", encoding="utf-8") as handle: + row = next(csv.DictReader(handle)) + assert row["review_action"] == "" + assert row["review_comment"] == "" + + +def test_browser_review_actions_require_selected_report_row(tmp_path: Path) -> None: + app = SpringCleanBrowser( + reports=[ + ReportData( + kind=BRANCH_KIND, + path=tmp_path / "branches.csv", + rows=[], + fieldnames=BRANCH_FIELDS, + ) + ], + ) + + async def run_app() -> None: + async with app.run_test() as pilot: + await pilot.pause() + + app.action_delete_report() + assert app.message == "Select a branch or pull request row first." + + app.action_clear_review() + assert app.message == "Select a branch or pull request row first." + + app.action_comment_review() + assert app.message == "Select a branch or pull request row first." + + app.command_mode = "review_comment" + app.on_input_submitted(SimpleNamespace(input=SimpleNamespace(id="search"), value="ignored")) + assert app.message == "No branch or pull request row selected." + + app.active_kind = GITHUB_REPO_KIND + app.action_delete_report() + assert "Open reports" in app.message + + asyncio.run(run_app()) + def test_browser_lists_github_repos_and_runs_selected_audit(tmp_path: Path) -> None: report = ReportData(