@autoclawd/autoclawd 1.0.4 → 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 +398 -94
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1310
|
+
import { join as join5 } from "path";
|
|
1062
1311
|
import { mkdirSync as mkdirSync2, existsSync as existsSync4 } from "fs";
|
|
1063
|
-
var DB_PATH =
|
|
1312
|
+
var DB_PATH = join5(CONFIG_DIR, "autoclawd.db");
|
|
1064
1313
|
var _db;
|
|
1065
1314
|
function getDb() {
|
|
1066
1315
|
if (_db) return _db;
|
|
@@ -1227,75 +1476,75 @@ function assertSafeRef(name, label) {
|
|
|
1227
1476
|
throw new Error(`Invalid ${label}: "${name}"`);
|
|
1228
1477
|
}
|
|
1229
1478
|
}
|
|
1230
|
-
function
|
|
1231
|
-
const dir =
|
|
1232
|
-
const path =
|
|
1233
|
-
|
|
1479
|
+
function createAskpass2(githubToken) {
|
|
1480
|
+
const dir = mkdtempSync2(join6(tmpdir2(), "autoclawd-cred-"));
|
|
1481
|
+
const path = join6(dir, "askpass.sh");
|
|
1482
|
+
writeFileSync2(path, `#!/bin/sh
|
|
1234
1483
|
echo "${githubToken}"
|
|
1235
1484
|
`, { mode: 448 });
|
|
1236
1485
|
return { dir, path };
|
|
1237
1486
|
}
|
|
1238
|
-
function
|
|
1487
|
+
function gitEnv2(askpassPath) {
|
|
1239
1488
|
return { ...process.env, GIT_ASKPASS: askpassPath, GIT_TERMINAL_PROMPT: "0" };
|
|
1240
1489
|
}
|
|
1241
|
-
function
|
|
1242
|
-
const askpass =
|
|
1490
|
+
function detectDefaultBranch2(repoUrl, githubToken) {
|
|
1491
|
+
const askpass = createAskpass2(githubToken);
|
|
1243
1492
|
const authedUrl = repoUrl.replace("https://", "https://x-access-token@");
|
|
1244
1493
|
try {
|
|
1245
|
-
const output =
|
|
1494
|
+
const output = execSync3(
|
|
1246
1495
|
`git ls-remote --symref -- ${authedUrl} HEAD`,
|
|
1247
|
-
{ stdio: "pipe", timeout: 3e4, env:
|
|
1496
|
+
{ stdio: "pipe", timeout: 3e4, env: gitEnv2(askpass.path), encoding: "utf-8" }
|
|
1248
1497
|
);
|
|
1249
1498
|
const match = output.match(/ref:\s+refs\/heads\/(\S+)\s+HEAD/);
|
|
1250
1499
|
if (match) return match[1];
|
|
1251
1500
|
} catch {
|
|
1252
1501
|
} finally {
|
|
1253
|
-
|
|
1502
|
+
rmSync2(askpass.dir, { recursive: true, force: true });
|
|
1254
1503
|
}
|
|
1255
1504
|
return "main";
|
|
1256
1505
|
}
|
|
1257
1506
|
async function pushScaffold(opts) {
|
|
1258
|
-
const scaffoldDir =
|
|
1259
|
-
const askpass =
|
|
1507
|
+
const scaffoldDir = mkdtempSync2(join6(tmpdir2(), "autoclawd-scaffold-"));
|
|
1508
|
+
const askpass = createAskpass2(opts.githubToken);
|
|
1260
1509
|
try {
|
|
1261
1510
|
const authedUrl = opts.repoUrl.replace("https://", "https://x-access-token@");
|
|
1262
|
-
const env =
|
|
1511
|
+
const env = gitEnv2(askpass.path);
|
|
1263
1512
|
const readmeLines = [`# ${opts.ticketTitle}`];
|
|
1264
1513
|
if (opts.ticketDescription) {
|
|
1265
1514
|
readmeLines.push("", opts.ticketDescription);
|
|
1266
1515
|
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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 });
|
|
1277
1526
|
} finally {
|
|
1278
|
-
|
|
1279
|
-
|
|
1527
|
+
rmSync2(scaffoldDir, { recursive: true, force: true });
|
|
1528
|
+
rmSync2(askpass.dir, { recursive: true, force: true });
|
|
1280
1529
|
}
|
|
1281
1530
|
}
|
|
1282
1531
|
async function cloneToTemp(repoUrl, baseBranch, githubToken) {
|
|
1283
1532
|
assertSafeRef(baseBranch, "base branch");
|
|
1284
1533
|
return retrySync(() => {
|
|
1285
|
-
const workDir =
|
|
1286
|
-
const askpass =
|
|
1534
|
+
const workDir = mkdtempSync2(join6(tmpdir2(), "autoclawd-"));
|
|
1535
|
+
const askpass = createAskpass2(githubToken);
|
|
1287
1536
|
const authedUrl = repoUrl.replace("https://", "https://x-access-token@");
|
|
1288
1537
|
try {
|
|
1289
|
-
|
|
1538
|
+
execSync3(`git clone --depth=50 -b ${baseBranch} -- ${authedUrl} ${workDir}`, {
|
|
1290
1539
|
stdio: "pipe",
|
|
1291
1540
|
timeout: 12e4,
|
|
1292
|
-
env:
|
|
1541
|
+
env: gitEnv2(askpass.path)
|
|
1293
1542
|
});
|
|
1294
1543
|
} catch (err) {
|
|
1295
|
-
|
|
1544
|
+
rmSync2(workDir, { recursive: true, force: true });
|
|
1296
1545
|
throw err;
|
|
1297
1546
|
} finally {
|
|
1298
|
-
|
|
1547
|
+
rmSync2(askpass.dir, { recursive: true, force: true });
|
|
1299
1548
|
}
|
|
1300
1549
|
return workDir;
|
|
1301
1550
|
}, { label: "git clone", retryIf: isTransientError });
|
|
@@ -1432,7 +1681,7 @@ async function executeTicket(opts) {
|
|
|
1432
1681
|
});
|
|
1433
1682
|
log.ticket(ticket.identifier, "Repo created, pushed scaffold");
|
|
1434
1683
|
}
|
|
1435
|
-
const detectedBase = repoCreated ? "main" :
|
|
1684
|
+
const detectedBase = repoCreated ? "main" : detectDefaultBranch2(ticket.repoUrl, config.github.token);
|
|
1436
1685
|
log.ticket(ticket.identifier, `Default branch: ${detectedBase}`);
|
|
1437
1686
|
workDir = await cloneToTemp(ticket.repoUrl, detectedBase, config.github.token);
|
|
1438
1687
|
log.ticket(ticket.identifier, "Cloned repo");
|
|
@@ -1446,7 +1695,7 @@ async function executeTicket(opts) {
|
|
|
1446
1695
|
log.ticket(ticket.identifier, `Stacked on branch: ${ticket.baseBranch}`);
|
|
1447
1696
|
}
|
|
1448
1697
|
if (actualBase !== detectedBase) {
|
|
1449
|
-
|
|
1698
|
+
rmSync2(workDir, { recursive: true, force: true });
|
|
1450
1699
|
try {
|
|
1451
1700
|
workDir = await cloneToTemp(ticket.repoUrl, actualBase, config.github.token);
|
|
1452
1701
|
} catch (err) {
|
|
@@ -1463,7 +1712,7 @@ async function executeTicket(opts) {
|
|
|
1463
1712
|
throw err;
|
|
1464
1713
|
}
|
|
1465
1714
|
}
|
|
1466
|
-
|
|
1715
|
+
execSync3(`git checkout -B ${branchName} --`, { cwd: workDir, stdio: "pipe" });
|
|
1467
1716
|
container = await createContainer({
|
|
1468
1717
|
dockerConfig: docker2,
|
|
1469
1718
|
workspacePath: workDir,
|
|
@@ -1538,6 +1787,10 @@ async function executeTicket(opts) {
|
|
|
1538
1787
|
body: buildPRBody({ commitCount: gitResult.commitCount }),
|
|
1539
1788
|
draft: hasValidation ? true : false
|
|
1540
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
|
+
}
|
|
1541
1794
|
if (hasValidation) {
|
|
1542
1795
|
let iterationsUsed = agentResult.iterations;
|
|
1543
1796
|
let lastFailures = [];
|
|
@@ -1593,6 +1846,10 @@ async function executeTicket(opts) {
|
|
|
1593
1846
|
prNumber: pr.number
|
|
1594
1847
|
});
|
|
1595
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
|
+
}
|
|
1596
1853
|
}
|
|
1597
1854
|
}
|
|
1598
1855
|
await completeTicket(linearClient, config, ticket.id, `PR opened: ${pr.url}`);
|
|
@@ -1630,7 +1887,7 @@ async function executeTicket(opts) {
|
|
|
1630
1887
|
}
|
|
1631
1888
|
if (workDir) {
|
|
1632
1889
|
try {
|
|
1633
|
-
|
|
1890
|
+
rmSync2(workDir, { recursive: true, force: true });
|
|
1634
1891
|
} catch {
|
|
1635
1892
|
}
|
|
1636
1893
|
}
|
|
@@ -1719,7 +1976,7 @@ async function fixPR(opts) {
|
|
|
1719
1976
|
if (container) await destroyContainer(container);
|
|
1720
1977
|
if (workDir) {
|
|
1721
1978
|
try {
|
|
1722
|
-
|
|
1979
|
+
rmSync2(workDir, { recursive: true, force: true });
|
|
1723
1980
|
} catch {
|
|
1724
1981
|
}
|
|
1725
1982
|
}
|
|
@@ -1993,6 +2250,8 @@ var Watcher = class {
|
|
|
1993
2250
|
activeInMemory = /* @__PURE__ */ new Set();
|
|
1994
2251
|
timer;
|
|
1995
2252
|
stopped = false;
|
|
2253
|
+
pollCount = 0;
|
|
2254
|
+
rebaseRunning = false;
|
|
1996
2255
|
async start(intervalSeconds, once) {
|
|
1997
2256
|
getDb();
|
|
1998
2257
|
this.resumeQueue();
|
|
@@ -2057,6 +2316,36 @@ var Watcher = class {
|
|
|
2057
2316
|
} catch (err) {
|
|
2058
2317
|
log.error(`Poll failed: ${err instanceof Error ? err.message : err}`);
|
|
2059
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
|
+
}
|
|
2060
2349
|
}
|
|
2061
2350
|
isNewTicket(ticket) {
|
|
2062
2351
|
if (this.activeInMemory.has(ticket.id)) return false;
|
|
@@ -2118,11 +2407,11 @@ var Watcher = class {
|
|
|
2118
2407
|
|
|
2119
2408
|
// src/history.ts
|
|
2120
2409
|
import { readFileSync as readFileSync3, existsSync as existsSync5 } from "fs";
|
|
2121
|
-
import { join as
|
|
2410
|
+
import { join as join7 } from "path";
|
|
2122
2411
|
import { homedir as homedir4 } from "os";
|
|
2123
2412
|
import chalk2 from "chalk";
|
|
2124
|
-
var HISTORY_DIR =
|
|
2125
|
-
var HISTORY_FILE =
|
|
2413
|
+
var HISTORY_DIR = join7(homedir4(), ".autoclawd");
|
|
2414
|
+
var HISTORY_FILE = join7(HISTORY_DIR, "history.jsonl");
|
|
2126
2415
|
function runToRecord(run) {
|
|
2127
2416
|
return {
|
|
2128
2417
|
ticketId: run.ticket_id,
|
|
@@ -2212,9 +2501,9 @@ function printHistoryTable(records) {
|
|
|
2212
2501
|
}
|
|
2213
2502
|
|
|
2214
2503
|
// src/deps.ts
|
|
2215
|
-
import { execSync as
|
|
2504
|
+
import { execSync as execSync4 } from "child_process";
|
|
2216
2505
|
import { existsSync as existsSync6 } from "fs";
|
|
2217
|
-
import { join as
|
|
2506
|
+
import { join as join8 } from "path";
|
|
2218
2507
|
import { homedir as homedir5 } from "os";
|
|
2219
2508
|
function detectPackageManager() {
|
|
2220
2509
|
const checks = [
|
|
@@ -2227,7 +2516,7 @@ function detectPackageManager() {
|
|
|
2227
2516
|
];
|
|
2228
2517
|
for (const [name, cmd] of checks) {
|
|
2229
2518
|
try {
|
|
2230
|
-
|
|
2519
|
+
execSync4(`which ${cmd}`, { stdio: "pipe" });
|
|
2231
2520
|
return name;
|
|
2232
2521
|
} catch {
|
|
2233
2522
|
}
|
|
@@ -2300,7 +2589,7 @@ function checkDeps() {
|
|
|
2300
2589
|
},
|
|
2301
2590
|
{
|
|
2302
2591
|
name: "Claude credentials",
|
|
2303
|
-
installed: existsSync6(
|
|
2592
|
+
installed: existsSync6(join8(homedir5(), ".claude", ".credentials.json")),
|
|
2304
2593
|
installable: false,
|
|
2305
2594
|
installCommands: [],
|
|
2306
2595
|
manualInstructions: 'Run "claude" and complete the login flow'
|
|
@@ -2318,7 +2607,7 @@ function installDep(dep) {
|
|
|
2318
2607
|
if (dep.installCommands.length === 0) return false;
|
|
2319
2608
|
for (const cmd of dep.installCommands) {
|
|
2320
2609
|
try {
|
|
2321
|
-
|
|
2610
|
+
execSync4(cmd, { stdio: "inherit", timeout: 3e5 });
|
|
2322
2611
|
} catch {
|
|
2323
2612
|
return false;
|
|
2324
2613
|
}
|
|
@@ -2328,15 +2617,15 @@ function installDep(dep) {
|
|
|
2328
2617
|
function addUserToDockerGroup() {
|
|
2329
2618
|
if (process.platform !== "linux") return;
|
|
2330
2619
|
try {
|
|
2331
|
-
const user =
|
|
2332
|
-
|
|
2620
|
+
const user = execSync4("whoami", { encoding: "utf-8" }).trim();
|
|
2621
|
+
execSync4(`sudo usermod -aG docker ${user}`, { stdio: "pipe" });
|
|
2333
2622
|
log.info(`Added ${user} to docker group \u2014 log out and back in to apply`);
|
|
2334
2623
|
} catch {
|
|
2335
2624
|
}
|
|
2336
2625
|
}
|
|
2337
2626
|
function commandExists(cmd) {
|
|
2338
2627
|
try {
|
|
2339
|
-
|
|
2628
|
+
execSync4(`which ${cmd}`, { stdio: "pipe" });
|
|
2340
2629
|
return true;
|
|
2341
2630
|
} catch {
|
|
2342
2631
|
return false;
|
|
@@ -2344,7 +2633,7 @@ function commandExists(cmd) {
|
|
|
2344
2633
|
}
|
|
2345
2634
|
function isDockerRunning() {
|
|
2346
2635
|
try {
|
|
2347
|
-
|
|
2636
|
+
execSync4("docker info", { stdio: "pipe", timeout: 1e4 });
|
|
2348
2637
|
return true;
|
|
2349
2638
|
} catch {
|
|
2350
2639
|
return false;
|
|
@@ -2352,10 +2641,10 @@ function isDockerRunning() {
|
|
|
2352
2641
|
}
|
|
2353
2642
|
|
|
2354
2643
|
// src/index.ts
|
|
2355
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync3, writeFileSync as
|
|
2356
|
-
import { execSync as
|
|
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";
|
|
2357
2646
|
import { createInterface } from "readline";
|
|
2358
|
-
import { join as
|
|
2647
|
+
import { join as join9 } from "path";
|
|
2359
2648
|
import { homedir as homedir6 } from "os";
|
|
2360
2649
|
import { LinearClient as LinearClient2 } from "@linear/sdk";
|
|
2361
2650
|
import { Octokit as Octokit2 } from "@octokit/rest";
|
|
@@ -2363,7 +2652,7 @@ import { createRequire } from "module";
|
|
|
2363
2652
|
var require2 = createRequire(import.meta.url);
|
|
2364
2653
|
var pkg = require2("../package.json");
|
|
2365
2654
|
var program = new Command();
|
|
2366
|
-
var WATCHER_PID_FILE =
|
|
2655
|
+
var WATCHER_PID_FILE = join9(homedir6(), ".autoclawd", "watcher.pid");
|
|
2367
2656
|
function readWatcherPid() {
|
|
2368
2657
|
try {
|
|
2369
2658
|
const content = readFileSync4(WATCHER_PID_FILE, "utf-8").trim();
|
|
@@ -2374,8 +2663,8 @@ function readWatcherPid() {
|
|
|
2374
2663
|
}
|
|
2375
2664
|
}
|
|
2376
2665
|
function writeWatcherPid(pid) {
|
|
2377
|
-
mkdirSync3(
|
|
2378
|
-
|
|
2666
|
+
mkdirSync3(join9(homedir6(), ".autoclawd"), { recursive: true });
|
|
2667
|
+
writeFileSync3(WATCHER_PID_FILE, String(pid), { mode: 384 });
|
|
2379
2668
|
}
|
|
2380
2669
|
function removeWatcherPid() {
|
|
2381
2670
|
try {
|
|
@@ -2432,25 +2721,7 @@ program.command("serve").description("Start webhook server with auto-tunnel").op
|
|
|
2432
2721
|
const webhookUrl = `${tunnel.url}/api/v1/linear/webhook`;
|
|
2433
2722
|
log.success(`Tunnel: ${tunnel.url}`);
|
|
2434
2723
|
log.info(`Webhook URL: ${webhookUrl}`);
|
|
2435
|
-
|
|
2436
|
-
try {
|
|
2437
|
-
await linearClient.deleteWebhook(config.webhook.webhookId);
|
|
2438
|
-
} catch {
|
|
2439
|
-
}
|
|
2440
|
-
}
|
|
2441
|
-
const team = await linearClient.team(config.linear.teamId);
|
|
2442
|
-
const result = await linearClient.createWebhook({
|
|
2443
|
-
url: webhookUrl,
|
|
2444
|
-
resourceTypes: ["Issue"],
|
|
2445
|
-
label: "autoclawd",
|
|
2446
|
-
teamId: team.id
|
|
2447
|
-
});
|
|
2448
|
-
const webhook = await result.webhook;
|
|
2449
|
-
if (webhook) {
|
|
2450
|
-
log.success(`Linear webhook created \u2192 ${webhookUrl}`);
|
|
2451
|
-
config.webhook.webhookId = webhook.id;
|
|
2452
|
-
config.webhook.signingSecret = void 0;
|
|
2453
|
-
}
|
|
2724
|
+
log.info("Configure this URL as a webhook in Linear (Settings \u2192 API \u2192 Webhooks)");
|
|
2454
2725
|
} catch (err) {
|
|
2455
2726
|
log.warn(`Tunnel failed: ${err instanceof Error ? err.message : err}`);
|
|
2456
2727
|
log.info("Falling back to local-only mode. Set up your own reverse proxy.");
|
|
@@ -2705,6 +2976,37 @@ Retrying ticket ${id}...`);
|
|
|
2705
2976
|
process.off("SIGTERM", onSignal);
|
|
2706
2977
|
}
|
|
2707
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
|
+
});
|
|
2708
3010
|
program.command("validate").description("Validate config without running anything").option("-c, --config <path>", "Config file path").action(async (opts) => {
|
|
2709
3011
|
try {
|
|
2710
3012
|
const config = loadConfig(opts.config);
|
|
@@ -2743,7 +3045,7 @@ program.command("history").description("Show run history").option("-n, --limit <
|
|
|
2743
3045
|
}
|
|
2744
3046
|
});
|
|
2745
3047
|
function checkClaudeCredentials() {
|
|
2746
|
-
return existsSync7(
|
|
3048
|
+
return existsSync7(join9(homedir6(), ".claude", ".credentials.json"));
|
|
2747
3049
|
}
|
|
2748
3050
|
async function validateGitHubToken(token) {
|
|
2749
3051
|
try {
|
|
@@ -2846,7 +3148,7 @@ program.command("init").description("Set up autoclawd interactively").action(asy
|
|
|
2846
3148
|
}
|
|
2847
3149
|
let ghToken = "";
|
|
2848
3150
|
try {
|
|
2849
|
-
ghToken =
|
|
3151
|
+
ghToken = execSync5("gh auth token", { encoding: "utf-8" }).trim();
|
|
2850
3152
|
log.success(`GitHub token detected from gh CLI`);
|
|
2851
3153
|
} catch {
|
|
2852
3154
|
ghToken = await ask("GitHub personal access token");
|
|
@@ -2897,7 +3199,7 @@ agent:
|
|
|
2897
3199
|
|
|
2898
3200
|
maxConcurrent: 1
|
|
2899
3201
|
`;
|
|
2900
|
-
|
|
3202
|
+
writeFileSync3(CONFIG_FILE, config, { mode: 384 });
|
|
2901
3203
|
log.success(`Config saved to ${CONFIG_FILE}`);
|
|
2902
3204
|
console.log(`
|
|
2903
3205
|
Setup complete! Next steps:
|
|
@@ -2908,10 +3210,12 @@ maxConcurrent: 1
|
|
|
2908
3210
|
2. Tag tickets with a repo: label
|
|
2909
3211
|
|
|
2910
3212
|
3. Start autoclawd:
|
|
2911
|
-
$ autoclawd
|
|
3213
|
+
$ autoclawd watch # polling mode (recommended)
|
|
3214
|
+
$ autoclawd serve # webhook mode
|
|
2912
3215
|
|
|
2913
|
-
|
|
2914
|
-
|
|
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.
|
|
2915
3219
|
`);
|
|
2916
3220
|
});
|
|
2917
3221
|
program.parseAsync().catch((err) => {
|