@spaceflow/core 0.1.1

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 (116) hide show
  1. package/CHANGELOG.md +1176 -0
  2. package/README.md +105 -0
  3. package/nest-cli.json +10 -0
  4. package/package.json +128 -0
  5. package/rspack.config.mjs +62 -0
  6. package/src/__mocks__/@opencode-ai/sdk.js +9 -0
  7. package/src/__mocks__/c12.ts +3 -0
  8. package/src/app.module.ts +18 -0
  9. package/src/config/ci.config.ts +29 -0
  10. package/src/config/config-loader.ts +101 -0
  11. package/src/config/config-reader.module.ts +16 -0
  12. package/src/config/config-reader.service.ts +133 -0
  13. package/src/config/feishu.config.ts +35 -0
  14. package/src/config/git-provider.config.ts +29 -0
  15. package/src/config/index.ts +29 -0
  16. package/src/config/llm.config.ts +110 -0
  17. package/src/config/schema-generator.service.ts +129 -0
  18. package/src/config/spaceflow.config.ts +292 -0
  19. package/src/config/storage.config.ts +33 -0
  20. package/src/extension-system/extension.interface.ts +221 -0
  21. package/src/extension-system/index.ts +1 -0
  22. package/src/index.ts +80 -0
  23. package/src/locales/en/translation.json +11 -0
  24. package/src/locales/zh-cn/translation.json +11 -0
  25. package/src/shared/claude-setup/claude-setup.module.ts +8 -0
  26. package/src/shared/claude-setup/claude-setup.service.ts +131 -0
  27. package/src/shared/claude-setup/index.ts +2 -0
  28. package/src/shared/editor-config/index.ts +23 -0
  29. package/src/shared/feishu-sdk/feishu-sdk.module.ts +77 -0
  30. package/src/shared/feishu-sdk/feishu-sdk.service.ts +130 -0
  31. package/src/shared/feishu-sdk/fieshu-card.service.ts +139 -0
  32. package/src/shared/feishu-sdk/index.ts +4 -0
  33. package/src/shared/feishu-sdk/types/card-action.ts +132 -0
  34. package/src/shared/feishu-sdk/types/card.ts +64 -0
  35. package/src/shared/feishu-sdk/types/common.ts +22 -0
  36. package/src/shared/feishu-sdk/types/index.ts +46 -0
  37. package/src/shared/feishu-sdk/types/message.ts +35 -0
  38. package/src/shared/feishu-sdk/types/module.ts +21 -0
  39. package/src/shared/feishu-sdk/types/user.ts +77 -0
  40. package/src/shared/git-provider/adapters/gitea.adapter.spec.ts +473 -0
  41. package/src/shared/git-provider/adapters/gitea.adapter.ts +499 -0
  42. package/src/shared/git-provider/adapters/github.adapter.spec.ts +341 -0
  43. package/src/shared/git-provider/adapters/github.adapter.ts +830 -0
  44. package/src/shared/git-provider/adapters/gitlab.adapter.ts +839 -0
  45. package/src/shared/git-provider/adapters/index.ts +3 -0
  46. package/src/shared/git-provider/detect-provider.spec.ts +195 -0
  47. package/src/shared/git-provider/detect-provider.ts +112 -0
  48. package/src/shared/git-provider/git-provider.interface.ts +188 -0
  49. package/src/shared/git-provider/git-provider.module.ts +73 -0
  50. package/src/shared/git-provider/git-provider.service.spec.ts +282 -0
  51. package/src/shared/git-provider/git-provider.service.ts +309 -0
  52. package/src/shared/git-provider/index.ts +7 -0
  53. package/src/shared/git-provider/parse-repo-url.spec.ts +221 -0
  54. package/src/shared/git-provider/parse-repo-url.ts +155 -0
  55. package/src/shared/git-provider/types.ts +434 -0
  56. package/src/shared/git-sdk/git-sdk-diff.utils.spec.ts +344 -0
  57. package/src/shared/git-sdk/git-sdk-diff.utils.ts +151 -0
  58. package/src/shared/git-sdk/git-sdk.module.ts +8 -0
  59. package/src/shared/git-sdk/git-sdk.service.ts +235 -0
  60. package/src/shared/git-sdk/git-sdk.types.ts +25 -0
  61. package/src/shared/git-sdk/index.ts +4 -0
  62. package/src/shared/i18n/i18n.spec.ts +96 -0
  63. package/src/shared/i18n/i18n.ts +86 -0
  64. package/src/shared/i18n/index.ts +1 -0
  65. package/src/shared/i18n/locale-detect.ts +134 -0
  66. package/src/shared/llm-jsonput/index.ts +94 -0
  67. package/src/shared/llm-jsonput/types.ts +17 -0
  68. package/src/shared/llm-proxy/adapters/claude-code.adapter.spec.ts +131 -0
  69. package/src/shared/llm-proxy/adapters/claude-code.adapter.ts +208 -0
  70. package/src/shared/llm-proxy/adapters/index.ts +4 -0
  71. package/src/shared/llm-proxy/adapters/llm-adapter.interface.ts +23 -0
  72. package/src/shared/llm-proxy/adapters/open-code.adapter.ts +342 -0
  73. package/src/shared/llm-proxy/adapters/openai.adapter.spec.ts +215 -0
  74. package/src/shared/llm-proxy/adapters/openai.adapter.ts +153 -0
  75. package/src/shared/llm-proxy/index.ts +6 -0
  76. package/src/shared/llm-proxy/interfaces/config.interface.ts +32 -0
  77. package/src/shared/llm-proxy/interfaces/index.ts +4 -0
  78. package/src/shared/llm-proxy/interfaces/message.interface.ts +48 -0
  79. package/src/shared/llm-proxy/interfaces/session.interface.ts +28 -0
  80. package/src/shared/llm-proxy/llm-proxy.module.ts +140 -0
  81. package/src/shared/llm-proxy/llm-proxy.service.spec.ts +303 -0
  82. package/src/shared/llm-proxy/llm-proxy.service.ts +132 -0
  83. package/src/shared/llm-proxy/llm-session.spec.ts +111 -0
  84. package/src/shared/llm-proxy/llm-session.ts +109 -0
  85. package/src/shared/llm-proxy/stream-logger.ts +97 -0
  86. package/src/shared/logger/index.ts +11 -0
  87. package/src/shared/logger/logger.interface.ts +93 -0
  88. package/src/shared/logger/logger.spec.ts +178 -0
  89. package/src/shared/logger/logger.ts +175 -0
  90. package/src/shared/logger/renderers/plain.renderer.ts +116 -0
  91. package/src/shared/logger/renderers/tui.renderer.ts +162 -0
  92. package/src/shared/mcp/index.ts +332 -0
  93. package/src/shared/output/index.ts +2 -0
  94. package/src/shared/output/output.module.ts +9 -0
  95. package/src/shared/output/output.service.ts +97 -0
  96. package/src/shared/package-manager/index.ts +115 -0
  97. package/src/shared/parallel/index.ts +1 -0
  98. package/src/shared/parallel/parallel-executor.ts +169 -0
  99. package/src/shared/rspack-config/index.ts +1 -0
  100. package/src/shared/rspack-config/rspack-config.ts +157 -0
  101. package/src/shared/source-utils/index.ts +130 -0
  102. package/src/shared/spaceflow-dir/index.ts +158 -0
  103. package/src/shared/storage/adapters/file.adapter.ts +113 -0
  104. package/src/shared/storage/adapters/index.ts +3 -0
  105. package/src/shared/storage/adapters/memory.adapter.ts +50 -0
  106. package/src/shared/storage/adapters/storage-adapter.interface.ts +48 -0
  107. package/src/shared/storage/index.ts +4 -0
  108. package/src/shared/storage/storage.module.ts +150 -0
  109. package/src/shared/storage/storage.service.ts +293 -0
  110. package/src/shared/storage/types.ts +51 -0
  111. package/src/shared/verbose/index.ts +73 -0
  112. package/test/app.e2e-spec.ts +22 -0
  113. package/tsconfig.build.json +4 -0
  114. package/tsconfig.json +25 -0
  115. package/tsconfig.skill.json +18 -0
  116. package/vitest.config.ts +58 -0
