#!/usr/bin/env -S npx --yes tsx
// =============================================================================
// list_prs.ts — General Forgejo PR listing script (TypeScript)
//
// Part of: .opencode/skills/auto-agents-system/scripts/
//
// Functional port of list_prs.sh.  Key performance difference: whereas the
// shell script is fully sequential (one curl subprocess per review page, one
// per PR), this version uses concurrent Promise chains so that:
//
//   • PR pages are fetched in parallel batches of PAGE_BATCH_SIZE
//   • All unique base-branch HEAD SHAs are fetched simultaneously
//   • Reviews for all PRs are fetched concurrently (up to REVIEW_CONCURRENCY
//     in-flight at once — respects the API without hammering it)
//
// Zero external npm dependencies: uses only Node.js built-ins and the global
// `fetch` API (stable since Node.js 21, available as unflagged since 18).
//
// USAGE (as CLI):
//   list_prs.ts --url URL --pat TOKEN --owner OWNER --repo REPO [OPTIONS]
//
//   Notable filter options:
//     --stale      not_stale|stale_no_conflicts|stale_with_conflicts|stale_unknown
//     --ci-status  passing|failing|pending|unknown  (comma-separated, any combination)
//
// USAGE (as module, imported by the four wrapper scripts):
//   import { listPRs, parseConnectionArgs } from './list_prs.ts';
// =============================================================================
import { fileURLToPath } from 'node:url';

// ─────────────────────────────────────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────────────────────────────────────

export type StaleState =
  | 'not_stale'             // merge_base == base HEAD; fully up-to-date
  | 'stale_no_conflicts'    // base advanced; mergeable=true  (auto-rebase ok)
  | 'stale_with_conflicts'  // base advanced; mergeable=false (manual resolve)
  | 'stale_unknown'         // base advanced; mergeable=null  (Forgejo pending)
  | 'compute_error';        // missing data or branch API failure

// CIStatus — combined commit-status result for the PR head SHA
//   'passing' — all checks reported success (Forgejo combined state == 'success')
//   'failing' — at least one check failed/errored (state == 'failure'|'error'|'warning')
//   'pending' — checks are queued or still running (state == 'pending')
//   'unknown' — no checks have been reported yet  (state == ''|'unknown')
export type CIStatus = 'passing' | 'failing' | 'pending' | 'unknown';

export interface Config {
  url: string;    // Forgejo base URL, no trailing slash
  pat: string;    // Personal Access Token
  owner: string;  // Repository owner / org
  repo: string;   // Repository name
}

export interface Filters {
  state?: 'open' | 'closed' | 'all';          // default: 'open'
  minApprovals?: number;                       // approvals_count >= N
  maxApprovals?: number;                       // approvals_count <= N
  mergeable?: boolean;                         // filter on PR.mergeable directly
  stale?: StaleState;                          // filter on computed stale_state
  ciStatus?: CIStatus[];                       // whitelist: PR's ci_status must be one of these values
  hasRequestChanges?: boolean;                 // true = at least one active REQUEST_CHANGES review; false = none
  hasUnaddressedRequestChanges?: boolean;      // true = at least one REQUEST_CHANGES not yet followed by a commit; false = none
}

// Raw shape of a Forgejo pull request object (only the fields we use).
// `[key: string]: unknown` preserves all other fields for pass-through output.
interface RawPR {
  number: number;
  title: string;
  head: { sha: string };
  base: { ref: string };
  merge_base?: string | null;
  mergeable: boolean | null;
  [key: string]: unknown;
}

// A PR object augmented with our computed fields.
export interface AugmentedPR extends RawPR {
  approvals_count: number;
  stale_state: StaleState;
  ci_status: CIStatus;
  priority_rank: number;         // 0 = CI Blocker (highest), higher = lower; PRIORITY_NONE if unlabelled
  priority_label: string | null; // matched Priority/* label name, or null if none
}

interface Review {
  id: number;
  user: { login: string } | null;
  state: string;     // 'APPROVED' | 'REQUEST_CHANGES' | 'COMMENT' | …
  dismissed: boolean;
  commit_id?: string; // SHA of the commit the review was submitted against
}

