santree 0.2.3 → 0.2.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.
@@ -603,7 +603,7 @@ export default function Dashboard() {
603
603
  if (!prTemplate) {
604
604
  dispatch({
605
605
  type: "PR_CREATE_ERROR",
606
- error: "No PR template found at .github/pull_request_template.md",
606
+ error: "No PR template found (checked .github/, docs/, and repo root)",
607
607
  });
608
608
  return;
609
609
  }
@@ -924,6 +924,10 @@ export default function Dashboard() {
924
924
  }
925
925
  // Open in Linear
926
926
  if (input === "o") {
927
+ if (!di.issue.url) {
928
+ dispatch({ type: "SET_ACTION_MESSAGE", message: "No Linear ticket URL" });
929
+ return;
930
+ }
927
931
  const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
928
932
  execSync(`${openCmd} "${di.issue.url}"`, { stdio: "ignore" });
929
933
  dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened in browser" });
@@ -56,7 +56,7 @@ export default function PR({ options }) {
56
56
  const prTemplate = getPRTemplate();
57
57
  if (!prTemplate) {
58
58
  setStatus("error");
59
- setMessage("No PR template found at .github/pull_request_template.md");
59
+ setMessage("No PR template found (checked .github/, docs/, and repo root)");
60
60
  setTimeout(() => exit(), 100);
61
61
  return;
62
62
  }
@@ -8,6 +8,8 @@ function stateColor(type) {
8
8
  return "blue";
9
9
  case "backlog":
10
10
  return "gray";
11
+ case "orphaned":
12
+ return "gray";
11
13
  default:
12
14
  return "yellow";
13
15
  }
@@ -47,7 +49,8 @@ function fileColor(xy) {
47
49
  return "gray";
48
50
  return "yellow";
49
51
  }
50
- function buildActions(worktree, pr) {
52
+ function buildActions(di) {
53
+ const { worktree, pr, issue } = di;
51
54
  const items = [];
52
55
  // Work/Resume
53
56
  if (worktree?.sessionId) {
@@ -77,7 +80,9 @@ function buildActions(worktree, pr) {
77
80
  items.push({ key: "r", label: "Review", color: "cyan" });
78
81
  }
79
82
  // Links
80
- items.push({ key: "o", label: "Linear", color: "gray" });
83
+ if (issue.url) {
84
+ items.push({ key: "o", label: "Linear", color: "gray" });
85
+ }
81
86
  if (pr)
82
87
  items.push({ key: "p", label: "Open PR", color: "gray" });
83
88
  // Destructive
@@ -210,7 +215,7 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
210
215
  }
211
216
  }
212
217
  // ── Build actions footer ──────────────────────────────────────────
213
- const actionRows = buildActions(worktree, pr);
218
+ const actionRows = buildActions(issue);
214
219
  // +1 for the separator line
215
220
  const actionsHeight = actionRows.length + 1;
216
221
  const scrollableHeight = height - actionsHeight;
@@ -15,6 +15,8 @@ function stateColor(type, name) {
15
15
  return "blue";
16
16
  case "backlog":
17
17
  return "gray";
18
+ case "orphaned":
19
+ return "gray";
18
20
  default:
19
21
  return "yellow";
20
22
  }
@@ -20,9 +20,13 @@ export async function loadDashboardData(repoRoot) {
20
20
  }
21
21
  // Read metadata once for session IDs
22
22
  const metadata = readAllMetadata(repoRoot);
23
+ // Track which ticket IDs are consumed by fetched issues
24
+ const consumedTicketIds = new Set();
23
25
  // Enrich issues in parallel
24
26
  const enriched = await Promise.all(issues.map(async (issue) => {
25
27
  const wt = wtMap.get(issue.identifier);
28
+ if (wt)
29
+ consumedTicketIds.add(issue.identifier);
26
30
  let worktreeInfo = null;
27
31
  let prInfo = null;
28
32
  let checksInfo = null;
@@ -58,6 +62,56 @@ export async function loadDashboardData(repoRoot) {
58
62
  reviews: reviewsInfo,
59
63
  };
60
64
  }));
65
+ // Build orphan DashboardIssue objects for worktrees not matched to any fetched issue
66
+ const orphans = await Promise.all([...wtMap.entries()]
67
+ .filter(([tid]) => !consumedTicketIds.has(tid))
68
+ .map(async ([tid, wt]) => {
69
+ const base = getBaseBranch(wt.branch);
70
+ const [gitStatusOutput, ahead, pr] = await Promise.all([
71
+ getGitStatusAsync(wt.path),
72
+ getCommitsAheadAsync(wt.path, base),
73
+ getPRInfoAsync(wt.branch),
74
+ ]);
75
+ let checksInfo = null;
76
+ let reviewsInfo = null;
77
+ if (pr) {
78
+ [checksInfo, reviewsInfo] = await Promise.all([
79
+ getPRChecksAsync(pr.number),
80
+ getPRReviewsAsync(pr.number),
81
+ ]);
82
+ }
83
+ // Derive a readable title from branch name: strip prefix and ticket ID
84
+ const titleFromBranch = wt.branch
85
+ .replace(/^[^/]+\//, "") // strip prefix (e.g. "feature/")
86
+ .replace(/^[A-Z]+-\d+-?/, "") // strip ticket ID
87
+ .replace(/-/g, " ")
88
+ .trim() || tid;
89
+ return {
90
+ issue: {
91
+ identifier: tid,
92
+ title: titleFromBranch,
93
+ description: null,
94
+ url: "",
95
+ priority: 0,
96
+ priorityLabel: "None",
97
+ state: { name: "Orphaned", type: "orphaned" },
98
+ labels: [],
99
+ projectId: null,
100
+ projectName: null,
101
+ },
102
+ worktree: {
103
+ path: wt.path,
104
+ branch: wt.branch,
105
+ dirty: Boolean(gitStatusOutput),
106
+ commitsAhead: ahead,
107
+ sessionId: metadata[tid]?.session_id ?? null,
108
+ gitStatus: gitStatusOutput,
109
+ },
110
+ pr,
111
+ checks: checksInfo,
112
+ reviews: reviewsInfo,
113
+ };
114
+ }));
61
115
  // Group by project
62
116
  const groupMap = new Map();
63
117
  for (const di of enriched) {
@@ -98,6 +152,20 @@ export async function loadDashboardData(repoRoot) {
98
152
  statusGroups,
99
153
  };
100
154
  });
155
+ // Append orphaned worktrees as a separate group at the bottom
156
+ if (orphans.length > 0) {
157
+ groups.push({
158
+ name: "Orphaned Worktrees",
159
+ id: null,
160
+ statusGroups: [
161
+ {
162
+ name: "Orphaned",
163
+ type: "orphaned",
164
+ issues: orphans,
165
+ },
166
+ ],
167
+ });
168
+ }
101
169
  const flatIssues = groups.flatMap((g) => g.statusGroups.flatMap((sg) => sg.issues));
102
170
  return { groups, flatIssues };
103
171
  }
@@ -31,8 +31,10 @@ export declare function pushBranch(branchName: string, force?: boolean): boolean
31
31
  */
32
32
  export declare function createPR(title: string, baseBranch: string, headBranch: string, bodyFile?: string): number;
33
33
  /**
34
- * Fetch the pull request template from the repo's .github/pull_request_template.md.
35
- * Runs: `gh api repos/{owner}/{repo}/contents/.github/pull_request_template.md --jq .content`
34
+ * Fetch the pull request template from the repo.
35
+ * Checks all standard locations and casings that GitHub supports:
36
+ * .github/, docs/, and repo root — with both lowercase and uppercase filenames.
37
+ * https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository
36
38
  * Returns the decoded template content, or null if none exists.
37
39
  */
38
40
  export declare function getPRTemplate(): string | null;
@@ -71,15 +71,28 @@ export function createPR(title, baseBranch, headBranch, bodyFile) {
71
71
  }
72
72
  }
73
73
  /**
74
- * Fetch the pull request template from the repo's .github/pull_request_template.md.
75
- * Runs: `gh api repos/{owner}/{repo}/contents/.github/pull_request_template.md --jq .content`
74
+ * Fetch the pull request template from the repo.
75
+ * Checks all standard locations and casings that GitHub supports:
76
+ * .github/, docs/, and repo root — with both lowercase and uppercase filenames.
77
+ * https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository
76
78
  * Returns the decoded template content, or null if none exists.
77
79
  */
78
80
  export function getPRTemplate() {
79
- const output = run(`gh api repos/{owner}/{repo}/contents/.github/pull_request_template.md --jq .content`);
80
- if (!output)
81
- return null;
82
- return Buffer.from(output, "base64").toString("utf-8");
81
+ const paths = [
82
+ ".github/pull_request_template.md",
83
+ ".github/PULL_REQUEST_TEMPLATE.md",
84
+ "docs/pull_request_template.md",
85
+ "docs/PULL_REQUEST_TEMPLATE.md",
86
+ "pull_request_template.md",
87
+ "PULL_REQUEST_TEMPLATE.md",
88
+ ];
89
+ for (const path of paths) {
90
+ const output = run(`gh api repos/{owner}/{repo}/contents/${path} --jq .content`);
91
+ if (output) {
92
+ return Buffer.from(output, "base64").toString("utf-8");
93
+ }
94
+ }
95
+ return null;
83
96
  }
84
97
  /**
85
98
  * Fetch CI check results for a pull request (async).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",