santree 0.1.2 → 0.1.4
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/README.md +1 -0
- package/dist/commands/helpers/template.d.ts +2 -0
- package/dist/commands/helpers/template.js +48 -11
- package/dist/commands/linear/switch.d.ts +2 -0
- package/dist/commands/linear/switch.js +71 -0
- package/dist/commands/pr/create.js +5 -7
- package/dist/commands/pr/fix.js +19 -12
- package/dist/commands/pr/review.js +18 -11
- package/dist/commands/worktree/remove.d.ts +4 -2
- package/dist/commands/worktree/remove.js +60 -19
- package/dist/commands/worktree/work.js +16 -10
- package/dist/lib/ai.d.ts +20 -7
- package/dist/lib/ai.js +89 -23
- package/dist/lib/exec.d.ts +7 -0
- package/dist/lib/exec.js +15 -1
- package/dist/lib/github.d.ts +19 -42
- package/dist/lib/github.js +49 -96
- package/package.json +1 -1
- package/prompts/fix-pr.njk +3 -1
- package/prompts/review.njk +3 -1
- package/prompts/work.njk +3 -1
package/README.md
CHANGED
|
@@ -110,6 +110,7 @@ With the `stw` alias: `stw create`, `stw list`, `stw switch`, `stw work`, `stw c
|
|
|
110
110
|
| Command | Description |
|
|
111
111
|
|---------|-------------|
|
|
112
112
|
| `santree linear auth` | Authenticate with Linear (OAuth) |
|
|
113
|
+
| `santree linear switch` | Switch Linear workspace for this repo |
|
|
113
114
|
| `santree linear open` | Open the current Linear ticket in the browser |
|
|
114
115
|
|
|
115
116
|
### Helpers (`santree helpers`)
|
|
@@ -7,12 +7,13 @@ import { z } from "zod/v4";
|
|
|
7
7
|
import { findRepoRoot, findMainRepoRoot, getCurrentBranch, extractTicketId, } from "../../lib/git.js";
|
|
8
8
|
import { renderTicket } from "../../lib/prompts.js";
|
|
9
9
|
import { getTicketContent } from "../../lib/linear.js";
|
|
10
|
-
import { fetchAndRenderPR, fetchAndRenderDiff } from "../../lib/ai.js";
|
|
10
|
+
import { resolveAIContext, renderAIPrompt, fetchAndRenderPR, fetchAndRenderDiff, } from "../../lib/ai.js";
|
|
11
11
|
export const description = "Render a template to stdout";
|
|
12
12
|
export const args = z.tuple([
|
|
13
|
-
z
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
z.enum(["linear", "git-changes", "pr", "fix-pr", "review"]).describe(argument({
|
|
14
|
+
name: "type",
|
|
15
|
+
description: "Template type (linear, git-changes, pr, fix-pr, or review)",
|
|
16
|
+
})),
|
|
16
17
|
]);
|
|
17
18
|
export default function Template({ args }) {
|
|
18
19
|
const [type] = args;
|
|
@@ -41,13 +42,13 @@ export default function Template({ args }) {
|
|
|
41
42
|
return;
|
|
42
43
|
}
|
|
43
44
|
if (type === "git-changes") {
|
|
44
|
-
const output = fetchAndRenderDiff(branch);
|
|
45
|
+
const output = await fetchAndRenderDiff(branch);
|
|
45
46
|
process.stdout.write(output);
|
|
46
47
|
setStatus("done");
|
|
47
48
|
setTimeout(() => exit(), 100);
|
|
48
49
|
}
|
|
49
50
|
else if (type === "pr") {
|
|
50
|
-
const output = fetchAndRenderPR(branch);
|
|
51
|
+
const output = await fetchAndRenderPR(branch);
|
|
51
52
|
if (!output) {
|
|
52
53
|
setStatus("error");
|
|
53
54
|
setMessage(`No pull request found for branch '${branch}'`);
|
|
@@ -58,6 +59,39 @@ export default function Template({ args }) {
|
|
|
58
59
|
setStatus("done");
|
|
59
60
|
setTimeout(() => exit(), 100);
|
|
60
61
|
}
|
|
62
|
+
else if (type === "fix-pr" || type === "review") {
|
|
63
|
+
const result = await resolveAIContext();
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
setStatus("error");
|
|
66
|
+
setMessage(result.error);
|
|
67
|
+
setTimeout(() => exit(), 100);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const ctx = result.context;
|
|
71
|
+
const diffContent = await fetchAndRenderDiff(branch);
|
|
72
|
+
if (type === "fix-pr") {
|
|
73
|
+
const prFeedback = await fetchAndRenderPR(branch);
|
|
74
|
+
if (!prFeedback) {
|
|
75
|
+
setStatus("error");
|
|
76
|
+
setMessage(`No pull request found for branch '${branch}'`);
|
|
77
|
+
setTimeout(() => exit(), 100);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const output = renderAIPrompt("fix-pr", ctx, {
|
|
81
|
+
pr_feedback: prFeedback,
|
|
82
|
+
diff_content: diffContent,
|
|
83
|
+
});
|
|
84
|
+
process.stdout.write(output);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const output = renderAIPrompt("review", ctx, {
|
|
88
|
+
diff_content: diffContent,
|
|
89
|
+
});
|
|
90
|
+
process.stdout.write(output);
|
|
91
|
+
}
|
|
92
|
+
setStatus("done");
|
|
93
|
+
setTimeout(() => exit(), 100);
|
|
94
|
+
}
|
|
61
95
|
else {
|
|
62
96
|
const ticketId = extractTicketId(branch);
|
|
63
97
|
if (!ticketId) {
|
|
@@ -83,10 +117,13 @@ export default function Template({ args }) {
|
|
|
83
117
|
}, [type]);
|
|
84
118
|
if (status === "done")
|
|
85
119
|
return null;
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
:
|
|
89
|
-
|
|
90
|
-
|
|
120
|
+
const spinnerTexts = {
|
|
121
|
+
linear: "Fetching Linear ticket...",
|
|
122
|
+
"git-changes": "Gathering changes...",
|
|
123
|
+
pr: "Fetching PR feedback...",
|
|
124
|
+
"fix-pr": "Building fix-pr prompt...",
|
|
125
|
+
review: "Building review prompt...",
|
|
126
|
+
};
|
|
127
|
+
const spinnerText = spinnerTexts[type] ?? "Loading...";
|
|
91
128
|
return (_jsxs(Box, { children: [status === "loading" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", spinnerText] })] })), status === "error" && (_jsx(Text, { color: "red", bold: true, children: message }))] }));
|
|
92
129
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Text, Box, useInput } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import { findMainRepoRoot, setRepoLinearOrg, getRepoLinearOrg } from "../../lib/git.js";
|
|
6
|
+
import { readAuthStore } from "../../lib/linear.js";
|
|
7
|
+
export const description = "Switch Linear workspace for this repo";
|
|
8
|
+
export default function LinearSwitch() {
|
|
9
|
+
const [status, setStatus] = useState("checking");
|
|
10
|
+
const [message, setMessage] = useState("");
|
|
11
|
+
const [error, setError] = useState(null);
|
|
12
|
+
const [choices, setChoices] = useState([]);
|
|
13
|
+
const [selected, setSelected] = useState(0);
|
|
14
|
+
const [currentOrg, setCurrentOrg] = useState(null);
|
|
15
|
+
useInput((input, key) => {
|
|
16
|
+
if (status !== "choosing")
|
|
17
|
+
return;
|
|
18
|
+
if (key.upArrow) {
|
|
19
|
+
setSelected((s) => Math.max(0, s - 1));
|
|
20
|
+
}
|
|
21
|
+
else if (key.downArrow) {
|
|
22
|
+
setSelected((s) => Math.min(choices.length - 1, s + 1));
|
|
23
|
+
}
|
|
24
|
+
else if (key.return) {
|
|
25
|
+
const choice = choices[selected];
|
|
26
|
+
const repoRoot = findMainRepoRoot();
|
|
27
|
+
setRepoLinearOrg(repoRoot, choice.slug);
|
|
28
|
+
setMessage(`Switched to ${choice.name} (${choice.slug})`);
|
|
29
|
+
setStatus("done");
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
async function run() {
|
|
34
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
35
|
+
const repoRoot = findMainRepoRoot();
|
|
36
|
+
if (!repoRoot) {
|
|
37
|
+
setError("Not inside a git repository");
|
|
38
|
+
setStatus("error");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const store = readAuthStore();
|
|
42
|
+
const orgs = Object.entries(store).map(([slug, tokens]) => ({
|
|
43
|
+
slug,
|
|
44
|
+
name: tokens.org_name,
|
|
45
|
+
}));
|
|
46
|
+
if (orgs.length === 0) {
|
|
47
|
+
setError("No authenticated workspaces. Run: santree linear auth");
|
|
48
|
+
setStatus("error");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (orgs.length === 1) {
|
|
52
|
+
const org = orgs[0];
|
|
53
|
+
setRepoLinearOrg(repoRoot, org.slug);
|
|
54
|
+
setMessage(`Linked to ${org.name} (${org.slug})`);
|
|
55
|
+
setStatus("done");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
setCurrentOrg(getRepoLinearOrg(repoRoot));
|
|
59
|
+
setChoices(orgs);
|
|
60
|
+
setStatus("choosing");
|
|
61
|
+
}
|
|
62
|
+
run();
|
|
63
|
+
}, []);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (status === "done" || status === "error") {
|
|
66
|
+
const timer = setTimeout(() => process.exit(status === "error" ? 1 : 0), 100);
|
|
67
|
+
return () => clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
}, [status]);
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Linear Switch" }) }), status === "checking" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Checking..." })] })), status === "choosing" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select a workspace to link to this repo:" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: choices.map((org, i) => (_jsxs(Text, { children: [i === selected ? (_jsx(Text, { color: "cyan", bold: true, children: "> " })) : (_jsx(Text, { children: " " })), org.name, " (", org.slug, ")", org.slug === currentOrg && _jsx(Text, { dimColor: true, children: " (current)" })] }, org.slug))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 to select, Enter to confirm" }) })] })), status === "done" && _jsxs(Text, { color: "green", children: ["\u2713 ", message] }), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] }));
|
|
71
|
+
}
|
|
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
|
|
3
3
|
import { Text, Box, useInput, useApp } from "ink";
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { exec
|
|
6
|
+
import { exec } from "child_process";
|
|
7
7
|
import { promisify } from "util";
|
|
8
8
|
import { join } from "path";
|
|
9
9
|
import { writeFileSync } from "fs";
|
|
@@ -11,6 +11,7 @@ import { tmpdir } from "os";
|
|
|
11
11
|
import { findMainRepoRoot, findRepoRoot, getCurrentBranch, getBaseBranch, hasUncommittedChanges, getCommitsAhead, remoteBranchExists, getUnpushedCommits, extractTicketId, isInWorktree, getFirstCommitMessage, getCommitLog, getDiffStat, getDiffContent, } from "../../lib/git.js";
|
|
12
12
|
import { ghCliAvailable, getPRInfoAsync, pushBranch, createPR, getPRTemplate, } from "../../lib/github.js";
|
|
13
13
|
import { renderPrompt, renderDiff } from "../../lib/prompts.js";
|
|
14
|
+
import { runAgent } from "../../lib/ai.js";
|
|
14
15
|
const execAsync = promisify(exec);
|
|
15
16
|
export const description = "Create a GitHub pull request";
|
|
16
17
|
export const options = z.object({
|
|
@@ -72,17 +73,14 @@ export default function PR({ options }) {
|
|
|
72
73
|
ticket_id: ticketId ?? "",
|
|
73
74
|
branch_name: branch,
|
|
74
75
|
});
|
|
75
|
-
const result =
|
|
76
|
-
|
|
77
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
78
|
-
});
|
|
79
|
-
if (result.status !== 0) {
|
|
76
|
+
const result = runAgent(prompt);
|
|
77
|
+
if (!result.success) {
|
|
80
78
|
setStatus("error");
|
|
81
79
|
setMessage("Failed to generate PR body with Claude");
|
|
82
80
|
setTimeout(() => exit(), 100);
|
|
83
81
|
return;
|
|
84
82
|
}
|
|
85
|
-
const body = result.
|
|
83
|
+
const body = result.output;
|
|
86
84
|
bodyFile = join(tmpdir(), `santree-pr-${Date.now()}.md`);
|
|
87
85
|
writeFileSync(bodyFile, body);
|
|
88
86
|
}
|
package/dist/commands/pr/fix.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { Text, Box } from "ink";
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
5
|
-
import { resolveAIContext, renderAIPrompt,
|
|
5
|
+
import { resolveAIContext, renderAIPrompt, launchAgent, cleanupImages, fetchAndRenderPR, fetchAndRenderDiff, } from "../../lib/ai.js";
|
|
6
6
|
export const description = "Fix PR review comments";
|
|
7
7
|
export default function Fix() {
|
|
8
8
|
const [status, setStatus] = useState("loading");
|
|
@@ -22,28 +22,35 @@ export default function Fix() {
|
|
|
22
22
|
const ctx = result.context;
|
|
23
23
|
setBranch(ctx.branch);
|
|
24
24
|
setTicketId(ctx.ticketId);
|
|
25
|
-
const prFeedback = fetchAndRenderPR(ctx.branch);
|
|
25
|
+
const prFeedback = await fetchAndRenderPR(ctx.branch);
|
|
26
26
|
if (!prFeedback) {
|
|
27
27
|
setStatus("error");
|
|
28
28
|
setError(`No pull request found for branch '${ctx.branch}'`);
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
-
const diffContent = fetchAndRenderDiff(ctx.branch);
|
|
31
|
+
const diffContent = await fetchAndRenderDiff(ctx.branch);
|
|
32
32
|
setStatus("launching");
|
|
33
33
|
const prompt = renderAIPrompt("fix-pr", ctx, {
|
|
34
34
|
pr_feedback: prFeedback,
|
|
35
35
|
diff_content: diffContent,
|
|
36
36
|
});
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
try {
|
|
38
|
+
const child = launchAgent(prompt);
|
|
39
|
+
child.on("error", (err) => {
|
|
40
|
+
setStatus("error");
|
|
41
|
+
setError(`Failed to launch agent: ${err.message}`);
|
|
42
|
+
});
|
|
43
|
+
child.on("close", () => {
|
|
44
|
+
if (ctx.ticketId)
|
|
45
|
+
cleanupImages(ctx.ticketId);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
39
50
|
setStatus("error");
|
|
40
|
-
setError(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (ctx.ticketId)
|
|
44
|
-
cleanupImages(ctx.ticketId);
|
|
45
|
-
process.exit(0);
|
|
46
|
-
});
|
|
51
|
+
setError(err instanceof Error ? err.message : "Failed to launch agent");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
47
54
|
}
|
|
48
55
|
init();
|
|
49
56
|
}, []);
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { Text, Box } from "ink";
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
5
|
-
import { resolveAIContext, renderAIPrompt,
|
|
5
|
+
import { resolveAIContext, renderAIPrompt, launchAgent, cleanupImages, fetchAndRenderDiff, } from "../../lib/ai.js";
|
|
6
6
|
export const description = "Review changes against ticket requirements";
|
|
7
7
|
export default function Review() {
|
|
8
8
|
const [status, setStatus] = useState("loading");
|
|
@@ -22,21 +22,28 @@ export default function Review() {
|
|
|
22
22
|
const ctx = result.context;
|
|
23
23
|
setBranch(ctx.branch);
|
|
24
24
|
setTicketId(ctx.ticketId);
|
|
25
|
-
const diffContent = fetchAndRenderDiff(ctx.branch);
|
|
25
|
+
const diffContent = await fetchAndRenderDiff(ctx.branch);
|
|
26
26
|
setStatus("launching");
|
|
27
27
|
const prompt = renderAIPrompt("review", ctx, {
|
|
28
28
|
diff_content: diffContent,
|
|
29
29
|
});
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
try {
|
|
31
|
+
const child = launchAgent(prompt);
|
|
32
|
+
child.on("error", (err) => {
|
|
33
|
+
setStatus("error");
|
|
34
|
+
setError(`Failed to launch agent: ${err.message}`);
|
|
35
|
+
});
|
|
36
|
+
child.on("close", () => {
|
|
37
|
+
if (ctx.ticketId)
|
|
38
|
+
cleanupImages(ctx.ticketId);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
32
43
|
setStatus("error");
|
|
33
|
-
setError(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (ctx.ticketId)
|
|
37
|
-
cleanupImages(ctx.ticketId);
|
|
38
|
-
process.exit(0);
|
|
39
|
-
});
|
|
44
|
+
setError(err instanceof Error ? err.message : "Failed to launch agent");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
40
47
|
}
|
|
41
48
|
init();
|
|
42
49
|
}, []);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const description = "Remove a worktree and its branch";
|
|
3
|
-
export declare const options: z.ZodObject<{
|
|
3
|
+
export declare const options: z.ZodObject<{
|
|
4
|
+
force: z.ZodOptional<z.ZodBoolean>;
|
|
5
|
+
}, z.core.$strip>;
|
|
4
6
|
export declare const args: z.ZodTuple<[z.ZodString], null>;
|
|
5
7
|
type Props = {
|
|
6
8
|
options: z.infer<typeof options>;
|
|
7
9
|
args: z.infer<typeof args>;
|
|
8
10
|
};
|
|
9
|
-
export default function Remove({ args }: Props): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export default function Remove({ args, options }: Props): import("react/jsx-runtime").JSX.Element;
|
|
10
12
|
export {};
|
|
@@ -1,40 +1,81 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
|
-
import { Text, Box } from "ink";
|
|
3
|
+
import { Text, Box, useInput, useApp } from "ink";
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { removeWorktree, findMainRepoRoot } from "../../lib/git.js";
|
|
7
7
|
export const description = "Remove a worktree and its branch";
|
|
8
|
-
export const options = z.object({
|
|
8
|
+
export const options = z.object({
|
|
9
|
+
force: z.boolean().optional().describe("Skip confirmation prompt"),
|
|
10
|
+
});
|
|
9
11
|
export const args = z.tuple([z.string().describe("Branch name to remove")]);
|
|
10
|
-
export default function Remove({ args }) {
|
|
12
|
+
export default function Remove({ args, options }) {
|
|
11
13
|
const [branchName] = args;
|
|
12
|
-
const
|
|
14
|
+
const { exit } = useApp();
|
|
15
|
+
const [status, setStatus] = useState("checking");
|
|
13
16
|
const [message, setMessage] = useState("");
|
|
17
|
+
const [repoRoot, setRepoRoot] = useState(null);
|
|
18
|
+
useInput((input) => {
|
|
19
|
+
if (status !== "confirming")
|
|
20
|
+
return;
|
|
21
|
+
if (input === "y" || input === "Y") {
|
|
22
|
+
doRemove();
|
|
23
|
+
}
|
|
24
|
+
else if (input === "n" || input === "N" || input === "\x03") {
|
|
25
|
+
setStatus("cancelled");
|
|
26
|
+
setMessage("Cancelled");
|
|
27
|
+
setTimeout(() => exit(), 100);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
async function doRemove() {
|
|
31
|
+
if (!repoRoot)
|
|
32
|
+
return;
|
|
33
|
+
setStatus("removing");
|
|
34
|
+
setMessage(`Removing worktree ${branchName}...`);
|
|
35
|
+
const result = await removeWorktree(branchName, repoRoot, true);
|
|
36
|
+
if (result.success) {
|
|
37
|
+
setStatus("done");
|
|
38
|
+
setMessage(`Removed worktree and branch: ${branchName}`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
setStatus("error");
|
|
42
|
+
setMessage(result.error ?? "Unknown error");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
14
45
|
useEffect(() => {
|
|
15
46
|
async function run() {
|
|
16
|
-
// Small delay to allow spinner to render
|
|
17
47
|
await new Promise((r) => setTimeout(r, 100));
|
|
18
|
-
const
|
|
19
|
-
if (!
|
|
48
|
+
const root = findMainRepoRoot();
|
|
49
|
+
if (!root) {
|
|
20
50
|
setStatus("error");
|
|
21
51
|
setMessage("Not inside a git repository");
|
|
22
52
|
return;
|
|
23
53
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
setRepoRoot(root);
|
|
55
|
+
if (options.force) {
|
|
56
|
+
setStatus("removing");
|
|
57
|
+
setMessage(`Removing worktree ${branchName}...`);
|
|
58
|
+
const result = await removeWorktree(branchName, root, true);
|
|
59
|
+
if (result.success) {
|
|
60
|
+
setStatus("done");
|
|
61
|
+
setMessage(`Removed worktree and branch: ${branchName}`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
setStatus("error");
|
|
65
|
+
setMessage(result.error ?? "Unknown error");
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
34
68
|
}
|
|
69
|
+
setStatus("confirming");
|
|
35
70
|
}
|
|
36
71
|
run();
|
|
37
72
|
}, [branchName]);
|
|
38
|
-
|
|
39
|
-
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (status === "done" || status === "error") {
|
|
75
|
+
const timer = setTimeout(() => process.exit(status === "error" ? 1 : 0), 100);
|
|
76
|
+
return () => clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}, [status]);
|
|
79
|
+
const isLoading = status === "checking" || status === "removing";
|
|
80
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\uD83D\uDDD1\uFE0F Remove" }) }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : status === "done" ? "green" : "yellow", paddingX: 1, width: "100%", children: _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "red", bold: true, children: branchName })] }) }), _jsxs(Box, { marginTop: 1, children: [isLoading && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", message || "Removing..."] })] })), status === "confirming" && (_jsxs(Text, { bold: true, color: "yellow", children: ["Remove this worktree and delete the branch? [y/N]:", " "] })), status === "done" && (_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", message] })), status === "cancelled" && _jsxs(Text, { color: "yellow", children: ["\u2717 ", message] }), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", message] }))] })] }));
|
|
40
81
|
}
|
|
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
|
|
3
3
|
import { Text, Box } from "ink";
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { resolveAIContext, renderAIPrompt,
|
|
6
|
+
import { resolveAIContext, renderAIPrompt, launchAgent, cleanupImages, } from "../../lib/ai.js";
|
|
7
7
|
export const description = "Launch Claude to work on current ticket";
|
|
8
8
|
export const options = z.object({
|
|
9
9
|
plan: z.boolean().optional().describe("Only create implementation plan"),
|
|
@@ -45,16 +45,22 @@ export default function Work({ options }) {
|
|
|
45
45
|
return;
|
|
46
46
|
setStatus("launching");
|
|
47
47
|
const prompt = renderAIPrompt("work", aiContext, { mode });
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
try {
|
|
49
|
+
const child = launchAgent(prompt, { planMode: mode === "plan" });
|
|
50
|
+
child.on("error", (err) => {
|
|
51
|
+
setStatus("error");
|
|
52
|
+
setError(`Failed to launch agent: ${err.message}`);
|
|
53
|
+
});
|
|
54
|
+
child.on("close", () => {
|
|
55
|
+
if (aiContext.ticketId)
|
|
56
|
+
cleanupImages(aiContext.ticketId);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
50
61
|
setStatus("error");
|
|
51
|
-
setError(
|
|
52
|
-
}
|
|
53
|
-
child.on("close", () => {
|
|
54
|
-
if (aiContext.ticketId)
|
|
55
|
-
cleanupImages(aiContext.ticketId);
|
|
56
|
-
process.exit(0);
|
|
57
|
-
});
|
|
62
|
+
setError(err instanceof Error ? err.message : "Failed to launch agent");
|
|
63
|
+
}
|
|
58
64
|
}, [status, aiContext, mode]);
|
|
59
65
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Work" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : getModeColor(mode), paddingX: 1, width: "100%", children: [branch && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "branch:" }), _jsx(Text, { color: "cyan", bold: true, children: branch })] })), ticketId && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "ticket:" }), _jsx(Text, { color: "blue", bold: true, children: ticketId })] })), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "mode:" }), _jsx(Text, { backgroundColor: getModeColor(mode), color: "white", bold: true, children: ` ${getModeLabel(mode)} ` })] })] }), _jsxs(Box, { marginTop: 1, children: [status === "loading" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading..." })] })), status === "fetching" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Fetching ticket from Linear..." })] })), status === "launching" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Launching Claude (through Happy)..." }), _jsxs(Text, { dimColor: true, children: [" ", "happy", mode === "plan" ? " --permission-mode plan" : "", " ", `"<${getModeLabel(mode)} prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
|
|
60
66
|
}
|
package/dist/lib/ai.d.ts
CHANGED
|
@@ -27,22 +27,35 @@ export declare function buildPromptContext(ctx: AIContext, extra?: Record<string
|
|
|
27
27
|
*/
|
|
28
28
|
export declare function renderAIPrompt(template: string, ctx: AIContext, extra?: Record<string, string | undefined>): string;
|
|
29
29
|
/**
|
|
30
|
-
* Fetch and render PR feedback for a branch.
|
|
30
|
+
* Fetch and render PR feedback for a branch (async, non-blocking).
|
|
31
31
|
* Returns rendered markdown or null if no PR exists.
|
|
32
32
|
*/
|
|
33
|
-
export declare function fetchAndRenderPR(branch: string): string | null
|
|
33
|
+
export declare function fetchAndRenderPR(branch: string): Promise<string | null>;
|
|
34
34
|
/**
|
|
35
|
-
* Fetch and render diff for a branch against its base branch.
|
|
35
|
+
* Fetch and render diff for a branch against its base branch (async, non-blocking).
|
|
36
36
|
* Returns rendered markdown.
|
|
37
37
|
*/
|
|
38
|
-
export declare function fetchAndRenderDiff(branch: string): string
|
|
38
|
+
export declare function fetchAndRenderDiff(branch: string): Promise<string>;
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* Launch an interactive agent session with a prompt.
|
|
41
|
+
* Resolves the agent binary (happy > claude), passes prompt directly
|
|
42
|
+
* or via temp file if too large for OS arg limit.
|
|
43
|
+
* Throws if no agent binary is found.
|
|
42
44
|
*/
|
|
43
|
-
export declare function
|
|
45
|
+
export declare function launchAgent(prompt: string, opts?: {
|
|
44
46
|
planMode?: boolean;
|
|
45
47
|
}): ChildProcess;
|
|
48
|
+
export interface RunAgentResult {
|
|
49
|
+
success: boolean;
|
|
50
|
+
output: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Run an agent in non-interactive print mode and capture output.
|
|
54
|
+
* Resolves the agent binary (happy > claude), passes prompt directly
|
|
55
|
+
* or via temp file if too large for OS arg limit.
|
|
56
|
+
* Throws if no agent binary is found.
|
|
57
|
+
*/
|
|
58
|
+
export declare function runAgent(prompt: string): RunAgentResult;
|
|
46
59
|
/**
|
|
47
60
|
* Cleanup images downloaded for a ticket.
|
|
48
61
|
*/
|
package/dist/lib/ai.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
2
|
-
import {
|
|
1
|
+
import { execSync, spawn, spawnSync } from "child_process";
|
|
2
|
+
import { writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { getCurrentBranch, extractTicketId, findRepoRoot, findMainRepoRoot, getBaseBranch, } from "./git.js";
|
|
3
6
|
import { renderPrompt, renderTicket, renderDiff, renderPR } from "./prompts.js";
|
|
4
7
|
import { getTicketContent, cleanupImages } from "./linear.js";
|
|
5
|
-
import {
|
|
8
|
+
import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRReviewCommentsAsync, getPRConversationCommentsAsync, getFailedCheckDetailsAsync, } from "./github.js";
|
|
9
|
+
import { runAsync } from "./exec.js";
|
|
6
10
|
/**
|
|
7
11
|
* Resolves repo, branch, ticket ID, and fetches the Linear ticket.
|
|
8
12
|
* Returns an error string if any required context is missing.
|
|
@@ -56,20 +60,20 @@ const BOT_AUTHORS = new Set([
|
|
|
56
60
|
"vercel",
|
|
57
61
|
]);
|
|
58
62
|
/**
|
|
59
|
-
* Fetch and render PR feedback for a branch.
|
|
63
|
+
* Fetch and render PR feedback for a branch (async, non-blocking).
|
|
60
64
|
* Returns rendered markdown or null if no PR exists.
|
|
61
65
|
*/
|
|
62
|
-
export function fetchAndRenderPR(branch) {
|
|
63
|
-
const prInfo =
|
|
66
|
+
export async function fetchAndRenderPR(branch) {
|
|
67
|
+
const prInfo = await getPRInfoAsync(branch);
|
|
64
68
|
if (!prInfo)
|
|
65
69
|
return null;
|
|
66
|
-
const checks =
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
70
|
+
const [checks, reviews, reviewComments, allComments] = await Promise.all([
|
|
71
|
+
getPRChecksAsync(prInfo.number),
|
|
72
|
+
getPRReviewsAsync(prInfo.number),
|
|
73
|
+
getPRReviewCommentsAsync(prInfo.number),
|
|
74
|
+
getPRConversationCommentsAsync(prInfo.number),
|
|
75
|
+
]);
|
|
76
|
+
const failedChecks = await Promise.all((checks ?? []).filter((c) => c.bucket === "fail").map((c) => getFailedCheckDetailsAsync(c)));
|
|
73
77
|
const conversationComments = (allComments ?? []).filter((c) => !BOT_AUTHORS.has(c.author) && !c.author.endsWith("[bot]"));
|
|
74
78
|
return renderPR({
|
|
75
79
|
pr_number: prInfo.number,
|
|
@@ -83,29 +87,91 @@ export function fetchAndRenderPR(branch) {
|
|
|
83
87
|
});
|
|
84
88
|
}
|
|
85
89
|
/**
|
|
86
|
-
* Fetch and render diff for a branch against its base branch.
|
|
90
|
+
* Fetch and render diff for a branch against its base branch (async, non-blocking).
|
|
87
91
|
* Returns rendered markdown.
|
|
88
92
|
*/
|
|
89
|
-
export function fetchAndRenderDiff(branch) {
|
|
93
|
+
export async function fetchAndRenderDiff(branch) {
|
|
90
94
|
const baseBranch = getBaseBranch(branch);
|
|
95
|
+
const [commitLog, diffStat, diff] = await Promise.all([
|
|
96
|
+
runAsync(`git log ${baseBranch}..HEAD --format="- %s"`).then((v) => v || null),
|
|
97
|
+
runAsync(`git diff ${baseBranch}..HEAD --stat`).then((v) => v || null),
|
|
98
|
+
runAsync(`git diff ${baseBranch}..HEAD`, { maxBuffer: 10 * 1024 * 1024 }).then((v) => v || null),
|
|
99
|
+
]);
|
|
91
100
|
return renderDiff({
|
|
92
101
|
base_branch: baseBranch,
|
|
93
|
-
commit_log:
|
|
94
|
-
diff_stat:
|
|
95
|
-
diff
|
|
102
|
+
commit_log: commitLog,
|
|
103
|
+
diff_stat: diffStat,
|
|
104
|
+
diff,
|
|
96
105
|
});
|
|
97
106
|
}
|
|
98
107
|
/**
|
|
99
|
-
*
|
|
100
|
-
* Returns the
|
|
108
|
+
* Resolve which agent binary to use (happy or claude).
|
|
109
|
+
* Returns the binary name, or null if neither is installed.
|
|
101
110
|
*/
|
|
102
|
-
|
|
111
|
+
function resolveAgentBinary() {
|
|
112
|
+
for (const bin of ["happy", "claude"]) {
|
|
113
|
+
try {
|
|
114
|
+
execSync(`which ${bin}`, { stdio: "ignore" });
|
|
115
|
+
return bin;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
// Conservative limit: 200KB leaves room for env vars within macOS 256KB ARG_MAX
|
|
124
|
+
const ARG_MAX_SAFE = 200 * 1024;
|
|
125
|
+
/**
|
|
126
|
+
* Build the prompt argument for the agent.
|
|
127
|
+
* If the prompt fits in ARG_MAX, returns it directly.
|
|
128
|
+
* Otherwise, writes to a temp file and returns a short instruction to read it.
|
|
129
|
+
*/
|
|
130
|
+
function promptArg(prompt) {
|
|
131
|
+
if (Buffer.byteLength(prompt) <= ARG_MAX_SAFE) {
|
|
132
|
+
return prompt;
|
|
133
|
+
}
|
|
134
|
+
const filePath = join(tmpdir(), `santree-prompt-${Date.now()}.md`);
|
|
135
|
+
writeFileSync(filePath, prompt);
|
|
136
|
+
return `Read ${filePath} and follow the instructions inside.`;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Launch an interactive agent session with a prompt.
|
|
140
|
+
* Resolves the agent binary (happy > claude), passes prompt directly
|
|
141
|
+
* or via temp file if too large for OS arg limit.
|
|
142
|
+
* Throws if no agent binary is found.
|
|
143
|
+
*/
|
|
144
|
+
export function launchAgent(prompt, opts) {
|
|
145
|
+
const bin = resolveAgentBinary();
|
|
146
|
+
if (!bin) {
|
|
147
|
+
throw new Error("No agent found. Install happy (npm i -g happy-coder) or claude (npm i -g @anthropic-ai/claude-code).");
|
|
148
|
+
}
|
|
103
149
|
const args = [];
|
|
104
150
|
if (opts?.planMode) {
|
|
105
151
|
args.push("--permission-mode", "plan");
|
|
106
152
|
}
|
|
107
|
-
args.push(prompt);
|
|
108
|
-
return spawn(
|
|
153
|
+
args.push("--", promptArg(prompt));
|
|
154
|
+
return spawn(bin, args, { stdio: "inherit" });
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Run an agent in non-interactive print mode and capture output.
|
|
158
|
+
* Resolves the agent binary (happy > claude), passes prompt directly
|
|
159
|
+
* or via temp file if too large for OS arg limit.
|
|
160
|
+
* Throws if no agent binary is found.
|
|
161
|
+
*/
|
|
162
|
+
export function runAgent(prompt) {
|
|
163
|
+
const bin = resolveAgentBinary();
|
|
164
|
+
if (!bin) {
|
|
165
|
+
throw new Error("No agent found. Install happy (npm i -g happy-coder) or claude (npm i -g @anthropic-ai/claude-code).");
|
|
166
|
+
}
|
|
167
|
+
const result = spawnSync(bin, ["-p", "--output-format", "text", "--", promptArg(prompt)], {
|
|
168
|
+
encoding: "utf-8",
|
|
169
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
success: result.status === 0,
|
|
173
|
+
output: result.stdout?.trim() ?? "",
|
|
174
|
+
};
|
|
109
175
|
}
|
|
110
176
|
/**
|
|
111
177
|
* Cleanup images downloaded for a ticket.
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -5,6 +5,13 @@ export declare function run(command: string, options?: {
|
|
|
5
5
|
cwd?: string;
|
|
6
6
|
maxBuffer?: number;
|
|
7
7
|
}): string | null;
|
|
8
|
+
/**
|
|
9
|
+
* Run a shell command asynchronously and return trimmed stdout, or null on failure.
|
|
10
|
+
*/
|
|
11
|
+
export declare function runAsync(command: string, options?: {
|
|
12
|
+
cwd?: string;
|
|
13
|
+
maxBuffer?: number;
|
|
14
|
+
}): Promise<string | null>;
|
|
8
15
|
/**
|
|
9
16
|
* Spawn a command asynchronously and capture its output.
|
|
10
17
|
* Returns the exit code and combined stdout/stderr.
|
package/dist/lib/exec.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { execSync, spawn } from "child_process";
|
|
1
|
+
import { execSync, exec, spawn } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
const execPromise = promisify(exec);
|
|
2
4
|
/**
|
|
3
5
|
* Run a shell command and return trimmed stdout, or null on failure.
|
|
4
6
|
*/
|
|
@@ -10,6 +12,18 @@ export function run(command, options) {
|
|
|
10
12
|
return null;
|
|
11
13
|
}
|
|
12
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Run a shell command asynchronously and return trimmed stdout, or null on failure.
|
|
17
|
+
*/
|
|
18
|
+
export async function runAsync(command, options) {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout } = await execPromise(command, { encoding: "utf-8", ...options });
|
|
21
|
+
return stdout.trim();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
13
27
|
/**
|
|
14
28
|
* Spawn a command asynchronously and capture its output.
|
|
15
29
|
* Returns the exit code and combined stdout/stderr.
|
package/dist/lib/github.d.ts
CHANGED
|
@@ -4,13 +4,7 @@ export interface PRInfo {
|
|
|
4
4
|
url?: string;
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
* Get PR info
|
|
8
|
-
* Runs: `gh pr view "<branchName>" --json number,state,url`
|
|
9
|
-
* Returns null if no PR exists for the branch or gh CLI fails.
|
|
10
|
-
*/
|
|
11
|
-
export declare function getPRInfo(branchName: string): PRInfo | null;
|
|
12
|
-
/**
|
|
13
|
-
* Async version of getPRInfo. Get PR info for a branch using the GitHub CLI.
|
|
7
|
+
* Get PR info for a branch using the GitHub CLI (async).
|
|
14
8
|
* Runs: `gh pr view "<branchName>" --json number,state,url`
|
|
15
9
|
* Returns null if no PR exists for the branch or gh CLI fails.
|
|
16
10
|
*/
|
|
@@ -42,22 +36,30 @@ export declare function createPR(title: string, baseBranch: string, headBranch:
|
|
|
42
36
|
*/
|
|
43
37
|
export declare function getPRTemplate(): string | null;
|
|
44
38
|
/**
|
|
45
|
-
* Fetch
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
* Fetch CI check results for a pull request (async).
|
|
40
|
+
*/
|
|
41
|
+
export declare function getPRChecksAsync(prNumber: string): Promise<PRCheck[] | null>;
|
|
42
|
+
/**
|
|
43
|
+
* Fetch reviews for a pull request (async).
|
|
44
|
+
*/
|
|
45
|
+
export declare function getPRReviewsAsync(prNumber: string): Promise<PRReview[] | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Fetch inline review comments for a pull request via the GitHub API (async).
|
|
48
|
+
*/
|
|
49
|
+
export declare function getPRReviewCommentsAsync(prNumber: string): Promise<PRReviewComment[] | null>;
|
|
50
|
+
/**
|
|
51
|
+
* Fetch structured conversation comments on a pull request (async).
|
|
52
|
+
*/
|
|
53
|
+
export declare function getPRConversationCommentsAsync(prNumber: string): Promise<PRConversationComment[] | null>;
|
|
54
|
+
/**
|
|
55
|
+
* Fetch details for a failed CI check (async): which step failed and the failed step's log.
|
|
48
56
|
*/
|
|
49
|
-
export declare function
|
|
57
|
+
export declare function getFailedCheckDetailsAsync(check: PRCheck): Promise<FailedCheckDetail>;
|
|
50
58
|
export interface PRConversationComment {
|
|
51
59
|
author: string;
|
|
52
60
|
body: string;
|
|
53
61
|
createdAt: string;
|
|
54
62
|
}
|
|
55
|
-
/**
|
|
56
|
-
* Fetch structured conversation comments on a pull request.
|
|
57
|
-
* Runs: `gh pr view <prNumber> --json comments`
|
|
58
|
-
* Returns null if gh CLI fails.
|
|
59
|
-
*/
|
|
60
|
-
export declare function getPRConversationComments(prNumber: string): PRConversationComment[] | null;
|
|
61
63
|
export interface PRCheck {
|
|
62
64
|
name: string;
|
|
63
65
|
state: string;
|
|
@@ -74,13 +76,6 @@ export interface FailedCheckDetail {
|
|
|
74
76
|
failed_step: string | null;
|
|
75
77
|
log: string | null;
|
|
76
78
|
}
|
|
77
|
-
/**
|
|
78
|
-
* Fetch details for a failed CI check: which step failed and the failed step's log.
|
|
79
|
-
* Extracts job ID from the check link, fetches job details for the step name,
|
|
80
|
-
* then fetches the job log via the GitHub API and extracts the failed step's output.
|
|
81
|
-
* Returns enriched detail; gracefully degrades if API calls fail.
|
|
82
|
-
*/
|
|
83
|
-
export declare function getFailedCheckDetails(check: PRCheck): FailedCheckDetail;
|
|
84
79
|
export interface PRReview {
|
|
85
80
|
author: {
|
|
86
81
|
login: string;
|
|
@@ -102,21 +97,3 @@ export interface PRReviewComment {
|
|
|
102
97
|
in_reply_to_id?: number;
|
|
103
98
|
id: number;
|
|
104
99
|
}
|
|
105
|
-
/**
|
|
106
|
-
* Fetch CI check results for a pull request.
|
|
107
|
-
* Runs: `gh pr checks <prNumber> --json name,state,bucket,link,description,workflow`
|
|
108
|
-
* Returns null if gh CLI fails.
|
|
109
|
-
*/
|
|
110
|
-
export declare function getPRChecks(prNumber: string): PRCheck[] | null;
|
|
111
|
-
/**
|
|
112
|
-
* Fetch reviews for a pull request.
|
|
113
|
-
* Runs: `gh pr view <prNumber> --json reviews`
|
|
114
|
-
* Returns null if gh CLI fails.
|
|
115
|
-
*/
|
|
116
|
-
export declare function getPRReviews(prNumber: string): PRReview[] | null;
|
|
117
|
-
/**
|
|
118
|
-
* Fetch inline review comments for a pull request via the GitHub API.
|
|
119
|
-
* Runs: `gh api repos/{owner}/{repo}/pulls/<prNumber>/comments --paginate`
|
|
120
|
-
* Returns null if gh CLI fails.
|
|
121
|
-
*/
|
|
122
|
-
export declare function getPRReviewComments(prNumber: string): PRReviewComment[] | null;
|
package/dist/lib/github.js
CHANGED
|
@@ -1,30 +1,9 @@
|
|
|
1
1
|
import { execSync, exec } from "child_process";
|
|
2
2
|
import { promisify } from "util";
|
|
3
|
-
import { run } from "./exec.js";
|
|
3
|
+
import { run, runAsync } from "./exec.js";
|
|
4
4
|
const execAsync = promisify(exec);
|
|
5
5
|
/**
|
|
6
|
-
* Get PR info
|
|
7
|
-
* Runs: `gh pr view "<branchName>" --json number,state,url`
|
|
8
|
-
* Returns null if no PR exists for the branch or gh CLI fails.
|
|
9
|
-
*/
|
|
10
|
-
export function getPRInfo(branchName) {
|
|
11
|
-
const output = run(`gh pr view "${branchName}" --json number,state,url`);
|
|
12
|
-
if (!output)
|
|
13
|
-
return null;
|
|
14
|
-
try {
|
|
15
|
-
const data = JSON.parse(output);
|
|
16
|
-
return {
|
|
17
|
-
number: String(data.number ?? ""),
|
|
18
|
-
state: data.state ?? "OPEN",
|
|
19
|
-
url: data.url,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Async version of getPRInfo. Get PR info for a branch using the GitHub CLI.
|
|
6
|
+
* Get PR info for a branch using the GitHub CLI (async).
|
|
28
7
|
* Runs: `gh pr view "<branchName>" --json number,state,url`
|
|
29
8
|
* Returns null if no PR exists for the branch or gh CLI fails.
|
|
30
9
|
*/
|
|
@@ -102,20 +81,53 @@ export function getPRTemplate() {
|
|
|
102
81
|
return Buffer.from(output, "base64").toString("utf-8");
|
|
103
82
|
}
|
|
104
83
|
/**
|
|
105
|
-
* Fetch
|
|
106
|
-
* Runs: `gh pr view <prNumber> --json comments --jq '.comments[] | "- \(.author.login): \(.body)"'`
|
|
107
|
-
* Returns empty string if the PR has no comments or on failure.
|
|
84
|
+
* Fetch CI check results for a pull request (async).
|
|
108
85
|
*/
|
|
109
|
-
export function
|
|
110
|
-
|
|
86
|
+
export async function getPRChecksAsync(prNumber) {
|
|
87
|
+
const output = await runAsync(`gh pr checks ${prNumber} --json name,state,bucket,link,description,workflow`);
|
|
88
|
+
if (!output)
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(output);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
111
96
|
}
|
|
112
97
|
/**
|
|
113
|
-
* Fetch
|
|
114
|
-
* Runs: `gh pr view <prNumber> --json comments`
|
|
115
|
-
* Returns null if gh CLI fails.
|
|
98
|
+
* Fetch reviews for a pull request (async).
|
|
116
99
|
*/
|
|
117
|
-
export function
|
|
118
|
-
const output =
|
|
100
|
+
export async function getPRReviewsAsync(prNumber) {
|
|
101
|
+
const output = await runAsync(`gh pr view ${prNumber} --json reviews`);
|
|
102
|
+
if (!output)
|
|
103
|
+
return null;
|
|
104
|
+
try {
|
|
105
|
+
const data = JSON.parse(output);
|
|
106
|
+
return data.reviews ?? null;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Fetch inline review comments for a pull request via the GitHub API (async).
|
|
114
|
+
*/
|
|
115
|
+
export async function getPRReviewCommentsAsync(prNumber) {
|
|
116
|
+
const output = await runAsync(`gh api repos/{owner}/{repo}/pulls/${prNumber}/comments --paginate`);
|
|
117
|
+
if (!output)
|
|
118
|
+
return null;
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(output);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Fetch structured conversation comments on a pull request (async).
|
|
128
|
+
*/
|
|
129
|
+
export async function getPRConversationCommentsAsync(prNumber) {
|
|
130
|
+
const output = await runAsync(`gh pr view ${prNumber} --json comments`);
|
|
119
131
|
if (!output)
|
|
120
132
|
return null;
|
|
121
133
|
try {
|
|
@@ -131,12 +143,9 @@ export function getPRConversationComments(prNumber) {
|
|
|
131
143
|
}
|
|
132
144
|
}
|
|
133
145
|
/**
|
|
134
|
-
* Fetch details for a failed CI check: which step failed and the failed step's log.
|
|
135
|
-
* Extracts job ID from the check link, fetches job details for the step name,
|
|
136
|
-
* then fetches the job log via the GitHub API and extracts the failed step's output.
|
|
137
|
-
* Returns enriched detail; gracefully degrades if API calls fail.
|
|
146
|
+
* Fetch details for a failed CI check (async): which step failed and the failed step's log.
|
|
138
147
|
*/
|
|
139
|
-
export function
|
|
148
|
+
export async function getFailedCheckDetailsAsync(check) {
|
|
140
149
|
const detail = {
|
|
141
150
|
name: check.name,
|
|
142
151
|
workflow: check.workflow,
|
|
@@ -151,7 +160,7 @@ export function getFailedCheckDetails(check) {
|
|
|
151
160
|
const jobId = urlMatch[1];
|
|
152
161
|
let stepStartMs = 0;
|
|
153
162
|
let stepEndMs = 0;
|
|
154
|
-
const jobOutput =
|
|
163
|
+
const jobOutput = await runAsync(`gh api repos/{owner}/{repo}/actions/jobs/${jobId}`);
|
|
155
164
|
if (jobOutput) {
|
|
156
165
|
try {
|
|
157
166
|
const job = JSON.parse(jobOutput);
|
|
@@ -159,7 +168,6 @@ export function getFailedCheckDetails(check) {
|
|
|
159
168
|
if (failedStep) {
|
|
160
169
|
detail.failed_step = failedStep.name;
|
|
161
170
|
stepStartMs = new Date(failedStep.started_at).getTime();
|
|
162
|
-
// Add 1s buffer — step API uses second precision but log has sub-second timestamps
|
|
163
171
|
stepEndMs = new Date(failedStep.completed_at).getTime() + 999;
|
|
164
172
|
}
|
|
165
173
|
}
|
|
@@ -167,11 +175,9 @@ export function getFailedCheckDetails(check) {
|
|
|
167
175
|
}
|
|
168
176
|
if (!stepStartMs)
|
|
169
177
|
return detail;
|
|
170
|
-
|
|
171
|
-
const logOutput = run(`gh api repos/{owner}/{repo}/actions/jobs/${jobId}/logs 2>/dev/null`);
|
|
178
|
+
const logOutput = await runAsync(`gh api repos/{owner}/{repo}/actions/jobs/${jobId}/logs 2>/dev/null`);
|
|
172
179
|
if (logOutput) {
|
|
173
180
|
const lines = logOutput.split("\n");
|
|
174
|
-
// Filter to lines within the failed step's time range
|
|
175
181
|
const stepLines = lines.filter((line) => {
|
|
176
182
|
const m = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)/);
|
|
177
183
|
if (!m)
|
|
@@ -179,12 +185,8 @@ export function getFailedCheckDetails(check) {
|
|
|
179
185
|
const ms = new Date(m[1]).getTime();
|
|
180
186
|
return ms >= stepStartMs && ms <= stepEndMs;
|
|
181
187
|
});
|
|
182
|
-
// Truncate at ##[error] — everything after is post-run cleanup noise
|
|
183
188
|
const errorIdx = stepLines.findIndex((l) => l.includes("##[error]"));
|
|
184
189
|
const bounded = errorIdx !== -1 ? stepLines.slice(0, errorIdx) : stepLines;
|
|
185
|
-
// Split non-group output into segments separated by ##[group]..##[endgroup] blocks.
|
|
186
|
-
// The last segment is the actual command output, earlier segments are
|
|
187
|
-
// setup noise (checkout, env vars, etc.).
|
|
188
190
|
const segments = [];
|
|
189
191
|
let current = [];
|
|
190
192
|
let inGroup = false;
|
|
@@ -214,52 +216,3 @@ export function getFailedCheckDetails(check) {
|
|
|
214
216
|
}
|
|
215
217
|
return detail;
|
|
216
218
|
}
|
|
217
|
-
/**
|
|
218
|
-
* Fetch CI check results for a pull request.
|
|
219
|
-
* Runs: `gh pr checks <prNumber> --json name,state,bucket,link,description,workflow`
|
|
220
|
-
* Returns null if gh CLI fails.
|
|
221
|
-
*/
|
|
222
|
-
export function getPRChecks(prNumber) {
|
|
223
|
-
const output = run(`gh pr checks ${prNumber} --json name,state,bucket,link,description,workflow`);
|
|
224
|
-
if (!output)
|
|
225
|
-
return null;
|
|
226
|
-
try {
|
|
227
|
-
return JSON.parse(output);
|
|
228
|
-
}
|
|
229
|
-
catch {
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* Fetch reviews for a pull request.
|
|
235
|
-
* Runs: `gh pr view <prNumber> --json reviews`
|
|
236
|
-
* Returns null if gh CLI fails.
|
|
237
|
-
*/
|
|
238
|
-
export function getPRReviews(prNumber) {
|
|
239
|
-
const output = run(`gh pr view ${prNumber} --json reviews`);
|
|
240
|
-
if (!output)
|
|
241
|
-
return null;
|
|
242
|
-
try {
|
|
243
|
-
const data = JSON.parse(output);
|
|
244
|
-
return data.reviews ?? null;
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
/**
|
|
251
|
-
* Fetch inline review comments for a pull request via the GitHub API.
|
|
252
|
-
* Runs: `gh api repos/{owner}/{repo}/pulls/<prNumber>/comments --paginate`
|
|
253
|
-
* Returns null if gh CLI fails.
|
|
254
|
-
*/
|
|
255
|
-
export function getPRReviewComments(prNumber) {
|
|
256
|
-
const output = run(`gh api repos/{owner}/{repo}/pulls/${prNumber}/comments --paginate`);
|
|
257
|
-
if (!output)
|
|
258
|
-
return null;
|
|
259
|
-
try {
|
|
260
|
-
return JSON.parse(output);
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
return null;
|
|
264
|
-
}
|
|
265
|
-
}
|
package/package.json
CHANGED
package/prompts/fix-pr.njk
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{% if ticket_content %}
|
|
2
2
|
{{ ticket_content }}
|
|
3
3
|
{% else %}
|
|
4
|
-
Note: Could not fetch Linear ticket {{ ticket_id }}
|
|
4
|
+
Note: Could not fetch Linear ticket {{ ticket_id }} directly.
|
|
5
|
+
If a Linear MCP server is available, use it to fetch the ticket description, comments, and any relevant details for {{ ticket_id }}.
|
|
6
|
+
Otherwise, proceed based on branch name context.
|
|
5
7
|
{% endif %}
|
|
6
8
|
{% if pr_feedback %}
|
|
7
9
|
{{ pr_feedback }}
|
package/prompts/review.njk
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{% if ticket_content %}
|
|
2
2
|
{{ ticket_content }}
|
|
3
3
|
{% else %}
|
|
4
|
-
Note: Could not fetch Linear ticket {{ ticket_id }}
|
|
4
|
+
Note: Could not fetch Linear ticket {{ ticket_id }} directly.
|
|
5
|
+
If a Linear MCP server is available, use it to fetch the ticket description, comments, and any relevant details for {{ ticket_id }}.
|
|
6
|
+
Otherwise, proceed based on branch name context.
|
|
5
7
|
{% endif %}
|
|
6
8
|
{% if diff_content %}
|
|
7
9
|
{{ diff_content }}
|
package/prompts/work.njk
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{% if ticket_content %}
|
|
2
2
|
{{ ticket_content }}
|
|
3
3
|
{% else %}
|
|
4
|
-
Note: Could not fetch Linear ticket {{ ticket_id }}
|
|
4
|
+
Note: Could not fetch Linear ticket {{ ticket_id }} directly.
|
|
5
|
+
If a Linear MCP server is available, use it to fetch the ticket description, comments, and any relevant details for {{ ticket_id }}.
|
|
6
|
+
Otherwise, proceed based on branch name context.
|
|
5
7
|
{% endif %}
|
|
6
8
|
|
|
7
9
|
Review the codebase to understand the relevant areas and existing patterns.
|