Commit 13adfe52d70b

Vincent Demeester <vincent@sbr.pm>
2026-03-12 12:29:14
feat(raffi): add merge status to PR script
Moved pr.py script into home repo as pr-raffi. Added mergeable/rebase status indicators using GitHub GraphQL API fields (mergeable, mergeStateStatus). Fixed Downloads entry to use xdg download path.
1 parent 830eb0b
Changed files (2)
dots
config
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: