santree 0.1.4 → 0.2.0

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,150 @@
1
+ import type { PRInfo, PRCheck, PRReview } from "../github.js";
2
+ export interface LinearAssignedIssue {
3
+ identifier: string;
4
+ title: string;
5
+ description: string | null;
6
+ url: string;
7
+ priority: number;
8
+ priorityLabel: string;
9
+ state: {
10
+ name: string;
11
+ type: string;
12
+ };
13
+ labels: string[];
14
+ projectId: string | null;
15
+ projectName: string | null;
16
+ }
17
+ export interface WorktreeInfo {
18
+ path: string;
19
+ branch: string;
20
+ dirty: boolean;
21
+ commitsAhead: number;
22
+ sessionId: string | null;
23
+ gitStatus: string;
24
+ }
25
+ export interface DashboardIssue {
26
+ issue: LinearAssignedIssue;
27
+ worktree: WorktreeInfo | null;
28
+ pr: PRInfo | null;
29
+ checks: PRCheck[] | null;
30
+ reviews: PRReview[] | null;
31
+ }
32
+ export interface ProjectGroup {
33
+ name: string;
34
+ id: string | null;
35
+ issues: DashboardIssue[];
36
+ }
37
+ export type ActionOverlay = "mode-select" | "confirm-delete" | "commit" | "pr-create" | null;
38
+ export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
39
+ export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "creating" | "done" | "error";
40
+ export interface DashboardState {
41
+ groups: ProjectGroup[];
42
+ flatIssues: DashboardIssue[];
43
+ selectedIndex: number;
44
+ listScrollOffset: number;
45
+ detailScrollOffset: number;
46
+ loading: boolean;
47
+ refreshing: boolean;
48
+ error: string | null;
49
+ overlay: ActionOverlay;
50
+ actionMessage: string | null;
51
+ creatingForTicket: string | null;
52
+ creationLogs: string;
53
+ creationError: string | null;
54
+ deletingForTicket: string | null;
55
+ commitPhase: CommitPhase;
56
+ commitMessage: string;
57
+ commitError: string | null;
58
+ commitTicketId: string | null;
59
+ commitWorktreePath: string | null;
60
+ commitBranch: string | null;
61
+ commitGitStatus: string;
62
+ prCreatePhase: PrCreatePhase;
63
+ prCreateTicketId: string | null;
64
+ prCreateWorktreePath: string | null;
65
+ prCreateBranch: string | null;
66
+ prCreateError: string | null;
67
+ prCreateUrl: string | null;
68
+ }
69
+ export type DashboardAction = {
70
+ type: "SET_DATA";
71
+ groups: ProjectGroup[];
72
+ flatIssues: DashboardIssue[];
73
+ } | {
74
+ type: "SELECT";
75
+ index: number;
76
+ } | {
77
+ type: "SCROLL_LIST";
78
+ offset: number;
79
+ } | {
80
+ type: "SCROLL_DETAIL";
81
+ offset: number;
82
+ } | {
83
+ type: "REFRESH_START";
84
+ } | {
85
+ type: "REFRESH_DONE";
86
+ } | {
87
+ type: "SET_ERROR";
88
+ error: string;
89
+ } | {
90
+ type: "SET_OVERLAY";
91
+ overlay: ActionOverlay;
92
+ } | {
93
+ type: "SET_ACTION_MESSAGE";
94
+ message: string | null;
95
+ } | {
96
+ type: "CLEAR_ERROR";
97
+ } | {
98
+ type: "CREATION_START";
99
+ ticketId: string;
100
+ } | {
101
+ type: "CREATION_LOG";
102
+ logs: string;
103
+ } | {
104
+ type: "CREATION_DONE";
105
+ } | {
106
+ type: "CREATION_ERROR";
107
+ error: string;
108
+ } | {
109
+ type: "DELETE_START";
110
+ ticketId: string;
111
+ } | {
112
+ type: "DELETE_DONE";
113
+ } | {
114
+ type: "COMMIT_START";
115
+ ticketId: string;
116
+ worktreePath: string;
117
+ branch: string;
118
+ gitStatus: string;
119
+ } | {
120
+ type: "COMMIT_PHASE";
121
+ phase: CommitPhase;
122
+ } | {
123
+ type: "COMMIT_MESSAGE";
124
+ message: string;
125
+ } | {
126
+ type: "COMMIT_ERROR";
127
+ error: string;
128
+ } | {
129
+ type: "COMMIT_DONE";
130
+ } | {
131
+ type: "COMMIT_CANCEL";
132
+ } | {
133
+ type: "PR_CREATE_START";
134
+ ticketId: string;
135
+ worktreePath: string;
136
+ branch: string;
137
+ } | {
138
+ type: "PR_CREATE_PHASE";
139
+ phase: PrCreatePhase;
140
+ } | {
141
+ type: "PR_CREATE_ERROR";
142
+ error: string;
143
+ } | {
144
+ type: "PR_CREATE_DONE";
145
+ url: string;
146
+ } | {
147
+ type: "PR_CREATE_CANCEL";
148
+ };
149
+ export declare const initialState: DashboardState;
150
+ export declare function reducer(state: DashboardState, action: DashboardAction): DashboardState;
@@ -0,0 +1,151 @@
1
+ // ── State management ──────────────────────────────────────────────────
2
+ export const initialState = {
3
+ groups: [],
4
+ flatIssues: [],
5
+ selectedIndex: 0,
6
+ listScrollOffset: 0,
7
+ detailScrollOffset: 0,
8
+ loading: true,
9
+ refreshing: false,
10
+ error: null,
11
+ overlay: null,
12
+ actionMessage: null,
13
+ creatingForTicket: null,
14
+ creationLogs: "",
15
+ creationError: null,
16
+ deletingForTicket: null,
17
+ commitPhase: "idle",
18
+ commitMessage: "",
19
+ commitError: null,
20
+ commitTicketId: null,
21
+ commitWorktreePath: null,
22
+ commitBranch: null,
23
+ commitGitStatus: "",
24
+ prCreatePhase: "idle",
25
+ prCreateTicketId: null,
26
+ prCreateWorktreePath: null,
27
+ prCreateBranch: null,
28
+ prCreateError: null,
29
+ prCreateUrl: null,
30
+ };
31
+ export function reducer(state, action) {
32
+ switch (action.type) {
33
+ case "SET_DATA": {
34
+ // Preserve selection by identifier if possible
35
+ const prevId = state.flatIssues[state.selectedIndex]?.issue.identifier;
36
+ let newIndex = 0;
37
+ if (prevId) {
38
+ const found = action.flatIssues.findIndex((d) => d.issue.identifier === prevId);
39
+ if (found >= 0)
40
+ newIndex = found;
41
+ }
42
+ return {
43
+ ...state,
44
+ groups: action.groups,
45
+ flatIssues: action.flatIssues,
46
+ selectedIndex: newIndex,
47
+ loading: false,
48
+ refreshing: false,
49
+ error: null,
50
+ detailScrollOffset: 0,
51
+ };
52
+ }
53
+ case "SELECT":
54
+ return { ...state, selectedIndex: action.index, detailScrollOffset: 0 };
55
+ case "SCROLL_LIST":
56
+ return { ...state, listScrollOffset: action.offset };
57
+ case "SCROLL_DETAIL":
58
+ return { ...state, detailScrollOffset: action.offset };
59
+ case "REFRESH_START":
60
+ return { ...state, refreshing: true };
61
+ case "REFRESH_DONE":
62
+ return { ...state, refreshing: false };
63
+ case "SET_ERROR":
64
+ return { ...state, error: action.error, loading: false, refreshing: false };
65
+ case "SET_OVERLAY":
66
+ return { ...state, overlay: action.overlay };
67
+ case "SET_ACTION_MESSAGE":
68
+ return { ...state, actionMessage: action.message };
69
+ case "CLEAR_ERROR":
70
+ return { ...state, error: null };
71
+ case "CREATION_START":
72
+ return {
73
+ ...state,
74
+ creatingForTicket: action.ticketId,
75
+ creationLogs: "",
76
+ creationError: null,
77
+ };
78
+ case "CREATION_LOG":
79
+ return { ...state, creationLogs: state.creationLogs + action.logs };
80
+ case "CREATION_DONE":
81
+ return { ...state, creatingForTicket: null, creationLogs: "", creationError: null };
82
+ case "CREATION_ERROR":
83
+ return { ...state, creationError: action.error, creatingForTicket: null, creationLogs: "" };
84
+ case "DELETE_START":
85
+ return { ...state, deletingForTicket: action.ticketId };
86
+ case "DELETE_DONE":
87
+ return { ...state, deletingForTicket: null };
88
+ case "COMMIT_START":
89
+ return {
90
+ ...state,
91
+ overlay: "commit",
92
+ commitPhase: "confirm-stage",
93
+ commitMessage: "",
94
+ commitError: null,
95
+ commitTicketId: action.ticketId,
96
+ commitWorktreePath: action.worktreePath,
97
+ commitBranch: action.branch,
98
+ commitGitStatus: action.gitStatus,
99
+ };
100
+ case "COMMIT_PHASE":
101
+ return { ...state, commitPhase: action.phase };
102
+ case "COMMIT_MESSAGE":
103
+ return { ...state, commitMessage: action.message };
104
+ case "COMMIT_ERROR":
105
+ return { ...state, commitPhase: "error", commitError: action.error };
106
+ case "COMMIT_DONE":
107
+ return { ...state, commitPhase: "done" };
108
+ case "COMMIT_CANCEL":
109
+ return {
110
+ ...state,
111
+ overlay: null,
112
+ commitPhase: "idle",
113
+ commitMessage: "",
114
+ commitError: null,
115
+ commitTicketId: null,
116
+ commitWorktreePath: null,
117
+ commitBranch: null,
118
+ commitGitStatus: "",
119
+ };
120
+ case "PR_CREATE_START":
121
+ return {
122
+ ...state,
123
+ overlay: "pr-create",
124
+ prCreatePhase: "choose-mode",
125
+ prCreateTicketId: action.ticketId,
126
+ prCreateWorktreePath: action.worktreePath,
127
+ prCreateBranch: action.branch,
128
+ prCreateError: null,
129
+ prCreateUrl: null,
130
+ };
131
+ case "PR_CREATE_PHASE":
132
+ return { ...state, prCreatePhase: action.phase };
133
+ case "PR_CREATE_ERROR":
134
+ return { ...state, prCreatePhase: "error", prCreateError: action.error };
135
+ case "PR_CREATE_DONE":
136
+ return { ...state, prCreatePhase: "done", prCreateUrl: action.url };
137
+ case "PR_CREATE_CANCEL":
138
+ return {
139
+ ...state,
140
+ overlay: null,
141
+ prCreatePhase: "idle",
142
+ prCreateTicketId: null,
143
+ prCreateWorktreePath: null,
144
+ prCreateBranch: null,
145
+ prCreateError: null,
146
+ prCreateUrl: null,
147
+ };
148
+ default:
149
+ return state;
150
+ }
151
+ }
package/dist/lib/git.d.ts CHANGED
@@ -111,6 +111,15 @@ export declare function setRepoLinearOrg(repoRoot: string, orgSlug: string): voi
111
111
  * Deletes the `_linear` key from .santree/metadata.json.
