gh-stack 0.1.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.
package/src/lib/ui.ts ADDED
@@ -0,0 +1,234 @@
1
+ // TUI helpers: tree rendering, formatting, prompts
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import type { Stack, StackMetadata } from "../types.ts";
5
+ import { getOrderedBranches } from "./metadata.ts";
6
+
7
+ // ── Auto-yes mode (for agents/CI) ──
8
+
9
+ let _autoYes = false;
10
+
11
+ /**
12
+ * Enable/disable auto-yes mode (skip all confirmations).
13
+ * Set via --yes flag or GH_STACK_YES=1 env var.
14
+ */
15
+ export function setAutoYes(enabled: boolean): void {
16
+ _autoYes = enabled;
17
+ }
18
+
19
+ export function isAutoYes(): boolean {
20
+ return _autoYes;
21
+ }
22
+
23
+ /**
24
+ * Render a stack as a tree with branch numbers, PR info, and current marker.
25
+ */
26
+ export function renderStackTree(
27
+ stack: Stack,
28
+ currentBranch: string,
29
+ options?: { showNumbers?: boolean },
30
+ ): string {
31
+ const ordered = getOrderedBranches(stack);
32
+ const showNumbers = options?.showNumbers ?? true;
33
+ const lines: string[] = [];
34
+
35
+ lines.push(`${pc.green("◯")} main`);
36
+
37
+ for (let i = 0; i < ordered.length; i++) {
38
+ const branchName = ordered[i]!;
39
+ const branch = stack.branches[branchName]!;
40
+ const isLast = i === ordered.length - 1;
41
+ const isCurrent = branchName === currentBranch;
42
+
43
+ // Tree characters
44
+ const tree = isLast ? "┗━◯" : "┣━◯";
45
+
46
+ // Branch number label
47
+ const numLabel = showNumbers ? `[${pc.blue(String(i + 1))}] ` : "";
48
+
49
+ // Current marker
50
+ const marker = isCurrent ? ` ${pc.yellow("(current)")}` : "";
51
+
52
+ // PR info
53
+ const prNum = branch.pr ? `#${branch.pr}` : "(no PR)";
54
+ const desc = branch.description || branchName;
55
+
56
+ // Separator
57
+ lines.push("┃");
58
+
59
+ if (isCurrent) {
60
+ lines.push(`${tree} ${numLabel}${pc.yellow(branchName)}${marker}`);
61
+ lines.push(`┃ ${pc.blue(prNum)}: ${desc}`);
62
+ } else {
63
+ lines.push(`${tree} ${numLabel}${branchName}`);
64
+ lines.push(`┃ ${pc.dim(`${prNum}: ${desc}`)}`);
65
+ }
66
+ }
67
+
68
+ return lines.join("\n");
69
+ }
70
+
71
+ /**
72
+ * Display a header banner.
73
+ */
74
+ export function header(title: string): void {
75
+ p.intro(pc.cyan(title));
76
+ }
77
+
78
+ /**
79
+ * Display a footer banner.
80
+ */
81
+ export function footer(title: string): void {
82
+ p.outro(pc.green(title));
83
+ }
84
+
85
+ /**
86
+ * Interactive branch selector for a stack.
87
+ * Returns the selected branch name, or null if cancelled.
88
+ */
89
+ export async function selectBranch(
90
+ stack: Stack,
91
+ message: string,
92
+ currentBranch?: string,
93
+ ): Promise<string | null> {
94
+ const ordered = getOrderedBranches(stack);
95
+
96
+ if (ordered.length === 0) {
97
+ p.log.warn("No branches in this stack");
98
+ return null;
99
+ }
100
+
101
+ // In auto-yes mode, can't select — return null (caller should use explicit args)
102
+ if (_autoYes) {
103
+ p.log.warn("Interactive selection skipped (--yes mode). Use explicit arguments.");
104
+ return null;
105
+ }
106
+
107
+ const options = ordered.map((name) => {
108
+ const branch = stack.branches[name]!;
109
+ const prNum = branch.pr ? `#${branch.pr}` : "";
110
+ const desc = branch.description || "";
111
+ const current = name === currentBranch ? pc.yellow(" (current)") : "";
112
+ const label = `${name}${current}`;
113
+ const hint = [prNum, desc].filter(Boolean).join(" — ");
114
+
115
+ return { value: name, label, hint };
116
+ });
117
+
118
+ const result = await p.select({
119
+ message,
120
+ options,
121
+ });
122
+
123
+ if (p.isCancel(result)) return null;
124
+ return result as string;
125
+ }
126
+
127
+ /**
128
+ * Interactive stack selector.
129
+ * Returns the selected stack name, or null if cancelled.
130
+ */
131
+ export async function selectStack(meta: StackMetadata, message: string): Promise<string | null> {
132
+ const stackNames = Object.keys(meta.stacks);
133
+
134
+ if (stackNames.length === 0) {
135
+ p.log.warn("No stacks found");
136
+ return null;
137
+ }
138
+
139
+ // In auto-yes mode, can't select — return null
140
+ if (_autoYes) {
141
+ p.log.warn("Interactive selection skipped (--yes mode). Use explicit arguments.");
142
+ return null;
143
+ }
144
+
145
+ const options = stackNames.map((name) => {
146
+ const stack = meta.stacks[name]!;
147
+ const branchCount = Object.keys(stack.branches).length;
148
+ const current = name === meta.current_stack ? pc.yellow(" (current)") : "";
149
+ const label = `${name}${current}`;
150
+ const hint = `${branchCount} branch${branchCount !== 1 ? "es" : ""}${stack.description ? ` — ${stack.description}` : ""}`;
151
+
152
+ return { value: name, label, hint };
153
+ });
154
+
155
+ const result = await p.select({
156
+ message,
157
+ options,
158
+ });
159
+
160
+ if (p.isCancel(result)) return null;
161
+ return result as string;
162
+ }
163
+
164
+ /**
165
+ * Interactive parent branch selector.
166
+ * Offers: branches in stack, "main", or custom input.
167
+ */
168
+ export async function selectParent(
169
+ stack: Stack | null,
170
+ currentBranch: string,
171
+ ): Promise<string | null> {
172
+ // In auto-yes mode, default to "main"
173
+ if (_autoYes) {
174
+ p.log.info("Auto-selecting parent: main (--yes mode)");
175
+ return "main";
176
+ }
177
+
178
+ const options: { value: string; label: string; hint?: string }[] = [
179
+ { value: "main", label: "main", hint: "Base branch" },
180
+ ];
181
+
182
+ // Add stack branches (if we have a stack)
183
+ if (stack) {
184
+ const ordered = getOrderedBranches(stack);
185
+ for (const name of ordered) {
186
+ if (name !== currentBranch) {
187
+ const branch = stack.branches[name]!;
188
+ const prNum = branch.pr ? `#${branch.pr}` : "";
189
+ options.push({
190
+ value: name,
191
+ label: name,
192
+ hint: [prNum, branch.description].filter(Boolean).join(" — "),
193
+ });
194
+ }
195
+ }
196
+ }
197
+
198
+ const result = await p.select({
199
+ message: `Select parent branch for ${pc.yellow(currentBranch)}`,
200
+ options,
201
+ });
202
+
203
+ if (p.isCancel(result)) return null;
204
+ return result as string;
205
+ }
206
+
207
+ /**
208
+ * Confirm a destructive action.
209
+ * In auto-yes mode, returns true immediately without prompting.
210
+ */
211
+ export async function confirmAction(message: string): Promise<boolean> {
212
+ if (_autoYes) {
213
+ p.log.info(`${message} ${pc.dim("(auto-yes)")}`);
214
+ return true;
215
+ }
216
+
217
+ const result = await p.confirm({ message });
218
+ if (p.isCancel(result)) return false;
219
+ return result;
220
+ }
221
+
222
+ /**
223
+ * Format a branch for inline display.
224
+ */
225
+ export function branchLabel(name: string): string {
226
+ return pc.yellow(name);
227
+ }
228
+
229
+ /**
230
+ * Format a PR number for inline display.
231
+ */
232
+ export function prLabel(num: number): string {
233
+ return pc.blue(`#${num}`);
234
+ }
package/src/types.ts ADDED
@@ -0,0 +1,68 @@
1
+ // gh-stack metadata schema (v2)
2
+
3
+ export interface StackMetadata {
4
+ version: 2;
5
+ current_stack: string | null;
6
+ stacks: Record<string, Stack>;
7
+ archive?: Record<string, Stack>;
8
+ snapshots?: Snapshot[];
9
+ }
10
+
11
+ export interface Stack {
12
+ description: string;
13
+ last_branch: string | null;
14
+ branches: Record<string, Branch>;
15
+ }
16
+
17
+ export interface Branch {
18
+ parent: string; // "main" or another branch name
19
+ pr?: number; // GitHub PR number
20
+ description?: string; // Human-readable label
21
+ }
22
+
23
+ export interface Snapshot {
24
+ timestamp: string; // ISO 8601
25
+ operation: string; // "restack" | "merge" | "sync" | "remove"
26
+ branches: Record<string, string>; // branch name -> commit SHA
27
+ }
28
+
29
+ // Resume state for restack --resume
30
+ export interface RestackState {
31
+ current_index: number;
32
+ stack_name: string;
33
+ chain: string[];
34
+ }
35
+
36
+ // PR info from GitHub API
37
+ export interface PrInfo {
38
+ number: number;
39
+ title: string;
40
+ state: "OPEN" | "CLOSED" | "MERGED";
41
+ reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | "PENDING" | null;
42
+ isDraft: boolean;
43
+ url: string;
44
+ updatedAt?: string;
45
+ headRefName?: string; // branch name
46
+ statusCheckRollup?: CheckRun[];
47
+ }
48
+
49
+ export interface CheckRun {
50
+ name: string;
51
+ status: string;
52
+ conclusion: string | null;
53
+ }
54
+
55
+ // Parsed PR status for display
56
+ export interface PrStatus extends PrInfo {
57
+ totalChecks: number;
58
+ passedChecks: number;
59
+ failedChecks: number;
60
+ pendingChecks: number;
61
+ failedNames: string[];
62
+ }
63
+
64
+ // v1 schema (for migration)
65
+ export interface StackMetadataV1 {
66
+ current_stack: string | null;
67
+ stacks: Record<string, Stack>;
68
+ }