Skip to content

Commit 904c880

Browse files
committed
Merge branch 'claude/run-try-die-wrappers' after its restack on main
Trunk meanwhile absorbed most of this PR (#42 reordered main(), #50 hardened the label and comment reads), so what remains is the has_sibling_conflicts fix plus a new one of the same kind: list_child_prs failures were swallowed by the process substitutions consuming it, so a failed listing read as 'no children' and let main() delete the merged branch under the children it never saw. Callers now capture the output and die on failure. https://claude.ai/code/session_01STkeSJ7cLrmrNn4aTDYkwH
2 parents 2384bbb + 3f6765b commit 904c880

13 files changed

Lines changed: 501 additions & 111 deletions

.github/workflows/tests.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ name: Tests
22

33
on:
44
pull_request:
5-
branches: ['*']
65

76
jobs:
87
unit-tests:
@@ -18,6 +17,8 @@ jobs:
1817
bash tests/test_rebase_workflow.sh
1918
bash tests/test_mixed_workflows.sh
2019
bash tests/test_conflict_resolution_resume.sh
20+
bash tests/test_merge_commit_merge.sh
21+
bash tests/test_rebase_merge_skip.sh
2122
2223
e2e-tests:
2324
name: E2E Tests

.github/workflows/update-pr-stack.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ permissions:
99
contents: write
1010
pull-requests: write
1111

12+
# Serialize runs per PR: the push that finishes a conflict resolution fires a
13+
# synchronize event while the conflict label is still attached, so without a
14+
# group the follow-up run races the one that triggered it.
15+
concurrency:
16+
group: update-pr-stack-${{ github.event.pull_request.number }}
17+
cancel-in-progress: false
18+
1219
jobs:
1320
update-pr-stack:
1421
runs-on: ubuntu-latest

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ This action tries to fix that in a transparent way. Install it, and hopefully th
1919
1. Triggers when a PR is squash merged
2020
2. Finds PRs that were based on the merged branch (direct children only)
2121
3. Creates a synthetic merge commit with three parents (child tip, deleted branch tip, squash commit) to preserve history without re-introducing code
22-
4. Updates the direct child PRs to base on trunk now that the bottom change has landed
23-
5. Pushes updated branches and deletes the merged branch
22+
4. Pushes the updated branches
23+
5. Updates the direct child PRs to base on trunk now that the bottom change has landed
24+
6. Deletes the merged branch
2425

2526
**Note:** Indirect descendants (grandchildren, etc.) are intentionally not modified. Their PR diffs remain correct because the merge-base calculation still works—the synthetic merge commit includes the original parent commit as an ancestor. When their direct parent is eventually merged, they become direct children and get updated at that point.
2627

@@ -79,6 +80,10 @@ on:
7980
pull_request:
8081
types: [closed, synchronize]
8182

83+
concurrency:
84+
group: update-pr-stack-${{ github.event.pull_request.number }}
85+
cancel-in-progress: false
86+
8287
jobs:
8388
update-pr-stack:
8489
runs-on: ubuntu-latest
@@ -110,6 +115,10 @@ permissions:
110115
contents: write
111116
pull-requests: write
112117

118+
concurrency:
119+
group: update-pr-stack-${{ github.event.pull_request.number }}
120+
cancel-in-progress: false
121+
113122
jobs:
114123
update-pr-stack:
115124
runs-on: ubuntu-latest
@@ -123,9 +132,10 @@ jobs:
123132
124133
### Notes
125134
126-
* Currently only supports squash merges
135+
* Built for squash merges. A PR merged with a merge commit keeps its history, so the action only retargets its children and deletes the branch. Rebase merges are not supported: the action detects them through GitHub's commit-PR association (the merge method itself is recorded nowhere) and comments on each child PR instead of acting.
127136
* If a merge hits a conflict, you'll need to resolve it manually; pushing the resolution automatically continues the stack update
128137
* Very large stacks might hit GitHub rate limits
138+
* After retargeting, GitHub sometimes keeps rendering a PR's diff against its old, deleted base, so the PR appears to contain already-merged changes. The branch itself is correct (`git diff <base>...HEAD` shows the real diff). Pushing any commit to the PR usually makes GitHub recompute. Sometimes it doesn't. Tough luck.
129139

130140
---
131141

action.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ runs:
5454
MERGED_BRANCH: ${{ github.event.pull_request.head.ref }}
5555
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
5656
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
57+
PR_NUMBER: ${{ github.event.pull_request.number }}
58+
PR_BASE: ${{ github.event.pull_request.base.ref }}
5759
run: |
5860
echo "Running in $ACTION_MODE mode"
5961
${{ github.action_path }}/update-pr-stack.sh

tests/mock_gh.sh

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
# Only direct children are queried now (no recursive updates of indirect children).
55

66
if [[ "$1" == "pr" && "$2" == "list" ]]; then
7+
if [[ "${MOCK_PR_LIST_FAIL:-}" == 1 ]]; then
8+
echo "mock gh: pr list API down" >&2
9+
exit 1
10+
fi
711
# Parse the --base argument to determine which PRs to return
812
base=""
913
for ((i=1; i<=$#; i++)); do
@@ -14,15 +18,28 @@ if [[ "$1" == "pr" && "$2" == "list" ]]; then
1418
done
1519

1620
if [[ "$base" == "feature1" ]]; then
17-
# feature2 is a direct child of feature1
18-
echo 'feature2'
21+
# feature2 is a direct child of feature1 (PR #2)
22+
echo '2 feature2'
1923
else
2024
# No other bases have direct children in our test scenario
2125
:
2226
fi
2327
elif [[ "$1" == "pr" && "$2" == "edit" ]]; then
2428
# Just log the edit command
2529
echo "Mock: gh pr edit $3 --base $5"
30+
elif [[ "$1" == "pr" && "$2" == "comment" ]]; then
31+
# Just log the comment command
32+
echo "Mock: gh pr comment $3"
33+
elif [[ "$1" == "api" && "$2" == repos/*/commits/*/pulls ]]; then
34+
# Which PRs introduced this trunk commit (already --jq filtered to bare
35+
# numbers). The merge commit belongs to the merged PR, and so does any sha
36+
# listed in MOCK_REBASE_COPIES (space-separated); anything else was not
37+
# introduced by a PR. SQUASH_COMMIT and PR_NUMBER come from the test's env.
38+
sha="${2#*/commits/}"
39+
sha="${sha%/pulls}"
40+
if [[ "$sha" == "$SQUASH_COMMIT" || " ${MOCK_REBASE_COPIES:-} " == *" $sha "* ]]; then
41+
echo "$PR_NUMBER"
42+
fi
2643
else
2744
echo "Unknown gh command: $@" >&2
2845
exit 1

tests/test_conflict_resolution_resume.sh

Lines changed: 62 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ ok() { echo "✅ $1"; PASS=$((PASS+1)); }
2121
# Build a configurable gh mock in a temp dir. It records every invocation to
2222
# $CALLS and is driven by env vars set per scenario:
2323
# MOCK_LABELS newline-separated labels returned by `pr view --json labels`
24-
# MOCK_BASE base branch returned by `pr view --json baseRefName`
2524
# MOCK_COMMENTS_FILE file whose contents are returned by `pr view --json comments`
2625
# MOCK_LABELS_FAIL set to 1 to make `pr view --json labels` fail
2726
# MOCK_PR_LIST_FAIL set to 1 to make `pr list` fail
27+
# The PR's base branch is not mocked: the script must take it from PR_BASE
28+
# (event payload), so a baseRefName query is an unhandled call and fails.
2829
make_mock_gh() {
2930
local dir="$1"
3031
cat > "$dir/mock_gh.sh" <<'EOF'
@@ -36,8 +37,11 @@ if [[ "$1 $2" == "pr view" ]]; then
3637
*--json\ labels*)
3738
[[ "${MOCK_LABELS_FAIL:-}" == 1 ]] && { echo "mock gh: labels API down" >&2; exit 1; }
3839
printf '%s\n' "${MOCK_LABELS:-}";;
39-
*--json\ baseRefName*) printf '%s\n' "${MOCK_BASE:-}";;
40-
*--json\ comments*) cat "${MOCK_COMMENTS_FILE:-/dev/null}";;
40+
*--json\ comments*)
41+
# The comments file stands for our own comments only, so the query
42+
# must restrict itself to those.
43+
[[ "$*" == *viewerDidAuthor* ]] || { echo "comments query must filter by viewerDidAuthor" >&2; exit 1; }
44+
cat "${MOCK_COMMENTS_FILE:-/dev/null}";;
4145
*) echo "unhandled pr view: $*" >&2; exit 1;;
4246
esac
4347
elif [[ "$1 $2" == "pr comment" ]]; then
@@ -93,11 +97,11 @@ setup_repo() {
9397
}
9498