@@ -0,0 +1,473 @@
1
+ import { vi, type MockInstance } from "vitest";
2
+ import { GiteaAdapter } from "./gitea.adapter";
3
+ import type { GitProviderModuleOptions } from "../types";
4
+
5
+ const mockOptions: GitProviderModuleOptions = {
6
+ provider: "gitea",
7
+ baseUrl: "https://gitea.example.com",
8
+ token: "test-token",
9
+ };
10
+
11
+ /** 构造 mock Response */
12
+ function mockResponse(body: unknown, status = 200): Response {
13
+ const text = typeof body === "string" ? body : JSON.stringify(body);
14
+ return {
15
+ ok: status >= 200 && status < 300,
16
+ status,
17
+ statusText: status === 200 ? "OK" : "Error",
18
+ text: vi.fn().mockResolvedValue(text),
19
+ json: vi.fn().mockResolvedValue(body),
20
+ headers: new Headers(),
21
+ } as unknown as Response;
22
+ }
23
+
24
+ describe("GiteaAdapter", () => {
25
+ let adapter: GiteaAdapter;
26
+ let fetchSpy: MockInstance;
27
+
28
+ beforeEach(() => {
29
+ adapter = new GiteaAdapter(mockOptions);
30
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(mockResponse({}));
31
+ });
32
+
33
+ afterEach(() => {
34
+ fetchSpy.mockRestore();
35
+ });
36
+
37
+ // ============ 配置验证 ============
38
+
39
+ describe("validateConfig", () => {
40
+ it("配置完整时不应抛出异常", () => {
41
+ expect(() => adapter.validateConfig()).not.toThrow();
42
+ });
43
+
44
+ it("缺少 baseUrl 时应抛出异常", () => {
45
+ const a = new GiteaAdapter({ ...mockOptions, baseUrl: "" });
46
+ expect(() => a.validateConfig()).toThrow("缺少配置 gitProvider.baseUrl");
47
+ });
48
+
49
+ it("缺少 token 时应抛出异常", () => {
50
+ const a = new GiteaAdapter({ ...mockOptions, token: "" });
51
+ expect(() => a.validateConfig()).toThrow("缺少配置 gitProvider.token");
52
+ });
53
+ });
54
+
55
+ // ============ request 基础方法 ============
56
+
57
+ describe("request", () => {
58
+ it("GET 请求应拼接正确的 URL 和 headers", async () => {
59
+ fetchSpy.mockResolvedValue(mockResponse({ id: 1 }));
60
+ await adapter.getRepository("owner", "repo");
61
+ expect(fetchSpy).toHaveBeenCalledWith(
62
+ "https://gitea.example.com/api/v1/repos/owner/repo",
63
+ expect.objectContaining({
64
+ method: "GET",
65
+ headers: expect.objectContaining({
66
+ Authorization: "token test-token",
67
+ }),
68
+ }),
69
+ );
70
+ });
71
+
72
+ it("POST 请求应正确序列化 body", async () => {
73
+ fetchSpy.mockResolvedValue(mockResponse({ id: 1 }));
74
+ await adapter.createIssue("owner", "repo", { title: "test" });
75
+ expect(fetchSpy).toHaveBeenCalledWith(
76
+ "https://gitea.example.com/api/v1/repos/owner/repo/issues",
77
+ expect.objectContaining({
78
+ method: "POST",
79
+ body: JSON.stringify({ title: "test" }),
80
+ }),
81
+ );
82
+ });
83
+
84
+ it("API 返回错误时应抛出异常", async () => {
85
+ fetchSpy.mockResolvedValue(mockResponse("Not Found", 404));
86
+ await expect(adapter.getRepository("owner", "repo")).rejects.toThrow("Gitea API error: 404");
87
+ });
88
+
89
+ it("204 响应应返回空对象", async () => {
90
+ fetchSpy.mockResolvedValue(mockResponse("", 204));
91
+ const result = await adapter.deleteBranchProtection("owner", "repo", "main");
92
+ expect(result).toBeUndefined();
93
+ });
94
+ });
95
+
96
+ // ============ 分支保护 ============
97
+
98
+ describe("listBranchProtections", () => {
99
+ it("应返回分支保护规则列表", async () => {
100
+ const protections = [{ rule_name: "main", enable_push: false }];
101
+ fetchSpy.mockResolvedValue(mockResponse(protections));
102
+ const result = await adapter.listBranchProtections("owner", "repo");
103
+ expect(result).toEqual(protections);
104
+ });
105
+ });
106
+
107
+ describe("lockBranch", () => {
108
+ it("无现有规则时应创建新规则(无白名单)", async () => {
109
+ // listBranchProtections 返回空
110
+ fetchSpy.mockResolvedValueOnce(mockResponse([]));
111
+ // createBranchProtection 返回新规则
112
+ const newProtection = { rule_name: "main", enable_push: false };
113
+ fetchSpy.mockResolvedValueOnce(mockResponse(newProtection));
114
+ const result = await adapter.lockBranch("owner", "repo", "main");
115
+ expect(result).toEqual(newProtection);
116
+ // 第二次调用应该是 POST 创建
117
+ const createCall = fetchSpy.mock.calls[1];
118
+ expect(createCall[0]).toContain("/branch_protections");
119
+ expect(createCall[1].method).toBe("POST");
120
+ const body = JSON.parse(createCall[1].body);
121
+ expect(body.enable_push).toBe(false);
122
+ });
123
+
124
+ it("有现有规则时应更新规则", async () => {
125
+ const existing = [{ rule_name: "main", enable_push: true }];
126
+ fetchSpy.mockResolvedValueOnce(mockResponse(existing));
127
+ const updated = { rule_name: "main", enable_push: false };
128
+ fetchSpy.mockResolvedValueOnce(mockResponse(updated));
129
+ const result = await adapter.lockBranch("owner", "repo", "main");
130
+ expect(result).toEqual(updated);
131
+ // 第二次调用应该是 PATCH 更新
132
+ const editCall = fetchSpy.mock.calls[1];
133
+ expect(editCall[1].method).toBe("PATCH");
134
+ });
135
+
136
+ it("有白名单时应启用推送白名单", async () => {
137
+ fetchSpy.mockResolvedValueOnce(mockResponse([]));
138
+ fetchSpy.mockResolvedValueOnce(mockResponse({ rule_name: "main" }));
139
+ await adapter.lockBranch("owner", "repo", "main", {
140
+ pushWhitelistUsernames: ["bot-user"],
141
+ });
142
+ const body = JSON.parse(fetchSpy.mock.calls[1][1].body);
143
+ expect(body.enable_push).toBe(true);
144
+ expect(body.enable_push_whitelist).toBe(true);
145
+ expect(body.push_whitelist_usernames).toEqual(["bot-user"]);
146
+ });
147
+ });
148
+
149
+ describe("unlockBranch", () => {
150
+ it("有保护规则时应删除并返回", async () => {
151
+ const existing = [{ rule_name: "main", enable_push: false }];
152
+ fetchSpy.mockResolvedValueOnce(mockResponse(existing));
153
+ // deleteBranchProtection 返回 204
154
+ fetchSpy.mockResolvedValueOnce(mockResponse("", 204));
155
+ const result = await adapter.unlockBranch("owner", "repo", "main");
156
+ expect(result).toEqual(existing[0]);
157
+ expect(fetchSpy.mock.calls[1][1].method).toBe("DELETE");
158
+ });
159
+
160
+ it("无保护规则时应返回 null", async () => {
161
+ fetchSpy.mockResolvedValueOnce(mockResponse([]));
162
+ const result = await adapter.unlockBranch("owner", "repo", "main");
163
+ expect(result).toBeNull();
164
+ });
165
+ });
166
+
167
+ // ============ Pull Request ============
168
+
169
+ describe("getPullRequest", () => {
170
+ it("应请求正确的 URL", async () => {
171
+ const pr = { id: 1, number: 42, title: "Test PR" };
172
+ fetchSpy.mockResolvedValue(mockResponse(pr));
173
+ const result = await adapter.getPullRequest("owner", "repo", 42);
174
+ expect(result).toEqual(pr);
175
+ expect(fetchSpy).toHaveBeenCalledWith(
176
+ "https://gitea.example.com/api/v1/repos/owner/repo/pulls/42",
177
+ expect.anything(),
178
+ );
179
+ });
180
+ });
181
+
182
+ describe("listAllPullRequests", () => {
183
+ it("应支持分页获取全部", async () => {
184
+ const page1 = Array.from({ length: 50 }, (_, i) => ({ id: i }));
185
+ const page2 = [{ id: 50 }, { id: 51 }];
186
+ fetchSpy
187
+ .mockResolvedValueOnce(mockResponse(page1))
188
+ .mockResolvedValueOnce(mockResponse(page2));
189
+ const result = await adapter.listAllPullRequests("owner", "repo", { state: "closed" });
190
+ expect(result).toHaveLength(52);
191
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
192
+ });
193
+
194
+ it("单页不满时应停止分页", async () => {
195
+ fetchSpy.mockResolvedValueOnce(mockResponse([{ id: 1 }]));
196
+ const result = await adapter.listAllPullRequests("owner", "repo");
197
+ expect(result).toHaveLength(1);
198
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
199
+ });
200
+ });
201
+
202
+ describe("getPullRequestCommits", () => {
203
+ it("应支持分页获取全部 commits", async () => {
204
+ const page1 = Array.from({ length: 50 }, (_, i) => ({ sha: `sha${i}` }));
205
+ const page2 = [{ sha: "sha50" }];
206
+ fetchSpy
207
+ .mockResolvedValueOnce(mockResponse(page1))
208
+ .mockResolvedValueOnce(mockResponse(page2));
209
+ const result = await adapter.getPullRequestCommits("owner", "repo", 1);
210
+ expect(result).toHaveLength(51);
211
+ });
212
+ });
213
+
214
+ describe("getPullRequestFiles", () => {
215
+ it("文件有 patch 时应直接返回", async () => {
216
+ const files = [{ filename: "a.ts", patch: "@@ -1 +1 @@", status: "modified" }];
217
+ fetchSpy.mockResolvedValue(mockResponse(files));
218
+ const result = await adapter.getPullRequestFiles("owner", "repo", 1);
219
+ expect(result).toEqual(files);
220
+ // 只调用一次(不需要获取 diff)
221
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
222
+ });
223
+
224
+ it("文件缺少 patch 时应从 diff 回填", async () => {
225
+ const files = [{ filename: "a.ts", status: "modified" }];
226
+ fetchSpy.mockResolvedValueOnce(mockResponse(files));
227
+ // getPullRequestDiff 返回 diff 文本
228
+ const diffText = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-old\n+new";
229
+ fetchSpy.mockResolvedValueOnce(mockResponse(diffText));
230
+ const result = await adapter.getPullRequestFiles("owner", "repo", 1);
231
+ expect(result[0].filename).toBe("a.ts");
232
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
233
+ });
234
+
235
+ it("deleted 文件缺少 patch 不应触发 diff 回填", async () => {
236
+ const files = [{ filename: "a.ts", status: "deleted" }];
237
+ fetchSpy.mockResolvedValue(mockResponse(files));
238
+ await adapter.getPullRequestFiles("owner", "repo", 1);
239
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
240
+ });
241
+ });
242
+
243
+ describe("getPullRequestDiff", () => {
244
+ it("应请求 .diff 后缀 URL", async () => {
245
+ fetchSpy.mockResolvedValue(mockResponse("diff text"));
246
+ await adapter.getPullRequestDiff("owner", "repo", 1);
247
+ expect(fetchSpy).toHaveBeenCalledWith(
248
+ "https://gitea.example.com/api/v1/repos/owner/repo/pulls/1.diff",
249
+ expect.anything(),
250
+ );
251
+ });
252
+ });
253
+
254
+ // ============ Commit ============
255
+
256
+ describe("getCommit", () => {
257
+ it("应请求 /git/commits/ 路径", async () => {
258
+ fetchSpy.mockResolvedValue(mockResponse({ sha: "abc123" }));
259
+ await adapter.getCommit("owner", "repo", "abc123");
260
+ expect(fetchSpy).toHaveBeenCalledWith(
261
+ "https://gitea.example.com/api/v1/repos/owner/repo/git/commits/abc123",
262
+ expect.anything(),
263
+ );
264
+ });
265
+ });
266
+
267
+ describe("getCompareDiff", () => {
268
+ it("应请求 compare URL", async () => {
269
+ fetchSpy.mockResolvedValue(mockResponse("diff"));
270
+ await adapter.getCompareDiff("owner", "repo", "base", "head");
271
+ expect(fetchSpy).toHaveBeenCalledWith(
272
+ "https://gitea.example.com/api/v1/repos/owner/repo/compare/base...head.diff",
273
+ expect.anything(),
274
+ );
275
+ });
276
+ });
277
+
278
+ describe("getCommitDiff", () => {
279
+ it("应请求 /git/commits/sha.diff", async () => {
280
+ fetchSpy.mockResolvedValue(mockResponse("diff"));
281
+ await adapter.getCommitDiff("owner", "repo", "abc");
282
+ expect(fetchSpy).toHaveBeenCalledWith(
283
+ "https://gitea.example.com/api/v1/repos/owner/repo/git/commits/abc.diff",
284
+ expect.anything(),
285
+ );
286
+ });
287
+ });
288
+
289
+ // ============ 文件操作 ============
290
+
291
+ describe("getFileContent", () => {
292
+ it("应请求 /raw/ 路径", async () => {
293
+ fetchSpy.mockResolvedValue(mockResponse("file content"));
294
+ const result = await adapter.getFileContent("owner", "repo", "src/index.ts");
295
+ expect(result).toBe("file content");
296
+ expect(fetchSpy).toHaveBeenCalledWith(
297
+ expect.stringContaining("/raw/src%2Findex.ts"),
298
+ expect.anything(),
299
+ );
300
+ });
301
+
302
+ it("指定 ref 时应添加 query 参数", async () => {
303
+ fetchSpy.mockResolvedValue(mockResponse("content"));
304
+ await adapter.getFileContent("owner", "repo", "a.ts", "main");
305
+ expect(fetchSpy).toHaveBeenCalledWith(
306
+ expect.stringContaining("?ref=main"),
307
+ expect.anything(),
308
+ );
309
+ });
310
+
311
+ it("404 时应返回空字符串", async () => {
312
+ fetchSpy.mockResolvedValue(mockResponse("Not Found", 404));
313
+ const result = await adapter.getFileContent("owner", "repo", "missing.ts");
314
+ expect(result).toBe("");
315
+ });
316
+ });
317
+
318
+ // ============ Issue 操作 ============
319
+
320
+ describe("createIssue", () => {
321
+ it("应 POST 创建 issue", async () => {
322
+ const issue = { id: 1, title: "Bug" };
323
+ fetchSpy.mockResolvedValue(mockResponse(issue));
324
+ const result = await adapter.createIssue("owner", "repo", { title: "Bug" });
325
+ expect(result).toEqual(issue);
326
+ });
327
+ });
328
+
329
+ describe("listIssueComments", () => {
330
+ it("应请求正确的 URL", async () => {
331
+ fetchSpy.mockResolvedValue(mockResponse([]));
332
+ await adapter.listIssueComments("owner", "repo", 5);
333
+ expect(fetchSpy).toHaveBeenCalledWith(
334
+ "https://gitea.example.com/api/v1/repos/owner/repo/issues/5/comments",
335
+ expect.anything(),
336
+ );
337
+ });
338
+ });
339
+
340
+ describe("createIssueComment", () => {
341
+ it("应 POST 创建评论", async () => {
342
+ fetchSpy.mockResolvedValue(mockResponse({ id: 1, body: "hello" }));
343
+ await adapter.createIssueComment("owner", "repo", 5, { body: "hello" });
344
+ const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
345
+ expect(body).toEqual({ body: "hello" });
346
+ });
347
+ });
348
+
349
+ describe("updateIssueComment", () => {
350
+ it("应 PATCH 更新评论", async () => {
351
+ fetchSpy.mockResolvedValue(mockResponse({ id: 1, body: "updated" }));
352
+ await adapter.updateIssueComment("owner", "repo", 10, "updated");
353
+ expect(fetchSpy.mock.calls[0][1].method).toBe("PATCH");
354
+ expect(fetchSpy).toHaveBeenCalledWith(
355
+ "https://gitea.example.com/api/v1/repos/owner/repo/issues/comments/10",
356
+ expect.anything(),
357
+ );
358
+ });
359
+ });
360
+
361
+ describe("deleteIssueComment", () => {
362
+ it("应 DELETE 删除评论", async () => {
363
+ fetchSpy.mockResolvedValue(mockResponse("", 204));
364
+ await adapter.deleteIssueComment("owner", "repo", 10);
365
+ expect(fetchSpy.mock.calls[0][1].method).toBe("DELETE");
366
+ });
367
+ });
368
+
369
+ // ============ PR Review ============
370
+
371
+ describe("createPullReview", () => {
372
+ it("应 POST 创建 review", async () => {
373
+ fetchSpy.mockResolvedValue(mockResponse({ id: 1 }));
374
+ await adapter.createPullReview("owner", "repo", 1, {
375
+ event: "COMMENT",
376
+ body: "LGTM",
377
+ comments: [{ path: "a.ts", body: "fix this", new_position: 10 }],
378
+ });
379
+ const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
380
+ expect(body.event).toBe("COMMENT");
381
+ expect(body.comments).toHaveLength(1);
382
+ });
383
+ });
384
+
385
+ describe("listPullReviews", () => {
386
+ it("应请求正确的 URL", async () => {
387
+ fetchSpy.mockResolvedValue(mockResponse([]));
388
+ await adapter.listPullReviews("owner", "repo", 42);
389
+ expect(fetchSpy).toHaveBeenCalledWith(
390
+ "https://gitea.example.com/api/v1/repos/owner/repo/pulls/42/reviews",
391
+ expect.anything(),
392
+ );
393
+ });
394
+ });
395
+
396
+ describe("deletePullReview", () => {
397
+ it("应 DELETE 删除 review", async () => {
398
+ fetchSpy.mockResolvedValue(mockResponse("", 204));
399
+ await adapter.deletePullReview("owner", "repo", 42, 100);
400
+ expect(fetchSpy).toHaveBeenCalledWith(
401
+ "https://gitea.example.com/api/v1/repos/owner/repo/pulls/42/reviews/100",
402
+ expect.objectContaining({ method: "DELETE" }),
403
+ );
404
+ });
405
+ });
406
+
407
+ describe("listPullReviewComments", () => {
408
+ it("应请求正确的 URL", async () => {
409
+ fetchSpy.mockResolvedValue(mockResponse([]));
410
+ await adapter.listPullReviewComments("owner", "repo", 42, 100);
411
+ expect(fetchSpy).toHaveBeenCalledWith(
412
+ "https://gitea.example.com/api/v1/repos/owner/repo/pulls/42/reviews/100/comments",
413
+ expect.anything(),
414
+ );
415
+ });
416
+ });
417
+
418
+ // ============ Reaction ============
419
+
420
+ describe("getIssueCommentReactions", () => {
421
+ it("应请求正确的 URL", async () => {
422
+ fetchSpy.mockResolvedValue(mockResponse([]));
423
+ await adapter.getIssueCommentReactions("owner", "repo", 10);
424
+ expect(fetchSpy).toHaveBeenCalledWith(
425
+ "https://gitea.example.com/api/v1/repos/owner/repo/issues/comments/10/reactions",
426
+ expect.anything(),
427
+ );
428
+ });
429
+ });
430
+
431
+ describe("getIssueReactions", () => {
432
+ it("应请求正确的 URL", async () => {
433
+ fetchSpy.mockResolvedValue(mockResponse([]));
434
+ await adapter.getIssueReactions("owner", "repo", 5);
435
+ expect(fetchSpy).toHaveBeenCalledWith(
436
+ "https://gitea.example.com/api/v1/repos/owner/repo/issues/5/reactions",
437
+ expect.anything(),
438
+ );
439
+ });
440
+ });
441
+
442
+ // ============ 用户操作 ============
443
+
444
+ describe("searchUsers", () => {
445
+ it("应请求 /users/search 并解构 data 字段", async () => {
446
+ const users = [{ id: 1, login: "test" }];
447
+ fetchSpy.mockResolvedValue(mockResponse({ data: users }));
448
+ const result = await adapter.searchUsers("test", 5);
449
+ expect(result).toEqual(users);
450
+ expect(fetchSpy).toHaveBeenCalledWith(
451
+ expect.stringContaining("/users/search?q=test&limit=5"),
452
+ expect.anything(),
453
+ );
454
+ });
455
+
456
+ it("data 为空时应返回空数组", async () => {
457
+ fetchSpy.mockResolvedValue(mockResponse({ data: null }));
458
+ const result = await adapter.searchUsers("nobody");
459
+ expect(result).toEqual([]);
460
+ });
461
+ });
462
+
463
+ describe("getTeamMembers", () => {
464
+ it("应请求 /teams/{id}/members", async () => {
465
+ fetchSpy.mockResolvedValue(mockResponse([{ id: 1 }]));
466
+ await adapter.getTeamMembers(99);
467
+ expect(fetchSpy).toHaveBeenCalledWith(
468
+ "https://gitea.example.com/api/v1/teams/99/members",
469
+ expect.anything(),
470
+ );
471
+ });
472
+ });
473
+ });