heartbeads 0.4.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.
Files changed (205) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +49 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +9 -0
  20. package/.next/server/app/api/auth/route.js +1 -0
  21. package/.next/server/app/api/auth/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/route.js +8 -0
  23. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads/stream/route.js +10 -0
  25. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  26. package/.next/server/app/api/beads.body +1 -0
  27. package/.next/server/app/api/beads.meta +1 -0
  28. package/.next/server/app/api/config/route.js +8 -0
  29. package/.next/server/app/api/config/route.js.nft.json +1 -0
  30. package/.next/server/app/api/docs/page.js +120 -0
  31. package/.next/server/app/api/docs/page.js.nft.json +1 -0
  32. package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/api/docs.html +120 -0
  34. package/.next/server/app/api/docs.meta +5 -0
  35. package/.next/server/app/api/docs.rsc +70 -0
  36. package/.next/server/app/api/login/route.js +1 -0
  37. package/.next/server/app/api/login/route.js.nft.json +1 -0
  38. package/.next/server/app/api/logout/route.js +1 -0
  39. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  40. package/.next/server/app/api/oauth/callback/route.js +1 -0
  41. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  42. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  43. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  44. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  45. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  46. package/.next/server/app/api/records/route.js +1 -0
  47. package/.next/server/app/api/records/route.js.nft.json +1 -0
  48. package/.next/server/app/api/status/route.js +1 -0
  49. package/.next/server/app/api/status/route.js.nft.json +1 -0
  50. package/.next/server/app/api/v1/graph/route.js +1 -0
  51. package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
  52. package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
  53. package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/v1/ready/route.js +1 -0
  55. package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
  56. package/.next/server/app/index.html +1 -0
  57. package/.next/server/app/index.meta +5 -0
  58. package/.next/server/app/index.rsc +9 -0
  59. package/.next/server/app/login/page.js +1 -0
  60. package/.next/server/app/login/page.js.nft.json +1 -0
  61. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/login.html +1 -0
  63. package/.next/server/app/login.meta +5 -0
  64. package/.next/server/app/login.rsc +9 -0
  65. package/.next/server/app/opengraph-image.png/route.js +1 -0
  66. package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
  67. package/.next/server/app/opengraph-image.png.body +0 -0
  68. package/.next/server/app/opengraph-image.png.meta +1 -0
  69. package/.next/server/app/page.js +24 -0
  70. package/.next/server/app/page.js.nft.json +1 -0
  71. package/.next/server/app/page_client-reference-manifest.js +1 -0
  72. package/.next/server/app/twitter-image.png/route.js +1 -0
  73. package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
  74. package/.next/server/app/twitter-image.png.body +0 -0
  75. package/.next/server/app/twitter-image.png.meta +1 -0
  76. package/.next/server/app-paths-manifest.json +22 -0
  77. package/.next/server/chunks/247.js +12 -0
  78. package/.next/server/chunks/29.js +1 -0
  79. package/.next/server/chunks/343.js +1 -0
  80. package/.next/server/chunks/460.js +12 -0
  81. package/.next/server/chunks/533.js +38 -0
  82. package/.next/server/chunks/542.js +27 -0
  83. package/.next/server/chunks/590.js +6 -0
  84. package/.next/server/chunks/615.js +15 -0
  85. package/.next/server/chunks/696.js +25 -0
  86. package/.next/server/chunks/719.js +2 -0
  87. package/.next/server/chunks/739.js +1 -0
  88. package/.next/server/chunks/950.js +2 -0
  89. package/.next/server/chunks/font-manifest.json +1 -0
  90. package/.next/server/edge-runtime-webpack.js +2 -0
  91. package/.next/server/edge-runtime-webpack.js.map +1 -0
  92. package/.next/server/font-manifest.json +1 -0
  93. package/.next/server/functions-config-manifest.json +1 -0
  94. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  95. package/.next/server/middleware-build-manifest.js +1 -0
  96. package/.next/server/middleware-manifest.json +32 -0
  97. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  98. package/.next/server/middleware.js +14 -0
  99. package/.next/server/middleware.js.map +1 -0
  100. package/.next/server/next-font-manifest.js +1 -0
  101. package/.next/server/next-font-manifest.json +1 -0
  102. package/.next/server/pages/404.html +1 -0
  103. package/.next/server/pages/500.html +1 -0
  104. package/.next/server/pages/_app.js +1 -0
  105. package/.next/server/pages/_app.js.nft.json +1 -0
  106. package/.next/server/pages/_document.js +1 -0
  107. package/.next/server/pages/_document.js.nft.json +1 -0
  108. package/.next/server/pages/_error.js +1 -0
  109. package/.next/server/pages/_error.js.nft.json +1 -0
  110. package/.next/server/pages-manifest.json +1 -0
  111. package/.next/server/server-reference-manifest.js +1 -0
  112. package/.next/server/server-reference-manifest.json +1 -0
  113. package/.next/server/webpack-runtime.js +1 -0
  114. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  115. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  116. package/.next/static/chunks/788-aa413085174e935a.js +1 -0
  117. package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
  118. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  119. package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
  120. package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
  121. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  122. package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
  123. package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
  124. package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
  125. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  126. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  127. package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
  128. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  129. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  130. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  131. package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
  132. package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
  133. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
  134. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
  135. package/README.md +389 -0
  136. package/app/api/auth/route.ts +103 -0
  137. package/app/api/beads/route.ts +27 -0
  138. package/app/api/beads/stream/route.ts +83 -0
  139. package/app/api/config/route.ts +48 -0
  140. package/app/api/docs/page.tsx +497 -0
  141. package/app/api/login/route.ts +42 -0
  142. package/app/api/logout/route.ts +14 -0
  143. package/app/api/oauth/callback/route.ts +97 -0
  144. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  145. package/app/api/oauth/jwks.json/route.ts +32 -0
  146. package/app/api/records/route.ts +168 -0
  147. package/app/api/status/route.ts +25 -0
  148. package/app/api/v1/graph/route.ts +251 -0
  149. package/app/api/v1/issues/[id]/route.ts +158 -0
  150. package/app/api/v1/ready/route.ts +229 -0
  151. package/app/globals.css +230 -0
  152. package/app/layout.tsx +51 -0
  153. package/app/login/page.tsx +164 -0
  154. package/app/not-found.tsx +91 -0
  155. package/app/opengraph-image.png +0 -0
  156. package/app/page.tsx +2041 -0
  157. package/app/twitter-image.png +0 -0
  158. package/bin/heartbeads.mjs +225 -0
  159. package/components/ActivityItem.tsx +326 -0
  160. package/components/ActivityOverlay.tsx +125 -0
  161. package/components/ActivityPanel.tsx +345 -0
  162. package/components/AllCommentsPanel.tsx +270 -0
  163. package/components/AuthButton.tsx +202 -0
  164. package/components/BeadTooltip.tsx +246 -0
  165. package/components/BeadsGraph.tsx +2493 -0
  166. package/components/BeadsLogo.tsx +94 -0
  167. package/components/CommentTooltip.tsx +338 -0
  168. package/components/ContextMenu.tsx +272 -0
  169. package/components/DescriptionModal.tsx +595 -0
  170. package/components/GraphStats.tsx +121 -0
  171. package/components/HeartIcon.tsx +33 -0
  172. package/components/HelpPanel.tsx +339 -0
  173. package/components/MobileActionSheet.tsx +255 -0
  174. package/components/NodeDetail.tsx +793 -0
  175. package/components/SettingsModal.tsx +315 -0
  176. package/components/StatusLegend.tsx +99 -0
  177. package/components/TimelineBar.tsx +116 -0
  178. package/components/TutorialOverlay.tsx +235 -0
  179. package/hooks/useBeadsComments.ts +81 -0
  180. package/hooks/useIsMobile.ts +19 -0
  181. package/lib/activity.ts +377 -0
  182. package/lib/agent.ts +29 -0
  183. package/lib/api-helpers.ts +46 -0
  184. package/lib/auth/client.ts +221 -0
  185. package/lib/auth.tsx +159 -0
  186. package/lib/comments.ts +413 -0
  187. package/lib/diff-beads.ts +128 -0
  188. package/lib/discover.ts +228 -0
  189. package/lib/env.ts +33 -0
  190. package/lib/gate.ts +55 -0
  191. package/lib/parse-beads.ts +234 -0
  192. package/lib/session.ts +52 -0
  193. package/lib/settings.ts +42 -0
  194. package/lib/timeline.ts +138 -0
  195. package/lib/tts.ts +397 -0
  196. package/lib/types.ts +271 -0
  197. package/lib/utils.ts +48 -0
  198. package/lib/watch-beads.ts +97 -0
  199. package/next.config.mjs +4 -0
  200. package/package.json +81 -0
  201. package/postcss.config.mjs +9 -0
  202. package/public/image.png +0 -0
  203. package/scripts/generate-jwk.js +38 -0
  204. package/tailwind.config.ts +41 -0
  205. package/tsconfig.json +24 -0