interface BranchInfo {
  commit: { id: string };
}

interface CombinedStatus {
  state: string;  // 'success' | 'pending' | 'failure' | 'error' | 'warning' | '' | 'unknown'
}

// ─────────────────────────────────────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────────────────────────────────────

const PAGE_SIZE          = 50;  // Forgejo max items per page
const PAGE_BATCH_SIZE    = 6;   // Pages fetched in parallel during pagination
const REVIEW_CONCURRENCY = 20;  // Max simultaneous in-flight review-fetch chains

// Priority label ordering — lower rank number = higher priority.
// PRs are sorted ascending by priority_rank so the most urgent appear first.
// Any PR whose labels contain none of these names receives PRIORITY_NONE.
const PRIORITY_ORDER = new Map<string, number>([
  ['Priority/CI Blocker', 0],
  ['Priority/Critical',   1],
  ['Priority/High',       2],
  ['Priority/Medium',     3],
  ['Priority/Low',        4],
  ['Priority/Backlog',    5],
]);
const PRIORITY_NONE = 6; // rank for PRs with no recognised Priority/* label

// ─────────────────────────────────────────────────────────────────────────────
// pLimit — run an array of async tasks with bounded concurrency
//
// Spawns `limit` worker coroutines that each pull the next available task from
// the shared queue.  Results are returned in input order.
//
// Pure JavaScript — no external dependencies.
// Safe: JS is single-threaded so `next++` has no data-race.
// ─────────────────────────────────────────────────────────────────────────────
async function pLimit<T>(
  tasks: ReadonlyArray<() => Promise<T>>,
  limit: number,
): Promise<T[]> {
  const results: T[] = new Array(tasks.length);
  let next = 0;

  async function worker(): Promise<void> {
    while (next < tasks.length) {
      const i = next++;
      results[i] = await tasks[i]();
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(limit, tasks.length) }, worker),
  );
  return results;
}

// ─────────────────────────────────────────────────────────────────────────────
// apiGet — single authenticated GET request; throws on non-2xx
// ─────────────────────────────────────────────────────────────────────────────
async function apiGet<T>(url: string, pat: string): Promise<T> {
  const res = await fetch(url, {
    headers: { Authorization: `token ${pat}` },
    signal: AbortSignal.timeout(120_000),
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({})) as { message?: string };
    throw new Error(`HTTP ${res.status}: ${body.message ?? 'unknown error'} — ${url}`);
  }
  return res.json() as Promise<T>;
}

// ─────────────────────────────────────────────────────────────────────────────
// fetchAllPages — paginate a list endpoint, fetching batches of pages in
//                parallel to minimise wall-clock time
//
// Strategy:
//   1. Fetch page 1 synchronously (lets us short-circuit single-page results).
//   2. If page 1 was full, fetch PAGE_BATCH_SIZE subsequent pages at once,
//      then the next batch, and so on, until a partial page is found.
//   3. API errors for any page in a batch are treated as empty pages so one
//      transient failure does not abort the whole fetch.
// ─────────────────────────────────────────────────────────────────────────────
async function fetchAllPages<T>(
  baseUrl: string,
  pat: string,
  extraParams: Record<string, string> = {},
): Promise<T[]> {
  const pageUrl = (page: number): string =>
    `${baseUrl}?${new URLSearchParams({
      ...extraParams,
      limit: String(PAGE_SIZE),
      page: String(page),
    })}`;

  // Bootstrap with page 1
  const firstPage = await apiGet<T[]>(pageUrl(1), pat);
  if (firstPage.length < PAGE_SIZE) return firstPage;

  const all: T[] = [...firstPage];
  let startPage = 2;

  for (;;) {
    const batch = await Promise.all(
      Array.from({ length: PAGE_BATCH_SIZE }, (_, i) =>
        apiGet<T[]>(pageUrl(startPage + i), pat).catch((): T[] => []),
      ),
    );

    let done = false;
    for (const page of batch) {
      all.push(...page);
      if (page.length < PAGE_SIZE) { done = true; break; }
    }
    if (done) break;
    startPage += PAGE_BATCH_SIZE;
  }

  return all;
}

