@spaceflow/review 0.29.3 → 0.31.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.
@@ -1,12 +1,11 @@
1
1
  import {
2
- Injectable,
3
- ConfigService,
4
2
  ConfigReaderService,
5
3
  GitProviderService,
6
4
  PullRequest,
7
5
  PullRequestCommit,
8
6
  ChangedFile,
9
7
  CreatePullReviewComment,
8
+ REVIEW_STATE,
10
9
  CiConfig,
11
10
  type LLMMode,
12
11
  LlmProxyService,
@@ -24,6 +23,7 @@ import {
24
23
  parseHunksFromPatch,
25
24
  calculateNewLineNumber,
26
25
  } from "@spaceflow/core";
26
+ import type { IConfigReader } from "@spaceflow/core";
27
27
  import { type AnalyzeDeletionsMode, type ReviewConfig } from "./review.config";
28
28
  import {
29
29
  ReviewSpecService,
@@ -41,7 +41,7 @@ import { execSync } from "child_process";
41
41
  import { readFile, readdir } from "fs/promises";
42
42
  import { join, dirname, extname, relative, isAbsolute } from "path";
43
43
  import micromatch from "micromatch";
44
- import { ReviewOptions } from "./review.command";
44
+ import { ReviewOptions } from "./review.config";
45
45
  import { IssueVerifyService } from "./issue-verify.service";
46
46
  import { DeletionImpactService } from "./deletion-impact.service";
47
47
  import { parseTitleOptions } from "./parse-title-options";
@@ -95,6 +95,7 @@ export interface LLMReviewOptions {
95
95
  }
96
96
 
97
97
  const REVIEW_COMMENT_MARKER = "<!-- spaceflow-review -->";
98
+ const REVIEW_LINE_COMMENTS_MARKER = "<!-- spaceflow-review-lines -->";
98
99
 
99
100
  const REVIEW_SCHEMA: LlmJsonPutSchema = {
100
101
  type: "object",
@@ -138,13 +139,12 @@ const REVIEW_SCHEMA: LlmJsonPutSchema = {
138
139
  additionalProperties: false,
139
140
  };
140
141
 
141
- @Injectable()
142
142
  export class ReviewService {
143
143
  protected readonly llmJsonPut: LlmJsonPut<ReviewResult>;
144
144
 
145
145
  constructor(
146
146
  protected readonly gitProvider: GitProviderService,
147
- protected readonly configService: ConfigService,
147
+ protected readonly config: IConfigReader,
148
148
  protected readonly configReader: ConfigReaderService,
149
149
  protected readonly reviewSpecService: ReviewSpecService,
150
150
  protected readonly llmProxyService: LlmProxyService,
@@ -172,7 +172,7 @@ export class ReviewService {
172
172
 
173
173
  async getContextFromEnv(options: ReviewOptions): Promise<ReviewContext> {
174
174
  const reviewConf = this.configReader.getPluginConfig<ReviewConfig>("review");
175
- const ciConf = this.configService.get<CiConfig>("ci");
175
+ const ciConf = this.config.get<CiConfig>("ci");
176
176
  const repository = ciConf?.repository;
177
177
 
178
178
  if (options.ci) {
@@ -291,6 +291,7 @@ export class ReviewService {
291
291
  retryDelay: options.retryDelay ?? reviewConf.retryDelay ?? 1000,
292
292
  generateDescription: options.generateDescription ?? reviewConf.generateDescription ?? false,
293
293
  showAll: options.showAll ?? false,
294
+ flush: options.flush ?? false,
294
295
  eventAction: options.eventAction,
295
296
  };
296
297
  }
@@ -405,8 +406,8 @@ export class ReviewService {
405
406
  return this.executeDeletionOnly(context);
406
407
  }
407
408
 
408
- // 如果是 closed 事件,仅收集 review 状态
409
- if (context.eventAction === "closed") {
409
+ // 如果是 closed 事件或 flush 模式,仅收集 review 状态
410
+ if (context.eventAction === "closed" || context.flush) {
410
411
  return this.executeCollectOnly(context);
411
412
  }
412
413
 
@@ -827,7 +828,7 @@ export class ReviewService {
827
828
  }
828
829
 
829
830
  /**
830
- * 仅收集 review 状态模式(用于 PR 关闭时)
831
+ * 仅收集 review 状态模式(用于 PR 关闭或 --flush 指令)
831
832
  * 从现有的 AI review 评论中读取问题状态,同步已解决/无效状态,输出统计信息
832
833
  */
833
834
  protected async executeCollectOnly(context: ReviewContext): Promise<ReviewResult> {
@@ -1875,14 +1876,14 @@ ${fileChanges || "无"}`;
1875
1876
  }
1876
1877
  }
1877
1878
 
1878
- // 获取已解决的评论,同步 fixed 状态(在删除旧 review 之前)
1879
+ // 获取已解决的评论,同步 fixed 状态(在更新 review 之前)
1879
1880
  await this.syncResolvedComments(owner, repo, prNumber, result);
1880
1881
 
1881
1882
  // 获取评论的 reactions,同步 valid 状态(👎 标记为无效)
1882
1883
  await this.syncReactionsToIssues(owner, repo, prNumber, result, verbose);
1883
1884
 
1884
- // 删除已有的 AI review(避免重复评论)
1885
- await this.deleteExistingAiReviews(owner, repo, prNumber);
1885
+ // 查找已有的 AI 评论(Issue Comment)
1886
+ const existingComment = await this.findExistingAiComment(owner, repo, prNumber);
1886
1887
 
1887
1888
  // 调试:检查 issues 是否有 author
1888
1889
  if (shouldLog(verbose, 3)) {
@@ -1903,7 +1904,55 @@ ${fileChanges || "无"}`;
1903
1904
  const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
1904
1905
  const commitId = pr.head?.sha;
1905
1906
 
1906
- // 构建行级评论(根据配置决定是否启用)
1907
+ // 1. 发布或更新主评论(使用 Issue Comment API,支持删除和更新)
1908
+ try {
1909
+ if (existingComment?.id) {
1910
+ await this.gitProvider.updateIssueComment(owner, repo, existingComment.id, reviewBody);
1911
+ console.log(`✅ 已更新 AI Review 评论`);
1912
+ } else {
1913
+ await this.gitProvider.createIssueComment(owner, repo, prNumber, { body: reviewBody });
1914
+ console.log(`✅ 已发布 AI Review 评论`);
1915
+ }
1916
+ } catch (error) {
1917
+ console.warn("⚠️ 发布/更新 AI Review 评论失败:", error);
1918
+ }
1919
+
1920
+ // 2. 删除旧的行级评论(逐条删除 PR Review Comment)
1921
+ try {
1922
+ const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
1923
+ const oldLineReviews = reviews.filter((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
1924
+ for (const review of oldLineReviews) {
1925
+ if (review.id) {
1926
+ const reviewComments = await this.gitProvider.listPullReviewComments(
1927
+ owner,
1928
+ repo,
1929
+ prNumber,
1930
+ review.id,
1931
+ );
1932
+ for (const comment of reviewComments) {
1933
+ if (comment.id) {
1934
+ try {
1935
+ await this.gitProvider.deletePullReviewComment(owner, repo, comment.id);
1936
+ } catch {
1937
+ // 删除失败忽略
1938
+ }
1939
+ }
1940
+ }
1941
+ // 评论删除后尝试删除 review 本身
1942
+ try {
1943
+ await this.gitProvider.deletePullReview(owner, repo, prNumber, review.id);
1944
+ } catch {
1945
+ // 已提交的 review 无法删除,忽略
1946
+ }
1947
+ }
1948
+ }
1949
+ if (oldLineReviews.length > 0) {
1950
+ console.log(`🗑️ 已清理 ${oldLineReviews.length} 个旧的行级评论 review`);
1951
+ }
1952
+ } catch (error) {
1953
+ console.warn("⚠️ 清理旧行级评论失败:", error);
1954
+ }
1955
+ // 3. 发布新的行级评论(使用 PR Review API)
1907
1956
  let comments: CreatePullReviewComment[] = [];
1908
1957
  if (reviewConf.lineComments) {
1909
1958
  comments = result.issues
@@ -1911,24 +1960,61 @@ ${fileChanges || "无"}`;
1911
1960
  .map((issue) => this.issueToReviewComment(issue))
1912
1961
  .filter((comment): comment is CreatePullReviewComment => comment !== null);
1913
1962
  }
1963
+ if (comments.length > 0) {
1964
+ try {
1965
+ await this.gitProvider.createPullReview(owner, repo, prNumber, {
1966
+ event: REVIEW_STATE.COMMENT,
1967
+ body: REVIEW_LINE_COMMENTS_MARKER,
1968
+ comments,
1969
+ commit_id: commitId,
1970
+ });
1971
+ console.log(`✅ 已发布 ${comments.length} 条行级评论`);
1972
+ } catch {
1973
+ // 批量失败时逐条发布,跳过无法定位的评论
1974
+ console.warn("⚠️ 批量发布行级评论失败,尝试逐条发布...");
1975
+ let successCount = 0;
1976
+ for (const comment of comments) {
1977
+ try {
1978
+ await this.gitProvider.createPullReview(owner, repo, prNumber, {
1979
+ event: REVIEW_STATE.COMMENT,
1980
+ body: successCount === 0 ? REVIEW_LINE_COMMENTS_MARKER : undefined,
1981
+ comments: [comment],
1982
+ commit_id: commitId,
1983
+ });
1984
+ successCount++;
1985
+ } catch {
1986
+ console.warn(`⚠️ 跳过无法定位的评论: ${comment.path}:${comment.new_position}`);
1987
+ }
1988
+ }
1989
+ if (successCount > 0) {
1990
+ console.log(`✅ 逐条发布成功 ${successCount}/${comments.length} 条行级评论`);
1991
+ } else {
1992
+ console.warn("⚠️ 所有行级评论均无法定位,已跳过");
1993
+ }
1994
+ }
1995
+ }
1996
+ }
1914
1997
 
1998
+ /**
1999
+ * 查找已有的 AI 评论(Issue Comment)
2000
+ */
2001
+ protected async findExistingAiComment(
2002
+ owner: string,
2003
+ repo: string,
2004
+ prNumber: number,
2005
+ ): Promise<{ id: number } | null> {
1915
2006
  try {
1916
- // 使用 PR Review 发布主评论 + 行级评论(合并为一个消息块)
1917
- await this.gitProvider.createPullReview(owner, repo, prNumber, {
1918
- event: "COMMENT",
1919
- body: reviewBody,
1920
- comments,
1921
- commit_id: commitId,
1922
- });
1923
- const lineMsg = comments.length > 0 ? `,包含 ${comments.length} 条行级评论` : "";
1924
- console.log(`✅ 已发布 AI Review${lineMsg}`);
1925
- } catch (error) {
1926
- console.warn("⚠️ 发布 AI Review 失败:", error);
2007
+ const comments = await this.gitProvider.listIssueComments(owner, repo, prNumber);
2008
+ const aiComment = comments.find((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
2009
+ return aiComment?.id ? { id: aiComment.id } : null;
2010
+ } catch {
2011
+ return null;
1927
2012
  }
1928
2013
  }
1929
2014
 
1930
2015
  /**
1931
- * 从旧的 AI review 中获取已解决的评论,同步 fixed 状态到 result.issues
2016
+ * PR 的所有 resolved review threads 中同步 fixed 状态到 result.issues
2017
+ * 直接通过 GraphQL 查询所有 resolved threads 的 path+line,匹配 issues
1932
2018
  */
1933
2019
  protected async syncResolvedComments(
1934
2020
  owner: string,
@@ -1937,31 +2023,16 @@ ${fileChanges || "无"}`;
1937
2023
  result: ReviewResult,
1938
2024
  ): Promise<void> {
1939
2025
  try {
1940
- const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
1941
- const aiReview = reviews.find((r) => r.body?.includes(REVIEW_COMMENT_MARKER));
1942
- if (!aiReview?.id) {
2026
+ const resolvedThreads = await this.gitProvider.listResolvedThreads(owner, repo, prNumber);
2027
+ if (resolvedThreads.length === 0) {
1943
2028
  return;
1944
2029
  }
1945
- // 获取该 review 的所有行级评论
1946
- const reviewComments = await this.gitProvider.listPullReviewComments(
1947
- owner,
1948
- repo,
1949
- prNumber,
1950
- aiReview.id,
1951
- );
1952
- // 找出已解决的评论(resolver 不为 null)
1953
- const resolvedComments = reviewComments.filter(
1954
- (c) => c.resolver !== null && c.resolver !== undefined,
1955
- );
1956
- if (resolvedComments.length === 0) {
1957
- return;
1958
- }
1959
- // 根据文件路径和行号匹配 issues,标记为已解决
1960
2030
  const now = new Date().toISOString();
1961
- for (const comment of resolvedComments) {
2031
+ for (const thread of resolvedThreads) {
2032
+ if (!thread.path) continue;
1962
2033
  const matchedIssue = result.issues.find(
1963
2034
  (issue) =>
1964
- issue.file === comment.path && this.lineMatchesPosition(issue.line, comment.position),
2035
+ issue.file === thread.path && this.lineMatchesPosition(issue.line, thread.line),
1965
2036
  );
1966
2037
  if (matchedIssue && !matchedIssue.fixed) {
1967
2038
  matchedIssue.fixed = now;
@@ -2000,7 +2071,7 @@ ${fileChanges || "无"}`;
2000
2071
  ): Promise<void> {
2001
2072
  try {
2002
2073
  const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
2003
- const aiReview = reviews.find((r) => r.body?.includes(REVIEW_COMMENT_MARKER));
2074
+ const aiReview = reviews.find((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
2004
2075
  if (!aiReview?.id) {
2005
2076
  if (shouldLog(verbose, 2)) {
2006
2077
  console.log(`[syncReactionsToIssues] No AI review found`);
@@ -2013,7 +2084,7 @@ ${fileChanges || "无"}`;
2013
2084
 
2014
2085
  // 1. 从已提交的 review 中获取评审人(排除 AI bot)
2015
2086
  for (const review of reviews) {
2016
- if (review.user?.login && !review.body?.includes(REVIEW_COMMENT_MARKER)) {
2087
+ if (review.user?.login && !review.body?.includes(REVIEW_LINE_COMMENTS_MARKER)) {
2017
2088
  reviewers.add(review.user.login);
2018
2089
  }
2019
2090
  }
@@ -2090,7 +2161,7 @@ ${fileChanges || "无"}`;
2090
2161
  commentIdToIssue.set(comment.id, matchedIssue);
2091
2162
  }
2092
2163
  try {
2093
- const reactions = await this.gitProvider.getIssueCommentReactions(
2164
+ const reactions = await this.gitProvider.getPullReviewCommentReactions(
2094
2165
  owner,
2095
2166
  repo,
2096
2167
  comment.id,
@@ -2194,25 +2265,54 @@ ${fileChanges || "无"}`;
2194
2265
 
2195
2266
  /**
2196
2267
  * 删除已有的 AI review(通过 marker 识别)
2268
+ * - 删除行级评论的 PR Review(带 REVIEW_LINE_COMMENTS_MARKER)
2269
+ * - 删除主评论的 Issue Comment(带 REVIEW_COMMENT_MARKER)
2197
2270
  */
2198
2271
  protected async deleteExistingAiReviews(
2199
2272
  owner: string,
2200
2273
  repo: string,
2201
2274
  prNumber: number,
2202
2275
  ): Promise<void> {
2276
+ let deletedCount = 0;
2277
+ // 删除行级评论的 PR Review
2203
2278
  try {
2204
2279
  const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
2205
- const aiReviews = reviews.filter((r) => r.body?.includes(REVIEW_COMMENT_MARKER));
2280
+ const aiReviews = reviews.filter(
2281
+ (r) =>
2282
+ r.body?.includes(REVIEW_LINE_COMMENTS_MARKER) || r.body?.includes(REVIEW_COMMENT_MARKER),
2283
+ );
2206
2284
  for (const review of aiReviews) {
2207
2285
  if (review.id) {
2208
- await this.gitProvider.deletePullReview(owner, repo, prNumber, review.id);
2286
+ try {
2287
+ await this.gitProvider.deletePullReview(owner, repo, prNumber, review.id);
2288
+ deletedCount++;
2289
+ } catch {
2290
+ // 已提交的 review 无法删除,忽略
2291
+ }
2209
2292
  }
2210
2293
  }
2211
- if (aiReviews.length > 0) {
2212
- console.log(`🗑️ 已删除 ${aiReviews.length} 个旧的 AI review`);
2294
+ } catch (error) {
2295
+ console.warn("⚠️ 列出 PR reviews 失败:", error);
2296
+ }
2297
+ // 删除主评论的 Issue Comment
2298
+ try {
2299
+ const comments = await this.gitProvider.listIssueComments(owner, repo, prNumber);
2300
+ const aiComments = comments.filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
2301
+ for (const comment of aiComments) {
2302
+ if (comment.id) {
2303
+ try {
2304
+ await this.gitProvider.deleteIssueComment(owner, repo, comment.id);
2305
+ deletedCount++;
2306
+ } catch (error) {
2307
+ console.warn(`⚠️ 删除评论 ${comment.id} 失败:`, error);
2308
+ }
2309
+ }
2213
2310
  }
2214
2311
  } catch (error) {
2215
- console.warn("⚠️ 删除旧 AI review 失败:", error);
2312
+ console.warn("⚠️ 列出 issue comments 失败:", error);
2313
+ }
2314
+ if (deletedCount > 0) {
2315
+ console.log(`🗑️ 已删除 ${deletedCount} 个旧的 AI review`);
2216
2316
  }
2217
2317
  }
2218
2318
 
@@ -2501,12 +2601,11 @@ ${fileChanges || "无"}`;
2501
2601
  newIssues: ReviewIssue[],
2502
2602
  existingIssues: ReviewIssue[],
2503
2603
  ): { filteredIssues: ReviewIssue[]; skippedCount: number } {
2504
- // 只有 valid === 'true' 的历史问题才阻止新问题,其他情况允许覆盖
2505
- const existingKeys = new Set(
2506
- existingIssues
2507
- .filter((issue) => issue.valid === "true")
2508
- .map((issue) => this.generateIssueKey(issue)),
2509
- );
2604
+ // 所有历史问题(无论 valid 状态)都阻止新问题重复添加
2605
+ // valid='false' 的问题已被评审人标记为无效,不应再次报告
2606
+ // valid='true' 的问题已存在,无需重复
2607
+ // fixed 的问题已解决,无需重复
2608
+ const existingKeys = new Set(existingIssues.map((issue) => this.generateIssueKey(issue)));
2510
2609
  const filteredIssues = newIssues.filter(
2511
2610
  (issue) => !existingKeys.has(this.generateIssueKey(issue)),
2512
2611
  );
@@ -2520,11 +2619,11 @@ ${fileChanges || "无"}`;
2520
2619
  prNumber: number,
2521
2620
  ): Promise<ReviewResult | null> {
2522
2621
  try {
2523
- // 从 PR Review 获取已有的审查结果
2524
- const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
2525
- const existingReview = reviews.find((r) => r.body?.includes(REVIEW_COMMENT_MARKER));
2526
- if (existingReview?.body) {
2527
- return this.parseExistingReviewResult(existingReview.body);
2622
+ // 从 Issue Comment 获取已有的审查结果
2623
+ const comments = await this.gitProvider.listIssueComments(owner, repo, prNumber);
2624
+ const existingComment = comments.find((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
2625
+ if (existingComment?.body) {
2626
+ return this.parseExistingReviewResult(existingComment.body);
2528
2627
  }
2529
2628
  } catch (error) {
2530
2629
  console.warn("⚠️ 获取已有评论失败:", error);
package/tsconfig.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": "../../core/tsconfig.skill.json",
2
+ "extends": "../../packages/core/tsconfig.skill.json",
3
3
  "compilerOptions": {
4
4
  "types": ["vitest/globals"]
5
5
  },
@@ -1,42 +0,0 @@
1
- import {
2
- ApiProperty,
3
- ApiPropertyOptional,
4
- IsString,
5
- IsBoolean,
6
- IsOptional,
7
- t,
8
- } from "@spaceflow/core";
9
-
10
- export class ListRulesInput {
11
- @ApiPropertyOptional({ description: t("review:mcp.dto.cwd") })
12
- @IsString()
13
- @IsOptional()
14
- cwd?: string;
15
- }
16
-
17
- export class GetRulesForFileInput {
18
- @ApiProperty({ description: t("review:mcp.dto.filePath") })
19
- @IsString()
20
- filePath!: string;
21
-
22
- @ApiPropertyOptional({ description: t("review:mcp.dto.cwd") })
23
- @IsString()
24
- @IsOptional()
25
- cwd?: string;
26
-
27
- @ApiPropertyOptional({ description: t("review:mcp.dto.includeExamples") })
28
- @IsBoolean()
29
- @IsOptional()
30
- includeExamples?: boolean;
31
- }
32
-
33
- export class GetRuleDetailInput {
34
- @ApiProperty({ description: t("review:mcp.dto.ruleId") })
35
- @IsString()
36
- ruleId!: string;
37
-
38
- @ApiPropertyOptional({ description: t("review:mcp.dto.cwd") })
39
- @IsString()
40
- @IsOptional()
41
- cwd?: string;
42
- }
@@ -1,8 +0,0 @@
1
- import { Module } from "@nestjs/common";
2
- import { ReviewReportService } from "./review-report.service";
3
-
4
- @Module({
5
- providers: [ReviewReportService],
6
- exports: [ReviewReportService],
7
- })
8
- export class ReviewReportModule {}
@@ -1,10 +0,0 @@
1
- import { Module } from "@nestjs/common";
2
- import { GitProviderModule } from "@spaceflow/core";
3
- import { ReviewSpecService } from "./review-spec.service";
4
-
5
- @Module({
6
- imports: [GitProviderModule.forFeature()],
7
- providers: [ReviewSpecService],
8
- exports: [ReviewSpecService],
9
- })
10
- export class ReviewSpecModule {}