jq for Git Forge CLIs

Manage repositories at scale across multiple forges. These patterns work with gh (GitHub), glab (GitLab), and tea (Gitea).


GitHub CLI (gh)

Repository Listing

# List all repos with key fields
gh repo list --limit 100 --json name,visibility,pushedAt | jq '.[] | {
  name,
  visibility,
  last_push: .pushedAt | split("T")[0]
}'

# Private repos only
gh repo list --limit 100 --json name,visibility | jq '
  .[] | select(.visibility == "PRIVATE") | .name
'

# Repos with specific topic
gh repo list --json name,repositoryTopics | jq '
  .[] | select(.repositoryTopics | map(.name) | contains(["infrastructure"])) | .name
'

# Repos pushed in last 7 days
gh repo list --limit 100 --json name,pushedAt | jq '
  (now - 604800) as $week_ago |
  .[] | select(.pushedAt | fromdateiso8601 > $week_ago) |
  {name, pushedAt}
'

Repository Details

# Get repo name for scripts
REPO_NAME=$(gh repo view --json name -q '.name')

# Full repo info
gh repo view --json name,owner,sshUrl,description | jq '.'

# Check if repo has issues enabled
gh repo view --json hasIssuesEnabled -q '.hasIssuesEnabled'

# Get default branch
gh repo view --json defaultBranchRef -q '.defaultBranchRef.name'

Issue and PR Management

# List open issues with labels
gh issue list --json number,title,labels,createdAt | jq '.[] | {
  number,
  title,
  labels: [.labels[].name],
  age: (((now - (.createdAt | fromdateiso8601)) / 86400) | floor | "\(.)d")
}'

# Issues without assignee
gh issue list --json number,title,assignees | jq '
  .[] | select(.assignees | length == 0) | {number, title}
'

# PR review status
gh pr list --json number,title,reviewDecision,author | jq '.[] | {
  number,
  title,
  author: .author.login,
  status: .reviewDecision
}'

# Stale PRs (open > 14 days)
gh pr list --json number,title,createdAt | jq '
  (now - 1209600) as $two_weeks_ago |
  .[] | select(.createdAt | fromdateiso8601 < $two_weeks_ago) |
  {
    number,
    title,
    age: (((now - (.createdAt | fromdateiso8601)) / 86400) | floor | "\(.)d")
  }
'

Workflow Runs

# Recent workflow runs
gh run list --json databaseId,displayTitle,status,conclusion,createdAt | jq '.[] | {
  id: .databaseId,
  title: .displayTitle,
  status: .status,
  conclusion: .conclusion,
  started: .createdAt | split("T")[0]
}'

# Failed runs only
gh run list --json databaseId,displayTitle,conclusion | jq '
  .[] | select(.conclusion == "failure") | {id: .databaseId, title: .displayTitle}
'

# Run duration analysis
gh run list --limit 20 --json databaseId,displayTitle,createdAt,updatedAt | jq '.[] | {
  title: .displayTitle,
  duration_mins: (
    ((.updatedAt | fromdateiso8601) - (.createdAt | fromdateiso8601)) / 60 | floor
  )
}'

Release Management

# List releases with assets
gh release list --json tagName,publishedAt,isPrerelease | jq '.[] | {
  tag: .tagName,
  published: .publishedAt | split("T")[0],
  prerelease: .isPrerelease
}'

# Latest release info
gh release view --json tagName,body,assets | jq '{
  tag: .tagName,
  assets: [.assets[] | {name, download_count: .downloadCount}],
  notes: .body | split("\n")[0:5] | join("\n")
}'

API Direct Access

# Any GitHub API endpoint
gh api repos/{owner}/{repo} | jq '{
  name,
  stars: .stargazers_count,
  forks: .forks_count,
  open_issues: .open_issues_count
}'

# GraphQL query
gh api graphql -f query='
  query {
    viewer {
      repositories(first: 10, orderBy: {field: PUSHED_AT, direction: DESC}) {
        nodes { name pushedAt }
      }
    }
  }
' | jq '.data.viewer.repositories.nodes'

GitLab CLI (glab)

Repository Listing

# List repos (projects)
glab repo list --output json | jq '.[] | {
  name: .path,
  visibility,
  last_activity: .last_activity_at | split("T")[0]
}'

# Private repos only
glab repo list --output json | jq '
  .[] | select(.visibility == "private") | .path
'

# Repos with recent activity (last 30 days)
glab repo list --output json | jq '
  (now - 2592000) as $month_ago |
  .[] | select(.last_activity_at | fromdateiso8601 > $month_ago) |
  {name: .path, last_activity: .last_activity_at}
'

SSH URLs for Multi-Forge Setup

# Extract SSH URLs for cloning
glab repo list --output json | jq -r '.[] | .ssh_url_to_repo'

# Create remote add commands
glab repo list --output json | jq -r '
  .[] | "git remote add gitlab \(.ssh_url_to_repo)"
'

Merge Request Management