112
112
  */
113
113
  export declare function removeRepoLinearOrg(repoRoot: string): void;
114
+ /**
115
+ * Get the stored session ID for a given ticket from .santree/metadata.json.
116
+ * Returns null if no session ID is stored.
117
+ */
118
+ export declare function getSessionId(repoRoot: string, ticketId: string): string | null;
119
+ /**
120
+ * Store a session ID for a given ticket in .santree/metadata.json.
121
+ */
122
+ export declare function setSessionId(repoRoot: string, ticketId: string, sessionId: string): void;
114
123
  /**
115
124
  * Get the base branch for a given branch name.
116
125
  * Looks up metadata first, falls back to the default branch.
@@ -147,6 +156,11 @@ export declare function hasUnstagedChanges(): boolean;
147
156
  * Returns empty string on failure.
148
157
  */
149
158
  export declare function getGitStatus(): string;
159
+ /**
160
+ * Get a short summary of the working tree status (async, with cwd).
161
+ * Returns empty string on failure.
162
+ */
163
+ export declare function getGitStatusAsync(cwd: string): Promise<string>;
150
164
  /**
151
165
  * Get a diffstat of staged changes.
152
166
  * Runs: `git diff --cached --stat`
@@ -165,6 +179,11 @@ export declare function getCommitsBehind(baseBranch: string): number;
165
179
  * Returns 0 on failure.
166
180
  */
