Skip to content

Commit c896fb4

Browse files
ralyodioclaude
andcommitted
Add gh-issues, the open-issue twin of gh-prs
Same scopes, same table, same clickable #123 and URL. The one real difference is the search: `gh search issues` leaves pull requests out unless --include-prs is passed, so the two tools never list each other's rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011gjtUzDzbbC2kfWdiNtUxs
1 parent 8720eab commit c896fb4

2 files changed

Lines changed: 257 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@ gh-prs --users ralyodio
9090
gh-prs --orgs profullstack --users ralyodio --limit 50
9191
```
9292

93+
### `gh-issues`
94+
95+
The same table for open issues rather than pull requests. `gh search issues`
96+
excludes pull requests unless asked for them, so the two tools never overlap.
97+
98+
```sh
99+
gh-issues --orgs profullstack,moshcoder,h4kr,infernetprotocol
100+
gh-issues --users ralyodio
101+
gh-issues --orgs profullstack --users ralyodio --limit 50
102+
```
103+
93104
### `gh-prs-merge`
94105

95106
Walks the same scopes and squash-merges every PR that qualifies, oldest first.

‎bin/gh-issues‎

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
orgs=''
5+
users=''
6+
limit=1000
7+
8+
usage() {
9+
cat <<'EOF'
10+
Usage:
11+
gh-issues --orgs ORG1,ORG2 [--users USER1,USER2] [--limit NUMBER]
12+
gh-issues --users USER1,USER2 [--limit NUMBER]
13+
14+
Examples:
15+
gh-issues --orgs profullstack,moshcoder,h4kr,infernetprotocol
16+
gh-issues --users ralyodio,devpreshy
17+
gh-issues --orgs profullstack,moshcoder --users ralyodio,devpreshy
18+
EOF
19+
}
20+
21+
die() {
22+
printf 'gh-issues: %s\n' "$*" >&2
23+
exit 2
24+
}
25+
26+
while (($#)); do
27+
case "$1" in
28+
--orgs)
29+
(($# >= 2)) || die '--orgs requires a comma-separated value'
30+
orgs=$2
31+
shift 2
32+
;;
33+
34+
--orgs=*)
35+
orgs=${1#*=}
36+
shift
37+
;;
38+
39+
--users)
40+
(($# >= 2)) || die '--users requires a comma-separated value'
41+
users=$2
42+
shift 2
43+
;;
44+
45+
--users=*)
46+
users=${1#*=}
47+
shift
48+
;;
49+
50+
--limit)
51+
(($# >= 2)) || die '--limit requires a number'
52+
limit=$2
53+
shift 2
54+
;;
55+
56+
--limit=*)
57+
limit=${1#*=}
58+
shift
59+
;;
60+
61+
-h|--help)
62+
usage
63+
exit 0
64+
;;
65+
66+
*)
67+
die "unknown option: $1"
68+
;;
69+
esac
70+
done
71+
72+
for dependency in gh jq awk; do
73+
command -v "$dependency" >/dev/null 2>&1 ||
74+
die "required command not found: $dependency"
75+
done
76+
77+
[[ $limit =~ ^[1-9][0-9]*$ ]] ||
78+
die '--limit must be a positive integer'
79+
80+
((limit <= 1000)) ||
81+
die '--limit cannot exceed 1000'
82+
83+
org_list=()
84+
user_list=()
85+
86+
split_csv() {
87+
local csv=$1
88+
local -n destination=$2
89+
local item
90+
local -a raw=()
91+
92+
[[ -n $csv ]] || return 0
93+
94+
IFS=',' read -r -a raw <<< "$csv"
95+
96+
for item in "${raw[@]}"; do
97+
# Trim whitespace around each value.
98+
item="${item#"${item%%[![:space:]]*}"}"
99+
item="${item%"${item##*[![:space:]]}"}"
100+
101+
[[ -n $item ]] && destination+=("$item")
102+
done
103+
}
104+
105+
split_csv "$orgs" org_list
106+
split_csv "$users" user_list
107+
108+
if ((${#org_list[@]} == 0 && ${#user_list[@]} == 0)); then
109+
usage >&2
110+
die 'pass --orgs, --users, or both'
111+
fi
112+
113+
search_scope() {
114+
local qualifier=$1
115+
local owner=$2
116+
117+
# `gh search issues` leaves pull requests out unless --include-prs is passed,
118+
# which is the only real difference from gh-prs.
119+
GH_PAGER=cat gh search issues "${qualifier}:${owner}" \
120+
--state=open \
121+
--archived=false \
122+
--sort=created \
123+
--order=desc \
124+
--limit="$limit" \
125+
--json repository,number,title,url,author,createdAt,updatedAt
126+
}
127+
128+
# Enable real terminal hyperlinks when stdout is a terminal.
129+
hyperlinks=0
130+
131+
if [[ -t 1 && ${TERM:-dumb} != dumb ]]; then
132+
hyperlinks=1
133+
fi
134+
135+
{
136+
# Organizations use org:
137+
for org in "${org_list[@]}"; do
138+
search_scope org "$org"
139+
done
140+
141+
# Personal repository owners use user:
142+
for user in "${user_list[@]}"; do
143+
search_scope user "$user"
144+
done
145+
} |
146+
jq -rs '
147+
def clean:
148+
tostring | gsub("[\t\r\n]+"; " ");
149+
150+
def truncate($length):
151+
if length > $length
152+
then .[0:($length - 3)] + "..."
153+
else .
154+
end;
155+
156+
def plural($number; $unit):
157+
"\($number) \($unit)\(if $number == 1 then "" else "s" end) ago";
158+
159+
def timeago:
160+
([0, (now - fromdateiso8601)] | max) as $seconds
161+
| if $seconds < 60 then
162+
plural(($seconds | floor); "second")
163+
elif $seconds < 3600 then
164+
plural((($seconds / 60) | floor); "minute")
165+
elif $seconds < 86400 then
166+
plural((($seconds / 3600) | floor); "hour")
167+
elif $seconds < 604800 then
168+
plural((($seconds / 86400) | floor); "day")
169+
elif $seconds < 2629800 then
170+
plural((($seconds / 604800) | floor); "week")
171+
elif $seconds < 31557600 then
172+
plural((($seconds / 2629800) | floor); "month")
173+
else
174+
plural((($seconds / 31557600) | floor); "year")
175+
end;
176+
177+
(add // [])
178+
| unique_by(.url)
179+
| sort_by(.createdAt)
180+
| reverse
181+
| (
182+
[
183+
"ORG/USER",
184+
"REPOSITORY",
185+
"ISSUE",
186+
"AUTHOR",
187+
"TITLE",
188+
"UPDATED",
189+
"LINK"
190+
],
191+
192+
(.[] | [
193+
(.repository.nameWithOwner | split("/")[0]),
194+
.repository.nameWithOwner,
195+
("#" + (.number | tostring)),
196+
(.author.login // "-"),
197+
(.title | clean | truncate(70)),
198+
(.updatedAt | timeago),
199+
.url
200+
])
201+
)
202+
| @tsv
203+
' |
204+
awk -F '\t' -v hyperlinks="$hyperlinks" '
205+
{
206+
rows = NR
207+
field_count[NR] = NF
208+
209+
for (column = 1; column <= NF; column++) {
210+
cell[NR, column] = $column
211+
212+
if (length($column) > width[column]) {
213+
width[column] = length($column)
214+
}
215+
}
216+
}
217+
218+
END {
219+
escape = sprintf("%c", 27)
220+
221+
for (row = 1; row <= rows; row++) {
222+
url = cell[row, field_count[row]]
223+
224+
for (column = 1; column <= field_count[row]; column++) {
225+
# Make both #123 and the full URL clickable.
226+
linked = hyperlinks && row > 1 && \
227+
(column == 3 || column == field_count[row])
228+
229+
if (linked) {
230+
link_start = escape "]8;;" url escape "\\"
231+
link_end = escape "]8;;" escape "\\"
232+
} else {
233+
link_start = ""
234+
link_end = ""
235+
}
236+
237+
if (column < field_count[row]) {
238+
printf "%s%-*s%s ", \
239+
link_start, width[column], cell[row, column], link_end
240+
} else {
241+
printf "%s%s%s\n", link_start, cell[row, column], link_end
242+
}
243+
}
244+
}
245+
}
246+
'

0 commit comments

Comments
 (0)