@f0rbit/overview 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/README.md +242 -0
- package/bunfig.toml +7 -0
- package/package.json +42 -0
- package/packages/core/__tests__/concurrency.test.ts +111 -0
- package/packages/core/__tests__/helpers.ts +60 -0
- package/packages/core/__tests__/integration/git-status.test.ts +62 -0
- package/packages/core/__tests__/integration/scanner.test.ts +140 -0
- package/packages/core/__tests__/ocn.test.ts +164 -0
- package/packages/core/package.json +13 -0
- package/packages/core/src/cache.ts +31 -0
- package/packages/core/src/concurrency.ts +44 -0
- package/packages/core/src/devpad.ts +61 -0
- package/packages/core/src/git-graph.ts +54 -0
- package/packages/core/src/git-stats.ts +201 -0
- package/packages/core/src/git-status.ts +316 -0
- package/packages/core/src/github.ts +286 -0
- package/packages/core/src/index.ts +58 -0
- package/packages/core/src/ocn.ts +74 -0
- package/packages/core/src/scanner.ts +118 -0
- package/packages/core/src/types.ts +199 -0
- package/packages/core/src/watcher.ts +128 -0
- package/packages/core/src/worktree.ts +80 -0
- package/packages/core/tsconfig.json +5 -0
- package/packages/render/bunfig.toml +8 -0
- package/packages/render/jsx-runtime.d.ts +3 -0
- package/packages/render/package.json +18 -0
- package/packages/render/src/components/__tests__/scrollbox-height.test.tsx +780 -0
- package/packages/render/src/components/__tests__/widget-container.integration.test.tsx +304 -0
- package/packages/render/src/components/git-graph.tsx +127 -0
- package/packages/render/src/components/help-overlay.tsx +108 -0
- package/packages/render/src/components/index.ts +7 -0
- package/packages/render/src/components/repo-list.tsx +127 -0
- package/packages/render/src/components/stats-panel.tsx +116 -0
- package/packages/render/src/components/status-badge.tsx +70 -0
- package/packages/render/src/components/status-bar.tsx +56 -0
- package/packages/render/src/components/widget-container.tsx +286 -0
- package/packages/render/src/components/widgets/__tests__/widget-rendering.test.tsx +326 -0
- package/packages/render/src/components/widgets/branch-list.tsx +93 -0
- package/packages/render/src/components/widgets/commit-activity.tsx +112 -0
- package/packages/render/src/components/widgets/devpad-milestones.tsx +88 -0
- package/packages/render/src/components/widgets/devpad-tasks.tsx +81 -0
- package/packages/render/src/components/widgets/file-changes.tsx +78 -0
- package/packages/render/src/components/widgets/git-status.tsx +125 -0
- package/packages/render/src/components/widgets/github-ci.tsx +98 -0
- package/packages/render/src/components/widgets/github-issues.tsx +101 -0
- package/packages/render/src/components/widgets/github-prs.tsx +119 -0
- package/packages/render/src/components/widgets/github-release.tsx +73 -0
- package/packages/render/src/components/widgets/index.ts +12 -0
- package/packages/render/src/components/widgets/recent-commits.tsx +64 -0
- package/packages/render/src/components/widgets/registry.ts +23 -0
- package/packages/render/src/components/widgets/repo-meta.tsx +80 -0
- package/packages/render/src/config/index.ts +104 -0
- package/packages/render/src/lib/__tests__/fetch-context.test.ts +200 -0
- package/packages/render/src/lib/__tests__/widget-grid.test.ts +665 -0
- package/packages/render/src/lib/actions.ts +68 -0
- package/packages/render/src/lib/fetch-context.ts +102 -0
- package/packages/render/src/lib/filter.ts +94 -0
- package/packages/render/src/lib/format.ts +36 -0
- package/packages/render/src/lib/use-devpad.ts +167 -0
- package/packages/render/src/lib/use-github.ts +75 -0
- package/packages/render/src/lib/widget-grid.ts +204 -0
- package/packages/render/src/lib/widget-state.ts +96 -0
- package/packages/render/src/overview.tsx +16 -0
- package/packages/render/src/screens/index.ts +1 -0
- package/packages/render/src/screens/main-screen.tsx +410 -0
- package/packages/render/src/theme/index.ts +37 -0
- package/packages/render/tsconfig.json +9 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { ok, err, type Result } from "@f0rbit/corpus";
|
|
2
|
+
import { basename, relative } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
RepoStatus,
|
|
5
|
+
GitFileChange,
|
|
6
|
+
BranchInfo,
|
|
7
|
+
StashEntry,
|
|
8
|
+
HealthStatus,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
export type GitStatusError =
|
|
12
|
+
| { kind: "not_a_repo"; path: string }
|
|
13
|
+
| { kind: "git_failed"; path: string; command: string; cause: string };
|
|
14
|
+
|
|
15
|
+
async function git(
|
|
16
|
+
args: string[],
|
|
17
|
+
cwd: string,
|
|
18
|
+
): Promise<Result<string, GitStatusError>> {
|
|
19
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
20
|
+
cwd,
|
|
21
|
+
stdout: "pipe",
|
|
22
|
+
stderr: "pipe",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
await proc.exited;
|
|
26
|
+
|
|
27
|
+
if (proc.exitCode !== 0) {
|
|
28
|
+
const stderr = await new Response(proc.stderr).text();
|
|
29
|
+
if (
|
|
30
|
+
stderr.includes("not a git repository") ||
|
|
31
|
+
stderr.includes("not a git repo")
|
|
32
|
+
) {
|
|
33
|
+
return err({ kind: "not_a_repo", path: cwd });
|
|
34
|
+
}
|
|
35
|
+
return err({
|
|
36
|
+
kind: "git_failed",
|
|
37
|
+
path: cwd,
|
|
38
|
+
command: `git ${args.join(" ")}`,
|
|
39
|
+
cause: stderr.trim(),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stdout = await new Response(proc.stdout).text();
|
|
44
|
+
return ok(stdout);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseFileStatus(line: string): GitFileChange | null {
|
|
48
|
+
const first_char = line[0];
|
|
49
|
+
if (!first_char) return null;
|
|
50
|
+
|
|
51
|
+
if (first_char === "?") {
|
|
52
|
+
const path = line.slice(2);
|
|
53
|
+
return { path, status: "untracked", staged: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (first_char === "u") {
|
|
57
|
+
const parts = line.split("\t");
|
|
58
|
+
const path = parts[1] ?? line.split(" ").pop() ?? "";
|
|
59
|
+
return { path, status: "conflicted", staged: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (first_char === "1") {
|
|
63
|
+
const parts = line.split(" ");
|
|
64
|
+
const xy = parts[1] ?? "..";
|
|
65
|
+
const path = line.split("\t")[0]?.split(" ").pop() ?? parts.at(-1) ?? "";
|
|
66
|
+
const staged = xy[0] !== ".";
|
|
67
|
+
const status = parseXY(xy);
|
|
68
|
+
return { path, status, staged };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (first_char === "2") {
|
|
72
|
+
const tab_parts = line.split("\t");
|
|
73
|
+
const path = tab_parts[2] ?? tab_parts[1] ?? "";
|
|
74
|
+
const parts = line.split(" ");
|
|
75
|
+
const xy = parts[1] ?? "..";
|
|
76
|
+
const staged = xy[0] !== ".";
|
|
77
|
+
return { path, status: "renamed", staged };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseXY(
|
|
84
|
+
xy: string,
|
|
85
|
+
): GitFileChange["status"] {
|
|
86
|
+
const x = xy[0] ?? ".";
|
|
87
|
+
const y = xy[1] ?? ".";
|
|
88
|
+
const code = x !== "." ? x : y;
|
|
89
|
+
switch (code) {
|
|
90
|
+
case "A":
|
|
91
|
+
return "added";
|
|
92
|
+
case "D":
|
|
93
|
+
return "deleted";
|
|
94
|
+
case "R":
|
|
95
|
+
return "renamed";
|
|
96
|
+
case "C":
|
|
97
|
+
return "copied";
|
|
98
|
+
case "M":
|
|
99
|
+
return "modified";
|
|
100
|
+
default:
|
|
101
|
+
return "modified";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseStatusPorcelain(raw: string): {
|
|
106
|
+
branch: string;
|
|
107
|
+
ahead: number;
|
|
108
|
+
behind: number;
|
|
109
|
+
changes: GitFileChange[];
|
|
110
|
+
} {
|
|
111
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
112
|
+
let branch = "HEAD";
|
|
113
|
+
let ahead = 0;
|
|
114
|
+
let behind = 0;
|
|
115
|
+
const changes: GitFileChange[] = [];
|
|
116
|
+
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
if (line.startsWith("# branch.head ")) {
|
|
119
|
+
branch = line.slice("# branch.head ".length);
|
|
120
|
+
} else if (line.startsWith("# branch.ab ")) {
|
|
121
|
+
const match = line.match(/\+(\d+) -(\d+)/);
|
|
122
|
+
if (match) {
|
|
123
|
+
ahead = Number.parseInt(match[1] ?? "0", 10);
|
|
124
|
+
behind = Number.parseInt(match[2] ?? "0", 10);
|
|
125
|
+
}
|
|
126
|
+
} else if (line.startsWith("#")) {
|
|
127
|
+
continue;
|
|
128
|
+
} else {
|
|
129
|
+
const change = parseFileStatus(line);
|
|
130
|
+
if (change) changes.push(change);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { branch, ahead, behind, changes };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseStashList(raw: string): StashEntry[] {
|
|
138
|
+
return raw
|
|
139
|
+
.split("\n")
|
|
140
|
+
.filter((l) => l.length > 0)
|
|
141
|
+
.map((line) => {
|
|
142
|
+
const colon_idx = line.indexOf(":");
|
|
143
|
+
const ref = colon_idx >= 0 ? line.slice(0, colon_idx) : line;
|
|
144
|
+
const message = colon_idx >= 0 ? line.slice(colon_idx + 1) : "";
|
|
145
|
+
const index_match = ref.match(/\{(\d+)\}/);
|
|
146
|
+
return {
|
|
147
|
+
index: index_match ? Number.parseInt(index_match[1] ?? "0", 10) : 0,
|
|
148
|
+
message: message.trim(),
|
|
149
|
+
date: "",
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseBranches(raw: string): {
|
|
155
|
+
branches: BranchInfo[];
|
|
156
|
+
local_count: number;
|
|
157
|
+
remote_count: number;
|
|
158
|
+
} {
|
|
159
|
+
const names = raw
|
|
160
|
+
.split("\n")
|
|
161
|
+
.map((l) => l.trim())
|
|
162
|
+
.filter((l) => l.length > 0);
|
|
163
|
+
|
|
164
|
+
const branches: BranchInfo[] = [];
|
|
165
|
+
let local_count = 0;
|
|
166
|
+
let remote_count = 0;
|
|
167
|
+
|
|
168
|
+
for (const name of names) {
|
|
169
|
+
const is_remote = name.includes("/");
|
|
170
|
+
if (is_remote) {
|
|
171
|
+
remote_count++;
|
|
172
|
+
} else {
|
|
173
|
+
local_count++;
|
|
174
|
+
}
|
|
175
|
+
branches.push({
|
|
176
|
+
name,
|
|
177
|
+
is_current: false,
|
|
178
|
+
upstream: null,
|
|
179
|
+
ahead: 0,
|
|
180
|
+
behind: 0,
|
|
181
|
+
last_commit_time: 0,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { branches, local_count, remote_count };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function deriveHealth(
|
|
189
|
+
ahead: number,
|
|
190
|
+
behind: number,
|
|
191
|
+
modified: number,
|
|
192
|
+
staged: number,
|
|
193
|
+
untracked: number,
|
|
194
|
+
conflicts: number,
|
|
195
|
+
): HealthStatus {
|
|
196
|
+
if (conflicts > 0) return "conflict";
|
|
197
|
+
if (ahead > 0 && behind > 0) return "diverged";
|
|
198
|
+
if (ahead > 0) return "ahead";
|
|
199
|
+
if (behind > 0) return "behind";
|
|
200
|
+
if (modified + staged + untracked > 0) return "dirty";
|
|
201
|
+
return "clean";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function collectStatus(
|
|
205
|
+
repoPath: string,
|
|
206
|
+
scanRoot: string,
|
|
207
|
+
): Promise<Result<RepoStatus, GitStatusError>> {
|
|
208
|
+
const [status_result, log_result, stash_result, branches_result, remote_result] =
|
|
209
|
+
await Promise.all([
|
|
210
|
+
git(["status", "--porcelain=v2", "--branch"], repoPath),
|
|
211
|
+
git(["log", "-1", "--format=%H:%s:%at"], repoPath),
|
|
212
|
+
git(["stash", "list", "--format=%gd:%gs"], repoPath),
|
|
213
|
+
git(["branch", "-a", "--format=%(refname:short)"], repoPath),
|
|
214
|
+
git(["remote", "get-url", "origin"], repoPath),
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
if (!status_result.ok) return status_result;
|
|
218
|
+
if (!log_result.ok) return log_result;
|
|
219
|
+
if (!stash_result.ok) return stash_result;
|
|
220
|
+
if (!branches_result.ok) return branches_result;
|
|
221
|
+
|
|
222
|
+
const { branch, ahead, behind, changes } = parseStatusPorcelain(
|
|
223
|
+
status_result.value,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const log_parts = log_result.value.trim().split(":");
|
|
227
|
+
const head_commit = log_parts[0] ?? "";
|
|
228
|
+
const head_message = log_parts.slice(1, -1).join(":") ;
|
|
229
|
+
const head_time = Number.parseInt(log_parts.at(-1) ?? "0", 10);
|
|
230
|
+
|
|
231
|
+
const stashes = parseStashList(stash_result.value);
|
|
232
|
+
const { branches, local_count, remote_count } = parseBranches(
|
|
233
|
+
branches_result.value,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const current_branch =
|
|
237
|
+
branch === "(detached)" || branch === "HEAD" ? "HEAD (detached)" : branch;
|
|
238
|
+
|
|
239
|
+
const current_idx = branches.findIndex(
|
|
240
|
+
(b) => b.name === current_branch || b.name === branch,
|
|
241
|
+
);
|
|
242
|
+
if (current_idx >= 0 && branches[current_idx]) {
|
|
243
|
+
branches[current_idx].is_current = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const remote_url = remote_result.ok ? remote_result.value.trim() || null : null;
|
|
247
|
+
|
|
248
|
+
const modified_count = changes.filter(
|
|
249
|
+
(c) => !c.staged && c.status !== "untracked" && c.status !== "conflicted",
|
|
250
|
+
).length;
|
|
251
|
+
const staged_count = changes.filter((c) => c.staged).length;
|
|
252
|
+
const untracked_count = changes.filter(
|
|
253
|
+
(c) => c.status === "untracked",
|
|
254
|
+
).length;
|
|
255
|
+
const conflict_count = changes.filter(
|
|
256
|
+
(c) => c.status === "conflicted",
|
|
257
|
+
).length;
|
|
258
|
+
|
|
259
|
+
const is_clean =
|
|
260
|
+
modified_count === 0 &&
|
|
261
|
+
staged_count === 0 &&
|
|
262
|
+
untracked_count === 0 &&
|
|
263
|
+
conflict_count === 0 &&
|
|
264
|
+
ahead === 0;
|
|
265
|
+
|
|
266
|
+
const health = deriveHealth(
|
|
267
|
+
ahead,
|
|
268
|
+
behind,
|
|
269
|
+
modified_count,
|
|
270
|
+
staged_count,
|
|
271
|
+
untracked_count,
|
|
272
|
+
conflict_count,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return ok({
|
|
276
|
+
path: repoPath,
|
|
277
|
+
name: basename(repoPath),
|
|
278
|
+
display_path: relative(scanRoot, repoPath),
|
|
279
|
+
|
|
280
|
+
current_branch,
|
|
281
|
+
head_commit,
|
|
282
|
+
head_message,
|
|
283
|
+
head_time,
|
|
284
|
+
|
|
285
|
+
remote_url,
|
|
286
|
+
ahead,
|
|
287
|
+
behind,
|
|
288
|
+
|
|
289
|
+
modified_count,
|
|
290
|
+
staged_count,
|
|
291
|
+
untracked_count,
|
|
292
|
+
conflict_count,
|
|
293
|
+
changes,
|
|
294
|
+
|
|
295
|
+
stash_count: stashes.length,
|
|
296
|
+
stashes,
|
|
297
|
+
|
|
298
|
+
branches,
|
|
299
|
+
local_branch_count: local_count,
|
|
300
|
+
remote_branch_count: remote_count,
|
|
301
|
+
|
|
302
|
+
tags: [],
|
|
303
|
+
total_commits: 0,
|
|
304
|
+
repo_size_bytes: 0,
|
|
305
|
+
contributor_count: 0,
|
|
306
|
+
|
|
307
|
+
recent_commits: [],
|
|
308
|
+
|
|
309
|
+
commit_activity: null,
|
|
310
|
+
|
|
311
|
+
ocn_status: null,
|
|
312
|
+
|
|
313
|
+
is_clean,
|
|
314
|
+
health,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { ok, err, type Result } from "@f0rbit/corpus";
|
|
2
|
+
|
|
3
|
+
export type GithubError =
|
|
4
|
+
| { kind: "not_github_repo" }
|
|
5
|
+
| { kind: "gh_cli_not_found" }
|
|
6
|
+
| { kind: "gh_auth_required" }
|
|
7
|
+
| { kind: "api_error"; cause: string }
|
|
8
|
+
| { kind: "rate_limited" };
|
|
9
|
+
|
|
10
|
+
export interface GithubPR {
|
|
11
|
+
number: number;
|
|
12
|
+
title: string;
|
|
13
|
+
state: string;
|
|
14
|
+
review_decision: string | null;
|
|
15
|
+
ci_status: "success" | "failure" | "pending" | "none";
|
|
16
|
+
is_draft: boolean;
|
|
17
|
+
author: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GithubIssue {
|
|
21
|
+
number: number;
|
|
22
|
+
title: string;
|
|
23
|
+
labels: string[];
|
|
24
|
+
created_at: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GithubWorkflowRun {
|
|
28
|
+
name: string;
|
|
29
|
+
status: string;
|
|
30
|
+
conclusion: string | null;
|
|
31
|
+
head_branch: string;
|
|
32
|
+
duration_seconds: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GithubRelease {
|
|
36
|
+
tag_name: string;
|
|
37
|
+
name: string;
|
|
38
|
+
published_at: string;
|
|
39
|
+
commits_since: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface GithubRepoData {
|
|
43
|
+
prs: GithubPR[];
|
|
44
|
+
issues: GithubIssue[];
|
|
45
|
+
ci_runs: GithubWorkflowRun[];
|
|
46
|
+
latest_release: GithubRelease | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let gh_available: boolean | null = null;
|
|
50
|
+
|
|
51
|
+
export function checkGhAvailable(): boolean {
|
|
52
|
+
if (gh_available === null) {
|
|
53
|
+
gh_available = Bun.which("gh") !== null;
|
|
54
|
+
}
|
|
55
|
+
return gh_available;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isGithubRemote(remote_url: string | null): boolean {
|
|
59
|
+
if (!remote_url) return false;
|
|
60
|
+
return remote_url.includes("github.com");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseGhOwnerRepo(remote_url: string): { owner: string; repo: string } | null {
|
|
64
|
+
const match = remote_url.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
65
|
+
if (match) return { owner: match[1]!, repo: match[2]! };
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safeJsonParse<T>(text: string): Result<T, GithubError> {
|
|
70
|
+
try {
|
|
71
|
+
return ok(JSON.parse(text) as T);
|
|
72
|
+
} catch {
|
|
73
|
+
return err({ kind: "api_error", cause: "invalid JSON response from gh CLI" });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function gh(args: string[], cwd: string): Promise<Result<string, GithubError>> {
|
|
78
|
+
if (!checkGhAvailable()) return err({ kind: "gh_cli_not_found" });
|
|
79
|
+
|
|
80
|
+
const proc = Bun.spawn(["gh", ...args], {
|
|
81
|
+
cwd,
|
|
82
|
+
stdout: "pipe",
|
|
83
|
+
stderr: "pipe",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const stdout = await new Response(proc.stdout).text();
|
|
87
|
+
const stderr = await new Response(proc.stderr).text();
|
|
88
|
+
const exit_code = await proc.exited;
|
|
89
|
+
|
|
90
|
+
if (exit_code !== 0) {
|
|
91
|
+
if (stderr.includes("auth login")) return err({ kind: "gh_auth_required" });
|
|
92
|
+
if (stderr.includes("rate limit")) return err({ kind: "rate_limited" });
|
|
93
|
+
return err({ kind: "api_error", cause: stderr.trim() });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return ok(stdout);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface RawPR {
|
|
100
|
+
number: number;
|
|
101
|
+
title: string;
|
|
102
|
+
state: string;
|
|
103
|
+
reviewDecision: string | null;
|
|
104
|
+
statusCheckRollup: Array<{ state: string; status: string; conclusion: string }> | null;
|
|
105
|
+
isDraft: boolean;
|
|
106
|
+
author: { login: string };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function deriveCiStatus(checks: RawPR["statusCheckRollup"]): GithubPR["ci_status"] {
|
|
110
|
+
if (!checks || checks.length === 0) return "none";
|
|
111
|
+
const has_failure = checks.some(
|
|
112
|
+
(c) => c.conclusion === "FAILURE" || c.conclusion === "failure",
|
|
113
|
+
);
|
|
114
|
+
if (has_failure) return "failure";
|
|
115
|
+
const all_success = checks.every(
|
|
116
|
+
(c) => c.conclusion === "SUCCESS" || c.conclusion === "success",
|
|
117
|
+
);
|
|
118
|
+
if (all_success) return "success";
|
|
119
|
+
return "pending";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function mapPR(raw: RawPR): GithubPR {
|
|
123
|
+
return {
|
|
124
|
+
number: raw.number,
|
|
125
|
+
title: raw.title,
|
|
126
|
+
state: raw.state,
|
|
127
|
+
review_decision: raw.reviewDecision ?? null,
|
|
128
|
+
ci_status: deriveCiStatus(raw.statusCheckRollup),
|
|
129
|
+
is_draft: raw.isDraft,
|
|
130
|
+
author: raw.author?.login ?? "unknown",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function collectPRs(cwd: string): Promise<Result<GithubPR[], GithubError>> {
|
|
135
|
+
const result = await gh(
|
|
136
|
+
["pr", "list", "--json", "number,title,state,reviewDecision,statusCheckRollup,isDraft,author", "--limit", "20"],
|
|
137
|
+
cwd,
|
|
138
|
+
);
|
|
139
|
+
if (!result.ok) return result;
|
|
140
|
+
|
|
141
|
+
const parsed = safeJsonParse<RawPR[]>(result.value);
|
|
142
|
+
if (!parsed.ok) return parsed;
|
|
143
|
+
|
|
144
|
+
return ok(parsed.value.map(mapPR));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface RawIssue {
|
|
148
|
+
number: number;
|
|
149
|
+
title: string;
|
|
150
|
+
labels: Array<{ name: string }>;
|
|
151
|
+
createdAt: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function collectIssues(cwd: string): Promise<Result<GithubIssue[], GithubError>> {
|
|
155
|
+
const result = await gh(
|
|
156
|
+
["issue", "list", "--json", "number,title,labels,createdAt", "--limit", "10"],
|
|
157
|
+
cwd,
|
|
158
|
+
);
|
|
159
|
+
if (!result.ok) return result;
|
|
160
|
+
|
|
161
|
+
const parsed = safeJsonParse<RawIssue[]>(result.value);
|
|
162
|
+
if (!parsed.ok) return parsed;
|
|
163
|
+
|
|
164
|
+
return ok(
|
|
165
|
+
parsed.value.map((raw) => ({
|
|
166
|
+
number: raw.number,
|
|
167
|
+
title: raw.title,
|
|
168
|
+
labels: raw.labels.map((l) => l.name),
|
|
169
|
+
created_at: raw.createdAt,
|
|
170
|
+
})),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface RawWorkflowRun {
|
|
175
|
+
name: string;
|
|
176
|
+
status: string;
|
|
177
|
+
conclusion: string | null;
|
|
178
|
+
headBranch: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function collectCIRuns(cwd: string): Promise<Result<GithubWorkflowRun[], GithubError>> {
|
|
182
|
+
const result = await gh(
|
|
183
|
+
["run", "list", "--json", "name,status,conclusion,headBranch", "--limit", "10"],
|
|
184
|
+
cwd,
|
|
185
|
+
);
|
|
186
|
+
if (!result.ok) return result;
|
|
187
|
+
|
|
188
|
+
const parsed = safeJsonParse<RawWorkflowRun[]>(result.value);
|
|
189
|
+
if (!parsed.ok) return parsed;
|
|
190
|
+
|
|
191
|
+
return ok(
|
|
192
|
+
parsed.value.map((raw) => ({
|
|
193
|
+
name: raw.name,
|
|
194
|
+
status: raw.status,
|
|
195
|
+
conclusion: raw.conclusion ?? null,
|
|
196
|
+
head_branch: raw.headBranch,
|
|
197
|
+
duration_seconds: null,
|
|
198
|
+
})),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface RawRelease {
|
|
203
|
+
tagName: string;
|
|
204
|
+
name: string;
|
|
205
|
+
publishedAt: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function countCommitsSince(tag: string, cwd: string): Promise<number> {
|
|
209
|
+
// Ensure tags are fetched — the release tag may not exist locally
|
|
210
|
+
const fetch_proc = Bun.spawn(["git", "fetch", "--tags", "--quiet"], {
|
|
211
|
+
cwd,
|
|
212
|
+
stdout: "pipe",
|
|
213
|
+
stderr: "pipe",
|
|
214
|
+
});
|
|
215
|
+
await fetch_proc.exited;
|
|
216
|
+
|
|
217
|
+
const proc = Bun.spawn(["git", "rev-list", `${tag}..HEAD`, "--count"], {
|
|
218
|
+
cwd,
|
|
219
|
+
stdout: "pipe",
|
|
220
|
+
stderr: "pipe",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const stdout = await new Response(proc.stdout).text();
|
|
224
|
+
const exit_code = await proc.exited;
|
|
225
|
+
|
|
226
|
+
if (exit_code !== 0) return 0;
|
|
227
|
+
|
|
228
|
+
const count = parseInt(stdout.trim(), 10);
|
|
229
|
+
return Number.isNaN(count) ? 0 : count;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function collectRelease(cwd: string): Promise<Result<GithubRelease | null, GithubError>> {
|
|
233
|
+
const result = await gh(
|
|
234
|
+
["release", "view", "--json", "tagName,publishedAt,name"],
|
|
235
|
+
cwd,
|
|
236
|
+
);
|
|
237
|
+
if (!result.ok) {
|
|
238
|
+
if (result.error.kind === "api_error" && result.error.cause.includes("no releases")) {
|
|
239
|
+
return ok(null);
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const parsed = safeJsonParse<RawRelease>(result.value);
|
|
245
|
+
if (!parsed.ok) return parsed;
|
|
246
|
+
|
|
247
|
+
const raw = parsed.value;
|
|
248
|
+
const commits_since = await countCommitsSince(raw.tagName, cwd);
|
|
249
|
+
|
|
250
|
+
return ok({
|
|
251
|
+
tag_name: raw.tagName,
|
|
252
|
+
name: raw.name,
|
|
253
|
+
published_at: raw.publishedAt,
|
|
254
|
+
commits_since,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isFatalError(error: GithubError): boolean {
|
|
259
|
+
return error.kind === "gh_auth_required" || error.kind === "rate_limited" || error.kind === "gh_cli_not_found";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function collectGithubData(
|
|
263
|
+
repo_path: string,
|
|
264
|
+
remote_url: string | null,
|
|
265
|
+
): Promise<Result<GithubRepoData, GithubError>> {
|
|
266
|
+
if (!isGithubRemote(remote_url)) return err({ kind: "not_github_repo" });
|
|
267
|
+
if (!checkGhAvailable()) return err({ kind: "gh_cli_not_found" });
|
|
268
|
+
|
|
269
|
+
const [prs_result, issues_result, ci_result, release_result] = await Promise.all([
|
|
270
|
+
collectPRs(repo_path),
|
|
271
|
+
collectIssues(repo_path),
|
|
272
|
+
collectCIRuns(repo_path),
|
|
273
|
+
collectRelease(repo_path),
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
const results = [prs_result, issues_result, ci_result, release_result];
|
|
277
|
+
const fatal = results.find((r) => !r.ok && isFatalError(r.error));
|
|
278
|
+
if (fatal && !fatal.ok) return fatal as Result<never, GithubError>;
|
|
279
|
+
|
|
280
|
+
return ok({
|
|
281
|
+
prs: prs_result.ok ? prs_result.value : [],
|
|
282
|
+
issues: issues_result.ok ? issues_result.value : [],
|
|
283
|
+
ci_runs: ci_result.ok ? ci_result.value : [],
|
|
284
|
+
latest_release: release_result.ok ? release_result.value : null,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ok, type Result } from "@f0rbit/corpus";
|
|
2
|
+
import type { RepoNode, OcnStatus } from "./types";
|
|
3
|
+
import { scanDirectory, type ScanError } from "./scanner";
|
|
4
|
+
import { collectStatus } from "./git-status";
|
|
5
|
+
import { detectWorktrees } from "./worktree";
|
|
6
|
+
import { createPool } from "./concurrency";
|
|
7
|
+
import { readOcnStates } from "./ocn";
|
|
8
|
+
|
|
9
|
+
export * from "./types";
|
|
10
|
+
export * from "./scanner";
|
|
11
|
+
export * from "./worktree";
|
|
12
|
+
export * from "./git-status";
|
|
13
|
+
export * from "./git-graph";
|
|
14
|
+
export * from "./git-stats";
|
|
15
|
+
export * from "./watcher";
|
|
16
|
+
export * from "./cache";
|
|
17
|
+
export * from "./github";
|
|
18
|
+
export * from "./devpad";
|
|
19
|
+
export * from "./concurrency";
|
|
20
|
+
export * from "./ocn";
|
|
21
|
+
|
|
22
|
+
export type ScanAndCollectError = ScanError;
|
|
23
|
+
|
|
24
|
+
const pool = createPool(8);
|
|
25
|
+
|
|
26
|
+
async function populateNode(node: RepoNode, scanRoot: string, ocn_map: Map<string, OcnStatus>): Promise<void> {
|
|
27
|
+
if (node.type === "repo" || node.type === "worktree") {
|
|
28
|
+
await pool.run(async () => {
|
|
29
|
+
const [status_result, worktree_result] = await Promise.all([
|
|
30
|
+
collectStatus(node.path, scanRoot),
|
|
31
|
+
detectWorktrees(node.path),
|
|
32
|
+
]);
|
|
33
|
+
node.status = status_result.ok ? status_result.value : null;
|
|
34
|
+
if (node.status) {
|
|
35
|
+
node.status.ocn_status = ocn_map.get(node.path) ?? null;
|
|
36
|
+
}
|
|
37
|
+
node.worktrees = worktree_result.ok ? worktree_result.value : [];
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await Promise.all(node.children.map((child) => populateNode(child, scanRoot, ocn_map)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function scanAndCollect(
|
|
45
|
+
root: string,
|
|
46
|
+
options: { depth: number; ignore: string[] },
|
|
47
|
+
): Promise<Result<RepoNode[], ScanAndCollectError>> {
|
|
48
|
+
const scan_result = await scanDirectory(root, options);
|
|
49
|
+
if (!scan_result.ok) return scan_result;
|
|
50
|
+
|
|
51
|
+
const ocn_result = await readOcnStates();
|
|
52
|
+
const ocn_map = ocn_result.ok ? ocn_result.value : new Map<string, OcnStatus>();
|
|
53
|
+
|
|
54
|
+
const nodes = scan_result.value;
|
|
55
|
+
await Promise.all(nodes.map((node) => populateNode(node, root, ocn_map)));
|
|
56
|
+
|
|
57
|
+
return ok(nodes);
|
|
58
|
+
}
|