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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ Adding a state means choosing its mark first and its glyph last. The
merge request in review that fell behind its target is found through
`diverged_commits_count`.
- `/discussions` paginates; a bot-reviewed merge request runs past one page.
- `ci_must_pass` stands for any head pipeline that did not succeed and is not
running, so the merge request's `head_pipeline.status` says what it waits on:
failed, canceled or missing is on you, created or scheduled starts by itself.
- Right after a merge, the branch pipeline may not exist yet: the merge commit's
pipeline is looked for a few times before the latest one stands in.

Expand Down Expand Up @@ -241,3 +244,7 @@ PRTOP_STATE_DIR=/tmp/prtop-scratch prtop
`test_a_deployment_that_shipped_leaves_the_report`.
- Comments describe the code as it is, with no reference to what it used to be.
- Commit titles follow Conventional Commits (`feat:`, `fix:`, `refactor:`…).
- A pull request that changes what a user sees or runs adds its line to
[CHANGELOG.md](CHANGELOG.md), under `## [Unreleased]` and `Added`, `Changed`
or `Fixed`, in the same pull request. One that only touches docs, CI or tests
adds none.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,18 @@ prtop's user-facing changes, newest first. The format follows
directory.

### Changed

- On macOS and Linux, the live view keeps the mouse the way htop does: the wheel
does not scroll the terminal's history behind the table. Selecting text takes
Option in iTerm2, Shift in most other terminals.

### Fixed

- A merge request whose pipeline failed, was canceled or never ran reads as on
you (`!`): `CI failed`, `CI canceled` or `no pipeline`, where it read as
`CI must pass`, on a machine. On GitHub, failing required checks read as
`CI failed`.

## [0.2.0] - 2026-10-26

### Added
Expand Down
23 changes: 23 additions & 0 deletions examples/claude-code/commands/prs.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ The first column says whose move it is. Act on it:
| `!` | `rebase needed, …` | Offer to rebase it through the API. |
| `!` | `conflicts, …` | Tell me, and offer to resolve them in a local checkout. |
| `!` | `resolve own threads, approved` | Only threads I opened block the merge. Ask whether to resolve them and merge. |
| `!` | `CI failed, …` | Read the failed jobs' logs and tell me what broke. Offer to fix what the change broke, or to retry a job that failed for a reason outside it. |
| `!` | `CI canceled, …` | Offer to retry the pipeline. |
| `!` | `no pipeline, …` | No pipeline ran on the last commit, and none starts on its own. Offer to start one. |
| `!` | `instance unreachable` | The forge the row names did not answer, so its open merge requests are unaccounted for. Relay the error from stderr; if it happens again, suggest `glab auth status` or `gh auth status`. |
| `?` | `changes requested, …` or `unresolved threads, …` | Sum up what the reviewers are waiting on, and offer to address it. |
| `?` | a label prtop does not recognise | Report the raw label. |
Expand Down Expand Up @@ -42,6 +45,17 @@ glab api --hostname <host> --method PUT "projects/<encoded path>/merge_requests/
glab api --hostname <host> --method POST "projects/<encoded path>/jobs/<job id>/play"
```

The merge request's `head_pipeline.id` leads to the pipeline that must pass:

```bash
glab api --hostname <host> "projects/<encoded path>/merge_requests/<iid>"
glab api --hostname <host> "projects/<encoded path>/pipelines/<pipeline id>/jobs?scope[]=failed"
glab api --hostname <host> "projects/<encoded path>/jobs/<job id>/trace"
glab api --hostname <host> --method POST "projects/<encoded path>/jobs/<job id>/retry"
glab api --hostname <host> --method POST "projects/<encoded path>/pipelines/<pipeline id>/retry"
glab api --hostname <host> --method POST "projects/<encoded path>/merge_requests/<iid>/pipelines"
```

On GitHub, an entry carries the `repo` (`owner/name`) and `number`, and a merged
one the `prod_run_id` whose deployment waits for approval:

Expand All @@ -52,3 +66,12 @@ gh api --hostname github.com "repos/<repo>/actions/runs/<run id>/pending_deploym
gh api --hostname github.com --method POST "repos/<repo>/actions/runs/<run id>/pending_deployments" \
-F "environment_ids[]=<id>" -f state=approved -f comment="<why>"
```

The checks link to their runs, whose failed jobs carry the logs. `--repo
github.com/<repo>` pins the host as `--hostname` does:

```bash
gh pr checks <number> --repo github.com/<repo>
gh run view <run id> --repo github.com/<repo> --log-failed
gh run rerun <run id> --repo github.com/<repo> --failed
```
2 changes: 1 addition & 1 deletion prtop/forges/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ def review_state(pr: dict, threads: Callable[[], list[dict]], login: str) -> tup
case "PENDING" | "EXPECTED":
return model.CI_RUNNING, True
case "FAILURE" | "ERROR":
return model.CI_MUST_PASS, True
return model.CI_FAILED, True
if unresolved:
return model.UNRESOLVED_THREADS, False
return model.BLOCKED, True
Expand Down
28 changes: 25 additions & 3 deletions prtop/forges/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
GLAB_URL = "https://gitlab.com/gitlab-org/cli"

