santree 0.1.0 → 0.1.2

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.
@@ -0,0 +1,12 @@
1
+ import { z } from "zod/v4";
2
+ export declare const description = "Render a template to stdout";
3
+ export declare const args: z.ZodTuple<[z.ZodEnum<{
4
+ linear: "linear";
5
+ "git-changes": "git-changes";
6
+ pr: "pr";
7
+ }>], null>;
8
+ type Props = {
9
+ args: z.infer<typeof args>;
10
+ };
11
+ export default function Template({ args }: Props): import("react/jsx-runtime").JSX.Element | null;
12
+ export {};
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState, useRef } from "react";
3
+ import { Text, Box, useApp } from "ink";
4
+ import Spinner from "ink-spinner";
5
+ import { argument } from "pastel";
6
+ import { z } from "zod/v4";
7
+ import { findRepoRoot, findMainRepoRoot, getCurrentBranch, extractTicketId, } from "../../lib/git.js";
8
+ import { renderTicket } from "../../lib/prompts.js";
9
+ import { getTicketContent } from "../../lib/linear.js";
10
+ import { fetchAndRenderPR, fetchAndRenderDiff } from "../../lib/ai.js";
11
+ export const description = "Render a template to stdout";
12
+ export const args = z.tuple([
13
+ z
14
+ .enum(["linear", "git-changes", "pr"])
15
+ .describe(argument({ name: "type", description: "Template type (linear, git-changes, or pr)" })),
16
+ ]);
17
+ export default function Template({ args }) {
18
+ const [type] = args;
19
+ const { exit } = useApp();
20
+ const [status, setStatus] = useState("loading");
21
+ const [message, setMessage] = useState("");
22
+ const hasRun = useRef(false);
23
+ useEffect(() => {
24
+ if (hasRun.current)
25
+ return;
26
+ hasRun.current = true;
27
+ async function run() {
28
+ await new Promise((r) => setTimeout(r, 50));
29
+ const repoRoot = findRepoRoot();
30
+ if (!repoRoot) {
31
+ setStatus("error");
32
+ setMessage("Not inside a git repository");
33
+ setTimeout(() => exit(), 100);
34
+ return;
35
+ }
36
+ const branch = getCurrentBranch();
37
+ if (!branch) {
38
+ setStatus("error");
39
+ setMessage("Could not determine current branch");
40
+ setTimeout(() => exit(), 100);
41
+ return;
42
+ }
43
+ if (type === "git-changes") {
44
+ const output = fetchAndRenderDiff(branch);
45
+ process.stdout.write(output);
46
+ setStatus("done");
47
+ setTimeout(() => exit(), 100);
48
+ }
49
+ else if (type === "pr") {
50
+ const output = fetchAndRenderPR(branch);
51
+ if (!output) {
52
+ setStatus("error");
53
+ setMessage(`No pull request found for branch '${branch}'`);
54
+ setTimeout(() => exit(), 100);
55
+ return;
56
+ }
57
+ process.stdout.write(output);
58
+ setStatus("done");
59
+ setTimeout(() => exit(), 100);
60
+ }
61
+ else {
62
+ const ticketId = extractTicketId(branch);
63
+ if (!ticketId) {
64
+ setStatus("error");
65
+ setMessage("Could not extract ticket ID from branch name. Expected format: user/TEAM-123-description");
66
+ setTimeout(() => exit(), 100);
67
+ return;
68
+ }
69
+ const mainRoot = findMainRepoRoot() ?? repoRoot;
70
+ const ticket = await getTicketContent(ticketId, mainRoot);
71
+ if (!ticket) {
72
+ setStatus("error");
73
+ setMessage(`Could not fetch Linear ticket ${ticketId}. Run 'santree linear auth' to authenticate.`);
74
+ setTimeout(() => exit(), 100);
75
+ return;
76
+ }
77
+ process.stdout.write(renderTicket(ticket));
78
+ setStatus("done");
79
+ setTimeout(() => exit(), 100);
80
+ }
81
+ }
82
+ run();
83
+ }, [type]);
84
+ if (status === "done")
85
+ return null;
86
+ const spinnerText = type === "linear"
87
+ ? "Fetching Linear ticket..."
88
+ : type === "pr"
89
+ ? "Fetching PR feedback..."
90
+ : "Gathering changes...";
91
+ 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
+ }
@@ -3,12 +3,12 @@ import { useEffect, useState } from "react";
3
3
  import { Text, Box, useInput } from "ink";