// ─────────────────────────────────────────────────────────────────────────────
// prPriority — extract the highest-priority Priority/* label from a PR
//
// Scans pr.labels (passed through as unknown from the Forgejo response) and
// returns the rank and name of the highest-priority label found.  If no
// recognised Priority/* label is present, returns PRIORITY_NONE / null.
// ─────────────────────────────────────────────────────────────────────────────
function prPriority(pr: RawPR): { rank: number; label: string | null } {
  const labels = pr.labels;
  if (!Array.isArray(labels)) return { rank: PRIORITY_NONE, label: null };
  let bestRank  = PRIORITY_NONE;
  let bestLabel: string | null = null;
  for (const lbl of labels) {
    if (typeof lbl !== 'object' || lbl === null) continue;
    const name = (lbl as Record<string, unknown>).name;
    if (typeof name !== 'string') continue;
    const rank = PRIORITY_ORDER.get(name);
    if (rank !== undefined && rank < bestRank) {
      bestRank  = rank;
      bestLabel = name;
    }
  }
  return { rank: bestRank, label: bestLabel };
}

// ─────────────────────────────────────────────────────────────────────────────
// countApprovals — distinct non-dismissed APPROVED review count for a PR
//
// Algorithm (mirrors list_prs.sh / jq logic exactly):
//   1. Discard reviews where user is null or dismissed == true.
//   2. Group by reviewer login; keep only the review with the highest ID per
//      user (their most recently submitted review supersedes earlier ones).
//   3. Count those whose final state is "APPROVED".
//
// A reviewer who later submits REQUEST_CHANGES after an earlier APPROVED is
// NOT counted (the later review supersedes the approval).
// ─────────────────────────────────────────────────────────────────────────────
function countApprovals(reviews: Review[]): number {
  const byLogin = new Map<string, Review>();

  for (const r of reviews) {
    if (r.user == null || r.dismissed) continue;
    const existing = byLogin.get(r.user.login);
    if (existing === undefined || r.id > existing.id) {
      byLogin.set(r.user.login, r);
    }
  }

  let count = 0;
  for (const r of byLogin.values()) {
    if (r.state === 'APPROVED') count++;
  }
  return count;
}

// ─────────────────────────────────────────────────────────────────────────────
// hasActiveRequestChanges — true if any reviewer's latest non-dismissed review
//                           is REQUEST_CHANGES
//
// Algorithm mirrors countApprovals exactly, but checks for REQUEST_CHANGES
// instead of APPROVED:
//   1. Discard reviews where user is null or dismissed == true.
//   2. Group by reviewer login; keep only the review with the highest ID per
//      user (their most recent review supersedes earlier ones).
//   3. Return true if any of those reviews have state "REQUEST_CHANGES".
// ─────────────────────────────────────────────────────────────────────────────
function hasActiveRequestChanges(reviews: Review[]): boolean {
  const byLogin = new Map<string, Review>();

  for (const r of reviews) {
    if (r.user == null || r.dismissed) continue;
    const existing = byLogin.get(r.user.login);
    if (existing === undefined || r.id > existing.id) {
      byLogin.set(r.user.login, r);
    }
  }

  for (const r of byLogin.values()) {
    if (r.state === 'REQUEST_CHANGES') return true;
  }
  return false;
}