# GitLab's `detailed_merge_status`, onto prtop's review states. A status missing
# here passes through raw and shows up as unrecognised.
# here passes through raw and shows up as unrecognised. `ci_must_pass` is told
# apart by its pipeline, below.
REVIEW_STATUS = {
"draft_status": model.DRAFT,
"not_approved": model.AWAITING_REVIEW,
Expand All @@ -34,13 +35,29 @@
"need_rebase": model.REBASE_NEEDED,
"conflict": model.CONFLICTS,
"ci_still_running": model.CI_RUNNING,
"ci_must_pass": model.CI_MUST_PASS,
"checking": model.MERGE_CHECK_RUNNING,
"unchecked": model.MERGE_CHECK_PENDING,
"blocked_status": model.BLOCKED,
"mergeable": model.MERGEABLE,
}

# `ci_must_pass` names a head pipeline that did not succeed and is not running,
# by its status: one that fails, was canceled or never started waits on you, one
# created or scheduled starts by itself. A status missing here passes through
# as `pipeline <status>` and shows up as unrecognised.
MUST_PASS_PIPELINE = {
"failed": model.CI_FAILED,
"canceled": model.CI_CANCELED,
"canceling": model.CI_CANCELED,
None: model.NO_PIPELINE,
"created": model.CI_RUNNING,
"scheduled": model.CI_RUNNING,
"waiting_for_resource": model.CI_RUNNING,
"preparing": model.CI_RUNNING,
"pending": model.CI_RUNNING,
"running": model.CI_RUNNING,
}


def project_path_from_summary(mr_summary: dict) -> str | None:
refs_full = (mr_summary.get("references") or {}).get("full")
Expand Down Expand Up @@ -111,6 +128,11 @@ def _hydrate(self, summary: dict, tasks: TaskSource) -> tuple[str, dict] | None:
# that fell behind its target would only say so once approved.
if detailed == "not_approved" and not full.get("draft") and full.get("diverged_commits_count"):
detailed = "need_rebase"
if detailed == "ci_must_pass":
pipeline = (full.get("head_pipeline") or {}).get("status")
status = MUST_PASS_PIPELINE.get(pipeline, f"pipeline {pipeline}")
else:
status = REVIEW_STATUS.get(detailed, detailed)
threads_await_user = True
# The extra calls earn their place only when threads are what blocks the merge.
# A bot-reviewed MR runs past a page of discussions, and a single reviewer
Expand All @@ -126,7 +148,7 @@ def _hydrate(self, summary: dict, tasks: TaskSource) -> tuple[str, dict] | None:
"title": full.get("title", ""),
"task": task.label if task else "",
"task_url": task.url if task else None,
"status": REVIEW_STATUS.get(detailed, detailed),
"status": status,
"fully_approved": bool(approvals.get("approved")),
"has_conflicts": bool(full.get("has_conflicts")),
"threads_await_user": threads_await_user,
Expand Down
4 changes: 3 additions & 1 deletion prtop/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ def short(key: str) -> str:
REBASE_NEEDED = "rebase_needed"
CONFLICTS = "conflicts"
CI_RUNNING = "ci_running"
CI_MUST_PASS = "ci_must_pass"
CI_FAILED = "ci_failed"
CI_CANCELED = "ci_canceled"
NO_PIPELINE = "no_pipeline"
MERGE_CHECK_RUNNING = "merge_check_running"
MERGE_CHECK_PENDING = "merge_check_pending"
BLOCKED = "blocked"
Expand Down
4 changes: 3 additions & 1 deletion prtop/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
model.REBASE_NEEDED: (ACT, "πŸ”", "rebase needed"),
model.CONFLICTS: (ACT, "πŸ’₯", "conflicts"),
model.CI_RUNNING: (RUNNING, "⏳", "CI running"),
model.CI_MUST_PASS: (RUNNING, "⏳", "CI must pass"),
model.CI_FAILED: (ACT, "❌", "CI failed"),
model.CI_CANCELED: (ACT, "πŸ›‘", "CI canceled"),
model.NO_PIPELINE: (ACT, "πŸ’€", "no pipeline"),
model.MERGE_CHECK_RUNNING: (RUNNING, "πŸ”", "merge check running"),
model.MERGE_CHECK_PENDING: (RUNNING, "πŸ”", "merge check pending"),
model.UNRESOLVED_THREADS: (ANSWER, "πŸ’¬", "unresolved threads"),
Expand Down
8 changes: 5 additions & 3 deletions tests/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,11 @@ def test_checks_still_running_are_on_the_machine(self):
pr = pull_request(decision="APPROVED", checks="PENDING")
self.assertEqual(state_of(pr), (model.CI_RUNNING, "⏳ CI running, approved"))

