#!/usr/bin/env -S npx --yes tsx
// =============================================================================
// merge_pr.ts — Initiate a rebase-style merge (with automerge) on a PR
//
// Part of: .opencode/skills/auto-agents-system/scripts/
//
// Calls the Forgejo REST API to merge a pull request using the 'rebase' merge
// style with merge_when_checks_succeed=true.  If all checks and reviews are
// already satisfied, the PR merges immediately; otherwise Forgejo queues it
// for automatic merging once those conditions are met.
//
// ── Dependency handling ──────────────────────────────────────────────────────
//
// Forgejo SILENTLY ignores a merge request (returns 200 but does nothing) when
// the PR has open issue dependencies.  This script detects open blocking
// dependencies up-front via the dependencies API and handles them according to
// the --dep argument:
//
//   error   (default) — abort with a descriptive error listing every blocker
//   delete            — delete each open dependency link, then proceed
//   reverse           — flip each open dependency link (PR depends on X →
//                       X now depends on PR), then proceed
//
// ── Auto-merge state ─────────────────────────────────────────────────────────
//
// Forgejo provides no read-only endpoint for "is auto-merge scheduled?".
// After the merge call returns 200/204 and the PR is not yet merged, this
// script simply reports status="auto_merge_scheduled" — it is the expected
// outcome when checks or approvals are still pending.  This is not an error.
//
// ── Error policy ─────────────────────────────────────────────────────────────
//
// The script NEVER silently fails.  Every non-2xx API response from the merge
// endpoint and every unhandled open dependency is surfaced as an explicit
// error on stderr and exit code 1.  The only non-error path where the PR is
// not immediately merged is status="auto_merge_scheduled".
//
// USAGE:
//   merge_pr.ts --url URL --pat TOKEN --owner OWNER --repo REPO --pr NUMBER
//               [--dep error|delete|reverse]
//
// OUTPUT (stdout, on success): JSON object:
//   {
//     "status": "merged" | "auto_merge_scheduled",
//     "pr_number": N,
//     "pr_title": "...",
//     "head_sha": "...",
//     "merge_commit_sha": "<sha>" | null,   // non-null only when status=merged
//     "merged_at": "<iso>" | null,           // non-null only when status=merged
//     "deps_handled": [                      // list of dependency actions taken
//       { "action": "deleted" | "reversed",
//         "dep_number": N, "dep_title": "...", "dep_repo": "owner/repo" }
//     ],
//     "message": "..."
//   }
//
// EXIT CODES:
//   0 — merge initiated (status="merged" or status="auto_merge_scheduled")
//   1 — any failure (see stderr for details and error code)
//
// ERROR CODES (in the error message):
//   PR_MERGED           — PR is already merged
//   PR_NOT_OPEN         — PR is closed
//   OPEN_DEPENDENCIES   — open deps found and --dep=error (default)
//   DEP_DELETE_FAILED   — DELETE of a dependency link failed
//   DEP_REVERSE_FAILED  — POST of the reversed dependency failed
//   MERGE_FORBIDDEN     — HTTP 403 from merge endpoint
//   MERGE_NOT_ALLOWED   — HTTP 405 (branch protection / merge style disabled)
//   MERGE_CONFLICT      — HTTP 409 from merge endpoint (conflicts or HEAD mismatch)
//   MERGE_UNPROCESSABLE — HTTP 422 (already merged, draft PR, etc.)
//   HTTP_NNN            — any other unexpected HTTP response from merge
//
// DEPENDENCIES: Node.js >= 18 (native fetch), npx tsx (TypeScript execution)
// =============================================================================
import { fileURLToPath } from 'node:url';

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

export type DepStrategy = 'error' | 'delete' | 'reverse';

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
  prNumber: number;     // Pull request number to merge
  depStrategy: DepStrategy; // How to handle open blocking dependencies
}

// Raw Forgejo PR object — only the fields this script reads.
interface RawPR {
  number: number;
  title: string;
  state: string;              // 'open' | 'closed'
  merged: boolean;
  mergeable: boolean | null;
  merge_commit_sha: string | null;
  merged_at: string | null;
  merged_by: { login: string } | null;
  head: { ref: string; sha: string };
  base: { ref: string; sha: string };
  [key: string]: unknown;
}

// Raw Forgejo issue (as returned by the dependency list endpoint).
interface RawIssue {
  number: number;
  title: string;
  state: string;   // 'open' | 'closed'
  repository?: { full_name: string };  // "<owner>/<repo>" — may be absent for same-repo
  [key: string]: unknown;
}