4
4
  import Spinner from "ink-spinner";
5
5
  import { z } from "zod";
6
- import { findMainRepoRoot, setRepoLinearOrg, getRepoLinearOrg } from "../../lib/git.js";
7
- import { startOAuthFlow, revokeTokens, getAuthStatus, getValidTokens, getTicketContent, readAuthStore, } from "../../lib/linear.js";
6
+ import { findMainRepoRoot, setRepoLinearOrg, getRepoLinearOrg, removeRepoLinearOrg, } from "../../lib/git.js";
7
+ import { startOAuthFlow, getAuthStatus, getValidTokens, getTicketContent, readAuthStore, } from "../../lib/linear.js";
8
8
  import { renderTicket } from "../../lib/prompts.js";
9
9
  export const description = "Authenticate with Linear";
10
10
  export const options = z.object({
11
- logout: z.boolean().optional().describe("Revoke tokens and log out"),
11
+ logout: z.boolean().optional().describe("Unlink Linear workspace from this repo"),
12
12
  status: z.boolean().optional().describe("Show current auth status"),
13
13
  test: z
14
14
  .string()
@@ -101,14 +101,19 @@ export default function LinearAuth({ options }) {
101
101
  }
102
102
  if (options.logout) {
103
103
  const repoRoot = findMainRepoRoot();
104
- const authStatus = getAuthStatus(repoRoot);
105
- if (!authStatus.authenticated || !authStatus.orgSlug) {
106
- setMessage("Not authenticated with Linear");
104
+ if (!repoRoot) {
105
+ setError("Not inside a git repository");
106
+ setStatus("error");
107
+ return;
108
+ }
109
+ const orgSlug = getRepoLinearOrg(repoRoot);
110
+ if (!orgSlug) {
111
+ setMessage("No Linear workspace linked to this repo");
107
112
  setStatus("done");
108
113
  return;
109
114
  }
110
- await revokeTokens(authStatus.orgSlug);
111
- setMessage(`Logged out from ${authStatus.orgName} (${authStatus.orgSlug})`);
115
+ removeRepoLinearOrg(repoRoot);
116
+ setMessage(`Unlinked Linear workspace (${orgSlug}) from this repo`);
112
117
  setStatus("done");
113
118
  return;
114
119
  }
@@ -149,15 +154,7 @@ export default function LinearAuth({ options }) {
149
154
  setStatus("done");
150
155
  return;
151
156
  }
152
- if (orgs.length === 1) {
153
- // Only one org, link it directly
154
- const org = orgs[0];
155
- setRepoLinearOrg(repoRoot, org.slug);
156
- setMessage(`Linked repo to ${org.name} (${org.slug})`);
157
- setStatus("done");
158
- return;
159
- }
160
- // Multiple orgs — let user choose
157
+ // Let user choose from existing orgs or authenticate a new one
161
158
  setChoices(orgs);
162
159
  setStatus("choosing");
163
160
  }
@@ -10,7 +10,7 @@ import { writeFileSync } from "fs";
10
10
  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
- import { renderPrompt } from "../../lib/prompts.js";
13
+ import { renderPrompt, renderDiff } from "../../lib/prompts.js";
14
14
  const execAsync = promisify(exec);
15
15
  export const description = "Create a GitHub pull request";
16
16
  export const options = z.object({
@@ -59,15 +59,16 @@ export default function PR({ options }) {
59
59
  setTimeout(() => exit(), 100);
60
60
  return;
61
61
  }
62
- const commitLog = getCommitLog(baseBranch) ?? "";
63
- const diffStat = getDiffStat(baseBranch) ?? "";
64
- const diff = getDiffContent(baseBranch) ?? "";
65
62
  const ticketId = extractTicketId(branch);
63
+ const diffContent = renderDiff({
64
+ base_branch: baseBranch,
65
+ commit_log: getCommitLog(baseBranch),
66
+ diff_stat: getDiffStat(baseBranch),
67
+ diff: getDiffContent(baseBranch),
68
+ });
66
69
  const prompt = renderPrompt("fill-pr", {
67
70
  pr_template: prTemplate,
68
- commit_log: commitLog,
69
- diff_stat: diffStat,
70
- diff,
71
+ diff_content: diffContent,
71
72
  ticket_id: ticketId ?? "",
72
73
  branch_name: branch,
73
74
  });
@@ -2,8 +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, launchHappy, cleanupImages } from "../../lib/ai.js";
6
- import { getPRInfo, getPRComments } from "../../lib/github.js";
5
+ import { resolveAIContext, renderAIPrompt, launchHappy, cleanupImages, fetchAndRenderPR, fetchAndRenderDiff, } from "../../lib/ai.js";
7
6
  export const description = "Fix PR review comments";
8
7
  export default function Fix() {
9
8
  const [status, setStatus] = useState("loading");
@@ -23,14 +22,18 @@ export default function Fix() {
23
22
  const ctx = result.context;
24
23
  setBranch(ctx.branch);
25
24
  setTicketId(ctx.ticketId);
26
- // Fetch PR comments
27
- const prInfo = getPRInfo(ctx.branch);
28
- let prComments;
29
- if (prInfo) {
30
- prComments = getPRComments(prInfo.number) || undefined;
25
+ const prFeedback = fetchAndRenderPR(ctx.branch);
26
+ if (!prFeedback) {
27
+ setStatus("error");
28
+ setError(`No pull request found for branch '${ctx.branch}'`);
29
+ return;
31
30
  }
31
+ const diffContent = fetchAndRenderDiff(ctx.branch);
32
32
  setStatus("launching");
33
- const prompt = renderAIPrompt("fix-pr", ctx, { pr_comments: prComments });
33
+ const prompt = renderAIPrompt("fix-pr", ctx, {
34
+ pr_feedback: prFeedback,
35
+ diff_content: diffContent,
36
+ });
34
37
  const child = launchHappy(prompt);
35
38
  child.on("error", (err) => {
36
39
  setStatus("error");
@@ -44,5 +47,5 @@ export default function Fix() {
44
47
  }
45
48
  init();
46
49
  }, []);
47
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Fix PR" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "magenta", 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: "magenta", color: "white", bold: true, children: " fix PR " })] })] }), _jsxs(Box, { marginTop: 1, children: [(status === "loading" || status === "fetching") && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", status === "loading" ? "Loading..." : "Fetching ticket and PR comments..."] })] })), 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 ", `"<fix-pr prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
50
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Fix PR" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "magenta", 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: "magenta", color: "white", bold: true, children: " fix PR " })] })] }), _jsxs(Box, { marginTop: 1, children: [(status === "loading" || status === "fetching") && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", status === "loading" ? "Loading..." : "Fetching ticket and PR feedback..."] })] })), 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 ", `"<fix-pr prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
48
51
  }