def test_failing_required_checks_must_pass(self):
pr = pull_request(decision="APPROVED", checks="ERROR")
self.assertEqual(review_state(pr, never_asked, ME)[0], model.CI_MUST_PASS)
def test_failing_required_checks_are_on_you(self):
for checks in ("FAILURE", "ERROR"):
with self.subTest(checks):
pr = pull_request(decision="APPROVED", checks=checks)
self.assertEqual(state_of(pr), (model.CI_FAILED, "❌ CI failed, approved"))

def test_a_reviewers_thread_outranks_the_checks(self):
pr = pull_request(decision="APPROVED", checks="FAILURE", threads=[False])
Expand Down
26 changes: 25 additions & 1 deletion tests/test_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from prtop import model
from prtop.config import Config
from prtop.forges.gitlab import (
MUST_PASS_PIPELINE,
REVIEW_STATUS,
GitLab,
project_path_from_summary,
Expand Down Expand Up @@ -134,7 +135,30 @@ def test_a_merge_status_gitlab_adds_later_passes_through_raw(self):
self.assertEqual(entry["status"], "some_status_from_the_future")

def test_every_gitlab_status_maps_to_a_state_the_report_knows(self):
self.assertLessEqual(set(REVIEW_STATUS.values()), set(REVIEW_SIGNAL) | {model.MERGEABLE})
known = set(REVIEW_SIGNAL) | {model.MERGEABLE}
self.assertLessEqual(set(REVIEW_STATUS.values()) | set(MUST_PASS_PIPELINE.values()), known)

def must_pass(self, head_pipeline):
mr = open_mr("ci_must_pass", head_pipeline=head_pipeline)
return FakeGitLab(mr).open_merge_requests(NoTasks())[KEY]["status"]

def test_a_pipeline_that_failed_was_canceled_or_never_ran_is_on_you(self):
for head_pipeline, status in (
({"status": "failed"}, model.CI_FAILED),
({"status": "canceled"}, model.CI_CANCELED),
({"status": "canceling"}, model.CI_CANCELED),
(None, model.NO_PIPELINE),
):
with self.subTest(head_pipeline):
self.assertEqual(self.must_pass(head_pipeline), status)

def test_a_pipeline_about_to_start_is_on_the_machine(self):
for pipeline_status in ("created", "scheduled"):
with self.subTest(pipeline_status):
self.assertEqual(self.must_pass({"status": pipeline_status}), model.CI_RUNNING)

def test_a_pipeline_that_must_pass_in_any_other_status_shows_that_status(self):
self.assertEqual(self.must_pass({"status": "manual"}), "pipeline manual")

def test_threads_only_you_wrote_in_do_not_await_you(self):
responses = open_mr("discussions_not_resolved")
Expand Down
16 changes: 13 additions & 3 deletions tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ def test_a_running_pipeline_resolves_without_anyone(self):
self.assertEqual(fmt_status(model.CI_RUNNING, False, False)[0], RUNNING)
self.assertEqual(fmt_status(model.MERGE_CHECK_RUNNING, False, False)[0], RUNNING)

def test_a_pipeline_that_failed_was_canceled_or_never_ran_is_on_you(self):
for status, cell in (
(model.CI_FAILED, "❌ CI failed, approved"),
(model.CI_CANCELED, "πŸ›‘ CI canceled, approved"),
(model.NO_PIPELINE, "πŸ’€ no pipeline, approved"),
):
with self.subTest(status):
self.assertEqual(fmt_status(status, True, False), (ACT, cell))

def test_a_status_the_forge_adds_later_surfaces_instead_of_sinking(self):
mark, cell = fmt_status("some_status_from_the_future", False, False)
self.assertEqual(mark, ANSWER)
Expand Down Expand Up @@ -226,13 +235,14 @@ def test_a_glyph_never_carries_two_meanings_in_the_status_column(self):
shared = {g: s for g, s in by_glyph.items() if len(s) > 1}
expected = {
# A pipeline in flight is a single state whether the MR is open or merged.
"⏳": {model.CI_RUNNING, model.CI_MUST_PASS, "deploy:pipeline_running"},
"⏳": {model.CI_RUNNING, "deploy:pipeline_running"},
"πŸ”": {model.MERGE_CHECK_RUNNING, model.MERGE_CHECK_PENDING},
# One meaning, two scopes: the monitor got no answer. `check failed`
# is a single deployment's call, this is a whole instance.
"πŸ”Œ": {"unreachable_instance", "deploy:check_failed"},
# One meaning, two scopes: a run failed, the whole pipeline or its prod job.
"❌": {"deploy:pipeline_failed", "deploy:prod_failed"},
# One meaning, three scopes: a run failed, an open MR's pipeline, a
# merged one's, or its prod job.
"❌": {model.CI_FAILED, "deploy:pipeline_failed", "deploy:prod_failed"},
}
for glyph, states in shared.items():
self.assertEqual(states, expected.get(glyph, set()), f"{glyph} means too many things")
Expand Down
Loading