diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml
new file mode 100644
index 0000000..4ea72a9
--- /dev/null
+++ b/.idea/copilot.data.migration.agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml
new file mode 100644
index 0000000..7ef04e2
--- /dev/null
+++ b/.idea/copilot.data.migration.ask.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml
new file mode 100644
index 0000000..1f2ea11
--- /dev/null
+++ b/.idea/copilot.data.migration.ask2agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml
new file mode 100644
index 0000000..8648f94
--- /dev/null
+++ b/.idea/copilot.data.migration.edit.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..feab0f2
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/involutionhell.github.io.iml b/.idea/involutionhell.github.io.iml
new file mode 100644
index 0000000..24643cc
--- /dev/null
+++ b/.idea/involutionhell.github.io.iml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..5027f88
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 9c56ecf..3c57604 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -5,7 +5,10 @@
-
+
+
+
+
@@ -16,6 +19,7 @@
+
@@ -28,12 +32,12 @@
"assignee": "longsizhuo"
}
}
- {
- "selectedUrlAndAccountId": {
- "url": "git@github.com:InvolutionHell/involutionhell.github.io.git",
- "accountId": "7e76acee-1bc6-4dbc-85cc-b6dd67159f8d"
+
+}]]>
{
"associatedIndex": 5
}
@@ -47,21 +51,23 @@
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
- "SHARE_PROJECT_CONFIGURATION_FILES": "true",
- "git-widget-placeholder": "feat/cuid",
+ "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
+ "git-widget-placeholder": "main",
"js.debugger.nextJs.config.created.client": "true",
"js.debugger.nextJs.config.created.server": "true",
"junie.onboarding.icon.badge.shown": "true",
- "last_opened_file_path": "E:/involutionhell.github.io",
+ "last_opened_file_path": "/Users/longsizhuo/involutionhell.github.io",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
- "nodejs_interpreter_path": "C:/Users/longs/AppData/Roaming/JetBrains/WebStorm2025.1/node/versions/22.20.0/node",
+ "nodejs_interpreter_path": "node",
"nodejs_package_manager_path": "pnpm",
"settings.editor.selected.configurable": "preferences.fileTypes",
- "ts.external.directory.path": "E:\\involutionhell.github.io\\node_modules\\typescript\\lib",
+ "to.speed.mode.migration.done": "true",
+ "ts.external.directory.path": "/Users/longsizhuo/involutionhell.github.io/node_modules/typescript/lib",
"vue.rearranger.settings.migration": "true"
},
"keyToStringList": {
@@ -104,7 +110,7 @@
-
+
@@ -117,6 +123,7 @@
1758971836294
+
diff --git a/AGENT.md b/AGENT.md
index 48d4c61..b3f04e6 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -1,36 +1,37 @@
-# AGENT Guidelines
+# AGENT Log
-Welcome! This repository hosts the Involution Hell documentation site, built with Next.js App Router, Fumadocs UI, TypeScript, and Tailwind CSS. Follow the instructions below whenever you contribute changes.
+## Objective
-## General workflow
+- Build a custom Giscus-like comment widget for the site, backed by GitHub Discussions via the GitHub API.
-- Prefer `pnpm` for all Node.js commands (`pnpm install`, `pnpm dev`, `pnpm build`, etc.).
-- Keep changes focused and provide helpful descriptions in commits and PR messages.
-- When adding dependencies, ensure they are compatible with Node.js 18+.
+## Context
-## Coding standards
+- Issue tracker reference: https://github.com/InvolutionHell/involutionhell.github.io/issues/200
+- Prior attempt for inspiration: https://github.com/InvolutionHell/involutionhell.github.io/pull/210
+- Storage will stay in GitHub Discussions; authentication uses NextAuth with GitHub; backend will read/write via GitHub GraphQL; each page already has a `docId` (cuid2) mapping; notifications remain within GitHub.
-- Follow existing patterns in the codebase; align new components with the established structure under `app/` and `components/`.
-- Use TypeScript (`.ts` / `.tsx`) and Tailwind CSS utility classes for styling unless a file already uses a different approach.
-- Avoid unnecessary abstractions; keep components small, composable, and accessible.
-- Do not wrap imports in `try/catch` blocks.
+## Plan of Attack
-## Documentation & content
+1. Audit the current site implementation to understand where the new discussion widget must hook in. **Status:** TODO
+2. Define the backend interface for reading/writing discussion threads keyed by `docId`. **Status:** TODO
+3. Implement Next.js route handlers that proxy to the GitHub GraphQL API with the required auth. **Status:** TODO
+4. Develop the front-end discussion component that mirrors Giscus UX but targets our custom API. **Status:** TODO
+5. Integrate GitHub login flow with NextAuth to gate posting while allowing read access. **Status:** TODO
+6. Validate end-to-end (local + production build) and document deployment steps. **Status:** TODO
-- All documentation lives under `app/docs/` (folder-as-book). Each Markdown/MDX file **must** retain a frontmatter block with at least a `title`.
-- Place images referenced by a document inside the document’s sibling `*.assets/` folder. Use the provided image migration scripts if needed.
-- Prefer relative links within the docs unless cross-referencing an external resource.
+## Current Progress
-## Testing & validation
+- Schema review confirms `docs` table stores each `docId` (cuid2) keyed to contributors; `doc_contributors` joins by `doc_id` + `github_id`; `doc_paths` tracks current routes. Env vars for Neon/GitHub live in `.env`.
+- `app/docs/[...slug]/page.tsx` 读取 frontmatter 中的 `docId`,传入 `GiscusComments`,同时利用 `lib/contributors.ts` 通过 docId 读取贡献者数据。
+- `GiscusComments` 组件基于 `docId` 切换 Giscus `mapping="specific"`;替换该组件将是我们嵌入自研讨论区的主要入口。
-- Run relevant scripts before submitting changes. Common checks include:
- - `pnpm dev` for local verification.
- - `pnpm build` for production validation when you touch runtime logic.
- - `pnpm lint:images` when you add or move media assets.
+## Open Questions / Risks
-## PR expectations
+- Need to confirm where `docId` values are stored/exposed in the current content pipeline.
+- Must verify available GitHub tokens/permissions for server-side API access and rate limits.
-- Summarize user-facing changes clearly.
-- Mention any new scripts, configuration, or docs that reviewers should be aware of.
+## Next Actions
-For additional details, consult `README.md` and `CONTRIBUTING.md`.
+1. 列出后端接口与会话处理的详细任务(包括服务端 token 读取与用户 token 代理策略)。
+2. 拟定前端评论组件的状态流与 UI 子任务(加载、分页、发送评论、鉴权提示等)。
+3. 规划必要的测试/脚本(GraphQL query mock、端到端自测步骤)。
diff --git a/app/api/discussions/[docId]/comments/route.ts b/app/api/discussions/[docId]/comments/route.ts
new file mode 100644
index 0000000..64827c0
--- /dev/null
+++ b/app/api/discussions/[docId]/comments/route.ts
@@ -0,0 +1,116 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/auth";
+import {
+ addDiscussionComment,
+ ensureDiscussionForDoc,
+ fetchDiscussionWithComments,
+ getServerGitHubToken,
+ GitHubDiscussionError,
+} from "@/lib/discussion/github-discussions";
+
+interface RouteContext {
+ params: Promise<{
+ docId: string;
+ }>;
+}
+
+interface CreateCommentBody {
+ body?: string;
+ docPath?: string;
+ docTitle?: string;
+ docUrl?: string;
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { docId: rawDocId } = await context.params;
+ const docId = rawDocId?.trim();
+ if (!docId) {
+ return NextResponse.json(
+ { code: "BAD_REQUEST", message: "docId 参数缺失" },
+ { status: 400 },
+ );
+ }
+
+ let payload: CreateCommentBody;
+ try {
+ payload = (await request.json()) as CreateCommentBody;
+ } catch (error) {
+ return NextResponse.json(
+ { code: "BAD_REQUEST", message: "请求体必须是合法的 JSON" },
+ { status: 400 },
+ );
+ }
+
+ const commentBody = payload.body?.trim();
+ if (!commentBody) {
+ return NextResponse.json(
+ { code: "BAD_REQUEST", message: "评论内容不能为空" },
+ { status: 400 },
+ );
+ }
+
+ const session = await auth();
+ const accessToken =
+ (session as typeof session & { accessToken?: string | null })
+ ?.accessToken ?? null;
+ if (!session || !accessToken) {
+ return NextResponse.json(
+ {
+ code: "UNAUTHORIZED",
+ message: "需要先登录 GitHub 才能发表评论",
+ },
+ { status: 401 },
+ );
+ }
+
+ try {
+ const serverToken = getServerGitHubToken();
+ const discussion = await ensureDiscussionForDoc({
+ docId,
+ docPath: payload.docPath,
+ docTitle: payload.docTitle,
+ docUrl: payload.docUrl,
+ token: serverToken,
+ });
+
+ const newComment = await addDiscussionComment({
+ discussionId: discussion.id,
+ body: commentBody,
+ token: accessToken,
+ });
+
+ const refreshed = await fetchDiscussionWithComments(discussion.id, {
+ token: serverToken,
+ });
+
+ return NextResponse.json(
+ {
+ docId,
+ discussion: refreshed.discussion,
+ comments: refreshed.comments,
+ created: newComment,
+ },
+ { status: 201 },
+ );
+ } catch (error) {
+ if (error instanceof GitHubDiscussionError) {
+ const status = error.status === 502 ? 502 : error.status || 500;
+ return NextResponse.json(
+ {
+ code: "GITHUB_ERROR",
+ message: error.message,
+ details: error.details,
+ },
+ { status },
+ );
+ }
+ console.error("[discussions][POST comment]", error);
+ return NextResponse.json(
+ {
+ code: "INTERNAL_ERROR",
+ message: "无法提交评论",
+ },
+ { status: 500 },
+ );
+ }
+}
diff --git a/app/api/discussions/[docId]/replies/route.ts b/app/api/discussions/[docId]/replies/route.ts
new file mode 100644
index 0000000..5e3f9d1
--- /dev/null
+++ b/app/api/discussions/[docId]/replies/route.ts
@@ -0,0 +1,122 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/auth";
+import {
+ addDiscussionReply,
+ fetchDiscussionWithComments,
+ getServerGitHubToken,
+ GitHubDiscussionError,
+ searchDiscussionByDocId,
+} from "@/lib/discussion/github-discussions";
+
+interface RouteContext {
+ params: Promise<{
+ docId: string;
+ }>;
+}
+
+interface CreateReplyBody {
+ body?: string;
+ commentId?: string;
+}
+
+export async function POST(request: Request, context: RouteContext) {
+ const { docId: rawDocId } = await context.params;
+ const docId = rawDocId?.trim();
+ if (!docId) {
+ return NextResponse.json(
+ { code: "BAD_REQUEST", message: "docId 参数缺失" },
+ { status: 400 },
+ );
+ }
+
+ let payload: CreateReplyBody;
+ try {
+ payload = (await request.json()) as CreateReplyBody;
+ } catch (error) {
+ return NextResponse.json(
+ { code: "BAD_REQUEST", message: "请求体必须是合法的 JSON" },
+ { status: 400 },
+ );
+ }
+
+ const replyBody = payload.body?.trim();
+ const commentId = payload.commentId?.trim();
+ if (!replyBody || !commentId) {
+ return NextResponse.json(
+ {
+ code: "BAD_REQUEST",
+ message: "commentId 与回复内容均不能为空",
+ },
+ { status: 400 },
+ );
+ }
+
+ const session = await auth();
+ const accessToken =
+ (session as typeof session & { accessToken?: string | null })
+ ?.accessToken ?? null;
+ if (!session || !accessToken) {
+ return NextResponse.json(
+ {
+ code: "UNAUTHORIZED",
+ message: "需要先登录 GitHub 才能回复评论",
+ },
+ { status: 401 },
+ );
+ }
+
+ try {
+ const serverToken = getServerGitHubToken();
+ const discussion = await searchDiscussionByDocId(docId, serverToken);
+
+ if (!discussion) {
+ return NextResponse.json(
+ {
+ code: "NOT_FOUND",
+ message: "该文档尚未创建讨论串,无法回复",
+ },
+ { status: 404 },
+ );
+ }
+
+ const reply = await addDiscussionReply({
+ commentId,
+ body: replyBody,
+ token: accessToken,
+ });
+
+ const refreshed = await fetchDiscussionWithComments(discussion.id, {
+ token: serverToken,
+ });
+
+ return NextResponse.json(
+ {
+ docId,
+ discussion: refreshed.discussion,
+ comments: refreshed.comments,
+ created: reply,
+ },
+ { status: 201 },
+ );
+ } catch (error) {
+ if (error instanceof GitHubDiscussionError) {
+ const status = error.status === 502 ? 502 : error.status || 500;
+ return NextResponse.json(
+ {
+ code: "GITHUB_ERROR",
+ message: error.message,
+ details: error.details,
+ },
+ { status },
+ );
+ }
+ console.error("[discussions][POST reply]", error);
+ return NextResponse.json(
+ {
+ code: "INTERNAL_ERROR",
+ message: "无法提交回复",
+ },
+ { status: 500 },
+ );
+ }
+}
diff --git a/app/api/discussions/[docId]/route.ts b/app/api/discussions/[docId]/route.ts
new file mode 100644
index 0000000..988cd04
--- /dev/null
+++ b/app/api/discussions/[docId]/route.ts
@@ -0,0 +1,114 @@
+import { NextResponse } from "next/server";
+import { DiscussionResponseSchema } from "@/lib/discussion/discussion.dto";
+import {
+ fetchDiscussionWithComments,
+ getServerGitHubToken,
+ GitHubDiscussionError,
+ searchDiscussionByDocId,
+} from "@/lib/discussion/github-discussions";
+import { ZodError } from "zod";
+
+interface RouteContext {
+ params: Promise<{
+ docId: string;
+ }>;
+}
+
+export async function GET(request: Request, context: RouteContext) {
+ // 约定 docId 从路径参数传入,前端会保证是 cuid2
+ const { docId: rawDocId } = await context.params;
+ const docId = rawDocId?.trim();
+ if (!docId) {
+ // 缺少 docId 时直接返回 400,便于前端快速定位问题
+ return NextResponse.json(
+ {
+ code: "BAD_REQUEST",
+ message: "docId 参数缺失",
+ },
+ { status: 400 },
+ );
+ }
+
+ const url = new URL(request.url);
+ // 支持前端透传分页参数,避免写死分页策略
+ const commentCursor = url.searchParams.get("cursor") ?? undefined;
+ const commentPageSize =
+ Number.parseInt(url.searchParams.get("pageSize") ?? "") || undefined;
+ const replyPageSize =
+ Number.parseInt(url.searchParams.get("replyPageSize") ?? "") || undefined;
+
+ try {
+ // 站点持有的 GitHub token,仅用于只读查询
+ const token = getServerGitHubToken();
+ const summary = await searchDiscussionByDocId(docId, token);
+
+ if (!summary) {
+ const payload = DiscussionResponseSchema.parse({
+ docId,
+ discussion: null,
+ comments: {
+ totalCount: 0,
+ pageInfo: {
+ hasNextPage: false,
+ endCursor: null,
+ },
+ nodes: [],
+ },
+ });
+ // 没查到 Discussion 时,返回空结构,交由前端触发懒创建或展示「暂无评论」
+ return NextResponse.json(payload, { status: 200 });
+ }
+
+ const detailed = await fetchDiscussionWithComments(summary.id, {
+ token,
+ commentCursor,
+ commentPageSize,
+ replyPageSize,
+ });
+
+ const payload = DiscussionResponseSchema.parse({
+ docId,
+ discussion: detailed.discussion,
+ comments: detailed.comments ?? {
+ totalCount: 0,
+ pageInfo: {
+ hasNextPage: false,
+ endCursor: null,
+ },
+ nodes: [],
+ },
+ });
+
+ return NextResponse.json(payload, { status: 200 });
+ } catch (error) {
+ if (error instanceof ZodError) {
+ return NextResponse.json(
+ {
+ code: "INVALID_RESPONSE_DTO",
+ message: "讨论数据不符合约定的响应格式",
+ details: error.issues,
+ },
+ { status: 500 },
+ );
+ }
+ if (error instanceof GitHubDiscussionError) {
+ // GraphQL 层面已给出明确状态码,直接透传给客户端
+ return NextResponse.json(
+ {
+ code: "GITHUB_ERROR",
+ message: error.message,
+ details: error.details,
+ },
+ { status: error.status },
+ );
+ }
+ console.error("[discussions][GET]", error);
+ return NextResponse.json(
+ {
+ code: "INTERNAL_ERROR",
+ message: "无法获取 Discussion 数据",
+ },
+ { status: 500 },
+ );
+ }
+}
diff --git a/auth.ts b/auth.ts
index 6ce9d9f..2999bb7 100644
--- a/auth.ts
+++ b/auth.ts
@@ -24,6 +24,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth(() => {
...authConfig,
providers: [
GitHub({
+ authorization: {
+ params: {
+ scope: [
+ "read:user",
+ "user:email",
+ "read:discussion",
+ "write:discussion",
+ ].join(" "),
+ },
+ },
profile(profile) {
return {
id: profile.id.toString(), // 与数据库的整数主键兼容
diff --git a/lib/discussion/README.md b/lib/discussion/README.md
new file mode 100644
index 0000000..78c4841
--- /dev/null
+++ b/lib/discussion/README.md
@@ -0,0 +1,27 @@
+# 内卷地狱——评论区——技术文档
+
+## 背景
+
+1. ISSUE:https://github.com/InvolutionHell/involutionhell.github.io/issues/200
+2. 尝试:https://github.com/InvolutionHell/involutionhell.github.io/pull/210
+
+## 解决方案:
+
+自己写一个类似的Giscus,放在网站里面,用GitHub Discussion维护。自己写一个“Giscus-like”前端组件,后端直接把评论读写到 GitHub Discussions。这样通知仍由 GitHub 负责(用户一旦评论/参与,GitHub 会按其通知设置发邮件/站内通知),也不需要自建邮件或扛数据库压力。
+
+- 存储:GitHub Discussions
+- 登录:站点只用 NextAuth + GitHub 一次登录
+- 读写:你的后端(Next.js Route Handler / API Route)用 GitHub API(推荐 GraphQL v4)读写 Discussion/Comment
+- 映射:每个页面用 docId(cuid2) 映射到对应的 Discussion(已由Giscus做完)
+- 通知:继续走 GitHub 的通知生态(无需自发邮件)
+
+## 数据流
+
+1. 用户在站点通过 NextAuth(GitHub) 登录
+2. 你的前端评论组件只和你自家的 /api/comments 通信
+3. 后端读取 NextAuth 的 session,拿到用户的 GitHub OAuth access_token(不要在前端暴露)
+4. 后端用该 token 调用 GitHub GraphQL:
+ - 查询:是否已有对应 pageId 的 Discussion;拉取评论列表(分页)
+ - 首评懒创建:若无 Discussion,则后端用 mutation 创建一个新的 Discussion
+ - 发评/回评:用 mutation addDiscussionComment 发送评论
+5. GitHub 负责给参与者/订阅者 发送通知(邮件/站内)
diff --git a/lib/discussion/discussion.dto.ts b/lib/discussion/discussion.dto.ts
new file mode 100644
index 0000000..c6860a1
--- /dev/null
+++ b/lib/discussion/discussion.dto.ts
@@ -0,0 +1,75 @@
+import { z } from "zod";
+
+// 讨论作者信息。GitHub 返回可能为空,因此允许 null。
+export const DiscussionAuthorSchema = z
+ .object({
+ login: z.string(),
+ avatarUrl: z.string().url(),
+ url: z.string().url(),
+ })
+ .nullable();
+
+// 回复节点。
+export const DiscussionReplySchema = z.object({
+ id: z.string(),
+ body: z.string(),
+ bodyHTML: z.string(),
+ bodyText: z.string(),
+ createdAt: z.string(),
+ url: z.string().url(),
+ author: DiscussionAuthorSchema,
+});
+
+export const PageInfoSchema = z.object({
+ hasNextPage: z.boolean(),
+ endCursor: z.string().nullable(),
+});
+
+// 评论节点,内含嵌套回复分页。
+export const DiscussionCommentSchema = z.object({
+ id: z.string(),
+ body: z.string(),
+ bodyHTML: z.string(),
+ bodyText: z.string(),
+ createdAt: z.string(),
+ url: z.string().url(),
+ isAnswer: z.boolean().optional(),
+ author: DiscussionAuthorSchema,
+ replies: z.object({
+ totalCount: z.number().int().nonnegative(),
+ pageInfo: PageInfoSchema,
+ nodes: z.array(DiscussionReplySchema),
+ }),
+});
+
+export const DiscussionSummarySchema = z
+ .object({
+ id: z.string(),
+ number: z.number().int().nonnegative(),
+ title: z.string(),
+ url: z.string().url(),
+ createdAt: z.string(),
+ })
+ .nullable();
+
+export const CommentsCollectionSchema = z.object({
+ totalCount: z.number().int().nonnegative(),
+ pageInfo: PageInfoSchema,
+ nodes: z.array(DiscussionCommentSchema),
+});
+
+// 成功响应 DTO,所有字段均有明确定义。
+export const DiscussionResponseSchema = z.object({
+ docId: z.string(),
+ discussion: DiscussionSummarySchema,
+ comments: CommentsCollectionSchema,
+});
+
+export type DiscussionAuthorDTO = z.infer;
+export type DiscussionReplyDTO = z.infer;
+export type DiscussionCommentDTO = z.infer;
+export type DiscussionSummaryDTO = NonNullable<
+ z.infer
+>;
+export type DiscussionCommentsDTO = z.infer;
+export type DiscussionResponseDTO = z.infer;
diff --git a/lib/discussion/github-discussions.constants.ts b/lib/discussion/github-discussions.constants.ts
new file mode 100644
index 0000000..450e474
--- /dev/null
+++ b/lib/discussion/github-discussions.constants.ts
@@ -0,0 +1,16 @@
+/**
+ * GitHub Discussion 模块的基础常量。
+ * 这些常量单独维护,便于在不同服务函数之间复用或在测试中覆盖。
+ */
+
+export const GITHUB_GRAPHQL_ENDPOINT = "https://api.github.com/graphql";
+
+export const DEFAULT_DISCUSSION_CATEGORY_NAME =
+ process.env.GITHUB_DISCUSSION_CATEGORY_NAME ?? "Comments";
+
+export const DEFAULT_COMMENT_PAGE_SIZE = 25;
+
+export const DEFAULT_REPLY_PAGE_SIZE = 10;
+
+export const USER_AGENT_HEADER =
+ process.env.GITHUB_CUSTOM_USER_AGENT ?? "involutionhell-discussion-agent";
diff --git a/lib/discussion/github-discussions.queries.ts b/lib/discussion/github-discussions.queries.ts
new file mode 100644
index 0000000..7a98d24
--- /dev/null
+++ b/lib/discussion/github-discussions.queries.ts
@@ -0,0 +1,167 @@
+/**
+ * @description GitHub Discussion 模块用到的 GraphQL 查询与 Mutation。
+ * 拆分成独立文件,方便在 service 层按需引用,也便于未来做单元测试时复用。
+ */
+
+export const REPOSITORY_METADATA_QUERY = /* GraphQL */ `
+ query ResolveRepositoryMetadata($owner: String!, $repo: String!) {
+ repository(owner: $owner, name: $repo) {
+ id
+ discussionCategories(first: 50) {
+ nodes {
+ id
+ name
+ slug
+ }
+ }
+ }
+ }
+`;
+
+export const SEARCH_DISCUSSION_QUERY = /* GraphQL */ `
+ query SearchDiscussionByDocId($query: String!) {
+ search(query: $query, first: 1, type: DISCUSSION) {
+ nodes {
+ ... on Discussion {
+ id
+ number
+ title
+ url
+ createdAt
+ }
+ }
+ }
+ }
+`;
+
+export const DISCUSSION_WITH_COMMENTS_QUERY = /* GraphQL */ `
+ query DiscussionWithComments(
+ $id: ID!
+ $commentPageSize: Int!
+ $commentCursor: String
+ $replyPageSize: Int!
+ ) {
+ node(id: $id) {
+ ... on Discussion {
+ id
+ number
+ title
+ url
+ body
+ createdAt
+ author {
+ login
+ avatarUrl
+ url
+ }
+ comments(first: $commentPageSize, after: $commentCursor) {
+ totalCount
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ nodes {
+ id
+ body
+ bodyHTML
+ bodyText
+ createdAt
+ url
+ isAnswer
+ author {
+ login
+ avatarUrl
+ url
+ }
+ replies(first: $replyPageSize) {
+ totalCount
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ nodes {
+ id
+ body
+ bodyHTML
+ bodyText
+ createdAt
+ url
+ author {
+ login
+ avatarUrl
+ url
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
+export const CREATE_DISCUSSION_MUTATION = /* GraphQL */ `
+ mutation CreateDiscussion(
+ $repositoryId: ID!
+ $categoryId: ID!
+ $title: String!
+ $body: String!
+ ) {
+ createDiscussion(
+ input: {
+ repositoryId: $repositoryId
+ categoryId: $categoryId
+ title: $title
+ body: $body
+ }
+ ) {
+ discussion {
+ id
+ number
+ title
+ url
+ createdAt
+ }
+ }
+ }
+`;
+
+export const ADD_DISCUSSION_COMMENT_MUTATION = /* GraphQL */ `
+ mutation AddDiscussionComment($discussionId: ID!, $body: String!) {
+ addDiscussionComment(input: { discussionId: $discussionId, body: $body }) {
+ comment {
+ id
+ body
+ bodyHTML
+ bodyText
+ createdAt
+ url
+ author {
+ login
+ avatarUrl
+ url
+ }
+ }
+ }
+ }
+`;
+
+export const ADD_DISCUSSION_REPLY_MUTATION = /* GraphQL */ `
+ mutation AddDiscussionReply($commentId: ID!, $body: String!) {
+ addDiscussionReply(input: { commentId: $commentId, body: $body }) {
+ comment {
+ id
+ body
+ bodyHTML
+ bodyText
+ createdAt
+ url
+ author {
+ login
+ avatarUrl
+ url
+ }
+ }
+ }
+ }
+`;
diff --git a/lib/discussion/github-discussions.ts b/lib/discussion/github-discussions.ts
new file mode 100644
index 0000000..63e791a
--- /dev/null
+++ b/lib/discussion/github-discussions.ts
@@ -0,0 +1,469 @@
+import { githubConstants } from "@/lib/github";
+import {
+ DiscussionCommentDTO,
+ DiscussionCommentsDTO,
+ DiscussionReplyDTO,
+ DiscussionSummaryDTO,
+} from "@/lib/discussion/discussion.dto";
+import {
+ DEFAULT_COMMENT_PAGE_SIZE,
+ DEFAULT_DISCUSSION_CATEGORY_NAME,
+ DEFAULT_REPLY_PAGE_SIZE,
+ GITHUB_GRAPHQL_ENDPOINT,
+ USER_AGENT_HEADER,
+} from "@/lib/discussion/github-discussions.constants";
+import {
+ ADD_DISCUSSION_COMMENT_MUTATION,
+ ADD_DISCUSSION_REPLY_MUTATION,
+ CREATE_DISCUSSION_MUTATION,
+ DISCUSSION_WITH_COMMENTS_QUERY,
+ REPOSITORY_METADATA_QUERY,
+ SEARCH_DISCUSSION_QUERY,
+} from "@/lib/discussion/github-discussions.queries";
+
+/**
+ * GitHub Discussion GraphQL API 封装。
+ * 目标:
+ * 1. 给 Next.js Route Handler 提供稳定的读写讨论数据接口。
+ * 2. 明确区分站点服务端 token 与用户 OAuth token 的职责。
+ * 3. 尽量复用缓存,避免重复的 GitHub 请求。
+ */
+
+type GraphQLVariables = Record;
+
+interface GraphQLResponse {
+ data?: T;
+ errors?: Array<{
+ type?: string;
+ message: string;
+ path?: string[];
+ locations?: Array<{ line: number; column: number }>;
+ }>;
+}
+
+export class GitHubDiscussionError extends Error {
+ /**
+ * 自定义错误类型,用于在上层区分 GitHub API 抛出的错误场景。
+ * status 会被直接映射到 HTTP 状态码,details 用来记录 GraphQL 返回的细节。
+ */
+ constructor(
+ message: string,
+ public status: number,
+ public details?: unknown,
+ ) {
+ super(message);
+ this.name = "GitHubDiscussionError";
+ }
+}
+
+function ensureToken(token: string | undefined | null, context: string) {
+ // 调用 GitHub API 前统一校验 token,context 帮助定位缺失来源
+ if (!token) {
+ throw new GitHubDiscussionError(`GitHub token missing for ${context}`, 500);
+ }
+ return token;
+}
+
+async function requestGraphQL(
+ token: string,
+ query: string,
+ variables?: GraphQLVariables,
+) {
+ // 统一的 GraphQL 请求封装:附带鉴权头、禁用缓存,并把 GitHub 错误转成 GitHubDiscussionError
+ const response = await fetch(GITHUB_GRAPHQL_ENDPOINT, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ "User-Agent": USER_AGENT_HEADER,
+ },
+ body: JSON.stringify({
+ query,
+ variables,
+ }),
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ // 这里保存响应体内容,方便上层在日志里还原失败原因
+ throw new GitHubDiscussionError(text || "GitHub request failed", 502, {
+ status: response.status,
+ body: text,
+ });
+ }
+
+ const json = (await response.json()) as GraphQLResponse;
+ if (json.errors && json.errors.length > 0) {
+ throw new GitHubDiscussionError(
+ json.errors[0]?.message ?? "GitHub error",
+ 502,
+ json.errors,
+ );
+ }
+ if (!json.data) {
+ throw new GitHubDiscussionError("Empty GitHub response", 502);
+ }
+
+ return json.data;
+}
+
+interface RepositoryMetadata {
+ repositoryId: string;
+ categoryId: string;
+ categoryName: string;
+}
+// 仓库与分类 ID 基本不会频繁变更,这里用进程级缓存减少 API 调用
+let cachedRepositoryMetadata: RepositoryMetadata | null = null;
+
+async function resolveRepositoryMetadata(token: string) {
+ // 优先命中缓存,避免重复查询
+ if (cachedRepositoryMetadata) {
+ return cachedRepositoryMetadata;
+ }
+
+ const envRepositoryId =
+ process.env.GITHUB_DISCUSSION_REPOSITORY_ID ??
+ process.env.GITHUB_REPOSITORY_ID;
+ const envCategoryId = process.env.GITHUB_DISCUSSION_CATEGORY_ID;
+
+ // 如果部署时显式配置了 ID,则无需再请求 GraphQL,直接返回
+ if (envRepositoryId && envCategoryId) {
+ cachedRepositoryMetadata = {
+ repositoryId: envRepositoryId,
+ categoryId: envCategoryId,
+ categoryName: DEFAULT_DISCUSSION_CATEGORY_NAME,
+ };
+ return cachedRepositoryMetadata;
+ }
+
+ const repositoryData = await requestGraphQL<{
+ repository: {
+ id: string;
+ discussionCategories: {
+ nodes: Array<{
+ id: string;
+ name: string;
+ slug: string;
+ }>;
+ };
+ } | null;
+ }>(token, REPOSITORY_METADATA_QUERY, {
+ owner: process.env.GITHUB_DISCUSSION_REPO_OWNER ?? githubConstants.owner,
+ repo: process.env.GITHUB_DISCUSSION_REPO_NAME ?? githubConstants.repo,
+ });
+
+ if (!repositoryData.repository) {
+ throw new GitHubDiscussionError("GitHub repository not found", 404);
+ }
+
+ const targetCategory =
+ repositoryData.repository.discussionCategories.nodes.find((node) => {
+ const name = node.name?.toLowerCase();
+ const slug = node.slug?.toLowerCase();
+ const target = DEFAULT_DISCUSSION_CATEGORY_NAME.toLowerCase();
+ return name === target || slug === target;
+ }) ?? repositoryData.repository.discussionCategories.nodes[0]; // 找不到同名分类时兜底使用第一个分类
+
+ if (!targetCategory) {
+ throw new GitHubDiscussionError(
+ `Discussion category "${DEFAULT_DISCUSSION_CATEGORY_NAME}" not found in repository`,
+ 500,
+ );
+ }
+
+ cachedRepositoryMetadata = {
+ repositoryId: repositoryData.repository.id,
+ categoryId: targetCategory.id,
+ categoryName: targetCategory.name,
+ };
+
+ return cachedRepositoryMetadata;
+}
+
+export type DiscussionReply = DiscussionReplyDTO;
+export type DiscussionComment = DiscussionCommentDTO;
+export type DiscussionSummary = DiscussionSummaryDTO;
+
+export interface DiscussionWithComments {
+ discussion: DiscussionSummary | null;
+ comments: DiscussionCommentsDTO | null;
+}
+
+export async function searchDiscussionByDocId(docId: string, token?: string) {
+ // docId 写在 Discussion 标题中,因此利用 GitHub search API + in:title 精准匹配
+ const serverToken = ensureToken(
+ token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
+ "searchDiscussionByDocId",
+ );
+
+ const query = [
+ `repo:${process.env.GITHUB_DISCUSSION_REPO_OWNER ?? githubConstants.owner}/${process.env.GITHUB_DISCUSSION_REPO_NAME ?? githubConstants.repo}`,
+ `in:title "${docId}"`,
+ ].join(" ");
+
+ const data = await requestGraphQL<{
+ search: {
+ nodes: Array;
+ };
+ }>(serverToken, SEARCH_DISCUSSION_QUERY, { query });
+
+ const [discussion] = data.search.nodes;
+ return discussion ?? null;
+}
+
+interface FetchDiscussionOptions {
+ commentPageSize?: number;
+ commentCursor?: string;
+ replyPageSize?: number;
+ token?: string;
+}
+
+export async function fetchDiscussionWithComments(
+ discussionId: string,
+ options: FetchDiscussionOptions = {},
+) {
+ // 读取评论时允许覆盖分页参数,若无参数则退回默认设置
+ const serverToken = ensureToken(
+ options.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
+ "fetchDiscussionWithComments",
+ );
+
+ const data = await requestGraphQL<{
+ node: {
+ id: string;
+ number: number;
+ title: string;
+ url: string;
+ body: string;
+ createdAt: string;
+ author: {
+ login: string;
+ avatarUrl: string;
+ url: string;
+ } | null;
+ comments: {
+ totalCount: number;
+ pageInfo: {
+ hasNextPage: boolean;
+ endCursor: string | null;
+ };
+ nodes: Array<{
+ id: string;
+ body: string;
+ bodyHTML: string;
+ bodyText: string;
+ createdAt: string;
+ url: string;
+ isAnswer: boolean;
+ author: {
+ login: string;
+ avatarUrl: string;
+ url: string;
+ } | null;
+ replies: {
+ totalCount: number;
+ pageInfo: {
+ hasNextPage: boolean;
+ endCursor: string | null;
+ };
+ nodes: DiscussionReplyDTO[];
+ };
+ }>;
+ };
+ } | null;
+ }>(serverToken, DISCUSSION_WITH_COMMENTS_QUERY, {
+ id: discussionId,
+ commentPageSize: options.commentPageSize ?? DEFAULT_COMMENT_PAGE_SIZE,
+ commentCursor: options.commentCursor,
+ replyPageSize: options.replyPageSize ?? DEFAULT_REPLY_PAGE_SIZE,
+ });
+
+ if (!data.node) {
+ // Discussion 被删除或传入 ID 失效时返回空结构,方便前端识别 404
+ return {
+ discussion: null,
+ comments: null,
+ } satisfies DiscussionWithComments;
+ }
+
+ return {
+ discussion: {
+ id: data.node.id,
+ number: data.node.number,
+ title: data.node.title,
+ url: data.node.url,
+ createdAt: data.node.createdAt,
+ },
+ comments: {
+ totalCount: data.node.comments.totalCount,
+ pageInfo: data.node.comments.pageInfo,
+ nodes: data.node.comments.nodes.map((comment) => ({
+ id: comment.id,
+ body: comment.body,
+ bodyHTML: comment.bodyHTML,
+ bodyText: comment.bodyText,
+ createdAt: comment.createdAt,
+ url: comment.url,
+ isAnswer: comment.isAnswer,
+ author: comment.author,
+ replies: comment.replies,
+ })),
+ },
+ } satisfies DiscussionWithComments;
+}
+
+interface CreateDiscussionParams {
+ docId: string;
+ body: string;
+ token?: string;
+}
+
+export async function createDiscussionForDocId({
+ docId,
+ body,
+ token,
+}: CreateDiscussionParams) {
+ const serverToken = ensureToken(
+ token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
+ "createDiscussionForDocId",
+ );
+ const metadata = await resolveRepositoryMetadata(serverToken);
+
+ const data = await requestGraphQL<{
+ createDiscussion: {
+ discussion: DiscussionSummary;
+ } | null;
+ }>(serverToken, CREATE_DISCUSSION_MUTATION, {
+ repositoryId: metadata.repositoryId,
+ categoryId: metadata.categoryId,
+ title: docId,
+ body,
+ });
+
+ return data.createDiscussion?.discussion ?? null;
+}
+
+interface AddCommentParams {
+ discussionId: string;
+ body: string;
+ token: string;
+}
+
+export async function addDiscussionComment({
+ discussionId,
+ body,
+ token,
+}: AddCommentParams) {
+ const userToken = ensureToken(token, "addDiscussionComment");
+ const data = await requestGraphQL<{
+ addDiscussionComment: {
+ comment: DiscussionComment | null;
+ } | null;
+ }>(userToken, ADD_DISCUSSION_COMMENT_MUTATION, {
+ discussionId,
+ body,
+ });
+
+ return data.addDiscussionComment?.comment ?? null;
+}
+
+interface AddReplyParams {
+ commentId: string;
+ body: string;
+ token: string;
+}
+
+export async function addDiscussionReply({
+ commentId,
+ body,
+ token,
+}: AddReplyParams) {
+ const userToken = ensureToken(token, "addDiscussionReply");
+ const data = await requestGraphQL<{
+ addDiscussionReply: {
+ comment: DiscussionReply | null;
+ } | null;
+ }>(userToken, ADD_DISCUSSION_REPLY_MUTATION, {
+ commentId,
+ body,
+ });
+
+ return data.addDiscussionReply?.comment ?? null;
+}
+
+interface EnsureDiscussionOptions {
+ docId: string;
+ docPath?: string;
+ docTitle?: string;
+ docUrl?: string;
+ token?: string;
+}
+
+function buildDiscussionBody({
+ docId,
+ docPath,
+ docTitle,
+ docUrl,
+}: Omit) {
+ // 懒创建 Discussion 时填充的默认正文,记录 docId 与文档元数据
+ const lines = [
+ ``,
+ `This discussion is automatically created for document updates.`,
+ ];
+ if (docTitle) {
+ lines.push(`- **Title:** ${docTitle}`);
+ }
+ if (docPath) {
+ lines.push(`- **Path:** \`${docPath}\``);
+ }
+ if (docUrl) {
+ lines.push(`- **URL:** ${docUrl}`);
+ }
+ lines.push(
+ "",
+ "Feel free to discuss the content here. Notifications are handled by GitHub.",
+ );
+ return lines.join("\n");
+}
+
+export async function ensureDiscussionForDoc({
+ docId,
+ docPath,
+ docTitle,
+ docUrl,
+ token,
+}: EnsureDiscussionOptions) {
+ const serverToken = ensureToken(
+ token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
+ "ensureDiscussionForDoc",
+ );
+
+ const existing = await searchDiscussionByDocId(docId, serverToken);
+ if (existing) {
+ // 已存在时直接返回,避免重复创建
+ return existing;
+ }
+
+ const body = buildDiscussionBody({ docId, docPath, docTitle, docUrl });
+ const discussion = await createDiscussionForDocId({
+ docId,
+ body,
+ token: serverToken,
+ });
+ if (!discussion) {
+ throw new GitHubDiscussionError(
+ "Failed to create discussion for docId",
+ 500,
+ );
+ }
+ return discussion;
+}
+
+export function getServerGitHubToken() {
+ // 服务器只读 API 统一从环境变量里取 token,方便集中管理权限
+ return ensureToken(
+ process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
+ "server",
+ );
+}