From 92dce5658ea0518608c8467636fc9ba487c68750 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Tue, 27 Oct 2026 02:13:48 +0200 Subject: [PATCH 1/5] chore: add a script that lists open pull requests from low risk to critical --- .github/pr-queue.sh | 52 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 54 insertions(+) create mode 100755 .github/pr-queue.sh diff --git a/.github/pr-queue.sh b/.github/pr-queue.sh new file mode 100755 index 000000000..be7c24033 --- /dev/null +++ b/.github/pr-queue.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Lists open pull requests from low risk to critical, for a maintainer to run with their own gh sign-in. +# It never runs in a workflow, and it only reads: one GraphQL request per 50 open pull requests. +# +# Drafts, conflicts, failing CI and requested changes wait on their authors. The rest is split by +# what it touches, then each šŸ‘ on a description (not the author's) lifts it one size step. + +set -euo pipefail + +gh api graphql --paginate --slurp -f query=' + query ($endCursor: String) { + repository(owner: "dev-null-sh", name: "dev-null.sh") { + pullRequests(states: OPEN, first: 50, after: $endCursor) { + pageInfo { hasNextPage endCursor } + nodes { + number title createdAt isDraft additions deletions changedFiles mergeable reviewDecision + author { login } + files(first: 100) { nodes { path } } + reactions(content: THUMBS_UP, first: 100) { nodes { user { login } } } + commits(last: 1) { nodes { commit { checkSuites(last: 20) { nodes { conclusion workflowRun { workflow { name } } } } } } } + } + } + } + }' | jq -r ' + [.[].data.repository.pullRequests.nodes[]] + | map( + (.additions + .deletions) as $lines + | [.files.nodes[].path] as $paths + | .size = ([10, 50, 250, 1000] | map(select($lines >= .)) | length) + | .votes = ([.reactions.nodes[].user.login // empty | select(endswith("[bot]") | not)] - [.author.login] | length) + | ([.commits.nodes[].commit.checkSuites.nodes[]] | group_by(.workflowRun.workflow.name) | map(last.conclusion)) as $suites + | .approval = any($suites[]; . == "ACTION_REQUIRED") + | .waiting = if .isDraft then "draft" + elif .mergeable == "CONFLICTING" then "merge conflicts" + elif any($suites[]; . == "FAILURE" or . == "TIMED_OUT") then "failing CI" + elif .reviewDecision == "CHANGES_REQUESTED" then "changes requested" + else null end + | .tier = if $lines >= 1000 or .changedFiles > 100 or any($paths[]; test("^(database/migrations|routes|bootstrap|config|patches|\\.github|app/Http/(Middleware|Requests))/|^app/Services/(SshSignatures|SignedRequests)\\.php$|^(composer\\.json|package\\.json|\\.npmrc|bunfig\\.toml|vet\\.json)$|\\.lock$")) then 2 + elif $lines < 250 and all($paths[]; test("^(resources/(js|css)|tests)/|\\.md$")) then 0 + else 1 end + ) + | (map(select(.waiting == null)) | sort_by(.tier, .size - .votes, .createdAt)) as $ready + | ( + ["🟢 Low risk: only UI, docs or tests, under 250 lines", "🟔 Review: everything else", "šŸ”“ Critical: data, routes, auth, config, CI or dependencies, or 1000+ lines or over 100 files"] as $titles + | range(0; 3) as $tier + | [$ready[] | select(.tier == $tier)] + | "\n\($titles[$tier]) (\(length))", + (.[] | " #\(.number) \(["XS", "S", "M", "L", "XL"][.size]) šŸ‘\(.votes)\(if .approval then " needs CI approval" else "" end) \(.title)") + ), + (map(select(.waiting != null)) | "\nāø Waiting on the author (\(length))", (group_by(.waiting)[] | " \(.[0].waiting): \(map("#\(.number)") | join(" "))")) +' diff --git a/README.md b/README.md index 10c3fb906..552f0b8c2 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ composer dev Run `composer test` before opening a pull request. +To vote for a pull request, give its description a šŸ‘ (its author's own doesn't count). Maintainers can run `.github/pr-queue.sh` to list open pull requests from low risk to critical, and each vote moves a pull request up within its group. + ## License /dev/null is open-sourced software licensed under the **[MIT license](composer.json)**. From ba0ee47ac068912c3139238cfac938999b51f4fe Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Tue, 27 Oct 2026 16:58:12 +0200 Subject: [PATCH 2/5] fix: keep check suites from other apps apart so a failing one still counts --- .github/pr-queue.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/pr-queue.sh b/.github/pr-queue.sh index be7c24033..ce81a77fb 100755 --- a/.github/pr-queue.sh +++ b/.github/pr-queue.sh @@ -18,7 +18,7 @@ gh api graphql --paginate --slurp -f query=' author { login } files(first: 100) { nodes { path } } reactions(content: THUMBS_UP, first: 100) { nodes { user { login } } } - commits(last: 1) { nodes { commit { checkSuites(last: 20) { nodes { conclusion workflowRun { workflow { name } } } } } } } + commits(last: 1) { nodes { commit { checkSuites(last: 20) { nodes { conclusion app { slug } workflowRun { workflow { name } } } } } } } } } } @@ -29,7 +29,7 @@ gh api graphql --paginate --slurp -f query=' | [.files.nodes[].path] as $paths | .size = ([10, 50, 250, 1000] | map(select($lines >= .)) | length) | .votes = ([.reactions.nodes[].user.login // empty | select(endswith("[bot]") | not)] - [.author.login] | length) - | ([.commits.nodes[].commit.checkSuites.nodes[]] | group_by(.workflowRun.workflow.name) | map(last.conclusion)) as $suites + | ([.commits.nodes[].commit.checkSuites.nodes[]] | group_by(.workflowRun.workflow.name // .app.slug) | map(last.conclusion)) as $suites | .approval = any($suites[]; . == "ACTION_REQUIRED") | .waiting = if .isDraft then "draft" elif .mergeable == "CONFLICTING" then "merge conflicts" From 50e3eacfc4cd53dd253e21ff31892b6ec2eb9be3 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Tue, 27 Oct 2026 16:58:12 +0200 Subject: [PATCH 3/5] docs: note that the queue script needs gh 2.48 or later and jq --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 552f0b8c2..32c2b873f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ composer dev Run `composer test` before opening a pull request. -To vote for a pull request, give its description a šŸ‘ (its author's own doesn't count). Maintainers can run `.github/pr-queue.sh` to list open pull requests from low risk to critical, and each vote moves a pull request up within its group. +To vote for a pull request, give its description a šŸ‘ (its author's own doesn't count). Maintainers can run `.github/pr-queue.sh` (it needs `gh` 2.48 or later and `jq`) to list open pull requests from low risk to critical, and each vote moves a pull request up within its group. ## License From 8462cee0d948c742707b792ada4a16995270f5d1 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Tue, 27 Oct 2026 20:55:20 +0200 Subject: [PATCH 4/5] fix: count a workflow that fails to start and check suites without an app as failing CI --- .github/pr-queue.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/pr-queue.sh b/.github/pr-queue.sh index ce81a77fb..6762d5bdd 100755 --- a/.github/pr-queue.sh +++ b/.github/pr-queue.sh @@ -18,7 +18,7 @@ gh api graphql --paginate --slurp -f query=' author { login } files(first: 100) { nodes { path } } reactions(content: THUMBS_UP, first: 100) { nodes { user { login } } } - commits(last: 1) { nodes { commit { checkSuites(last: 20) { nodes { conclusion app { slug } workflowRun { workflow { name } } } } } } } + commits(last: 1) { nodes { commit { checkSuites(last: 20) { nodes { id conclusion app { slug } workflowRun { workflow { name } } } } } } } } } } @@ -29,11 +29,11 @@ gh api graphql --paginate --slurp -f query=' | [.files.nodes[].path] as $paths | .size = ([10, 50, 250, 1000] | map(select($lines >= .)) | length) | .votes = ([.reactions.nodes[].user.login // empty | select(endswith("[bot]") | not)] - [.author.login] | length) - | ([.commits.nodes[].commit.checkSuites.nodes[]] | group_by(.workflowRun.workflow.name // .app.slug) | map(last.conclusion)) as $suites + | ([.commits.nodes[].commit.checkSuites.nodes[]] | group_by(.workflowRun.workflow.name // .app.slug // .id) | map(last.conclusion)) as $suites | .approval = any($suites[]; . == "ACTION_REQUIRED") | .waiting = if .isDraft then "draft" elif .mergeable == "CONFLICTING" then "merge conflicts" - elif any($suites[]; . == "FAILURE" or . == "TIMED_OUT") then "failing CI" + elif any($suites[]; . == "FAILURE" or . == "TIMED_OUT" or . == "STARTUP_FAILURE") then "failing CI" elif .reviewDecision == "CHANGES_REQUESTED" then "changes requested" else null end | .tier = if $lines >= 1000 or .changedFiles > 100 or any($paths[]; test("^(database/migrations|routes|bootstrap|config|patches|\\.github|app/Http/(Middleware|Requests))/|^app/Services/(SshSignatures|SignedRequests)\\.php$|^(composer\\.json|package\\.json|\\.npmrc|bunfig\\.toml|vet\\.json)$|\\.lock$")) then 2 From 661736cad5d5f51bc8dca35bbe30c915efa238df Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Tue, 27 Oct 2026 21:43:40 +0200 Subject: [PATCH 5/5] fix: group check suites by workflow id so two workflows with the same name stay apart --- .github/pr-queue.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/pr-queue.sh b/.github/pr-queue.sh index 6762d5bdd..9a8a0bc30 100755 --- a/.github/pr-queue.sh +++ b/.github/pr-queue.sh @@ -18,7 +18,7 @@ gh api graphql --paginate --slurp -f query=' author { login } files(first: 100) { nodes { path } } reactions(content: THUMBS_UP, first: 100) { nodes { user { login } } } - commits(last: 1) { nodes { commit { checkSuites(last: 20) { nodes { id conclusion app { slug } workflowRun { workflow { name } } } } } } } + commits(last: 1) { nodes { commit { checkSuites(last: 20) { nodes { id conclusion app { slug } workflowRun { workflow { id } } } } } } } } } } @@ -29,7 +29,7 @@ gh api graphql --paginate --slurp -f query=' | [.files.nodes[].path] as $paths | .size = ([10, 50, 250, 1000] | map(select($lines >= .)) | length) | .votes = ([.reactions.nodes[].user.login // empty | select(endswith("[bot]") | not)] - [.author.login] | length) - | ([.commits.nodes[].commit.checkSuites.nodes[]] | group_by(.workflowRun.workflow.name // .app.slug // .id) | map(last.conclusion)) as $suites + | ([.commits.nodes[].commit.checkSuites.nodes[]] | group_by(.workflowRun.workflow.id // .app.slug // .id) | map(last.conclusion)) as $suites | .approval = any($suites[]; . == "ACTION_REQUIRED") | .waiting = if .isDraft then "draft" elif .mergeable == "CONFLICTING" then "merge conflicts"