// ─────────────────────────────────────────────────────────────────────────────
// hasUnaddressedRequestChangesCheck — true if any reviewer's latest
//                                     non-dismissed REQUEST_CHANGES review
//                                     has NOT been followed by new commits
//
// A REQUEST_CHANGES review is considered "unaddressed" when the commit it was
// submitted against (`review.commit_id`) is still the PR's current HEAD SHA —
// meaning no commits were pushed to the branch after the review was submitted.
//
// Algorithm:
//   1. Discard reviews where user is null or dismissed == true.
//   2. Group by reviewer login; keep only the review with the highest ID per
//      user (their most recently submitted review supersedes earlier ones).
//   3. For each reviewer whose final state is "REQUEST_CHANGES":
//        • If review.commit_id === headSHA  → the review targets current HEAD;
//          no commits pushed since → UNADDRESSED: return true.
//        • If review.commit_id differs     → commits exist after the review;
//          the author has addressed (or attempted to address) the feedback.
//        • If review.commit_id is absent   → cannot determine ordering;
//          treat conservatively as unaddressed: return true.
//   4. Return false if every REQUEST_CHANGES review is addressed.
// ─────────────────────────────────────────────────────────────────────────────
function hasUnaddressedRequestChangesCheck(reviews: Review[], headSHA: string): boolean {
  const byLogin = new Map<string, Review>();

  for (const r of reviews) {
    if (r.user == null || r.dismissed) continue;
    const existing = byLogin.get(r.user.login);
    if (existing === undefined || r.id > existing.id) {
      byLogin.set(r.user.login, r);
    }
  }

  for (const r of byLogin.values()) {
    if (r.state !== 'REQUEST_CHANGES') continue;
    // Unaddressed when: no commit_id info OR commit_id still matches HEAD
    if (!r.commit_id || r.commit_id === headSHA) return true;
  }
  return false;
}

// ─────────────────────────────────────────────────────────────────────────────
// computeStaleState — classify a PR's staleness relative to its base branch
//
// Logic mirrors compute_stale_state() in list_prs.sh:
//   merge_base == base HEAD  →  not_stale         (PR is fully up-to-date)
//   merge_base != base HEAD  →  stale; sub-class by PR.mergeable:
//     true   → stale_no_conflicts   (clean auto-rebase possible)
//     false  → stale_with_conflicts (manual resolution needed)
//     null   → stale_unknown        (Forgejo hasn't computed it yet)
// ─────────────────────────────────────────────────────────────────────────────
function computeStaleState(
  mergeBase: string | null | undefined,
  baseHead: string | null,
  mergeable: boolean | null,
): StaleState {
  if (!mergeBase || !baseHead) return 'compute_error';
  if (mergeBase === baseHead)  return 'not_stale';
  if (mergeable === true)      return 'stale_no_conflicts';
  if (mergeable === false)     return 'stale_with_conflicts';
  return 'stale_unknown';
}

// ─────────────────────────────────────────────────────────────────────────────
// mapCIState — translate Forgejo's combined commit-status state string into
//              our canonical CIStatus enum value
// ─────────────────────────────────────────────────────────────────────────────
function mapCIState(state: string): CIStatus {
  if (state === 'success')                                               return 'passing';
  if (state === 'pending')                                               return 'pending';
  if (state === 'failure' || state === 'error' || state === 'warning')  return 'failing';
  return 'unknown';  // '' | 'unknown' | anything else → no checks reported
}

// ─────────────────────────────────────────────────────────────────────────────
// listPRs — fetch, enrich, filter, and return matching PRs
//
// Exported so the four wrapper scripts can call it with their hard-coded filters.
// ─────────────────────────────────────────────────────────────────────────────
export async function listPRs(
  config: Config,
  filters: Filters,
): Promise<AugmentedPR[]> {
  const baseUrl    = config.url.replace(/\/$/, '');
  const baseApiUrl = `${baseUrl}/api/v1`;
  const repoBase   = `${baseApiUrl}/repos/${config.owner}/${config.repo}`;
  const stateParam = filters.state ?? 'open';

  const log = (msg: string): void => process.stderr.write(msg + '\n');

  // ── Step 1: Fetch all PRs (parallel page batches) ─────────────────────────
  log(`Fetching PRs from ${baseUrl}/${config.owner}/${config.repo} (state=${stateParam})...`);

  const rawPRs = await fetchAllPages<RawPR>(
    `${repoBase}/pulls`,
    config.pat,
    { state: stateParam },
  );

  log(`Fetched ${rawPRs.length} PR(s).  Enriching and filtering...`);

  // ── Step 2: Fetch all unique base-branch HEAD SHAs in parallel ────────────
  // Cache: one API call per unique base branch regardless of how many PRs
  // target it — mirrors the _BRANCH_HEAD_CACHE in list_prs.sh.
  const uniqueBranches = [
    ...new Set(
      rawPRs.map(pr => pr.base?.ref).filter((b): b is string => Boolean(b)),
    ),
  ];

  log(`Fetching HEAD SHAs for ${uniqueBranches.length} unique base branch(es)...`);

  const branchHeads = await Promise.all(
    uniqueBranches.map(async (branch): Promise<[string, string | null]> => {
      try {
        const info = await apiGet<BranchInfo>(
          `${repoBase}/branches/${encodeURIComponent(branch)}`,
          config.pat,
        );
        return [branch, info.commit?.id ?? null];
      } catch {
        log(`WARNING: Could not fetch HEAD for branch '${branch}'; stale state will be compute_error`);
        return [branch, null];
      }
    }),
  );

  const branchCache = new Map<string, string | null>(branchHeads);

  // ── Step 3: Fetch reviews for every PR concurrently ───────────────────────
  // pLimit keeps at most REVIEW_CONCURRENCY fetch chains in-flight at once.
  // With 20 concurrent chains each taking ~100 ms, 326 PRs takes ~1.7 s
  // instead of ~230 s sequentially.
  log(`Fetching reviews for ${rawPRs.length} PR(s) concurrently (limit=${REVIEW_CONCURRENCY})...`);

  const allReviews = await pLimit<Review[]>(
    rawPRs.map(pr => async () => {
      try {
        return await fetchAllPages<Review>(
          `${repoBase}/pulls/${pr.number}/reviews`,
          config.pat,
        );
      } catch {
        log(`WARNING: Could not fetch reviews for PR #${pr.number}; treating as 0 approvals`);
        return [];
      }
    }),
    REVIEW_CONCURRENCY,
  );

  // ── Step 4: Fetch CI (combined commit-status) for every PR concurrently ───
  // Uses GET /api/v1/repos/{owner}/{repo}/commits/{sha}/status which returns
  // the aggregated combined state for all status checks on the head commit.
  log(`Fetching CI status for ${rawPRs.length} PR(s) concurrently (limit=${REVIEW_CONCURRENCY})...`);

  const allCIStatuses = await pLimit<CIStatus>(
    rawPRs.map(pr => async () => {
      try {
        const combined = await apiGet<CombinedStatus>(
          `${repoBase}/commits/${encodeURIComponent(pr.head.sha)}/status`,
          config.pat,
        );
        return mapCIState(combined.state ?? '');
      } catch {
        log(`WARNING: Could not fetch CI status for PR #${pr.number}; treating as 'unknown'`);
        return 'unknown' as CIStatus;
      }
    }),
    REVIEW_CONCURRENCY,
  );

  // ── Step 5: Compute fields, apply filters, collect results ────────────────
  const results: AugmentedPR[] = [];

  for (let i = 0; i < rawPRs.length; i++) {
    const pr             = rawPRs[i];
    const approvalsCount = countApprovals(allReviews[i]);
    const ciStatus       = allCIStatuses[i];
    const baseHead       = branchCache.get(pr.base?.ref ?? '') ?? null;
    const staleState     = computeStaleState(
      pr.merge_base ?? null,
      baseHead,
      pr.mergeable ?? null,
    );

    // Apply each filter — continue (skip) if the PR doesn't match
    if (filters.minApprovals !== undefined && approvalsCount < filters.minApprovals) continue;
    if (filters.maxApprovals !== undefined && approvalsCount > filters.maxApprovals) continue;
    if (filters.mergeable    !== undefined && pr.mergeable   !== filters.mergeable)   continue;
    if (filters.stale        !== undefined && staleState     !== filters.stale)       continue;
    if (filters.ciStatus     !== undefined && !filters.ciStatus.includes(ciStatus))   continue;
    if (filters.hasRequestChanges !== undefined &&
        hasActiveRequestChanges(allReviews[i]) !== filters.hasRequestChanges)         continue;
    if (filters.hasUnaddressedRequestChanges !== undefined &&
        hasUnaddressedRequestChangesCheck(allReviews[i], pr.head.sha) !== filters.hasUnaddressedRequestChanges) continue;

    const { rank: priorityRank, label: priorityLabel } = prPriority(pr);
    results.push({
      ...pr,
      approvals_count: approvalsCount,
      stale_state:     staleState,
      ci_status:       ciStatus,
      priority_rank:   priorityRank,
      priority_label:  priorityLabel,
    });
  }

  // Sort ascending by priority_rank so the most urgent PRs appear first.
  // PRs at the same rank retain their original API order (stable sort).
  results.sort((a, b) => a.priority_rank - b.priority_rank);

  log('');
  log(`Result: ${results.length}/${rawPRs.length} PR(s) matched the filter criteria.`);
  return results;
}

// ─────────────────────────────────────────────────────────────────────────────
// parseConnectionArgs — parse only the four connection flags
//
// Used by the four thin wrapper scripts which hard-code their filter args.
// Rejects any unknown flag with an informative error.
// ─────────────────────────────────────────────────────────────────────────────
export function parseConnectionArgs(argv: string[]): Config {
  const args = argv.slice(2);
  const config: Partial<Config> = {};

  for (let i = 0; i < args.length; i++) {
    const key = args[i];
    const val = args[i + 1];
    switch (key) {
      case '--url':   config.url   = val; i++; break;
      case '--pat':   config.pat   = val; i++; break;
      case '--owner': config.owner = val; i++; break;
      case '--repo':  config.repo  = val; i++; break;
      case '-h':
      case '--help':
        process.stderr.write(
          'Usage: <script> --url URL --pat TOKEN --owner OWNER --repo REPO\n' +
          '  Filter arguments are hard-coded in each wrapper script.\n',
        );
        process.exit(0);
        break;
      default:
        process.stderr.write(
          `ERROR: Unknown argument: ${key}\n` +
          '       Filter parameters are hard-coded. Only --url, --pat, --owner, --repo are accepted.\n',
        );
        process.exit(1);
    }
  }

  if (!config.url)   { process.stderr.write('ERROR: --url is required\n');   process.exit(1); }
  if (!config.pat)   { process.stderr.write('ERROR: --pat is required\n');   process.exit(1); }
  if (!config.owner) { process.stderr.write('ERROR: --owner is required\n'); process.exit(1); }
  if (!config.repo)  { process.stderr.write('ERROR: --repo is required\n');  process.exit(1); }

  return config as Config;
}

// ─────────────────────────────────────────────────────────────────────────────
// parseFullArgs — parse all flags for the full CLI (list_prs.ts run directly)
// ─────────────────────────────────────────────────────────────────────────────
function parseFullArgs(argv: string[]): { config: Config; filters: Filters } {
  const args = argv.slice(2);
  const config: Partial<Config> = {};
  const filters: Filters = {};

  for (let i = 0; i < args.length; i++) {
    const key = args[i];
    const val = args[i + 1];
    switch (key) {
      case '--url':           config.url           = val;                          i++; break;
      case '--pat':           config.pat           = val;                          i++; break;
      case '--owner':         config.owner         = val;                          i++; break;
      case '--repo':          config.repo          = val;                          i++; break;
      case '--state':         filters.state        = val as Filters['state'];                              i++; break;
      case '--min-approvals': filters.minApprovals = Number(val);                                          i++; break;
      case '--max-approvals': filters.maxApprovals = Number(val);                                          i++; break;
      case '--mergeable':     filters.mergeable    = val === 'true';                                       i++; break;
      case '--stale':         filters.stale        = val as StaleState;                                    i++; break;
      case '--ci-status':           filters.ciStatus          = val.split(',').map(s => s.trim()) as CIStatus[];  i++; break;
      case '--has-request-changes':           filters.hasRequestChanges           = val === 'true'; i++; break;
      case '--has-unaddressed-request-changes': filters.hasUnaddressedRequestChanges = val === 'true'; i++; break;
      case '-h':
      case '--help':
        printUsage();
        process.exit(0);
        break;
      default:
        process.stderr.write(`ERROR: Unknown argument: ${key}\n`);
        printUsage();
        process.exit(1);
    }
  }

  if (!config.url)   { process.stderr.write('ERROR: --url is required\n');   process.exit(1); }
  if (!config.pat)   { process.stderr.write('ERROR: --pat is required\n');   process.exit(1); }
  if (!config.owner) { process.stderr.write('ERROR: --owner is required\n'); process.exit(1); }
  if (!config.repo)  { process.stderr.write('ERROR: --repo is required\n');  process.exit(1); }

  return { config: config as Config, filters };
}

function printUsage(): void {
  process.stderr.write(`\
Usage:
  list_prs.ts --url URL --pat TOKEN --owner OWNER --repo REPO [OPTIONS]
              [--ci-status passing|failing|pending|unknown[,...]]

REQUIRED:
  --url    URL     Forgejo base URL, no trailing slash (e.g. https://git.example.com)
  --pat    TOKEN   Personal Access Token
  --owner  OWNER   Repository owner or organisation name
  --repo   REPO    Repository name

OPTIONAL FILTERS (all default to no filter):
  --state  open|closed|all       PR state (default: open)
  --min-approvals  N             Only PRs with >= N approvals
  --max-approvals  N             Only PRs with <= N approvals
  --mergeable  true|false        Filter on PR.mergeable field
  --stale  not_stale|stale_no_conflicts|stale_with_conflicts|stale_unknown
                                 Filter on computed staleness state
  --ci-status  passing|failing|pending|unknown[,...]
                                 Comma-separated whitelist of CI statuses to include.
                                 passing  = all checks succeeded (Forgejo state: success)
                                 failing  = at least one check failed/errored (failure|error|warning)
                                 pending  = checks are queued or running (state: pending)
                                 unknown  = no checks reported yet (state: ''|unknown)
                                 Multiple values: --ci-status passing,pending
                                 Omit flag entirely to include all CI statuses.
  --has-request-changes  true|false
                                 true  = only PRs where at least one reviewer's latest
                                         non-dismissed review is REQUEST_CHANGES
                                 false = only PRs with no active REQUEST_CHANGES reviews
                                 Omit flag entirely to include all PRs regardless.
  --has-unaddressed-request-changes  true|false
                                 true  = only PRs where at least one REQUEST_CHANGES review
                                         has NOT been followed by new commits (the reviewer
                                         asked for changes and the author has not yet pushed
                                         any code in response — review.commit_id still matches
                                         the PR's current head SHA)
                                 false = only PRs where every REQUEST_CHANGES review has been
                                         followed by at least one new commit (changes addressed)
                                 Omit flag entirely to include all PRs regardless.

OUTPUT (stdout): JSON array sorted by priority (highest first); each object is
the full Forgejo PR plus:
  .approvals_count  integer  distinct non-dismissed APPROVED review count
  .stale_state      string   one of the stale states listed above
  .ci_status        string   passing|failing|pending|unknown (see --ci-status above)
  .priority_rank    integer  0=CI Blocker, 1=Critical, 2=High, 3=Medium,
                             4=Low, 5=Backlog, 6=none (unlabelled)
  .priority_label   string|null  matched Priority/* label name, or null
`);
}

// ─────────────────────────────────────────────────────────────────────────────
// CLI entry point — only executes when this file is the main script
// ─────────────────────────────────────────────────────────────────────────────
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const { config, filters } = parseFullArgs(process.argv);
  listPRs(config, filters)
    .then(prs => { process.stdout.write(JSON.stringify(prs, null, 2) + '\n'); })
    .catch((err: unknown) => {
      process.stderr.write(`ERROR: ${err instanceof Error ? err.message : String(err)}\n`);
      process.exit(1);
    });
}
