main
1#!/usr/bin/env python3
2"""Alfred Script Filter: list GitHub pull requests for a repository."""
3
4import json
5import os
6import platform
7import re
8import subprocess
9import sys
10
11
12def setup_path():
13 """Prepend Homebrew bin to PATH so `gh` is found in Alfred's environment."""
14 machine = platform.machine()
15 prefix = "/opt/homebrew" if machine == "arm64" else "/usr/local"
16 brew_bin = os.path.join(prefix, "bin")
17 os.environ["PATH"] = brew_bin + ":" + os.environ.get("PATH", "")
18
19
20def error_item(title, subtitle=""):
21 """Return Alfred JSON with a single error item."""
22 return json.dumps(
23 {"items": [{"title": title, "subtitle": subtitle, "valid": False}]}
24 )
25
26
27def resolve_alias(query):
28 """If ALIAS_{query} env var exists, return its value; otherwise return query as-is."""
29 alias_key = "ALIAS_" + query
30 return os.environ.get(alias_key, query)
31
32
33ANSI_COLORS = {
34 "SUCCESS": "\033[32m",
35 "FAILURE": "\033[31m",
36 "ERROR": "\033[31m",
37 "PENDING": "\033[33m",
38 "BEHIND": "\033[33m",
39 "DIRTY": "\033[31m",
40 "CONFLICTING": "\033[31m",
41}
42ANSI_RESET = "\033[0m"
43
44
45def get_ci_icon(state, color=False):
46 """Map CI statusCheckRollup state to a nerd font icon and label."""
47 if state == "SUCCESS":
48 icon, label = "\uf058", "CI: Passing"
49 elif state in ("FAILURE", "ERROR"):
50 icon, label = "\uf057", "CI: Failed"
51 elif state == "PENDING":
52 icon, label = "\uf017", "CI: InProgress"
53 else:
54 return "", ""
55 if color and state in ANSI_COLORS:
56 label = f"{ANSI_COLORS[state]}{label}{ANSI_RESET}"
57 return icon, label
58
59
60def get_merge_icon(mergeable, merge_state_status, color=False):
61 """Map mergeable/mergeStateStatus to a nerd font icon and label."""
62 if mergeable == "CONFLICTING":
63 icon, label, ckey = "\uf06a", "Conflicts", "CONFLICTING"
64 elif merge_state_status == "BEHIND":
65 icon, label, ckey = "\uf0ab", "Needs rebase", "BEHIND"
66 elif merge_state_status == "DIRTY":
67 icon, label, ckey = "\uf06a", "Merge conflicts", "DIRTY"
68 elif mergeable == "MERGEABLE" and merge_state_status == "CLEAN":
69 icon, label, ckey = "\uf00c", "Ready to merge", "SUCCESS"
70 elif merge_state_status == "BLOCKED":
71 icon, label, ckey = "\uf023", "Pending", "PENDING"
72 elif merge_state_status == "UNSTABLE":
73 icon, label, ckey = "\uf071", "Unstable", "PENDING"
74 elif mergeable == "UNKNOWN":
75 return "", ""
76 else:
77 return "", ""
78 if color and ckey in ANSI_COLORS:
79 label = f"{ANSI_COLORS[ckey]}{label}{ANSI_RESET}"
80 return icon, label
81
82
83def fetch_my_prs(use_color, filter_term=""):
84 """Fetch open PRs authored by the current user across all repos."""
85 api_host = os.environ.get("API_HOST", "github.com")
86 cache_pulls = os.environ.get("CACHE_PULLS", "10m")
87
88 graphql_query = """
89query($searchQuery: String!, $first: Int!) {
90 search(query: $searchQuery, type: ISSUE, first: $first) {
91 nodes {
92 ... on PullRequest {
93 number
94 title
95 url
96 state
97 mergedAt
98 mergeable
99 mergeStateStatus
100 repository { nameWithOwner }
101 author { login }
102 commits(last: 1) {
103 nodes {
104 commit {
105 statusCheckRollup {
106 state
107 }
108 }
109 }
110 }
111 }
112 }
113 }
114}
115"""
116
117 cmd = [
118 "gh",
119 "api",
120 "graphql",
121 "-f",
122 f"query={graphql_query}",
123 "-f",
124 "searchQuery=is:pr is:open author:@me",
125 "-F",
126 "first=50",
127 "--hostname",
128 api_host,
129 "--cache",
130 cache_pulls,
131 ]
132
133 try:
134 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
135 except subprocess.TimeoutExpired:
136 print(error_item("Request timed out", "gh api call timed out"))
137 return
138 except FileNotFoundError:
139 print(error_item("GitHub CLI not found", "Install with: brew install gh"))
140 return
141
142 if result.returncode != 0:
143 err_msg = result.stderr.strip() or "Unknown error"
144 print(error_item("GitHub API error", err_msg))
145 return
146
147 try:
148 response = json.loads(result.stdout)
149 except json.JSONDecodeError as exc:
150 print(error_item("Failed to parse response", str(exc)))
151 return
152
153 if "errors" in response:
154 msg = response["errors"][0].get("message", "Unknown GraphQL error")
155 print(error_item("GitHub API error", msg))
156 return
157
158 pulls = response["data"]["search"]["nodes"]
159
160 host = api_host
161 items = [
162 {
163 "title": "Open your pull requests dashboard",
164 "subtitle": f"Open https://{host}/pulls",
165 "arg": f"https://{host}/pulls",
166 }
167 ]
168
169 for pr in pulls:
170 if not pr:
171 continue
172 url = pr.get("url", "")
173 number = pr.get("number", "")
174 title = pr.get("title", "")
175 repo_name = pr.get("repository", {}).get("nameWithOwner", "")
176 author = (pr.get("author") or {}).get("login", "unknown")
177
178 if pr.get("mergedAt"):
179 state = "Merged"
180 elif pr.get("state") == "CLOSED":
181 state = "Closed"
182 else:
183 state = "Open"
184
185 ci_state = None
186 commits = pr.get("commits", {}).get("nodes", [])
187 if commits:
188 rollup = commits[0].get("commit", {}).get("statusCheckRollup")
189 if rollup:
190 ci_state = rollup.get("state")
191
192 ci_icon, ci_label = get_ci_icon(ci_state, color=use_color)
193 merge_icon, merge_label = get_merge_icon(
194 pr.get("mergeable"), pr.get("mergeStateStatus"), color=use_color
195 )
196
197 parts = []
198 if ci_icon:
199 parts.append(f"{ci_icon} {ci_label}")
200 if merge_icon:
201 parts.append(f"{merge_icon} {merge_label}")
202 parts.append(state)
203 subtitle = " - ".join(parts) + f" \u2014 {url}"
204
205 filter_str = f"{number} {title} {repo_name} {author} {state} {ci_label} {merge_label}".lower()
206 if filter_term and filter_term not in filter_str:
207 continue
208
209 items.append(
210 {
211 "title": f"PR #{number}: {title} - {repo_name}",
212 "subtitle": subtitle,
213 "arg": url,
214 "mods": {
215 "cmd": {
216 "subtitle": "Copy PR URL to clipboard",
217 "arg": url,
218 }
219 },
220 "text": {"copy": url},
221 }
222 )
223
224 print(json.dumps({"items": items}))
225
226
227def main():
228 setup_path()
229
230 args = sys.argv[1:]
231 use_color = "--color" in args
232 if use_color:
233 args.remove("--color")
234
235 if not args or not args[0].strip():
236 print(
237 error_item(
238 "No repository specified", "Usage: pr [myprs|owner/repo] [filter]"
239 )
240 )
241 return
242
243 query = args[0].strip()
244 filter_term = args[1].strip().lower() if len(args) > 1 else ""
245
246 if query.lower() == "myprs":
247 fetch_my_prs(use_color, filter_term)
248 return
249 repo = resolve_alias(query)
250
251 repo_pattern = re.compile(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$")
252 if not repo_pattern.match(repo):
253 subtitle = repo if repo != query else ""
254 if subtitle:
255 subtitle = f'Alias "{query}" resolved to invalid repo: {repo}'
256 else:
257 subtitle = "Expected format: owner/repo"
258 print(error_item("Invalid repository format", subtitle))
259 return
260
261 if not _which("gh"):
262 print(
263 error_item(
264 "GitHub CLI not found",
265 "Install with: brew install gh",
266 )
267 )
268 return
269
270 api_host = os.environ.get("API_HOST", "github.com")
271 cache_pulls = os.environ.get("CACHE_PULLS", "10m")
272
273 owner, name = repo.split("/")
274 graphql_query = """
275query($owner: String!, $name: String!, $first: Int!) {
276 repository(owner: $owner, name: $name) {
277 pullRequests(states: OPEN, first: $first, orderBy: {field: CREATED_AT, direction: DESC}) {
278 nodes {
279 number
280 title
281 url
282 state
283 mergedAt
284 mergeable
285 mergeStateStatus
286 author { login }
287 commits(last: 1) {
288 nodes {
289 commit {
290 statusCheckRollup {
291 state
292 }
293 }
294 }
295 }
296 }
297 }
298 }
299}
300"""
301
302 cmd = [
303 "gh",
304 "api",
305 "graphql",
306 "-f",
307 f"query={graphql_query}",
308 "-f",
309 f"owner={owner}",
310 "-f",
311 f"name={name}",
312 "-F",
313 "first=50",
314 "--hostname",
315 api_host,
316 "--cache",
317 cache_pulls,
318 ]
319
320 try:
321 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
322 except subprocess.TimeoutExpired:
323 print(error_item("Request timed out", f"gh api call for {repo} timed out"))
324 return
325 except FileNotFoundError:
326 print(error_item("GitHub CLI not found", "Install with: brew install gh"))
327 return
328
329 if result.returncode != 0:
330 err_msg = result.stderr.strip() or "Unknown error"
331 print(error_item("GitHub API error", err_msg))
332 return
333
334 try:
335 response = json.loads(result.stdout)
336 except json.JSONDecodeError as exc:
337 print(error_item("Failed to parse response", str(exc)))
338 return
339
340 if "errors" in response:
341 msg = response["errors"][0].get("message", "Unknown GraphQL error")
342 print(error_item("GitHub API error", msg))
343 return
344
345 pulls = response["data"]["repository"]["pullRequests"]["nodes"]
346
347 host = api_host
348 repo_url = f"https://{host}/{repo}"
349
350 items = [
351 {
352 "title": "Open all pull requests page",
353 "subtitle": f"Open {repo_url}/pulls",
354 "arg": f"{repo_url}/pulls",
355 }
356 ]
357
358 for pr in pulls:
359 url = pr.get("url", "")
360 number = pr.get("number", "")
361 title = pr.get("title", "")
362 author = (pr.get("author") or {}).get("login", "unknown")
363
364 if pr.get("mergedAt"):
365 state = "Merged"
366 elif pr.get("state") == "CLOSED":
367 state = "Closed"
368 else:
369 state = "Open"
370
371 ci_state = None
372 commits = pr.get("commits", {}).get("nodes", [])
373 if commits:
374 rollup = commits[0].get("commit", {}).get("statusCheckRollup")
375 if rollup:
376 ci_state = rollup.get("state")
377
378 ci_icon, ci_label = get_ci_icon(ci_state, color=use_color)
379 merge_icon, merge_label = get_merge_icon(
380 pr.get("mergeable"), pr.get("mergeStateStatus"), color=use_color
381 )
382
383 parts = []
384 if ci_icon:
385 parts.append(f"{ci_icon} {ci_label}")
386 if merge_icon:
387 parts.append(f"{merge_icon} {merge_label}")
388 parts.append(state)
389 subtitle = " - ".join(parts) + f" \u2014 {url}"
390
391 filter_str = f"{number} {title} {author} {state} {ci_label} {merge_label}".lower()
392 if filter_term and filter_term not in filter_str:
393 continue
394
395 items.append(
396 {
397 "title": f"PR #{number}: {title} - {author}",
398 "subtitle": subtitle,
399 "arg": url,
400 "mods": {
401 "cmd": {
402 "subtitle": "Copy PR URL to clipboard",
403 "arg": url,
404 }
405 },
406 "text": {"copy": url},
407 }
408 )
409
410 print(json.dumps({"items": items}))
411
412
413def _which(name):
414 """Check if a command exists on PATH."""
415 for directory in os.environ.get("PATH", "").split(os.pathsep):
416 if os.path.isfile(os.path.join(directory, name)):
417 return True
418 return False
419
420
421if __name__ == "__main__":
422 main()