# List open MRs
glab mr list --output json | jq '.[] | {
  iid,
  title,
  author: .author.username,
  draft: .draft,
  created: .created_at | split("T")[0]
}'

# MRs awaiting review
glab mr list --output json | jq '
  .[] | select(.reviewers | length > 0) |
  select(.approved == false) |
  {iid, title, reviewers: [.reviewers[].username]}
'

# MR with pipeline status
glab mr list --output json | jq '.[] | {
  iid,
  title,
  pipeline: .head_pipeline.status
}'

Pipeline Analysis

# Recent pipelines
glab api projects/:id/pipelines | jq '.[] | {
  id,
  status,
  ref,
  duration: (if .duration then "\(.duration)s" else "running" end),
  created: .created_at | split("T")[0]
}'

# Failed pipelines with job details
glab api projects/:id/pipelines | jq '
  .[] | select(.status == "failed") | .id
' | head -5 | while read pipeline_id; do
  glab api "projects/:id/pipelines/$pipeline_id/jobs" | jq --arg pid "$pipeline_id" '
    .[] | select(.status == "failed") | {
      pipeline: $pid,
      job: .name,
      stage: .stage,
      failure_reason: .failure_reason
    }
  '
done

API Direct Access

# Project details
glab api projects/:id | jq '{
  name: .path,
  namespace: .namespace.path,
  default_branch,
  visibility,
  open_issues: .open_issues_count
}'

# Group projects
glab api groups/:group_id/projects | jq '.[] | {name: .path, visibility}'

Gitea CLI (tea)

Repository Listing

# List repos
tea repo list --output json | jq '.[] | {
  name,
  private,
  clone_url,
  updated: .updated_at | split("T")[0]
}'

# Private repos only
tea repo list --output json | jq '
  .[] | select(.private == true) | .name
'

Creating Repos

# Create and extract clone URL
tea repo create --name new-project --private --output json | jq -r '.clone_url'

Issue Management

# List issues
tea issue list --output json | jq '.[] | {
  number: .index,
  title,
  state,
  labels: [.labels[].name]
}'

Cross-Forge Patterns

Sync Repo Names Across Forges

# Extract unique repo names across all forges
{
  gh repo list --json name -q '.[].name'
  glab repo list --output json | jq -r '.[].path'
  tea repo list --output json | jq -r '.[].name'
} | sort -u > all_repos.txt

Multi-Forge Status Check

#!/bin/bash
# Check if repo exists on all forges

REPO_NAME="${1:?Usage: check-repo.sh <repo-name>}"

echo "=== GitHub ==="
gh repo view "EvanusModestus/$REPO_NAME" --json name 2>/dev/null && echo "EXISTS" || echo "NOT FOUND"

echo "=== GitLab ==="
glab repo view "EvanusModestus/$REPO_NAME" --output json 2>/dev/null | jq -r '.path' && echo "EXISTS" || echo "NOT FOUND"

echo "=== Gitea ==="
tea repo list --output json | jq -r --arg r "$REPO_NAME" '.[] | select(.name == $r) | .name' | grep -q . && echo "EXISTS" || echo "NOT FOUND"

Create Repo on All Forges

#!/bin/bash
# create-everywhere.sh - Create repo on all forges

REPO_NAME="${1:?Usage: create-everywhere.sh <repo-name>}"

echo "Creating $REPO_NAME on all forges..."

# GitHub
gh repo create "$REPO_NAME" --private --source=. --push 2>/dev/null && echo "✓ GitHub" || echo "✗ GitHub (may exist)"

# GitLab
glab repo create "$REPO_NAME" --private 2>/dev/null && echo "✓ GitLab" || echo "✗ GitLab (may exist)"

# Gitea
tea repo create --name "$REPO_NAME" --private 2>/dev/null && echo "✓ Gitea" || echo "✗ Gitea (may exist)"

# Add remotes
git remote add gitlab "git@gitlab.com:EvanusModestus/$REPO_NAME.git" 2>/dev/null
git remote add gitea "git@gitea:evanusmodestus/$REPO_NAME.git" 2>/dev/null

echo "Remotes configured:"
git remote -v

Export Repo List to JSON

# Unified repo inventory across forges
{
  echo '{"github":'
  gh repo list --limit 200 --json name,visibility,pushedAt
  echo ',"gitlab":'
  glab repo list --output json
  echo ',"gitea":'
  tea repo list --output json
  echo '}'
} | jq '
  {
    github: [.github[] | {name, visibility, lastPush: .pushedAt}],
    gitlab: [.gitlab[] | {name: .path, visibility, lastPush: .last_activity_at}],
    gitea: [.gitea[] | {name, visibility: (if .private then "private" else "public" end), lastPush: .updated_at}]
  }
' > repo_inventory.json

Useful Variables

# Get repo name from current directory
REPO_NAME=$(gh repo view --json name -q '.name')

# Get owner/org
OWNER=$(gh repo view --json owner -q '.owner.login')

# Get default branch
DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name')

# Use in subsequent commands
gh api "repos/$OWNER/$REPO_NAME/branches/$DEFAULT_BRANCH/protection"