This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch fork-ci-status-latency-and-detail
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 9e39ebbcf699e1e7f832e7a5030ab989b447cd7e
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Nov 10 05:42:51 2026 -0700

    List the jobs that did not pass in the fork CI state comment
    
    When the fork run of a gated pull request failed, the state comment only
    said "failed" and linked the run, so the author and reviewers had to open
    the fork's run to see what broke, and with job statuses turned off or
    deferred by the write budget the pull request showed nothing more.
    
    The state comment of a failed run now lists the jobs that failed, timed
    out or were cancelled, linking each job in the fork (at most 10). The jobs
    are reused from the job statuses when they were read in the same run, and
    a failed read leaves the comment for the next run instead of dropping the
    list.
    
    Generated-by: Claude Opus 5
---
 .../doc/ci/08_fork_ci_for_external_contributors.md |  12 ++-
 .../src/airflow_breeze/utils/fork_ci_messages.py   |  41 ++++++-
 .../src/airflow_breeze/utils/fork_ci_reconcile.py  |  73 ++++++++++++-
 dev/breeze/tests/test_fork_ci_messages.py          |  31 ++++++
 dev/breeze/tests/test_fork_ci_reconcile.py         | 119 ++++++++++++++++++++-
 5 files changed, 270 insertions(+), 6 deletions(-)

diff --git a/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md 
b/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md
index 6ede7917e34..3014ab1c345 100644
--- a/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md
+++ b/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md
@@ -608,7 +608,7 @@ since the draft-state job runs after the close and GitHub 
refuses to draft a clo
 | **setup_required**: workflow state is `disabled_fork`, `disabled_manually` 
or `disabled_inactivity`, or the fork has no `ci-amd.yml` | `error` (red), link 
to the fork's Actions page for `ci-amd.yml` | create or update: how to enable, 
with the exact `gh repo sync <fork> --branch main` and `gh workflow enable 
ci-amd.yml -R <fork>` lines, and `breeze ci audit`; on transition from any 
other state but `closed` (the workflow stopped running after it worked): also 
post a **new** comment sayin [...]
 | **awaiting_push**: enabled, no run for the head SHA | `pending` (yellow), 
link to the fork's Actions page | on transition from setup_required: post a 
**new** comment asking to push again (an edit would not notify) |
 | **running**: run in progress | `pending` (yellow), link to the run | update 
state and run link |
-| **failed**: conclusion is failure, cancelled, timed out or startup failure | 
`failure` (red), link to the run; for a startup failure the description says 
the run could not start | update with the run link; for a startup failure the 
text says no job ran and points to the fork's Actions settings |
+| **failed**: conclusion is failure, cancelled, timed out or startup failure | 
`failure` (red), link to the run; for a startup failure the description says 
the run could not start | update with the run link and the jobs that did not 
pass; for a startup failure the text says no job ran and points to the fork's 
Actions settings |
 | **green**: conclusion success and the pull request does not change 
CI-defining files | `success` (green), link to the run | update: ready for 
review, run link; on the author's draft: mark it ready for review when it is |
 | **green_unverified**: conclusion success, but the pull request changes 
CI-defining files (see "Trusting a green fork run") | `pending` (yellow), link 
to the run | update: a maintainer must run project CI; on the author's draft: 
mark it ready for review, then a maintainer runs project CI |
 | **closed**: setup_required or awaiting_push for `close_after_days` | 
unchanged | close with a new comment; reopening restarts the clock |
@@ -873,6 +873,16 @@ are no job statuses either, and GitHub's error is only on 
the run page. It is ha
 but `Fork CI / Tests (AMD)` says "Fork CI run could not start - check your 
fork's Actions
 settings", and the state comment explains what to check instead of asking to 
fix failures.
 
