beads-map 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/.next/BUILD_ID +1 -0
- package/.next/app-build-manifest.json +27 -0
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +32 -0
- package/.next/export-marker.json +1 -0
- package/.next/images-manifest.json +1 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +1 -0
- package/.next/react-loadable-manifest.json +8 -0
- package/.next/required-server-files.json +1 -0
- package/.next/routes-manifest.json +1 -0
- package/.next/server/app/_not-found/page.js +1 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +6 -0
- package/.next/server/app/_not-found.rsc +10 -0
- package/.next/server/app/api/beads/route.js +8 -0
- package/.next/server/app/api/beads/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/stream/route.js +10 -0
- package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
- package/.next/server/app/api/beads.body +1 -0
- package/.next/server/app/api/beads.meta +1 -0
- package/.next/server/app/api/config/route.js +8 -0
- package/.next/server/app/api/config/route.js.nft.json +1 -0
- package/.next/server/app/api/config.body +1 -0
- package/.next/server/app/api/config.meta +1 -0
- package/.next/server/app/api/login/route.js +1 -0
- package/.next/server/app/api/login/route.js.nft.json +1 -0
- package/.next/server/app/api/logout/route.js +1 -0
- package/.next/server/app/api/logout/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/callback/route.js +1 -0
- package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
- package/.next/server/app/api/records/route.js +1 -0
- package/.next/server/app/api/records/route.js.nft.json +1 -0
- package/.next/server/app/api/status/route.js +1 -0
- package/.next/server/app/api/status/route.js.nft.json +1 -0
- package/.next/server/app/index.html +1 -0
- package/.next/server/app/index.meta +5 -0
- package/.next/server/app/index.rsc +8 -0
- package/.next/server/app/page.js +24 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app-paths-manifest.json +14 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/251.js +2 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/533.js +38 -0
- package/.next/server/chunks/590.js +6 -0
- package/.next/server/chunks/615.js +15 -0
- package/.next/server/chunks/696.js +25 -0
- package/.next/server/chunks/719.js +2 -0
- package/.next/server/chunks/739.js +1 -0
- package/.next/server/chunks/font-manifest.json +1 -0
- package/.next/server/font-manifest.json +1 -0
- package/.next/server/functions-config-manifest.json +1 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +6 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +1 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +1 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/99eOjoTtoO32H-c1faxZ5/_buildManifest.js +1 -0
- package/.next/static/99eOjoTtoO32H-c1faxZ5/_ssgManifest.js +1 -0
- package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
- package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
- package/.next/static/chunks/666-fb778298a77f3754.js +1 -0
- package/.next/static/chunks/945-bf736d0119e7437b.js +2 -0
- package/.next/static/chunks/app/_not-found/page-b568fd9238f85f27.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/page-49d569c912d5af9d.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-62aa0e18004db880.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
- package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
- package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-c8b9ebfd35ae1d92.js +1 -0
- package/.next/static/css/10ef08b24212fe36.css +3 -0
- package/README.md +243 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +46 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +94 -0
- package/app/api/oauth/client-metadata.json/route.ts +33 -0
- package/app/api/oauth/jwks.json/route.ts +32 -0
- package/app/api/records/route.ts +168 -0
- package/app/api/status/route.ts +25 -0
- package/app/globals.css +192 -0
- package/app/layout.tsx +30 -0
- package/app/page.tsx +1151 -0
- package/bin/beads-map.mjs +175 -0
- package/components/AllCommentsPanel.tsx +265 -0
- package/components/AuthButton.tsx +197 -0
- package/components/BeadsGraph.tsx +1539 -0
- package/components/CommentTooltip.tsx +310 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/NodeDetail.tsx +741 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/hooks/useBeadsComments.ts +412 -0
- package/lib/agent.ts +29 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/diff-beads.ts +125 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +28 -0
- package/lib/parse-beads.ts +232 -0
- package/lib/session.ts +52 -0
- package/lib/timeline.ts +138 -0
- package/lib/types.ts +202 -0
- package/lib/utils.ts +25 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +75 -0
- package/postcss.config.mjs +9 -0
- package/public/image.png +0 -0
- package/scripts/generate-jwk.js +38 -0
- package/tailwind.config.ts +41 -0
- package/tsconfig.json +24 -0
package/lib/discover.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "fs";
|
|
2
|
+
import { join, resolve, dirname, basename, parse as pathParse } from "path";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
|
|
6
|
+
export interface BeadsDiscovery {
|
|
7
|
+
beadsDir: string; // Absolute path to the .beads/ directory
|
|
8
|
+
repoRoot: string; // Parent directory of .beads/
|
|
9
|
+
repoName: string; // Directory name of repoRoot (e.g. 'my-project')
|
|
10
|
+
issuePrefix?: string; // From config.yaml issue-prefix field, if set
|
|
11
|
+
additionalRepos: number; // Count of repos.additional entries (0 for single-repo)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read config.yaml from a .beads directory and extract metadata.
|
|
16
|
+
*/
|
|
17
|
+
function readBeadsConfig(beadsDir: string): {
|
|
18
|
+
issuePrefix?: string;
|
|
19
|
+
additionalRepos: number;
|
|
20
|
+
} {
|
|
21
|
+
const configPath = join(beadsDir, "config.yaml");
|
|
22
|
+
try {
|
|
23
|
+
if (!existsSync(configPath)) return { additionalRepos: 0 };
|
|
24
|
+
const content = readFileSync(configPath, "utf-8");
|
|
25
|
+
const config = parseYaml(content);
|
|
26
|
+
|
|
27
|
+
const issuePrefix = config?.["issue-prefix"] || undefined;
|
|
28
|
+
const additional = config?.repos?.additional;
|
|
29
|
+
const additionalRepos = Array.isArray(additional) ? additional.length : 0;
|
|
30
|
+
|
|
31
|
+
return { issuePrefix, additionalRepos };
|
|
32
|
+
} catch {
|
|
33
|
+
return { additionalRepos: 0 };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Auto-discover the .beads/ directory by walking up from a starting directory,
|
|
39
|
+
* similar to how git finds .git/ or how bd itself finds .beads/.
|
|
40
|
+
*
|
|
41
|
+
* Resolution order:
|
|
42
|
+
* 1. BEADS_DIR environment variable (if set)
|
|
43
|
+
* 2. Walk up from startDir (or process.cwd()) looking for .beads/issues.jsonl
|
|
44
|
+
*
|
|
45
|
+
* @param startDir - Directory to start searching from (defaults to process.cwd())
|
|
46
|
+
* @throws Error if no .beads/ directory is found
|
|
47
|
+
*/
|
|
48
|
+
export function discoverBeadsDir(startDir?: string): BeadsDiscovery {
|
|
49
|
+
// 1. Check BEADS_DIR env var
|
|
50
|
+
const envDir = process.env.BEADS_DIR;
|
|
51
|
+
if (envDir) {
|
|
52
|
+
const resolved = resolve(envDir);
|
|
53
|
+
|
|
54
|
+
// Accept both /path/to/.beads and /path/to/repo (auto-append .beads/)
|
|
55
|
+
let beadsDir: string;
|
|
56
|
+
if (basename(resolved) === ".beads") {
|
|
57
|
+
beadsDir = resolved;
|
|
58
|
+
} else if (existsSync(join(resolved, ".beads"))) {
|
|
59
|
+
beadsDir = join(resolved, ".beads");
|
|
60
|
+
} else {
|
|
61
|
+
// Treat as .beads dir directly even if it doesn't exist yet
|
|
62
|
+
beadsDir = resolved;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
beadsDir = realpathSync(beadsDir);
|
|
67
|
+
} catch {
|
|
68
|
+
// realpathSync fails if path doesn't exist — use as-is
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const repoRoot = dirname(beadsDir);
|
|
72
|
+
const repoName = basename(repoRoot);
|
|
73
|
+
const configData = readBeadsConfig(beadsDir);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
beadsDir,
|
|
77
|
+
repoRoot,
|
|
78
|
+
repoName,
|
|
79
|
+
...configData,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 2. Walk up directory tree from startDir or cwd
|
|
84
|
+
let current = resolve(startDir || process.cwd());
|
|
85
|
+
const { root } = pathParse(current);
|
|
86
|
+
|
|
87
|
+
while (true) {
|
|
88
|
+
const candidate = join(current, ".beads");
|
|
89
|
+
|
|
90
|
+
// Check for .beads/ directory (with or without issues.jsonl — empty projects are valid)
|
|
91
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
|
|
92
|
+
let beadsDir: string;
|
|
93
|
+
try {
|
|
94
|
+
beadsDir = realpathSync(candidate);
|
|
95
|
+
} catch {
|
|
96
|
+
beadsDir = candidate;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const repoRoot = dirname(beadsDir);
|
|
100
|
+
const repoName = basename(repoRoot);
|
|
101
|
+
const configData = readBeadsConfig(beadsDir);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
beadsDir,
|
|
105
|
+
repoRoot,
|
|
106
|
+
repoName,
|
|
107
|
+
...configData,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Reached filesystem root without finding .beads/
|
|
112
|
+
if (current === root) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`No .beads/ directory found.\n` +
|
|
115
|
+
`Searched from: ${resolve(startDir || process.cwd())}\n\n` +
|
|
116
|
+
`To initialize beads in your project, run:\n` +
|
|
117
|
+
` bd init\n\n` +
|
|
118
|
+
`Or set the BEADS_DIR environment variable:\n` +
|
|
119
|
+
` BEADS_DIR=/path/to/project/.beads beads-map`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Move up one level
|
|
124
|
+
current = dirname(current);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Convert a raw git remote URL to a browser-friendly HTTPS URL.
|
|
130
|
+
* Handles: git@github.com:Org/repo.git, https://github.com/Org/repo.git, etc.
|
|
131
|
+
*/
|
|
132
|
+
function gitUrlToHttps(rawUrl: string): string | null {
|
|
133
|
+
let url = rawUrl.trim().replace(/\.git$/, "");
|
|
134
|
+
|
|
135
|
+
// SSH format: git@github.com:Org/repo
|
|
136
|
+
const sshMatch = url.match(/^git@([^:]+):(.+)$/);
|
|
137
|
+
if (sshMatch) {
|
|
138
|
+
return `https://${sshMatch[1]}/${sshMatch[2]}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Already HTTPS
|
|
142
|
+
if (url.startsWith("https://") || url.startsWith("http://")) {
|
|
143
|
+
return url;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get the git remote origin URL for a directory, if it's a git repo.
|
|
151
|
+
*/
|
|
152
|
+
function getGitRemoteUrl(dir: string): string | null {
|
|
153
|
+
try {
|
|
154
|
+
const raw = execSync("git config --get remote.origin.url", {
|
|
155
|
+
cwd: dir,
|
|
156
|
+
encoding: "utf-8",
|
|
157
|
+
timeout: 3000,
|
|
158
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
159
|
+
});
|
|
160
|
+
return gitUrlToHttps(raw);
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build a mapping of repo prefix → GitHub HTTPS URL for all discovered repos.
|
|
168
|
+
* Uses the primary repo + any additional repos from config.yaml.
|
|
169
|
+
*/
|
|
170
|
+
export function getRepoUrls(beadsDir: string): Record<string, string> {
|
|
171
|
+
const urls: Record<string, string> = {};
|
|
172
|
+
|
|
173
|
+
// Primary repo
|
|
174
|
+
const repoRoot = dirname(beadsDir);
|
|
175
|
+
const primaryUrl = getGitRemoteUrl(repoRoot);
|
|
176
|
+
|
|
177
|
+
// Read config to get issue-prefix and additional repos
|
|
178
|
+
const configPath = join(beadsDir, "config.yaml");
|
|
179
|
+
let issuePrefix: string | undefined;
|
|
180
|
+
let additionalPaths: string[] = [];
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
if (existsSync(configPath)) {
|
|
184
|
+
const content = readFileSync(configPath, "utf-8");
|
|
185
|
+
const config = parseYaml(content);
|
|
186
|
+
issuePrefix = config?.["issue-prefix"] || undefined;
|
|
187
|
+
const additional = config?.repos?.additional;
|
|
188
|
+
if (Array.isArray(additional)) {
|
|
189
|
+
additionalPaths = additional
|
|
190
|
+
.map((p: string) => resolve(repoRoot, p))
|
|
191
|
+
.filter((p: string) => existsSync(join(p, ".beads")));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// config.yaml unreadable
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Primary repo: use issue-prefix or directory name as key
|
|
199
|
+
const primaryPrefix = issuePrefix || basename(repoRoot);
|
|
200
|
+
if (primaryUrl) {
|
|
201
|
+
urls[primaryPrefix] = primaryUrl;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Additional repos
|
|
205
|
+
for (const repoPath of additionalPaths) {
|
|
206
|
+
const url = getGitRemoteUrl(repoPath);
|
|
207
|
+
if (!url) continue;
|
|
208
|
+
|
|
209
|
+
// Read that repo's .beads/config.yaml for its issue-prefix
|
|
210
|
+
const subConfigPath = join(repoPath, ".beads", "config.yaml");
|
|
211
|
+
let prefix = basename(repoPath); // fallback: directory name
|
|
212
|
+
try {
|
|
213
|
+
if (existsSync(subConfigPath)) {
|
|
214
|
+
const content = readFileSync(subConfigPath, "utf-8");
|
|
215
|
+
const config = parseYaml(content);
|
|
216
|
+
if (config?.["issue-prefix"]) {
|
|
217
|
+
prefix = config["issue-prefix"];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// use directory name fallback
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
urls[prefix] = url;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return urls;
|
|
228
|
+
}
|
package/lib/env.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variable validation and defaults for ATProto OAuth.
|
|
3
|
+
* Allows missing values in dev mode (defaults kick in).
|
|
4
|
+
*/
|
|
5
|
+
export const env = {
|
|
6
|
+
/** iron-session encryption key (32+ chars) */
|
|
7
|
+
get COOKIE_SECRET(): string {
|
|
8
|
+
return (
|
|
9
|
+
process.env.COOKIE_SECRET ||
|
|
10
|
+
"development-secret-at-least-32-chars!!"
|
|
11
|
+
);
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
/** App's public URL (empty = localhost dev mode with public OAuth client) */
|
|
15
|
+
get PUBLIC_URL(): string {
|
|
16
|
+
return process.env.PUBLIC_URL || "";
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
/** Dev server port */
|
|
20
|
+
get PORT(): number {
|
|
21
|
+
return parseInt(process.env.PORT || "3000", 10);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
/** ES256 JWK private key JSON (empty = public client mode) */
|
|
25
|
+
get ATPROTO_JWK_PRIVATE(): string {
|
|
26
|
+
return process.env.ATPROTO_JWK_PRIVATE || "";
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { discoverBeadsDir } from "./discover";
|
|
5
|
+
import type {
|
|
6
|
+
BeadIssue,
|
|
7
|
+
BeadDependency,
|
|
8
|
+
GraphNode,
|
|
9
|
+
GraphLink,
|
|
10
|
+
GraphData,
|
|
11
|
+
BeadsApiResponse,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract the prefix from a bead ID (e.g. "my-app-5gw" -> "my-app")
|
|
16
|
+
*/
|
|
17
|
+
export function extractPrefix(id: string): string {
|
|
18
|
+
const lastDash = id.lastIndexOf("-");
|
|
19
|
+
if (lastDash === -1) return id;
|
|
20
|
+
return id.substring(0, lastDash);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read repos.additional paths from .beads/config.yaml
|
|
25
|
+
*/
|
|
26
|
+
export function getAdditionalRepoPaths(beadsDir: string): string[] {
|
|
27
|
+
const configPath = join(beadsDir, "config.yaml");
|
|
28
|
+
try {
|
|
29
|
+
const content = readFileSync(configPath, "utf-8");
|
|
30
|
+
const config = parseYaml(content);
|
|
31
|
+
const additional = config?.repos?.additional;
|
|
32
|
+
if (Array.isArray(additional)) {
|
|
33
|
+
return additional
|
|
34
|
+
.map((p: string) => resolve(beadsDir, "..", p))
|
|
35
|
+
.filter((p: string) => existsSync(join(p, ".beads", "issues.jsonl")));
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error("Failed to read config.yaml:", err);
|
|
39
|
+
}
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse a single issues.jsonl file and return issues
|
|
45
|
+
*/
|
|
46
|
+
function parseJsonlFile(filePath: string): BeadIssue[] {
|
|
47
|
+
try {
|
|
48
|
+
const content = readFileSync(filePath, "utf-8");
|
|
49
|
+
const lines = content.split("\n").filter((line) => line.trim().length > 0);
|
|
50
|
+
return lines.map((line) => JSON.parse(line) as BeadIssue);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(`Failed to parse ${filePath}:`, err);
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse issues.jsonl from the primary .beads directory and all additional repos
|
|
59
|
+
*/
|
|
60
|
+
export function parseIssuesJsonl(beadsDir?: string): BeadIssue[] {
|
|
61
|
+
const dir = beadsDir || discoverBeadsDir().beadsDir;
|
|
62
|
+
|
|
63
|
+
// Load primary repo issues
|
|
64
|
+
const primaryPath = join(dir, "issues.jsonl");
|
|
65
|
+
const issues = parseJsonlFile(primaryPath);
|
|
66
|
+
|
|
67
|
+
// Load additional repo issues from config.yaml
|
|
68
|
+
const additionalRepos = getAdditionalRepoPaths(dir);
|
|
69
|
+
for (const repoPath of additionalRepos) {
|
|
70
|
+
const repoJsonl = join(repoPath, ".beads", "issues.jsonl");
|
|
71
|
+
const repoIssues = parseJsonlFile(repoJsonl);
|
|
72
|
+
issues.push(...repoIssues);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Deduplicate by issue ID (primary repo wins) and filter out tombstones.
|
|
76
|
+
// bd delete marks issues with status "tombstone" rather than removing them
|
|
77
|
+
// from the JSONL file — we must exclude these from the graph.
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
return issues.filter((issue) => {
|
|
80
|
+
if (seen.has(issue.id)) return false;
|
|
81
|
+
seen.add(issue.id);
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
if ((issue as any).status === "tombstone") return false;
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract all dependencies from issues (they're embedded in each issue)
|
|
90
|
+
*/
|
|
91
|
+
export function extractDependencies(issues: BeadIssue[]): BeadDependency[] {
|
|
92
|
+
const deps: BeadDependency[] = [];
|
|
93
|
+
const seen = new Set<string>();
|
|
94
|
+
|
|
95
|
+
for (const issue of issues) {
|
|
96
|
+
if (issue.dependencies) {
|
|
97
|
+
for (const dep of issue.dependencies) {
|
|
98
|
+
const key = `${dep.issue_id}->${dep.depends_on_id}:${dep.type}`;
|
|
99
|
+
if (!seen.has(key)) {
|
|
100
|
+
seen.add(key);
|
|
101
|
+
deps.push(dep);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return deps;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build graph data from issues and dependencies
|
|
112
|
+
*/
|
|
113
|
+
export function buildGraphData(
|
|
114
|
+
issues: BeadIssue[],
|
|
115
|
+
dependencies: BeadDependency[]
|
|
116
|
+
): GraphData {
|
|
117
|
+
const issueMap = new Map(issues.map((i) => [i.id, i]));
|
|
118
|
+
|
|
119
|
+
// Count blockers and dependents for each issue
|
|
120
|
+
const blockerCounts = new Map<string, string[]>(); // issue -> issues it blocks
|
|
121
|
+
const dependentCounts = new Map<string, string[]>(); // issue -> issues that block it
|
|
122
|
+
|
|
123
|
+
for (const dep of dependencies) {
|
|
124
|
+
if (dep.type === "blocks" || dep.type === "parent-child") {
|
|
125
|
+
// For blocks: depends_on_id blocks issue_id
|
|
126
|
+
// For parent-child: depends_on_id is parent of issue_id
|
|
127
|
+
// Both count as connections for node sizing
|
|
128
|
+
if (!blockerCounts.has(dep.depends_on_id)) {
|
|
129
|
+
blockerCounts.set(dep.depends_on_id, []);
|
|
130
|
+
}
|
|
131
|
+
blockerCounts.get(dep.depends_on_id)!.push(dep.issue_id);
|
|
132
|
+
|
|
133
|
+
if (!dependentCounts.has(dep.issue_id)) {
|
|
134
|
+
dependentCounts.set(dep.issue_id, []);
|
|
135
|
+
}
|
|
136
|
+
dependentCounts.get(dep.issue_id)!.push(dep.depends_on_id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const nodes: GraphNode[] = issues.map((issue) => ({
|
|
141
|
+
id: issue.id,
|
|
142
|
+
title: issue.title,
|
|
143
|
+
description: issue.description,
|
|
144
|
+
status: issue.status,
|
|
145
|
+
priority: issue.priority,
|
|
146
|
+
issueType: issue.issue_type,
|
|
147
|
+
owner: issue.owner,
|
|
148
|
+
createdAt: issue.created_at,
|
|
149
|
+
updatedAt: issue.updated_at,
|
|
150
|
+
closedAt: issue.closed_at,
|
|
151
|
+
closeReason: issue.close_reason,
|
|
152
|
+
prefix: extractPrefix(issue.id),
|
|
153
|
+
|
|
154
|
+
blockerCount: blockerCounts.get(issue.id)?.length || 0,
|
|
155
|
+
dependentCount: dependentCounts.get(issue.id)?.length || 0,
|
|
156
|
+
blockerIds: blockerCounts.get(issue.id) || [],
|
|
157
|
+
dependentIds: dependentCounts.get(issue.id) || [],
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
// Build links: include blocking AND parent-child dependencies where both nodes exist
|
|
161
|
+
const links: GraphLink[] = dependencies
|
|
162
|
+
.filter(
|
|
163
|
+
(d) =>
|
|
164
|
+
(d.type === "blocks" || d.type === "parent-child") &&
|
|
165
|
+
issueMap.has(d.issue_id) &&
|
|
166
|
+
issueMap.has(d.depends_on_id)
|
|
167
|
+
)
|
|
168
|
+
.map((d) => ({
|
|
169
|
+
// For both types: depends_on_id is the "upstream" node (blocker or parent)
|
|
170
|
+
// blocks: blocker -> blocked
|
|
171
|
+
// parent-child: parent -> child
|
|
172
|
+
source: d.depends_on_id,
|
|
173
|
+
target: d.issue_id,
|
|
174
|
+
type: d.type,
|
|
175
|
+
createdAt: d.created_at,
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
return { nodes, links };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Compute stats from issues and dependencies
|
|
183
|
+
*/
|
|
184
|
+
export function computeStats(
|
|
185
|
+
issues: BeadIssue[],
|
|
186
|
+
dependencies: BeadDependency[],
|
|
187
|
+
graphData: GraphData
|
|
188
|
+
) {
|
|
189
|
+
const prefixes = [...new Set(issues.map((i) => extractPrefix(i.id)))];
|
|
190
|
+
|
|
191
|
+
// An issue is "actionable" if it's open and has no open blockers
|
|
192
|
+
const openIssueIds = new Set(
|
|
193
|
+
issues.filter((i) => i.status !== "closed").map((i) => i.id)
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const blockedByOpen = new Set<string>();
|
|
197
|
+
for (const dep of dependencies) {
|
|
198
|
+
if (dep.type === "blocks" && openIssueIds.has(dep.depends_on_id)) {
|
|
199
|
+
// issue_id is blocked by depends_on_id which is still open
|
|
200
|
+
blockedByOpen.add(dep.issue_id);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const actionable = issues.filter(
|
|
205
|
+
(i) =>
|
|
206
|
+
(i.status === "open" || i.status === "in_progress") &&
|
|
207
|
+
!blockedByOpen.has(i.id)
|
|
208
|
+
).length;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
total: issues.length,
|
|
212
|
+
open: issues.filter((i) => i.status === "open").length,
|
|
213
|
+
inProgress: issues.filter((i) => i.status === "in_progress").length,
|
|
214
|
+
blocked: issues.filter((i) => i.status === "blocked").length,
|
|
215
|
+
closed: issues.filter((i) => i.status === "closed").length,
|
|
216
|
+
actionable,
|
|
217
|
+
edges: graphData.links.length,
|
|
218
|
+
prefixes,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Full pipeline: parse, extract, build, compute
|
|
224
|
+
*/
|
|
225
|
+
export function loadBeadsData(beadsDir?: string): BeadsApiResponse {
|
|
226
|
+
const issues = parseIssuesJsonl(beadsDir);
|
|
227
|
+
const dependencies = extractDependencies(issues);
|
|
228
|
+
const graphData = buildGraphData(issues, dependencies);
|
|
229
|
+
const stats = computeStats(issues, dependencies, graphData);
|
|
230
|
+
|
|
231
|
+
return { issues, dependencies, graphData, stats };
|
|
232
|
+
}
|
package/lib/session.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { env } from "./env";
|
|
2
|
+
import { getIronSession, type SessionOptions } from "iron-session";
|
|
3
|
+
import { cookies } from "next/headers";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Session data stored in the encrypted cookie.
|
|
7
|
+
*/
|
|
8
|
+
export interface Session {
|
|
9
|
+
did?: string;
|
|
10
|
+
handle?: string;
|
|
11
|
+
displayName?: string;
|
|
12
|
+
avatar?: string;
|
|
13
|
+
returnTo?: string;
|
|
14
|
+
// OAuth session data (serialized) - persisted across serverless invocations
|
|
15
|
+
oauthSession?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
19
|
+
|
|
20
|
+
const sessionOptions: SessionOptions = {
|
|
21
|
+
cookieName: "beads_map_sid",
|
|
22
|
+
password: env.COOKIE_SECRET,
|
|
23
|
+
cookieOptions: {
|
|
24
|
+
secure: isProduction,
|
|
25
|
+
maxAge: 60 * 60 * 24 * 30, // 30 days
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the current user's session from their encrypted cookie.
|
|
31
|
+
*/
|
|
32
|
+
export async function getSession(): Promise<Session> {
|
|
33
|
+
const cookieStore = await cookies();
|
|
34
|
+
const session = await getIronSession<Session>(cookieStore, sessionOptions);
|
|
35
|
+
return session;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the raw iron-session object for direct manipulation (save/destroy).
|
|
40
|
+
*/
|
|
41
|
+
export async function getRawSession() {
|
|
42
|
+
const cookieStore = await cookies();
|
|
43
|
+
return await getIronSession<Session>(cookieStore, sessionOptions);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Clear the current user's session.
|
|
48
|
+
*/
|
|
49
|
+
export async function clearSession(): Promise<void> {
|
|
50
|
+
const session = await getRawSession();
|
|
51
|
+
session.destroy();
|
|
52
|
+
}
|
package/lib/timeline.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { GraphNode, GraphLink } from "./types";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Timeline event types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export type TimelineEventType = "node-created" | "node-closed";
|
|
8
|
+
|
|
9
|
+
export interface TimelineEvent {
|
|
10
|
+
time: number; // unix ms
|
|
11
|
+
type: TimelineEventType;
|
|
12
|
+
id: string; // node ID or "source->target"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TimelineRange {
|
|
16
|
+
events: TimelineEvent[];
|
|
17
|
+
minTime: number; // earliest event (unix ms)
|
|
18
|
+
maxTime: number; // latest event (unix ms)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Event extraction
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract all temporal events from nodes and links, sorted chronologically.
|
|
27
|
+
*
|
|
28
|
+
* Events:
|
|
29
|
+
* - node-created: from node.createdAt
|
|
30
|
+
* - node-closed: from node.closedAt (if present)
|
|
31
|
+
* - link-created: from link.createdAt (if present)
|
|
32
|
+
*
|
|
33
|
+
* Nodes/links missing timestamps are skipped.
|
|
34
|
+
*/
|
|
35
|
+
export function buildTimelineEvents(
|
|
36
|
+
nodes: GraphNode[],
|
|
37
|
+
links: GraphLink[]
|
|
38
|
+
): TimelineRange {
|
|
39
|
+
const events: TimelineEvent[] = [];
|
|
40
|
+
|
|
41
|
+
for (const node of nodes) {
|
|
42
|
+
const createdMs = new Date(node.createdAt).getTime();
|
|
43
|
+
if (!isNaN(createdMs)) {
|
|
44
|
+
events.push({ time: createdMs, type: "node-created", id: node.id });
|
|
45
|
+
}
|
|
46
|
+
if (node.closedAt) {
|
|
47
|
+
const closedMs = new Date(node.closedAt).getTime();
|
|
48
|
+
if (!isNaN(closedMs)) {
|
|
49
|
+
events.push({ time: closedMs, type: "node-closed", id: node.id });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
events.sort((a, b) => a.time - b.time);
|
|
55
|
+
|
|
56
|
+
const minTime = events.length > 0 ? events[0].time : Date.now();
|
|
57
|
+
const maxTime =
|
|
58
|
+
events.length > 0 ? events[events.length - 1].time : Date.now();
|
|
59
|
+
|
|
60
|
+
return { events, minTime, maxTime };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Time filtering
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Filter nodes and links to only include items visible at `currentTime`.
|
|
69
|
+
*
|
|
70
|
+
* Node visibility: createdAt <= currentTime.
|
|
71
|
+
* Node status override: if closedAt <= currentTime, force status to "closed".
|
|
72
|
+
* Link visibility: both endpoints visible AND link.createdAt <= currentTime.
|
|
73
|
+
* If link has no createdAt, it appears when both endpoints are visible.
|
|
74
|
+
*
|
|
75
|
+
* Returns shallow copies when status is overridden, original objects otherwise
|
|
76
|
+
* (preserves x/y positions from force simulation).
|
|
77
|
+
*/
|
|
78
|
+
export function filterDataAtTime(
|
|
79
|
+
allNodes: GraphNode[],
|
|
80
|
+
allLinks: GraphLink[],
|
|
81
|
+
currentTime: number
|
|
82
|
+
): { nodes: GraphNode[]; links: GraphLink[] } {
|
|
83
|
+
const visibleNodeIds = new Set<string>();
|
|
84
|
+
const nodes: GraphNode[] = [];
|
|
85
|
+
|
|
86
|
+
for (const node of allNodes) {
|
|
87
|
+
const createdMs = new Date(node.createdAt).getTime();
|
|
88
|
+
if (isNaN(createdMs) || createdMs > currentTime) continue;
|
|
89
|
+
|
|
90
|
+
visibleNodeIds.add(node.id);
|
|
91
|
+
|
|
92
|
+
// Determine correct status at this point in time
|
|
93
|
+
let status = node.status;
|
|
94
|
+
if (node.closedAt) {
|
|
95
|
+
const closedMs = new Date(node.closedAt).getTime();
|
|
96
|
+
if (!isNaN(closedMs) && closedMs <= currentTime) {
|
|
97
|
+
status = "closed";
|
|
98
|
+
} else if (node.status === "closed") {
|
|
99
|
+
// Node is closed in current data but we're before closedAt — show as open
|
|
100
|
+
status = "open";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (status !== node.status) {
|
|
105
|
+
nodes.push({ ...node, status } as GraphNode);
|
|
106
|
+
} else {
|
|
107
|
+
nodes.push(node);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Filter visible links
|
|
112
|
+
const links: GraphLink[] = [];
|
|
113
|
+
for (const link of allLinks) {
|
|
114
|
+
const src =
|
|
115
|
+
typeof link.source === "object"
|
|
116
|
+
? (link.source as { id: string }).id
|
|
117
|
+
: link.source;
|
|
118
|
+
const tgt =
|
|
119
|
+
typeof link.target === "object"
|
|
120
|
+
? (link.target as { id: string }).id
|
|
121
|
+
: link.target;
|
|
122
|
+
|
|
123
|
+
// Both endpoints must be visible — link appears when both nodes are on canvas
|
|
124
|
+
if (!visibleNodeIds.has(src) || !visibleNodeIds.has(tgt)) continue;
|
|
125
|
+
|
|
126
|
+
// Normalize source/target to string IDs — d3-force mutates link objects
|
|
127
|
+
// in-place replacing strings with object refs to the main graph's nodes.
|
|
128
|
+
// We must return fresh objects with string IDs so ForceGraph2D resolves
|
|
129
|
+
// them against the timeline's node array, not the main graph's.
|
|
130
|
+
links.push({
|
|
131
|
+
...link,
|
|
132
|
+
source: src,
|
|
133
|
+
target: tgt,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { nodes, links };
|
|
138
|
+
}
|