@@ -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, launchHappy, cleanupImages } from "../../lib/ai.js";
5
+ import { resolveAIContext, renderAIPrompt, launchHappy, 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,8 +22,11 @@ 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
26
  setStatus("launching");
26
- const prompt = renderAIPrompt("review", ctx);
27
+ const prompt = renderAIPrompt("review", ctx, {
28
+ diff_content: diffContent,
29
+ });
27
30
  const child = launchHappy(prompt);
28
31
  child.on("error", (err) => {
29
32
  setStatus("error");
@@ -37,5 +40,5 @@ export default function Review() {
37
40
  }
38
41
  init();
39
42
  }, []);
40
- return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Review" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "yellow", 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: "yellow", color: "white", bold: true, children: " review " })] })] }), _jsxs(Box, { marginTop: 1, children: [(status === "loading" || status === "fetching") && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", status === "loading" ? "Loading..." : "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 ", `"<review prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
43
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "Review" }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === "error" ? "red" : "yellow", 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: "yellow", color: "white", bold: true, children: " review " })] })] }), _jsxs(Box, { marginTop: 1, children: [(status === "loading" || status === "fetching") && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", status === "loading" ? "Loading..." : "Fetching ticket, diff, and PR feedback..."] })] })), 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 ", `"<review prompt for ${ticketId}>"`] })] })), status === "error" && (_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }))] })] }));
41
44
  }
@@ -44,7 +44,7 @@ export default function Work({ options }) {
44
44
  if (status !== "ready" || !aiContext)
45
45
  return;
46
46
  setStatus("launching");
47
- const prompt = renderAIPrompt(mode, aiContext);
47
+ const prompt = renderAIPrompt("work", aiContext, { mode });
48
48
  const child = launchHappy(prompt, { planMode: mode === "plan" });
49
49
  child.on("error", (err) => {
50
50
  setStatus("error");
package/dist/lib/ai.d.ts CHANGED
@@ -26,6 +26,16 @@ export declare function buildPromptContext(ctx: AIContext, extra?: Record<string
26
26
  * Renders a named prompt template with the given context.
27
27
  */
28
28
  export declare function renderAIPrompt(template: string, ctx: AIContext, extra?: Record<string, string | undefined>): string;
29
+ /**
30
+ * Fetch and render PR feedback for a branch.
31
+ * Returns rendered markdown or null if no PR exists.
32
+ */
33
+ export declare function fetchAndRenderPR(branch: string): string | null;
34
+ /**
35
+ * Fetch and render diff for a branch against its base branch.
36
+ * Returns rendered markdown.
37
+ */
38
+ export declare function fetchAndRenderDiff(branch: string): string;
29
39
  /**
30
40
  * Spawns `happy` CLI with a rendered prompt.
31
41
  * Returns the child process so callers can listen for close/error.
package/dist/lib/ai.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { spawn } from "child_process";
2
- import { getCurrentBranch, extractTicketId, findRepoRoot, findMainRepoRoot } from "./git.js";
3
- import { renderPrompt, renderTicket } from "./prompts.js";
2
+ import { getCurrentBranch, extractTicketId, findRepoRoot, findMainRepoRoot, getBaseBranch, getCommitLog, getDiffStat, getDiffContent, } from "./git.js";
3
+ import { renderPrompt, renderTicket, renderDiff, renderPR } from "./prompts.js";
4
4
  import { getTicketContent, cleanupImages } from "./linear.js";
5
+ import { getPRInfo, getPRChecks, getPRReviews, getPRReviewComments, getPRConversationComments, getFailedCheckDetails, } from "./github.js";
5
6
  /**
6
7
  * Resolves repo, branch, ticket ID, and fetches the Linear ticket.
7
8
  * Returns an error string if any required context is missing.
@@ -45,6 +46,55 @@ export function buildPromptContext(ctx, extra) {
45
46
  export function renderAIPrompt(template, ctx, extra) {
46
47
  return renderPrompt(template, buildPromptContext(ctx, extra));
47
48
  }
49
+ const BOT_AUTHORS = new Set([
50
+ "linear",
51
+ "github-actions",
52
+ "codecov",
53
+ "dependabot",
54
+ "renovate",
55
+ "netlify",
56
+ "vercel",
57
+ ]);
58
+ /**
59
+ * Fetch and render PR feedback for a branch.
60
+ * Returns rendered markdown or null if no PR exists.
61
+ */
62
+ export function fetchAndRenderPR(branch) {
63
+ const prInfo = getPRInfo(branch);
64
+ if (!prInfo)
65
+ return null;
66
+ const checks = getPRChecks(prInfo.number);
67
+ const failedChecks = (checks ?? [])
68
+ .filter((c) => c.bucket === "fail")
69
+ .map((c) => getFailedCheckDetails(c));
70
+ const reviews = getPRReviews(prInfo.number);
71
+ const reviewComments = getPRReviewComments(prInfo.number);
72
+ const allComments = getPRConversationComments(prInfo.number);
73
+ const conversationComments = (allComments ?? []).filter((c) => !BOT_AUTHORS.has(c.author) && !c.author.endsWith("[bot]"));
74
+ return renderPR({
75
+ pr_number: prInfo.number,
76
+ pr_url: prInfo.url ?? "",
77
+ branch,
78
+ checks,
79
+ failed_checks: failedChecks,
80
+ reviews,
81
+ review_comments: reviewComments,
82
+ conversation_comments: conversationComments,
83
+ });
84
+ }
85
+ /**
86
+ * Fetch and render diff for a branch against its base branch.
87
+ * Returns rendered markdown.
88
+ */
89
+ export function fetchAndRenderDiff(branch) {
90
+ const baseBranch = getBaseBranch(branch);
91
+ return renderDiff({
92
+ base_branch: baseBranch,
93
+ commit_log: getCommitLog(baseBranch),
94
+ diff_stat: getDiffStat(baseBranch),
95
+ diff: getDiffContent(baseBranch),
96
+ });
97
+ }
48
98
  /**
49
99
  * Spawns `happy` CLI with a rendered prompt.
50
100
  * Returns the child process so callers can listen for close/error.
package/dist/lib/git.d.ts CHANGED
@@ -106,6 +106,11 @@ export declare function getRepoLinearOrg(repoRoot: string): string | null;
106
106
  * Stored as `_linear.org` in .santree/metadata.json.
107
107
  */
108
108
  export declare function setRepoLinearOrg(repoRoot: string, orgSlug: string): void;
109
+ /**
110
+ * Remove the Linear org association from this repo.
111
+ * Deletes the `_linear` key from .santree/metadata.json.
112
+ */
113
+ export declare function removeRepoLinearOrg(repoRoot: string): void;
109
114
  /**
110
115
  * Get the base branch for a given branch name.
111
116
  * Looks up metadata first, falls back to the default branch.
package/dist/lib/git.js CHANGED
@@ -315,6 +315,15 @@ export function setRepoLinearOrg(repoRoot, orgSlug) {
315
315
  all._linear = { org: orgSlug };
316
316
  writeAllMetadata(repoRoot, all);
317
317
  }
318
+ /**
319
+ * Remove the Linear org association from this repo.
320
+ * Deletes the `_linear` key from .santree/metadata.json.
321
+ */
322
+ export function removeRepoLinearOrg(repoRoot) {
323
+ const all = readAllMetadata(repoRoot);
324
+ delete all._linear;
325
+ writeAllMetadata(repoRoot, all);
326
+ }
318
327
  /**
319
328
  * Get the base branch for a given branch name.
320
329
  * Looks up metadata first, falls back to the default branch.
@@ -47,3 +47,76 @@ export declare function getPRTemplate(): string | null;
47
47
  * Returns empty string if the PR has no comments or on failure.
48
48
  */
49
49
  export declare function getPRComments(prNumber: string): string;
50
+ export interface PRConversationComment {
51
+ author: string;
52
+ body: string;
53
+ createdAt: string;
54
+ }
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
+ export interface PRCheck {
62
+ name: string;
63
+ state: string;
64
+ bucket: string;
65
+ link: string;
66
+ description: string;
67
+ workflow: string;
68
+ }
69
+ export interface FailedCheckDetail {
70
+ name: string;
71
+ workflow: string;
72
+ description: string;
73
+ link: string;
74
+ failed_step: string | null;
75
+ log: string | null;
76
+ }
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
+ export interface PRReview {
85
+ author: {
86
+ login: string;
87
+ };
88
+ state: string;
89
+ body: string;
90
+ submittedAt: string;
91
+ }
92
+ export interface PRReviewComment {
93
+ user: {
94
+ login: string;
95
+ };
96
+ body: string;
97
+ path: string;
98
+ line: number | null;
99
+ original_line: number | null;
100
+ diff_hunk: string;
101
+ created_at: string;
102
+ in_reply_to_id?: number;
103
+ id: number;
104
+ }
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;
@@ -109,3 +109,157 @@ export function getPRTemplate() {
109
109
  export function getPRComments(prNumber) {
110
110
  return (run(`gh pr view ${prNumber} --json comments --jq '.comments[] | "- \\(.author.login): \\(.body)"'`) ?? "");
111
111
  }
112
+ /**
113
+ * Fetch structured conversation comments on a pull request.
114
+ * Runs: `gh pr view <prNumber> --json comments`
115
+ * Returns null if gh CLI fails.
116
+ */
117
+ export function getPRConversationComments(prNumber) {
118
+ const output = run(`gh pr view ${prNumber} --json comments`);
119
+ if (!output)
120
+ return null;
121
+ try {
122
+ const data = JSON.parse(output);
123
+ return (data.comments ?? []).map((c) => ({
124
+ author: c.author?.login ?? "unknown",
125
+ body: c.body ?? "",
126
+ createdAt: c.createdAt ?? "",
127
+ }));
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ /**
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.
138
+ */
139
+ export function getFailedCheckDetails(check) {
140
+ const detail = {
141
+ name: check.name,
142
+ workflow: check.workflow,
143
+ description: check.description,
144
+ link: check.link,
145
+ failed_step: null,
146
+ log: null,
147
+ };
148
+ const urlMatch = check.link?.match(/job\/(\d+)/);
149
+ if (!urlMatch)
150
+ return detail;
151
+ const jobId = urlMatch[1];
152
+ let stepStartMs = 0;
153
+ let stepEndMs = 0;
154
+ const jobOutput = run(`gh api repos/{owner}/{repo}/actions/jobs/${jobId}`);
155
+ if (jobOutput) {
156
+ try {
157
+ const job = JSON.parse(jobOutput);
158
+ const failedStep = job.steps?.find((s) => s.conclusion === "failure");
159
+ if (failedStep) {
160
+ detail.failed_step = failedStep.name;
161
+ stepStartMs = new Date(failedStep.started_at).getTime();
162
+ // Add 1s buffer — step API uses second precision but log has sub-second timestamps
163
+ stepEndMs = new Date(failedStep.completed_at).getTime() + 999;
164
+ }
165
+ }
166
+ catch { }
167
+ }
168
+ if (!stepStartMs)
169
+ return detail;
170
+ // Fetch job log via API (works even while run is still in progress)
171
+ const logOutput = run(`gh api repos/{owner}/{repo}/actions/jobs/${jobId}/logs 2>/dev/null`);
172
+ if (logOutput) {
173
+ const lines = logOutput.split("\n");
174
+ // Filter to lines within the failed step's time range
175
+ const stepLines = lines.filter((line) => {
176
+ const m = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)/);
177
+ if (!m)
178
+ return false;
179
+ const ms = new Date(m[1]).getTime();
180
+ return ms >= stepStartMs && ms <= stepEndMs;
181
+ });
182
+ // Truncate at ##[error] — everything after is post-run cleanup noise
183
+ const errorIdx = stepLines.findIndex((l) => l.includes("##[error]"));
184
+ 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
+ const segments = [];
189
+ let current = [];
190
+ let inGroup = false;
191
+ for (const raw of bounded) {
192
+ const line = raw.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*/, "");
193
+ if (line.startsWith("##[group]")) {
194
+ if (current.length) {
195
+ segments.push(current);
196
+ current = [];
197
+ }
198
+ inGroup = true;
199
+ continue;
200
+ }
201
+ if (line.startsWith("##[endgroup]")) {
202
+ inGroup = false;
203
+ continue;
204
+ }
205
+ if (line.startsWith("##["))
206
+ continue;
207
+ if (!inGroup)
208
+ current.push(line);
209
+ }
210
+ if (current.length)
211
+ segments.push(current);
212
+ if (segments.length)
213
+ detail.log = segments[segments.length - 1].join("\n");
214
+ }
215
+ return detail;
216
+ }
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
+ }
@@ -1,4 +1,5 @@
1
1
  import type { LinearIssue } from "./linear.js";
2
+ import type { PRCheck, PRReview, PRReviewComment, FailedCheckDetail, PRConversationComment } from "./github.js";
2
3
  /**
3
4
  * Render a nunjucks template from the prompts/ directory.
4
5
  * @param template - Template name without extension (e.g. "fill-pr")
@@ -9,3 +10,27 @@ export declare function renderPrompt(template: string, context: Record<string, s
9
10
  * Render a LinearIssue into formatted markdown using the ticket template.
10
11
  */
11
12
  export declare function renderTicket(issue: LinearIssue): string;
13
+ export interface DiffData {
14
+ base_branch: string;
15
+ commit_log: string | null;
16
+ diff_stat: string | null;
17
+ diff: string | null;
18
+ }
19
+ /**
20
+ * Render diff data into formatted markdown using the diff template.
21
+ */
22
+ export declare function renderDiff(data: DiffData): string;
23
+ export interface PRData {
24
+ pr_number: string;
25
+ pr_url: string;
26
+ branch: string;
27
+ checks: PRCheck[] | null;
28
+ failed_checks: FailedCheckDetail[];
29
+ reviews: PRReview[] | null;
30
+ review_comments: PRReviewComment[] | null;
31
+ conversation_comments: PRConversationComment[];
32
+ }
33
+ /**
34
+ * Render PR feedback data into formatted markdown using the pr template.
35
+ */
36
+ export declare function renderPR(data: PRData): string;
@@ -38,3 +38,15 @@ export function renderPrompt(template, context) {
38
38
  export function renderTicket(issue) {
39
39
  return promptsEnv.render("ticket.njk", issue);
40
40
  }
41
+ /**
42
+ * Render diff data into formatted markdown using the diff template.
43
+ */
44
+ export function renderDiff(data) {
45
+ return promptsEnv.render("diff.njk", data);
46
+ }
47
+ /**
48
+ * Render PR feedback data into formatted markdown using the pr template.
49
+ */
50
+ export function renderPR(data) {
51
+ return promptsEnv.render("pr.njk", data);
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -0,0 +1,18 @@
1
+ ## Changes ({{ base_branch }}..HEAD)
2
+ {%- if commit_log %}
3
+
4
+ ### Commits
5
+ {{ commit_log }}
6
+ {%- endif %}
7
+ {%- if diff_stat %}
8
+
9
+ ### Files Changed
10
+ {{ diff_stat }}
11
+ {%- endif %}
12
+ {%- if diff %}
13
+
14
+ ### Diff
15
+ ```
16
+ {{ diff }}
17
+ ```
18
+ {%- endif %}
@@ -12,16 +12,7 @@ Branch: {{ branch_name }}
12
12
  {{ ticket_content }}
13
13
  {% endif %}
14
14
 
15
- ### Commits
16
- {{ commit_log }}
17
-
18
- ### Files Changed
19
- {{ diff_stat }}
20
-
21
- ### Diff
22
- ```
23
- {{ diff }}
24
- ```
15
+ {{ diff_content }}
25
16
 
26
17
  ## Instructions
27
18
 
@@ -3,13 +3,13 @@
3
3
  {% else %}
4
4
  Note: Could not fetch Linear ticket {{ ticket_id }}. Proceed based on branch name context.
5
5
  {% endif %}
6
-
7
- Use `gh` CLI to fetch the latest PR comments and review feedback for the current branch.
8
- {% if pr_comments %}
9
- ## PR Comments to Address
10
-
11
- {{ pr_comments }}
6
+ {% if pr_feedback %}
7
+ {{ pr_feedback }}
12
8
  {% endif %}
9
+ {% if diff_content %}
10
+ {{ diff_content }}
11
+ {% endif %}
12
+
13
13
  ## Task
14
14
 
15
15
  1. Review each PR comment and understand what changes are requested
package/prompts/pr.njk ADDED
@@ -0,0 +1,68 @@
1
+ ## Pull Request #{{ pr_number }}
2
+ **Branch:** {{ branch }}
3
+ [View on GitHub]({{ pr_url }})
4
+
5
+ {%- if checks %}
6
+
7
+ ### CI Checks
8
+ {% for check in checks %}
9
+ - {{ "PASS" if check.bucket == "pass" else ("FAIL" if check.bucket == "fail" else "PENDING") }} — {{ check.name }}{% if check.description %}: {{ check.description }}{% endif %}
10
+ {%- endfor %}
11
+ {%- endif %}
12
+ {%- if failed_checks | length %}
13
+
14
+ ### Failed Checks
15
+ {% for check in failed_checks %}
16
+ **{{ check.name }}**{% if check.workflow %} ({{ check.workflow }}){% endif %}
17
+ {% if check.failed_step %}- Failed step: `{{ check.failed_step }}`{% endif %}
18
+ {% if check.link %}- [View logs]({{ check.link }}){% endif %}
19
+ {% if check.log %}
20
+ ```
21
+ {{ check.log }}
22
+ ```
23
+ {% endif %}
24
+ {% endfor %}
25
+ {%- endif %}
26
+ {%- if reviews | length %}
27
+
28
+ ### Reviews
29
+ {% for review in reviews %}
30
+ **{{ review.author.login }}** — {{ review.state }}{% if review.submittedAt %} — {{ review.submittedAt | date }}{% endif %}
31
+ {% if review.body %}{{ review.body }}{% endif %}
32
+ {% if not loop.last %}
33
+ ---
34
+ {% endif %}
35
+ {%- endfor %}
36
+ {%- endif %}
37
+ {%- if review_comments | length %}
38
+
39
+ ### Inline Review Comments
40
+ {% for comment in review_comments %}
41
+ {% if not comment.in_reply_to_id %}
42
+ **{{ comment.user.login }}** on `{{ comment.path }}{% if comment.line %}:{{ comment.line }}{% endif %}` — {{ comment.created_at | date }}
43
+ ```
44
+ {{ comment.diff_hunk }}
45
+ ```
46
+ {{ comment.body }}
47
+ {% for reply in review_comments %}
48
+ {% if reply.in_reply_to_id == comment.id %}
49
+ > **{{ reply.user.login }}** — {{ reply.created_at | date }}
50
+ > {{ reply.body | indent(2) }}
51
+
52
+ {% endif %}
53
+ {%- endfor %}
54
+ ---
55
+ {% endif %}
56
+ {%- endfor %}
57
+ {%- endif %}
58
+ {%- if conversation_comments | length %}
59
+
60
+ ### Conversation Comments
61
+ {% for comment in conversation_comments %}
62
+ **{{ comment.author }}** — {{ comment.createdAt | date }}
63
+ {{ comment.body }}
64
+ {% if not loop.last %}
65
+ ---
66
+ {% endif %}
67
+ {%- endfor %}
68
+ {%- endif %}
@@ -3,10 +3,9 @@
3
3
  {% else %}
4
4
  Note: Could not fetch Linear ticket {{ ticket_id }}. Proceed based on branch name context.
5
5
  {% endif %}
6
-
7
- If a PR URL is linked in the ticket, use `gh` CLI to fetch PR details and any existing review comments.
8
-
9
- Review the current changes by running `git diff` against the base branch.
6
+ {% if diff_content %}
7
+ {{ diff_content }}
8
+ {% endif %}
10
9
 
11
10
  Analyze:
12
11
  - Do the changes fully address the ticket requirements?
@@ -4,12 +4,18 @@
4
4
  Note: Could not fetch Linear ticket {{ ticket_id }}. Proceed based on branch name context.
5
5
  {% endif %}
6
6
 
7
- If a PR URL is linked in the ticket, use `gh` CLI to fetch PR details, comments, and review feedback.
8
-
9
7
  Review the codebase to understand the relevant areas and existing patterns.
8
+ {% if mode == "plan" %}
9
+ Create a detailed implementation plan with:
10
+ - Step-by-step approach
11
+ - Files to modify
12
+ - Potential risks or edge cases
10
13
 
14
+ Do NOT implement yet - just plan. Wait for approval before making changes.
15
+ {% else %}
11
16
  Create an implementation plan, then implement the changes.
12
17
 
13
18
  After implementation:
14
19
  - Run tests if applicable
15
20
  - Ensure code follows existing patterns
21
+ {% endif %}
package/prompts/plan.njk DELETED
@@ -1,19 +0,0 @@
1
- {% if ticket_content %}
2
- {{ ticket_content }}
3
- {% else %}
4
- Note: Could not fetch Linear ticket {{ ticket_id }}. Proceed based on branch name context.
5
- {% endif %}
6
-
7
- If a PR URL is linked in the ticket, use `gh` CLI to fetch PR details, comments, and review feedback for additional context.
8
-
9
- Review the codebase to understand:
10
- - Relevant files and modules
11
- - Existing patterns and conventions
12
- - Dependencies and potential impact areas
13
-
14
- Create a detailed implementation plan with:
15
- - Step-by-step approach
16
- - Files to modify
17
- - Potential risks or edge cases
18
-
19
- Do NOT implement yet - just plan. Wait for approval before making changes.