167
181
  export declare function getCommitsAhead(baseBranch: string): number;
182
+ /**
183
+ * Count how many commits the current branch is ahead of baseBranch (async, with cwd).
184
+ * Returns 0 on failure.
185
+ */
186
+ export declare function getCommitsAheadAsync(cwd: string, baseBranch: string): Promise<number>;
168
187
  /**
169
188
  * Check if a branch exists on the remote (origin).
170
189
  * Runs: `git ls-remote --heads origin <branchName>`
package/dist/lib/git.js CHANGED
@@ -2,7 +2,7 @@ import { execSync, exec } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import * as path from "path";
4
4
  import * as fs from "fs";
5
- import { run } from "./exec.js";
5
+ import { run, runAsync } from "./exec.js";
6
6
  const execAsync = promisify(exec);
7
7
  /**
8
8
  * Find the toplevel directory of the current git repository.
@@ -324,6 +324,22 @@ export function removeRepoLinearOrg(repoRoot) {
324
324
  delete all._linear;
325
325
  writeAllMetadata(repoRoot, all);
326
326
  }
327
+ /**
328
+ * Get the stored session ID for a given ticket from .santree/metadata.json.
329
+ * Returns null if no session ID is stored.
330
+ */
331
+ export function getSessionId(repoRoot, ticketId) {
332
+ const all = readAllMetadata(repoRoot);
333
+ return all[ticketId]?.session_id ?? null;
334
+ }
335
+ /**
336
+ * Store a session ID for a given ticket in .santree/metadata.json.
337
+ */
338
+ export function setSessionId(repoRoot, ticketId, sessionId) {
339
+ const all = readAllMetadata(repoRoot);
340
+ all[ticketId] = { ...all[ticketId], session_id: sessionId };
341
+ writeAllMetadata(repoRoot, all);
342
+ }
327
343
  /**
328
344
  * Get the base branch for a given branch name.
329
345
  * Looks up metadata first, falls back to the default branch.
@@ -399,6 +415,13 @@ export function hasUnstagedChanges() {
399
415
  export function getGitStatus() {
400
416
  return run("git status --short") ?? "";
401
417
  }
418
+ /**
419
+ * Get a short summary of the working tree status (async, with cwd).
420
+ * Returns empty string on failure.
421
+ */
422
+ export async function getGitStatusAsync(cwd) {
423
+ return (await runAsync(`git -C "${cwd}" status --short`)) ?? "";
424
+ }
402
425
  /**
403
426
  * Get a diffstat of staged changes.
404
427
  * Runs: `git diff --cached --stat`
@@ -425,6 +448,14 @@ export function getCommitsAhead(baseBranch) {
425
448
  const output = run(`git rev-list --count ${baseBranch}..HEAD`);
426
449
  return output ? parseInt(output, 10) || 0 : 0;
427
450
  }
451
+ /**
452
+ * Count how many commits the current branch is ahead of baseBranch (async, with cwd).
453
+ * Returns 0 on failure.
454
+ */
455
+ export async function getCommitsAheadAsync(cwd, baseBranch) {
456
+ const output = await runAsync(`git -C "${cwd}" rev-list --count ${baseBranch}..HEAD`);
457
+ return output ? parseInt(output, 10) || 0 : 0;
458
+ }
428
459
  /**
429
460
  * Check if a branch exists on the remote (origin).
430
461
  * Runs: `git ls-remote --heads origin <branchName>`
@@ -1,11 +1,12 @@
1
1
  export interface PRInfo {
2
2
  number: string;
3
3
  state: "OPEN" | "MERGED" | "CLOSED";
4
+ isDraft: boolean;
4
5
  url?: string;
5
6
  }
6
7
  /**
7
8
  * Get PR info for a branch using the GitHub CLI (async).
8
- * Runs: `gh pr view "<branchName>" --json number,state,url`
9
+ * Runs: `gh pr view "<branchName>" --json number,state,url,isDraft`
9
10
  * Returns null if no PR exists for the branch or gh CLI fails.
10
11
  */
