santree 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -46
- package/dist/commands/dashboard.d.ts +1 -1
- package/dist/commands/dashboard.js +22 -18
- package/dist/commands/doctor.js +97 -76
- 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/english-tutor/index.d.ts +1 -0
- package/dist/commands/helpers/english-tutor/index.js +1 -0
- package/dist/commands/helpers/english-tutor/install.d.ts +8 -0
- package/dist/commands/helpers/english-tutor/install.js +24 -0
- package/dist/commands/helpers/english-tutor/prompt.d.ts +2 -0
- package/dist/commands/helpers/english-tutor/prompt.js +16 -0
- package/dist/commands/helpers/english-tutor/session-start.d.ts +2 -0
- package/dist/commands/helpers/english-tutor/session-start.js +34 -0
- package/dist/commands/helpers/english-tutor/uninstall.d.ts +2 -0
- package/dist/commands/helpers/english-tutor/uninstall.js +15 -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/english-tutor.d.ts +13 -0
- package/dist/lib/english-tutor.js +125 -0
- 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/index.js +5 -12
- 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/english-tutor-prompt.njk +15 -0
- 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
|
@@ -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.
|
|
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.
|
|
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;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
import { exec } from "child_process";
|
|
4
|
+
import { readAllMetadata, writeAllMetadata } from "../../metadata.js";
|
|
5
|
+
import { readLinearAuthStore, writeLinearTokens, deleteLinearTokens, } from "../auth-store.js";
|
|
6
|
+
const CLIENT_ID = "4be2738749371d7d3401061aabe2d11b";
|
|
7
|
+
const LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
|
|
8
|
+
const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
|
|
9
|
+
const LINEAR_REVOKE_URL = "https://api.linear.app/oauth/revoke";
|
|
10
|
+
const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
11
|
+
const OAUTH_PORT = 8420;
|
|
12
|
+
const REDIRECT_URI = `http://localhost:${OAUTH_PORT}`;
|
|
13
|
+
function generateCodeVerifier() {
|
|
14
|
+
return crypto.randomBytes(32).toString("base64url");
|
|
15
|
+
}
|
|
16
|
+
function generateCodeChallenge(verifier) {
|
|
17
|
+
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
18
|
+
}
|
|
19
|
+
export async function startOAuthFlow() {
|
|
20
|
+
const codeVerifier = generateCodeVerifier();
|
|
21
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
22
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let handled = false;
|
|
25
|
+
const server = http.createServer(async (req, res) => {
|
|
26
|
+
const url = new URL(req.url, `http://localhost`);
|
|
27
|
+
const code = url.searchParams.get("code");
|
|
28
|
+
const returnedState = url.searchParams.get("state");
|
|
29
|
+
if (!code || returnedState !== state) {
|
|
30
|
+
res.writeHead(404);
|
|
31
|
+
res.end();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (handled) {
|
|
35
|
+
res.writeHead(200);
|
|
36
|
+
res.end();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
handled = true;
|
|
40
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
41
|
+
res.end("<html><body><h2>Authentication successful!</h2><p>You can close this tab.</p></body></html>");
|
|
42
|
+
try {
|
|
43
|
+
const tokens = await exchangeCode(code, REDIRECT_URI, codeVerifier);
|
|
44
|
+
const orgInfo = await fetchViewerOrg(tokens.access_token);
|
|
45
|
+
if (!orgInfo) {
|
|
46
|
+
server.close();
|
|
47
|
+
resolve(null);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
writeLinearTokens(orgInfo.urlKey, {
|
|
51
|
+
access_token: tokens.access_token,
|
|
52
|
+
refresh_token: tokens.refresh_token,
|
|
53
|
+
expires_at: tokens.expires_at,
|
|
54
|
+
org_name: orgInfo.name,
|
|
55
|
+
});
|
|
56
|
+
server.close();
|
|
57
|
+
resolve({ orgSlug: orgInfo.urlKey, orgName: orgInfo.name });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
server.close();
|
|
61
|
+
resolve(null);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
server.listen(OAUTH_PORT, () => {
|
|
65
|
+
const params = new URLSearchParams({
|
|
66
|
+
client_id: CLIENT_ID,
|
|
67
|
+
redirect_uri: REDIRECT_URI,
|
|
68
|
+
response_type: "code",
|
|
69
|
+
scope: "read",
|
|
70
|
+
state,
|
|
71
|
+
code_challenge: codeChallenge,
|
|
72
|
+
code_challenge_method: "S256",
|
|
73
|
+
});
|
|
74
|
+
const authUrl = `${LINEAR_AUTHORIZE_URL}?${params.toString()}`;
|
|
75
|
+
const openCmd = process.platform === "darwin"
|
|
76
|
+
? "open"
|
|
77
|
+
: process.platform === "win32"
|
|
78
|
+
? "start"
|
|
79
|
+
: "xdg-open";
|
|
80
|
+
exec(`${openCmd} "${authUrl}"`, (err) => {
|
|
81
|
+
if (err) {
|
|
82
|
+
console.error(`\nCouldn't open browser automatically. Open this URL manually:\n${authUrl}\n`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
server.close();
|
|
88
|
+
resolve(null);
|
|
89
|
+
}, 120_000);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async function exchangeCode(code, redirectUri, codeVerifier) {
|
|
93
|
+
const res = await fetch(LINEAR_TOKEN_URL, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
96
|
+
body: new URLSearchParams({
|
|
97
|
+
grant_type: "authorization_code",
|
|
98
|
+
client_id: CLIENT_ID,
|
|
99
|
+
code,
|
|
100
|
+
redirect_uri: redirectUri,
|
|
101
|
+
code_verifier: codeVerifier,
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
throw new Error(`Token exchange failed: ${res.status}`);
|
|
106
|
+
}
|
|
107
|
+
const data = (await res.json());
|
|
108
|
+
return {
|
|
109
|
+
access_token: data.access_token,
|
|
110
|
+
refresh_token: data.refresh_token,
|
|
111
|
+
expires_at: Date.now() + data.expires_in * 1000,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async function fetchViewerOrg(accessToken) {
|
|
115
|
+
const res = await fetch(LINEAR_GRAPHQL_URL, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: {
|
|
118
|
+
"Content-Type": "application/json",
|
|
119
|
+
Authorization: `Bearer ${accessToken}`,
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
query: `query { viewer { organization { urlKey name } } }`,
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok)
|
|
126
|
+
return null;
|
|
127
|
+
const json = (await res.json());
|
|
128
|
+
const org = json.data?.viewer?.organization;
|
|
129
|
+
return org ?? null;
|
|
130
|
+
}
|
|
131
|
+
function isTokenExpired(tokens) {
|
|
132
|
+
return Date.now() >= tokens.expires_at - 5 * 60 * 1000;
|
|
133
|
+
}
|
|
134
|
+
async function refreshTokens(orgSlug, tokens) {
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetch(LINEAR_TOKEN_URL, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
139
|
+
body: new URLSearchParams({
|
|
140
|
+
grant_type: "refresh_token",
|
|
141
|
+
client_id: CLIENT_ID,
|
|
142
|
+
refresh_token: tokens.refresh_token,
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok)
|
|
146
|
+
return null;
|
|
147
|
+
const data = (await res.json());
|
|
148
|
+
const updated = {
|
|
149
|
+
access_token: data.access_token,
|
|
150
|
+
refresh_token: data.refresh_token,
|
|
151
|
+
expires_at: Date.now() + data.expires_in * 1000,
|
|
152
|
+
org_name: tokens.org_name,
|
|
153
|
+
};
|
|
154
|
+
writeLinearTokens(orgSlug, updated);
|
|
155
|
+
return updated;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export async function revokeTokens(orgSlug) {
|
|
162
|
+
const store = readLinearAuthStore();
|
|
163
|
+
const tokens = store[orgSlug];
|
|
164
|
+
if (!tokens)
|
|
165
|
+
return false;
|
|
166
|
+
try {
|
|
167
|
+
await fetch(LINEAR_REVOKE_URL, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
170
|
+
body: new URLSearchParams({
|
|
171
|
+
client_id: CLIENT_ID,
|
|
172
|
+
token: tokens.access_token,
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// best effort
|
|
178
|
+
}
|
|
179
|
+
deleteLinearTokens(orgSlug);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
export async function getValidTokens(orgSlug) {
|
|
183
|
+
const store = readLinearAuthStore();
|
|
184
|
+
const tokens = store[orgSlug];
|
|
185
|
+
if (!tokens)
|
|
186
|
+
return null;
|
|
187
|
+
if (isTokenExpired(tokens)) {
|
|
188
|
+
return refreshTokens(orgSlug, tokens);
|
|
189
|
+
}
|
|
190
|
+
return tokens;
|
|
191
|
+
}
|
|
192
|
+
export function getRepoLinearOrg(repoRoot) {
|
|
193
|
+
const all = readAllMetadata(repoRoot);
|
|
194
|
+
const linear = all._linear;
|
|
195
|
+
return linear?.org ?? null;
|
|
196
|
+
}
|
|
197
|
+
export function setRepoLinearOrg(repoRoot, orgSlug) {
|
|
198
|
+
const all = readAllMetadata(repoRoot);
|
|
199
|
+
all._linear = { org: orgSlug };
|
|
200
|
+
writeAllMetadata(repoRoot, all);
|
|
201
|
+
}
|
|
202
|
+
export function removeRepoLinearOrg(repoRoot) {
|
|
203
|
+
const all = readAllMetadata(repoRoot);
|
|
204
|
+
delete all._linear;
|
|
205
|
+
writeAllMetadata(repoRoot, all);
|
|
206
|
+
}
|