@fureworks/scope 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/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +54 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/context.d.ts +6 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/context.js +76 -0
- package/dist/cli/context.js.map +1 -0
- package/dist/cli/daemon.d.ts +2 -0
- package/dist/cli/daemon.d.ts.map +1 -0
- package/dist/cli/daemon.js +190 -0
- package/dist/cli/daemon.js.map +1 -0
- package/dist/cli/onboard.d.ts +2 -0
- package/dist/cli/onboard.d.ts.map +1 -0
- package/dist/cli/onboard.js +286 -0
- package/dist/cli/onboard.js.map +1 -0
- package/dist/cli/status.d.ts +6 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +57 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/switch.d.ts +2 -0
- package/dist/cli/switch.d.ts.map +1 -0
- package/dist/cli/switch.js +78 -0
- package/dist/cli/switch.js.map +1 -0
- package/dist/cli/today.d.ts +7 -0
- package/dist/cli/today.d.ts.map +1 -0
- package/dist/cli/today.js +80 -0
- package/dist/cli/today.js.map +1 -0
- package/dist/engine/prioritize.d.ts +21 -0
- package/dist/engine/prioritize.d.ts.map +1 -0
- package/dist/engine/prioritize.js +204 -0
- package/dist/engine/prioritize.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications/index.d.ts +2 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +41 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/sources/calendar.d.ts +17 -0
- package/dist/sources/calendar.d.ts.map +1 -0
- package/dist/sources/calendar.js +120 -0
- package/dist/sources/calendar.js.map +1 -0
- package/dist/sources/git.d.ts +20 -0
- package/dist/sources/git.d.ts.map +1 -0
- package/dist/sources/git.js +124 -0
- package/dist/sources/git.js.map +1 -0
- package/dist/sources/issues.d.ts +14 -0
- package/dist/sources/issues.d.ts.map +1 -0
- package/dist/sources/issues.js +34 -0
- package/dist/sources/issues.js.map +1 -0
- package/dist/store/config.d.ts +22 -0
- package/dist/store/config.d.ts.map +1 -0
- package/dist/store/config.js +74 -0
- package/dist/store/config.js.map +1 -0
- package/package.json +45 -0
- package/src/cli/config.ts +66 -0
- package/src/cli/context.ts +109 -0
- package/src/cli/daemon.ts +217 -0
- package/src/cli/onboard.ts +335 -0
- package/src/cli/status.ts +77 -0
- package/src/cli/switch.ts +93 -0
- package/src/cli/today.ts +114 -0
- package/src/engine/prioritize.ts +257 -0
- package/src/index.ts +58 -0
- package/src/notifications/index.ts +42 -0
- package/src/sources/calendar.ts +170 -0
- package/src/sources/git.ts +168 -0
- package/src/sources/issues.ts +62 -0
- package/src/store/config.ts +104 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { GitSignal, PRInfo } from "../sources/git.js";
|
|
2
|
+
import { CalendarEvent, FreeBlock } from "../sources/calendar.js";
|
|
3
|
+
import { IssueSignal } from "../sources/issues.js";
|
|
4
|
+
|
|
5
|
+
export type Priority = "now" | "today" | "later";
|
|
6
|
+
|
|
7
|
+
export interface ScoredItem {
|
|
8
|
+
priority: Priority;
|
|
9
|
+
score: number;
|
|
10
|
+
emoji: string;
|
|
11
|
+
label: string;
|
|
12
|
+
detail: string;
|
|
13
|
+
source: "git" | "calendar" | "pr" | "issue";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PrioritizedOutput {
|
|
17
|
+
now: ScoredItem[];
|
|
18
|
+
today: ScoredItem[];
|
|
19
|
+
laterCount: number;
|
|
20
|
+
freeBlocks: FreeBlock[];
|
|
21
|
+
suggestions: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function scorePR(pr: PRInfo, repoName: string): ScoredItem {
|
|
25
|
+
let score = 0;
|
|
26
|
+
const details: string[] = [];
|
|
27
|
+
|
|
28
|
+
// Staleness
|
|
29
|
+
if (pr.ageDays > 14) {
|
|
30
|
+
score += 9; // 2+ weeks = critical
|
|
31
|
+
details.push(`open ${Math.round(pr.ageDays)} days`);
|
|
32
|
+
} else if (pr.ageDays > 5) {
|
|
33
|
+
score += 7;
|
|
34
|
+
details.push(`open ${Math.round(pr.ageDays)} days`);
|
|
35
|
+
} else if (pr.ageDays > 2) {
|
|
36
|
+
score += 4;
|
|
37
|
+
details.push(`open ${Math.round(pr.ageDays)} days`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Blocking potential
|
|
41
|
+
if (pr.reviewRequested) {
|
|
42
|
+
score += 8;
|
|
43
|
+
details.push("review requested");
|
|
44
|
+
}
|
|
45
|
+
if (pr.ciStatus === "fail") {
|
|
46
|
+
score += 5;
|
|
47
|
+
details.push("CI failing");
|
|
48
|
+
}
|
|
49
|
+
if (pr.hasConflicts) {
|
|
50
|
+
score += 4;
|
|
51
|
+
details.push("has conflicts");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const priority: Priority = score >= 8 ? "now" : score >= 4 ? "today" : "later";
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
priority,
|
|
58
|
+
score,
|
|
59
|
+
emoji: priority === "now" ? "🔴" : "🟡",
|
|
60
|
+
label: `PR #${pr.number} on ${repoName}`,
|
|
61
|
+
detail: `${pr.title} — ${details.join(", ")}`,
|
|
62
|
+
source: "pr",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scoreRepoWork(signal: GitSignal): ScoredItem | null {
|
|
67
|
+
if (signal.uncommittedFiles === 0) return null;
|
|
68
|
+
|
|
69
|
+
let score = 0;
|
|
70
|
+
const details: string[] = [];
|
|
71
|
+
|
|
72
|
+
details.push(
|
|
73
|
+
`${signal.uncommittedFiles} uncommitted file${signal.uncommittedFiles > 1 ? "s" : ""}`
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Staleness of uncommitted work
|
|
77
|
+
const days = Math.round(signal.lastCommitAge / 24);
|
|
78
|
+
if (signal.lastCommitAge > 72) {
|
|
79
|
+
score += 9; // 3+ days uncommitted = NOW
|
|
80
|
+
details.push(`last commit ${days}d ago`);
|
|
81
|
+
} else if (signal.lastCommitAge > 24) {
|
|
82
|
+
score += 6;
|
|
83
|
+
details.push(`last commit ${days}d ago`);
|
|
84
|
+
} else if (signal.lastCommitAge > 4) {
|
|
85
|
+
score += 3;
|
|
86
|
+
details.push(`last touched ${Math.round(signal.lastCommitAge)}h ago`);
|
|
87
|
+
} else {
|
|
88
|
+
score += 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (score < 2) return null; // Don't surface fresh uncommitted work
|
|
92
|
+
|
|
93
|
+
const priority: Priority = score >= 8 ? "now" : score >= 4 ? "today" : "later";
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
priority,
|
|
97
|
+
score,
|
|
98
|
+
emoji: priority === "now" ? "🔴" : "🟡",
|
|
99
|
+
label: `${signal.repo}`,
|
|
100
|
+
detail: details.join(", "),
|
|
101
|
+
source: "git",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function scoreCalendarEvent(event: CalendarEvent): ScoredItem | null {
|
|
106
|
+
// Only surface upcoming events (not past ones)
|
|
107
|
+
if (event.minutesUntilStart < -15) return null;
|
|
108
|
+
|
|
109
|
+
let score = 0;
|
|
110
|
+
|
|
111
|
+
if (event.minutesUntilStart <= 60 && event.minutesUntilStart > 0) {
|
|
112
|
+
score += 10;
|
|
113
|
+
} else if (event.minutesUntilStart <= 0 && event.minutesUntilStart > -15) {
|
|
114
|
+
score += 10; // Happening now
|
|
115
|
+
} else {
|
|
116
|
+
score += 5;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const priority: Priority = score >= 8 ? "now" : "today";
|
|
120
|
+
|
|
121
|
+
let timeLabel: string;
|
|
122
|
+
if (event.minutesUntilStart <= 0) {
|
|
123
|
+
timeLabel = "happening now";
|
|
124
|
+
} else if (event.minutesUntilStart < 60) {
|
|
125
|
+
timeLabel = `in ${event.minutesUntilStart} min`;
|
|
126
|
+
} else {
|
|
127
|
+
const hours = Math.floor(event.minutesUntilStart / 60);
|
|
128
|
+
const mins = event.minutesUntilStart % 60;
|
|
129
|
+
timeLabel = `at ${event.startTime.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
|
130
|
+
if (hours > 0) timeLabel += ` (${hours}h${mins > 0 ? `${mins}m` : ""} from now)`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
priority,
|
|
135
|
+
score,
|
|
136
|
+
emoji: "🔴",
|
|
137
|
+
label: `Meeting: ${event.title}`,
|
|
138
|
+
detail: timeLabel,
|
|
139
|
+
source: "calendar",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function scoreIssue(issue: IssueSignal): ScoredItem | null {
|
|
144
|
+
let score = 0;
|
|
145
|
+
const details: string[] = [];
|
|
146
|
+
|
|
147
|
+
if (issue.ageDays > 14) {
|
|
148
|
+
score += 9;
|
|
149
|
+
details.push(`open ${Math.round(issue.ageDays)} days`);
|
|
150
|
+
} else if (issue.ageDays > 7) {
|
|
151
|
+
score += 7;
|
|
152
|
+
details.push(`open ${Math.round(issue.ageDays)} days`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const hasPriorityLabel = issue.labels.some((label) =>
|
|
156
|
+
["urgent", "critical", "bug"].includes(label.toLowerCase())
|
|
157
|
+
);
|
|
158
|
+
if (hasPriorityLabel) {
|
|
159
|
+
score += 3;
|
|
160
|
+
details.push("priority label");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (score === 0) return null;
|
|
164
|
+
|
|
165
|
+
const priority: Priority = score >= 8 ? "now" : score >= 4 ? "today" : "later";
|
|
166
|
+
const labels = issue.labels.length > 0 ? ` [${issue.labels.join(", ")}]` : "";
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
priority,
|
|
170
|
+
score,
|
|
171
|
+
emoji: "📋",
|
|
172
|
+
label: `Issue #${issue.number} on ${issue.repo}`,
|
|
173
|
+
detail: `${issue.title}${labels}${details.length > 0 ? ` — ${details.join(", ")}` : ""}`,
|
|
174
|
+
source: "issue",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function prioritize(
|
|
179
|
+
gitSignals: GitSignal[],
|
|
180
|
+
calendarEvents: CalendarEvent[],
|
|
181
|
+
freeBlocks: FreeBlock[],
|
|
182
|
+
issues: IssueSignal[]
|
|
183
|
+
): PrioritizedOutput {
|
|
184
|
+
const allItems: ScoredItem[] = [];
|
|
185
|
+
|
|
186
|
+
// Score calendar events
|
|
187
|
+
for (const event of calendarEvents) {
|
|
188
|
+
const scored = scoreCalendarEvent(event);
|
|
189
|
+
if (scored) allItems.push(scored);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Score git repos
|
|
193
|
+
for (const signal of gitSignals) {
|
|
194
|
+
// Score uncommitted work
|
|
195
|
+
const repoItem = scoreRepoWork(signal);
|
|
196
|
+
if (repoItem) allItems.push(repoItem);
|
|
197
|
+
|
|
198
|
+
// Score PRs
|
|
199
|
+
for (const pr of signal.openPRs) {
|
|
200
|
+
allItems.push(scorePR(pr, signal.repo));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Score issues
|
|
205
|
+
for (const issue of issues) {
|
|
206
|
+
const scored = scoreIssue(issue);
|
|
207
|
+
if (scored) allItems.push(scored);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Sort by score descending
|
|
211
|
+
allItems.sort((a, b) => b.score - a.score);
|
|
212
|
+
|
|
213
|
+
// Cap output: max 3 NOW items, max 5 TODAY items. Rest goes to later.
|
|
214
|
+
const allNow = allItems.filter((i) => i.priority === "now");
|
|
215
|
+
const allToday = allItems.filter((i) => i.priority === "today");
|
|
216
|
+
const allLater = allItems.filter((i) => i.priority === "later");
|
|
217
|
+
|
|
218
|
+
const now = allNow.slice(0, 3);
|
|
219
|
+
const todayOverflow = allNow.slice(3);
|
|
220
|
+
const today = [...todayOverflow, ...allToday].slice(0, 5);
|
|
221
|
+
const laterCount =
|
|
222
|
+
allLater.length +
|
|
223
|
+
Math.max(0, todayOverflow.length + allToday.length - 5);
|
|
224
|
+
|
|
225
|
+
// Generate suggestions
|
|
226
|
+
const suggestions: string[] = [];
|
|
227
|
+
if (freeBlocks.length > 0) {
|
|
228
|
+
const biggestBlock = freeBlocks.reduce((a, b) =>
|
|
229
|
+
a.durationMinutes > b.durationMinutes ? a : b
|
|
230
|
+
);
|
|
231
|
+
const blockStart = biggestBlock.start.toLocaleTimeString([], {
|
|
232
|
+
hour: "2-digit",
|
|
233
|
+
minute: "2-digit",
|
|
234
|
+
});
|
|
235
|
+
const blockEnd = biggestBlock.end.toLocaleTimeString([], {
|
|
236
|
+
hour: "2-digit",
|
|
237
|
+
minute: "2-digit",
|
|
238
|
+
});
|
|
239
|
+
const hours = Math.floor(biggestBlock.durationMinutes / 60);
|
|
240
|
+
const mins = biggestBlock.durationMinutes % 60;
|
|
241
|
+
const durationStr = hours > 0 ? `${hours}h${mins > 0 ? `${mins}m` : ""}` : `${mins}m`;
|
|
242
|
+
|
|
243
|
+
// Find a good item to suggest for this block
|
|
244
|
+
const deepWorkItem = today.find((i) => i.source === "git");
|
|
245
|
+
if (deepWorkItem) {
|
|
246
|
+
suggestions.push(
|
|
247
|
+
`${durationStr} free block (${blockStart}–${blockEnd}). Good for: ${deepWorkItem.label}`
|
|
248
|
+
);
|
|
249
|
+
} else {
|
|
250
|
+
suggestions.push(
|
|
251
|
+
`${durationStr} free block available (${blockStart}–${blockEnd})`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { now, today, laterCount, freeBlocks, suggestions };
|
|
257
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { todayCommand } from "./cli/today.js";
|
|
5
|
+
import { switchCommand } from "./cli/switch.js";
|
|
6
|
+
import { contextCommand } from "./cli/context.js";
|
|
7
|
+
import { statusCommand } from "./cli/status.js";
|
|
8
|
+
import { onboardCommand } from "./cli/onboard.js";
|
|
9
|
+
import { configCommand } from "./cli/config.js";
|
|
10
|
+
import { daemonCommand } from "./cli/daemon.js";
|
|
11
|
+
|
|
12
|
+
const program = new Command();
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.name("scope")
|
|
16
|
+
.description("Personal ops CLI — focus on what matters.")
|
|
17
|
+
.version("0.1.0");
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.command("today")
|
|
21
|
+
.description("What needs your attention right now")
|
|
22
|
+
.option("--no-calendar", "Skip calendar data")
|
|
23
|
+
.option("--json", "Output as JSON")
|
|
24
|
+
.action(todayCommand);
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command("onboard")
|
|
28
|
+
.description("Guided first-time setup")
|
|
29
|
+
.action(onboardCommand);
|
|
30
|
+
|
|
31
|
+
program
|
|
32
|
+
.command("switch <project>")
|
|
33
|
+
.description("Switch to a project context")
|
|
34
|
+
.action(switchCommand);
|
|
35
|
+
|
|
36
|
+
program
|
|
37
|
+
.command("context")
|
|
38
|
+
.description("Show current project context")
|
|
39
|
+
.option("--edit", "Open scratchpad in $EDITOR")
|
|
40
|
+
.action(contextCommand);
|
|
41
|
+
|
|
42
|
+
program
|
|
43
|
+
.command("status")
|
|
44
|
+
.description("Overview of all watched projects")
|
|
45
|
+
.option("--json", "Output as JSON")
|
|
46
|
+
.action(statusCommand);
|
|
47
|
+
|
|
48
|
+
program
|
|
49
|
+
.command("config [key] [value]")
|
|
50
|
+
.description("View or edit configuration")
|
|
51
|
+
.action(configCommand);
|
|
52
|
+
|
|
53
|
+
program
|
|
54
|
+
.command("daemon <action>")
|
|
55
|
+
.description("Manage background signal checks (start|stop|status)")
|
|
56
|
+
.action(daemonCommand);
|
|
57
|
+
|
|
58
|
+
program.parse();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ensureScopeDir, getScopeDir } from "../store/config.js";
|
|
5
|
+
|
|
6
|
+
function commandExists(command: string): boolean {
|
|
7
|
+
const check = spawnSync("which", [command], { stdio: "pipe" });
|
|
8
|
+
return check.status === 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fallbackNotify(title: string, body: string): void {
|
|
12
|
+
ensureScopeDir();
|
|
13
|
+
const logPath = join(getScopeDir(), "notifications.log");
|
|
14
|
+
appendFileSync(logPath, `[${new Date().toISOString()}] ${title}: ${body}\n`, "utf-8");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function notify(title: string, body: string): void {
|
|
18
|
+
try {
|
|
19
|
+
if (process.platform === "linux") {
|
|
20
|
+
if (commandExists("notify-send")) {
|
|
21
|
+
const result = spawnSync("notify-send", [title, body], { stdio: "pipe" });
|
|
22
|
+
if (result.status === 0) return;
|
|
23
|
+
}
|
|
24
|
+
fallbackNotify(title, body);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (process.platform === "darwin") {
|
|
29
|
+
const escapedTitle = title.replace(/"/g, '\\"');
|
|
30
|
+
const escapedBody = body.replace(/"/g, '\\"');
|
|
31
|
+
const script = `display notification "${escapedBody}" with title "${escapedTitle}"`;
|
|
32
|
+
const result = spawnSync("osascript", ["-e", script], { stdio: "pipe" });
|
|
33
|
+
if (result.status === 0) return;
|
|
34
|
+
fallbackNotify(title, body);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fallbackNotify(title, body);
|
|
39
|
+
} catch {
|
|
40
|
+
fallbackNotify(title, body);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export interface CalendarEvent {
|
|
2
|
+
title: string;
|
|
3
|
+
startTime: Date;
|
|
4
|
+
endTime: Date;
|
|
5
|
+
minutesUntilStart: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CalendarSignal {
|
|
9
|
+
events: CalendarEvent[];
|
|
10
|
+
freeBlocks: FreeBlock[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FreeBlock {
|
|
14
|
+
start: Date;
|
|
15
|
+
end: Date;
|
|
16
|
+
durationMinutes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getCalendarToday(): Promise<CalendarSignal | null> {
|
|
20
|
+
try {
|
|
21
|
+
const { execSync } = await import("node:child_process");
|
|
22
|
+
|
|
23
|
+
// Check if gws is installed
|
|
24
|
+
try {
|
|
25
|
+
execSync("which gws", { stdio: "pipe" });
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Get today's events via gws calendar +agenda --today
|
|
31
|
+
const result = execSync(
|
|
32
|
+
`gws calendar +agenda --today --format json 2>/dev/null`,
|
|
33
|
+
{
|
|
34
|
+
encoding: "utf-8",
|
|
35
|
+
timeout: 15000,
|
|
36
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
let items: Array<{
|
|
41
|
+
summary?: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
start?: string | { dateTime?: string; date?: string };
|
|
44
|
+
end?: string | { dateTime?: string; date?: string };
|
|
45
|
+
startTime?: string;
|
|
46
|
+
endTime?: string;
|
|
47
|
+
}>;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const data = JSON.parse(result);
|
|
51
|
+
// gws may return array directly or nested in a field
|
|
52
|
+
items = Array.isArray(data) ? data : data.items || data.events || [];
|
|
53
|
+
} catch {
|
|
54
|
+
// Try parsing as NDJSON (one JSON object per line)
|
|
55
|
+
items = result
|
|
56
|
+
.trim()
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter((line) => line.trim())
|
|
59
|
+
.flatMap((line) => {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(line);
|
|
62
|
+
// Could be a wrapper with items array or a single event
|
|
63
|
+
if (Array.isArray(parsed)) return parsed;
|
|
64
|
+
if (parsed.items) return parsed.items;
|
|
65
|
+
if (parsed.events) return parsed.events;
|
|
66
|
+
return [parsed];
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
|
|
75
|
+
const events: CalendarEvent[] = items
|
|
76
|
+
.map((item) => {
|
|
77
|
+
// Handle various gws output formats
|
|
78
|
+
const startStr =
|
|
79
|
+
typeof item.start === "string"
|
|
80
|
+
? item.start
|
|
81
|
+
: item.start?.dateTime || item.startTime;
|
|
82
|
+
const endStr =
|
|
83
|
+
typeof item.end === "string"
|
|
84
|
+
? item.end
|
|
85
|
+
: item.end?.dateTime || item.endTime;
|
|
86
|
+
const title = item.summary || item.title || "Untitled event";
|
|
87
|
+
|
|
88
|
+
if (!startStr) return null;
|
|
89
|
+
|
|
90
|
+
const startTime = new Date(startStr);
|
|
91
|
+
const endTime = endStr ? new Date(endStr) : new Date(startTime.getTime() + 60 * 60 * 1000);
|
|
92
|
+
|
|
93
|
+
if (isNaN(startTime.getTime())) return null;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
title,
|
|
97
|
+
startTime,
|
|
98
|
+
endTime,
|
|
99
|
+
minutesUntilStart: Math.round(
|
|
100
|
+
(startTime.getTime() - now) / (1000 * 60)
|
|
101
|
+
),
|
|
102
|
+
};
|
|
103
|
+
})
|
|
104
|
+
.filter((e): e is CalendarEvent => e !== null)
|
|
105
|
+
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
|
106
|
+
|
|
107
|
+
// Calculate free blocks (between events, min 30 min)
|
|
108
|
+
const freeBlocks = calculateFreeBlocks(events, new Date());
|
|
109
|
+
|
|
110
|
+
return { events, freeBlocks };
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function calculateFreeBlocks(
|
|
117
|
+
events: CalendarEvent[],
|
|
118
|
+
today: Date
|
|
119
|
+
): FreeBlock[] {
|
|
120
|
+
const blocks: FreeBlock[] = [];
|
|
121
|
+
const now = new Date();
|
|
122
|
+
|
|
123
|
+
// Working hours: 9am to 6pm
|
|
124
|
+
const workStart = new Date(today);
|
|
125
|
+
workStart.setHours(9, 0, 0, 0);
|
|
126
|
+
const workEnd = new Date(today);
|
|
127
|
+
workEnd.setHours(18, 0, 0, 0);
|
|
128
|
+
|
|
129
|
+
// Start from now or work start, whichever is later
|
|
130
|
+
let cursor = new Date(Math.max(now.getTime(), workStart.getTime()));
|
|
131
|
+
|
|
132
|
+
const futureEvents = events.filter(
|
|
133
|
+
(e) => e.endTime.getTime() > cursor.getTime()
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
for (const event of futureEvents) {
|
|
137
|
+
if (event.startTime.getTime() > cursor.getTime()) {
|
|
138
|
+
const gapMinutes = Math.round(
|
|
139
|
+
(event.startTime.getTime() - cursor.getTime()) / (1000 * 60)
|
|
140
|
+
);
|
|
141
|
+
if (gapMinutes >= 30) {
|
|
142
|
+
blocks.push({
|
|
143
|
+
start: new Date(cursor),
|
|
144
|
+
end: new Date(event.startTime),
|
|
145
|
+
durationMinutes: gapMinutes,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Move cursor past this event
|
|
150
|
+
if (event.endTime.getTime() > cursor.getTime()) {
|
|
151
|
+
cursor = new Date(event.endTime);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check for free time after last event until end of work
|
|
156
|
+
if (cursor.getTime() < workEnd.getTime()) {
|
|
157
|
+
const gapMinutes = Math.round(
|
|
158
|
+
(workEnd.getTime() - cursor.getTime()) / (1000 * 60)
|
|
159
|
+
);
|
|
160
|
+
if (gapMinutes >= 30) {
|
|
161
|
+
blocks.push({
|
|
162
|
+
start: new Date(cursor),
|
|
163
|
+
end: new Date(workEnd),
|
|
164
|
+
durationMinutes: gapMinutes,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return blocks;
|
|
170
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { simpleGit, SimpleGit } from "simple-git";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
export interface GitSignal {
|
|
5
|
+
repo: string;
|
|
6
|
+
branch: string;
|
|
7
|
+
uncommittedFiles: number;
|
|
8
|
+
lastCommitAge: number; // hours since last commit
|
|
9
|
+
staleBranches: string[]; // branches untouched > 3 days
|
|
10
|
+
openPRs: PRInfo[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PRInfo {
|
|
14
|
+
number: number;
|
|
15
|
+
title: string;
|
|
16
|
+
url: string;
|
|
17
|
+
ageDays: number;
|
|
18
|
+
reviewRequested: boolean;
|
|
19
|
+
ciStatus: "pass" | "fail" | "pending" | "unknown";
|
|
20
|
+
hasConflicts: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function scanRepo(repoPath: string): Promise<GitSignal | null> {
|
|
24
|
+
if (!existsSync(repoPath)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const git: SimpleGit = simpleGit(repoPath);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const isRepo = await git.checkIsRepo();
|
|
32
|
+
if (!isRepo) return null;
|
|
33
|
+
|
|
34
|
+
// Get current branch
|
|
35
|
+
const branchInfo = await git.branch();
|
|
36
|
+
const branch = branchInfo.current;
|
|
37
|
+
|
|
38
|
+
// Get uncommitted changes
|
|
39
|
+
const status = await git.status();
|
|
40
|
+
const uncommittedFiles =
|
|
41
|
+
status.modified.length +
|
|
42
|
+
status.not_added.length +
|
|
43
|
+
status.created.length +
|
|
44
|
+
status.deleted.length;
|
|
45
|
+
|
|
46
|
+
// Get last commit time
|
|
47
|
+
let lastCommitAge = 0;
|
|
48
|
+
try {
|
|
49
|
+
const log = await git.log({ maxCount: 1 });
|
|
50
|
+
if (log.latest?.date) {
|
|
51
|
+
const lastCommitDate = new Date(log.latest.date);
|
|
52
|
+
lastCommitAge =
|
|
53
|
+
(Date.now() - lastCommitDate.getTime()) / (1000 * 60 * 60);
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// No commits yet
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get stale branches (untouched > 3 days)
|
|
60
|
+
const staleBranches: string[] = [];
|
|
61
|
+
try {
|
|
62
|
+
const branches = await git.branch(["-a", "--sort=-committerdate"]);
|
|
63
|
+
const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000;
|
|
64
|
+
|
|
65
|
+
for (const branchName of branches.all) {
|
|
66
|
+
if (branchName === branch) continue;
|
|
67
|
+
if (branchName.startsWith("remotes/")) continue;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const branchLog = await git.log({
|
|
71
|
+
maxCount: 1,
|
|
72
|
+
from: branchName,
|
|
73
|
+
});
|
|
74
|
+
if (branchLog.latest?.date) {
|
|
75
|
+
const branchDate = new Date(branchLog.latest.date);
|
|
76
|
+
if (branchDate.getTime() < threeDaysAgo) {
|
|
77
|
+
staleBranches.push(branchName);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Skip branches we can't read
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Branch listing failed
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Get open PRs via gh CLI
|
|
89
|
+
const openPRs = await getOpenPRs(repoPath);
|
|
90
|
+
|
|
91
|
+
const repoName = repoPath.split("/").pop() || repoPath;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
repo: repoName,
|
|
95
|
+
branch,
|
|
96
|
+
uncommittedFiles,
|
|
97
|
+
lastCommitAge,
|
|
98
|
+
staleBranches: staleBranches.slice(0, 5), // Cap at 5
|
|
99
|
+
openPRs,
|
|
100
|
+
};
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function getOpenPRs(repoPath: string): Promise<PRInfo[]> {
|
|
107
|
+
try {
|
|
108
|
+
const { execSync } = await import("node:child_process");
|
|
109
|
+
const result = execSync(
|
|
110
|
+
`gh pr list --json number,title,url,createdAt,reviewRequests,statusCheckRollup,mergeable --limit 10`,
|
|
111
|
+
{
|
|
112
|
+
cwd: repoPath,
|
|
113
|
+
encoding: "utf-8",
|
|
114
|
+
timeout: 10000,
|
|
115
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const prs = JSON.parse(result) as Array<{
|
|
120
|
+
number: number;
|
|
121
|
+
title: string;
|
|
122
|
+
url: string;
|
|
123
|
+
createdAt: string;
|
|
124
|
+
reviewRequests: Array<{ login?: string }>;
|
|
125
|
+
statusCheckRollup: Array<{ conclusion: string }> | null;
|
|
126
|
+
mergeable: string;
|
|
127
|
+
}>;
|
|
128
|
+
|
|
129
|
+
return prs.map((pr) => {
|
|
130
|
+
const ageDays =
|
|
131
|
+
(Date.now() - new Date(pr.createdAt).getTime()) /
|
|
132
|
+
(1000 * 60 * 60 * 24);
|
|
133
|
+
|
|
134
|
+
let ciStatus: PRInfo["ciStatus"] = "unknown";
|
|
135
|
+
if (pr.statusCheckRollup && pr.statusCheckRollup.length > 0) {
|
|
136
|
+
const hasFailure = pr.statusCheckRollup.some(
|
|
137
|
+
(c) => c.conclusion === "FAILURE"
|
|
138
|
+
);
|
|
139
|
+
const allSuccess = pr.statusCheckRollup.every(
|
|
140
|
+
(c) => c.conclusion === "SUCCESS"
|
|
141
|
+
);
|
|
142
|
+
if (hasFailure) ciStatus = "fail";
|
|
143
|
+
else if (allSuccess) ciStatus = "pass";
|
|
144
|
+
else ciStatus = "pending";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
number: pr.number,
|
|
149
|
+
title: pr.title,
|
|
150
|
+
url: pr.url,
|
|
151
|
+
ageDays: Math.round(ageDays * 10) / 10,
|
|
152
|
+
reviewRequested: pr.reviewRequests.length > 0,
|
|
153
|
+
ciStatus,
|
|
154
|
+
hasConflicts: pr.mergeable === "CONFLICTING",
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
} catch {
|
|
158
|
+
// gh CLI not available or not in a repo with remote
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function scanAllRepos(
|
|
164
|
+
repoPaths: string[]
|
|
165
|
+
): Promise<GitSignal[]> {
|
|
166
|
+
const results = await Promise.all(repoPaths.map(scanRepo));
|
|
167
|
+
return results.filter((r): r is GitSignal => r !== null);
|
|
168
|
+
}
|