@spaceflow/review 0.76.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaceflow/review",
3
- "version": "0.76.0",
3
+ "version": "0.77.0",
4
4
  "description": "Spaceflow 代码审查插件,使用 LLM 对 PR 代码进行自动审查",
5
5
  "license": "MIT",
6
6
  "author": "Lydanne",
@@ -28,7 +28,7 @@
28
28
  "@spaceflow/cli": "0.40.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@spaceflow/core": "0.29.0"
31
+ "@spaceflow/core": "0.30.0"
32
32
  },
33
33
  "spaceflow": {
34
34
  "type": "flow",
@@ -12,6 +12,7 @@ import {
12
12
  } from "@spaceflow/core";
13
13
  import micromatch from "micromatch";
14
14
  import type { DeletionImpactResult } from "./review-spec";
15
+ import { extractGlobsFromIncludes } from "./review-includes-filter";
15
16
  import { spawn } from "child_process";
16
17
 
17
18
  export interface DeletedCodeBlock {
@@ -160,11 +161,12 @@ export class DeletionImpactService {
160
161
  console.log(` 📦 发现 ${deletedBlocks.length} 个删除的代码块`);
161
162
  }
162
163
 
163
- // 1.5 使用 includes 过滤文件
164
+ // 1.5 使用 includes 过滤文件(删除分析模式中只按 glob 匹配,不区分 status)
164
165
  if (context.includes && context.includes.length > 0) {
165
166
  const beforeCount = deletedBlocks.length;
167
+ const globs = extractGlobsFromIncludes(context.includes);
166
168
  const filenames = deletedBlocks.map((b) => b.file);
167
- const matchedFilenames = micromatch(filenames, context.includes);
169
+ const matchedFilenames = micromatch(filenames, globs);
168
170
  deletedBlocks = deletedBlocks.filter((b) => matchedFilenames.includes(b.file));
169
171
  if (shouldLog(verbose, 1)) {
170
172
  console.log(` 🔍 Includes 过滤: ${beforeCount} -> ${deletedBlocks.length} 个删除块`);
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import "./locales";
2
2
  export * from "./review-spec";
3
3
  export * from "./review-report";
4
+ export { PullRequestModel } from "./pull-request-model";
5
+ export { ReviewResultModel } from "./review-result-model";
6
+ export type { ReviewResultModelDeps, ReviewResultSaveOptions } from "./review-result-model";
4
7
  import { defineExtension, t } from "@spaceflow/core";
5
8
  import type {
6
9
  GitProviderService,
@@ -10,7 +13,7 @@ import type {
10
13
  LocalReviewMode,
11
14
  } from "@spaceflow/core";
12
15
  import { parseVerbose } from "@spaceflow/core";
13
- import { reviewSchema, type AnalyzeDeletionsMode } from "./review.config";
16
+ import { reviewSchema, type AnalyzeDeletionsMode, type ReviewConfig } from "./review.config";
14
17
  import { ReviewService } from "./review.service";
15
18
  import { ReviewSpecService } from "./review-spec";
16
19
  import { ReviewReportService, type ReportFormat } from "./review-report";
@@ -54,6 +57,7 @@ export const extension = defineExtension({
54
57
  { flags: "--event-action <action>", description: t("review:options.eventAction") },
55
58
  { flags: "--local [mode]", description: t("review:options.local") },
56
59
  { flags: "--no-local", description: t("review:options.noLocal") },
60
+ { flags: "--fail-on-issues [mode]", description: t("review:options.failOnIssues") },
57
61
  ],
58
62
  run: async (_args, options, ctx) => {
59
63
  const isFlush = !!options?.flush;
@@ -113,8 +117,18 @@ export const extension = defineExtension({
113
117
  flush: isFlush,
114
118
  eventAction: options?.eventAction as string,
115
119
  local: parseLocalOption(options?.local),
120
+ failOnIssues: parseFailOnIssues(options?.failOnIssues),
116
121
  };
117
122
 
123
+ function parseFailOnIssues(
124
+ value: unknown,
125
+ ): "off" | "warn" | "error" | "warn+error" | undefined {
126
+ if (value === true || value === "") return "error";
127
+ if (value === "warn" || value === "error" || value === "off" || value === "warn+error")
128
+ return value;
129
+ return undefined;
130
+ }
131
+
118
132
  function parseLocalOption(value: unknown): LocalReviewMode | undefined {
119
133
  if (value === false) return false;
120
134
  if (value === true || value === undefined || value === "") return undefined;
@@ -124,7 +138,25 @@ export const extension = defineExtension({
124
138
 
125
139
  try {
126
140
  const context = await reviewService.getContextFromEnv(reviewOptions);
127
- await reviewService.execute(context);
141
+ const result = await reviewService.execute(context);
142
+ const effectiveFailOnIssues: "off" | "warn" | "error" | "warn+error" =
143
+ reviewOptions.failOnIssues ??
144
+ ctx.config.getPluginConfig<ReviewConfig>("review")?.failOnIssues ??
145
+ "off";
146
+ if (effectiveFailOnIssues !== "off") {
147
+ const blockers = result.issues.filter((issue) => {
148
+ if (issue.fixed || issue.resolved || issue.valid === "false") return false;
149
+ if (effectiveFailOnIssues === "warn") return issue.severity === "warn";
150
+ if (effectiveFailOnIssues === "error") return issue.severity === "error";
151
+ return issue.severity === "error" || issue.severity === "warn"; // warn+error
152
+ });
153
+ if (blockers.length > 0) {
154
+ ctx.output.error(
155
+ `审核不通过:存在 ${blockers.length} 个未解决的问题(模式: ${effectiveFailOnIssues})`,
156
+ );
157
+ process.exit(1);
158
+ }
159
+ }
128
160
  } catch (error) {
129
161
  if (error instanceof Error) {
130
162
  ctx.output.error(t("common.executionFailed", { error: error.message }));
@@ -4,7 +4,7 @@
4
4
  "options.prNumber": "PR number, auto-detected from env if not specified",
5
5
  "options.base": "Base branch/tag for diff comparison",
6
6
  "options.head": "Head branch/tag for diff comparison",
7
- "options.includes": "File glob patterns to review, e.g. *.ts *.js (can be specified multiple times)",
7
+ "options.includes": "File glob patterns to review, e.g. *.ts *.js (can be specified multiple times). Supports change-type prefixes: added|*.ts (new files only), modified|*.ts (modified only), deleted|*.ts (deleted only)",
8
8
  "options.llmMode": "LLM mode: claude-code, openai, gemini",
9
9
  "options.files": "Only review specified files (space-separated)",
10
10
  "options.commits": "Only review specified commits (space-separated)",
@@ -21,6 +21,7 @@
21
21
  "options.eventAction": "PR event type (opened, synchronize, closed, etc.), closed only collects stats without AI review",
22
22
  "options.local": "Review local uncommitted code. Mode: 'uncommitted' (default, staged + working) or 'staged' (only staged)",
23
23
  "options.noLocal": "Disable local mode, use branch comparison instead",
24
+ "options.failOnIssues": "Fail the workflow when unresolved issues exist. Mode: warn (warn-level only) / error (error-level only) / warn+error (either warn or error). Passing no mode is equivalent to error",
24
25
  "extensionDescription": "Code review command using LLM for automated PR review",
25
26
  "mcp.serverDescription": "Code review rules query service",
26
27
  "mcp.listRules": "List all code review rules for the current project, returning rule list with ID, title, description, applicable file extensions, etc.",
@@ -4,7 +4,7 @@
4
4
  "options.prNumber": "PR 编号,如果不指定则从环境变量获取",
5
5
  "options.base": "基准分支/tag,用于比较差异",
6
6
  "options.head": "目标分支/tag,用于比较差异",
7
- "options.includes": "要审查的文件 glob 模式,如 *.ts *.js(可多次指定)",
7
+ "options.includes": "要审查的文件 glob 模式,如 *.ts *.js(可多次指定)。支持变更类型前缀: added|*.ts(仅新增)、modified|*.ts(仅修改)、deleted|*.ts(仅删除)",
8
8
  "options.llmMode": "使用的 LLM 模式: claude-code, openai, gemini",
9
9
  "options.files": "仅审查指定的文件(空格分隔)",
10
10
  "options.commits": "仅审查指定的 commits(空格分隔)",
@@ -21,6 +21,7 @@
21
21
  "options.eventAction": "PR 事件类型(opened, synchronize, closed 等),closed 时仅收集统计不进行 AI 审查",
22
22
  "options.local": "审查本地未提交的代码。模式: 'uncommitted'(默认,暂存区+工作区)或 'staged'(仅暂存区)",
23
23
  "options.noLocal": "禁用本地模式,使用分支比较",
24
+ "options.failOnIssues": "存在未解决问题时工作流抛出异常。模式: warn(仅 warn 问题)/ error(仅 error 问题)/ warn&error(warn 或 error 问题)。不加模式等同 error",
24
25
  "extensionDescription": "代码审查命令,使用 LLM 对 PR 代码进行自动审查",
25
26
  "mcp.serverDescription": "代码审查规则查询服务",
26
27
  "mcp.listRules": "获取当前项目的所有代码审查规则,返回规则列表包含 ID、标题、描述、适用的文件扩展名等信息",
@@ -0,0 +1,236 @@
1
+ import {
2
+ GitProviderService,
3
+ PullRequest,
4
+ PullRequestCommit,
5
+ ChangedFile,
6
+ CommitInfo,
7
+ IssueComment,
8
+ CreateIssueCommentOption,
9
+ CreatePullReviewOption,
10
+ PullReview,
11
+ PullReviewComment,
12
+ EditPullRequestOption,
13
+ Reaction,
14
+ ResolvedThread,
15
+ } from "@spaceflow/core";
16
+
17
+ /**
18
+ * PR 数据模型映射层
19
+ * 封装所有 PR 相关的读写操作,消除 owner/repo/prNumber 三元组的散传
20
+ * 读操作带懒加载缓存,写操作直通 gitProvider
21
+ */
22
+ export class PullRequestModel {
23
+ private _info: PullRequest | null = null;
24
+ private _commits: PullRequestCommit[] | null = null;
25
+ private _files: ChangedFile[] | null = null;
26
+ private _diff: string | null = null;
27
+ private _comments: IssueComment[] | null = null;
28
+ private _reviews: PullReview[] | null = null;
29
+
30
+ constructor(
31
+ private readonly gitProvider: GitProviderService,
32
+ readonly owner: string,
33
+ readonly repo: string,
34
+ readonly number: number,
35
+ ) {}
36
+
37
+ // ============ 懒加载数据(带缓存) ============
38
+
39
+ /** 获取 PR 基本信息(懒加载 + 缓存) */
40
+ async getInfo(): Promise<PullRequest> {
41
+ if (!this._info) {
42
+ this._info = await this.gitProvider.getPullRequest(this.owner, this.repo, this.number);
43
+ }
44
+ return this._info;
45
+ }
46
+
47
+ /** 获取 head commit SHA */
48
+ async getHeadSha(): Promise<string> {
49
+ const info = await this.getInfo();
50
+ return info.head?.sha || "HEAD";
51
+ }
52
+
53
+ /** 获取 PR 的所有 commits(懒加载 + 缓存) */
54
+ async getCommits(): Promise<PullRequestCommit[]> {
55
+ if (!this._commits) {
56
+ this._commits = await this.gitProvider.getPullRequestCommits(
57
+ this.owner,
58
+ this.repo,
59
+ this.number,
60
+ );
61
+ }
62
+ return [...this._commits];
63
+ }
64
+
65
+ /** 获取 PR 的变更文件列表(懒加载 + 缓存) */
66
+ async getFiles(): Promise<ChangedFile[]> {
67
+ if (!this._files) {
68
+ this._files = await this.gitProvider.getPullRequestFiles(this.owner, this.repo, this.number);
69
+ }
70
+ return [...this._files];
71
+ }
72
+
73
+ /** 获取 PR 的 diff 文本(懒加载 + 缓存) */
74
+ async getDiff(): Promise<string> {
75
+ if (!this._diff) {
76
+ this._diff = await this.gitProvider.getPullRequestDiff(this.owner, this.repo, this.number);
77
+ }
78
+ return this._diff;
79
+ }
80
+
81
+ // ============ PR 编辑 ============
82
+
83
+ /** 编辑 PR(标题、描述等),自动 invalidate info 缓存 */
84
+ async edit(options: EditPullRequestOption): Promise<PullRequest> {
85
+ const result = await this.gitProvider.editPullRequest(
86
+ this.owner,
87
+ this.repo,
88
+ this.number,
89
+ options,
90
+ );
91
+ this._info = result;
92
+ return result;
93
+ }
94
+
95
+ // ============ Issue Comments(PR 主评论) ============
96
+
97
+ /** 列出 PR 的所有 Issue Comments(懒加载 + 缓存) */
98
+ async getComments(): Promise<IssueComment[]> {
99
+ if (!this._comments) {
100
+ this._comments = await this.gitProvider.listIssueComments(this.owner, this.repo, this.number);
101
+ }
102
+ return [...this._comments];
103
+ }
104
+
105
+ /** 创建 Issue Comment */
106
+ async createComment(options: CreateIssueCommentOption): Promise<IssueComment> {
107
+ const result = await this.gitProvider.createIssueComment(
108
+ this.owner,
109
+ this.repo,
110
+ this.number,
111
+ options,
112
+ );
113
+ this._comments = null; // invalidate
114
+ return result;
115
+ }
116
+
117
+ /** 更新 Issue Comment */
118
+ async updateComment(commentId: number, body: string): Promise<IssueComment> {
119
+ const result = await this.gitProvider.updateIssueComment(
120
+ this.owner,
121
+ this.repo,
122
+ commentId,
123
+ body,
124
+ );
125
+ this._comments = null; // invalidate
126
+ return result;
127
+ }
128
+
129
+ /** 删除 Issue Comment */
130
+ async deleteComment(commentId: number): Promise<void> {
131
+ await this.gitProvider.deleteIssueComment(this.owner, this.repo, commentId);
132
+ this._comments = null; // invalidate
133
+ }
134
+
135
+ // ============ PR Reviews(行级评论) ============
136
+
137
+ /** 列出 PR 的所有 Reviews(懒加载 + 缓存) */
138
+ async getReviews(): Promise<PullReview[]> {
139
+ if (!this._reviews) {
140
+ this._reviews = await this.gitProvider.listPullReviews(this.owner, this.repo, this.number);
141
+ }
142
+ return [...this._reviews];
143
+ }
144
+
145
+ /** 创建 PR Review */
146
+ async createReview(options: CreatePullReviewOption): Promise<PullReview> {
147
+ const result = await this.gitProvider.createPullReview(
148
+ this.owner,
149
+ this.repo,
150
+ this.number,
151
+ options,
152
+ );
153
+ this._reviews = null; // invalidate
154
+ return result;
155
+ }
156
+
157
+ /** 删除 PR Review */
158
+ async deleteReview(reviewId: number): Promise<void> {
159
+ await this.gitProvider.deletePullReview(this.owner, this.repo, this.number, reviewId);
160
+ this._reviews = null; // invalidate
161
+ }
162
+
163
+ /** 获取 PR Review 的行级评论列表 */
164
+ async getReviewComments(reviewId: number): Promise<PullReviewComment[]> {
165
+ return this.gitProvider.listPullReviewComments(this.owner, this.repo, this.number, reviewId);
166
+ }
167
+
168
+ /** 获取已解决的 review threads */
169
+ async getResolvedThreads(): Promise<ResolvedThread[]> {
170
+ return this.gitProvider.listResolvedThreads(this.owner, this.repo, this.number);
171
+ }
172
+
173
+ // ============ Reaction 操作 ============
174
+
175
+ /** 获取 PR Review 行级评论的 reactions */
176
+ async getReviewCommentReactions(commentId: number): Promise<Reaction[]> {
177
+ return this.gitProvider.getPullReviewCommentReactions(this.owner, this.repo, commentId);
178
+ }
179
+
180
+ // ============ 仓库级操作(需要 owner/repo 但不需要 prNumber) ============
181
+
182
+ /** 获取文件内容 */
183
+ async getFileContent(filepath: string, ref?: string): Promise<string> {
184
+ return this.gitProvider.getFileContent(this.owner, this.repo, filepath, ref);
185
+ }
186
+
187
+ /** 获取单个 commit 信息 */
188
+ async getCommit(sha: string): Promise<CommitInfo> {
189
+ return this.gitProvider.getCommit(this.owner, this.repo, sha);
190
+ }
191
+
192
+ /** 获取单个 commit 的 diff */
193
+ async getCommitDiff(sha: string): Promise<string> {
194
+ return this.gitProvider.getCommitDiff(this.owner, this.repo, sha);
195
+ }
196
+
197
+ /** 列出 workflow runs */
198
+ async listWorkflowRuns(options?: { status?: string; sha?: string }) {
199
+ return this.gitProvider.listWorkflowRuns(this.owner, this.repo, options);
200
+ }
201
+
202
+ // ============ 缓存控制 ============
203
+
204
+ /** 清除指定或所有缓存 */
205
+ invalidate(key?: "info" | "commits" | "files" | "diff" | "comments" | "reviews"): void {
206
+ if (key) {
207
+ switch (key) {
208
+ case "info":
209
+ this._info = null;
210
+ break;
211
+ case "commits":
212
+ this._commits = null;
213
+ break;
214
+ case "files":
215
+ this._files = null;
216
+ break;
217
+ case "diff":
218
+ this._diff = null;
219
+ break;
220
+ case "comments":
221
+ this._comments = null;
222
+ break;
223
+ case "reviews":
224
+ this._reviews = null;
225
+ break;
226
+ }
227
+ } else {
228
+ this._info = null;
229
+ this._commits = null;
230
+ this._files = null;
231
+ this._diff = null;
232
+ this._comments = null;
233
+ this._reviews = null;
234
+ }
235
+ }
236
+ }