santree 0.5.3 → 0.5.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 +145 -52
- package/dist/commands/dashboard.d.ts +1 -1
- package/dist/commands/dashboard.js +22 -18
- package/dist/commands/doctor.js +33 -71
- package/dist/commands/github/auth.d.ts +2 -0
- package/dist/commands/github/auth.js +56 -0
- package/dist/commands/github/index.d.ts +1 -0
- package/dist/commands/github/index.js +1 -0
- package/dist/commands/helpers/template.d.ts +1 -0
- package/dist/commands/helpers/template.js +13 -10
- package/dist/commands/issue/index.d.ts +1 -0
- package/dist/commands/issue/index.js +1 -0
- package/dist/commands/issue/open.d.ts +2 -0
- package/dist/commands/{linear → issue}/open.js +13 -11
- package/dist/commands/issue/switch.d.ts +11 -0
- package/dist/commands/issue/switch.js +38 -0
- package/dist/commands/linear/auth.js +23 -10
- package/dist/commands/linear/switch.js +7 -3
- package/dist/commands/pr/create.js +7 -5
- package/dist/commands/worktree/create.js +4 -6
- package/dist/commands/worktree/work.js +1 -1
- package/dist/lib/ai.d.ts +8 -6
- package/dist/lib/ai.js +29 -15
- package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
- package/dist/lib/dashboard/DetailPanel.js +6 -3
- package/dist/lib/dashboard/data.js +17 -9
- package/dist/lib/dashboard/types.d.ts +3 -16
- package/dist/lib/git.d.ts +16 -33
- package/dist/lib/git.js +20 -74
- package/dist/lib/metadata.d.ts +3 -0
- package/dist/lib/metadata.js +27 -0
- package/dist/lib/multiplexer/cmux.js +1 -1
- package/dist/lib/multiplexer/types.d.ts +1 -1
- package/dist/lib/prompts.d.ts +4 -3
- package/dist/lib/prompts.js +4 -3
- package/dist/lib/session-signal.d.ts +2 -3
- package/dist/lib/session-signal.js +3 -29
- package/dist/lib/trackers/auth-store.d.ts +16 -0
- package/dist/lib/trackers/auth-store.js +57 -0
- package/dist/lib/trackers/config.d.ts +8 -0
- package/dist/lib/trackers/config.js +21 -0
- package/dist/lib/trackers/github/api.d.ts +3 -0
- package/dist/lib/trackers/github/api.js +90 -0
- package/dist/lib/trackers/github/auth.d.ts +5 -0
- package/dist/lib/trackers/github/auth.js +27 -0
- package/dist/lib/trackers/github/images.d.ts +2 -0
- package/dist/lib/trackers/github/images.js +42 -0
- package/dist/lib/trackers/github/index.d.ts +2 -0
- package/dist/lib/trackers/github/index.js +78 -0
- package/dist/lib/trackers/index.d.ts +12 -0
- package/dist/lib/trackers/index.js +34 -0
- package/dist/lib/trackers/linear/api.d.ts +4 -0
- package/dist/lib/trackers/linear/api.js +128 -0
- package/dist/lib/trackers/linear/auth.d.ts +11 -0
- package/dist/lib/trackers/linear/auth.js +206 -0
- package/dist/lib/trackers/linear/images.d.ts +2 -0
- package/dist/lib/trackers/linear/images.js +44 -0
- package/dist/lib/trackers/linear/index.d.ts +3 -0
- package/dist/lib/trackers/linear/index.js +100 -0
- package/dist/lib/trackers/types.d.ts +52 -0
- package/dist/lib/trackers/types.js +1 -0
- package/package.json +1 -1
- package/prompts/ticket.njk +3 -3
- package/dist/commands/linear/open.d.ts +0 -2
- package/dist/lib/linear.d.ts +0 -83
- package/dist/lib/linear.js +0 -482
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import { spawn } from "child_process";
|
|
4
3
|
import { getMultiplexer } from "./multiplexer/index.js";
|
|
5
4
|
export function readStdin() {
|
|
6
5
|
try {
|
|
@@ -22,7 +21,7 @@ export function extractRepoAndTicket(cwd) {
|
|
|
22
21
|
return null;
|
|
23
22
|
return { repoRoot, ticketId };
|
|
24
23
|
}
|
|
25
|
-
export function
|
|
24
|
+
export function renameSessionWindow(ticketId, state) {
|
|
26
25
|
const mux = getMultiplexer();
|
|
27
26
|
if (!mux.isActive())
|
|
28
27
|
return;
|
|
@@ -40,25 +39,9 @@ export function renameTmuxWindow(ticketId, state) {
|
|
|
40
39
|
}
|
|
41
40
|
mux.renameWindow("", name);
|
|
42
41
|
}
|
|
43
|
-
export function runHookScript(repoRoot, state, env) {
|
|
44
|
-
const script = path.join(repoRoot, ".santree", "hooks", `on-${state}.sh`);
|
|
45
|
-
try {
|
|
46
|
-
fs.accessSync(script, fs.constants.X_OK);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
const child = spawn(script, [], {
|
|
52
|
-
cwd: env.SANTREE_WORKTREE_PATH,
|
|
53
|
-
env: { ...process.env, ...env },
|
|
54
|
-
stdio: "ignore",
|
|
55
|
-
detached: true,
|
|
56
|
-
});
|
|
57
|
-
child.unref();
|
|
58
|
-
}
|
|
59
42
|
/**
|
|
60
43
|
* Unified helper: reads stdin, extracts repo/ticket, writes state file,
|
|
61
|
-
* renames
|
|
44
|
+
* renames the multiplexer window, then exits.
|
|
62
45
|
*/
|
|
63
46
|
export function signalState(state) {
|
|
64
47
|
const input = readStdin();
|
|
@@ -85,16 +68,7 @@ export function signalState(state) {
|
|
|
85
68
|
at: new Date().toISOString(),
|
|
86
69
|
};
|
|
87
70
|
fs.writeFileSync(stateFile, JSON.stringify(payload, null, 2) + "\n");
|
|
88
|
-
|
|
89
|
-
const worktreePath = path.join(repoRoot, ".santree", "worktrees", ticketId);
|
|
90
|
-
runHookScript(repoRoot, state, {
|
|
91
|
-
SANTREE_TICKET_ID: ticketId,
|
|
92
|
-
SANTREE_SESSION_STATE: state,
|
|
93
|
-
SANTREE_SESSION_ID: data.session_id ?? "",
|
|
94
|
-
SANTREE_WORKTREE_PATH: worktreePath,
|
|
95
|
-
SANTREE_REPO_ROOT: repoRoot,
|
|
96
|
-
SANTREE_MESSAGE: payload.message ?? "",
|
|
97
|
-
});
|
|
71
|
+
renameSessionWindow(ticketId, state);
|
|
98
72
|
process.exit(0);
|
|
99
73
|
}
|
|
100
74
|
export function getHooksJson() {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface LinearTokens {
|
|
2
|
+
access_token: string;
|
|
3
|
+
refresh_token: string;
|
|
4
|
+
expires_at: number;
|
|
5
|
+
org_name: string;
|
|
6
|
+
}
|
|
7
|
+
export interface AuthStoreV2 {
|
|
8
|
+
version: 2;
|
|
9
|
+
linear: Record<string, LinearTokens>;
|
|
10
|
+
github: Record<string, never>;
|
|
11
|
+
}
|
|
12
|
+
export declare function readAuthStore(): AuthStoreV2;
|
|
13
|
+
export declare function writeAuthStore(store: AuthStoreV2): void;
|
|
14
|
+
export declare function readLinearAuthStore(): Record<string, LinearTokens>;
|
|
15
|
+
export declare function writeLinearTokens(orgSlug: string, tokens: LinearTokens): void;
|
|
16
|
+
export declare function deleteLinearTokens(orgSlug: string): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
const CONFIG_DIR = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
5
|
+
const AUTH_FILE_PATH = path.join(CONFIG_DIR, "santree", "auth.json");
|
|
6
|
+
function emptyStore() {
|
|
7
|
+
return { version: 2, linear: {}, github: {} };
|
|
8
|
+
}
|
|
9
|
+
export function readAuthStore() {
|
|
10
|
+
if (!fs.existsSync(AUTH_FILE_PATH))
|
|
11
|
+
return emptyStore();
|
|
12
|
+
let raw;
|
|
13
|
+
try {
|
|
14
|
+
raw = JSON.parse(fs.readFileSync(AUTH_FILE_PATH, "utf-8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return emptyStore();
|
|
18
|
+
}
|
|
19
|
+
if (raw && typeof raw === "object" && raw.version === 2) {
|
|
20
|
+
const v2 = raw;
|
|
21
|
+
return {
|
|
22
|
+
version: 2,
|
|
23
|
+
linear: v2.linear ?? {},
|
|
24
|
+
github: (v2.github ?? {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const migrated = emptyStore();
|
|
28
|
+
if (raw && typeof raw === "object") {
|
|
29
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
30
|
+
if (v && typeof v === "object" && "access_token" in v) {
|
|
31
|
+
migrated.linear[k] = v;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
writeAuthStore(migrated);
|
|
36
|
+
return migrated;
|
|
37
|
+
}
|
|
38
|
+
export function writeAuthStore(store) {
|
|
39
|
+
const dir = path.dirname(AUTH_FILE_PATH);
|
|
40
|
+
if (!fs.existsSync(dir)) {
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
fs.writeFileSync(AUTH_FILE_PATH, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
44
|
+
}
|
|
45
|
+
export function readLinearAuthStore() {
|
|
46
|
+
return readAuthStore().linear;
|
|
47
|
+
}
|
|
48
|
+
export function writeLinearTokens(orgSlug, tokens) {
|
|
49
|
+
const store = readAuthStore();
|
|
50
|
+
store.linear[orgSlug] = tokens;
|
|
51
|
+
writeAuthStore(store);
|
|
52
|
+
}
|
|
53
|
+
export function deleteLinearTokens(orgSlug) {
|
|
54
|
+
const store = readAuthStore();
|
|
55
|
+
delete store.linear[orgSlug];
|
|
56
|
+
writeAuthStore(store);
|
|
57
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IssueTrackerKind } from "./types.js";
|
|
2
|
+
export interface TrackerConfig {
|
|
3
|
+
kind: IssueTrackerKind | null;
|
|
4
|
+
legacyLinearOrg: string | null;
|
|
5
|
+
}
|
|
6
|
+
export declare function readTrackerConfig(repoRoot: string): TrackerConfig;
|
|
7
|
+
export declare function setRepoTracker(repoRoot: string, kind: IssueTrackerKind): void;
|
|
8
|
+
export declare function removeRepoTracker(repoRoot: string): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readAllMetadata, writeAllMetadata } from "../metadata.js";
|
|
2
|
+
export function readTrackerConfig(repoRoot) {
|
|
3
|
+
const all = readAllMetadata(repoRoot);
|
|
4
|
+
const tracker = all._tracker;
|
|
5
|
+
const linear = all._linear;
|
|
6
|
+
let kind = null;
|
|
7
|
+
if (tracker?.kind === "linear" || tracker?.kind === "github") {
|
|
8
|
+
kind = tracker.kind;
|
|
9
|
+
}
|
|
10
|
+
return { kind, legacyLinearOrg: linear?.org ?? null };
|
|
11
|
+
}
|
|
12
|
+
export function setRepoTracker(repoRoot, kind) {
|
|
13
|
+
const all = readAllMetadata(repoRoot);
|
|
14
|
+
all._tracker = { kind };
|
|
15
|
+
writeAllMetadata(repoRoot, all);
|
|
16
|
+
}
|
|
17
|
+
export function removeRepoTracker(repoRoot) {
|
|
18
|
+
const all = readAllMetadata(repoRoot);
|
|
19
|
+
delete all._tracker;
|
|
20
|
+
writeAllMetadata(repoRoot, all);
|
|
21
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
const execAsync = promisify(exec);
|
|
4
|
+
const PRIORITY_PATTERNS = [
|
|
5
|
+
{ regex: /^p0(?:\b|:|-)|urgent|critical/i, rank: 1, label: "Urgent" },
|
|
6
|
+
{ regex: /^p1(?:\b|:|-)|high(?:[ -]priority)?/i, rank: 2, label: "High" },
|
|
7
|
+
{ regex: /^p2(?:\b|:|-)|medium(?:[ -]priority)?/i, rank: 3, label: "Medium" },
|
|
8
|
+
{ regex: /^p3(?:\b|:|-)|low(?:[ -]priority)?/i, rank: 4, label: "Low" },
|
|
9
|
+
];
|
|
10
|
+
function priorityFromLabels(labels) {
|
|
11
|
+
for (const label of labels) {
|
|
12
|
+
for (const pat of PRIORITY_PATTERNS) {
|
|
13
|
+
if (pat.regex.test(label)) {
|
|
14
|
+
return { priority: pat.rank, priorityLabel: pat.label };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { priority: 0, priorityLabel: "No priority" };
|
|
19
|
+
}
|
|
20
|
+
function deriveState(state, stateReason) {
|
|
21
|
+
if (state === "closed") {
|
|
22
|
+
const completed = !stateReason || stateReason === "completed";
|
|
23
|
+
return { name: completed ? "Done" : "Cancelled", type: completed ? "completed" : "canceled" };
|
|
24
|
+
}
|
|
25
|
+
return { name: "Open", type: "started" };
|
|
26
|
+
}
|
|
27
|
+
function ghJsonRun(cmd) {
|
|
28
|
+
return execAsync(cmd, { maxBuffer: 10 * 1024 * 1024 })
|
|
29
|
+
.then(({ stdout }) => {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(stdout);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
.catch(() => null);
|
|
38
|
+
}
|
|
39
|
+
export async function fetchAssignedIssues(repoNwo) {
|
|
40
|
+
const cmd = `gh search issues --assignee=@me --state=open --repo ${repoNwo} --limit 100 --json number,title,body,url,state,labels,repository`;
|
|
41
|
+
const result = await ghJsonRun(cmd);
|
|
42
|
+
if (!result)
|
|
43
|
+
return null;
|
|
44
|
+
return result.map((row) => {
|
|
45
|
+
const labels = (row.labels ?? []).map((l) => l.name);
|
|
46
|
+
const { priority, priorityLabel } = priorityFromLabels(labels);
|
|
47
|
+
return {
|
|
48
|
+
identifier: String(row.number),
|
|
49
|
+
title: row.title,
|
|
50
|
+
description: row.body ?? null,
|
|
51
|
+
url: row.url,
|
|
52
|
+
priority,
|
|
53
|
+
priorityLabel,
|
|
54
|
+
state: deriveState(row.state.toLowerCase()),
|
|
55
|
+
labels,
|
|
56
|
+
projectId: null,
|
|
57
|
+
projectName: row.repository?.nameWithOwner ?? null,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export async function fetchIssue(repoNwo, identifier) {
|
|
62
|
+
const number = identifier.replace(/^#/, "");
|
|
63
|
+
if (!/^\d+$/.test(number))
|
|
64
|
+
return null;
|
|
65
|
+
const issue = await ghJsonRun(`gh api repos/${repoNwo}/issues/${number}`);
|
|
66
|
+
if (!issue)
|
|
67
|
+
return null;
|
|
68
|
+
const commentsRaw = await ghJsonRun(`gh api repos/${repoNwo}/issues/${number}/comments --paginate`);
|
|
69
|
+
const labels = issue.labels.map((l) => l.name);
|
|
70
|
+
const { priority, priorityLabel } = priorityFromLabels(labels);
|
|
71
|
+
const comments = (commentsRaw ?? []).map((c) => ({
|
|
72
|
+
author: c.user.login,
|
|
73
|
+
body: c.body,
|
|
74
|
+
createdAt: c.created_at,
|
|
75
|
+
children: [],
|
|
76
|
+
}));
|
|
77
|
+
return {
|
|
78
|
+
identifier: String(issue.number),
|
|
79
|
+
title: issue.title,
|
|
80
|
+
description: issue.body ?? null,
|
|
81
|
+
url: issue.html_url,
|
|
82
|
+
priority,
|
|
83
|
+
priorityLabel,
|
|
84
|
+
state: deriveState(issue.state, issue.state_reason ?? null),
|
|
85
|
+
labels,
|
|
86
|
+
projectId: issue.milestone?.number ? String(issue.milestone.number) : null,
|
|
87
|
+
projectName: issue.milestone?.title ?? repoNwo,
|
|
88
|
+
comments,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
const execAsync = promisify(exec);
|
|
4
|
+
export async function getAuthenticatedUser() {
|
|
5
|
+
try {
|
|
6
|
+
const { stdout } = await execAsync("gh api user --jq .login");
|
|
7
|
+
const login = stdout.trim();
|
|
8
|
+
if (!login)
|
|
9
|
+
return null;
|
|
10
|
+
return { login };
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function getCurrentRepoNwo(cwd) {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execAsync("gh repo view --json nameWithOwner --jq .nameWithOwner", {
|
|
19
|
+
cwd,
|
|
20
|
+
});
|
|
21
|
+
const nwo = stdout.trim();
|
|
22
|
+
return nwo || null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
function getTempImageDir(identifier) {
|
|
5
|
+
return path.join(os.tmpdir(), `santree-images-gh-${identifier}`);
|
|
6
|
+
}
|
|
7
|
+
export async function rewriteGithubImages(markdown, identifier) {
|
|
8
|
+
const imageRegex = /!\[([^\]]*)\]\((https:\/\/(?:user-images\.githubusercontent\.com|github\.com\/[^)]*\/assets|private-user-images\.githubusercontent\.com)[^)]+)\)/g;
|
|
9
|
+
const matches = [...markdown.matchAll(imageRegex)];
|
|
10
|
+
if (matches.length === 0)
|
|
11
|
+
return markdown;
|
|
12
|
+
const tempDir = getTempImageDir(identifier);
|
|
13
|
+
if (!fs.existsSync(tempDir)) {
|
|
14
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
let result = markdown;
|
|
17
|
+
for (let i = 0; i < matches.length; i++) {
|
|
18
|
+
const match = matches[i];
|
|
19
|
+
const [fullMatch, altText, url] = match;
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(url);
|
|
22
|
+
if (!res.ok)
|
|
23
|
+
continue;
|
|
24
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
25
|
+
const ext = path.extname(new URL(url).pathname) || ".png";
|
|
26
|
+
const filename = `image-${i}${ext}`;
|
|
27
|
+
const filePath = path.join(tempDir, filename);
|
|
28
|
+
fs.writeFileSync(filePath, buffer);
|
|
29
|
+
result = result.replace(fullMatch, ``);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// keep original URL on failure
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
export function cleanupGithubImages(identifier) {
|
|
38
|
+
const tempDir = getTempImageDir(identifier);
|
|
39
|
+
if (fs.existsSync(tempDir)) {
|
|
40
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { getAuthenticatedUser, getCurrentRepoNwo } from "./auth.js";
|
|
2
|
+
import { fetchAssignedIssues, fetchIssue } from "./api.js";
|
|
3
|
+
import { cleanupGithubImages, rewriteGithubImages } from "./images.js";
|
|
4
|
+
async function getAuthStatus(_repoRoot) {
|
|
5
|
+
const user = await getAuthenticatedUser();
|
|
6
|
+
if (!user) {
|
|
7
|
+
return { authenticated: false, hint: "Run: santree github auth (or: gh auth login)" };
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
authenticated: true,
|
|
11
|
+
accountLabel: `@${user.login}`,
|
|
12
|
+
repoLinked: true,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async function signOut(_repoRoot) {
|
|
16
|
+
// gh CLI owns the token. No santree-side credential to clear.
|
|
17
|
+
}
|
|
18
|
+
function extractIdFromBranch(branch) {
|
|
19
|
+
// Require an explicit prefix so commit-style branches like `fix-typo-1`
|
|
20
|
+
// don't match. Recognized: `gh-NN`, `issue-NN`, `#NN`, or a slash-separated
|
|
21
|
+
// number segment (e.g. `feature/123-foo`, `123-foo`).
|
|
22
|
+
const explicit = branch.match(/(?:^|[/_-])(?:gh-|issue-|#)(\d+)/i);
|
|
23
|
+
if (explicit?.[1])
|
|
24
|
+
return explicit[1];
|
|
25
|
+
const slashLed = branch.match(/(?:^|\/)(\d+)(?:-|$)/);
|
|
26
|
+
if (slashLed?.[1])
|
|
27
|
+
return slashLed[1];
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
async function listAssigned(repoRoot) {
|
|
31
|
+
const user = await getAuthenticatedUser();
|
|
32
|
+
if (!user) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
reason: "unauthenticated",
|
|
36
|
+
message: "Run: santree github auth (or: gh auth login)",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const nwo = await getCurrentRepoNwo(repoRoot);
|
|
40
|
+
if (!nwo) {
|
|
41
|
+
return { ok: false, reason: "network", message: "Could not resolve GitHub repo" };
|
|
42
|
+
}
|
|
43
|
+
const issues = await fetchAssignedIssues(nwo);
|
|
44
|
+
if (issues === null) {
|
|
45
|
+
return { ok: false, reason: "network", message: "GitHub API request failed" };
|
|
46
|
+
}
|
|
47
|
+
return { ok: true, value: issues };
|
|
48
|
+
}
|
|
49
|
+
async function getIssue(identifier, repoRoot) {
|
|
50
|
+
const nwo = await getCurrentRepoNwo(repoRoot);
|
|
51
|
+
if (!nwo) {
|
|
52
|
+
return { ok: false, reason: "network", message: "Could not resolve GitHub repo" };
|
|
53
|
+
}
|
|
54
|
+
const issue = await fetchIssue(nwo, identifier);
|
|
55
|
+
if (!issue) {
|
|
56
|
+
return { ok: false, reason: "not-found", message: `Issue #${identifier} not found` };
|
|
57
|
+
}
|
|
58
|
+
if (issue.description) {
|
|
59
|
+
issue.description = await rewriteGithubImages(issue.description, identifier);
|
|
60
|
+
}
|
|
61
|
+
for (const comment of issue.comments) {
|
|
62
|
+
if (comment.body) {
|
|
63
|
+
comment.body = await rewriteGithubImages(comment.body, identifier);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, value: issue };
|
|
67
|
+
}
|
|
68
|
+
export const githubTracker = {
|
|
69
|
+
kind: "github",
|
|
70
|
+
displayName: "GitHub",
|
|
71
|
+
issueNoun: "issue",
|
|
72
|
+
getAuthStatus,
|
|
73
|
+
signOut,
|
|
74
|
+
extractIdFromBranch,
|
|
75
|
+
cleanupCache: cleanupGithubImages,
|
|
76
|
+
listAssigned,
|
|
77
|
+
getIssue,
|
|
78
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IssueTracker, IssueTrackerKind } from "./types.js";
|
|
2
|
+
export type { AssignedIssue, AuthStatus, Comment, Issue, IssueTracker, IssueTrackerKind, IssueTrackerResult, State, } from "./types.js";
|
|
3
|
+
export { setRepoTracker, removeRepoTracker, readTrackerConfig } from "./config.js";
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the active IssueTracker for a given repo. Selection order:
|
|
6
|
+
* 1. SANTREE_TRACKER env override (matches SANTREE_MULTIPLEXER pattern).
|
|
7
|
+
* 2. Per-repo `_tracker.kind` in .santree/metadata.json.
|
|
8
|
+
* 3. Legacy `_linear.org` (treated as kind: "linear" so existing repos keep working).
|
|
9
|
+
* 4. Auto-detect: any stored Linear creds → Linear, else GitHub (gh is always available).
|
|
10
|
+
*/
|
|
11
|
+
export declare function getIssueTracker(repoRoot: string | null): IssueTracker;
|
|
12
|
+
export declare function getActiveTrackerKind(repoRoot: string | null): IssueTrackerKind;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { linearTracker } from "./linear/index.js";
|
|
2
|
+
import { githubTracker } from "./github/index.js";
|
|
3
|
+
import { readTrackerConfig } from "./config.js";
|
|
4
|
+
import { readLinearAuthStore } from "./auth-store.js";
|
|
5
|
+
export { setRepoTracker, removeRepoTracker, readTrackerConfig } from "./config.js";
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the active IssueTracker for a given repo. Selection order:
|
|
8
|
+
* 1. SANTREE_TRACKER env override (matches SANTREE_MULTIPLEXER pattern).
|
|
9
|
+
* 2. Per-repo `_tracker.kind` in .santree/metadata.json.
|
|
10
|
+
* 3. Legacy `_linear.org` (treated as kind: "linear" so existing repos keep working).
|
|
11
|
+
* 4. Auto-detect: any stored Linear creds → Linear, else GitHub (gh is always available).
|
|
12
|
+
*/
|
|
13
|
+
export function getIssueTracker(repoRoot) {
|
|
14
|
+
const explicit = process.env["SANTREE_TRACKER"]?.toLowerCase();
|
|
15
|
+
if (explicit === "linear")
|
|
16
|
+
return linearTracker;
|
|
17
|
+
if (explicit === "github")
|
|
18
|
+
return githubTracker;
|
|
19
|
+
if (repoRoot) {
|
|
20
|
+
const cfg = readTrackerConfig(repoRoot);
|
|
21
|
+
if (cfg.kind === "linear")
|
|
22
|
+
return linearTracker;
|
|
23
|
+
if (cfg.kind === "github")
|
|
24
|
+
return githubTracker;
|
|
25
|
+
if (cfg.legacyLinearOrg)
|
|
26
|
+
return linearTracker;
|
|
27
|
+
}
|
|
28
|
+
if (Object.keys(readLinearAuthStore()).length > 0)
|
|
29
|
+
return linearTracker;
|
|
30
|
+
return githubTracker;
|
|
31
|
+
}
|
|
32
|
+
export function getActiveTrackerKind(repoRoot) {
|
|
33
|
+
return getIssueTracker(repoRoot).kind;
|
|
34
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AssignedIssue, Issue } from "../types.js";
|
|
2
|
+
export declare const PRIORITY_MAP: Record<number, string>;
|
|
3
|
+
export declare function fetchIssue(ticketId: string, accessToken: string): Promise<Issue | null>;
|
|
4
|
+
export declare function fetchAssignedIssues(accessToken: string): Promise<AssignedIssue[] | null>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
2
|
+
export const PRIORITY_MAP = {
|
|
3
|
+
0: "No priority",
|
|
4
|
+
1: "Urgent",
|
|
5
|
+
2: "High",
|
|
6
|
+
3: "Medium",
|
|
7
|
+
4: "Low",
|
|
8
|
+
};
|
|
9
|
+
async function graphqlQuery(query, variables, accessToken) {
|
|
10
|
+
const res = await fetch(LINEAR_GRAPHQL_URL, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: {
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
Authorization: `Bearer ${accessToken}`,
|
|
15
|
+
},
|
|
16
|
+
body: JSON.stringify({ query, variables }),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
return null;
|
|
20
|
+
const json = (await res.json());
|
|
21
|
+
if (json.errors) {
|
|
22
|
+
console.error("Linear GraphQL errors:", JSON.stringify(json.errors, null, 2));
|
|
23
|
+
}
|
|
24
|
+
return json.data ?? null;
|
|
25
|
+
}
|
|
26
|
+
const ISSUE_QUERY = `
|
|
27
|
+
query GetIssue($id: String!) {
|
|
28
|
+
issue(id: $id) {
|
|
29
|
+
identifier
|
|
30
|
+
title
|
|
31
|
+
description
|
|
32
|
+
url
|
|
33
|
+
state { name type }
|
|
34
|
+
priority
|
|
35
|
+
labels { nodes { name } }
|
|
36
|
+
project { id name }
|
|
37
|
+
comments {
|
|
38
|
+
nodes {
|
|
39
|
+
body
|
|
40
|
+
createdAt
|
|
41
|
+
parent { id }
|
|
42
|
+
user { displayName }
|
|
43
|
+
children {
|
|
44
|
+
nodes {
|
|
45
|
+
body
|
|
46
|
+
createdAt
|
|
47
|
+
user { displayName }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
const ASSIGNED_ISSUES_QUERY = `
|
|
56
|
+
query AssignedIssues {
|
|
57
|
+
viewer {
|
|
58
|
+
assignedIssues(
|
|
59
|
+
filter: { state: { type: { nin: ["completed", "canceled"] } } }
|
|
60
|
+
orderBy: updatedAt
|
|
61
|
+
first: 100
|
|
62
|
+
) {
|
|
63
|
+
nodes {
|
|
64
|
+
identifier
|
|
65
|
+
title
|
|
66
|
+
description
|
|
67
|
+
url
|
|
68
|
+
priority
|
|
69
|
+
state { name type }
|
|
70
|
+
labels { nodes { name } }
|
|
71
|
+
project { id name }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
function mapAssigned(issue) {
|
|
78
|
+
return {
|
|
79
|
+
identifier: issue.identifier,
|
|
80
|
+
title: issue.title,
|
|
81
|
+
description: issue.description ?? null,
|
|
82
|
+
url: issue.url,
|
|
83
|
+
priority: issue.priority,
|
|
84
|
+
priorityLabel: PRIORITY_MAP[issue.priority] ?? "No priority",
|
|
85
|
+
state: {
|
|
86
|
+
name: issue.state?.name ?? "Unknown",
|
|
87
|
+
type: issue.state?.type ?? "unstarted",
|
|
88
|
+
},
|
|
89
|
+
labels: (issue.labels?.nodes ?? []).map((l) => l.name),
|
|
90
|
+
projectId: issue.project?.id ?? null,
|
|
91
|
+
projectName: issue.project?.name ?? null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function mapComments(nodes) {
|
|
95
|
+
return nodes
|
|
96
|
+
.filter((c) => !c.parent)
|
|
97
|
+
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
|
98
|
+
.map((c) => ({
|
|
99
|
+
author: c.user?.displayName ?? "Unknown",
|
|
100
|
+
body: c.body,
|
|
101
|
+
createdAt: c.createdAt,
|
|
102
|
+
children: (c.children?.nodes ?? [])
|
|
103
|
+
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
|
104
|
+
.map((r) => ({
|
|
105
|
+
author: r.user?.displayName ?? "Unknown",
|
|
106
|
+
body: r.body,
|
|
107
|
+
createdAt: r.createdAt,
|
|
108
|
+
children: [],
|
|
109
|
+
})),
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
export async function fetchIssue(ticketId, accessToken) {
|
|
113
|
+
const data = (await graphqlQuery(ISSUE_QUERY, { id: ticketId }, accessToken));
|
|
114
|
+
if (!data?.issue)
|
|
115
|
+
return null;
|
|
116
|
+
const base = mapAssigned(data.issue);
|
|
117
|
+
return {
|
|
118
|
+
...base,
|
|
119
|
+
comments: mapComments(data.issue.comments?.nodes ?? []),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export async function fetchAssignedIssues(accessToken) {
|
|
123
|
+
const data = (await graphqlQuery(ASSIGNED_ISSUES_QUERY, {}, accessToken));
|
|
124
|
+
const nodes = data?.viewer?.assignedIssues?.nodes;
|
|
125
|
+
if (!nodes)
|
|
126
|
+
return null;
|
|
127
|
+
return nodes.map(mapAssigned);
|
|
128
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type LinearTokens } from "../auth-store.js";
|
|
2
|
+
export type { LinearTokens } from "../auth-store.js";
|
|
3
|
+
export declare function startOAuthFlow(): Promise<{
|
|
4
|
+
orgSlug: string;
|
|
5
|
+
orgName: string;
|
|
6
|
+
} | null>;
|
|
7
|
+
export declare function revokeTokens(orgSlug: string): Promise<boolean>;
|
|
8
|
+
export declare function getValidTokens(orgSlug: string): Promise<LinearTokens | null>;
|
|
9
|
+
export declare function getRepoLinearOrg(repoRoot: string): string | null;
|
|
10
|
+
export declare function setRepoLinearOrg(repoRoot: string, orgSlug: string): void;
|
|
11
|
+
export declare function removeRepoLinearOrg(repoRoot: string): void;
|