@autoclawd/autoclawd 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -34,7 +34,9 @@ var LinearConfigSchema = z.object({
34
34
  inProgressStatus: z.string().default("In Progress")
35
35
  });
36
36
  var GitHubConfigSchema = z.object({
37
- token: z.string()
37
+ token: z.string(),
38
+ autoMerge: z.boolean().default(false),
39
+ mergeMethod: z.enum(["merge", "squash", "rebase"]).default("squash")
38
40
  });
39
41
  var WebhookConfigSchema = z.object({
40
42
  port: z.number().default(3e3),
@@ -393,6 +395,10 @@ ${reason}`
393
395
 
394
396
  // src/github.ts
395
397
  import { Octokit } from "@octokit/rest";
398
+ import { execSync } from "child_process";
399
+ import { mkdtempSync, writeFileSync, rmSync } from "fs";
400
+ import { join as join3 } from "path";
401
+ import { tmpdir } from "os";
396
402
  function createOctokit(config) {
397
403
  return new Octokit({ auth: config.github.token });
398
404
  }
@@ -557,6 +563,144 @@ async function fetchFailedChecks(octokit, opts) {
557
563
  }
558
564
  return results;
559
565
  }
566
+ async function enableAutoMerge(octokit, opts) {
567
+ return retry(async () => {
568
+ const { owner, repo } = parseRepoUrl(opts.repoUrl);
569
+ const { data: pr } = await octokit.rest.pulls.get({
570
+ owner,
571
+ repo,
572
+ pull_number: opts.prNumber
573
+ });
574
+ try {
575
+ await octokit.graphql(`
576
+ mutation EnableAutoMerge($prId: ID!, $mergeMethod: PullRequestMergeMethod!) {
577
+ enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: $mergeMethod}) {
578
+ pullRequest { autoMergeRequest { enabledAt } }
579
+ }
580
+ }
581
+ `, {
582
+ prId: pr.node_id,
583
+ mergeMethod: opts.mergeMethod ?? "SQUASH"
584
+ });
585
+ log.info(`Auto-merge enabled on PR #${opts.prNumber} (${opts.mergeMethod ?? "SQUASH"})`);
586
+ } catch (err) {
587
+ const msg = err instanceof Error ? err.message : String(err);
588
+ const msgLower = msg.toLowerCase();
589
+ if (msgLower.includes("auto-merge") || msgLower.includes("not allowed") || msgLower.includes("branch protection") || msgLower.includes("protected branch")) {
590
+ log.warn(`Auto-merge not available on PR #${opts.prNumber}: ${msg}`);
591
+ } else {
592
+ throw err;
593
+ }
594
+ }
595
+ }, { label: "GitHub auto-merge", retryIf: isTransientError });
596
+ }
597
+ async function retargetPR(octokit, opts) {
598
+ return retry(async () => {
599
+ const { owner, repo } = parseRepoUrl(opts.repoUrl);
600
+ await octokit.rest.pulls.update({
601
+ owner,
602
+ repo,
603
+ pull_number: opts.prNumber,
604
+ base: opts.newBase
605
+ });
606
+ log.info(`Retargeted PR #${opts.prNumber} to base ${opts.newBase}`);
607
+ }, { label: "GitHub retarget", retryIf: isTransientError });
608
+ }
609
+ function createAskpass(githubToken) {
610
+ const dir = mkdtempSync(join3(tmpdir(), "autoclawd-rebase-"));
611
+ const path = join3(dir, "askpass.sh");
612
+ writeFileSync(path, `#!/bin/sh
613
+ echo "${githubToken}"
614
+ `, { mode: 448 });
615
+ return { dir, path };
616
+ }
617
+ function gitEnv(askpassPath) {
618
+ return { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: "0" };
619
+ }
620
+ function detectDefaultBranch(repoUrl, githubToken) {
621
+ const askpass = createAskpass(githubToken);
622
+ const authedUrl = repoUrl.replace("https://", "https://x-access-token@");
623
+ try {
624
+ const output = execSync(
625
+ `git ls-remote --symref -- ${authedUrl} HEAD`,
626
+ { stdio: "pipe", timeout: 3e4, env: gitEnv(askpass.path), encoding: "utf-8" }
627
+ );
628
+ const match = output.match(/ref:\s+refs\/heads\/(\S+)\s+HEAD/);
629
+ if (match) return match[1];
630
+ } catch {
631
+ } finally {
632
+ rmSync(askpass.dir, { recursive: true, force: true });
633
+ }
634
+ return "main";
635
+ }
636
+ async function rebaseStackedPRs(octokit, opts) {
637
+ const { owner, repo } = parseRepoUrl(opts.repoUrl);
638
+ const defaultBranch = detectDefaultBranch(opts.repoUrl, opts.githubToken);
639
+ const prefix = opts.branchPrefix ?? "autoclawd/";
640
+ const { data: openPRs } = await octokit.rest.pulls.list({
641
+ owner,
642
+ repo,
643
+ state: "open",
644
+ per_page: 100
645
+ });
646
+ const stackedPRs = openPRs.filter(
647
+ (pr) => pr.head.ref.startsWith(prefix) && pr.base.ref !== defaultBranch
648
+ );
649
+ if (stackedPRs.length === 0) return 0;
650
+ let rebased = 0;
651
+ for (const pr of stackedPRs) {
652
+ const baseBranch = pr.base.ref;
653
+ const { data: basePRs } = await octokit.rest.pulls.list({
654
+ owner,
655
+ repo,
656
+ head: `${owner}:${baseBranch}`,
657
+ state: "closed"
658
+ });
659
+ const mergedBasePR = basePRs.find((p) => p.merged_at !== null);
660
+ if (!mergedBasePR) {
661
+ log.debug(`PR #${pr.number}: base "${baseBranch}" not yet merged, skipping`);
662
+ continue;
663
+ }
664
+ log.info(`PR #${pr.number} ("${pr.title}"): base "${baseBranch}" was merged, rebasing onto ${defaultBranch}`);
665
+ const askpass = createAskpass(opts.githubToken);
666
+ const workDir = mkdtempSync(join3(tmpdir(), "autoclawd-rebase-"));
667
+ try {
668
+ const authedUrl = opts.repoUrl.replace("https://", "https://x-access-token@");
669
+ const env = gitEnv(askpass.path);
670
+ const run = (cmd) => execSync(cmd, { cwd: workDir, stdio: "pipe", timeout: 12e4, env, encoding: "utf-8" });
671
+ run(`git clone --no-tags -- ${authedUrl} .`);
672
+ run(`git checkout ${pr.head.ref}`);
673
+ run(`git fetch origin ${defaultBranch}`);
674
+ try {
675
+ run(`git rebase --onto origin/${defaultBranch} origin/${baseBranch}`);
676
+ } catch {
677
+ try {
678
+ run("git rebase --abort");
679
+ } catch {
680
+ }
681
+ log.warn(`PR #${pr.number}: rebase conflict, skipping (manual resolution needed)`);
682
+ continue;
683
+ }
684
+ run(`git config credential.helper '!f() { echo "password=${opts.githubToken}"; echo "username=x-access-token"; }; f'`);
685
+ run(`git push --force-with-lease origin ${pr.head.ref}`);
686
+ await retargetPR(octokit, {
687
+ repoUrl: opts.repoUrl,
688
+ prNumber: pr.number,
689
+ newBase: defaultBranch
690
+ });
691
+ log.success(`PR #${pr.number}: rebased onto ${defaultBranch} and retargeted`);
692
+ rebased++;
693
+ } catch (err) {
694
+ const msg = err instanceof Error ? err.message : String(err);
695
+ const safe = msg.replace(/x-access-token:[^\s@]+/g, "x-access-token:***");
696
+ log.error(`PR #${pr.number}: rebase failed: ${safe}`);
697
+ } finally {
698
+ rmSync(workDir, { recursive: true, force: true });
699
+ rmSync(askpass.dir, { recursive: true, force: true });
700
+ }
701
+ }
702
+ return rebased;
703
+ }
560
704
 