+For a failed run that started, the state comment lists the jobs that did not 
pass, each linking
+to the job in the fork, at most 10 of them; the job statuses list them all. 
The jobs come from
+`GET /repos/<fork>/actions/runs/<id>/jobs?filter=latest`, reused from the job 
statuses when they
+read them in the same run, so a failed pull request costs one jobs read per 
reconciler run. Job
+names come from the contributor's workflow, so each is shown in a code span 
with backticks and
+line breaks replaced. When the jobs cannot be read the state comment is left 
as it is until the
+next run, rather than dropping the list. A read that failed after its retries 
is not repeated in
+the same run, whether the job statuses or the state comment made it, so 
persistent server errors
+cost each failed pull request one sequence of retries a run, not two.
+
 ### Commit status
 
 The reconciler posts a commit status with context `Fork CI / Tests (AMD)` on 
the pull
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py 
b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
index c2682485f13..20212ad8fd7 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py
@@ -30,8 +30,10 @@ from __future__ import annotations
 
 import json
 import re
+from collections.abc import Sequence
 from dataclasses import dataclass
 from datetime import datetime
+from typing import NamedTuple
 
 STATUS_CONTEXT = "Fork CI / Tests (AMD)"
 # Each job of the fork run is reported under this prefix. Job names come from 
the contributor's
@@ -189,6 +191,37 @@ STARTUP_FAILURE_TEXT = (
 )
 
 
