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