561
705
  // src/webhook.ts
562
706
  import { createServer } from "http";
@@ -565,18 +709,123 @@ import { createHmac, timingSafeEqual } from "crypto";
565
709
  // src/docker.ts
566
710
  import Docker from "dockerode";
567
711
  import { PassThrough } from "stream";
568
- import { homedir as homedir3 } from "os";
569
- import { join as join3 } from "path";
712
+ import { homedir as homedir3, platform } from "os";
713
+ import { join as join4 } from "path";
570
714
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
715
+ import { execSync as execSync2 } from "child_process";
571
716
  var docker = new Docker();
717
+ function sleep2(ms) {
718
+ return new Promise((r) => setTimeout(r, ms));
719
+ }
720
+ async function tryStartDocker() {
721
+ const os = platform();
722
+ if (os === "darwin") {
723
+ if (existsSync3("/Applications/Docker.app")) {
724
+ log.info("Docker not running \u2014 starting Docker Desktop...");
725
+ try {
726
+ execSync2("open -a Docker", { stdio: "pipe" });
727
+ } catch {
728
+ return false;
729
+ }
730
+ } else {
731
+ return false;
732
+ }
733
+ } else if (os === "linux") {
734
+ log.info("Docker not running \u2014 attempting to start...");
735
+ try {
736
+ execSync2("sudo systemctl start docker 2>/dev/null || sudo service docker start 2>/dev/null", {
737
+ stdio: "pipe",
738
+ timeout: 3e4
739
+ });
740
+ } catch {
741
+ return false;
742
+ }
743
+ } else {
744
+ return false;
745
+ }
746
+ for (let i = 0; i < 30; i++) {
747
+ await sleep2(1e3);
748
+ try {
749
+ await docker.ping();
750
+ log.success("Docker started");
751
+ return true;
752
+ } catch {
753
+ }
754
+ if (i > 0 && i % 5 === 0) {
755
+ log.info(`Waiting for Docker daemon... (${i}s)`);
756
+ }
757
+ }
758
+ return false;
759
+ }
760
+ async function tryInstallDocker() {
761
+ const os = platform();
762
+ if (os === "darwin") {
763
+ try {
764
+ execSync2("which brew", { stdio: "pipe" });
765
+ } catch {
766
+ log.warn("Docker not installed and Homebrew not found \u2014 cannot auto-install");
767
+ return false;
768
+ }
769
+ log.info("Docker not installed \u2014 installing via Homebrew (this may take a few minutes)...");
770
+ try {
771
+ execSync2("brew install --cask docker", { stdio: "inherit", timeout: 6e5 });
772
+ log.success("Docker Desktop installed");
773
+ return true;
774
+ } catch {
775
+ log.warn("Docker install via Homebrew failed");
776
+ return false;
777
+ }
778
+ } else if (os === "linux") {
779
+ const cmds = [
780
+ { check: "apt-get", install: "sudo apt-get update -qq && sudo apt-get install -y docker.io && sudo systemctl enable docker" },
781
+ { check: "dnf", install: "sudo dnf install -y docker && sudo systemctl enable docker" },
782
+ { check: "yum", install: "sudo yum install -y docker && sudo systemctl enable docker" },
783
+ { check: "pacman", install: "sudo pacman -Sy --noconfirm docker && sudo systemctl enable docker" }
784
+ ];
785
+ for (const { check, install } of cmds) {
786
+ try {
787
+ execSync2(`which ${check}`, { stdio: "pipe" });
788
+ log.info(`Docker not installed \u2014 installing via ${check}...`);
789
+ execSync2(install, { stdio: "inherit", timeout: 3e5 });
790
+ log.success("Docker installed");
791
+ return true;
792
+ } catch {
793
+ }
794
+ }
795
+ return false;
796
+ }
797
+ return false;
798
+ }
572
799
  async function checkDockerAvailable() {
573
800
  try {
574
801
  await docker.ping();
575
- } catch (err) {
802
+ return;
803
+ } catch {
804
+ }
805
+ const started = await tryStartDocker();
806
+ if (started) return;
807
+ let installed = false;
808
+ try {
809
+ execSync2("which docker", { stdio: "pipe" });
810
+ installed = true;
811
+ } catch {
812
+ }
813
+ if (installed) {
814
+ throw new Error(
815
+ "Docker is installed but the daemon is not running and could not be started automatically.\nStart it manually:\n" + (platform() === "darwin" ? " open -a Docker\n" : " sudo systemctl start docker\n")
816
+ );
817
+ }
818
+ const didInstall = await tryInstallDocker();
819
+ if (didInstall) {
820
+ const started2 = await tryStartDocker();
821
+ if (started2) return;
576
822
  throw new Error(
577
- "Cannot connect to Docker daemon. Is Docker running?\nInstall: https://docs.docker.com/get-docker/"
823
+ "Docker was installed but could not be started.\n" + (platform() === "darwin" ? "Open Docker Desktop from Applications and try again.\n" : "Run: sudo systemctl start docker\n")
578
824
  );
579
825
  }
826
+ throw new Error(
827
+ "Docker is not installed and could not be auto-installed.\n" + (platform() === "darwin" ? "Install manually: brew install --cask docker\n" : "Install manually: https://docs.docker.com/get-docker/\n")
828
+ );
580
829
  }
581
830
  async function ensureImage(image) {
582
831
  try {
@@ -766,7 +1015,7 @@ async function copyClaudeCredentials(container) {
766
1015
  "-c",
767
1016
  "mkdir -p /home/autoclawd/.claude && chown autoclawd /home/autoclawd/.claude"
768
1017
  ]);
769
- const credFile = join3(home, ".claude", ".credentials.json");
1018
+ const credFile = join4(home, ".claude", ".credentials.json");
770
1019
  if (existsSync3(credFile)) {
771
1020
  const content = readFileSync2(credFile, "utf-8");
772
1021
  const b64 = Buffer.from(content).toString("base64");
@@ -777,7 +1026,7 @@ async function copyClaudeCredentials(container) {
777
1026
  ]);
778
1027
  log.debug("Copied .credentials.json");
779
1028
  }
780
- const settingsFile = join3(home, ".claude", "settings.json");
1029
+ const settingsFile = join4(home, ".claude", "settings.json");
781
1030
  if (existsSync3(settingsFile)) {
782
1031
  const content = readFileSync2(settingsFile, "utf-8");
783
1032
  const b64 = Buffer.from(content).toString("base64");
@@ -969,7 +1218,7 @@ async function runAgentLoop(opts) {
969
1218
  }
970
1219
  const waitMs = estimateResetMs(combined);
971
1220
  log.ticket(ticketId, `Rate limited (${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES}) \u2014 pausing ${Math.round(waitMs / 1e3)}s`);
972
- await sleep2(waitMs);
1221
+ await sleep3(waitMs);
973
1222
  i--;
974
1223
  continue;
975
1224
  }
@@ -980,7 +1229,7 @@ async function runAgentLoop(opts) {
980
1229
  log.ticket(ticketId, `Max iterations (${agentConfig.maxIterations}) reached`);
981
1230
  return { iterations: agentConfig.maxIterations, success: false, lastOutput };
982
1231
  }
983
- function sleep2(ms) {
1232
+ function sleep3(ms) {
984
1233
  return new Promise((resolve) => setTimeout(resolve, ms));
985
1234
  }
986
1235
 
@@ -1051,16 +1300,16 @@ async function commitAndPush(container, opts) {
1051
1300
  }
1052
1301
 
1053
1302
  // src/worker.ts
1054
- import { mkdtempSync, rmSync, writeFileSync } from "fs";
1055
- import { join as join5 } from "path";
1056
- import { tmpdir } from "os";
1057
- import { execSync } from "child_process";
1303
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
1304
+ import { join as join6 } from "path";
1305
+ import { tmpdir as tmpdir2 } from "os";
1306
+ import { execSync as execSync3 } from "child_process";
1058
1307
 
1059
1308
  // src/db.ts
1060
1309
  import Database from "better-sqlite3";
1061
- import { join as join4 } from "path";
1310
+ import { join as join5 } from "path";
1062
1311
  import { mkdirSync as mkdirSync2, existsSync as existsSync4 } from "fs";
1063
- var DB_PATH = join4(CONFIG_DIR, "autoclawd.db");
1312
+ var DB_PATH = join5(CONFIG_DIR, "autoclawd.db");
1064
1313
  var _db;
1065
1314
  function getDb() {
1066
1315
  if (_db) return _db;
@@ -1138,6 +1387,10 @@ function isTicketProcessed(ticketId) {
1138
1387
  ).get(ticketId);
1139
1388
  return !!row;
1140
1389
  }
1390
+ function removeFromProcessed(ticketId) {
1391
+ const db = getDb();
1392
+ db.prepare("DELETE FROM runs WHERE id = ? AND status = 'failed'").run(ticketId);
1393
+ }
1141
1394
  function isTicketActive(ticketId) {
1142
1395
  const db = getDb();
1143
1396
  const row = db.prepare(
@@ -1223,75 +1476,75 @@ function assertSafeRef(name, label) {
1223
1476
  throw new Error(`Invalid ${label}: "${name}"`);
1224
1477
  }
1225
1478
  }
1226
- function createAskpass(githubToken) {
1227
- const dir = mkdtempSync(join5(tmpdir(), "autoclawd-cred-"));
1228
- const path = join5(dir, "askpass.sh");
1229
- writeFileSync(path, `#!/bin/sh
1479
+ function createAskpass2(githubToken) {
1480
+ const dir = mkdtempSync2(join6(tmpdir2(), "autoclawd-cred-"));
1481
+ const path = join6(dir, "askpass.sh");
1482
+ writeFileSync2(path, `#!/bin/sh
1230
1483
  echo "${githubToken}"
1231
1484
  `, { mode: 448 });
1232
1485
  return { dir, path };
1233
1486
  }
1234
- function gitEnv(askpassPath) {
1487
+ function gitEnv2(askpassPath) {
1235
1488
  return { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: "0" };
1236
1489
  }
1237
- function detectDefaultBranch(repoUrl, githubToken) {
1238
- const askpass = createAskpass(githubToken);
1490
+ function detectDefaultBranch2(repoUrl, githubToken) {
1491
+ const askpass = createAskpass2(githubToken);
1239
1492
  const authedUrl = repoUrl.replace("https://", "https://x-access-token@");
1240
1493
  try {
1241
- const output = execSync(
1494
+ const output = execSync3(
1242
1495
  `git ls-remote --symref -- ${authedUrl} HEAD`,
1243
- { stdio: "pipe", timeout: 3e4, env: gitEnv(askpass.path), encoding: "utf-8" }
1496
+ { stdio: "pipe", timeout: 3e4, env: gitEnv2(askpass.path), encoding: "utf-8" }
1244
1497
  );
1245
1498
  const match = output.match(/ref:\s+refs\/heads\/(\S+)\s+HEAD/);
1246
1499
  if (match) return match[1];
1247
1500
  } catch {
1248
1501
  } finally {
1249
- rmSync(askpass.dir, { recursive: true, force: true });
1502
+ rmSync2(askpass.dir, { recursive: true, force: true });
1250
1503
  }
1251
1504
  return "main";
1252
1505
  }
1253
1506
  async function pushScaffold(opts) {
1254
- const scaffoldDir = mkdtempSync(join5(tmpdir(), "autoclawd-scaffold-"));
1255
- const askpass = createAskpass(opts.githubToken);
1507
+ const scaffoldDir = mkdtempSync2(join6(tmpdir2(), "autoclawd-scaffold-"));
1508
+ const askpass = createAskpass2(opts.githubToken);
1256
1509
  try {
1257
1510
  const authedUrl = opts.repoUrl.replace("https://", "https://x-access-token@");
1258
- const env = gitEnv(askpass.path);
1511
+ const env = gitEnv2(askpass.path);
1259
1512
  const readmeLines = [`# ${opts.ticketTitle}`];
1260
1513
  if (opts.ticketDescription) {
1261
1514
  readmeLines.push("", opts.ticketDescription);
1262
1515
  }
1263
- writeFileSync(join5(scaffoldDir, "README.md"), readmeLines.join("\n") + "\n");
1264
- writeFileSync(join5(scaffoldDir, ".autoclawd.yaml"), "base: main\n");
1265
- execSync("git init", { cwd: scaffoldDir, stdio: "pipe", env });
1266
- execSync("git checkout -b main", { cwd: scaffoldDir, stdio: "pipe", env });
1267
- execSync('git config user.email "autoclawd@users.noreply.github.com"', { cwd: scaffoldDir, stdio: "pipe", env });
1268
- execSync('git config user.name "autoclawd"', { cwd: scaffoldDir, stdio: "pipe", env });
1269
- execSync("git add .", { cwd: scaffoldDir, stdio: "pipe", env });
1270
- execSync('git commit -m "chore: initial scaffold"', { cwd: scaffoldDir, stdio: "pipe", env });
1271
- execSync(`git remote add origin ${authedUrl}`, { cwd: scaffoldDir, stdio: "pipe", env });
1272
- execSync("git push -u origin main", { cwd: scaffoldDir, stdio: "pipe", timeout: 6e4, env });
1516
+ writeFileSync2(join6(scaffoldDir, "README.md"), readmeLines.join("\n") + "\n");
1517
+ writeFileSync2(join6(scaffoldDir, ".autoclawd.yaml"), "base: main\n");
1518
+ execSync3("git init", { cwd: scaffoldDir, stdio: "pipe", env });
1519
+ execSync3("git checkout -b main", { cwd: scaffoldDir, stdio: "pipe", env });
1520
+ execSync3('git config user.email "autoclawd@users.noreply.github.com"', { cwd: scaffoldDir, stdio: "pipe", env });
1521
+ execSync3('git config user.name "autoclawd"', { cwd: scaffoldDir, stdio: "pipe", env });
1522
+ execSync3("git add .", { cwd: scaffoldDir, stdio: "pipe", env });
1523
+ execSync3('git commit -m "chore: initial scaffold"', { cwd: scaffoldDir, stdio: "pipe", env });
1524
+ execSync3(`git remote add origin ${authedUrl}`, { cwd: scaffoldDir, stdio: "pipe", env });
1525
+ execSync3("git push -u origin main", { cwd: scaffoldDir, stdio: "pipe", timeout: 6e4, env });
1273
1526
  } finally {
1274
- rmSync(scaffoldDir, { recursive: true, force: true });
1275
- rmSync(askpass.dir, { recursive: true, force: true });
1527
+ rmSync2(scaffoldDir, { recursive: true, force: true });
1528
+ rmSync2(askpass.dir, { recursive: true, force: true });
1276
1529
  }
1277
1530
  }
1278
1531
  async function cloneToTemp(repoUrl, baseBranch, githubToken) {
1279
1532
  assertSafeRef(baseBranch, "base branch");
1280
1533
  return retrySync(() => {
1281
- const workDir = mkdtempSync(join5(tmpdir(), "autoclawd-"));
1282
- const askpass = createAskpass(githubToken);
1534
+ const workDir = mkdtempSync2(join6(tmpdir2(), "autoclawd-"));
1535
+ const askpass = createAskpass2(githubToken);
1283
1536
  const authedUrl = repoUrl.replace("https://", "https://x-access-token@");
1284
1537
  try {
1285
- execSync(`git clone --depth=50 -b ${baseBranch} -- ${authedUrl} ${workDir}`, {
1538
+ execSync3(`git clone --depth=50 -b ${baseBranch} -- ${authedUrl} ${workDir}`, {
1286
1539
  stdio: "pipe",
1287
1540
  timeout: 12e4,
1288
- env: gitEnv(askpass.path)
1541
+ env: gitEnv2(askpass.path)
1289
1542
  });
1290
1543
  } catch (err) {
1291
- rmSync(workDir, { recursive: true, force: true });
1544
+ rmSync2(workDir, { recursive: true, force: true });
1292
1545
  throw err;
1293
1546
  } finally {
1294
- rmSync(askpass.dir, { recursive: true, force: true });
1547
+ rmSync2(askpass.dir, { recursive: true, force: true });
1295
1548
  }
1296
1549
  return workDir;
1297
1550
  }, { label: "git clone", retryIf: isTransientError });
@@ -1428,7 +1681,7 @@ async function executeTicket(opts) {
1428
1681
  });
1429
1682
  log.ticket(ticket.identifier, "Repo created, pushed scaffold");
1430
1683
  }
1431
- const detectedBase = repoCreated ? "main" : detectDefaultBranch(ticket.repoUrl, config.github.token);
1684
+ const detectedBase = repoCreated ? "main" : detectDefaultBranch2(ticket.repoUrl, config.github.token);
1432
1685
  log.ticket(ticket.identifier, `Default branch: ${detectedBase}`);
1433
1686
  workDir = await cloneToTemp(ticket.repoUrl, detectedBase, config.github.token);
1434
1687
  log.ticket(ticket.identifier, "Cloned repo");
@@ -1442,10 +1695,24 @@ async function executeTicket(opts) {
1442
1695
  log.ticket(ticket.identifier, `Stacked on branch: ${ticket.baseBranch}`);
1443
1696
  }
1444
1697
  if (actualBase !== detectedBase) {
1445
- rmSync(workDir, { recursive: true, force: true });
1446
- workDir = await cloneToTemp(ticket.repoUrl, actualBase, config.github.token);
1698
+ rmSync2(workDir, { recursive: true, force: true });
1699
+ try {
1700
+ workDir = await cloneToTemp(ticket.repoUrl, actualBase, config.github.token);
1701
+ } catch (err) {
1702
+ const msg = err instanceof Error ? err.message : String(err);
1703
+ if (msg.includes("not found in upstream") || msg.includes("Could not find remote branch")) {
1704
+ log.ticket(ticket.identifier, `Base branch "${actualBase}" not found \u2014 will retry later`);
1705
+ return {
1706
+ ticketId: ticket.identifier,
1707
+ success: false,
1708
+ error: `REQUEUE: base branch "${actualBase}" does not exist yet`,
1709
+ iterations: 0
1710
+ };
1711
+ }
1712
+ throw err;
1713
+ }
1447
1714
  }
1448
- execSync(`git checkout -B ${branchName} --`, { cwd: workDir, stdio: "pipe" });
1715
+ execSync3(`git checkout -B ${branchName} --`, { cwd: workDir, stdio: "pipe" });
1449
1716
  container = await createContainer({
1450
1717
  dockerConfig: docker2,
1451
1718
  workspacePath: workDir,
@@ -1520,6 +1787,10 @@ async function executeTicket(opts) {
1520
1787
  body: buildPRBody({ commitCount: gitResult.commitCount }),
1521
1788
  draft: hasValidation ? true : false
1522
1789
  });
1790
+ if (!hasValidation && config.github.autoMerge) {
1791
+ const method = config.github.mergeMethod.toUpperCase();
1792
+ await enableAutoMerge(octokit, { repoUrl: ticket.repoUrl, prNumber: pr.number, mergeMethod: method });
1793
+ }
1523
1794
  if (hasValidation) {
1524
1795
  let iterationsUsed = agentResult.iterations;
1525
1796
  let lastFailures = [];
@@ -1575,6 +1846,10 @@ async function executeTicket(opts) {
1575
1846
  prNumber: pr.number
1576
1847
  });
1577
1848
  log.ticket(ticket.identifier, "PR marked as ready for review");
1849
+ if (config.github.autoMerge) {
1850
+ const method = config.github.mergeMethod.toUpperCase();
1851
+ await enableAutoMerge(octokit, { repoUrl: ticket.repoUrl, prNumber: pr.number, mergeMethod: method });
1852
+ }
1578
1853
  }
1579
1854
  }
1580
1855
  await completeTicket(linearClient, config, ticket.id, `PR opened: ${pr.url}`);
@@ -1612,7 +1887,7 @@ async function executeTicket(opts) {
1612
1887
  }
1613
1888
  if (workDir) {
1614
1889
  try {
1615
- rmSync(workDir, { recursive: true, force: true });
1890
+ rmSync2(workDir, { recursive: true, force: true });
1616
1891
  } catch {
1617
1892
  }
1618
1893
  }
@@ -1701,7 +1976,7 @@ async function fixPR(opts) {
1701
1976
  if (container) await destroyContainer(container);
1702
1977
  if (workDir) {
1703
1978
  try {
1704
- rmSync(workDir, { recursive: true, force: true });
1979
+ rmSync2(workDir, { recursive: true, force: true });
1705
1980
  } catch {
1706
1981
  }
1707
1982
  }
@@ -1884,8 +2159,17 @@ var WebhookServer = class {
1884
2159
  linearClient: this.linearClient,
1885
2160
  octokit: this.octokit
1886
2161
  }).then(
1887
- () => {
1888
- finishRun(ticket.id, "success");
2162
+ (result) => {
2163
+ if (result.error?.startsWith("REQUEUE:")) {
2164
+ log.ticket(ticket.identifier, "Re-queued (waiting for dependency branch)");
2165
+ finishRun(ticket.id, "failed", result.error);
2166
+ removeFromProcessed(ticket.id);
2167
+ enqueue(ticket);
2168
+ } else if (result.success) {
2169
+ finishRun(ticket.id, "success");
2170
+ } else {
2171
+ finishRun(ticket.id, "failed", result.error);
2172
+ }
1889
2173
  },
1890
2174
  (err) => {
1891
2175
  finishRun(ticket.id, "failed", err.message);
@@ -1966,6 +2250,8 @@ var Watcher = class {
1966
2250
  activeInMemory = /* @__PURE__ */ new Set();
1967
2251
  timer;
1968
2252
  stopped = false;
2253
+ pollCount = 0;
2254
+ rebaseRunning = false;
1969
2255
  async start(intervalSeconds, once) {
1970
2256
  getDb();
1971
2257
  this.resumeQueue();
@@ -2030,6 +2316,36 @@ var Watcher = class {
2030
2316
  } catch (err) {
2031
2317
  log.error(`Poll failed: ${err instanceof Error ? err.message : err}`);
2032
2318
  }
2319
+ this.pollCount++;
2320
+ if (this.pollCount % 5 === 0) {
2321
+ void this.rebaseSweep();
2322
+ }
2323
+ }
2324
+ async rebaseSweep() {
2325
+ if (this.rebaseRunning) return;
2326
+ this.rebaseRunning = true;
2327
+ try {
2328
+ const repos = this.config.safety.allowedRepos;
2329
+ if (!repos || repos.length === 0) return;
2330
+ for (const allowed of repos) {
2331
+ const repoUrl = allowed.startsWith("https://") ? allowed : `https://github.com/${allowed}`;
2332
+ if (!isRepoAllowed(repoUrl, repos)) continue;
2333
+ try {
2334
+ const count = await rebaseStackedPRs(this.octokit, {
2335
+ repoUrl,
2336
+ githubToken: this.config.github.token,
2337
+ branchPrefix: this.config.safety.branchPrefix
2338
+ });
2339
+ if (count > 0) {
2340
+ log.info(`Rebase sweep: rebased ${count} stacked PR(s) in ${allowed}`);
2341
+ }
2342
+ } catch (err) {
2343
+ log.debug(`Rebase sweep failed for ${allowed}: ${err instanceof Error ? err.message : err}`);
2344
+ }
2345
+ }
2346
+ } finally {
2347
+ this.rebaseRunning = false;
2348
+ }
2033
2349
  }
2034
2350
  isNewTicket(ticket) {
2035
2351
  if (this.activeInMemory.has(ticket.id)) return false;
@@ -2047,8 +2363,17 @@ var Watcher = class {
2047
2363
  linearClient: this.linearClient,
2048
2364
  octokit: this.octokit
2049
2365
  }).then(
2050
- () => {
2051
- finishRun(ticket.id, "success");
2366
+ (result) => {
2367
+ if (result.error?.startsWith("REQUEUE:")) {
2368
+ log.ticket(ticket.identifier, "Re-queued (waiting for dependency branch)");
2369
+ finishRun(ticket.id, "failed", result.error);
2370
+ removeFromProcessed(ticket.id);
2371
+ enqueue(ticket);
2372
+ } else if (result.success) {
2373
+ finishRun(ticket.id, "success");
2374
+ } else {
2375
+ finishRun(ticket.id, "failed", result.error);
2376
+ }
2052
2377
  },
2053
2378
  (err) => {
2054
2379
  finishRun(ticket.id, "failed", err.message);
@@ -2082,11 +2407,11 @@ var Watcher = class {
2082
2407
 
2083
2408
  // src/history.ts
2084
2409
  import { readFileSync as readFileSync3, existsSync as existsSync5 } from "fs";
2085
- import { join as join6 } from "path";
2410
+ import { join as join7 } from "path";
2086
2411
  import { homedir as homedir4 } from "os";
2087
2412
  import chalk2 from "chalk";
2088
- var HISTORY_DIR = join6(homedir4(), ".autoclawd");
2089
- var HISTORY_FILE = join6(HISTORY_DIR, "history.jsonl");
2413
+ var HISTORY_DIR = join7(homedir4(), ".autoclawd");
2414
+ var HISTORY_FILE = join7(HISTORY_DIR, "history.jsonl");
2090
2415
  function runToRecord(run) {
2091
2416
  return {
2092
2417
  ticketId: run.ticket_id,
@@ -2176,9 +2501,9 @@ function printHistoryTable(records) {
2176
2501
  }
2177
2502
 
2178
2503
  // src/deps.ts
2179
- import { execSync as execSync2 } from "child_process";
2504
+ import { execSync as execSync4 } from "child_process";
2180
2505
  import { existsSync as existsSync6 } from "fs";
2181
- import { join as join7 } from "path";
2506
+ import { join as join8 } from "path";
2182
2507
  import { homedir as homedir5 } from "os";
2183
2508
  function detectPackageManager() {
2184
2509
  const checks = [
@@ -2191,7 +2516,7 @@ function detectPackageManager() {
2191
2516
  ];
2192
2517
  for (const [name, cmd] of checks) {
2193
2518
  try {
2194
- execSync2(`which ${cmd}`, { stdio: "pipe" });
2519
+ execSync4(`which ${cmd}`, { stdio: "pipe" });
2195
2520
  return name;
2196
2521
  } catch {
2197
2522
  }
@@ -2264,7 +2589,7 @@ function checkDeps() {
2264
2589
  },
2265
2590
  {
2266
2591
  name: "Claude credentials",
2267
- installed: existsSync6(join7(homedir5(), ".claude", ".credentials.json")),
2592
+ installed: existsSync6(join8(homedir5(), ".claude", ".credentials.json")),
2268
2593
  installable: false,
2269
2594
  installCommands: [],
2270
2595
  manualInstructions: 'Run "claude" and complete the login flow'
@@ -2282,7 +2607,7 @@ function installDep(dep) {
2282
2607
  if (dep.installCommands.length === 0) return false;
2283
2608
  for (const cmd of dep.installCommands) {
2284
2609
  try {
2285
- execSync2(cmd, { stdio: "inherit", timeout: 3e5 });
2610
+ execSync4(cmd, { stdio: "inherit", timeout: 3e5 });
2286
2611
  } catch {
2287
2612
  return false;
2288
2613
  }
@@ -2292,15 +2617,15 @@ function installDep(dep) {
2292
2617
  function addUserToDockerGroup() {
2293
2618
  if (process.platform !== "linux") return;
2294
2619
  try {
2295
- const user = execSync2("whoami", { encoding: "utf-8" }).trim();
2296
- execSync2(`sudo usermod -aG docker ${user}`, { stdio: "pipe" });
2620
+ const user = execSync4("whoami", { encoding: "utf-8" }).trim();
2621
+ execSync4(`sudo usermod -aG docker ${user}`, { stdio: "pipe" });
2297
2622
  log.info(`Added ${user} to docker group \u2014 log out and back in to apply`);
2298
2623
  } catch {
2299
2624
  }
2300
2625
  }
2301
2626
  function commandExists(cmd) {
2302
2627
  try {
2303
- execSync2(`which ${cmd}`, { stdio: "pipe" });
2628
+ execSync4(`which ${cmd}`, { stdio: "pipe" });
2304
2629
  return true;
2305
2630
  } catch {
2306
2631
  return false;
@@ -2308,7 +2633,7 @@ function commandExists(cmd) {
2308
2633
  }
2309
2634
  function isDockerRunning() {
2310
2635
  try {
2311
- execSync2("docker info", { stdio: "pipe", timeout: 1e4 });
2636
+ execSync4("docker info", { stdio: "pipe", timeout: 1e4 });
2312
2637
  return true;
2313
2638
  } catch {
2314
2639
  return false;
@@ -2316,10 +2641,10 @@ function isDockerRunning() {
2316
2641
  }
2317
2642
 
2318
2643
  // src/index.ts
2319
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2, readFileSync as readFileSync4, unlinkSync } from "fs";
2320
- import { execSync as execSync3, spawn as spawn2 } from "child_process";
2644
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
2645
+ import { execSync as execSync5, spawn as spawn2 } from "child_process";
2321
2646
  import { createInterface } from "readline";
2322
- import { join as join8 } from "path";
2647
+ import { join as join9 } from "path";
2323
2648
  import { homedir as homedir6 } from "os";
2324
2649
  import { LinearClient as LinearClient2 } from "@linear/sdk";
2325
2650
  import { Octokit as Octokit2 } from "@octokit/rest";
@@ -2327,7 +2652,7 @@ import { createRequire } from "module";
2327
2652
  var require2 = createRequire(import.meta.url);
2328
2653
  var pkg = require2("../package.json");
2329
2654
  var program = new Command();
2330
- var WATCHER_PID_FILE = join8(homedir6(), ".autoclawd", "watcher.pid");
2655
+ var WATCHER_PID_FILE = join9(homedir6(), ".autoclawd", "watcher.pid");
2331
2656
  function readWatcherPid() {
2332
2657
  try {
2333
2658
  const content = readFileSync4(WATCHER_PID_FILE, "utf-8").trim();
@@ -2338,8 +2663,8 @@ function readWatcherPid() {
2338
2663
  }
2339
2664
  }
2340
2665
  function writeWatcherPid(pid) {
2341
- mkdirSync3(join8(homedir6(), ".autoclawd"), { recursive: true });
2342
- writeFileSync2(WATCHER_PID_FILE, String(pid), { mode: 384 });
2666
+ mkdirSync3(join9(homedir6(), ".autoclawd"), { recursive: true });
2667
+ writeFileSync3(WATCHER_PID_FILE, String(pid), { mode: 384 });
2343
2668
  }
2344
2669
  function removeWatcherPid() {
2345
2670
  try {
@@ -2396,25 +2721,7 @@ program.command("serve").description("Start webhook server with auto-tunnel").op
2396
2721
  const webhookUrl = `${tunnel.url}/api/v1/linear/webhook`;
2397
2722
  log.success(`Tunnel: ${tunnel.url}`);
2398
2723
  log.info(`Webhook URL: ${webhookUrl}`);
2399
- if (config.webhook.webhookId) {
2400
- try {
2401
- await linearClient.deleteWebhook(config.webhook.webhookId);
2402
- } catch {
2403
- }
2404
- }
2405
- const team = await linearClient.team(config.linear.teamId);
2406
- const result = await linearClient.createWebhook({
2407
- url: webhookUrl,
2408
- resourceTypes: ["Issue"],
2409
- label: "autoclawd",
2410
- teamId: team.id
2411
- });
2412
- const webhook = await result.webhook;
2413
- if (webhook) {
2414
- log.success(`Linear webhook created \u2192 ${webhookUrl}`);
2415
- config.webhook.webhookId = webhook.id;
2416
- config.webhook.signingSecret = void 0;
2417
- }
2724
+ log.info("Configure this URL as a webhook in Linear (Settings \u2192 API \u2192 Webhooks)");
2418
2725
  } catch (err) {
2419
2726
  log.warn(`Tunnel failed: ${err instanceof Error ? err.message : err}`);
2420
2727
  log.info("Falling back to local-only mode. Set up your own reverse proxy.");
@@ -2669,6 +2976,37 @@ Retrying ticket ${id}...`);
2669
2976
  process.off("SIGTERM", onSignal);
2670
2977
  }
2671
2978
  });
2979
+ program.command("rebase [repo]").description("Rebase stacked PRs whose base branch has been merged (e.g. autoclawd rebase owner/repo)").option("-c, --config <path>", "Config file path").option("-v, --verbose", "Verbose logging").action(async (repoArg, opts) => {
2980
+ if (opts.verbose) setLogLevel("debug");
2981
+ enableFileLogging();
2982
+ const config = loadConfig(opts.config);
2983
+ const octokit = createOctokit(config);
2984
+ let repos;
2985
+ if (repoArg) {
2986
+ repos = [repoArg.startsWith("https://") ? repoArg : `https://github.com/${repoArg}`];
2987
+ } else if (config.safety.allowedRepos && config.safety.allowedRepos.length > 0) {
2988
+ repos = config.safety.allowedRepos.map(
2989
+ (r) => r.startsWith("https://") ? r : `https://github.com/${r}`
2990
+ );
2991
+ } else {
2992
+ throw new Error("Provide a repo (autoclawd rebase owner/repo) or configure safety.allowedRepos");
2993
+ }
2994
+ let totalRebased = 0;
2995
+ for (const repoUrl of repos) {
2996
+ log.info(`Checking ${repoUrl} for stacked PRs to rebase...`);
2997
+ const count = await rebaseStackedPRs(octokit, {
2998
+ repoUrl,
2999
+ githubToken: config.github.token,
3000
+ branchPrefix: config.safety.branchPrefix
3001
+ });
3002
+ totalRebased += count;
3003
+ }
3004
+ if (totalRebased === 0) {
3005
+ log.info("No stacked PRs needed rebasing");
3006
+ } else {
3007
+ log.success(`Rebased ${totalRebased} stacked PR(s)`);
3008
+ }
3009
+ });
2672
3010
  program.command("validate").description("Validate config without running anything").option("-c, --config <path>", "Config file path").action(async (opts) => {
2673
3011
  try {
2674
3012
  const config = loadConfig(opts.config);
@@ -2707,7 +3045,7 @@ program.command("history").description("Show run history").option("-n, --limit <
2707
3045
  }
2708
3046
  });
2709
3047
  function checkClaudeCredentials() {
2710
- return existsSync7(join8(homedir6(), ".claude", ".credentials.json"));
3048
+ return existsSync7(join9(homedir6(), ".claude", ".credentials.json"));
2711
3049
  }
2712
3050
  async function validateGitHubToken(token) {
2713
3051
  try {
@@ -2810,7 +3148,7 @@ program.command("init").description("Set up autoclawd interactively").action(asy
2810
3148
  }
2811
3149
  let ghToken = "";
2812
3150
  try {
2813
- ghToken = execSync3("gh auth token", { encoding: "utf-8" }).trim();
3151
+ ghToken = execSync5("gh auth token", { encoding: "utf-8" }).trim();
2814
3152
  log.success(`GitHub token detected from gh CLI`);
2815
3153
  } catch {
2816
3154
  ghToken = await ask("GitHub personal access token");
@@ -2861,7 +3199,7 @@ agent:
2861
3199
 
2862
3200
  maxConcurrent: 1
2863
3201
  `;
2864
- writeFileSync2(CONFIG_FILE, config, { mode: 384 });
3202
+ writeFileSync3(CONFIG_FILE, config, { mode: 384 });
2865
3203
  log.success(`Config saved to ${CONFIG_FILE}`);
2866
3204
  console.log(`
2867
3205
  Setup complete! Next steps:
@@ -2872,10 +3210,12 @@ maxConcurrent: 1
2872
3210
  2. Tag tickets with a repo: label
2873
3211
 
2874
3212
  3. Start autoclawd:
2875
- $ autoclawd serve
3213
+ $ autoclawd watch # polling mode (recommended)
3214
+ $ autoclawd serve # webhook mode
2876
3215
 
2877
- autoclawd serve will auto-create a tunnel and
2878
- register the webhook with Linear. Just run it.
3216
+ For webhook mode, configure the webhook URL manually in
3217
+ Linear \u2192 Settings \u2192 API \u2192 Webhooks. autoclawd serve will
3218
+ print the tunnel URL to use.
2879
3219
  `);
2880
3220
  });
2881
3221
  program.parseAsync().catch((err) => {