9599
run_resume() {
96-
env ACTION_MODE=conflict-resolved PR_BRANCH=child \
100+
env ACTION_MODE=conflict-resolved PR_BRANCH=child PR_NUMBER=5 PR_BASE="$PR_BASE" \
97101
GH="$MOCK_DIR/mock_gh.sh" GIT="$MOCK_DIR/mock_git.sh" \
98-
MOCK_LABELS="$MOCK_LABELS" MOCK_BASE="$MOCK_BASE" \
102+
MOCK_LABELS="$MOCK_LABELS" MOCK_LABELS_FAIL="${MOCK_LABELS_FAIL:-}" \
99103
MOCK_COMMENTS_FILE="$MOCK_COMMENTS_FILE" CALLS="$CALLS" \
100-
MOCK_LABELS_FAIL="${MOCK_LABELS_FAIL:-}" MOCK_PR_LIST_FAIL="${MOCK_PR_LIST_FAIL:-}" \
104+
MOCK_PR_LIST_FAIL="${MOCK_PR_LIST_FAIL:-}" \
101105
bash "$ROOT_DIR/update-pr-stack.sh" >"$WORK/out.log" 2>&1 || echo "EXIT=$?" >>"$WORK/out.log"
102106
}
103107

@@ -109,7 +113,7 @@ marker() { # base target squash
109113
echo "### Scenario A: user manually retargeted the base -> no mutation"
110114
setup_repo
111115
MOCK_LABELS="autorestack-needs-conflict-resolution"
112-
MOCK_BASE="spark" # human changed it; marker says parent
116+
PR_BASE="spark" # human changed it; marker says parent
113117
MOCK_COMMENTS_FILE="$WORK/comments.txt"
114118
{ echo "### conflict"; echo; marker parent main "$SQUASH"; } > "$MOCK_COMMENTS_FILE"
115119
run_resume
@@ -125,7 +129,7 @@ ok "A: manual retarget detected, no branch mutation, label removed"
125129
echo "### Scenario B: no state marker -> no mutation"
126130
setup_repo
127131
MOCK_LABELS="autorestack-needs-conflict-resolution"
128-
MOCK_BASE="parent"
132+
PR_BASE="parent"
129133
MOCK_COMMENTS_FILE="$WORK/comments.txt"
130134
{ echo "### some old conflict comment with no marker"; } > "$MOCK_COMMENTS_FILE"
131135
run_resume
@@ -143,13 +147,13 @@ setup_repo
143147
git -C "$WORK" merge -q --no-edit main
144148
git -C "$WORK" push -q origin child
145149
MOCK_LABELS="autorestack-needs-conflict-resolution"
146-
MOCK_BASE="parent" # matches marker -> not a manual retarget
150+
PR_BASE="parent" # matches marker -> not a manual retarget
147151
MOCK_COMMENTS_FILE="$WORK/comments.txt"
148152
{ echo "### conflict"; echo; marker parent main "$SQUASH"; } > "$MOCK_COMMENTS_FILE"
149153
run_resume
150154

151155
grep -q -- "git push origin child" "$CALLS" || fail "C: child not pushed"
152-
grep -q -- "pr edit child --base main" "$CALLS" || fail "C: base not retargeted to main"
156+
grep -q -- "pr edit 5 --base main" "$CALLS" || fail "C: base not retargeted to main"
153157
grep -q "remove-label autorestack-needs-conflict-resolution" "$CALLS" || fail "C: label not removed"
154158
push_line=$(grep -n -- "git push origin child" "$CALLS" | head -1 | cut -d: -f1)
155159
base_line=$(grep -n -- "--base main" "$CALLS" | head -1 | cut -d: -f1)
@@ -162,7 +166,7 @@ ok "C: resume pushes, retargets base, then removes label"
162166
echo "### Scenario D: recorded target branch is gone -> give up cleanly"
163167
setup_repo
164168
MOCK_LABELS="autorestack-needs-conflict-resolution"
165-
MOCK_BASE="parent" # matches marker -> not a manual retarget
169+
PR_BASE="parent" # matches marker -> not a manual retarget
166170
MOCK_COMMENTS_FILE="$WORK/comments.txt"
167171
{ echo "### conflict"; echo; marker parent ghost-target "$SQUASH"; } > "$MOCK_COMMENTS_FILE"
168172
run_resume
@@ -174,7 +178,23 @@ grep -q -- "--base" "$CALLS" && fail "D: base must NOT be edited"
174178
ok "D: missing target detected, no branch mutation, label removed"
175179

176180
# ---------------------------------------------------------------------------
177-
echo "### Scenario E: recorded base branch is gone -> give up cleanly, no crash"
181+
echo "### Scenario E: malformed state marker -> internal error, die without touching the PR"
182+
setup_repo
183+
MOCK_LABELS="autorestack-needs-conflict-resolution"
184+
PR_BASE="parent"
185+
MOCK_COMMENTS_FILE="$WORK/comments.txt"
186+
{ echo "### conflict"; echo; echo '<!-- autorestack-state: base=parent target=main -->'; } > "$MOCK_COMMENTS_FILE"
187+
run_resume
188+
189+
grep -q "EXIT=1" "$WORK/out.log" || fail "E: run must die on a malformed marker"
190+
grep -q "remove-label" "$CALLS" && fail "E: label must stay on"
191+
grep -q "gh pr comment" "$CALLS" && fail "E: no PR comment for an internal error"
192+
grep -q -- "--base" "$CALLS" && fail "E: base must NOT be edited"
193+
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "E: child was pushed"
194+
ok "E: malformed marker dies, PR untouched, label kept"
195+
196+
# ---------------------------------------------------------------------------
197+
echo "### Scenario F: recorded base branch is gone -> give up cleanly, no crash"
178198
setup_repo
179199
# Advance main with the squash commit so the child is not already up to date and
180200
# the resume would actually reach the merge step.
@@ -190,55 +210,55 @@ git checkout -q child
190210
# repeating on every push.
191211
git push -q origin ":parent"
192212
MOCK_LABELS="autorestack-needs-conflict-resolution"
193-
MOCK_BASE="parent" # matches marker -> not a manual retarget
213+
PR_BASE="parent" # matches marker -> not a manual retarget
194214
MOCK_COMMENTS_FILE="$WORK/comments.txt"
195215
{ echo "### conflict"; echo; marker parent main "$SQUASH2"; } > "$MOCK_COMMENTS_FILE"
196216
run_resume
197217

198-
grep -q "EXIT=" "$WORK/out.log" && fail "E: script exited nonzero: $(cat "$WORK/out.log")"
199-
grep -q "remove-label autorestack-needs-conflict-resolution" "$CALLS" || fail "E: label not removed"
200-
grep -q -- "add-label" "$CALLS" && fail "E: conflict label must NOT be re-added"
201-
grep -q "gh pr comment" "$CALLS" || fail "E: no explanatory comment posted"
202-
grep -q -- "--base" "$CALLS" && fail "E: base must NOT be edited"
203-
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "E: child was pushed"
204-
ok "E: missing base branch detected, no crash, label removed"
218+
grep -q "EXIT=" "$WORK/out.log" && fail "F: script exited nonzero: $(cat "$WORK/out.log")"
219+
grep -q "remove-label autorestack-needs-conflict-resolution" "$CALLS" || fail "F: label not removed"
220+
grep -q -- "add-label" "$CALLS" && fail "F: conflict label must NOT be re-added"
221+
grep -q "gh pr comment" "$CALLS" || fail "F: no explanatory comment posted"
222+
grep -q -- "--base" "$CALLS" && fail "F: base must NOT be edited"
223+
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "F: child was pushed"
224+
ok "F: missing base branch detected, no crash, label removed"
205225

206226
# ---------------------------------------------------------------------------
207-
echo "### Scenario F: reading the PR comments fails -> fail the run, keep the label"
227+
echo "### Scenario G: reading the PR comments fails -> fail the run, keep the label"
208228
setup_repo
209-
# A transient API failure while reading the comments must not pass for "no
210-
# state marker": that path removes the conflict label and permanently cancels
211-
# the auto-resume. The run must fail loudly instead, so the next push retries.
229+
# An API failure must not pass for "no state marker": that path removes the
230+
# conflict label and permanently cancels the auto-resume. Fail loudly instead,
231+
# so the label stays on and the next push retries.
212232
MOCK_LABELS="autorestack-needs-conflict-resolution"
213-
MOCK_BASE="parent"
233+
PR_BASE="parent"
214234
MOCK_COMMENTS_FILE="$WORK/does-not-exist" # makes the mock gh fail on pr view --json comments
215235
run_resume
216236

217-
grep -q "EXIT=" "$WORK/out.log" || fail "F: run should have failed"
218-
grep -q "remove-label" "$CALLS" && fail "F: label must NOT be removed on an API failure"
219-
grep -q "gh pr comment" "$CALLS" && fail "F: no comment must be posted on an API failure"
220-
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "F: child was pushed"
221-
ok "F: API failure fails the run and keeps the resume armed"
237+
grep -q "EXIT=" "$WORK/out.log" || fail "G: run should have failed"
238+
grep -q "remove-label" "$CALLS" && fail "G: label must NOT be removed on an API failure"
239+
grep -q "gh pr comment" "$CALLS" && fail "G: no comment must be posted on an API failure"
240+
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "G: child was pushed"
241+
ok "G: comments API failure fails the run and keeps the resume armed"
222242

223243
# ---------------------------------------------------------------------------
224-
echo "### Scenario G: reading the labels fails -> fail the run, do nothing"
244+
echo "### Scenario H: reading the labels fails -> fail the run, do nothing"
225245
setup_repo
226246
# An API failure must not pass for "no conflict label": that ends the run
227247
# green without resuming anything.
228248
MOCK_LABELS=""
229249
MOCK_LABELS_FAIL=1
230-
MOCK_BASE="parent"
250+
PR_BASE="parent"
231251
MOCK_COMMENTS_FILE="$WORK/comments.txt"
232252
{ echo "### conflict"; echo; marker parent main "$SQUASH"; } > "$MOCK_COMMENTS_FILE"
233253
run_resume
234254

235-
grep -q "EXIT=" "$WORK/out.log" || fail "G: run should have failed, not ended green"
236-
grep -q "remove-label" "$CALLS" && fail "G: label must NOT be touched on an API failure"
237-
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "G: child was pushed"
238-
ok "G: labels API failure fails the run instead of skipping the resume"
255+
grep -q "EXIT=" "$WORK/out.log" || fail "H: run should have failed, not ended green"
256+
grep -q "remove-label" "$CALLS" && fail "H: label must NOT be touched on an API failure"
257+
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "H: child was pushed"
258+
ok "H: labels API failure fails the run instead of skipping the resume"
239259

240260
# ---------------------------------------------------------------------------
241-
echo "### Scenario H: listing sibling conflicts fails -> keep the old base branch"
261+
echo "### Scenario I: listing sibling conflicts fails -> keep the old base branch"
242262
setup_repo
243263
# Same successful-resume setup as scenario C, but the sibling listing that
244264
# decides whether the old base branch can be deleted fails. Answering "no
@@ -247,16 +267,16 @@ git -C "$WORK" merge -q --no-edit main
247267
git -C "$WORK" push -q origin child
248268
MOCK_LABELS="autorestack-needs-conflict-resolution"
249269
MOCK_LABELS_FAIL=""
250-
MOCK_BASE="parent"
270+
PR_BASE="parent"
251271
MOCK_PR_LIST_FAIL=1
252272
MOCK_COMMENTS_FILE="$WORK/comments.txt"
253273
{ echo "### conflict"; echo; marker parent main "$SQUASH"; } > "$MOCK_COMMENTS_FILE"
254274
run_resume
255275

256-
grep -q "EXIT=" "$WORK/out.log" || fail "H: run should have failed"
257-
git -C "$ORIGIN" rev-parse --verify -q parent >/dev/null || fail "H: old base branch was deleted on an API failure"
258-
grep -q -- "push origin :parent" "$CALLS" && fail "H: deletion must not be attempted"
259-
ok "H: sibling-listing API failure keeps the old base branch"
276+
grep -q "EXIT=" "$WORK/out.log" || fail "I: run should have failed"
277+
git -C "$ORIGIN" rev-parse --verify -q parent >/dev/null || fail "I: old base branch was deleted on an API failure"
278+
grep -q -- "push origin :parent" "$CALLS" && fail "I: deletion must not be attempted"
279+
ok "I: sibling-listing API failure keeps the old base branch"
260280

261281
echo
262282
echo "All conflict-resume tests passed 🎉 ($PASS scenarios)"

tests/test_e2e.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ assert_conflict_comment_merges() {
291291
expected+="git merge $conflict"$'\n'
292292
done
293293
expected=${expected%$'\n'}
294-
actual=$(echo "$comment" | grep -E '^git merge' | sed 's/ *#.*//' || true)
294+
actual=$(echo "$comment" | grep -E '^git merge' | grep -v -- '--ff-only' | sed 's/ *#.*//' || true)
295295

296296
if [[ "$actual" == "$expected" ]]; then
297297
echo >&2 "✅ Verification Passed: conflict comment lists expected merge command(s)."
@@ -558,7 +558,7 @@ wait_for_synchronize_workflow() {
558558
log_cmd gh run view "$target_run_id" --repo "$REPO_FULL_NAME" --log || echo >&2 "Could not fetch logs for run $target_run_id"
559559
return 1
560560
fi
561-
elif [[ "$run_status" == "queued" || "$run_status" == "in_progress" || "$run_status" == "waiting" ]]; then
561+
elif [[ "$run_status" == "queued" || "$run_status" == "in_progress" || "$run_status" == "waiting" || "$run_status" == "pending" ]]; then
562562
echo >&2 "Workflow $target_run_id is $run_status. Sleeping $sleep_time seconds."
563563
else
564564
echo >&2 "Workflow $target_run_id has unexpected status: $run_status. Conclusion: $run_conclusion"
@@ -665,7 +665,7 @@ wait_for_workflow() {
665665
log_cmd gh run view "$target_run_id" --repo "$REPO_FULL_NAME" --log || echo >&2 "Could not fetch logs for run $target_run_id"
666666
return 1
667667
fi
668-
elif [[ "$run_status" == "queued" || "$run_status" == "in_progress" || "$run_status" == "waiting" ]]; then
668+
elif [[ "$run_status" == "queued" || "$run_status" == "in_progress" || "$run_status" == "waiting" || "$run_status" == "pending" ]]; then
669669
echo >&2 "Workflow $target_run_id is $run_status. Sleeping $sleep_time seconds."
670670
else
671671
echo >&2 "Workflow $target_run_id has unexpected status: $run_status. Conclusion: $run_conclusion"

0 commit comments

Comments
 (0)