// Summary of one dependency that was handled.
interface DepHandled {
  action: 'deleted' | 'reversed';
  dep_number: number;
  dep_title: string;
  dep_repo: string;   // "<owner>/<repo>"
}

export interface MergeResult {
  status: 'merged' | 'auto_merge_scheduled';
  pr_number: number;
  pr_title: string;
  head_sha: string;
  merge_commit_sha: string | null;  // non-null only when status === 'merged'
  merged_at: string | null;         // non-null only when status === 'merged'
  deps_handled: DepHandled[];
  message: string;
}

interface MergeBody {
  Do: 'rebase';
  merge_when_checks_succeed: true;
  head_commit_id: string;
}

// ─────────────────────────────────────────────────────────────────────────────
// MergeError — typed error with a machine-readable code
// ─────────────────────────────────────────────────────────────────────────────

export class MergeError extends Error {
  constructor(
    message: string,
    public readonly code: string,
  ) {
    super(message);
    this.name = 'MergeError';
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Low-level HTTP helpers
// ─────────────────────────────────────────────────────────────────────────────

async function apiGet<T>(url: string, pat: string): Promise<T> {
  const res = await fetch(url, {
    headers: { Authorization: `token ${pat}` },
    signal: AbortSignal.timeout(30_000),
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({})) as { message?: string };
    throw new MergeError(
      `HTTP ${res.status}: ${body.message ?? 'unknown error'} — GET ${url}`,
      `HTTP_${res.status}`,
    );
  }
  return res.json() as Promise<T>;
}

async function apiPost(
  url: string,
  pat: string,
  body?: unknown,
): Promise<{ status: number; body: unknown }> {
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      Authorization: `token ${pat}`,
      ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body !== undefined ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(60_000),
  });
  const resBody = await res.json().catch((): null => null);
  return { status: res.status, body: resBody };
}

