santree 0.6.1 → 0.6.3
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 +8 -8
- package/dist/commands/dashboard.js +420 -46
- package/dist/commands/issue/setup.d.ts +2 -0
- package/dist/commands/issue/setup.js +108 -0
- package/dist/commands/issue/switch.d.ts +1 -0
- package/dist/commands/issue/switch.js +2 -2
- package/dist/commands/worktree/work.js +1 -1
- package/dist/lib/ai.js +4 -0
- package/dist/lib/dashboard/DetailPanel.d.ts +5 -2
- package/dist/lib/dashboard/DetailPanel.js +18 -1
- package/dist/lib/dashboard/Overlays.js +14 -0
- package/dist/lib/dashboard/data.d.ts +2 -0
- package/dist/lib/dashboard/data.js +56 -54
- package/dist/lib/dashboard/types.d.ts +77 -2
- package/dist/lib/dashboard/types.js +93 -1
- package/dist/lib/multiplexer/cmux.js +0 -15
- package/dist/lib/multiplexer/none.js +0 -3
- package/dist/lib/multiplexer/tmux.js +0 -8
- package/dist/lib/multiplexer/types.d.ts +0 -1
- package/dist/lib/session-signal.d.ts +5 -3
- package/dist/lib/session-signal.js +5 -22
- package/dist/lib/trackers/config.js +1 -1
- package/dist/lib/trackers/index.d.ts +11 -0
- package/dist/lib/trackers/index.js +26 -0
- package/dist/lib/trackers/local/frontmatter.d.ts +12 -0
- package/dist/lib/trackers/local/frontmatter.js +91 -0
- package/dist/lib/trackers/local/index.d.ts +2 -0
- package/dist/lib/trackers/local/index.js +102 -0
- package/dist/lib/trackers/local/store.d.ts +30 -0
- package/dist/lib/trackers/local/store.js +203 -0
- package/dist/lib/trackers/types.d.ts +26 -1
- package/package.json +1 -1
|
@@ -36,14 +36,6 @@ export const tmuxMultiplexer = {
|
|
|
36
36
|
const ok = tmuxSync(`tmux select-window -t ${shellEscape(name)}`);
|
|
37
37
|
return ok ? { ok: true } : { ok: false, reason: "failed" };
|
|
38
38
|
},
|
|
39
|
-
renameWindow(_currentName, newName) {
|
|
40
|
-
if (!this.isActive())
|
|
41
|
-
return { ok: false, reason: "not-active" };
|
|
42
|
-
// tmux rename-window operates on the current window when no -t is given, which
|
|
43
|
-
// matches every existing call site in santree.
|
|
44
|
-
const ok = tmuxSync(`tmux rename-window ${shellEscape(newName)}`);
|
|
45
|
-
return ok ? { ok: true } : { ok: false, reason: "failed" };
|
|
46
|
-
},
|
|
47
39
|
sendCommand(name, command) {
|
|
48
40
|
if (!this.isActive())
|
|
49
41
|
return { ok: false, reason: "not-active" };
|
|
@@ -16,7 +16,6 @@ export interface Multiplexer {
|
|
|
16
16
|
isActive(): boolean;
|
|
17
17
|
createWindow(opts: CreateWindowOpts): Promise<SessionResult>;
|
|
18
18
|
selectWindow(name: string): Promise<SessionResult>;
|
|
19
|
-
renameWindow(currentName: string, newName: string): SessionResult;
|
|
20
19
|
sendCommand(name: string, command: string): SessionResult;
|
|
21
20
|
isSessionAlive(ticketId: string): boolean;
|
|
22
21
|
}
|
|
@@ -4,10 +4,12 @@ export declare function extractRepoAndTicket(cwd: string): {
|
|
|
4
4
|
repoRoot: string;
|
|
5
5
|
ticketId: string;
|
|
6
6
|
} | null;
|
|
7
|
-
export declare function renameSessionWindow(ticketId: string, state: SessionStateValue): void;
|
|
8
7
|
/**
|
|
9
|
-
* Unified helper: reads stdin, extracts repo/ticket, writes state
|
|
10
|
-
*
|
|
8
|
+
* Unified helper: reads stdin, extracts repo/ticket, writes the session-state
|
|
9
|
+
* file, then exits. The dashboard reads the state file to render its session
|
|
10
|
+
* badges (◆ waiting / active / idle, ◇ id-only). Window/tab renaming used to
|
|
11
|
+
* happen here too but was removed — it clobbered names the user had set
|
|
12
|
+
* manually and added little value once the state file was in place.
|
|
11
13
|
*/
|
|
12
14
|
export declare function signalState(state: SessionStateValue): void;
|
|
13
15
|
export declare function getHooksJson(): Record<string, unknown>;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import { getMultiplexer } from "./multiplexer/index.js";
|
|
4
3
|
export function readStdin() {
|
|
5
4
|
try {
|
|
6
5
|
return fs.readFileSync(0, "utf-8");
|
|
@@ -21,27 +20,12 @@ export function extractRepoAndTicket(cwd) {
|
|
|
21
20
|
return null;
|
|
22
21
|
return { repoRoot, ticketId };
|
|
23
22
|
}
|
|
24
|
-
export function renameSessionWindow(ticketId, state) {
|
|
25
|
-
const mux = getMultiplexer();
|
|
26
|
-
if (!mux.isActive())
|
|
27
|
-
return;
|
|
28
|
-
let name;
|
|
29
|
-
switch (state) {
|
|
30
|
-
case "waiting":
|
|
31
|
-
name = `${ticketId} !`;
|
|
32
|
-
break;
|
|
33
|
-
case "idle":
|
|
34
|
-
name = `${ticketId} ~`;
|
|
35
|
-
break;
|
|
36
|
-
default:
|
|
37
|
-
name = ticketId;
|
|
38
|
-
break;
|
|
39
|
-
}
|
|
40
|
-
mux.renameWindow("", name);
|
|
41
|
-
}
|
|
42
23
|
/**
|
|
43
|
-
* Unified helper: reads stdin, extracts repo/ticket, writes state
|
|
44
|
-
*
|
|
24
|
+
* Unified helper: reads stdin, extracts repo/ticket, writes the session-state
|
|
25
|
+
* file, then exits. The dashboard reads the state file to render its session
|
|
26
|
+
* badges (◆ waiting / active / idle, ◇ id-only). Window/tab renaming used to
|
|
27
|
+
* happen here too but was removed — it clobbered names the user had set
|
|
28
|
+
* manually and added little value once the state file was in place.
|
|
45
29
|
*/
|
|
46
30
|
export function signalState(state) {
|
|
47
31
|
const input = readStdin();
|
|
@@ -68,7 +52,6 @@ export function signalState(state) {
|
|
|
68
52
|
at: new Date().toISOString(),
|
|
69
53
|
};
|
|
70
54
|
fs.writeFileSync(stateFile, JSON.stringify(payload, null, 2) + "\n");
|
|
71
|
-
renameSessionWindow(ticketId, state);
|
|
72
55
|
process.exit(0);
|
|
73
56
|
}
|
|
74
57
|
export function getHooksJson() {
|
|
@@ -4,7 +4,7 @@ export function readTrackerConfig(repoRoot) {
|
|
|
4
4
|
const tracker = all._tracker;
|
|
5
5
|
const linear = all._linear;
|
|
6
6
|
let kind = null;
|
|
7
|
-
if (tracker?.kind === "linear" || tracker?.kind === "github") {
|
|
7
|
+
if (tracker?.kind === "linear" || tracker?.kind === "github" || tracker?.kind === "local") {
|
|
8
8
|
kind = tracker.kind;
|
|
9
9
|
}
|
|
10
10
|
return { kind, legacyLinearOrg: linear?.org ?? null };
|
|
@@ -9,6 +9,17 @@ export { setRepoTracker, removeRepoTracker, readTrackerConfig } from "./config.j
|
|
|
9
9
|
* 4. Auto-detect: any stored Linear creds → Linear, else GitHub (gh is always available).
|
|
10
10
|
*/
|
|
11
11
|
export declare function getIssueTracker(repoRoot: string | null): IssueTracker;
|
|
12
|
+
/**
|
|
13
|
+
* True when the repo has an *explicit* tracker choice — env override,
|
|
14
|
+
* per-repo `_tracker.kind`, legacy `_linear.org`, or any stored Linear
|
|
15
|
+
* credentials (the old auto-detect signal). When false, callers (the
|
|
16
|
+
* dashboard) should present the tracker-selection flow instead of letting
|
|
17
|
+
* `getIssueTracker` silently fall back to GitHub and then fail auth.
|
|
18
|
+
*
|
|
19
|
+
* Pure detection — does not alter `getIssueTracker`'s fallback, so existing
|
|
20
|
+
* non-dashboard call sites keep working unchanged.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isRepoTrackerConfigured(repoRoot: string | null): boolean;
|
|
12
23
|
export declare function getActiveTrackerKind(repoRoot: string | null): IssueTrackerKind;
|
|
13
24
|
/**
|
|
14
25
|
* Trackers worth trying when resolving a ticket from a foreign PR branch.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { linearTracker } from "./linear/index.js";
|
|
2
2
|
import { githubTracker } from "./github/index.js";
|
|
3
|
+
import { localTracker } from "./local/index.js";
|
|
3
4
|
import { readTrackerConfig } from "./config.js";
|
|
4
5
|
import { readLinearAuthStore } from "./auth-store.js";
|
|
5
6
|
export { setRepoTracker, removeRepoTracker, readTrackerConfig } from "./config.js";
|
|
@@ -16,12 +17,16 @@ export function getIssueTracker(repoRoot) {
|
|
|
16
17
|
return linearTracker;
|
|
17
18
|
if (explicit === "github")
|
|
18
19
|
return githubTracker;
|
|
20
|
+
if (explicit === "local")
|
|
21
|
+
return localTracker;
|
|
19
22
|
if (repoRoot) {
|
|
20
23
|
const cfg = readTrackerConfig(repoRoot);
|
|
21
24
|
if (cfg.kind === "linear")
|
|
22
25
|
return linearTracker;
|
|
23
26
|
if (cfg.kind === "github")
|
|
24
27
|
return githubTracker;
|
|
28
|
+
if (cfg.kind === "local")
|
|
29
|
+
return localTracker;
|
|
25
30
|
if (cfg.legacyLinearOrg)
|
|
26
31
|
return linearTracker;
|
|
27
32
|
}
|
|
@@ -29,6 +34,27 @@ export function getIssueTracker(repoRoot) {
|
|
|
29
34
|
return linearTracker;
|
|
30
35
|
return githubTracker;
|
|
31
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* True when the repo has an *explicit* tracker choice — env override,
|
|
39
|
+
* per-repo `_tracker.kind`, legacy `_linear.org`, or any stored Linear
|
|
40
|
+
* credentials (the old auto-detect signal). When false, callers (the
|
|
41
|
+
* dashboard) should present the tracker-selection flow instead of letting
|
|
42
|
+
* `getIssueTracker` silently fall back to GitHub and then fail auth.
|
|
43
|
+
*
|
|
44
|
+
* Pure detection — does not alter `getIssueTracker`'s fallback, so existing
|
|
45
|
+
* non-dashboard call sites keep working unchanged.
|
|
46
|
+
*/
|
|
47
|
+
export function isRepoTrackerConfigured(repoRoot) {
|
|
48
|
+
const explicit = process.env["SANTREE_TRACKER"]?.toLowerCase();
|
|
49
|
+
if (explicit === "linear" || explicit === "github" || explicit === "local")
|
|
50
|
+
return true;
|
|
51
|
+
if (repoRoot) {
|
|
52
|
+
const cfg = readTrackerConfig(repoRoot);
|
|
53
|
+
if (cfg.kind || cfg.legacyLinearOrg)
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return Object.keys(readLinearAuthStore()).length > 0;
|
|
57
|
+
}
|
|
32
58
|
export function getActiveTrackerKind(repoRoot) {
|
|
33
59
|
return getIssueTracker(repoRoot).kind;
|
|
34
60
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type FrontmatterValue = string | number | string[];
|
|
2
|
+
export interface ParsedFile {
|
|
3
|
+
data: Record<string, FrontmatterValue>;
|
|
4
|
+
body: string;
|
|
5
|
+
}
|
|
6
|
+
/** Parse a Markdown document with an optional leading `---` frontmatter
|
|
7
|
+
* block. Returns `{ data, body }`; `data` is `{}` when there is no valid
|
|
8
|
+
* frontmatter. */
|
|
9
|
+
export declare function parseFrontmatter(content: string): ParsedFile;
|
|
10
|
+
/** Serialize `{ data, body }` back into a `---` frontmatter Markdown file.
|
|
11
|
+
* Key order is preserved as inserted by the caller. */
|
|
12
|
+
export declare function serializeFrontmatter(data: Record<string, FrontmatterValue>, body: string): string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Minimal, hand-rolled frontmatter parse/serialize.
|
|
2
|
+
//
|
|
3
|
+
// The project intentionally has no YAML dependency and adding one
|
|
4
|
+
// (gray-matter / yaml) is out of scope. The Local tracker only ever stores
|
|
5
|
+
// scalars (string, number) and a single string[] (`labels`), so a tiny
|
|
6
|
+
// purpose-built reader/writer is enough. It is deliberately defensive:
|
|
7
|
+
// unknown keys are preserved as raw strings, malformed lines are skipped,
|
|
8
|
+
// and a missing/garbled frontmatter block yields an empty record rather
|
|
9
|
+
// than throwing (matches the "degrade gracefully" pattern in metadata.ts).
|
|
10
|
+
const FENCE = "---";
|
|
11
|
+
function parseScalar(raw) {
|
|
12
|
+
const trimmed = raw.trim();
|
|
13
|
+
// Strip matching surrounding quotes if present.
|
|
14
|
+
const unquoted = (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
15
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
16
|
+
? trimmed.slice(1, -1)
|
|
17
|
+
: trimmed;
|
|
18
|
+
// Only treat as a number when the whole token is numeric — issue IDs
|
|
19
|
+
// like "LOCAL-1" must stay strings.
|
|
20
|
+
if (unquoted !== "" && /^-?\d+(\.\d+)?$/.test(unquoted)) {
|
|
21
|
+
return Number(unquoted);
|
|
22
|
+
}
|
|
23
|
+
return unquoted;
|
|
24
|
+
}
|
|
25
|
+
function parseList(raw) {
|
|
26
|
+
const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "");
|
|
27
|
+
if (inner.trim() === "")
|
|
28
|
+
return [];
|
|
29
|
+
return inner
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((s) => {
|
|
32
|
+
const t = s.trim();
|
|
33
|
+
return (t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))
|
|
34
|
+
? t.slice(1, -1)
|
|
35
|
+
: t;
|
|
36
|
+
})
|
|
37
|
+
.filter((s) => s.length > 0);
|
|
38
|
+
}
|
|
39
|
+
/** Parse a Markdown document with an optional leading `---` frontmatter
|
|
40
|
+
* block. Returns `{ data, body }`; `data` is `{}` when there is no valid
|
|
41
|
+
* frontmatter. */
|
|
42
|
+
export function parseFrontmatter(content) {
|
|
43
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
44
|
+
const lines = normalized.split("\n");
|
|
45
|
+
if (lines[0]?.trim() !== FENCE) {
|
|
46
|
+
return { data: {}, body: normalized };
|
|
47
|
+
}
|
|
48
|
+
const data = {};
|
|
49
|
+
let i = 1;
|
|
50
|
+
for (; i < lines.length; i++) {
|
|
51
|
+
const line = lines[i] ?? "";
|
|
52
|
+
if (line.trim() === FENCE) {
|
|
53
|
+
i++;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
const colon = line.indexOf(":");
|
|
57
|
+
if (colon === -1)
|
|
58
|
+
continue; // skip malformed line
|
|
59
|
+
const key = line.slice(0, colon).trim();
|
|
60
|
+
if (!key)
|
|
61
|
+
continue;
|
|
62
|
+
const rawValue = line.slice(colon + 1).trim();
|
|
63
|
+
data[key] = rawValue.startsWith("[") ? parseList(rawValue) : parseScalar(rawValue);
|
|
64
|
+
}
|
|
65
|
+
// Body is everything after the closing fence; drop a single leading
|
|
66
|
+
// blank line that separates fence from content.
|
|
67
|
+
const bodyLines = lines.slice(i);
|
|
68
|
+
if (bodyLines[0] === "")
|
|
69
|
+
bodyLines.shift();
|
|
70
|
+
return { data, body: bodyLines.join("\n").replace(/\s+$/, "") };
|
|
71
|
+
}
|
|
72
|
+
function serializeValue(value) {
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return `[${value.map((v) => v).join(", ")}]`;
|
|
75
|
+
}
|
|
76
|
+
if (typeof value === "number")
|
|
77
|
+
return String(value);
|
|
78
|
+
// Quote strings that contain characters which would break the simple
|
|
79
|
+
// `key: value` line parser or look like a list.
|
|
80
|
+
if (/^[\[\]]|[:#]|^\s|\s$/.test(value) || value === "") {
|
|
81
|
+
return JSON.stringify(value);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
/** Serialize `{ data, body }` back into a `---` frontmatter Markdown file.
|
|
86
|
+
* Key order is preserved as inserted by the caller. */
|
|
87
|
+
export function serializeFrontmatter(data, body) {
|
|
88
|
+
const head = Object.entries(data).map(([k, v]) => `${k}: ${serializeValue(v)}`);
|
|
89
|
+
const trimmedBody = body.replace(/\s+$/, "");
|
|
90
|
+
return `${FENCE}\n${head.join("\n")}\n${FENCE}\n\n${trimmedBody}\n`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { allocateId, deleteIssueFile, listIssues, priorityLabel, readCreatedAt, readIssue, writeIssue, } from "./store.js";
|
|
2
|
+
// Terminal states are hidden from the "assigned" list, mirroring how Linear
|
|
3
|
+
// filters out completed/canceled issues.
|
|
4
|
+
const TERMINAL_TYPES = new Set(["completed", "canceled"]);
|
|
5
|
+
async function getAuthStatus(repoRoot) {
|
|
6
|
+
if (!repoRoot) {
|
|
7
|
+
return { authenticated: false, hint: "Not inside a git repository" };
|
|
8
|
+
}
|
|
9
|
+
// Local has no credentials — being in a repo is all it needs.
|
|
10
|
+
return { authenticated: true, accountLabel: ".santree/issues" };
|
|
11
|
+
}
|
|
12
|
+
async function signOut(_repoRoot) {
|
|
13
|
+
// No credentials to clear.
|
|
14
|
+
}
|
|
15
|
+
function extractIdFromBranch(branch) {
|
|
16
|
+
// Recognizes `LOCAL-1`, `local-1`, `feature/LOCAL-1-foo`, `local-1-foo`.
|
|
17
|
+
// The dashboard builds branches as `feature/${ticketId}-${slug}` where
|
|
18
|
+
// ticketId is `LOCAL-1`, so the literal `LOCAL-1` is always present.
|
|
19
|
+
const m = branch.match(/(?:^|[/_-])local-(\d+)(?:-|$)/i);
|
|
20
|
+
if (!m)
|
|
21
|
+
return null;
|
|
22
|
+
return `LOCAL-${m[1]}`;
|
|
23
|
+
}
|
|
24
|
+
function cleanupCache(_identifier) {
|
|
25
|
+
// No remote image cache for local issues.
|
|
26
|
+
}
|
|
27
|
+
async function listAssigned(repoRoot) {
|
|
28
|
+
// Local has no assignee concept — "assigned" == all non-terminal issues.
|
|
29
|
+
const issues = listIssues(repoRoot).filter((i) => !TERMINAL_TYPES.has(i.state.type));
|
|
30
|
+
return { ok: true, value: issues };
|
|
31
|
+
}
|
|
32
|
+
async function getIssue(identifier, repoRoot) {
|
|
33
|
+
const issue = readIssue(repoRoot, identifier);
|
|
34
|
+
if (!issue) {
|
|
35
|
+
return { ok: false, reason: "not-found", message: `Issue ${identifier} not found` };
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, value: issue };
|
|
38
|
+
}
|
|
39
|
+
async function createIssue(input, repoRoot) {
|
|
40
|
+
const identifier = allocateId(repoRoot);
|
|
41
|
+
const priority = input.priority ?? 0;
|
|
42
|
+
const issue = {
|
|
43
|
+
identifier,
|
|
44
|
+
title: input.title,
|
|
45
|
+
description: input.description.trim() === "" ? null : input.description,
|
|
46
|
+
url: "",
|
|
47
|
+
priority,
|
|
48
|
+
priorityLabel: priorityLabel(priority),
|
|
49
|
+
state: { name: "Todo", type: "unstarted" },
|
|
50
|
+
labels: input.labels ?? [],
|
|
51
|
+
projectId: null,
|
|
52
|
+
projectName: null,
|
|
53
|
+
comments: [],
|
|
54
|
+
};
|
|
55
|
+
writeIssue(repoRoot, issue, new Date().toISOString());
|
|
56
|
+
return { ok: true, value: issue };
|
|
57
|
+
}
|
|
58
|
+
async function updateIssue(identifier, patch, repoRoot) {
|
|
59
|
+
const existing = readIssue(repoRoot, identifier);
|
|
60
|
+
if (!existing) {
|
|
61
|
+
return { ok: false, reason: "not-found", message: `Issue ${identifier} not found` };
|
|
62
|
+
}
|
|
63
|
+
const priority = patch.priority ?? existing.priority;
|
|
64
|
+
const updated = {
|
|
65
|
+
...existing,
|
|
66
|
+
title: patch.title ?? existing.title,
|
|
67
|
+
description: patch.description !== undefined
|
|
68
|
+
? patch.description.trim() === ""
|
|
69
|
+
? null
|
|
70
|
+
: patch.description
|
|
71
|
+
: existing.description,
|
|
72
|
+
priority,
|
|
73
|
+
priorityLabel: patch.priority !== undefined ? priorityLabel(priority) : existing.priorityLabel,
|
|
74
|
+
labels: patch.labels ?? existing.labels,
|
|
75
|
+
state: patch.state ?? existing.state,
|
|
76
|
+
};
|
|
77
|
+
// Preserve the original creation timestamp across edits.
|
|
78
|
+
writeIssue(repoRoot, updated, readCreatedAt(repoRoot, identifier) || new Date().toISOString());
|
|
79
|
+
return { ok: true, value: updated };
|
|
80
|
+
}
|
|
81
|
+
async function deleteIssue(identifier, repoRoot) {
|
|
82
|
+
const ok = deleteIssueFile(repoRoot, identifier);
|
|
83
|
+
if (!ok) {
|
|
84
|
+
return { ok: false, reason: "not-found", message: `Issue ${identifier} not found` };
|
|
85
|
+
}
|
|
86
|
+
return { ok: true, value: undefined };
|
|
87
|
+
}
|
|
88
|
+
export const localTracker = {
|
|
89
|
+
kind: "local",
|
|
90
|
+
displayName: "Local",
|
|
91
|
+
issueNoun: "issue",
|
|
92
|
+
getAuthStatus,
|
|
93
|
+
signOut,
|
|
94
|
+
extractIdFromBranch,
|
|
95
|
+
cleanupCache,
|
|
96
|
+
listAssigned,
|
|
97
|
+
getIssue,
|
|
98
|
+
canMutate: true,
|
|
99
|
+
createIssue,
|
|
100
|
+
updateIssue,
|
|
101
|
+
deleteIssue,
|
|
102
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Issue } from "../types.js";
|
|
2
|
+
export declare const ID_PREFIX = "LOCAL";
|
|
3
|
+
export declare function getIssuesDir(repoRoot: string): string;
|
|
4
|
+
export declare function ensureIssuesDir(repoRoot: string): string;
|
|
5
|
+
/** Map a Linear-style numeric priority to a human label. 0 == no priority. */
|
|
6
|
+
export declare function priorityLabel(priority: number): string;
|
|
7
|
+
/** Read every well-formed issue file. Malformed files are skipped, never
|
|
8
|
+
* fatal. Returned newest-first by creation time (falling back to numeric ID). */
|
|
9
|
+
export declare function listIssues(repoRoot: string): Issue[];
|
|
10
|
+
export declare function readIssue(repoRoot: string, identifier: string): Issue | null;
|
|
11
|
+
/** Write (create or overwrite) an issue file. `createdAt` preserves the
|
|
12
|
+
* original timestamp on edits; pass a fresh ISO string when creating. */
|
|
13
|
+
export declare function writeIssue(repoRoot: string, issue: Issue, createdAt: string): void;
|
|
14
|
+
/** Return the original `createdAt` for an existing issue, or "" if unknown. */
|
|
15
|
+
export declare function readCreatedAt(repoRoot: string, identifier: string): string;
|
|
16
|
+
export declare function deleteIssueFile(repoRoot: string, identifier: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Allocate the next issue ID and persist the high-water mark so numbers are
|
|
19
|
+
* monotonic and never recycled: deleting LOCAL-3 then creating again yields
|
|
20
|
+
* LOCAL-4, not LOCAL-3 — a stale local `feature/LOCAL-3-*` branch/worktree
|
|
21
|
+
* can't collide with a reused ID.
|
|
22
|
+
*
|
|
23
|
+
* The counter lives in `.santree/metadata.json` (`_local.lastId`), which is
|
|
24
|
+
* git-ignored and therefore per-machine. That's exactly the right scope: the
|
|
25
|
+
* collision we're avoiding is with *local* worktrees/branches. On a fresh
|
|
26
|
+
* clone metadata.json is absent, so the counter rebuilds from the committed
|
|
27
|
+
* issue files (max existing) — correct, since a fresh clone has no stale
|
|
28
|
+
* local worktrees.
|
|
29
|
+
*/
|
|
30
|
+
export declare function allocateId(repoRoot: string): string;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getSantreeDir, readAllMetadata, writeAllMetadata } from "../../metadata.js";
|
|
4
|
+
import { parseFrontmatter, serializeFrontmatter } from "./frontmatter.js";
|
|
5
|
+
// On-disk layout: one Markdown file per issue under `.santree/issues/`,
|
|
6
|
+
// named `<ID>.md` (e.g. `LOCAL-1.md`). `.santree/issues/` is NOT in
|
|
7
|
+
// .gitignore (which only excludes worktrees/metadata.json/session-states),
|
|
8
|
+
// so issue files are version-controlled by default — the whole point of the
|
|
9
|
+
// built-in tracker.
|
|
10
|
+
//
|
|
11
|
+
// Comments are intentionally not modeled in v1: Local issues always carry
|
|
12
|
+
// `comments: []`. The Issue type already permits an empty array.
|
|
13
|
+
export const ID_PREFIX = "LOCAL";
|
|
14
|
+
const FILE_RE = /^LOCAL-(\d+)\.md$/;
|
|
15
|
+
export function getIssuesDir(repoRoot) {
|
|
16
|
+
return path.join(getSantreeDir(repoRoot), "issues");
|
|
17
|
+
}
|
|
18
|
+
export function ensureIssuesDir(repoRoot) {
|
|
19
|
+
const dir = getIssuesDir(repoRoot);
|
|
20
|
+
if (!fs.existsSync(dir)) {
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
function issueFilePath(repoRoot, identifier) {
|
|
26
|
+
return path.join(getIssuesDir(repoRoot), `${identifier}.md`);
|
|
27
|
+
}
|
|
28
|
+
/** Map a Linear-style numeric priority to a human label. 0 == no priority. */
|
|
29
|
+
export function priorityLabel(priority) {
|
|
30
|
+
switch (priority) {
|
|
31
|
+
case 1:
|
|
32
|
+
return "Urgent";
|
|
33
|
+
case 2:
|
|
34
|
+
return "High";
|
|
35
|
+
case 3:
|
|
36
|
+
return "Medium";
|
|
37
|
+
case 4:
|
|
38
|
+
return "Low";
|
|
39
|
+
default:
|
|
40
|
+
return "None";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function recordToIssue(data, body) {
|
|
44
|
+
const identifier = typeof data["id"] === "string" ? data["id"] : null;
|
|
45
|
+
if (!identifier || !FILE_RE.test(`${identifier}.md`))
|
|
46
|
+
return null;
|
|
47
|
+
const title = typeof data["title"] === "string" ? data["title"] : String(data["title"] ?? "");
|
|
48
|
+
const priority = typeof data["priority"] === "number" ? data["priority"] : 0;
|
|
49
|
+
const labels = Array.isArray(data["labels"]) ? data["labels"] : [];
|
|
50
|
+
const stateName = typeof data["state"] === "string" ? data["state"] : "Todo";
|
|
51
|
+
const stateType = typeof data["stateType"] === "string" ? data["stateType"] : "unstarted";
|
|
52
|
+
const description = body.trim() === "" ? null : body;
|
|
53
|
+
return {
|
|
54
|
+
identifier,
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
url: "", // Local issues have no web URL — the dashboard hides the [o] action.
|
|
58
|
+
priority,
|
|
59
|
+
priorityLabel: typeof data["priorityLabel"] === "string" ? data["priorityLabel"] : priorityLabel(priority),
|
|
60
|
+
state: { name: stateName, type: stateType },
|
|
61
|
+
labels,
|
|
62
|
+
projectId: null,
|
|
63
|
+
projectName: null,
|
|
64
|
+
comments: [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function issueToRecord(issue, createdAt) {
|
|
68
|
+
return {
|
|
69
|
+
data: {
|
|
70
|
+
id: issue.identifier,
|
|
71
|
+
title: issue.title,
|
|
72
|
+
state: issue.state.name,
|
|
73
|
+
stateType: issue.state.type,
|
|
74
|
+
priority: issue.priority,
|
|
75
|
+
priorityLabel: issue.priorityLabel,
|
|
76
|
+
labels: issue.labels,
|
|
77
|
+
createdAt,
|
|
78
|
+
},
|
|
79
|
+
body: issue.description ?? "",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Read every well-formed issue file. Malformed files are skipped, never
|
|
83
|
+
* fatal. Returned newest-first by creation time (falling back to numeric ID). */
|
|
84
|
+
export function listIssues(repoRoot) {
|
|
85
|
+
const dir = getIssuesDir(repoRoot);
|
|
86
|
+
if (!fs.existsSync(dir))
|
|
87
|
+
return [];
|
|
88
|
+
let names;
|
|
89
|
+
try {
|
|
90
|
+
names = fs.readdirSync(dir);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const name of names) {
|
|
97
|
+
const m = name.match(FILE_RE);
|
|
98
|
+
if (!m)
|
|
99
|
+
continue;
|
|
100
|
+
try {
|
|
101
|
+
const raw = fs.readFileSync(path.join(dir, name), "utf-8");
|
|
102
|
+
const { data, body } = parseFrontmatter(raw);
|
|
103
|
+
const issue = recordToIssue(data, body);
|
|
104
|
+
if (!issue)
|
|
105
|
+
continue;
|
|
106
|
+
const createdAt = typeof data["createdAt"] === "string" ? data["createdAt"] : "";
|
|
107
|
+
out.push({ issue, createdAt, num: Number(m[1]) });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Skip unreadable / corrupt file.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
out.sort((a, b) => {
|
|
114
|
+
if (a.createdAt && b.createdAt && a.createdAt !== b.createdAt) {
|
|
115
|
+
return b.createdAt.localeCompare(a.createdAt);
|
|
116
|
+
}
|
|
117
|
+
return b.num - a.num;
|
|
118
|
+
});
|
|
119
|
+
return out.map((o) => o.issue);
|
|
120
|
+
}
|
|
121
|
+
export function readIssue(repoRoot, identifier) {
|
|
122
|
+
const file = issueFilePath(repoRoot, identifier);
|
|
123
|
+
if (!fs.existsSync(file))
|
|
124
|
+
return null;
|
|
125
|
+
try {
|
|
126
|
+
const { data, body } = parseFrontmatter(fs.readFileSync(file, "utf-8"));
|
|
127
|
+
return recordToIssue(data, body);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Write (create or overwrite) an issue file. `createdAt` preserves the
|
|
134
|
+
* original timestamp on edits; pass a fresh ISO string when creating. */
|
|
135
|
+
export function writeIssue(repoRoot, issue, createdAt) {
|
|
136
|
+
ensureIssuesDir(repoRoot);
|
|
137
|
+
const { data, body } = issueToRecord(issue, createdAt);
|
|
138
|
+
fs.writeFileSync(issueFilePath(repoRoot, issue.identifier), serializeFrontmatter(data, body));
|
|
139
|
+
}
|
|
140
|
+
/** Return the original `createdAt` for an existing issue, or "" if unknown. */
|
|
141
|
+
export function readCreatedAt(repoRoot, identifier) {
|
|
142
|
+
const file = issueFilePath(repoRoot, identifier);
|
|
143
|
+
if (!fs.existsSync(file))
|
|
144
|
+
return "";
|
|
145
|
+
try {
|
|
146
|
+
const { data } = parseFrontmatter(fs.readFileSync(file, "utf-8"));
|
|
147
|
+
return typeof data["createdAt"] === "string" ? data["createdAt"] : "";
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function deleteIssueFile(repoRoot, identifier) {
|
|
154
|
+
const file = issueFilePath(repoRoot, identifier);
|
|
155
|
+
if (!fs.existsSync(file))
|
|
156
|
+
return false;
|
|
157
|
+
try {
|
|
158
|
+
fs.unlinkSync(file);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function maxExistingNum(repoRoot) {
|
|
166
|
+
const dir = getIssuesDir(repoRoot);
|
|
167
|
+
let max = 0;
|
|
168
|
+
if (fs.existsSync(dir)) {
|
|
169
|
+
try {
|
|
170
|
+
for (const name of fs.readdirSync(dir)) {
|
|
171
|
+
const m = name.match(FILE_RE);
|
|
172
|
+
if (m)
|
|
173
|
+
max = Math.max(max, Number(m[1]));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// fall through with max = 0
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return max;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Allocate the next issue ID and persist the high-water mark so numbers are
|
|
184
|
+
* monotonic and never recycled: deleting LOCAL-3 then creating again yields
|
|
185
|
+
* LOCAL-4, not LOCAL-3 — a stale local `feature/LOCAL-3-*` branch/worktree
|
|
186
|
+
* can't collide with a reused ID.
|
|
187
|
+
*
|
|
188
|
+
* The counter lives in `.santree/metadata.json` (`_local.lastId`), which is
|
|
189
|
+
* git-ignored and therefore per-machine. That's exactly the right scope: the
|
|
190
|
+
* collision we're avoiding is with *local* worktrees/branches. On a fresh
|
|
191
|
+
* clone metadata.json is absent, so the counter rebuilds from the committed
|
|
192
|
+
* issue files (max existing) — correct, since a fresh clone has no stale
|
|
193
|
+
* local worktrees.
|
|
194
|
+
*/
|
|
195
|
+
export function allocateId(repoRoot) {
|
|
196
|
+
const all = readAllMetadata(repoRoot);
|
|
197
|
+
const local = all["_local"] ?? {};
|
|
198
|
+
const last = typeof local.lastId === "number" ? local.lastId : 0;
|
|
199
|
+
const next = Math.max(last, maxExistingNum(repoRoot)) + 1;
|
|
200
|
+
all["_local"] = { ...local, lastId: next };
|
|
201
|
+
writeAllMetadata(repoRoot, all);
|
|
202
|
+
return `${ID_PREFIX}-${next}`;
|
|
203
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type IssueTrackerKind = "linear" | "github";
|
|
1
|
+
export type IssueTrackerKind = "linear" | "github" | "local";
|
|
2
2
|
export interface Comment {
|
|
3
3
|
author: string;
|
|
4
4
|
body: string;
|
|
@@ -39,6 +39,22 @@ export type IssueTrackerResult<T> = {
|
|
|
39
39
|
reason: "unauthenticated" | "not-found" | "network";
|
|
40
40
|
message?: string;
|
|
41
41
|
};
|
|
42
|
+
/** Fields accepted when creating a new issue. Only the built-in Local tracker
|
|
43
|
+
* supports mutation today (see `IssueTracker.canMutate`). */
|
|
44
|
+
export interface NewIssueInput {
|
|
45
|
+
title: string;
|
|
46
|
+
description: string;
|
|
47
|
+
priority?: number;
|
|
48
|
+
labels?: string[];
|
|
49
|
+
}
|
|
50
|
+
/** Partial patch for an existing issue. Omitted fields are left unchanged. */
|
|
51
|
+
export interface IssuePatch {
|
|
52
|
+
title?: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
priority?: number;
|
|
55
|
+
labels?: string[];
|
|
56
|
+
state?: State;
|
|
57
|
+
}
|
|
42
58
|
export interface IssueTracker {
|
|
43
59
|
readonly kind: IssueTrackerKind;
|
|
44
60
|
readonly displayName: string;
|
|
@@ -49,4 +65,13 @@ export interface IssueTracker {
|
|
|
49
65
|
cleanupCache(identifier: string): void;
|
|
50
66
|
listAssigned(repoRoot: string): Promise<IssueTrackerResult<AssignedIssue[]>>;
|
|
51
67
|
getIssue(identifier: string, repoRoot: string): Promise<IssueTrackerResult<Issue>>;
|
|
68
|
+
/** When true, the tracker implements createIssue/updateIssue/deleteIssue.
|
|
69
|
+
* Read-only trackers (Linear, GitHub) leave this undefined; UI surfaces
|
|
70
|
+
* gate every mutation path on `tracker.canMutate === true` (feature
|
|
71
|
+
* detection — never a `kind === "local"` string check, per the
|
|
72
|
+
* no-tracker-conditionals-outside-the-factory policy). */
|
|
73
|
+
readonly canMutate?: boolean;
|
|
74
|
+
createIssue?(input: NewIssueInput, repoRoot: string): Promise<IssueTrackerResult<Issue>>;
|
|
75
|
+
updateIssue?(identifier: string, patch: IssuePatch, repoRoot: string): Promise<IssueTrackerResult<Issue>>;
|
|
76
|
+
deleteIssue?(identifier: string, repoRoot: string): Promise<IssueTrackerResult<void>>;
|
|
52
77
|
}
|