Commit 13adfe52d70b
Changed files (2)
dots
config
raffi
scripts
dots/config/raffi/scripts/pr-raffi
@@ -0,0 +1,422 @@
+#!/usr/bin/env python3
+"""Alfred Script Filter: list GitHub pull requests for a repository."""
+
+import json
+import os
+import platform
+import re
+import subprocess
+import sys
+
+
+def setup_path():
+ """Prepend Homebrew bin to PATH so `gh` is found in Alfred's environment."""
+ machine = platform.machine()
+ prefix = "/opt/homebrew" if machine == "arm64" else "/usr/local"
+ brew_bin = os.path.join(prefix, "bin")
+ os.environ["PATH"] = brew_bin + ":" + os.environ.get("PATH", "")
+
+
+def error_item(title, subtitle=""):
+ """Return Alfred JSON with a single error item."""
+ return json.dumps(
+ {"items": [{"title": title, "subtitle": subtitle, "valid": False}]}
+ )
+
+
+def resolve_alias(query):
+ """If ALIAS_{query} env var exists, return its value; otherwise return query as-is."""
+ alias_key = "ALIAS_" + query
+ return os.environ.get(alias_key, query)
+
+
+ANSI_COLORS = {
+ "SUCCESS": "\033[32m",
+ "FAILURE": "\033[31m",
+ "ERROR": "\033[31m",
+ "PENDING": "\033[33m",
+ "BEHIND": "\033[33m",
+ "DIRTY": "\033[31m",
+ "CONFLICTING": "\033[31m",
+}
+ANSI_RESET = "\033[0m"
+
+
+def get_ci_icon(state, color=False):
+ """Map CI statusCheckRollup state to a nerd font icon and label."""
+ if state == "SUCCESS":
+ icon, label = "\uf058", "CI: Passing"
+ elif state in ("FAILURE", "ERROR"):
+ icon, label = "\uf057", "CI: Failed"
+ elif state == "PENDING":
+ icon, label = "\uf017", "CI: InProgress"
+ else:
+ return "", ""
+ if color and state in ANSI_COLORS:
+ label = f"{ANSI_COLORS[state]}{label}{ANSI_RESET}"
+ return icon, label
+
+
+def get_merge_icon(mergeable, merge_state_status, color=False):
+ """Map mergeable/mergeStateStatus to a nerd font icon and label."""
+ if mergeable == "CONFLICTING":
+ icon, label, ckey = "\uf06a", "Conflicts", "CONFLICTING"
+ elif merge_state_status == "BEHIND":
+ icon, label, ckey = "\uf0ab", "Needs rebase", "BEHIND"
+ elif merge_state_status == "DIRTY":
+ icon, label, ckey = "\uf06a", "Merge conflicts", "DIRTY"
+ elif mergeable == "MERGEABLE" and merge_state_status == "CLEAN":
+ icon, label, ckey = "\uf00c", "Ready to merge", "SUCCESS"
+ elif merge_state_status == "BLOCKED":
+ icon, label, ckey = "\uf023", "Pending", "PENDING"
+ elif merge_state_status == "UNSTABLE":
+ icon, label, ckey = "\uf071", "Unstable", "PENDING"
+ elif mergeable == "UNKNOWN":
+ return "", ""
+ else:
+ return "", ""
+ if color and ckey in ANSI_COLORS:
+ label = f"{ANSI_COLORS[ckey]}{label}{ANSI_RESET}"
+ return icon, label
+
+
+def fetch_my_prs(use_color, filter_term=""):
+ """Fetch open PRs authored by the current user across all repos."""
+ api_host = os.environ.get("API_HOST", "github.com")
+ cache_pulls = os.environ.get("CACHE_PULLS", "10m")
+
+ graphql_query = """
+query($searchQuery: String!, $first: Int!) {
+ search(query: $searchQuery, type: ISSUE, first: $first) {
+ nodes {
+ ... on PullRequest {
+ number
+ title
+ url
+ state
+ mergedAt
+ mergeable
+ mergeStateStatus
+ repository { nameWithOwner }
+ author { login }
+ commits(last: 1) {
+ nodes {
+ commit {
+ statusCheckRollup {
+ state
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+ cmd = [
+ "gh",
+ "api",
+ "graphql",
+ "-f",
+ f"query={graphql_query}",
+ "-f",
+ "searchQuery=is:pr is:open author:@me",
+ "-F",
+ "first=50",
+ "--hostname",
+ api_host,
+ "--cache",
+ cache_pulls,
+ ]
+
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+ except subprocess.TimeoutExpired:
+ print(error_item("Request timed out", "gh api call timed out"))
+ return
+ except FileNotFoundError:
+ print(error_item("GitHub CLI not found", "Install with: brew install gh"))
+ return
+
+ if result.returncode != 0:
+ err_msg = result.stderr.strip() or "Unknown error"
+ print(error_item("GitHub API error", err_msg))
+ return
+
+ try:
+ response = json.loads(result.stdout)
+ except json.JSONDecodeError as exc:
+ print(error_item("Failed to parse response", str(exc)))
+ return
+
+ if "errors" in response:
+ msg = response["errors"][0].get("message", "Unknown GraphQL error")
+ print(error_item("GitHub API error", msg))
+ return
+
+ pulls = response["data"]["search"]["nodes"]
+
+ host = api_host
+ items = [
+ {
+ "title": "Open your pull requests dashboard",
+ "subtitle": f"Open https://{host}/pulls",
+ "arg": f"https://{host}/pulls",
+ }
+ ]
+
+ for pr in pulls:
+ if not pr:
+ continue
+ url = pr.get("url", "")
+ number = pr.get("number", "")
+ title = pr.get("title", "")
+ repo_name = pr.get("repository", {}).get("nameWithOwner", "")
+ author = (pr.get("author") or {}).get("login", "unknown")
+
+ if pr.get("mergedAt"):
+ state = "Merged"
+ elif pr.get("state") == "CLOSED":
+ state = "Closed"
+ else:
+ state = "Open"
+
+ ci_state = None
+ commits = pr.get("commits", {}).get("nodes", [])
+ if commits:
+ rollup = commits[0].get("commit", {}).get("statusCheckRollup")
+ if rollup:
+ ci_state = rollup.get("state")
+
+ ci_icon, ci_label = get_ci_icon(ci_state, color=use_color)
+ merge_icon, merge_label = get_merge_icon(
+ pr.get("mergeable"), pr.get("mergeStateStatus"), color=use_color
+ )
+
+ parts = []
+ if ci_icon:
+ parts.append(f"{ci_icon} {ci_label}")
+ if merge_icon:
+ parts.append(f"{merge_icon} {merge_label}")
+ parts.append(state)
+ subtitle = " - ".join(parts) + f" \u2014 {url}"
+
+ filter_str = f"{number} {title} {repo_name} {author} {state} {ci_label} {merge_label}".lower()
+ if filter_term and filter_term not in filter_str:
+ continue
+
+ items.append(
+ {
+ "title": f"PR #{number}: {title} - {repo_name}",
+ "subtitle": subtitle,
+ "arg": url,
+ "mods": {
+ "cmd": {
+ "subtitle": "Copy PR URL to clipboard",
+ "arg": url,
+ }
+ },
+ "text": {"copy": url},
+ }
+ )
+
+ print(json.dumps({"items": items}))
+
+
+def main():
+ setup_path()
+
+ args = sys.argv[1:]
+ use_color = "--color" in args
+ if use_color:
+ args.remove("--color")
+
+ if not args or not args[0].strip():
+ print(
+ error_item(
+ "No repository specified", "Usage: pr [myprs|owner/repo] [filter]"
+ )
+ )
+ return
+
+ query = args[0].strip()
+ filter_term = args[1].strip().lower() if len(args) > 1 else ""
+
+ if query.lower() == "myprs":
+ fetch_my_prs(use_color, filter_term)
+ return
+ repo = resolve_alias(query)
+
+ repo_pattern = re.compile(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$")
+ if not repo_pattern.match(repo):
+ subtitle = repo if repo != query else ""
+ if subtitle:
+ subtitle = f'Alias "{query}" resolved to invalid repo: {repo}'
+ else:
+ subtitle = "Expected format: owner/repo"
+ print(error_item("Invalid repository format", subtitle))
+ return
+
+ if not _which("gh"):
+ print(
+ error_item(
+ "GitHub CLI not found",
+ "Install with: brew install gh",
+ )
+ )
+ return
+
+ api_host = os.environ.get("API_HOST", "github.com")
+ cache_pulls = os.environ.get("CACHE_PULLS", "10m")
+
+ owner, name = repo.split("/")
+ graphql_query = """
+query($owner: String!, $name: String!, $first: Int!) {
+ repository(owner: $owner, name: $name) {
+ pullRequests(states: OPEN, first: $first, orderBy: {field: CREATED_AT, direction: DESC}) {
+ nodes {
+ number
+ title
+ url
+ state
+ mergedAt
+ mergeable
+ mergeStateStatus
+ author { login }
+ commits(last: 1) {
+ nodes {
+ commit {
+ statusCheckRollup {
+ state
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+ cmd = [
+ "gh",
+ "api",
+ "graphql",
+ "-f",
+ f"query={graphql_query}",
+ "-f",
+ f"owner={owner}",
+ "-f",
+ f"name={name}",
+ "-F",
+ "first=50",
+ "--hostname",
+ api_host,
+ "--cache",
+ cache_pulls,
+ ]
+
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+ except subprocess.TimeoutExpired:
+ print(error_item("Request timed out", f"gh api call for {repo} timed out"))
+ return
+ except FileNotFoundError:
+ print(error_item("GitHub CLI not found", "Install with: brew install gh"))
+ return
+
+ if result.returncode != 0:
+ err_msg = result.stderr.strip() or "Unknown error"
+ print(error_item("GitHub API error", err_msg))
+ return
+
+ try:
+ response = json.loads(result.stdout)
+ except json.JSONDecodeError as exc:
+ print(error_item("Failed to parse response", str(exc)))
+ return
+
+ if "errors" in response:
+ msg = response["errors"][0].get("message", "Unknown GraphQL error")
+ print(error_item("GitHub API error", msg))
+ return
+
+ pulls = response["data"]["repository"]["pullRequests"]["nodes"]
+
+ host = api_host
+ repo_url = f"https://{host}/{repo}"
+
+ items = [
+ {
+ "title": "Open all pull requests page",
+ "subtitle": f"Open {repo_url}/pulls",
+ "arg": f"{repo_url}/pulls",
+ }
+ ]
+
+ for pr in pulls:
+ url = pr.get("url", "")
+ number = pr.get("number", "")
+ title = pr.get("title", "")
+ author = (pr.get("author") or {}).get("login", "unknown")
+
+ if pr.get("mergedAt"):
+ state = "Merged"
+ elif pr.get("state") == "CLOSED":
+ state = "Closed"
+ else:
+ state = "Open"
+
+ ci_state = None
+ commits = pr.get("commits", {}).get("nodes", [])
+ if commits:
+ rollup = commits[0].get("commit", {}).get("statusCheckRollup")
+ if rollup:
+ ci_state = rollup.get("state")
+
+ ci_icon, ci_label = get_ci_icon(ci_state, color=use_color)
+ merge_icon, merge_label = get_merge_icon(
+ pr.get("mergeable"), pr.get("mergeStateStatus"), color=use_color
+ )
+
+ parts = []
+ if ci_icon:
+ parts.append(f"{ci_icon} {ci_label}")
+ if merge_icon:
+ parts.append(f"{merge_icon} {merge_label}")
+ parts.append(state)
+ subtitle = " - ".join(parts) + f" \u2014 {url}"
+
+ filter_str = f"{number} {title} {author} {state} {ci_label} {merge_label}".lower()
+ if filter_term and filter_term not in filter_str:
+ continue
+
+ items.append(
+ {
+ "title": f"PR #{number}: {title} - {author}",
+ "subtitle": subtitle,
+ "arg": url,
+ "mods": {
+ "cmd": {
+ "subtitle": "Copy PR URL to clipboard",
+ "arg": url,
+ }
+ },
+ "text": {"copy": url},
+ }
+ )
+
+ print(json.dumps({"items": items}))
+
+
+def _which(name):
+ """Check if a command exists on PATH."""
+ for directory in os.environ.get("PATH", "").split(os.pathsep):
+ if os.path.isfile(os.path.join(directory, name)):
+ return True
+ return False
+
+
+if __name__ == "__main__":
+ main()
dots/config/raffi/raffi.yaml
@@ -92,7 +92,7 @@ addons:
icon: clock
- name: GitHub Pull Requests
keyword: pr
- command: ~/src/gitlab.com/chmouel/alfred-pr-workflow/pr.py
+ command: ~/.config/raffi/scripts/pr-raffi
args:
- --color
icon: github
@@ -243,7 +243,7 @@ launchers:
nautilus:
binary: nautilus
args:
- - ~/Downloads/
+ - ~/desktop/downloads
description: File Manager (Downloads)
icon: system-file-manager
cliphist: