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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Fixed

- **Backlog digest now links a PR to its issue on any `#N` reference, not just closing keywords** — a "Part of #N" PR no longer leaves its issue showing In Progress. ([#652](https://github.com/johanzander/bess-manager/issues/652))
- **Beta release changelog merges no longer absorb the new section into the previous one** — the merge is now resolved deterministically instead of by hand. ([#648](https://github.com/johanzander/bess-manager/issues/648))

## [10.1.0] - 2026-08-22
Expand Down
248 changes: 247 additions & 1 deletion backend/tests/test_backlog_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,144 @@ def test_an_issue_with_several_prs_reports_all_of_them(bin_dir: Path) -> None:
assert "pr" not in item


def test_a_part_of_pr_is_linked_to_its_issue(bin_dir: Path) -> None:
"""The no-auto-close rule forbids `Closes #N` on an intermediate PR, so a
beta PR says `Part of #N` (or `tracking #N`, or nothing but a bare `#N`)
instead. A digest that links by closing keyword only therefore makes that
PR invisible: #409 reported In Progress while its PR #490 sat approved,
because `prs_for` resolved to nothing and the column fell through to the
live worktree. Linkage must match any `#N` reference, not just
`fixes/closes/resolves/refs`.

The branch name deliberately carries no issue number, so the association
is proved by the body reference alone -- the headRefName fallback is
exercised by its own test and must not mask this one."""
issue = _issue(409, labels=[{"name": "bug"}])
pr = _pr(
490,
body="Part of #409 — this PR covers PredictionSnapshotStore only.",
headRefName="feat/prediction-snapshot-store",
isDraft=False,
mergeable="MERGEABLE",
)
_write_shim(bin_dir, "gh", _gh_shim([issue], [pr], []))
_write_shim(
bin_dir,
"git",
_git_shim(
_porcelain(
("/repo/wt/409", "feat/issue-409-prediction-snapshot-consolidation")
)
),
)

digest = _run(bin_dir)
item = digest["items"][0]

assert [p["number"] for p in item["prs"]] == [490]
assert item["column"] == "In Review" # was In Progress before the fix
# ...and the PR is no longer reported as belonging to no issue.
assert [o for o in digest["orphans"] if o["kind"] == "pr_no_issue"] == []


def test_a_bare_number_reference_links_a_pr_to_its_issue(bin_dir: Path) -> None:
"""`tracking #N` and a bare `#N` are the other spellings the no-auto-close
rule leaves an intermediate PR with. Any `#N` in the body must link."""
issue = _issue(633, labels=[{"name": "bug"}])
pr = _pr(
634,
body="First of two PRs tracking #633; the issue stays open until the second lands.",
headRefName="feat/split-work-a",
isDraft=False,
)
_write_shim(bin_dir, "gh", _gh_shim([issue], [pr], []))

item = _run(bin_dir)["items"][0]

assert [p["number"] for p in item["prs"]] == [634]
assert item["column"] == "In Review"


def test_a_blocked_by_reference_does_not_link_a_pr_to_its_issue(bin_dir: Path) -> None:
"""`Blocked by #N` is a documented convention -- a PR that waits on issue N
is not part of N's work. The widened `any #N` linkage must not grab it, or
a PR that merely names its blocker would flip the blocked issue to
In Review and clear the PR's orphan status."""
issue = _issue(900, labels=[{"name": "bug"}])
pr = _pr(
901,
body="Blocked by #900 \u2014 landing once the price-provider decision is made.",
headRefName="feat/price-provider-wait",
isDraft=True,
)
_write_shim(bin_dir, "gh", _gh_shim([issue], [pr], []))

digest = _run(bin_dir)
item = digest["items"][0]

assert item["prs"] == []
assert item["column"] == "Backlog"
assert [o for o in digest["orphans"] if o["kind"] == "pr_no_issue"] != []


def test_a_blocked_by_side_reference_still_links_the_prs_own_issue(
bin_dir: Path,
) -> None:
"""Phrase-stripping removes only the blocker reference, not the whole line:
"- Blocked by #900 \u2014 part of #905" links the PR to #905 while leaving
#900 untouched."""
issues = [
_issue(900, labels=[{"name": "bug"}]),
_issue(905, labels=[{"name": "bug"}]),
]
pr = _pr(
901,
body="- Blocked by #900 \u2014 part of #905",
headRefName="feat/prediction-snapshot-store",
isDraft=False,
)
_write_shim(bin_dir, "gh", _gh_shim(issues, [pr], []))

items = {i["number"]: i for i in _run(bin_dir)["items"]}

assert [p["number"] for p in items[905]["prs"]] == [901]
assert items[900]["prs"] == []


@pytest.mark.parametrize(
"body",
[
"Related to #900. Not closing it.",
"Not blocked by #900 anymore \u2014 resuming.",
"Depends on #900.",
"Unblocks #900.",
"Unblocking #900.",
"See also #900.",
"See #900 for the original report.",
"Relationship to #900.",
"Unrelated to #900.",
"Not part of #900 anymore.",
],
)
def test_a_non_work_reference_does_not_link_a_pr_to_its_issue(
bin_dir: Path, body: str
) -> None:
"""Phrases that name an issue without claiming to work on it must not link
-- real bodies say "Related to #403. Not closing it", "unblocks #485",
"unrelated to #402". Linking on them would flip an unrelated issue to
In Review and clear the PR's orphan status."""
issue = _issue(900, labels=[{"name": "bug"}])
pr = _pr(901, body=body, headRefName="feat/price-provider-wait", isDraft=True)
_write_shim(bin_dir, "gh", _gh_shim([issue], [pr], []))

digest = _run(bin_dir)
item = digest["items"][0]

assert item["prs"] == []
assert item["column"] == "Backlog"
assert [o for o in digest["orphans"] if o["kind"] == "pr_no_issue"] != []


def test_open_pr_list_actually_requests_isdraft(bin_dir: Path) -> None:
"""`prs_for` emits `isDraft` on every PR object, but jq can only surface a
field the `gh pr list --json ...` call actually requested -- a fixture
Expand Down Expand Up @@ -569,12 +707,61 @@ def test_a_merged_pr_with_the_issue_open_is_in_verification(bin_dir: Path) -> No
this period unnamed, so a fix awaiting real-world confirmation sat in
whatever column it happened to be in."""
issue = _issue(510, labels=[{"name": "bug"}, {"name": "analyzed"}])
merged = [_pr(511, body="Refs #510", headRefName="fix/issue-510")]
merged = [_pr(511, body="Closes #510", headRefName="fix/issue-510")]
_write_shim(bin_dir, "gh", _gh_shim([issue], [], [], merged))

assert _run(bin_dir)["items"][0]["column"] == "In Verification"


def test_a_merged_intermediate_pr_keeps_the_issue_in_verification(
bin_dir: Path,
) -> None:
"""A merged intermediate PR (`Part of #N`) means the work has landed on main
and is awaiting graduation -- In Verification, never re-dispatchable.

This is the no-auto-close contract: beta PRs omit `Closes #N` until the
fix graduates, so `Part of`/`Refs` are how a fix normally reads on merge.
When the merged scan was narrowed to closing keywords only, issues whose
fix had already merged (#643 -> #675, #571 -> #584, #592 -> #619, #666 ->
#672, #542 -> #591) fell through to Backlog / Ready for Dev, so a backlog
pass could re-dispatch an issue whose partial work already landed."""
issue = _issue(517, labels=[{"name": "bug"}, {"name": "analyzed"}])
merged = [_pr(518, body="Part of #517", headRefName="fix/issue-517-a")]
_write_shim(bin_dir, "gh", _gh_shim([issue], [], [], merged))

item = _run(bin_dir)["items"][0]

assert item["merged_pr"] == 518
assert item["merged_prs"] == [518]
assert item["column"] == "In Verification"


def test_a_merged_cross_ref_does_not_move_an_issue_to_in_verification(
bin_dir: Path,
) -> None:
"""The merged scan is deliberately narrower than the open-PR one: it uses
work verbs only (`fixes/closes/resolves/refs/part of/tracking`), never bare
`#N`. A merged PR that merely names another issue -- "Related to #403. Not
closing it -- leaving it open until #456 and #457 are also resolved" --
must not flip that issue to In Verification."""
issue = _issue(403, labels=[{"name": "bug"}])
merged = [
_pr(
453,
body="Related to #403. Not closing it -- leaving it open until "
"#456 and #457 are also resolved.",
headRefName="fix/issue-403-logging",
)
]
_write_shim(bin_dir, "gh", _gh_shim([issue], [], [], merged))

item = _run(bin_dir)["items"][0]

assert item["merged_pr"] is None
assert item["merged_prs"] == []
assert item["column"] != "In Verification"


def test_an_open_pr_outranks_a_merged_one(bin_dir: Path) -> None:
"""A graduation PR still open means the work is In Review, not verified."""
issue = _issue(512, labels=[{"name": "bug"}])
Expand Down Expand Up @@ -678,6 +865,65 @@ def test_conflicting_pr_is_reported_on_its_issue(bin_dir: Path) -> None:
assert item["column"] == "In Review"


def test_mergeable_is_requeried_until_it_leaves_unknown(bin_dir: Path) -> None:
"""GitHub computes `mergeable` LAZILY: the first query on a cold PR returns
UNKNOWN and triggers the computation, so a single query reports UNKNOWN as
if it were a verdict (measured on #490: six consecutive UNKNOWN passes).
The digest must re-query until the value settles, exactly as sweep-prs
does. This shim returns UNKNOWN on the first `pr list` and MERGEABLE on
the second, so only a re-query produces the asserted value."""
issue = _issue(801, labels=[{"name": "bug"}])
# `Refs #N` (not `Part of #N`): this test isolates the mergeable retry,
# and `Refs` already links under both the old and new linkage rules.
unknown_pr = _pr(
802, body="Refs #801", headRefName="fix/part-a", mergeable="UNKNOWN"
)
mergeable_pr = dict(unknown_pr, mergeable="MERGEABLE")
counter = bin_dir / "gh_pr_list_calls"
shim = f"""
case "$*" in
*"issue list"*) cat <<'EOF'
{json.dumps([issue])}
EOF
;;
*"pr diff "*"--name-only"*) : ;;
*"pr list"*"--state merged"*) printf '%s\\n' '[]' ;;
*"pr list"*)
if [ -f '{counter}' ]; then
cat <<'EOF'
{json.dumps([mergeable_pr])}
EOF
else
touch '{counter}'
cat <<'EOF'
{json.dumps([unknown_pr])}
EOF
fi
;;
*"project item-list"*) printf '%s\\n' '{{"items": []}}' ;;
*) echo "unexpected gh call: $*" >&2; exit 1 ;;
esac
"""
_write_shim(bin_dir, "gh", shim)

item = _run(bin_dir, MERGE_RETRY_SLEEP="0")["items"][0]

assert item["prs"][0]["mergeable"] == "MERGEABLE"


def test_mergeable_still_unknown_after_retries_is_reported_null(bin_dir: Path) -> None:
"""If GitHub has still not computed `mergeable` inside the retry budget,
the digest must not pass UNKNOWN through as if it were a definite state --
it emits null, so no consumer can read it as a verdict."""
issue = _issue(803, labels=[{"name": "bug"}])
pr = _pr(804, body="Refs #803", headRefName="fix/part-b", mergeable="UNKNOWN")
_write_shim(bin_dir, "gh", _gh_shim([issue], [pr], []))

item = _run(bin_dir, MERGE_RETRY_SLEEP="0")["items"][0]

assert item["prs"][0]["mergeable"] is None


def test_issue_matched_by_two_prs_emits_one_item_with_both_prs(
bin_dir: Path,
) -> None:
Expand Down
70 changes: 63 additions & 7 deletions scripts/backlog-digest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,25 @@ fi
issues=$(gh issue list --repo "$repo" --state open --limit 200 \
--json number,title,labels,author,createdAt,updatedAt,comments,body)

# GitHub computes `mergeable` LAZILY: the first query on a cold PR returns
# UNKNOWN and triggers the computation, so a single pass would report UNKNOWN
# as if it were a verdict. Re-query until no open PR is still UNKNOWN -- the
# budget covers the #490 measurement, which stayed UNKNOWN for six consecutive
# passes -- because the digest must never hang on a cold PR. A PR left UNKNOWN
# after the budget is emitted as null in `prs_for` below, so it never
# masquerades as a definite state.
MERGE_RETRY_LIMIT="${MERGE_RETRY_LIMIT:-6}"
MERGE_RETRY_SLEEP="${MERGE_RETRY_SLEEP:-2}"
prs=$(gh pr list --repo "$repo" --state open --limit 100 \
--json number,title,headRefName,mergeable,body,isDraft)
merge_attempts=0
while printf '%s' "$prs" | jq -e 'any(.[]; .mergeable == "UNKNOWN")' >/dev/null 2>&1 \
&& [ "$merge_attempts" -lt "$MERGE_RETRY_LIMIT" ]; do
merge_attempts=$((merge_attempts + 1))
sleep "$MERGE_RETRY_SLEEP"
prs=$(gh pr list --repo "$repo" --state open --limit 100 \
--json number,title,headRefName,mergeable,body,isDraft)
done

# The EXACT half of the collision gate: what every open PR already touches.
# One gh pr diff per open PR, bounded by the WIP limit in practice. The
Expand Down Expand Up @@ -110,7 +127,13 @@ merged_prs=$(gh pr list --repo "$repo" --state merged --limit 200 \
| jq -c '[ .[] | {
number,
headRefName,
refs: [ (.body // "") | scan("(?i)(?:fixes|closes|resolves|refs) #([0-9]+)") | .[0] | tonumber ]
# WORK references only -- the closing verbs plus the no-auto-close
# spellings (`Part of`, `tracking`, `Refs`). Deliberately NOT bare `#N`:
# a merged PR body can name other issues without working on them
# ("until #456 and #457 are also resolved"), and a merged PR must not
# flip an unrelated issue to In Verification. Drives both `merged_pr`
# (the column) and `merged_prs` (the visibility list).
refs: [ (.body // "") | scan("(?i)(?:fixes|closes|resolves|refs|part of|tracking|tracks) #([0-9]+)") | .[0] | tonumber ]
} ]')

# Emits, per worktree, a JSON object of {path, branch, locked}. `git worktree
Expand Down Expand Up @@ -206,20 +229,45 @@ jq -n \
def resume_count($comments):
[ $comments[]? | select((.body // "") | contains("<!-- resume-handoff -->")) ] | length;

# `refs` joins the closing verbs deliberately. The project rule is that a beta
# or intermediate PR must NOT close the reporters issue -- only the graduation
# PR does -- so an intermediate PR carries `Refs #N` and would otherwise
# associate with nothing at all.
# LINKAGE is any `#N` reference in the body, not just the closing verbs. The
# no-auto-close rule forbids `Closes #N` on an intermediate PR -- it says
# `Part of #N`, `tracking #N`, `Refs #N`, or a bare `#N` -- so a digest that
# links by closing keyword only makes that PR invisible (the #409/#490
# defect). Whether the work has LANDED is the merged-PR scan above, which is
# deliberately narrower (work verbs only) so a merged PR that merely names
# another issue cannot flip it to In Verification; linkage here is the broad
# any-`#N` net for OPEN PRs. The number is bounded on both sides so `#2409`
# does not match issue 409 and `#4095` does not match 409.
#
# Cross-references that name an issue WITHOUT claiming to work on it are
# stripped before matching, so they neither link nor orphan-claim: the
# documented `Blocked by #N` convention, `Depends on`, `Unblocks`,
# `Related to` (and `unrelated to`), `Relationship to`, `Follow-up to`,
# `See also`. Real bodies use these -- "Related to #403. Not closing it",
# "unblocks #485", "unrelated to #402" -- and linking on them would flip an
# unrelated issue to In Review. Stripping the PHRASE, not the whole
# line, keeps a combined reference like "- Blocked by #100 -- part of #409"
# working: only the blocker phrase disappears and #409 still links. The
# remaining test is still any `#N`, so the no-auto-close spellings
# (`Part of #N`, `tracking #N`, `Refs #N`, bare `#N`) all link.
def linkage_body($body):
($body // "")
| gsub("(?i)(blocked by|depends on|unblocks?(?:ing)?|related to|relationship to|follow[- ]?up to|see also|see|not part of) #[0-9]+"; "");

def pr_matches_issue($p; $n):
($p.body // "" | test("(?i)(fixes|closes|resolves|refs) #\($n)\\b"))
(linkage_body($p.body) | test("(?i)(^|[^0-9])#\($n)\\b"))
or ($p.headRefName | test("issue-\($n)(\\D|$)"));

# Returns EVERY matching PR, ascending. Taking `[0]` discarded the rest, and
# with one issue routinely carrying several PRs that meant the column was
# derived from whichever happened to sort first.
def prs_for($n):
[ $prs[] | select(pr_matches_issue(.; $n))
| {number: .number, mergeable: .mergeable, isDraft: .isDraft} ]
| {number: .number,
# A mergeable still UNKNOWN after the retry loop is not a
# verdict -- report null so no consumer reads it as definite.
mergeable: (if .mergeable == "UNKNOWN" then null else .mergeable end),
isDraft: .isDraft} ]
| sort_by(.number);

# Matches a worktree whose path OR branch contains the issue number in a
Expand Down Expand Up @@ -457,6 +505,14 @@ jq -n \
last_comment: last_comment(.comments; .author.login),
priority: $prio,
prs: $open_prs,
# Every merged PR whose body references this issue with a WORK verb
# (`fixes/closes/resolves/refs/part of/tracking`) -- the visibility
# list. `merged_pr` above is the first in the order `gh` returns
# (the most recent merge) and drives the In Verification column; this
# plural exposes all of them, sorted, so a merged
# intermediate PR (`Part of #N`, which must not close the issue)
# stays visible even alongside later PRs.
merged_prs: ([ $merged_prs[] | select((.refs | index($i.number)) != null) | .number ] | sort),
merged_pr: $merged_pr,
worktree: ($wt.path // null),
worktree_branch: ($wt.branch // null),
Expand Down
Loading