Merge pull request #176 from codex-bot-andresmgsl/build/175-post-merge-queue-state
feat: add post-merge issue queue state
This commit is contained in:
commit
ede2767232
8 changed files with 347 additions and 48 deletions
|
|
@ -141,7 +141,12 @@ triage bug, and the move is to say so on the issue, not to guess.
|
||||||
merge — a live proof of a workflow trigger, a released-artifact check,
|
merge — a live proof of a workflow trigger, a released-artifact check,
|
||||||
anything whose subject does not exist until the change is on the base
|
anything whose subject does not exist until the change is on the base
|
||||||
branch — the same-repo PR uses `Refs #N` instead, and triage closes the
|
branch — the same-repo PR uses `Refs #N` instead, and triage closes the
|
||||||
issue by hand on the evidence, exactly as it does for cross-repo work.
|
issue by hand on the evidence, exactly as it does for cross-repo work. The
|
||||||
|
merge releases the claim: the issue moves to `post-merge`, the builder
|
||||||
|
walks away, and triage owns verification and closure. If evidence later
|
||||||
|
requires corrective build work, triage returns it to `ready` or mints a
|
||||||
|
fresh `ready` issue; any builder claims from current `main`, and the
|
||||||
|
original builder has no special standing.
|
||||||
The issue body is what says so; you never judge which issues qualify, and
|
The issue body is what says so; you never judge which issues qualify, and
|
||||||
absent that instruction `Closes #N` remains the default. The exception was
|
absent that instruction `Closes #N` remains the default. The exception was
|
||||||
bought the hard way: #143 carried `Closes #137` as doctrine then required,
|
bought the hard way: #143 carried `Closes #137` as doctrine then required,
|
||||||
|
|
|
||||||
27
LABELS.md
27
LABELS.md
|
|
@ -52,14 +52,32 @@ strips it on sight).
|
||||||
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
||||||
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
||||||
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
||||||
|
| `post-merge` | `#006B75` | the Refs-linked PR merged; post-merge acceptance criteria remain; the claim is released — nothing here is buildable and nobody owes a draft | the sweep or triage |
|
||||||
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
|
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
|
||||||
|
|
||||||
The work-queue sweep enforces the invariant a board scan relies on: every open issue is either
|
The work-queue sweep enforces the invariant a board scan relies on: every open issue is either
|
||||||
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
||||||
`blocked`. It flags conflicts rather than guessing intent. A `claimed` issue
|
`blocked` / `post-merge`. It flags conflicts rather than guessing intent. A `claimed` issue
|
||||||
with no open PR and no activity for 48 hours is reclaimed by the sweep: it
|
with no open PR and no activity for 48 hours is reclaimed by the sweep: it
|
||||||
comments, unassigns the stale owner, and restores `ready`.
|
comments, unassigns the stale owner, and restores `ready`.
|
||||||
|
|
||||||
|
When a merged PR references a `claimed` issue with `Refs #N` and unchecked
|
||||||
|
criteria remain, the sweep moves the issue to `post-merge`, clears the
|
||||||
|
assignee, and comments with the remaining criteria verbatim. The comment says
|
||||||
|
that the claim is released and that triage owes a follow-up naming the owner
|
||||||
|
and wake condition for completion. Triage writes that full transition comment
|
||||||
|
in the same tick when it or the operator makes the move by hand. The sweep
|
||||||
|
never reclaims `post-merge`: weeks of quiet can be the state working.
|
||||||
|
|
||||||
|
`post-merge` never composes with `blocked`; the transition comment carries the
|
||||||
|
wait. It never composes with `attention`, because releasing the claim clears
|
||||||
|
the assignee and leaves nobody parked-for. An assigned `post-merge` issue is
|
||||||
|
flagged rather than repaired: a hand-assignment is intent. `needs-ruling`
|
||||||
|
still composes. When the remainder becomes buildable, triage moves
|
||||||
|
`post-merge` to `ready` or mints a fresh `ready` issue. Any builder may claim
|
||||||
|
that work from current `main`; the original builder has no special standing,
|
||||||
|
and re-entry does not set `attention`.
|
||||||
|
|
||||||
## Cross-cutting (PRs and issues)
|
## Cross-cutting (PRs and issues)
|
||||||
|
|
||||||
| Label | Color | Meaning |
|
| Label | Color | Meaning |
|
||||||
|
|
@ -155,7 +173,12 @@ unanswered `attention` is exactly the silence the 48-hour reclaim should
|
||||||
take. It is hand-set doctrine only: nothing in `actions/` sets, clears,
|
take. It is hand-set doctrine only: nothing in `actions/` sets, clears,
|
||||||
reads, or validates it, and no reconciler enforces the assignee requirement.
|
reads, or validates it, and no reconciler enforces the assignee requirement.
|
||||||
An `attention` issue without an assignee is therefore a board bug, not a
|
An `attention` issue without an assignee is therefore a board bug, not a
|
||||||
demand; anyone may assign it or remove the flag.
|
demand; anyone may assign it or remove the flag. It never composes with
|
||||||
|
`post-merge`, whose released claim has no assignee to answer the demand. The
|
||||||
|
one machine-clear exception is the derived `claimed` → `post-merge`
|
||||||
|
transition: releasing the assignee clears a carried `attention` in the same
|
||||||
|
edit. A hand-created `post-merge` + `attention` composition is flagged, not
|
||||||
|
rewritten.
|
||||||
|
|
||||||
The three signals are mutually distinct: `attention` means an assignee owes
|
The three signals are mutually distinct: `attention` means an assignee owes
|
||||||
a move; `needs-ruling` means a human owes a decision under
|
a move; `needs-ruling` means a human owes a decision under
|
||||||
|
|
|
||||||
12
TRIAGE.md
12
TRIAGE.md
|
|
@ -96,7 +96,12 @@ Every issue you mint carries, in this order:
|
||||||
rather than `Closes #N`. A criterion that survives the merge only if
|
rather than `Closes #N`. A criterion that survives the merge only if
|
||||||
someone remembers to reopen the issue is an incomplete criterion — #137's
|
someone remembers to reopen the issue is an incomplete criterion — #137's
|
||||||
amended body is the worked example, reopened by hand after `Closes #137`
|
amended body is the worked example, reopened by hand after `Closes #137`
|
||||||
closed it with the criterion unmet (#151).
|
closed it with the criterion unmet (#151). The merge moves the issue to
|
||||||
|
`post-merge` and releases the claim. The sweep writes the transition
|
||||||
|
comment when it derives the move; when triage or the operator moves it by
|
||||||
|
hand, triage writes the comment in the same tick. In either case triage
|
||||||
|
follows up with the remaining criteria, their owner, and the wake condition
|
||||||
|
for completion.
|
||||||
- **Test plan**: what proves it, including the cases that must fail.
|
- **Test plan**: what proves it, including the cases that must fail.
|
||||||
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
||||||
epic organizes it. Name a cross-repo dependency the same way with its
|
epic organizes it. Name a cross-repo dependency the same way with its
|
||||||
|
|
@ -126,6 +131,11 @@ itself. Keep the checklist current — a stale epic misleads every scan.
|
||||||
lands, and flags a blocked issue whose dependency declaration is unreadable.
|
lands, and flags a blocked issue whose dependency declaration is unreadable.
|
||||||
- The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
|
- The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
|
||||||
+ no activity → comment, unassign, restore `ready`.
|
+ no activity → comment, unassign, restore `ready`.
|
||||||
|
- `post-merge` is triage's completion queue, not a parked claim. Tick verified
|
||||||
|
criteria and close under the criterion's existing contract. If corrective
|
||||||
|
build work becomes necessary, move it to `ready` or mint a fresh `ready`
|
||||||
|
issue: any builder claims from current `main`, the original builder has no
|
||||||
|
special standing, and re-entry does not set `attention`.
|
||||||
- Automation never guesses intent. Resolve the conflict comments it leaves on
|
- Automation never guesses intent. Resolve the conflict comments it leaves on
|
||||||
malformed queue states, and close or extend completed epics when nudged.
|
malformed queue states, and close or extend completed epics when nudged.
|
||||||
- **Close obsolete issues** with the reason and a link to what obsoleted
|
- **Close obsolete issues** with the reason and a link to what obsoleted
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ ISSUEFLOW_STALE_HOURS="${ISSUEFLOW_STALE_HOURS:-48}"
|
||||||
}
|
}
|
||||||
NOW="$ISSUEFLOW_NOW"
|
NOW="$ISSUEFLOW_NOW"
|
||||||
STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600))
|
STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600))
|
||||||
QUEUE_LABELS=(ready claimed blocked)
|
QUEUE_LABELS=(ready claimed blocked post-merge)
|
||||||
TRIAGE_ACTORS=()
|
TRIAGE_ACTORS=()
|
||||||
|
|
||||||
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
||||||
|
|
@ -122,6 +122,50 @@ claim_reclaim_marker() { # $1 = last activity epoch
|
||||||
printf 'claim-reclaimed-%s\n' "$1"
|
printf 'claim-reclaimed-%s\n' "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refs_references() { # PR body on stdin -> local issue numbers named by Refs
|
||||||
|
awk '
|
||||||
|
{
|
||||||
|
line = $0
|
||||||
|
lower = tolower(line)
|
||||||
|
if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) {
|
||||||
|
line = substr(line, RSTART + RLENGTH)
|
||||||
|
if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
|
||||||
|
sub(/[.(;].*/, "", line)
|
||||||
|
print line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' | issue_references \
|
||||||
|
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
||||||
|
}
|
||||||
|
|
||||||
|
unchecked_criteria() { # issue body on stdin -> unchecked task-list lines verbatim
|
||||||
|
awk '
|
||||||
|
/^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+\[[[:space:]]\]/ {
|
||||||
|
sub(/\r$/, "")
|
||||||
|
print
|
||||||
|
}
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
post_merge_decision() { # $1 merged Refs PR, $2 linked open PR, $3 already handled
|
||||||
|
local merged="$1" open_pr="$2" handled="$3" unchecked
|
||||||
|
unchecked="$(cat)"
|
||||||
|
if [ -n "$merged" ] && [ "$open_pr" = false ] && [ "$handled" = false ] \
|
||||||
|
&& [ -n "$unchecked" ]; then echo TRANSITION
|
||||||
|
else echo KEEP
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
post_merge_pr_for_issue() { # $1 issue; records are ISSUE<TAB>PR
|
||||||
|
awk -F '\t' -v issue="$1" '$1 == issue { print $2 }' \
|
||||||
|
<<<"${MERGED_REF_PR_RECORDS:-}" | sort -n | tail -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
post_merge_transition_marker() { # $1 merged PR number
|
||||||
|
printf 'post-merge-transition-pr-%s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
|
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
|
||||||
# A qualified reference belongs to another repository. Classify the whole
|
# A qualified reference belongs to another repository. Classify the whole
|
||||||
# token before extracting numbers so rig#112 can never become local #112.
|
# token before extracting numbers so rig#112 can never become local #112.
|
||||||
|
|
@ -216,12 +260,16 @@ offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET
|
||||||
# API edge. Marker comments make warnings and nudges idempotent across sweeps.
|
# API edge. Marker comments make warnings and nudges idempotent across sweeps.
|
||||||
ensure_comment() { # $1 issue, $2 marker, $3 message
|
ensure_comment() { # $1 issue, $2 marker, $3 message
|
||||||
local n="$1" marker="$2" message="$3"
|
local n="$1" marker="$2" message="$3"
|
||||||
if gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].body' \
|
if issue_comment_has_marker "$n" "$marker"; then return; fi
|
||||||
| grep -qF "<!-- issueflow:$marker -->"; then return; fi
|
|
||||||
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$marker -->
|
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$marker -->
|
||||||
$message" >/dev/null
|
$message" >/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issue_comment_has_marker() { # $1 issue, $2 marker
|
||||||
|
gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
||||||
|
| grep -qF "<!-- issueflow:$2 -->"
|
||||||
|
}
|
||||||
|
|
||||||
reference_states() {
|
reference_states() {
|
||||||
local ref state
|
local ref state
|
||||||
while IFS= read -r ref; do
|
while IFS= read -r ref; do
|
||||||
|
|
@ -262,6 +310,8 @@ last_issue_activity() {
|
||||||
|
|
||||||
reconcile_issue() {
|
reconcile_issue() {
|
||||||
local n="$1" decision refs cross_refs states age assignees open_pr=false label owners
|
local n="$1" decision refs cross_refs states age assignees open_pr=false label owners
|
||||||
|
local merged_ref_pr="" transition_marker="" transition_handled=false
|
||||||
|
local unchecked="" remove_claimed=claimed
|
||||||
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
||||||
case "$decision" in
|
case "$decision" in
|
||||||
ADD_NEEDS_TRIAGE)
|
ADD_NEEDS_TRIAGE)
|
||||||
|
|
@ -269,7 +319,7 @@ reconcile_issue() {
|
||||||
log "#$n: needs-triage (no queue state)" ;;
|
log "#$n: needs-triage (no queue state)" ;;
|
||||||
FLAG_CONFLICT)
|
FLAG_CONFLICT)
|
||||||
ensure_comment "$n" queue-conflict \
|
ensure_comment "$n" queue-conflict \
|
||||||
'The issue-flow sweep found conflicting queue labels. It cannot infer intent safely; triage must leave exactly one of `needs-triage`, `epic`, `ready`, `claimed`, or `blocked`.'
|
'The issue-flow sweep found conflicting queue labels. It cannot infer intent safely; triage must leave exactly one of `needs-triage`, `epic`, `ready`, `claimed`, `blocked`, or `post-merge`.'
|
||||||
log "#$n: conflicting queue labels; flagged"
|
log "#$n: conflicting queue labels; flagged"
|
||||||
return ;;
|
return ;;
|
||||||
esac
|
esac
|
||||||
|
|
@ -277,46 +327,83 @@ reconcile_issue() {
|
||||||
if has_issue_label claimed; then
|
if has_issue_label claimed; then
|
||||||
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
||||||
grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true
|
grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true
|
||||||
age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
|
merged_ref_pr="$(post_merge_pr_for_issue "$n")"
|
||||||
if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then
|
if [ -n "$merged_ref_pr" ]; then
|
||||||
# Legitimately quiet work does not run the reclaim clock. Only the
|
transition_marker="$(post_merge_transition_marker "$merged_ref_pr")"
|
||||||
# clock stops: an unassigned claim is still a repair the decision must
|
issue_comment_has_marker "$n" "$transition_marker" \
|
||||||
# see, so it runs on a zero age rather than being skipped.
|
&& transition_handled=true
|
||||||
decision="$(claim_decision "$assignees" "$open_pr" 0)"
|
|
||||||
else
|
|
||||||
decision="$(claim_decision_at "$assignees" "$open_pr" "$age")"
|
|
||||||
fi
|
fi
|
||||||
case "$decision" in
|
unchecked="$(unchecked_criteria <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||||
FLAG_UNASSIGNED)
|
if [ "$(post_merge_decision "$merged_ref_pr" "$open_pr" "$transition_handled" \
|
||||||
ensure_comment "$n" claimed-unassigned \
|
<<<"$unchecked")" = TRANSITION ]; then
|
||||||
'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;;
|
ensure_comment "$n" "$transition_marker" \
|
||||||
RECLAIM)
|
"The Refs-linked PR merged with these acceptance criteria still unchecked:
|
||||||
# The last-activity epoch identifies a claim episode. A fixed marker
|
|
||||||
# hid the required comment when the same issue was later claimed and
|
$unchecked
|
||||||
# reclaimed again.
|
|
||||||
ensure_comment "$n" "$(claim_reclaim_marker "$age")" \
|
The merge releases the claim; no builder owes a draft. Triage owes completion in a follow-up comment that names the owner and wake condition."
|
||||||
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
|
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
||||||
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
# `attention` is a demand for the assigned builder. The derived
|
||||||
if [ -n "$owners" ]; then
|
# transition releases that builder, so carrying the demand forward
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
|
# would create an impossible parked-for state (#175 D4).
|
||||||
--remove-label claimed --add-label ready >/dev/null
|
has_issue_label attention && remove_claimed=claimed,attention
|
||||||
else
|
if [ -n "$owners" ]; then
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null
|
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
|
||||||
fi
|
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
||||||
log "#$n: stale claim reclaimed -> ready" ;;
|
else
|
||||||
esac
|
run gh issue edit "$n" -R "$REPO" \
|
||||||
if has_issue_label offsite; then
|
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
||||||
local timeline
|
fi
|
||||||
if timeline="$(offsite_timeline "$n")"; then
|
log "#$n: merged Refs PR -> post-merge; claim released"
|
||||||
refs="$(offsite_cross_referenced_prs <<<"$timeline")"
|
else
|
||||||
states="$(offsite_pr_states <<<"$refs")"
|
age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
|
||||||
if [ "$(offsite_resolved_decision <<<"$states")" = NUDGE ]; then
|
if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then
|
||||||
ensure_comment "$n" offsite-resolved \
|
# Legitimately quiet work does not run the reclaim clock. Only the
|
||||||
"$(tr '\n' ' ' <<<"$refs" | sed 's/[[:space:]]*$//') is closed; this issue's \`offsite\` flag is still up. Clear it and close the issue, or say what is still outstanding. @$(jq -r '.assignees[0].login' <<<"$ISSUE_JSON")"
|
# clock stops: an unassigned claim is still a repair the decision must
|
||||||
log "#$n: resolved offsite PRs nudged"
|
# see, so it runs on a zero age rather than being skipped.
|
||||||
|
decision="$(claim_decision "$assignees" "$open_pr" 0)"
|
||||||
|
else
|
||||||
|
decision="$(claim_decision_at "$assignees" "$open_pr" "$age")"
|
||||||
|
fi
|
||||||
|
case "$decision" in
|
||||||
|
FLAG_UNASSIGNED)
|
||||||
|
ensure_comment "$n" claimed-unassigned \
|
||||||
|
'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;;
|
||||||
|
RECLAIM)
|
||||||
|
# The last-activity epoch identifies a claim episode. A fixed marker
|
||||||
|
# hid the required comment when the same issue was later claimed and
|
||||||
|
# reclaimed again.
|
||||||
|
ensure_comment "$n" "$(claim_reclaim_marker "$age")" \
|
||||||
|
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
|
||||||
|
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
||||||
|
if [ -n "$owners" ]; then
|
||||||
|
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
|
||||||
|
--remove-label claimed --add-label ready >/dev/null
|
||||||
|
else
|
||||||
|
run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null
|
||||||
|
fi
|
||||||
|
log "#$n: stale claim reclaimed -> ready" ;;
|
||||||
|
esac
|
||||||
|
if has_issue_label offsite; then
|
||||||
|
local timeline
|
||||||
|
if timeline="$(offsite_timeline "$n")"; then
|
||||||
|
refs="$(offsite_cross_referenced_prs <<<"$timeline")"
|
||||||
|
states="$(offsite_pr_states <<<"$refs")"
|
||||||
|
if [ "$(offsite_resolved_decision <<<"$states")" = NUDGE ]; then
|
||||||
|
ensure_comment "$n" offsite-resolved \
|
||||||
|
"$(tr '\n' ' ' <<<"$refs" | sed 's/[[:space:]]*$//') is closed; this issue's \`offsite\` flag is still up. Clear it and close the issue, or say what is still outstanding. @$(jq -r '.assignees[0].login' <<<"$ISSUE_JSON")"
|
||||||
|
log "#$n: resolved offsite PRs nudged"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
elif has_issue_label post-merge; then
|
||||||
|
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
|
||||||
|
if [ "$assignees" -gt 0 ] || has_issue_label attention; then
|
||||||
|
ensure_comment "$n" post-merge-assigned \
|
||||||
|
'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.'
|
||||||
|
log "#$n: assigned or attention-bearing post-merge issue flagged"
|
||||||
|
fi
|
||||||
elif has_issue_label blocked; then
|
elif has_issue_label blocked; then
|
||||||
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||||
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
|
||||||
|
|
@ -406,6 +493,22 @@ main() {
|
||||||
}
|
}
|
||||||
}' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \
|
}' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \
|
||||||
| sort -nu)"
|
| sort -nu)"
|
||||||
|
MERGED_REF_PR_RECORDS="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query='
|
||||||
|
query($owner: String!, $name: String!, $endCursor: String) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
pullRequests(first: 100, states: MERGED, after: $endCursor) {
|
||||||
|
nodes { number body }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}' --jq '.data.repository.pullRequests.nodes[]
|
||||||
|
| .number as $pr | .body | split("\n")[]
|
||||||
|
| [$pr, .] | @tsv' \
|
||||||
|
| while IFS=$'\t' read -r pr body; do
|
||||||
|
while IFS= read -r issue; do
|
||||||
|
[ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr"
|
||||||
|
done < <(refs_references <<<"$body")
|
||||||
|
done)"
|
||||||
|
|
||||||
local n
|
local n
|
||||||
for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \
|
for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,7 @@ release|0E8A16|Release flow and version/packaging work
|
||||||
needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion
|
needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion
|
||||||
ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed
|
ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed
|
||||||
claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly
|
claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly
|
||||||
|
post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion
|
||||||
epic|5319E7|Organizes other issues via a dependency-ordered task list — builders never pick it
|
epic|5319E7|Organizes other issues via a dependency-ordered task list — builders never pick it
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
changelog.d/175.md
Normal file
1
changelog.d/175.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
- Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification.
|
||||||
|
|
@ -43,6 +43,10 @@ check "one ready queue label is valid" 0 "KEEP" queue_decision <<<"ready"
|
||||||
check "zero queue labels is derivably needs-triage" 0 "ADD_NEEDS_TRIAGE" queue_decision <<<"enhancement"
|
check "zero queue labels is derivably needs-triage" 0 "ADD_NEEDS_TRIAGE" queue_decision <<<"enhancement"
|
||||||
check "multiple queue labels are ambiguous" 0 "FLAG_CONFLICT" queue_decision <<< $'ready\nblocked'
|
check "multiple queue labels are ambiguous" 0 "FLAG_CONFLICT" queue_decision <<< $'ready\nblocked'
|
||||||
check "needs-triage plus queue is a conflict" 0 "FLAG_CONFLICT" queue_decision <<< $'needs-triage\nready'
|
check "needs-triage plus queue is a conflict" 0 "FLAG_CONFLICT" queue_decision <<< $'needs-triage\nready'
|
||||||
|
check "claimed plus post-merge is a conflict" 0 "FLAG_CONFLICT" \
|
||||||
|
queue_decision <<< $'claimed\npost-merge'
|
||||||
|
check "post-merge plus needs-ruling is healthy" 0 "KEEP" \
|
||||||
|
queue_decision <<< $'post-merge\nneeds-ruling'
|
||||||
|
|
||||||
# Invariant 2: claims have an owner and either a PR or recent activity.
|
# Invariant 2: claims have an owner and either a PR or recent activity.
|
||||||
check "claim with open PR stays claimed" 0 "KEEP" claim_decision 1 true 999999
|
check "claim with open PR stays claimed" 0 "KEEP" claim_decision 1 true 999999
|
||||||
|
|
@ -78,6 +82,20 @@ check "attention does not exempt a claimed issue" 0 "SWEEP" \
|
||||||
claim_clock_exempt <<<"attention"
|
claim_clock_exempt <<<"attention"
|
||||||
check "ready does not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt <<<"ready"
|
check "ready does not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt <<<"ready"
|
||||||
check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt </dev/null
|
check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt </dev/null
|
||||||
|
refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)'
|
||||||
|
check "Refs parser returns only references owned by a valid Refs marker" 0 "" \
|
||||||
|
test "$(refs_references <<<"$refs_body")" = $'8\n12\n175'
|
||||||
|
check "unchecked criteria preserve their source lines verbatim" 0 \
|
||||||
|
$'- [ ] first criterion\n * [ ] indented criterion\n1. [ ] numbered criterion' \
|
||||||
|
unchecked_criteria <<< $'- [x] done\n- [ ] first criterion\r\n * [ ] indented criterion\n1. [ ] numbered criterion'
|
||||||
|
check "merged Refs with unchecked criteria transitions" 0 "TRANSITION" \
|
||||||
|
post_merge_decision 12 false false <<<"- [ ] verify after merge"
|
||||||
|
check "open Refs does not transition" 0 "KEEP" \
|
||||||
|
post_merge_decision 12 true false <<<"- [ ] verify after merge"
|
||||||
|
check "a handled merged Refs episode does not transition again" 0 "KEEP" \
|
||||||
|
post_merge_decision 12 false true <<<"- [ ] verify after merge"
|
||||||
|
check "merged Refs with all criteria checked does not transition" 0 "KEEP" \
|
||||||
|
post_merge_decision 12 false false </dev/null
|
||||||
check "one closed offsite PR nudges" 0 "NUDGE" offsite_resolved_decision <<<"CLOSED"
|
check "one closed offsite PR nudges" 0 "NUDGE" offsite_resolved_decision <<<"CLOSED"
|
||||||
check "two closed offsite PRs nudge" 0 "NUDGE" offsite_resolved_decision <<< $'CLOSED\nCLOSED'
|
check "two closed offsite PRs nudge" 0 "NUDGE" offsite_resolved_decision <<< $'CLOSED\nCLOSED'
|
||||||
check "one open offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision <<< $'CLOSED\nOPEN'
|
check "one open offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision <<< $'CLOSED\nOPEN'
|
||||||
|
|
@ -234,16 +252,22 @@ issue_stub_gh() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
issue_probe() { # $1 issue, $2 labels, $3 assignees (default 1), $4 open PR
|
issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 open PR, $5 merged PR, $6 body
|
||||||
(
|
(
|
||||||
local assignees="${3:-1}" open_pr="${4:-false}" assignee_json='[]'
|
local assignees="${3:-1}" open_pr="${4:-false}" merged_ref_pr="${5:-}"
|
||||||
|
local body="${6:-}" assignee_json='[]'
|
||||||
[ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]'
|
[ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]'
|
||||||
REPO=owner/repo NOW="$INOW"
|
REPO=owner/repo NOW="$INOW"
|
||||||
ISSUE_LABELS="$2"
|
ISSUE_LABELS="$2"
|
||||||
ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \
|
ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \
|
||||||
--argjson assignees "$assignee_json" \
|
--argjson assignees "$assignee_json" --arg body "$body" \
|
||||||
'{created_at: $at, assignees: $assignees, body: ""}')"
|
'{created_at: $at, assignees: $assignees, body: $body}')"
|
||||||
if [ "$open_pr" = true ]; then OPEN_PR_ISSUES="$1"; else OPEN_PR_ISSUES=""; fi
|
if [ "$open_pr" = true ]; then OPEN_PR_ISSUES="$1"; else OPEN_PR_ISSUES=""; fi
|
||||||
|
if [ -n "$merged_ref_pr" ]; then
|
||||||
|
MERGED_REF_PR_RECORDS="$(printf '%s\t%s\n' "$1" "$merged_ref_pr")"
|
||||||
|
else
|
||||||
|
MERGED_REF_PR_RECORDS=""
|
||||||
|
fi
|
||||||
run() { "$@"; }
|
run() { "$@"; }
|
||||||
gh() { issue_stub_gh "$@"; }
|
gh() { issue_stub_gh "$@"; }
|
||||||
reconcile_issue "$1" 2>&1
|
reconcile_issue "$1" 2>&1
|
||||||
|
|
@ -291,6 +315,108 @@ control="$(issue_probe 22 claimed)"
|
||||||
check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" \
|
check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" \
|
||||||
grep -q 'stale claim reclaimed -> ready' <<<"$control"
|
grep -q 'stale claim reclaimed -> ready' <<<"$control"
|
||||||
|
|
||||||
|
# -- merged Refs work releases the claim before the reclaim clock ------------
|
||||||
|
printf '[]\n' >"$(cfix 35)"
|
||||||
|
transition="$(issue_probe 35 claimed 1 false 350 $'- [x] built\n- [ ] verify dispatch\n * [ ] confirm warning clears')"
|
||||||
|
check "merged Refs + unchecked criteria transitions in the sweep body" 0 "" \
|
||||||
|
grep -q 'merged Refs PR -> post-merge; claim released' <<<"$transition"
|
||||||
|
# shellcheck disable=SC2016 # positional parameters belong to bash -c
|
||||||
|
check "...names every remaining criterion verbatim in the comment" 0 "" \
|
||||||
|
bash -c 'grep -qF -- "- [ ] verify dispatch" "$1" &&
|
||||||
|
grep -qF -- " * [ ] confirm warning clears" "$1"' _ "$TMP/posted-35"
|
||||||
|
check "...states triage owes completion with owner and wake condition" 0 "" \
|
||||||
|
grep -qF 'Triage owes completion in a follow-up comment that names the owner and wake condition.' \
|
||||||
|
"$TMP/posted-35"
|
||||||
|
check "...unassigns and swaps claimed to post-merge" 0 "" \
|
||||||
|
grep -qF -- '--remove-assignee owner-bot --remove-label claimed --add-label post-merge' \
|
||||||
|
"$TMP/issue-edits"
|
||||||
|
|
||||||
|
printf '[]\n' >"$(cfix 36)"
|
||||||
|
post_merge_quiet="$(issue_probe 36 post-merge 0)"
|
||||||
|
check "quiet unassigned post-merge work is not reclaimed" 1 "" \
|
||||||
|
grep -q 'reclaimed' <<<"$post_merge_quiet"
|
||||||
|
check "...and causes no comment or edit" 1 "" test -f "$TMP/posted-36"
|
||||||
|
|
||||||
|
printf '[]\n' >"$(cfix 37)"
|
||||||
|
issue_probe 37 post-merge 1 >/dev/null
|
||||||
|
check "assigned post-merge is flagged" 0 "" \
|
||||||
|
grep -qF '<!-- issueflow:post-merge-assigned -->' "$TMP/posted-37"
|
||||||
|
check "...and the hand-assignment is not repaired" 1 "" \
|
||||||
|
grep -qF -- 'issue edit 37' "$TMP/issue-edits"
|
||||||
|
|
||||||
|
# -- non-triggers stay byte-for-byte outside the transition ------------------
|
||||||
|
recent_timeline() {
|
||||||
|
jq -n --arg at "$(iso_at $((INOW - 60)))" \
|
||||||
|
'[{"event":"assigned","created_at":$at}]' >"$(tfix "$1")"
|
||||||
|
printf '[]\n' >"$(cfix "$1")"
|
||||||
|
}
|
||||||
|
edit_count_before="$(wc -l <"$TMP/issue-edits")"
|
||||||
|
recent_timeline 38
|
||||||
|
open_refs="$(issue_probe 38 claimed 1 true 380 '- [ ] verify after merge')"
|
||||||
|
check "open Refs PR leaves the issue exactly as found" 0 "" \
|
||||||
|
test -z "$open_refs"
|
||||||
|
# shellcheck disable=SC2016 # positional parameters belong to bash -c
|
||||||
|
check "...with no edit or comment" 0 "" \
|
||||||
|
bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \
|
||||||
|
"$edit_count_before" "$TMP/issue-edits" "$TMP/posted-38"
|
||||||
|
|
||||||
|
recent_timeline 39
|
||||||
|
merged_closes="$(issue_probe 39 claimed 1 false "" '- [ ] verify after merge')"
|
||||||
|
check "merged Closes PR leaves a recent claim exactly as found" 0 "" \
|
||||||
|
test -z "$merged_closes"
|
||||||
|
# shellcheck disable=SC2016 # positional parameters belong to bash -c
|
||||||
|
check "...with no edit or comment" 0 "" \
|
||||||
|
bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \
|
||||||
|
"$edit_count_before" "$TMP/issue-edits" "$TMP/posted-39"
|
||||||
|
|
||||||
|
recent_timeline 40
|
||||||
|
all_checked="$(issue_probe 40 claimed 1 false 400 '- [x] verified after merge')"
|
||||||
|
check "merged Refs with zero unchecked boxes leaves the issue exactly as found" 0 "" \
|
||||||
|
test -z "$all_checked"
|
||||||
|
# shellcheck disable=SC2016 # positional parameters belong to bash -c
|
||||||
|
check "...with no edit or comment" 0 "" \
|
||||||
|
bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \
|
||||||
|
"$edit_count_before" "$TMP/issue-edits" "$TMP/posted-40"
|
||||||
|
|
||||||
|
printf '[]\n' >"$(cfix 41)"
|
||||||
|
attention_transition="$(issue_probe 41 $'claimed\nattention' 1 false 410 '- [ ] verify')"
|
||||||
|
check "derived post-merge transition clears attention with the released claim" 0 "" \
|
||||||
|
grep -qF -- '--remove-label claimed,attention --add-label post-merge' "$TMP/issue-edits"
|
||||||
|
check "...still completes the transition" 0 "" \
|
||||||
|
grep -qF 'merged Refs PR -> post-merge; claim released' <<<"$attention_transition"
|
||||||
|
|
||||||
|
recent_timeline 43
|
||||||
|
jq -n --arg b '<!-- issueflow:post-merge-transition-pr-430 -->' \
|
||||||
|
--arg at "$(iso_at $((INOW - 60)))" \
|
||||||
|
'[{"body":$b,"created_at":$at}]' >"$(cfix 43)"
|
||||||
|
reentry_edit_count="$(wc -l <"$TMP/issue-edits")"
|
||||||
|
historical="$(issue_probe 43 claimed 1 false 430 '- [ ] corrective verification')"
|
||||||
|
check "a handled historical Refs merge cannot steal a re-entered claim" 0 "" \
|
||||||
|
test -z "$historical"
|
||||||
|
# shellcheck disable=SC2016 # positional parameters belong to bash -c
|
||||||
|
check "...and re-entry produces no edit or duplicate transition comment" 0 "" \
|
||||||
|
bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \
|
||||||
|
"$reentry_edit_count" "$TMP/issue-edits" "$TMP/posted-43"
|
||||||
|
|
||||||
|
printf '[]\n' >"$(cfix 44)"
|
||||||
|
second_transition="$(issue_probe 44 claimed 1 false 441 '- [ ] second verification')"
|
||||||
|
check "a later merged Refs PR gets an episode-specific transition comment" 0 "" \
|
||||||
|
grep -qF '<!-- issueflow:post-merge-transition-pr-441 -->' "$TMP/posted-44"
|
||||||
|
check "...and the later episode still transitions" 0 "" \
|
||||||
|
grep -qF 'merged Refs PR -> post-merge; claim released' <<<"$second_transition"
|
||||||
|
|
||||||
|
printf '[]\n' >"$(cfix 45)"
|
||||||
|
issue_probe 45 $'claimed\npost-merge' >/dev/null
|
||||||
|
# shellcheck disable=SC2016 # Markdown backticks are literal evidence
|
||||||
|
check "queue-conflict evidence lists every category including post-merge" 0 "" \
|
||||||
|
grep -qF 'needs-triage`, `epic`, `ready`, `claimed`, `blocked`, or `post-merge`' \
|
||||||
|
"$TMP/posted-45"
|
||||||
|
|
||||||
|
printf '[]\n' >"$(cfix 42)"
|
||||||
|
issue_probe 42 $'post-merge\nattention' 0 >/dev/null
|
||||||
|
check "hand-created post-merge plus attention is flagged, not rewritten" 0 "" \
|
||||||
|
grep -qF '<!-- issueflow:post-merge-assigned -->' "$TMP/posted-42"
|
||||||
|
|
||||||
# -- offsite stops only the reclaim clock ------------------------------------
|
# -- offsite stops only the reclaim clock ------------------------------------
|
||||||
offsite="$(issue_probe 25 $'claimed\noffsite')"
|
offsite="$(issue_probe 25 $'claimed\noffsite')"
|
||||||
check "a 10-day-quiet offsite claim is not reclaimed" 1 "" \
|
check "a 10-day-quiet offsite claim is not reclaimed" 1 "" \
|
||||||
|
|
@ -475,6 +601,33 @@ check "...stands down without minting" 1 "" test -s "$ARRIVAL/fixtures/edits"
|
||||||
check "...and the sweep still runs" 0 "" \
|
check "...and the sweep still runs" 0 "" \
|
||||||
grep -qF 'issueflow: reconciled.' <<<"$pr_out"
|
grep -qF 'issueflow: reconciled.' <<<"$pr_out"
|
||||||
|
|
||||||
|
# The merged-Refs transition must survive the executable's set -e path too.
|
||||||
|
# Keep this at main() granularity: the GraphQL gather and loop are the code
|
||||||
|
# a sourced decision probe cannot exercise (#91's lesson).
|
||||||
|
printf '%s\n' \
|
||||||
|
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
|
||||||
|
>"$ARRIVAL/fixtures/graphql.json"
|
||||||
|
printf '[{"number":40}]\n' \
|
||||||
|
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json"
|
||||||
|
jq -n --arg at "$(iso_at "$INOW")" \
|
||||||
|
'{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
|
||||||
|
>"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json"
|
||||||
|
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json"
|
||||||
|
: >"$ARRIVAL/fixtures/edits"
|
||||||
|
subprocess_out="$(
|
||||||
|
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
||||||
|
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
||||||
|
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
|
||||||
|
)"
|
||||||
|
subprocess_rc=$?
|
||||||
|
check "executable sweep transitions merged Refs work" 0 "" \
|
||||||
|
test "$subprocess_rc" -eq 0
|
||||||
|
check "...reaches the transition through GraphQL and the issue loop" 0 "" \
|
||||||
|
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out"
|
||||||
|
check "...and performs the release edit from the executable path" 0 "" \
|
||||||
|
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
|
||||||
|
"$ARRIVAL/fixtures/edits"
|
||||||
|
|
||||||
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on
|
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on
|
||||||
# the arrival path still kills the run loudly.
|
# the arrival path still kills the run loudly.
|
||||||
: >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"
|
: >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,9 @@ expect "a 300-char reason passes through whole" 300 "${#exact_reason}"
|
||||||
# -- a missing core taxonomy row is visible without mutating labels ----------
|
# -- a missing core taxonomy row is visible without mutating labels ----------
|
||||||
core_rows="$(core_label_rows)"
|
core_rows="$(core_label_rows)"
|
||||||
core_names="$(cut -d'|' -f1 <<<"$core_rows")"
|
core_names="$(cut -d'|' -f1 <<<"$core_rows")"
|
||||||
|
expect "post-merge core row is byte-exact" \
|
||||||
|
"post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion" \
|
||||||
|
"$(grep '^post-merge|' <<<"$core_rows")"
|
||||||
expect "a complete core taxonomy does not warn" "" \
|
expect "a complete core taxonomy does not warn" "" \
|
||||||
"$(missing_core_labels_warning "$core_rows" "$core_names")"
|
"$(missing_core_labels_warning "$core_rows" "$core_names")"
|
||||||
expect "one missing core label is named exactly" \
|
expect "one missing core label is named exactly" \
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue