#!/usr/bin/env -S npx --yes tsx
// =============================================================================
// list_issues.ts — Open Forgejo issue lister (TypeScript)
//
// Part of: .opencode/skills/auto-agents-system/scripts/
//
// Fetches all OPEN issues from a Forgejo repository, filters out any that carry
// the "State/In Progress" label, and outputs them sorted by:
//
//   1. Milestone due date — ascending (oldest due date first):
//        earliest due_on first; issues with no milestone or no due date sort last
//   2. Priority label — ascending rank within each milestone tier (highest priority first):
//        Priority/CI Blocker (0) → Critical (1) → High (2) → Medium (3)
//        → Low (4) → Backlog (5) → unlabelled (6)
//
// 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).
// Pages are fetched in parallel batches of PAGE_BATCH_SIZE to minimise
// wall-clock time.
//
// USAGE:
//   list_issues.ts --url URL --pat TOKEN --owner OWNER --repo REPO
//
// OUTPUT (stdout): JSON array sorted as described above; each object is the
//   full Forgejo issue response plus:
//     .priority_rank       integer  0=CI Blocker … 6=unlabelled
//     .priority_label      string|null  matched Priority/* label, or null
//     .milestone_due_sort  number   Unix ms timestamp for sort; MAX_SAFE_INTEGER
//                                   when no milestone or no due_on
//
// DEPENDENCIES: Node.js >= 18 (native fetch), npx tsx (TypeScript execution)
// =============================================================================
import { fileURLToPath } from 'node:url';

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

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
}

// Raw shape of a Forgejo issue (only the fields we use; [key: string]: unknown
// preserves all other fields for pass-through to the JSON output).
interface RawIssue {
  number: number;
  title: string;
  labels: unknown;    // Array<{ name: string; ... }> per Forgejo response
  milestone: unknown; // { id, title, due_on: string|null } | null
  [key: string]: unknown;
}

// An issue augmented with our computed sort/display fields.
export interface AugmentedIssue extends RawIssue {
  priority_rank: number;          // 0 = CI Blocker (highest); 6 = unlabelled (lowest)
  priority_label: string | null;  // Matched Priority/* label name, or null
  milestone_due_sort: number;     // Unix ms timestamp; Number.MAX_SAFE_INTEGER if no due date
}

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

const PAGE_SIZE       = 50;  // Forgejo max items per page
const PAGE_BATCH_SIZE = 6;   // Pages fetched in parallel during pagination

// Priority label ordering — lower rank number = higher priority.
// Issues are sorted ascending by priority_rank so the most urgent appear first.
// Issues with none of these labels receive 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;

// Issues carrying this label are excluded from all output.
const IN_PROGRESS_LABEL = 'State/In Progress';

// ─────────────────────────────────────────────────────────────────────────────
// 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 (short-circuits single-page results).
//   2. If page 1 was full, fetch PAGE_BATCH_SIZE subsequent pages at once,
//      then the next batch, etc., until a partial page is found.
//   3. API errors for any page in a batch are treated as empty pages.
// ─────────────────────────────────────────────────────────────────────────────
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),
    })}`;

  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;
}

// ─────────────────────────────────────────────────────────────────────────────
// isInProgress — true if the issue carries the State/In Progress label
// ─────────────────────────────────────────────────────────────────────────────
function isInProgress(issue: RawIssue): boolean {
  if (!Array.isArray(issue.labels)) return false;
  return issue.labels.some(
    l => typeof l === 'object' && l !== null &&
         (l as Record<string, unknown>).name === IN_PROGRESS_LABEL,
  );
}

// ─────────────────────────────────────────────────────────────────────────────
// issuePriority — extract the highest-priority Priority/* label from an issue
//
// Scans issue.labels and returns the rank and name of the highest-priority
// label found. If no recognised Priority/* label is present, returns
// PRIORITY_NONE / null.
// ─────────────────────────────────────────────────────────────────────────────
function issuePriority(issue: RawIssue): { rank: number; label: string | null } {
  if (!Array.isArray(issue.labels)) return { rank: PRIORITY_NONE, label: null };
  let bestRank  = PRIORITY_NONE;
  let bestLabel: string | null = null;
  for (const lbl of issue.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 };
}

// ─────────────────────────────────────────────────────────────────────────────
// milestoneSortKey — numeric sort key for a milestone's due date
//
// Returns the Unix timestamp (ms) of the milestone's due_on date.
// Returns Number.MAX_SAFE_INTEGER when the issue has no milestone or the
// milestone has no due_on set — pushing those issues to the end of each
// priority tier in the final output.
// ─────────────────────────────────────────────────────────────────────────────
function milestoneSortKey(issue: RawIssue): number {
  const ms = issue.milestone as { due_on?: string | null } | null;
  if (ms == null || !ms.due_on) return Number.MAX_SAFE_INTEGER;
  const ts = Date.parse(ms.due_on);
  return Number.isNaN(ts) ? Number.MAX_SAFE_INTEGER : ts;
}

// ─────────────────────────────────────────────────────────────────────────────
// listIssues — fetch, filter, augment, sort, and return open issues
//
// Exported so callers can import this function and use it as a module.
// ─────────────────────────────────────────────────────────────────────────────
export async function listIssues(config: Config): Promise<AugmentedIssue[]> {
  const baseUrl  = config.url.replace(/\/$/, '');
  const repoBase = `${baseUrl}/api/v1/repos/${config.owner}/${config.repo}`;
  const log      = (msg: string): void => process.stderr.write(msg + '\n');

  // ── Step 1: Fetch all open issues in parallel page batches ────────────────
  log(`Fetching open issues from ${baseUrl}/${config.owner}/${config.repo}...`);

  const rawIssues = await fetchAllPages<RawIssue>(
    `${repoBase}/issues`,
    config.pat,
    { state: 'open', type: 'issues' },
  );

  log(`Fetched ${rawIssues.length} open issue(s).  Filtering and sorting...`);

  // ── Step 2: Filter out State/In Progress, augment, collect ────────────────
  const results: AugmentedIssue[] = [];

  for (const issue of rawIssues) {
    if (isInProgress(issue)) continue;

    const { rank: priorityRank, label: priorityLabel } = issuePriority(issue);
    const milestoneDueSort = milestoneSortKey(issue);

    results.push({
      ...issue,
      priority_rank:      priorityRank,
      priority_label:     priorityLabel,
      milestone_due_sort: milestoneDueSort,
    });
  }

  // ── Step 3: Sort — primary: milestone ASC; secondary: priority_rank ASC ───
  // Primary: oldest milestone due date first; issues with no milestone / no
  // due_on sort last (milestone_due_sort == Number.MAX_SAFE_INTEGER).
  // Secondary: within each milestone tier, highest priority first (rank ASC).
  // JavaScript's Array.sort is stable — equal-key issues retain their original
  // API (creation-descending) order within each sort group.
  results.sort((a, b) => {
    if (a.milestone_due_sort !== b.milestone_due_sort) return a.milestone_due_sort - b.milestone_due_sort;
    return a.priority_rank - b.priority_rank;
  });

  log('');
  log(`Result: ${results.length}/${rawIssues.length} issue(s) after filtering "${IN_PROGRESS_LABEL}".`);
  return results;
}

// ─────────────────────────────────────────────────────────────────────────────
// parseConnectionArgs — parse the four required connection flags
//
// Rejects any unknown flag with an informative error, consistent with the
// other list_* wrapper scripts in this skills directory.
// ─────────────────────────────────────────────────────────────────────────────
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: list_issues.ts --url URL --pat TOKEN --owner OWNER --repo REPO\n',
        );
        process.exit(0);
        break;
      default:
        process.stderr.write(
          `ERROR: Unknown argument: ${key}\n` +
          '       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;
}

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