11
12
  export declare function getPRInfoAsync(branchName: string): Promise<PRInfo | null>;
@@ -4,16 +4,17 @@ import { run, runAsync } from "./exec.js";
4
4
  const execAsync = promisify(exec);
5
5
  /**
6
6
  * Get PR info for a branch using the GitHub CLI (async).
7
- * Runs: `gh pr view "<branchName>" --json number,state,url`
7
+ * Runs: `gh pr view "<branchName>" --json number,state,url,isDraft`
8
8
  * Returns null if no PR exists for the branch or gh CLI fails.
9
9
  */
10
10
  export async function getPRInfoAsync(branchName) {
11
11
  try {
12
- const { stdout } = await execAsync(`gh pr view "${branchName}" --json number,state,url`);
12
+ const { stdout } = await execAsync(`gh pr view "${branchName}" --json number,state,url,isDraft`);
13
13
  const data = JSON.parse(stdout);
14
14
  return {
15
15
  number: String(data.number ?? ""),
16
16
  state: data.state ?? "OPEN",
17
+ isDraft: data.isDraft ?? false,
17
18
  url: data.url,
18
19
  };
19
20
  }
@@ -54,6 +54,26 @@ export interface AuthStatus {
54
54
  * Get auth status for the current repo's Linear org (or any stored org).
55
55
  */
56
56
  export declare function getAuthStatus(repoRoot: string | null): AuthStatus;
57
+ export interface LinearAssignedIssue {
58
+ identifier: string;
59
+ title: string;
60
+ description: string | null;
61
+ url: string;
62
+ priority: number;
63
+ priorityLabel: string;
64
+ state: {
65
+ name: string;
66
+ type: string;
67
+ };
68
+ labels: string[];
69
+ projectId: string | null;
70
+ projectName: string | null;
71
+ }
72
+ /**
73
+ * Fetch all active issues assigned to the current user.
74
+ * Returns null if not authenticated or fetch fails.
75
+ */
76
+ export declare function fetchAssignedIssues(repoRoot: string): Promise<LinearAssignedIssue[] | null>;
57
77
  /**
58
78
  * Fetch full ticket content for a given ticket ID.
59
79
  * Looks up the repo's Linear org, gets valid tokens, fetches issue, downloads images.
@@ -390,6 +390,59 @@ export function getAuthStatus(repoRoot) {
390
390
  repoLinked: false,
391
391
  };
392
392
  }
393
+ // ── Assigned Issues Query ──────────────────────────────────────────────
394
+ const ASSIGNED_ISSUES_QUERY = `
395
+ query AssignedIssues {
396
+ viewer {
397
+ assignedIssues(
398
+ filter: { state: { type: { nin: ["completed", "cancelled"] } } }
399
+ orderBy: updatedAt
400
+ first: 100
401
+ ) {
402
+ nodes {
403
+ identifier
404
+ title
405
+ description
406
+ url
407
+ priority
408
+ state { name type }
409
+ labels { nodes { name } }
410
+ project { id name }
411
+ }
412
+ }
413
+ }
414
+ }
415
+ `;
416
+ /**
417
+ * Fetch all active issues assigned to the current user.
418
+ * Returns null if not authenticated or fetch fails.
419
+ */
420
+ export async function fetchAssignedIssues(repoRoot) {
421
+ const orgSlug = getRepoLinearOrg(repoRoot);
422
+ if (!orgSlug)
423
+ return null;
424
+ const tokens = await getValidTokens(orgSlug);
425
+ if (!tokens)
426
+ return null;
427
+ const data = await graphqlQuery(ASSIGNED_ISSUES_QUERY, {}, tokens.access_token);
428
+ if (!data?.viewer?.assignedIssues?.nodes)
429
+ return null;
430
+ return data.viewer.assignedIssues.nodes.map((issue) => ({
431
+ identifier: issue.identifier,
432
+ title: issue.title,
433
+ description: issue.description ?? null,
434
+ url: issue.url,
435
+ priority: issue.priority,
436
+ priorityLabel: PRIORITY_MAP[issue.priority] ?? "No priority",
437
+ state: {
438
+ name: issue.state?.name ?? "Unknown",
439
+ type: issue.state?.type ?? "unstarted",
440
+ },
441
+ labels: (issue.labels?.nodes ?? []).map((l) => l.name),
442
+ projectId: issue.project?.id ?? null,
443
+ projectName: issue.project?.name ?? null,
444
+ }));
445
+ }
393
446
  // ── High-Level Entry Point ─────────────────────────────────────────────
394
447
  /**
395
448
  * Fetch full ticket content for a given ticket ID.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -29,7 +29,7 @@
29
29
  "node": ">=20"
30
30
  },
31
31
  "scripts": {
32
- "build": "tsc",
32
+ "build": "tsc && chmod +x dist/cli.js",
33
33
  "dev": "tsc --watch",
34
34
  "start": "node dist/cli.js",
35
35
  "lint": "eslint source",