@@ -0,0 +1,33 @@
1
+ import { NextResponse } from "next/server";
2
+ import { env } from "@/lib/env";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET() {
7
+ const publicUrl = env.PUBLIC_URL;
8
+ const url = publicUrl || `http://127.0.0.1:${env.PORT}`;
9
+ const isConfidential = !!publicUrl && !!env.ATPROTO_JWK_PRIVATE;
10
+
11
+ const metadata: Record<string, unknown> = {
12
+ client_name: "Beads Map",
13
+ client_uri: url,
14
+ dpop_bound_access_tokens: true,
15
+ grant_types: ["authorization_code", "refresh_token"],
16
+ response_types: ["code"],
17
+ scope: "atproto transition:generic",
18
+ application_type: "web",
19
+ redirect_uris: [`${url}/api/oauth/callback`],
20
+ };
21
+
22
+ if (isConfidential) {
23
+ metadata.client_id = `${publicUrl}/api/oauth/client-metadata.json`;
24
+ metadata.token_endpoint_auth_method = "private_key_jwt";
25
+ metadata.token_endpoint_auth_signing_alg = "ES256";
26
+ metadata.jwks_uri = `${publicUrl}/api/oauth/jwks.json`;
27
+ } else {
28
+ metadata.client_id = `http://localhost?redirect_uri=${encodeURIComponent(`${url}/api/oauth/callback`)}&scope=${encodeURIComponent("atproto transition:generic")}`;
29
+ metadata.token_endpoint_auth_method = "none";
30
+ }
31
+
32
+ return NextResponse.json(metadata);
33
+ }
@@ -0,0 +1,32 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getJwks } from "@/lib/auth/client";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET() {
7
+ try {
8
+ const jwks = await getJwks();
9
+
10
+ if (!jwks) {
11
+ return NextResponse.json(
12
+ { keys: [] },
13
+ {
14
+ headers: {
15
+ "Content-Type": "application/json",
16
+ "Cache-Control": "public, max-age=3600",
17
+ },
18
+ }
19
+ );
20
+ }
21
+
22
+ return NextResponse.json(jwks, {
23
+ headers: {
24
+ "Content-Type": "application/json",
25
+ "Cache-Control": "public, max-age=3600",
26
+ },
27
+ });
28
+ } catch (error) {
29
+ console.error("Failed to get JWKS:", error);
30
+ return NextResponse.json({ keys: [] }, { status: 500 });
31
+ }
32
+ }
@@ -0,0 +1,168 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getAuthenticatedAgent } from "@/lib/agent";
3
+ import { getSession } from "@/lib/session";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ /**
8
+ * POST /api/records — Create a new record
9
+ * Body: { collection, rkey?, record }
10
+ */
11
+ export async function POST(request: NextRequest) {
12
+ try {
13
+ const session = await getSession();
14
+ if (!session.did) {
15
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
16
+ }
17
+
18
+ const agent = await getAuthenticatedAgent();
19
+ if (!agent) {
20
+ return NextResponse.json(
21
+ { error: "Failed to authenticate" },
22
+ { status: 401 }
23
+ );
24
+ }
25
+
26
+ const body = await request.json();
27
+ const { collection, rkey, record } = body;
28
+
29
+ if (!collection || typeof collection !== "string") {
30
+ return NextResponse.json(
31
+ { error: "Collection is required" },
32
+ { status: 400 }
33
+ );
34
+ }
35
+ if (!record || typeof record !== "object") {
36
+ return NextResponse.json(
37
+ { error: "Record is required" },
38
+ { status: 400 }
39
+ );
40
+ }
41
+
42
+ const res = await agent.com.atproto.repo.createRecord({
43
+ repo: session.did,
44
+ collection,
45
+ rkey: rkey || undefined,
46
+ record,
47
+ });
48
+
49
+ return NextResponse.json({
50
+ success: true,
51
+ uri: res.data.uri,
52
+ cid: res.data.cid,
53
+ });
54
+ } catch (error) {
55
+ console.error("Failed to create record:", error);
56
+ const message =
57
+ error instanceof Error ? error.message : "Failed to create record";
58
+ return NextResponse.json({ error: message }, { status: 500 });
59
+ }
60
+ }
61
+
62
+ /**
63
+ * PUT /api/records — Update an existing record
64
+ * Body: { collection, rkey, record }
65
+ */
66
+ export async function PUT(request: NextRequest) {
67
+ try {
68
+ const session = await getSession();
69
+ if (!session.did) {
70
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
71
+ }
72
+
73
+ const agent = await getAuthenticatedAgent();
74
+ if (!agent) {
75
+ return NextResponse.json(
76
+ { error: "Failed to authenticate" },
77
+ { status: 401 }
78
+ );
79
+ }
80
+
81
+ const body = await request.json();
82
+ const { collection, rkey, record } = body;
83
+
84
+ if (!collection || typeof collection !== "string") {
85
+ return NextResponse.json(
86
+ { error: "Collection is required" },
87
+ { status: 400 }
88
+ );
89
+ }
90
+ if (!rkey || typeof rkey !== "string") {
91
+ return NextResponse.json(
92
+ { error: "Record key is required" },
93
+ { status: 400 }
94
+ );
95
+ }
96
+ if (!record || typeof record !== "object") {
97
+ return NextResponse.json(
98
+ { error: "Record is required" },
99
+ { status: 400 }
100
+ );
101
+ }
102
+
103
+ await agent.com.atproto.repo.putRecord({
104
+ repo: session.did,
105
+ collection,
106
+ rkey,
107
+ record,
108
+ });
109
+
110
+ return NextResponse.json({ success: true });
111
+ } catch (error) {
112
+ console.error("Failed to update record:", error);
113
+ const message =
114
+ error instanceof Error ? error.message : "Failed to update record";
115
+ return NextResponse.json({ error: message }, { status: 500 });
116
+ }
117
+ }
118
+
119
+ /**
120
+ * DELETE /api/records?collection=...&rkey=...
121
+ * Deletes a record from the authenticated user's repo.
122
+ */
123
+ export async function DELETE(request: NextRequest) {
124
+ try {
125
+ const session = await getSession();
126
+ if (!session.did) {
127
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
128
+ }
129
+
130
+ const agent = await getAuthenticatedAgent();
131
+ if (!agent) {
132
+ return NextResponse.json(
133
+ { error: "Failed to authenticate" },
134
+ { status: 401 }
135
+ );
136
+ }
137
+
138
+ const { searchParams } = new URL(request.url);
139
+ const collection = searchParams.get("collection");
140
+ const rkey = searchParams.get("rkey");
141
+
142
+ if (!collection) {
143
+ return NextResponse.json(
144
+ { error: "Collection is required" },
145
+ { status: 400 }
146
+ );
147
+ }
148
+ if (!rkey) {
149
+ return NextResponse.json(
150
+ { error: "Record key is required" },
151
+ { status: 400 }
152
+ );
153
+ }
154
+
155
+ await agent.com.atproto.repo.deleteRecord({
156
+ repo: session.did,
157
+ collection,
158
+ rkey,
159
+ });
160
+
161
+ return NextResponse.json({ success: true });
162
+ } catch (error) {
163
+ console.error("Failed to delete record:", error);
164
+ const message =
165
+ error instanceof Error ? error.message : "Failed to delete record";
166
+ return NextResponse.json({ error: message }, { status: 500 });
167
+ }
168
+ }
@@ -0,0 +1,25 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getSession } from "@/lib/session";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET() {
7
+ try {
8
+ const session = await getSession();
9
+
10
+ if (!session.did) {
11
+ return NextResponse.json({ authenticated: false });
12
+ }
13
+
14
+ return NextResponse.json({
15
+ authenticated: true,
16
+ did: session.did,
17
+ handle: session.handle,
18
+ displayName: session.displayName,
19
+ avatar: session.avatar,
20
+ });
21
+ } catch (error) {
22
+ console.error("Status check failed:", error);
23
+ return NextResponse.json({ authenticated: false });
24
+ }
25
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * GET /api/v1/graph — Full project snapshot for AI agents.
3
+ *
4
+ * Returns the entire beads graph with issues, dependencies, comments,
5
+ * claims, and activity in a single JSON response. No auth required.
6
+ *
7
+ * Query params:
8
+ * ?status=open,in_progress Filter issues by status (comma-separated)
9
+ * ?priority=0,1 Filter by priority (comma-separated)
10
+ * ?prefix=beads-map Filter by repo prefix
11
+ * ?include=comments,activity Opt-in fields (default: all)
12
+ * ?limit=50 Activity feed cap (default 50, max 200)
13
+ */
14
+
15
+ import { loadBeadsData } from "@/lib/parse-beads";
16
+ import { discoverBeadsDir, getRepoUrls } from "@/lib/discover";
17
+ import { fetchBeadsComments, getClaimedNodes } from "@/lib/comments";
18
+ import type { BeadsComment, ClaimInfo } from "@/lib/comments";
19
+ import { buildHistoricalFeed } from "@/lib/activity";
20
+ import type { GraphNode } from "@/lib/types";
21
+ import { jsonResponse, errorResponse, OPTIONS } from "@/lib/api-helpers";
22
+ import { readFileSync, existsSync } from "fs";
23
+ import { join } from "path";
24
+ import { parse as parseYaml } from "yaml";
25
+
26
+ export const dynamic = "force-dynamic";
27
+ export { OPTIONS };
28
+
29
+ // Read version from package.json at module load time
30
+ let heartbeadsVersion = "0.0.0";
31
+ try {
32
+ const pkg = JSON.parse(
33
+ readFileSync(join(process.cwd(), "package.json"), "utf-8")
34
+ );
35
+ heartbeadsVersion = pkg.version || heartbeadsVersion;
36
+ } catch {
37
+ // ignore
38
+ }
39
+
40
+ /** Serialize a BeadsComment tree for API output */
41
+ function serializeComment(c: BeadsComment): Record<string, unknown> {
42
+ return {
43
+ author: {
44
+ handle: c.handle,
45
+ did: c.did,
46
+ ...(c.displayName ? { displayName: c.displayName } : {}),
47
+ },
48
+ text: c.text,
49
+ createdAt: c.createdAt,
50
+ likes: c.likes.length,
51
+ replies: c.replies.map(serializeComment),
52
+ };
53
+ }
54
+
55
+ /** Serialize a claim for API output */
56
+ function serializeClaim(claim: ClaimInfo) {
57
+ return {
58
+ handle: claim.handle,
59
+ did: claim.did,
60
+ ...(claim.displayName ? { displayName: claim.displayName } : {}),
61
+ claimed_at: claim.claimedAt,
62
+ };
63
+ }
64
+
65
+ export async function GET(request: Request) {
66
+ try {
67
+ const discovery = discoverBeadsDir();
68
+ const url = new URL(request.url);
69
+ const params = url.searchParams;
70
+
71
+ // Parse query params
72
+ const statusFilter = params.get("status")?.split(",").filter(Boolean);
73
+ const priorityFilter = params
74
+ .get("priority")
75
+ ?.split(",")
76
+ .map(Number)
77
+ .filter((n) => !isNaN(n));
78
+ const prefixFilter = params.get("prefix") || null;
79
+ const includeParam = params.get("include");
80
+ const limit = Math.min(
81
+ Math.max(parseInt(params.get("limit") || "50") || 50, 1),
82
+ 200
83
+ );
84
+
85
+ // Determine what to include (default: everything)
86
+ let includeComments = true;
87
+ let includeActivity = true;
88
+ if (includeParam !== null) {
89
+ const includes = includeParam.split(",").filter(Boolean);
90
+ includeComments = includes.includes("comments");
91
+ includeActivity = includes.includes("activity");
92
+ }
93
+
94
+ // Load beads data + config in parallel with optional comments
95
+ const beadsPromise = Promise.resolve(loadBeadsData(discovery.beadsDir));
96
+ const commentsPromise = includeComments
97
+ ? fetchBeadsComments().catch((err) => {
98
+ console.error("[api/v1/graph] Failed to fetch comments:", err);
99
+ return null;
100
+ })
101
+ : Promise.resolve(null);
102
+
103
+ const [beadsData, commentsResult] = await Promise.all([
104
+ beadsPromise,
105
+ commentsPromise,
106
+ ]);
107
+
108
+ // Build project metadata
109
+ let repos: string[] = ["."];
110
+ const configPath = join(discovery.beadsDir, "config.yaml");
111
+ try {
112
+ if (existsSync(configPath)) {
113
+ const content = readFileSync(configPath, "utf-8");
114
+ const config = parseYaml(content);
115
+ const additional = config?.repos?.additional;
116
+ if (Array.isArray(additional)) {
117
+ repos = [config?.repos?.primary || ".", ...additional];
118
+ }
119
+ }
120
+ } catch {
121
+ // ignore
122
+ }
123
+
124
+ const repoUrls = getRepoUrls(discovery.beadsDir);
125
+ const claims = commentsResult
126
+ ? getClaimedNodes(commentsResult.allComments)
127
+ : new Map<string, ClaimInfo>();
128
+
129
+ // Build node map for enrichment
130
+ const nodeMap = new Map<string, GraphNode>(
131
+ beadsData.graphData.nodes.map((n) => [n.id, n])
132
+ );
133
+
134
+ // Filter and enrich issues
135
+ let issues = beadsData.graphData.nodes;
136
+
137
+ if (statusFilter) {
138
+ issues = issues.filter((n) => statusFilter.includes(n.status));
139
+ }
140
+ if (priorityFilter) {
141
+ issues = issues.filter((n) => priorityFilter.includes(n.priority));
142
+ }
143
+ if (prefixFilter) {
144
+ issues = issues.filter((n) => n.prefix === prefixFilter);
145
+ }
146
+
147
+ const enrichedIssues = issues.map((node) => {
148
+ const nodeComments =
149
+ commentsResult?.commentsByNode.get(node.id) || [];
150
+ const claim = claims.get(node.id) || null;
151
+
152
+ return {
153
+ id: node.id,
154
+ title: node.title,
155
+ description: node.description || null,
156
+ status: node.status,
157
+ priority: node.priority,
158
+ issue_type: node.issueType,
159
+ owner: node.owner || null,
160
+ assignee: node.assignee || null,
161
+ labels: [] as string[], // labels not on GraphNode, could extend later
162
+ created_at: node.createdAt,
163
+ updated_at: node.updatedAt,
164
+ closed_at: node.closedAt || null,
165
+ close_reason: node.closeReason || null,
166
+ prefix: node.prefix,
167
+ blockers: node.dependentIds, // issues that block this one (upstream)
168
+ dependents: node.blockerIds, // issues this one blocks (downstream)
169
+ ...(includeComments
170
+ ? { comments: nodeComments.map(serializeComment) }
171
+ : {}),
172
+ claimed_by: claim ? serializeClaim(claim) : null,
173
+ };
174
+ });
175
+
176
+ // Build dependencies
177
+ const dependencies = beadsData.graphData.links.map((link) => {
178
+ const src =
179
+ typeof link.source === "object"
180
+ ? (link.source as { id: string }).id
181
+ : link.source;
182
+ const tgt =
183
+ typeof link.target === "object"
184
+ ? (link.target as { id: string }).id
185
+ : link.target;
186
+ return { from: src, to: tgt, type: link.type };
187
+ });
188
+
189
+ // Build activity feed
190
+ let activity: Record<string, unknown>[] = [];
191
+ if (includeActivity) {
192
+ const feed = buildHistoricalFeed(
193
+ beadsData.graphData.nodes,
194
+ beadsData.graphData.links,
195
+ commentsResult?.allComments || null
196
+ );
197
+ activity = feed.slice(0, limit).map((event) => ({
198
+ type: event.type,
199
+ time: new Date(event.time).toISOString(),
200
+ issue_id: event.nodeId,
201
+ ...(event.nodeTitle ? { issue_title: event.nodeTitle } : {}),
202
+ ...(event.actor ? { actor: { handle: event.actor.handle } } : {}),
203
+ ...(event.detail ? { detail: event.detail } : {}),
204
+ }));
205
+ }
206
+
207
+ // Warnings for partial data
208
+ const warnings: string[] = [];
209
+ if (includeComments && !commentsResult) {
210
+ warnings.push("Failed to fetch comments from indexer");
211
+ }
212
+
213
+ return jsonResponse({
214
+ project: {
215
+ name: discovery.issuePrefix || discovery.repoName,
216
+ prefix: discovery.issuePrefix || null,
217
+ repos,
218
+ repoUrls,
219
+ },
220
+ issues: enrichedIssues,
221
+ dependencies,
222
+ stats: {
223
+ total: beadsData.stats.total,
224
+ open: beadsData.stats.open,
225
+ in_progress: beadsData.stats.inProgress,
226
+ blocked: beadsData.stats.blocked,
227
+ closed: beadsData.stats.closed,
228
+ actionable: beadsData.stats.actionable,
229
+ },
230
+ ...(includeActivity ? { activity } : {}),
231
+ _meta: {
232
+ generated_at: new Date().toISOString(),
233
+ api_version: "v1" as const,
234
+ heartbeads_version: heartbeadsVersion,
235
+ ...(warnings.length ? { warnings } : {}),
236
+ },
237
+ });
238
+ } catch (error: unknown) {
239
+ const message =
240
+ error instanceof Error ? error.message : "Unknown error";
241
+ if (message.includes("No .beads/ directory found")) {
242
+ return errorResponse(
243
+ "No .beads directory found",
244
+ 404,
245
+ "Run bd init in your project first, or set BEADS_DIR."
246
+ );
247
+ }
248
+ console.error("[api/v1/graph] Error:", error);
249
+ return errorResponse("Internal server error", 500);
250
+ }
251
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * GET /api/v1/issues/:id — Single issue detail for AI agents.
3
+ *
4
+ * Returns one issue with full description, threaded comments,
5
+ * claim info, and enriched blockers/dependents (with title + status).
6
+ * No auth required.
7
+ */
8
+
9
+ import { loadBeadsData } from "@/lib/parse-beads";
10
+ import { discoverBeadsDir } from "@/lib/discover";
11
+ import { fetchBeadsComments, getClaimedNodes } from "@/lib/comments";
12
+ import type { BeadsComment, ClaimInfo } from "@/lib/comments";
13
+ import type { GraphNode } from "@/lib/types";
14
+ import { jsonResponse, errorResponse, OPTIONS } from "@/lib/api-helpers";
15
+ import { readFileSync } from "fs";
16
+ import { join } from "path";
17
+
18
+ export const dynamic = "force-dynamic";
19
+ export { OPTIONS };
20
+
21
+ let heartbeadsVersion = "0.0.0";
22
+ try {
23
+ const pkg = JSON.parse(
24
+ readFileSync(join(process.cwd(), "package.json"), "utf-8")
25
+ );
26
+ heartbeadsVersion = pkg.version || heartbeadsVersion;
27
+ } catch {
28
+ // ignore
29
+ }
30
+
31
+ function serializeComment(c: BeadsComment): Record<string, unknown> {
32
+ return {
33
+ author: {
34
+ handle: c.handle,
35
+ did: c.did,
36
+ ...(c.displayName ? { displayName: c.displayName } : {}),
37
+ },
38
+ text: c.text,
39
+ createdAt: c.createdAt,
40
+ likes: c.likes.length,
41
+ replies: c.replies.map(serializeComment),
42
+ };
43
+ }
44
+
45
+ export async function GET(
46
+ request: Request,
47
+ { params }: { params: { id: string } }
48
+ ) {
49
+ try {
50
+ const discovery = discoverBeadsDir();
51
+ const issueId = params.id;
52
+
53
+ // Load beads data + comments in parallel
54
+ const [beadsData, commentsResult] = await Promise.all([
55
+ Promise.resolve(loadBeadsData(discovery.beadsDir)),
56
+ fetchBeadsComments().catch((err) => {
57
+ console.error("[api/v1/issues] Failed to fetch comments:", err);
58
+ return null;
59
+ }),
60
+ ]);
61
+
62
+ // Find the issue
63
+ const nodeMap = new Map<string, GraphNode>(
64
+ beadsData.graphData.nodes.map((n) => [n.id, n])
65
+ );
66
+ const node = nodeMap.get(issueId);
67
+
68
+ if (!node) {
69
+ return errorResponse(
70
+ "Issue not found",
71
+ 404,
72
+ `No issue with id "${issueId}". Use GET /api/v1/graph to list all issues.`
73
+ );
74
+ }
75
+
76
+ // Build enriched blockers/dependents with title + status
77
+ // dependentIds = issues that block this one (upstream)
78
+ // blockerIds = issues this one blocks (downstream)
79
+ const enrichedBlockers = node.dependentIds
80
+ .map((id) => {
81
+ const n = nodeMap.get(id);
82
+ return n
83
+ ? { id: n.id, title: n.title, status: n.status }
84
+ : { id, title: null, status: null };
85
+ });
86
+
87
+ const enrichedDependents = node.blockerIds
88
+ .map((id) => {
89
+ const n = nodeMap.get(id);
90
+ return n
91
+ ? { id: n.id, title: n.title, status: n.status }
92
+ : { id, title: null, status: null };
93
+ });
94
+
95
+ // Comments and claims
96
+ const nodeComments =
97
+ commentsResult?.commentsByNode.get(issueId) || [];
98
+ const claims = commentsResult
99
+ ? getClaimedNodes(commentsResult.allComments)
100
+ : new Map<string, ClaimInfo>();
101
+ const claim = claims.get(issueId) || null;
102
+
103
+ const warnings: string[] = [];
104
+ if (!commentsResult) {
105
+ warnings.push("Failed to fetch comments from indexer");
106
+ }
107
+
108
+ return jsonResponse({
109
+ issue: {
110
+ id: node.id,
111
+ title: node.title,
112
+ description: node.description || null,
113
+ status: node.status,
114
+ priority: node.priority,
115
+ issue_type: node.issueType,
116
+ owner: node.owner || null,
117
+ assignee: node.assignee || null,
118
+ labels: [] as string[],
119
+ created_at: node.createdAt,
120
+ updated_at: node.updatedAt,
121
+ closed_at: node.closedAt || null,
122
+ close_reason: node.closeReason || null,
123
+ prefix: node.prefix,
124
+ blockers: enrichedBlockers,
125
+ dependents: enrichedDependents,
126
+ comments: nodeComments.map(serializeComment),
127
+ claimed_by: claim
128
+ ? {
129
+ handle: claim.handle,
130
+ did: claim.did,
131
+ ...(claim.displayName
132
+ ? { displayName: claim.displayName }
133
+ : {}),
134
+ claimed_at: claim.claimedAt,
135
+ }
136
+ : null,
137
+ },
138
+ _meta: {
139
+ generated_at: new Date().toISOString(),
140
+ api_version: "v1" as const,
141
+ heartbeads_version: heartbeadsVersion,
142
+ ...(warnings.length ? { warnings } : {}),
143
+ },
144
+ });
145
+ } catch (error: unknown) {
146
+ const message =
147
+ error instanceof Error ? error.message : "Unknown error";
148
+ if (message.includes("No .beads/ directory found")) {
149
+ return errorResponse(
150
+ "No .beads directory found",
151
+ 404,
152
+ "Run bd init in your project first, or set BEADS_DIR."
153
+ );
154
+ }
155
+ console.error("[api/v1/issues] Error:", error);
156
+ return errorResponse("Internal server error", 500);
157
+ }
158
+ }