#!/usr/bin/env -S npx --yes tsx
// =============================================================================
// rebase_pr.ts — Trigger a server-side rebase on a stale, conflict-free PR
//
// Part of: .opencode/skills/auto-agents-system/scripts/
//
// Calls the Forgejo REST API to rebase a pull request's head branch onto the
// latest commit of its base branch, entirely server-side (no local git clone).
//
// Endpoint used:
//   POST /api/v1/repos/{owner}/{repo}/pulls/{index}/update?style=rebase
//
// This script ONLY proceeds when ALL of the following conditions hold:
//   1. The PR is OPEN — not closed and not merged.
//   2. The PR is STALE — the base branch has advanced since the PR was last
//      synced, i.e. PR.merge_base != current base-branch HEAD SHA.
//   3. The PR has NO conflicts — Forgejo has computed PR.mergeable == true.
//      If mergeable is null (not yet computed) or false (conflicts), the
//      script exits with an error rather than proceeding blindly.
//
// If ANY condition fails, or if ANY API call fails for any reason, the script
// exits non-zero with a descriptive error message on stderr.  It NEVER
// silently swallows errors.
//
// USAGE:
//   rebase_pr.ts --url URL --pat TOKEN --owner OWNER --repo REPO --pr NUMBER
//
// OUTPUT (stdout, on success): JSON object:
//   {
//     "success": true,
//     "message": "PR #N successfully rebased onto 'branch'.",
//     "pr_number": N,
//     "base_branch": "branch-name",
//     "old_merge_base": "<sha>",
//     "new_head_sha": "<sha>"
//   }
//
// EXIT CODES:
//   0 — rebase triggered successfully
//   1 — any error (not stale, has conflicts, permission denied, API error, …)
//
// DIAGNOSTICS (stderr):
//   Progress messages and the HTTP status of the rebase call are always written
//   to stderr, both on success and on failure.
//
// 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
  prNumber: number; // Pull request number to rebase
}

// Raw shape of a Forgejo pull request (only the fields this script needs).
// `[key: string]: unknown` preserves all other fields without asserting `any`.
interface RawPR {
  number: number;
  title: string;
  state: string;              // 'open' | 'closed'
  merged: boolean;
  mergeable: boolean | null;  // null = Forgejo hasn't computed it yet
  merge_base: string | null;  // common ancestor SHA between head and base
  base: { ref: string; sha: string };  // base branch name + current SHA
  head: { ref: string; sha: string };  // head branch name + current SHA
  [key: string]: unknown;
}

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

export interface RebaseResult {
  success: true;
  message: string;
  pr_number: number;
  base_branch: string;
  old_merge_base: string;  // merge_base SHA before the rebase
  new_head_sha: string;    // head SHA after the rebase
}

// ─────────────────────────────────────────────────────────────────────────────
// RebaseError — typed error carrying a machine-readable code
//
// The `code` field makes it easy for callers to programmatically distinguish
// between "PR is not stale" (PR_NOT_STALE), "has conflicts" (PR_HAS_CONFLICTS),
// etc. without string-matching the message.
// ─────────────────────────────────────────────────────────────────────────────

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

// ─────────────────────────────────────────────────────────────────────────────
// apiGet — single authenticated GET; throws RebaseError 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(30_000),
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({})) as { message?: string };
    throw new RebaseError(
      `HTTP ${res.status}: ${body.message ?? 'unknown error'} — GET ${url}`,
      `HTTP_${res.status}`,
    );
  }
  return res.json() as Promise<T>;
}

// ─────────────────────────────────────────────────────────────────────────────
// apiPost — authenticated POST; returns raw status + body.
//
// Does NOT throw on non-2xx — callers inspect the status themselves so they
// can produce targeted error messages for each HTTP status code.
// ─────────────────────────────────────────────────────────────────────────────

async function apiPost(
  url: string,
  pat: string,
): Promise<{ status: number; body: unknown }> {
  const res = await fetch(url, {
    method: 'POST',
    headers: { Authorization: `token ${pat}` },
    signal: AbortSignal.timeout(60_000),
  });
  const body = await res.json().catch((): null => null);
  return { status: res.status, body };
}

// ─────────────────────────────────────────────────────────────────────────────
// bodyMessage — extract a readable message string from an API response body
// ─────────────────────────────────────────────────────────────────────────────

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) : '';
}

// ─────────────────────────────────────────────────────────────────────────────
// rebasePR — fetch, pre-flight validate, trigger rebase, return result
//
// Exported so this function can be called programmatically from other scripts
// (e.g. a merge worker that wants to rebase before merging).
// ─────────────────────────────────────────────────────────────────────────────

export async function rebasePR(config: Config): Promise<RebaseResult> {
  const baseUrl    = config.url.replace(/\/$/, '');
  const baseApiUrl = `${baseUrl}/api/v1`;
  const repoBase   = `${baseApiUrl}/repos/${config.owner}/${config.repo}`;
  const prIndex    = config.prNumber;

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

  // ── Step 1: Fetch PR details ───────────────────────────────────────────────
  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(`  Base:       ${pr.base.ref}`);
  log(`  Head:       ${pr.head.ref}`);
  log(`  Merge base: ${pr.merge_base ?? '(null)'}`);
  log(`  Mergeable:  ${String(pr.mergeable)}`);

  // ── Guard: PR must be open and not merged ─────────────────────────────────
  if (pr.merged) {
    throw new RebaseError(
      `PR #${prIndex} is already merged — nothing to rebase.`,
      'PR_MERGED',
    );
  }
  if (pr.state !== 'open') {
    throw new RebaseError(
      `PR #${prIndex} is ${pr.state} (not open) — cannot rebase a closed PR.`,
      'PR_NOT_OPEN',
    );
  }

  // ── Step 2: Fetch current base-branch HEAD SHA ────────────────────────────
  const baseBranch = pr.base.ref;
  log(`\nFetching current HEAD of base branch '${baseBranch}'...`);

  const branchInfo = await apiGet<BranchInfo>(
    `${repoBase}/branches/${encodeURIComponent(baseBranch)}`,
    config.pat,
  );

  const baseHead = branchInfo.commit?.id;
  if (!baseHead) {
    throw new RebaseError(
      `Could not determine HEAD SHA for base branch '${baseBranch}'. ` +
      `The branch API did not return a commit id.`,
      'BASE_HEAD_UNKNOWN',
    );
  }

  log(`  Base HEAD: ${baseHead}`);

  // ── Guard: PR must be stale ────────────────────────────────────────────────
  const mergeBase = pr.merge_base;
  if (!mergeBase) {
    throw new RebaseError(
      `PR #${prIndex} has no merge_base recorded (merge_base is null). ` +
      `Staleness cannot be determined — the PR may be too new or Forgejo may ` +
      `still be computing it. Retry shortly.`,
      'MERGE_BASE_MISSING',
    );
  }

  if (mergeBase === baseHead) {
    throw new RebaseError(
      `PR #${prIndex} is NOT stale — it is already up to date with '${baseBranch}'. ` +
      `(merge_base matches base HEAD: ${baseHead.slice(0, 12)}…) No rebase is needed.`,
      'PR_NOT_STALE',
    );
  }

  log(
    `\nPR is stale: merge_base (${mergeBase.slice(0, 12)}…) ` +
    `!= base HEAD (${baseHead.slice(0, 12)}…).`,
  );

  // ── Guard: PR must have no conflicts (mergeable == true) ──────────────────
  //
  // mergeable == null → Forgejo hasn't finished computing the merge status.
  //   We refuse to proceed rather than assume it's safe — the user should
  //   retry in a few seconds once Forgejo has resolved the value.
  //
  // mergeable == false → Forgejo has determined there are conflicts.
  //   A server-side rebase will fail or produce broken state; manual
  //   resolution is required.
  //
  // mergeable == true → No conflicts detected; safe to rebase.
  if (pr.mergeable === null) {
    throw new RebaseError(
      `PR #${prIndex} is stale but Forgejo has not yet computed its merge status ` +
      `(mergeable=null). This typically resolves within a few seconds. ` +
      `Retry shortly — the script will proceed once the value is known.`,
      'MERGEABLE_UNKNOWN',
    );
  }
  if (pr.mergeable === false) {
    throw new RebaseError(
      `PR #${prIndex} is stale AND has merge conflicts (mergeable=false). ` +
      `A server-side rebase cannot proceed automatically. ` +
      `Manual conflict resolution by the PR author is required first.`,
      'PR_HAS_CONFLICTS',
    );
  }

  // pr.mergeable === true — preconditions satisfied, proceed.
  log(`No conflicts detected (mergeable=true). Triggering server-side rebase...`);

  // ── Step 3: POST /pulls/{index}/update?style=rebase ───────────────────────
  //
  // The `style` parameter is a query parameter, not a JSON body field.
  // style=rebase  → rewrites the PR's commits on top of the current base HEAD
  // style=merge   → creates a merge commit (we intentionally do NOT fall back
  //                 to this — callers should decide that explicitly)
  const updateUrl = `${repoBase}/pulls/${prIndex}/update?style=rebase`;
  log(`\nPOST ${updateUrl}`);

  const { status, body } = await apiPost(updateUrl, config.pat);
  const detail = bodyMessage(body);

  log(`Response: HTTP ${status}${detail ? ` — ${detail}` : ''}`);

  if (status !== 200) {
    switch (status) {
      case 403:
        throw new RebaseError(
          `Permission denied (HTTP 403) — the PAT does not have write access ` +
          `to the head repository '${config.owner}/${config.repo}'. ` +
          (detail ? `API detail: ${detail}` : ''),
          'FORBIDDEN',
        );
      case 404:
        throw new RebaseError(
          `PR #${prIndex} or repository '${config.owner}/${config.repo}' not found ` +
          `(HTTP 404). ` +
          (detail ? `API detail: ${detail}` : ''),
          'NOT_FOUND',
        );
      case 409:
        // The rebase itself detected conflicts — this can happen if the branch
        // was pushed to between our pre-flight check and this call (race condition),
        // or if Forgejo's mergeable computation was stale.
        throw new RebaseError(
          `Server-side rebase failed with HTTP 409 — conflicts were detected during ` +
          `the rebase operation itself, despite the pre-flight mergeable=true check. ` +
          `This is typically caused by a concurrent push to the PR or base branch. ` +
          (detail ? `API detail: ${detail}` : ''),
          'REBASE_CONFLICT',
        );
      case 422:
        // Forgejo returns 422 when the PR is already up to date.  This is
        // unexpected because our staleness check should have caught it, but
        // a race condition (concurrent rebase by another process) can cause it.
        throw new RebaseError(
          `Server returned HTTP 422 (already up to date) despite the pre-flight ` +
          `staleness check indicating the PR was behind. A concurrent rebase may ` +
          `have updated the branch already. ` +
          (detail ? `API detail: ${detail}` : ''),
          'ALREADY_UP_TO_DATE',
        );
      default:
        throw new RebaseError(
          `Unexpected response from the update endpoint (HTTP ${status}). ` +
          (detail ? `API detail: ${detail}` : 'No response body.'),
          `HTTP_${status}`,
        );
    }
  }

  // ── Step 4: Fetch updated PR to capture the new head SHA ──────────────────
  //
  // The update endpoint returns 200 with a small acknowledgement body; the
  // actual new head SHA is only visible by re-fetching the PR.  We do this
  // so the caller has a concrete, verifiable indicator of what changed.
  log(`\nRebase triggered. Fetching updated PR details...`);

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

  const newHeadSha = updatedPR.head.sha;
  log(`  New head SHA:   ${newHeadSha}`);
  log(`  New merge_base: ${updatedPR.merge_base ?? '(pending — Forgejo will compute shortly)'}`);

  return {
    success:        true,
    message:        `PR #${prIndex} successfully rebased onto '${baseBranch}'.`,
    pr_number:      prIndex,
    base_branch:    baseBranch,
    old_merge_base: mergeBase,
    new_head_sha:   newHeadSha,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// parseArgs — parse and validate CLI arguments
// ─────────────────────────────────────────────────────────────────────────────

function printUsage(): void {
  process.stderr.write(`\
Usage:
  rebase_pr.ts --url URL --pat TOKEN --owner OWNER --repo REPO --pr NUMBER

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

BEHAVIOUR:
  Triggers a server-side rebase (POST /pulls/{index}/update?style=rebase) ONLY when:
    • PR is open (not closed or merged)
    • PR is stale — PR.merge_base != current base-branch HEAD SHA
    • PR has no conflicts — PR.mergeable == true (not false or null)

  If mergeable is null (Forgejo still computing) or false (conflicts exist),
  the script exits 1 with a descriptive error.  It never silently fails.

OUTPUT (stdout, on success):
  {
    "success": true,
    "message": "PR #N successfully rebased onto 'branch'.",
    "pr_number": N,
    "base_branch": "branch-name",
    "old_merge_base": "<sha before rebase>",
    "new_head_sha":   "<sha after rebase>"
  }

EXIT CODES:
  0 — rebase triggered successfully
  1 — any failure (see stderr for details and error code)

ERROR CODES (in the error message, after "ERROR:"):
  PR_MERGED           — the PR is already merged
  PR_NOT_OPEN         — the PR is closed (not open)
  MERGE_BASE_MISSING  — PR.merge_base is null; Forgejo hasn't set it yet
  PR_NOT_STALE        — PR is already up to date; no rebase needed
  MERGEABLE_UNKNOWN   — PR.mergeable is null; Forgejo hasn't computed it yet
  PR_HAS_CONFLICTS    — PR.mergeable is false; manual resolution required
  FORBIDDEN           — token lacks write access (HTTP 403)
  NOT_FOUND           — PR or repo not found (HTTP 404)
  REBASE_CONFLICT     — conflict detected during rebase despite pre-flight check (HTTP 409)
  ALREADY_UP_TO_DATE  — race condition: already rebased by the time we called (HTTP 422)
  HTTP_NNN            — any other unexpected HTTP response
`);
}

function parseArgs(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 '--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 '-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 executes when this file is the main script,
// not when it is imported as a module by another script.
// ─────────────────────────────────────────────────────────────────────────────

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

  rebasePR(config)
    .then((result: RebaseResult) => {
      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);
    });
}