async function apiDelete(
  url: string,
  pat: string,
  body?: unknown,
): Promise<{ status: number; body: unknown }> {
  const res = await fetch(url, {
    method: 'DELETE',
    headers: {
      Authorization: `token ${pat}`,
      ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body !== undefined ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(30_000),
  });
  const resBody = await res.json().catch((): null => null);
  return { status: res.status, body: resBody };
}

// Note: apiDelete is used only for dependency removal, not for automerge probing.

// ─────────────────────────────────────────────────────────────────────────────
// fetchAllPages — paginate any list endpoint exhaustively
// ─────────────────────────────────────────────────────────────────────────────

const PAGE_SIZE = 50;

async function fetchAllPages<T>(baseUrl: string, pat: string): Promise<T[]> {
  const pageUrl = (page: number): string =>
    `${baseUrl}?${new URLSearchParams({ 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 p = 2;
  for (;;) {
    const page = await apiGet<T[]>(pageUrl(p), pat);
    all.push(...page);
    if (page.length < PAGE_SIZE) break;
    p++;
  }
  return all;
}

// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────

function bodyMessage(body: unknown): string {
  if (typeof body === 'object' && body !== null && 'message' in body) {
    return String((body as Record<string, unknown>).message);
  }
  return body !== null ? JSON.stringify(body) : '';
}

// Split "owner/repo" into its two parts; falls back to the PR's own repo when
// the dependency issue does not carry a repository field (same-repo case).
function depOwnerRepo(
  dep: RawIssue,
  fallbackOwner: string,
  fallbackRepo: string,
): { owner: string; repo: string } {
  const full = dep.repository?.full_name;
  if (full) {
    const slash = full.indexOf('/');
    if (slash > 0) {
      return { owner: full.slice(0, slash), repo: full.slice(slash + 1) };
    }
  }
  return { owner: fallbackOwner, repo: fallbackRepo };
}

// ─────────────────────────────────────────────────────────────────────────────
// mergePR — the main orchestration function
// ─────────────────────────────────────────────────────────────────────────────

export async function mergePR(config: Config): Promise<MergeResult> {
  const baseUrl    = config.url.replace(/\/$/, '');
  const apiBase    = `${baseUrl}/api/v1`;
  const repoBase   = `${apiBase}/repos/${config.owner}/${config.repo}`;
  const prIndex    = config.prNumber;
  const log        = (msg: string): void => process.stderr.write(msg + '\n');

  const depsHandled: DepHandled[] = [];

  // ── Step 1: Fetch PR and guard open + not merged ───────────────────────────
  log(`Fetching PR #${prIndex} from ${baseUrl}/${config.owner}/${config.repo}...`);

  const pr = await apiGet<RawPR>(`${repoBase}/pulls/${prIndex}`, config.pat);

  log(`PR #${pr.number}: "${pr.title}"`);
  log(`  State:     ${pr.state}${pr.merged ? ' (merged)' : ''}`);
  log(`  Head SHA:  ${pr.head.sha}`);
  log(`  Mergeable: ${String(pr.mergeable)}`);

  if (pr.merged) {
    throw new MergeError(
      `PR #${prIndex} is already merged — nothing to do.`,
      'PR_MERGED',
    );
  }
  if (pr.state !== 'open') {
    throw new MergeError(
      `PR #${prIndex} is ${pr.state} (not open) — cannot merge a closed PR.`,
      'PR_NOT_OPEN',
    );
  }

  // ── Step 2: Fetch all dependencies (exhaustive pagination) ────────────────
  log(`\nFetching dependencies for PR #${prIndex}...`);

  const allDeps = await fetchAllPages<RawIssue>(
    `${repoBase}/issues/${prIndex}/dependencies`,
    config.pat,
  );

  const openDeps = allDeps.filter(d => d.state === 'open');

  log(`  Total dependencies: ${allDeps.length} (${openDeps.length} open / blocking)`);
  for (const d of openDeps) {
    const { owner: dOwner, repo: dRepo } = depOwnerRepo(d, config.owner, config.repo);
    log(`  BLOCKING: #${d.number} [${dOwner}/${dRepo}] "${d.title}"`);
  }

  // ── Step 3: Handle open (blocking) dependencies per strategy ─────────────
  if (openDeps.length > 0) {
    if (config.depStrategy === 'error') {
      const list = openDeps
        .map(d => {
          const { owner: dOwner, repo: dRepo } = depOwnerRepo(d, config.owner, config.repo);
          return `  • #${d.number} [${dOwner}/${dRepo}] "${d.title}"`;
        })
        .join('\n');
      throw new MergeError(
        `PR #${prIndex} has ${openDeps.length} open dependency(ies) that block merging.\n` +
        `Use --dep delete or --dep reverse to handle them automatically.\n` +
        `Blocking dependencies:\n${list}`,
        'OPEN_DEPENDENCIES',
      );
    }

    log(`\nHandling ${openDeps.length} open dependency(ies) with strategy '${config.depStrategy}'...`);

    for (const dep of openDeps) {
      const { owner: dOwner, repo: dRepo } = depOwnerRepo(dep, config.owner, config.repo);
      const depMeta = { owner: dOwner, repo: dRepo, index: dep.number };

      // ── Delete the dependency link (PR depended on dep) ───────────────────
      log(`  DELETE dependency: PR #${prIndex} → #${dep.number} (${dOwner}/${dRepo})`);
      const delRes = await apiDelete(
        `${repoBase}/issues/${prIndex}/dependencies`,
        config.pat,
        depMeta,
      );

      if (delRes.status !== 200) {
        throw new MergeError(
          `Failed to delete dependency link from PR #${prIndex} to #${dep.number} ` +
          `(HTTP ${delRes.status}). ` +
          (bodyMessage(delRes.body) || 'No detail.'),
          'DEP_DELETE_FAILED',
        );
      }

      log(`    Deleted OK (HTTP ${delRes.status})`);

      if (config.depStrategy === 'reverse') {
        // ── Add the reversed dependency link (dep now depends on PR) ─────────
        // POST to /issues/{DEP_NUMBER}/dependencies with our PR as the target.
        const depRepoBase = `${apiBase}/repos/${dOwner}/${dRepo}`;
        const reverseMeta = { owner: config.owner, repo: config.repo, index: prIndex };

        log(`  POST reverse dep: #${dep.number} → PR #${prIndex}`);
        const revRes = await apiPost(
          `${depRepoBase}/issues/${dep.number}/dependencies`,
          config.pat,
          reverseMeta,
        );

        if (revRes.status !== 201) {
          throw new MergeError(
            `Failed to add reversed dependency from #${dep.number} to PR #${prIndex} ` +
            `(HTTP ${revRes.status}). ` +
            (bodyMessage(revRes.body) || 'No detail.'),
            'DEP_REVERSE_FAILED',
          );
        }

        log(`    Reversed OK (HTTP ${revRes.status})`);
        depsHandled.push({
          action: 'reversed',
          dep_number: dep.number,
          dep_title: dep.title,
          dep_repo: `${dOwner}/${dRepo}`,
        });
      } else {
        depsHandled.push({
          action: 'deleted',
          dep_number: dep.number,
          dep_title: dep.title,
          dep_repo: `${dOwner}/${dRepo}`,
        });
      }
    }
  }

  // ── Step 4: Call the merge endpoint ───────────────────────────────────────
  //
  // Do=rebase          — rebase-style merge (linear history, no merge commit)
  // merge_when_checks_succeed=true — if checks are passing, merge now; if not,
  //                                  queue for auto-merge when they do
  // head_commit_id     — optimistic lock: fail if someone pushed since we read
  const mergeBody: MergeBody = {
    Do:                       'rebase',
    merge_when_checks_succeed: true,
    head_commit_id:            pr.head.sha,
  };

  const mergeUrl = `${repoBase}/pulls/${prIndex}/merge`;
  log(`\nPOST ${mergeUrl}`);
  log(`  Do=rebase  merge_when_checks_succeed=true  head_commit_id=${pr.head.sha.slice(0, 12)}…`);

  const mergeRes = await apiPost(mergeUrl, config.pat, mergeBody);
  const mergeDetail = bodyMessage(mergeRes.body);

  log(`Response: HTTP ${mergeRes.status}${mergeDetail ? ` — ${mergeDetail}` : ''}`);

  if (mergeRes.status !== 200 && mergeRes.status !== 201 && mergeRes.status !== 204) {
    switch (mergeRes.status) {
      case 403:
        throw new MergeError(
          `Permission denied (HTTP 403) — the PAT lacks merge access to ` +
          `'${config.owner}/${config.repo}'. ` +
          (mergeDetail || ''),
          'MERGE_FORBIDDEN',
        );
      case 405:
        // Branch protection does not allow this merge style, or automerge is
        // disabled on the repository, or required checks are not configured.
        throw new MergeError(
          `Merge not allowed (HTTP 405) — the repository's branch protection may ` +
          `not permit the 'rebase' merge style, or automerge is disabled. ` +
          (mergeDetail || ''),
          'MERGE_NOT_ALLOWED',
        );
      case 409:
        // Three sub-cases under 409:
        //   1. "already scheduled to auto merge" — auto-merge was already set
        //      (e.g. by a previous run of this script). Treat as success.
        //   2. Merge conflicts — PR cannot be merged cleanly.
        //   3. head_commit_id mismatch (optimistic lock) — branch was pushed to.
        if (mergeDetail.toLowerCase().includes('already scheduled to auto merge')) {
          // Auto-merge is already active — fall through to the re-fetch below.
          log(`  (auto-merge was already scheduled — treating as success)`);
          break;
        }
        throw new MergeError(
          `Merge rejected (HTTP 409) — either the PR has merge conflicts, or ` +
          `the head branch was updated between our read and the merge call ` +
          `(optimistic-lock mismatch). Re-run to retry with the latest HEAD. ` +
          (mergeDetail || ''),
          'MERGE_CONFLICT',
        );
      case 422:
        throw new MergeError(
          `Merge rejected (HTTP 422) — the PR may be a draft, already merged, ` +
          `or otherwise not in a mergeable state. ` +
          (mergeDetail || ''),
          'MERGE_UNPROCESSABLE',
        );
      default:
        throw new MergeError(
          `Unexpected response from merge endpoint (HTTP ${mergeRes.status}). ` +
          (mergeDetail || 'No detail.'),
          `HTTP_${mergeRes.status}`,
        );
    }
  }

  // ── Step 5: Re-fetch PR to determine the resulting state ──────────────────
  log(`\nMerge request accepted. Re-fetching PR to determine state...`);

  const updated = await apiGet<RawPR>(`${repoBase}/pulls/${prIndex}`, config.pat);
  log(`  merged=${updated.merged}  state=${updated.state}`);

  // ── Step 6: Return result based on PR state ───────────────────────────────

  if (updated.merged) {
    // PR was merged immediately — all checks were already green and approvals met.
    log(`PR #${prIndex} merged immediately.`);
    return {
      status:           'merged',
      pr_number:        prIndex,
      pr_title:         pr.title,
      head_sha:         pr.head.sha,
      merge_commit_sha: updated.merge_commit_sha,
      merged_at:        updated.merged_at,
      deps_handled:     depsHandled,
      message:          `PR #${prIndex} merged immediately using rebase style.`,
    };
  }

  // PR is not yet merged — the merge call was accepted (HTTP 200) so Forgejo
  // has queued it for auto-merge once pending checks/approvals are satisfied.
  // Forgejo exposes no read-only endpoint to confirm this state, but a 200
  // from the merge endpoint with merge_when_checks_succeed=true is the
  // authoritative signal that the request was accepted.
  log(`PR #${prIndex} not yet merged — auto-merge is pending checks/approvals.`);
  return {
    status:           'auto_merge_scheduled',
    pr_number:        prIndex,
    pr_title:         pr.title,
    head_sha:         pr.head.sha,
    merge_commit_sha: null,
    merged_at:        null,
    deps_handled:     depsHandled,
    message:
      `PR #${prIndex} queued for auto-merge (rebase style). Forgejo will merge it ` +
      `automatically once all required checks pass and approvals are satisfied.`,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// parseArgs / printUsage
// ─────────────────────────────────────────────────────────────────────────────

function printUsage(): void {
  process.stderr.write(`\
Usage:
  merge_pr.ts --url URL --pat TOKEN --owner OWNER --repo REPO --pr NUMBER
              [--dep error|delete|reverse]

REQUIRED:
  --url    URL     Forgejo base URL, no trailing slash  (e.g. https://git.example.com)
  --pat    TOKEN   Personal Access Token (needs merge permission on the repository)
  --owner  OWNER   Repository owner or organisation name
  --repo   REPO    Repository name
  --pr     NUMBER  Pull request number to merge (positive integer)

OPTIONAL:
  --dep    STRATEGY  How to handle open blocking issue dependencies (default: error)
             error    — abort with a list of blockers (safe, never modifies state)
             delete   — delete each open dependency link, then proceed
             reverse  — flip each open link (PR depends on X → X depends on PR)

BEHAVIOUR:
  1. Verifies the PR is open and not already merged.
  2. Fetches all dependencies; filters to open (blocking) ones.
  3. Handles open deps per --dep strategy (error/delete/reverse).
  4. Calls POST /pulls/{index}/merge with:
       Do=rebase  merge_when_checks_succeed=true  head_commit_id=<current HEAD SHA>
     Any non-2xx response (403/405/409/422/…) is raised as an explicit error.
  5. Re-fetches the PR:
       • merged=true  → status="merged" (all checks were already satisfied)
       • merged=false → status="auto_merge_scheduled" (Forgejo accepted the
                        request; will merge once checks/approvals clear)

OUTPUT (stdout, on success):
  {
    "status": "merged" | "auto_merge_scheduled",
    "pr_number": N,
    "pr_title": "...",
    "head_sha": "...",
    "merge_commit_sha": "<sha>" | null,
    "merged_at": "<iso>" | null,
    "deps_handled": [
      { "action": "deleted"|"reversed", "dep_number": N,
        "dep_title": "...", "dep_repo": "owner/repo" }
    ],
    "message": "..."
  }

EXIT CODES:
  0 — merge initiated (merged immediately or auto-merge confirmed scheduled)
  1 — any failure (see stderr for details and error code)
`);
}

function parseArgs(argv: string[]): Config {
  const args = argv.slice(2);
  const config: Partial<Config> = { depStrategy: 'error' };

  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 '--pr': {
        const parsed = Number(val);
        if (!val || isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
          process.stderr.write(
            `ERROR: --pr must be a positive integer, got: ${val ?? '(missing)'}\n`,
          );
          process.exit(1);
        }
        config.prNumber = parsed;
        i++;
        break;
      }
      case '--dep': {
        if (val !== 'error' && val !== 'delete' && val !== 'reverse') {
          process.stderr.write(
            `ERROR: --dep must be one of: error, delete, reverse. Got: ${val ?? '(missing)'}\n`,
          );
          process.exit(1);
        }
        config.depStrategy = val as DepStrategy;
        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); }
  if (config.prNumber === undefined) { process.stderr.write('ERROR: --pr is required\n');    process.exit(1); }

  return config as Config;
}

// ─────────────────────────────────────────────────────────────────────────────
// CLI entry point — only when this file is run directly, not when imported
// ─────────────────────────────────────────────────────────────────────────────

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const config = parseArgs(process.argv);

  mergePR(config)
    .then((result: MergeResult) => {
      process.stdout.write(JSON.stringify(result, null, 2) + '\n');
    })
    .catch((err: unknown) => {
      const msg = err instanceof Error ? err.message : String(err);
      process.stderr.write(`ERROR: ${msg}\n`);
      process.exit(1);
    });
}