+class FailedJob(NamedTuple):
+    """A job of a failed fork run that did not pass, as listed in the state 
comment."""
+
+    name: str
+    url: str
+    conclusion: str
+
+
+# A full-matrix run can fail in dozens of jobs; the job statuses list them all.
+MAX_LISTED_FAILED_JOBS = 10
+_FAILED_JOB_OUTCOMES = {
+    "failure": "failed",
+    "timed_out": "timed out",
+    "cancelled": "cancelled",
+    "startup_failure": "could not start",
+}
+
+
+def build_failed_job_list(failed_jobs: Sequence[FailedJob]) -> str:
+    listed = [
+        # Job names come from the contributor's workflow; a code span keeps 
them from posting
+        # Markdown, including mentions, as the bot.
+        f"- [`{_sanitize_path(job.name[:JOB_NAME_MAX_LENGTH])}`]({job.url}): "
+        f"{_FAILED_JOB_OUTCOMES.get(job.conclusion, 'failed')}"
+        for job in failed_jobs[:MAX_LISTED_FAILED_JOBS]
+    ]
+    if len(failed_jobs) > MAX_LISTED_FAILED_JOBS:
+        listed.append(f"- and {len(failed_jobs) - MAX_LISTED_FAILED_JOBS} 
more, see the run")
+    return "Jobs that did not pass:\n\n" + "\n".join(listed)
+
+
 def behind_warning(behind: int, *, base_ref: str) -> str:
     return (
         f"**This branch is {behind} commits behind `{base_ref}`.** Fork CI 
tests the branch as it is, "
@@ -205,8 +238,12 @@ def state_comment(
     base_ref: str = "main",
     author_draft: bool = False,
     startup_failure: bool = False,
+    failed_jobs: Sequence[FailedJob] = (),
 ) -> str:
-    """:param startup_failure: the fork run of a failed state did not start, 
see ``STARTUP_FAILURE_TEXT``."""
+    """
+    :param startup_failure: the fork run of a failed state did not start, see 
``STARTUP_FAILURE_TEXT``.
+    :param failed_jobs: the jobs of a failed state's fork run that did not 
pass.
+    """
     text = _AUTHOR_DRAFT_TEXTS.get(marker.state) if author_draft else None
     if startup_failure:
         text = STARTUP_FAILURE_TEXT
@@ -219,6 +256,8 @@ def state_comment(
         parts.append(behind_warning(marker.behind, base_ref=base_ref))
     if run_url:
         parts.append(f"Latest run: {run_url}")
+    if marker.state == "failed" and failed_jobs:
+        parts.append(build_failed_job_list(failed_jobs))
     if marker.state == "setup_required":
         parts.append(_setup_instructions(fork))
     parts.append(f"How this works: [CI in your fork]({CONTRIBUTING_URL}).")
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py 
b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
index 7cc8419e022..1e6c9e0e211 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
@@ -69,6 +69,7 @@ from airflow_breeze.utils.fork_ci_messages import (
     STALE_JOB_STATUS,
     STATUS_CONTEXT,
     STATUS_STATES,
+    FailedJob,
     Marker,
     fork_actions_url,
     gate_modified_close_comment,
@@ -618,6 +619,7 @@ def classify(
     jobs_reported: str | None = None,
     head_shared_across_bases: bool = False,
     find_gate_change: Callable[[], str | None] = lambda: None,
+    find_failed_jobs: Callable[[], list[FailedJob] | None] = lambda: [],
 ) -> Plan:
     """
     Decide the mutations for one gated pull request.
@@ -639,6 +641,9 @@ def classify(
     :param find_gate_change: describes how the pull request changes the fork 
CI gate, or returns
         None; only called for pull requests that the gate tampering check 
applies to, once per
         head commit.
+    :param find_failed_jobs: the jobs of the fork run that did not pass, None 
when they cannot be
+        read; only called for a failed run that started. On None the state 
comment is left for the
+        next run, so that a failed read does not drop the list from it.
     """
     plan = Plan()
     labels = set(pr.labels)
@@ -828,6 +833,9 @@ def classify(
     _set_ci_passed(plan, pr, passed=state == ForkState.GREEN, 
target_url=target_url)
     _set_ci_run_started(plan, pr, fork)
     green = state in (ForkState.GREEN, ForkState.GREEN_UNVERIFIED)
+    failed_jobs = find_failed_jobs() if state == ForkState.FAILED and not 
startup_failure else []
+    if failed_jobs is None:
+        return plan
     _set_marker_comment(
         plan,
         pr,
@@ -845,6 +853,7 @@ def classify(
             base_ref=pr.base_ref,
             author_draft=_is_author_draft(pr),
             startup_failure=startup_failure,
+            failed_jobs=failed_jobs,
         ),
     )
     return plan
@@ -1445,6 +1454,48 @@ def build_job_statuses(jobs: Iterable[dict[str, Any]]) 
-> dict[str, tuple[str, s
     return statuses
 
 
+def find_failed_jobs(jobs: Iterable[dict[str, Any]]) -> list[FailedJob]:
+    """The finished jobs of a fork run that did not pass, by name, leaving out 
the jobs every run has."""
+    failed: dict[str, FailedJob] = {}
+    for job in jobs:
+        if job["name"] in UNREPORTED_JOB_NAMES or job.get("status") in 
RUNNING_JOB_STATUSES:
+            continue
+        conclusion = job.get("conclusion") or ""
+        if JOB_STATUSES.get(conclusion, ("",))[0] == "failure":
+            failed.setdefault(job["name"], FailedJob(job["name"], 
job["html_url"], conclusion))
+    return sorted(failed.values())
+
+
+# The jobs of fork runs read in this reconciler run, by run id. None records a 
read that failed after
+# its retries, so the next reader gives up at once instead of repeating them; 
the next run reads again.
+ReadJobs = dict[int, list[dict[str, Any]] | None]
+
+
+def read_failed_jobs(
+    client: GitHubClient,
+    pr: PullRequestSnapshot,
+    fork: ForkObservation,
+    read_jobs: ReadJobs,
+) -> list[FailedJob] | None:
+    """
+    ``find_failed_jobs`` of the pull request's fork run; None when its jobs 
cannot be read.
+
+    :param read_jobs: the jobs already read in this reconciler run; a read 
made here is recorded too.
+    """
+    if fork.run_id is None or not pr.head_repo:
+        return []
+    if fork.run_id in read_jobs:
+        jobs = read_jobs[fork.run_id]
+    else:
+        try:
+            jobs = client.get_run_jobs(pr.head_repo, fork.run_id)
+        except GitHubError as e:
+            console_print(f"[warning]#{pr.number}: could not read the jobs of 
{fork.run_url}: {e}[/]")
+            jobs = None
+        read_jobs[fork.run_id] = jobs
+    return None if jobs is None else find_failed_jobs(jobs)
+
+
 class JobStatusReporter:
     """Reports each job of a pull request's fork run as a commit status, 
within a per-run budget."""
 
@@ -1455,12 +1506,17 @@ class JobStatusReporter:
         repo: str,
         budget: int = MAX_JOB_STATUSES_PER_RUN,
         now: datetime | None = None,
+        read_jobs: ReadJobs | None = None,
     ):
-        """:param now: when given, deferring the statuses of a run that 
finished long ago is reported."""
+        """
+        :param now: when given, deferring the statuses of a run that finished 
long ago is reported.
+        :param read_jobs: where the jobs read, or a failed read, are kept, for 
the state comment to reuse.
+        """
         self._client = client
         self._repo = repo
         self._now = now
         self.remaining = budget
+        self.read_jobs = {} if read_jobs is None else read_jobs
         self.deferred: list[int] = []
         # (pull request, attention item) for runs whose statuses are deferred 
long after they finished.
         self.delayed: list[tuple[int, str]] = []
@@ -1505,7 +1561,12 @@ class JobStatusReporter:
         if self.remaining == 0:
             self._defer(pr, fork)
             return None
-        jobs = self._client.get_run_jobs(pr.head_repo, fork.run_id)
+        try:
+            jobs = self._client.get_run_jobs(pr.head_repo, fork.run_id)
+        except Exception:
+            self.read_jobs[fork.run_id] = None
+            raise
+        self.read_jobs[fork.run_id] = jobs
         # By the case-folded context, as GitHub compares them.
         current = {
             status["context"].casefold(): (
@@ -2360,8 +2421,13 @@ def run_reconcile(
         ci_run_started_note = (
             f"reporting `{CI_RUN_STARTED_CONTEXT}` for project CI failed: 
{type(e).__name__}"
         )
+    read_jobs: ReadJobs = {}
     job_reporter = JobStatusReporter(
-        client, repo=repo, budget=max(MAX_JOB_STATUSES_PER_RUN - 
ci_run_started_writes, 0), now=now
+        client,
+        repo=repo,
+        budget=max(MAX_JOB_STATUSES_PER_RUN - ci_run_started_writes, 0),
+        now=now,
+        read_jobs=read_jobs,
     )
 
     previous_job_status_failure_runs = 
read_job_status_failure_runs(reported_file)
@@ -2411,6 +2477,7 @@ def run_reconcile(
                 jobs_reported=jobs_reported,
                 head_shared_across_bases=pr.head_sha in shared_heads,
                 find_gate_change=functools.partial(gate_change_finder.find, 
pr),
+                find_failed_jobs=functools.partial(read_failed_jobs, client, 
pr, fork, read_jobs),
             )
             draft_change = apply_plan(client, pr, plan)
             if draft_change is not None:
diff --git a/dev/breeze/tests/test_fork_ci_messages.py 
b/dev/breeze/tests/test_fork_ci_messages.py
index ccc12a48ce3..bbd3b03c568 100644
--- a/dev/breeze/tests/test_fork_ci_messages.py
+++ b/dev/breeze/tests/test_fork_ci_messages.py
@@ -36,6 +36,8 @@ from airflow_breeze.utils.fork_ci_messages import (
     JOB_NAME_MAX_LENGTH,
     JOB_STATUS_CONTEXT_PREFIX,
     MAX_LISTED_CI_FILES,
+    MAX_LISTED_FAILED_JOBS,
+    FailedJob,
     Marker,
     ci_change_approval_comment,
     ci_modification_note_comment,
@@ -120,6 +122,35 @@ def 
test_state_comment_links_the_contributor_documentation(startup_failure):
     assert text.endswith(FOOTER)
 
 
+def test_failed_job_list_is_safe_markdown_and_capped():
+    jobs = [
+        FailedJob("Tests `evil`\n\n@someone", "https://job/0";, "timed_out"),
+        *(
+            FailedJob(f"Job {i}", f"https://job/{i + 1}", "failure")
+            for i in range(MAX_LISTED_FAILED_JOBS + 1)
+        ),
+    ]
+
+    text = state_comment(
+        Marker("failed", "a", MARKER.since), fork="u/airflow", run_url=None, 
failed_jobs=jobs
+    )
+
+    assert "- [`Tests ?evil???@someone`](https://job/0): timed out" in text
+    assert "\n@someone" not in text
+    assert "- [`Job 0`](https://job/1): failed" in text
+    assert "- and 2 more, see the run" in text
+
+
+def test_failed_jobs_are_listed_only_for_a_failed_state():
+    jobs = [FailedJob("Static checks", "https://job/1";, "failure")]
+
+    text = state_comment(
+        Marker("running", "a", MARKER.since), fork="u/airflow", run_url=None, 
failed_jobs=jobs
+    )
+
+    assert "Static checks" not in text
+
+
 def test_parse_marker_absent():
     assert parse_marker("just a comment") is None
 
diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py 
b/dev/breeze/tests/test_fork_ci_reconcile.py
index 441901e4feb..140078f5dda 100644
--- a/dev/breeze/tests/test_fork_ci_reconcile.py
+++ b/dev/breeze/tests/test_fork_ci_reconcile.py
@@ -36,6 +36,7 @@ from airflow_breeze.utils.fork_ci_messages import (
     JOB_STATUS_CONTEXT_PREFIX,
     STALE_JOB_STATUS,
     STATUS_CONTEXT,
+    FailedJob,
     Marker,
     parse_marker,
     render_marker,
@@ -68,6 +69,7 @@ from airflow_breeze.utils.fork_ci_reconcile import (
     fetch_open_pull_requests,
     fetch_workflow_state,
     find_ci_run_since,
+    find_failed_jobs,
     find_heads_shared_across_bases,
     find_project_ci_run_url,
     find_state_comment,
@@ -81,6 +83,7 @@ from airflow_breeze.utils.fork_ci_reconcile import (
     parse_pull_request,
     pull_request_changes_ci_files,
     read_ci_run_at,
+    read_failed_jobs,
     read_head_status,
     read_job_status_failure_runs,
     read_project_ci_run_url,
@@ -2709,6 +2712,9 @@ def 
test_run_reconcile_reports_job_statuses_before_classifying(
     (pr, fork), _ = mock_reporter.return_value.report.call_args
     assert pr.number == 1
     assert mock_classify.call_args.kwargs["jobs_reported"] == "7:SUCCESS"
+    find_failed = mock_classify.call_args.kwargs["find_failed_jobs"]
+    assert find_failed.func is read_failed_jobs
+    assert find_failed.args == (client, pr, fork, 
mock_reporter.call_args.kwargs["read_jobs"])
 
 
 @pytest.mark.parametrize("status", ["queued", "in_progress", "waiting"])
@@ -3562,7 +3568,7 @@ def 
test_run_reconcile_marks_older_pilot_pull_requests_and_reports_project_ci_ru
 
     assert mock_classify.call_args.args[0].ci_run_at == datetime(2026, 11, 2, 
tzinfo=UTC)
     mock_report.assert_called_once_with(client, repo=REPO, skip={1})
-    mock_reporter.assert_called_once_with(client, repo=REPO, 
budget=job_status_budget, now=NOW)
+    mock_reporter.assert_called_once_with(client, repo=REPO, 
budget=job_status_budget, now=NOW, read_jobs={})
     assert has_alerts is fails
     if fails:
         assert "reporting `CI run started` for project CI failed: GitHubError" 
in output.read_text()
@@ -3699,3 +3705,114 @@ def 
test_run_reconcile_reports_delayed_job_statuses_as_attention_items(
     assert _reconcile_with_reporter(client, tmp_path) is True
 
     assert "job statuses of fork run R are still not all posted" in (tmp_path 
/ "s.json").read_text()
+
+
+def test_find_failed_jobs_lists_finished_jobs_that_did_not_pass():
+    jobs = [
+        _fork_job("Static checks"),
+        _fork_job("Postgres tests", conclusion="failure"),
+        _fork_job("Helm tests", status="in_progress", conclusion=None),
+        _fork_job("Docs", conclusion="timed_out"),
+        _fork_job("Java SDK tests", conclusion="skipped"),
+        _fork_job("Platform: AMD", conclusion="failure"),
+        _fork_job("Postgres tests", conclusion="cancelled", 
url="https://job/second";),
+    ]
+
+    assert find_failed_jobs(jobs) == [
+        FailedJob("Docs", "https://job/Docs";, "timed_out"),
+        FailedJob("Postgres tests", "https://job/Postgres tests", "failure"),
+    ]
+
+
[email protected](
+    ("read_jobs", "side_effect", "expected", "reads"),
+    [
+        pytest.param({1: [_fork_job("A", conclusion="failure")]}, None, ["A"], 
False, id="read-this-run"),
+        pytest.param({}, [[_fork_job("B", conclusion="failure")]], ["B"], 
True, id="not-read-yet"),
+        pytest.param({}, GitHubError("HTTP 502"), None, True, id="unreadable"),
+        pytest.param({1: None}, None, None, False, id="read-failed-this-run"),
+    ],
+)
+def test_read_failed_jobs(client, read_jobs, side_effect, expected, reads):
+    client.get_run_jobs.side_effect = side_effect
+
+    failed = read_failed_jobs(client, _pr(), COMPLETED_RUN, read_jobs)
+
+    assert (None if failed is None else [job.name for job in failed]) == 
expected
+    assert client.get_run_jobs.called is reads
+    assert 1 in read_jobs
+
+
+def test_a_failed_jobs_read_is_not_repeated_for_the_state_comment(client):
+    client.rest_get.return_value = []
+    client.get_run_jobs.side_effect = GitHubError("HTTP 502")
+    read_jobs: dict = {}
+    reporter = JobStatusReporter(client, repo=REPO, budget=10, 
read_jobs=read_jobs)
+
+    with pytest.raises(GitHubError):
+        reporter.report(_pr(), COMPLETED_RUN)
+
+    assert read_failed_jobs(client, _pr(), COMPLETED_RUN, read_jobs) is None
+    client.get_run_jobs.assert_called_once_with("someone/airflow", 1)
+
+
+def test_job_status_reporter_keeps_the_jobs_it_read(client):
+    jobs = [_fork_job("Static checks", conclusion="failure")]
+    reporter = JobStatusReporter(_reporter_client(client, jobs), repo=REPO, 
budget=10)
+
+    reporter.report(_pr(), COMPLETED_RUN)
+
+    assert reporter.read_jobs == {1: jobs}
+
+
+FAILED_JOB = FailedJob("Static checks", "https://job/1";, "failure")
+
+
[email protected](
+    ("fork", "failed_jobs", "called", "listed"),
+    [
+        pytest.param(COMPLETED_RUN, [FAILED_JOB], True, True, id="failed"),
+        pytest.param(
+            dataclasses.replace(COMPLETED_RUN, 
suite_conclusion="STARTUP_FAILURE"),
+            [FAILED_JOB],
+            False,
+            False,
+            id="startup-failure",
+        ),
+        pytest.param(RUNNING_RUN, [FAILED_JOB], False, False, id="running"),
+    ],
+)
+def test_state_comment_lists_the_jobs_of_a_failed_run(fork, failed_jobs, 
called, listed):
+    find_failed = mock.Mock(return_value=failed_jobs)
+
+    plan = classify(
+        _pr(),
+        fork,
+        config=CONFIG,
+        now=NOW,
+        repo=REPO,
+        find_bypass_run=lambda: None,
+        changes_ci_files=lambda: False,
+        find_failed_jobs=find_failed,
+    )
+
+    assert find_failed.called is called
+    assert ("- [`Static checks`](https://job/1): failed" in 
plan.marker_comment) is listed
+
+
+def 
test_state_comment_is_left_for_the_next_run_when_failed_jobs_cannot_be_read():
+    plan = classify(
+        _pr(is_draft=False, labels=("fork ci required", "ready for maintainer 
review")),
+        COMPLETED_RUN,
+        config=CONFIG,
+        now=NOW,
+        repo=REPO,
+        find_bypass_run=lambda: None,
+        changes_ci_files=lambda: False,
+        find_failed_jobs=lambda: None,
+    )
+
+    assert plan.state == ForkState.FAILED
+    assert plan.status == ("failure", RUN_URL, "Fork CI failed - see Details")
+    assert plan.remove_labels == ["ready for maintainer review"]
+    assert plan.marker_comment is None

Reply via email to