tmux-team 1.1.0 → 2.0.0-alpha.1
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 +195 -22
- package/bin/tmux-team +31 -430
- package/package.json +25 -5
- package/src/cli.ts +212 -0
- package/src/commands/add.ts +38 -0
- package/src/commands/check.ts +34 -0
- package/src/commands/completion.ts +118 -0
- package/src/commands/help.ts +51 -0
- package/src/commands/init.ts +24 -0
- package/src/commands/list.ts +27 -0
- package/src/commands/remove.ts +25 -0
- package/src/commands/talk.test.ts +652 -0
- package/src/commands/talk.ts +261 -0
- package/src/commands/update.ts +47 -0
- package/src/config.test.ts +246 -0
- package/src/config.ts +159 -0
- package/src/context.ts +38 -0
- package/src/exits.ts +14 -0
- package/src/pm/commands.test.ts +158 -0
- package/src/pm/commands.ts +654 -0
- package/src/pm/manager.test.ts +377 -0
- package/src/pm/manager.ts +140 -0
- package/src/pm/storage/adapter.ts +55 -0
- package/src/pm/storage/fs.test.ts +384 -0
- package/src/pm/storage/fs.ts +255 -0
- package/src/pm/storage/github.ts +751 -0
- package/src/pm/types.ts +79 -0
- package/src/state.test.ts +311 -0
- package/src/state.ts +83 -0
- package/src/tmux.test.ts +205 -0
- package/src/tmux.ts +27 -0
- package/src/types.ts +86 -0
- package/src/ui.ts +67 -0
- package/src/version.ts +21 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// GitHub storage adapter for PM (Phase 5)
|
|
3
|
+
// Uses GitHub Issues for tasks, GitHub Milestones for milestones
|
|
4
|
+
// ─────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from 'child_process';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import type { StorageAdapter } from './adapter.js';
|
|
10
|
+
import type {
|
|
11
|
+
Team,
|
|
12
|
+
Milestone,
|
|
13
|
+
Task,
|
|
14
|
+
AuditEvent,
|
|
15
|
+
CreateTaskInput,
|
|
16
|
+
UpdateTaskInput,
|
|
17
|
+
CreateMilestoneInput,
|
|
18
|
+
UpdateMilestoneInput,
|
|
19
|
+
ListTasksFilter,
|
|
20
|
+
TaskStatus,
|
|
21
|
+
} from '../types.js';
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────────
|
|
24
|
+
// Labels used for task status tracking
|
|
25
|
+
// ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const LABELS = {
|
|
28
|
+
// Base label for all tmux-team managed issues
|
|
29
|
+
TASK: 'tmux-team:task',
|
|
30
|
+
// Status labels
|
|
31
|
+
PENDING: 'tmux-team:pending',
|
|
32
|
+
IN_PROGRESS: 'tmux-team:in_progress',
|
|
33
|
+
DELETED: 'tmux-team:deleted',
|
|
34
|
+
// 'done' status uses closed issue state, no label needed
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────────────────────────────────────────
|
|
38
|
+
// GitHub Issue/Milestone JSON types (from gh CLI)
|
|
39
|
+
// ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface GHIssue {
|
|
42
|
+
number: number;
|
|
43
|
+
title: string;
|
|
44
|
+
body: string;
|
|
45
|
+
state: 'OPEN' | 'CLOSED';
|
|
46
|
+
labels: Array<{ name: string }>;
|
|
47
|
+
milestone?: { number: number; title: string } | null;
|
|
48
|
+
assignees: Array<{ login: string }>;
|
|
49
|
+
createdAt: string;
|
|
50
|
+
updatedAt: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface GHMilestone {
|
|
54
|
+
number: number;
|
|
55
|
+
title: string;
|
|
56
|
+
state: 'OPEN' | 'CLOSED';
|
|
57
|
+
createdAt: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─────────────────────────────────────────────────────────────
|
|
61
|
+
// Local cache for ID mapping (task ID -> issue number)
|
|
62
|
+
// ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
const CACHE_VERSION = 1;
|
|
65
|
+
|
|
66
|
+
interface IdCache {
|
|
67
|
+
version: number; // Cache format version for migrations
|
|
68
|
+
repo: string; // Associated repo to detect cross-repo drift
|
|
69
|
+
tasks: Record<string, number>; // task ID -> issue number
|
|
70
|
+
milestones: Record<string, number>; // milestone ID -> milestone number
|
|
71
|
+
nextTaskId: number;
|
|
72
|
+
nextMilestoneId: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─────────────────────────────────────────────────────────────
|
|
76
|
+
// GitHub Adapter Implementation
|
|
77
|
+
// ─────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export class GitHubAdapter implements StorageAdapter {
|
|
80
|
+
private teamDir: string;
|
|
81
|
+
private repo: string;
|
|
82
|
+
|
|
83
|
+
constructor(teamDir: string, repo: string) {
|
|
84
|
+
this.teamDir = teamDir;
|
|
85
|
+
this.repo = repo;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─────────────────────────────────────────────────────────────
|
|
89
|
+
// Helper: Execute gh CLI command safely (no shell injection)
|
|
90
|
+
// ─────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
private gh(args: string[]): string {
|
|
93
|
+
const fullArgs = [...args];
|
|
94
|
+
if (this.repo) {
|
|
95
|
+
fullArgs.push('--repo', this.repo);
|
|
96
|
+
}
|
|
97
|
+
// Use spawnSync with array args to avoid shell injection
|
|
98
|
+
const result = spawnSync('gh', fullArgs, {
|
|
99
|
+
encoding: 'utf-8',
|
|
100
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (result.error) {
|
|
104
|
+
throw new Error(`gh command failed: ${result.error.message}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (result.status !== 0) {
|
|
108
|
+
const stderr = result.stderr?.trim() || 'Unknown error';
|
|
109
|
+
// Surface auth errors clearly
|
|
110
|
+
if (stderr.includes('gh auth login') || stderr.includes('not logged in')) {
|
|
111
|
+
throw new Error(`GitHub authentication required. Run: gh auth login`);
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`gh command failed: ${stderr}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (result.stdout || '').trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private ghJson<T>(args: string[]): T {
|
|
120
|
+
const result = this.gh(args);
|
|
121
|
+
return JSON.parse(result) as T;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─────────────────────────────────────────────────────────────
|
|
125
|
+
// Helper: Local ID cache management
|
|
126
|
+
// ─────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
private get cacheFile(): string {
|
|
129
|
+
return path.join(this.teamDir, 'github-cache.json');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private loadCache(): IdCache {
|
|
133
|
+
if (fs.existsSync(this.cacheFile)) {
|
|
134
|
+
try {
|
|
135
|
+
const data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf-8')) as IdCache;
|
|
136
|
+
|
|
137
|
+
// Validate cache integrity
|
|
138
|
+
if (data.version !== CACHE_VERSION) {
|
|
139
|
+
// Version mismatch - reset cache (future: migration logic)
|
|
140
|
+
console.error(`[tmux-team] Cache version mismatch, resetting cache`);
|
|
141
|
+
return this.createEmptyCache();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (data.repo !== this.repo) {
|
|
145
|
+
// Repo mismatch - cache is for different repo, reset
|
|
146
|
+
console.error(
|
|
147
|
+
`[tmux-team] Cache repo mismatch (${data.repo} vs ${this.repo}), resetting cache`
|
|
148
|
+
);
|
|
149
|
+
return this.createEmptyCache();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Validate nextId counters are consistent
|
|
153
|
+
const maxTaskId = Math.max(0, ...Object.keys(data.tasks).map((k) => parseInt(k, 10) || 0));
|
|
154
|
+
const maxMilestoneId = Math.max(
|
|
155
|
+
0,
|
|
156
|
+
...Object.keys(data.milestones).map((k) => parseInt(k, 10) || 0)
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (data.nextTaskId <= maxTaskId) {
|
|
160
|
+
data.nextTaskId = maxTaskId + 1;
|
|
161
|
+
}
|
|
162
|
+
if (data.nextMilestoneId <= maxMilestoneId) {
|
|
163
|
+
data.nextMilestoneId = maxMilestoneId + 1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return data;
|
|
167
|
+
} catch {
|
|
168
|
+
// Corrupted cache, reset
|
|
169
|
+
console.error(`[tmux-team] Cache corrupted, resetting`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return this.createEmptyCache();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private createEmptyCache(): IdCache {
|
|
176
|
+
return {
|
|
177
|
+
version: CACHE_VERSION,
|
|
178
|
+
repo: this.repo,
|
|
179
|
+
tasks: {},
|
|
180
|
+
milestones: {},
|
|
181
|
+
nextTaskId: 1,
|
|
182
|
+
nextMilestoneId: 1,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private saveCache(cache: IdCache): void {
|
|
187
|
+
fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true });
|
|
188
|
+
fs.writeFileSync(this.cacheFile, JSON.stringify(cache, null, 2) + '\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private getIssueNumber(taskId: string): number | null {
|
|
192
|
+
const cache = this.loadCache();
|
|
193
|
+
return cache.tasks[taskId] ?? null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private getMilestoneNumber(milestoneId: string): number | null {
|
|
197
|
+
const cache = this.loadCache();
|
|
198
|
+
return cache.milestones[milestoneId] ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─────────────────────────────────────────────────────────────
|
|
202
|
+
// Helper: Status <-> Label conversion
|
|
203
|
+
// ─────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
private statusToLabel(status: TaskStatus): string | null {
|
|
206
|
+
switch (status) {
|
|
207
|
+
case 'pending':
|
|
208
|
+
return LABELS.PENDING;
|
|
209
|
+
case 'in_progress':
|
|
210
|
+
return LABELS.IN_PROGRESS;
|
|
211
|
+
case 'done':
|
|
212
|
+
return null; // Use closed state
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private issueToTask(issue: GHIssue, taskId: string, cache?: IdCache): Task {
|
|
217
|
+
let status: TaskStatus = 'pending';
|
|
218
|
+
if (issue.state === 'CLOSED') {
|
|
219
|
+
status = 'done';
|
|
220
|
+
} else if (issue.labels.some((l) => l.name === LABELS.IN_PROGRESS)) {
|
|
221
|
+
status = 'in_progress';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Look up local milestone ID from GitHub milestone number
|
|
225
|
+
let milestoneId: string | undefined;
|
|
226
|
+
if (issue.milestone?.number) {
|
|
227
|
+
const c = cache || this.loadCache();
|
|
228
|
+
const ghMilestoneNum = issue.milestone.number;
|
|
229
|
+
milestoneId = Object.entries(c.milestones).find(([, num]) => num === ghMilestoneNum)?.[0];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
id: taskId,
|
|
234
|
+
title: issue.title,
|
|
235
|
+
milestone: milestoneId,
|
|
236
|
+
status,
|
|
237
|
+
assignee: issue.assignees[0]?.login,
|
|
238
|
+
docPath: `github:issue/${issue.number}`,
|
|
239
|
+
createdAt: issue.createdAt,
|
|
240
|
+
updatedAt: issue.updatedAt,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private milestoneToMilestone(ghMilestone: GHMilestone, id: string): Milestone {
|
|
245
|
+
return {
|
|
246
|
+
id,
|
|
247
|
+
name: ghMilestone.title,
|
|
248
|
+
status: ghMilestone.state === 'CLOSED' ? 'done' : 'pending',
|
|
249
|
+
createdAt: ghMilestone.createdAt,
|
|
250
|
+
updatedAt: ghMilestone.createdAt, // GH milestones don't have updatedAt
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─────────────────────────────────────────────────────────────
|
|
255
|
+
// Helper: Ensure labels exist
|
|
256
|
+
// ─────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
private async ensureLabels(): Promise<void> {
|
|
259
|
+
const labels = [
|
|
260
|
+
{ name: LABELS.TASK, color: '0366d6', desc: 'tmux-team managed task' },
|
|
261
|
+
{ name: LABELS.PENDING, color: 'fbca04', desc: 'Task pending' },
|
|
262
|
+
{ name: LABELS.IN_PROGRESS, color: '1d76db', desc: 'Task in progress' },
|
|
263
|
+
{ name: LABELS.DELETED, color: 'b60205', desc: 'Task deleted' },
|
|
264
|
+
];
|
|
265
|
+
for (const label of labels) {
|
|
266
|
+
try {
|
|
267
|
+
this.gh([
|
|
268
|
+
'label',
|
|
269
|
+
'create',
|
|
270
|
+
label.name,
|
|
271
|
+
'--force',
|
|
272
|
+
'--color',
|
|
273
|
+
label.color,
|
|
274
|
+
'--description',
|
|
275
|
+
label.desc,
|
|
276
|
+
]);
|
|
277
|
+
} catch {
|
|
278
|
+
// Label might already exist, ignore
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─────────────────────────────────────────────────────────────
|
|
284
|
+
// Team operations
|
|
285
|
+
// ─────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
private get teamFile(): string {
|
|
288
|
+
return path.join(this.teamDir, 'team.json');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private get eventsFile(): string {
|
|
292
|
+
return path.join(this.teamDir, 'events.jsonl');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private now(): string {
|
|
296
|
+
return new Date().toISOString();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async initTeam(name: string, windowId?: string): Promise<Team> {
|
|
300
|
+
fs.mkdirSync(this.teamDir, { recursive: true });
|
|
301
|
+
|
|
302
|
+
// Ensure labels exist in the repo
|
|
303
|
+
await this.ensureLabels();
|
|
304
|
+
|
|
305
|
+
const team: Team = {
|
|
306
|
+
id: path.basename(this.teamDir),
|
|
307
|
+
name,
|
|
308
|
+
windowId,
|
|
309
|
+
createdAt: this.now(),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
fs.writeFileSync(this.teamFile, JSON.stringify(team, null, 2) + '\n');
|
|
313
|
+
|
|
314
|
+
// Initialize empty cache with version and repo
|
|
315
|
+
this.saveCache(this.createEmptyCache());
|
|
316
|
+
|
|
317
|
+
return team;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async getTeam(): Promise<Team | null> {
|
|
321
|
+
if (!fs.existsSync(this.teamFile)) return null;
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(fs.readFileSync(this.teamFile, 'utf-8')) as Team;
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async updateTeam(updates: Partial<Team>): Promise<Team> {
|
|
330
|
+
const team = await this.getTeam();
|
|
331
|
+
if (!team) throw new Error('Team not initialized');
|
|
332
|
+
const updated = { ...team, ...updates };
|
|
333
|
+
fs.writeFileSync(this.teamFile, JSON.stringify(updated, null, 2) + '\n');
|
|
334
|
+
return updated;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ─────────────────────────────────────────────────────────────
|
|
338
|
+
// Milestone operations (GitHub Milestones)
|
|
339
|
+
// ─────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
async createMilestone(input: CreateMilestoneInput): Promise<Milestone> {
|
|
342
|
+
// Create milestone in GitHub
|
|
343
|
+
const result = this.gh([
|
|
344
|
+
'api',
|
|
345
|
+
`repos/${this.repo}/milestones`,
|
|
346
|
+
'-X',
|
|
347
|
+
'POST',
|
|
348
|
+
'-f',
|
|
349
|
+
`title=${input.name}`,
|
|
350
|
+
'-f',
|
|
351
|
+
'state=open',
|
|
352
|
+
]);
|
|
353
|
+
const ghMilestone = JSON.parse(result) as { number: number; title: string; created_at: string };
|
|
354
|
+
|
|
355
|
+
// Cache the ID mapping
|
|
356
|
+
const cache = this.loadCache();
|
|
357
|
+
const id = String(cache.nextMilestoneId++);
|
|
358
|
+
cache.milestones[id] = ghMilestone.number;
|
|
359
|
+
this.saveCache(cache);
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
id,
|
|
363
|
+
name: ghMilestone.title,
|
|
364
|
+
status: 'pending',
|
|
365
|
+
createdAt: ghMilestone.created_at,
|
|
366
|
+
updatedAt: ghMilestone.created_at,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async getMilestone(id: string): Promise<Milestone | null> {
|
|
371
|
+
const number = this.getMilestoneNumber(id);
|
|
372
|
+
if (!number) return null;
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const result = this.gh(['api', `repos/${this.repo}/milestones/${number}`]);
|
|
376
|
+
const ghMilestone = JSON.parse(result) as GHMilestone;
|
|
377
|
+
return this.milestoneToMilestone(ghMilestone, id);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const err = error as Error;
|
|
380
|
+
if (err.message.includes('auth')) {
|
|
381
|
+
throw err; // Re-throw auth errors
|
|
382
|
+
}
|
|
383
|
+
return null; // Not found or other error
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async listMilestones(): Promise<Milestone[]> {
|
|
388
|
+
const cache = this.loadCache();
|
|
389
|
+
const milestones: Milestone[] = [];
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
// Get all milestones (open and closed)
|
|
393
|
+
const result = this.gh([
|
|
394
|
+
'api',
|
|
395
|
+
`repos/${this.repo}/milestones`,
|
|
396
|
+
'-X',
|
|
397
|
+
'GET',
|
|
398
|
+
'--jq',
|
|
399
|
+
'.',
|
|
400
|
+
'-f',
|
|
401
|
+
'state=all',
|
|
402
|
+
]);
|
|
403
|
+
const ghMilestones = JSON.parse(result) as GHMilestone[];
|
|
404
|
+
|
|
405
|
+
// Match with cached IDs, or create new mappings
|
|
406
|
+
for (const ghm of ghMilestones) {
|
|
407
|
+
let id = Object.entries(cache.milestones).find(([, num]) => num === ghm.number)?.[0];
|
|
408
|
+
if (!id) {
|
|
409
|
+
// New milestone from GitHub, assign ID
|
|
410
|
+
id = String(cache.nextMilestoneId++);
|
|
411
|
+
cache.milestones[id] = ghm.number;
|
|
412
|
+
}
|
|
413
|
+
milestones.push(this.milestoneToMilestone(ghm, id));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
this.saveCache(cache);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
// Surface auth errors instead of silently returning empty array
|
|
419
|
+
const err = error as Error;
|
|
420
|
+
if (err.message.includes('auth')) {
|
|
421
|
+
throw err; // Re-throw auth errors
|
|
422
|
+
}
|
|
423
|
+
// Other errors (no milestones, network issues) return empty array
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return milestones.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async updateMilestone(id: string, input: UpdateMilestoneInput): Promise<Milestone> {
|
|
430
|
+
const number = this.getMilestoneNumber(id);
|
|
431
|
+
if (!number) throw new Error(`Milestone ${id} not found`);
|
|
432
|
+
|
|
433
|
+
const args = ['api', `repos/${this.repo}/milestones/${number}`, '-X', 'PATCH'];
|
|
434
|
+
if (input.name) args.push('-f', `title=${input.name}`);
|
|
435
|
+
if (input.status) {
|
|
436
|
+
args.push('-f', `state=${input.status === 'done' ? 'closed' : 'open'}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const result = this.gh(args);
|
|
440
|
+
const ghMilestone = JSON.parse(result) as GHMilestone;
|
|
441
|
+
return this.milestoneToMilestone(ghMilestone, id);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async deleteMilestone(id: string): Promise<void> {
|
|
445
|
+
const number = this.getMilestoneNumber(id);
|
|
446
|
+
if (!number) return;
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
this.gh(['api', `repos/${this.repo}/milestones/${number}`, '-X', 'DELETE']);
|
|
450
|
+
} catch {
|
|
451
|
+
// Already deleted or not found
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Remove from cache
|
|
455
|
+
const cache = this.loadCache();
|
|
456
|
+
delete cache.milestones[id];
|
|
457
|
+
this.saveCache(cache);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ─────────────────────────────────────────────────────────────
|
|
461
|
+
// Task operations (GitHub Issues)
|
|
462
|
+
// ─────────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
465
|
+
// Always add both TASK (base) and PENDING (status) labels
|
|
466
|
+
const args = [
|
|
467
|
+
'issue',
|
|
468
|
+
'create',
|
|
469
|
+
'--title',
|
|
470
|
+
input.title,
|
|
471
|
+
'--label',
|
|
472
|
+
LABELS.TASK,
|
|
473
|
+
'--label',
|
|
474
|
+
LABELS.PENDING,
|
|
475
|
+
];
|
|
476
|
+
|
|
477
|
+
// Add milestone if specified
|
|
478
|
+
if (input.milestone) {
|
|
479
|
+
const milestoneNum = this.getMilestoneNumber(input.milestone);
|
|
480
|
+
if (milestoneNum) {
|
|
481
|
+
args.push('--milestone', String(milestoneNum));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Add assignee if specified
|
|
486
|
+
if (input.assignee) {
|
|
487
|
+
args.push('--assignee', input.assignee);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Create issue and get its number
|
|
491
|
+
const url = this.gh(args);
|
|
492
|
+
const issueNumber = parseInt(url.split('/').pop() || '0', 10);
|
|
493
|
+
|
|
494
|
+
// Cache the ID mapping
|
|
495
|
+
const cache = this.loadCache();
|
|
496
|
+
const id = String(cache.nextTaskId++);
|
|
497
|
+
cache.tasks[id] = issueNumber;
|
|
498
|
+
this.saveCache(cache);
|
|
499
|
+
|
|
500
|
+
// Fetch the created issue for full details
|
|
501
|
+
const issue = this.ghJson<GHIssue>([
|
|
502
|
+
'issue',
|
|
503
|
+
'view',
|
|
504
|
+
String(issueNumber),
|
|
505
|
+
'--json',
|
|
506
|
+
'number,title,body,state,labels,milestone,assignees,createdAt,updatedAt',
|
|
507
|
+
]);
|
|
508
|
+
|
|
509
|
+
return this.issueToTask(issue, id);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async getTask(id: string): Promise<Task | null> {
|
|
513
|
+
const number = this.getIssueNumber(id);
|
|
514
|
+
if (!number) return null;
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const issue = this.ghJson<GHIssue>([
|
|
518
|
+
'issue',
|
|
519
|
+
'view',
|
|
520
|
+
String(number),
|
|
521
|
+
'--json',
|
|
522
|
+
'number,title,body,state,labels,milestone,assignees,createdAt,updatedAt',
|
|
523
|
+
]);
|
|
524
|
+
return this.issueToTask(issue, id);
|
|
525
|
+
} catch {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async listTasks(filter?: ListTasksFilter): Promise<Task[]> {
|
|
531
|
+
const cache = this.loadCache();
|
|
532
|
+
const args = [
|
|
533
|
+
'issue',
|
|
534
|
+
'list',
|
|
535
|
+
'--json',
|
|
536
|
+
'number,title,body,state,labels,milestone,assignees,createdAt,updatedAt',
|
|
537
|
+
'--limit',
|
|
538
|
+
'1000',
|
|
539
|
+
];
|
|
540
|
+
|
|
541
|
+
// Filter by status
|
|
542
|
+
if (filter?.status === 'done') {
|
|
543
|
+
args.push('--state', 'closed');
|
|
544
|
+
} else if (filter?.status) {
|
|
545
|
+
args.push('--state', 'open');
|
|
546
|
+
args.push('--label', filter.status === 'in_progress' ? LABELS.IN_PROGRESS : LABELS.PENDING);
|
|
547
|
+
} else {
|
|
548
|
+
args.push('--state', 'all');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Filter by milestone
|
|
552
|
+
if (filter?.milestone) {
|
|
553
|
+
const milestoneNum = this.getMilestoneNumber(filter.milestone);
|
|
554
|
+
if (milestoneNum) {
|
|
555
|
+
args.push('--milestone', String(milestoneNum));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Filter by assignee
|
|
560
|
+
if (filter?.assignee) {
|
|
561
|
+
args.push('--assignee', filter.assignee);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Only get tmux-team managed issues (all have TASK label)
|
|
565
|
+
args.push('--label', LABELS.TASK);
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
const issues = this.ghJson<GHIssue[]>(args);
|
|
569
|
+
const tasks: Task[] = [];
|
|
570
|
+
|
|
571
|
+
for (const issue of issues) {
|
|
572
|
+
// Find or create ID mapping
|
|
573
|
+
let id = Object.entries(cache.tasks).find(([, num]) => num === issue.number)?.[0];
|
|
574
|
+
if (!id) {
|
|
575
|
+
id = String(cache.nextTaskId++);
|
|
576
|
+
cache.tasks[id] = issue.number;
|
|
577
|
+
}
|
|
578
|
+
// Pass cache for efficient milestone lookup
|
|
579
|
+
tasks.push(this.issueToTask(issue, id, cache));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
this.saveCache(cache);
|
|
583
|
+
return tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
|
584
|
+
} catch (error) {
|
|
585
|
+
// Surface errors instead of silently returning empty array
|
|
586
|
+
const err = error as Error;
|
|
587
|
+
if (err.message.includes('auth')) {
|
|
588
|
+
throw err; // Re-throw auth errors
|
|
589
|
+
}
|
|
590
|
+
return [];
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async updateTask(id: string, input: UpdateTaskInput): Promise<Task> {
|
|
595
|
+
const number = this.getIssueNumber(id);
|
|
596
|
+
if (!number) throw new Error(`Task ${id} not found`);
|
|
597
|
+
|
|
598
|
+
const args = ['issue', 'edit', String(number)];
|
|
599
|
+
|
|
600
|
+
if (input.title) {
|
|
601
|
+
args.push('--title', input.title);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (input.assignee) {
|
|
605
|
+
args.push('--add-assignee', input.assignee);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (input.milestone) {
|
|
609
|
+
const milestoneNum = this.getMilestoneNumber(input.milestone);
|
|
610
|
+
if (milestoneNum) {
|
|
611
|
+
args.push('--milestone', String(milestoneNum));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Handle status change via labels
|
|
616
|
+
if (input.status) {
|
|
617
|
+
if (input.status === 'done') {
|
|
618
|
+
// Close the issue
|
|
619
|
+
this.gh(['issue', 'close', String(number)]);
|
|
620
|
+
// Remove status labels
|
|
621
|
+
this.gh(['issue', 'edit', String(number), '--remove-label', LABELS.PENDING]);
|
|
622
|
+
this.gh(['issue', 'edit', String(number), '--remove-label', LABELS.IN_PROGRESS]);
|
|
623
|
+
} else {
|
|
624
|
+
// Reopen if needed
|
|
625
|
+
this.gh(['issue', 'reopen', String(number)]);
|
|
626
|
+
// Update labels
|
|
627
|
+
const newLabel = this.statusToLabel(input.status);
|
|
628
|
+
const oldLabel = input.status === 'pending' ? LABELS.IN_PROGRESS : LABELS.PENDING;
|
|
629
|
+
if (newLabel) {
|
|
630
|
+
this.gh([
|
|
631
|
+
'issue',
|
|
632
|
+
'edit',
|
|
633
|
+
String(number),
|
|
634
|
+
'--add-label',
|
|
635
|
+
newLabel,
|
|
636
|
+
'--remove-label',
|
|
637
|
+
oldLabel,
|
|
638
|
+
]);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Apply other edits
|
|
644
|
+
if (args.length > 3) {
|
|
645
|
+
this.gh(args);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Return updated task
|
|
649
|
+
const task = await this.getTask(id);
|
|
650
|
+
if (!task) throw new Error(`Task ${id} not found after update`);
|
|
651
|
+
return task;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async deleteTask(id: string): Promise<void> {
|
|
655
|
+
const number = this.getIssueNumber(id);
|
|
656
|
+
if (!number) return;
|
|
657
|
+
|
|
658
|
+
// Close the issue (GitHub doesn't allow deleting issues via API)
|
|
659
|
+
try {
|
|
660
|
+
this.gh(['issue', 'close', String(number)]);
|
|
661
|
+
// Add a label to indicate it was deleted
|
|
662
|
+
this.gh(['issue', 'edit', String(number), '--add-label', LABELS.DELETED]);
|
|
663
|
+
} catch {
|
|
664
|
+
// Already closed or not found
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Remove from cache
|
|
668
|
+
const cache = this.loadCache();
|
|
669
|
+
delete cache.tasks[id];
|
|
670
|
+
this.saveCache(cache);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ─────────────────────────────────────────────────────────────
|
|
674
|
+
// Documentation (Issue body)
|
|
675
|
+
// ─────────────────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
async getTaskDoc(id: string): Promise<string | null> {
|
|
678
|
+
const number = this.getIssueNumber(id);
|
|
679
|
+
if (!number) return null;
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const issue = this.ghJson<GHIssue>(['issue', 'view', String(number), '--json', 'body']);
|
|
683
|
+
return issue.body || null;
|
|
684
|
+
} catch {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async setTaskDoc(id: string, content: string): Promise<void> {
|
|
690
|
+
const number = this.getIssueNumber(id);
|
|
691
|
+
if (!number) throw new Error(`Task ${id} not found`);
|
|
692
|
+
|
|
693
|
+
this.gh(['issue', 'edit', String(number), '--body', content]);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ─────────────────────────────────────────────────────────────
|
|
697
|
+
// Audit log (Issue Comments)
|
|
698
|
+
// ─────────────────────────────────────────────────────────────
|
|
699
|
+
|
|
700
|
+
async appendEvent(event: AuditEvent): Promise<void> {
|
|
701
|
+
// Also append to local JSONL for offline access
|
|
702
|
+
fs.mkdirSync(this.teamDir, { recursive: true });
|
|
703
|
+
fs.appendFileSync(this.eventsFile, JSON.stringify(event) + '\n', { flag: 'a' });
|
|
704
|
+
|
|
705
|
+
// If event is related to a task, add comment to the issue
|
|
706
|
+
// Events are named: task_created, task_updated, etc.
|
|
707
|
+
if (event.id && event.event.startsWith('task_')) {
|
|
708
|
+
const number = this.getIssueNumber(event.id);
|
|
709
|
+
if (number) {
|
|
710
|
+
// Format a readable comment
|
|
711
|
+
let comment = `[tmux-team] **${event.actor}** - \`${event.event}\``;
|
|
712
|
+
if (event.field && event.from !== undefined && event.to !== undefined) {
|
|
713
|
+
comment += `\n\n${event.field}: ${event.from} → ${event.to}`;
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
this.gh(['issue', 'comment', String(number), '--body', comment]);
|
|
717
|
+
} catch {
|
|
718
|
+
// Ignore comment failures (might be rate limited)
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async getEvents(limit?: number): Promise<AuditEvent[]> {
|
|
725
|
+
// Read from local JSONL (primary source)
|
|
726
|
+
if (!fs.existsSync(this.eventsFile)) return [];
|
|
727
|
+
const lines = fs.readFileSync(this.eventsFile, 'utf-8').trim().split('\n');
|
|
728
|
+
const events: AuditEvent[] = [];
|
|
729
|
+
for (const line of lines) {
|
|
730
|
+
if (line.trim()) {
|
|
731
|
+
try {
|
|
732
|
+
events.push(JSON.parse(line) as AuditEvent);
|
|
733
|
+
} catch {
|
|
734
|
+
// Skip malformed lines
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (limit) {
|
|
739
|
+
return events.slice(-limit);
|
|
740
|
+
}
|
|
741
|
+
return events;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ─────────────────────────────────────────────────────────────
|
|
746
|
+
// Factory function
|
|
747
|
+
// ─────────────────────────────────────────────────────────────
|
|
748
|
+
|
|
749
|
+
export function createGitHubAdapter(teamDir: string, repo: string): StorageAdapter {
|
|
750
|
+
return new GitHubAdapter(teamDir, repo);
|
|
751
|
+
}
|