@ateriss_/aiv-cli 1.0.0 → 1.0.2

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
- "name": "@ateriss_/aiv-cli",
3
- "version": "1.0.0",
2
+ "name": "@ateriss_/aiv-cli",
3
+ "version": "1.0.2",
4
4
  "description": "AI-powered PR reviewer CLI — local-first, multi-agent semantic analysis",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -13,7 +13,14 @@
13
13
  "start": "node dist/index.js",
14
14
  "prepublishOnly": "npm run build"
15
15
  },
16
- "keywords": ["ai", "pr-review", "cli", "github", "claude", "openai"],
16
+ "keywords": [
17
+ "ai",
18
+ "pr-review",
19
+ "cli",
20
+ "github",
21
+ "claude",
22
+ "openai"
23
+ ],
17
24
  "author": "",
18
25
  "license": "MIT",
19
26
  "dependencies": {
@@ -1,5 +1,6 @@
1
1
  import { AgentResult, AgentFinding, PRDiff, AivRules } from '../types';
2
2
  import { LLMProvider } from '../providers/base';
3
+ import { getLang } from '../i18n';
3
4
 
4
5
  export interface AgentContext {
5
6
  projectContext: string;
@@ -36,6 +37,10 @@ export abstract class BaseAgent {
36
37
  .map(f => `### ${f.filename}\n\`\`\`diff\n${f.patch}\n\`\`\``)
37
38
  .join('\n\n');
38
39
 
40
+ const langInstruction = getLang() === 'es'
41
+ ? '\n\nIMPORTANT: Respond in Spanish. All string values in the JSON (summary, title, description, suggestion, possibleRegressions) must be written in Spanish.'
42
+ : '';
43
+
39
44
  return `## PR: #${ctx.diff.pr.number} — ${ctx.diff.pr.title}
40
45
 
41
46
  **Author:** ${ctx.diff.pr.author}
@@ -83,7 +88,7 @@ Analyze the above and return a JSON response matching this schema:
83
88
  "possibleRegressions": ["string"]
84
89
  }
85
90
 
86
- Return ONLY valid JSON. No markdown fences, no explanation outside the JSON.`;
91
+ Return ONLY valid JSON. No markdown fences, no explanation outside the JSON.${langInstruction}`;
87
92
  }
88
93
 
89
94
  protected parseResponse(raw: string): AgentResult {
@@ -2,13 +2,13 @@ import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
4
  import { resolveConfig, loadRules, getGithubToken, isInitialized } from '../../config';
5
- import type { ResolvedConfig } from '../../types';
5
+ import type { ResolvedConfig, ReviewResult } from '../../types';
6
6
  import { GithubClient } from '../../git/github';
7
7
  import { detectRepoInfo } from '../../git/utils';
8
8
  import { Orchestrator } from '../../orchestrator';
9
9
  import { ContextManager, refreshContextFiles } from '../../context/manager';
10
- import { renderReview } from '../renderer';
11
- import { selectPR, selectPostReviewAction } from '../selector';
10
+ import { renderReview, buildAivComment } from '../renderer';
11
+ import { selectPR, selectPostReviewAction, confirmMerge, selectMergeStrategy } from '../selector';
12
12
  import { t } from '../../i18n';
13
13
 
14
14
  // ─── Shared review runner (used by prs.ts and review command) ─────────────────
@@ -27,15 +27,14 @@ export async function runReview(opts: RunReviewOptions): Promise<void> {
27
27
  const { prNumber, owner, repo, config, token } = opts;
28
28
  const rules = loadRules();
29
29
  const activeAgents = opts.agents ?? ['business', 'architecture', 'security'];
30
+ const client = new GithubClient(token);
30
31
 
31
32
  console.log(chalk.bold(t().reviewTitle(prNumber)));
32
33
  console.log(chalk.dim(` ${t().reviewAccount(config.github.accountName, config.github.token_env)}\n`));
33
34
 
34
35
  const fetchSpinner = ora(t().reviewFetching(prNumber, `${owner}/${repo}`)).start();
35
36
  let prDiff: Awaited<ReturnType<GithubClient['getPRDiff']>>;
36
-
37
37
  try {
38
- const client = new GithubClient(token);
39
38
  prDiff = await client.getPRDiff(owner, repo, prNumber);
40
39
  fetchSpinner.succeed(t().reviewLoaded(chalk.cyan(prDiff.pr.title), prDiff.files.length));
41
40
  } catch (e: any) {
@@ -43,49 +42,110 @@ export async function runReview(opts: RunReviewOptions): Promise<void> {
43
42
  return;
44
43
  }
45
44
 
45
+ const result = await resolveResult(client, { owner, repo, prNumber }, config, rules, prDiff, activeAgents, opts.json);
46
+ if (!result) return;
47
+
48
+ if (opts.json) {
49
+ console.log(JSON.stringify(result, null, 2));
50
+ return;
51
+ }
52
+
53
+ renderReview(result);
54
+ if (!process.stdout.isTTY) return;
55
+
56
+ const action = await selectPostReviewAction(t().postReviewSelectAction);
57
+ if (action === 'skip') return;
58
+
59
+ if (action === 'post_comment') {
60
+ await doPostComment(client, owner, repo, prNumber, result);
61
+ return;
62
+ }
63
+
64
+ const submitted = await doSubmitReview(client, owner, repo, prNumber, action);
65
+ if (!submitted) return;
66
+
67
+ if (action === 'approve') {
68
+ await doApproveFlow(client, owner, repo, prNumber, result);
69
+ }
70
+ }
71
+
72
+ interface RepoRef { owner: string; repo: string; prNumber: number; }
73
+
74
+ async function resolveResult(
75
+ client: GithubClient,
76
+ ref: RepoRef,
77
+ config: ResolvedConfig, rules: ReturnType<typeof loadRules>,
78
+ prDiff: Awaited<ReturnType<GithubClient['getPRDiff']>>,
79
+ activeAgents: string[], json?: boolean,
80
+ ): Promise<ReviewResult | null> {
81
+ const { owner, repo, prNumber } = ref;
82
+ if (!json && process.stdout.isTTY) {
83
+ const cached = await client.findAivReview(owner, repo, prNumber);
84
+ if (cached) {
85
+ console.log(chalk.cyan(`\n ${t().reviewCachedFound}`));
86
+ const useCached = await confirmMerge(t().reviewCachedUse);
87
+ if (useCached) {
88
+ console.log(chalk.dim(` ${t().reviewCachedUsing}`));
89
+ return cached;
90
+ }
91
+ console.log(chalk.dim(` ${t().reviewCachedSkipping}`));
92
+ }
93
+ }
94
+
46
95
  const ctxSpinner = ora(t().reviewLoadingContext).start();
47
96
  const context = new ContextManager(process.cwd()).buildReviewContext(prDiff);
48
97
  ctxSpinner.succeed(t().reviewContextLoaded);
49
-
50
98
  console.log(chalk.dim(t().reviewRunningAgents(activeAgents.join(', '))));
51
99
 
52
100
  try {
53
- const result = await new Orchestrator(config, rules).run(prDiff, context, activeAgents);
54
- if (opts.json) {
55
- console.log(JSON.stringify(result, null, 2));
56
- return;
57
- }
58
- renderReview(result);
101
+ return await new Orchestrator(config, rules).run(prDiff, context, activeAgents);
59
102
  } catch (e: any) {
60
103
  console.log(chalk.red(t().reviewFailed(e.message)));
61
- return;
104
+ return null;
62
105
  }
106
+ }
63
107
 
64
- if (!process.stdout.isTTY) return;
65
-
66
- const action = await selectPostReviewAction(t().postReviewSelectAction);
67
- if (action === 'skip') return;
108
+ async function doPostComment(client: GithubClient, owner: string, repo: string, prNumber: number, result: ReviewResult): Promise<void> {
109
+ const spinner = ora(t().postReviewPostingComment).start();
110
+ try {
111
+ await client.postComment(owner, repo, prNumber, buildAivComment(result));
112
+ spinner.succeed(chalk.green(t().postReviewCommentPosted(prNumber)));
113
+ } catch (e: any) {
114
+ spinner.fail(chalk.red(t().postReviewCommentFailed(e.message)));
115
+ }
116
+ }
68
117
 
118
+ async function doSubmitReview(client: GithubClient, owner: string, repo: string, prNumber: number, action: 'approve' | 'request_changes'): Promise<boolean> {
69
119
  const event = action === 'approve' ? 'APPROVE' : 'REQUEST_CHANGES';
70
- const submitSpinner = ora(t().postReviewSubmitting).start();
71
-
120
+ const spinner = ora(t().postReviewSubmitting).start();
72
121
  try {
73
- await new GithubClient(token).submitReview(owner, repo, prNumber, event);
74
- if (action === 'approve') {
75
- submitSpinner.succeed(chalk.green(t().postReviewApproved(prNumber)));
76
- } else {
77
- submitSpinner.succeed(chalk.yellow(t().postReviewChangesRequested(prNumber)));
78
- }
122
+ await client.submitReview(owner, repo, prNumber, event);
123
+ const msg = action === 'approve' ? t().postReviewApproved(prNumber) : t().postReviewChangesRequested(prNumber);
124
+ const color = action === 'approve' ? chalk.green : chalk.yellow;
125
+ spinner.succeed(color(msg));
126
+ return true;
79
127
  } catch (e: any) {
80
- submitSpinner.fail(chalk.red(t().postReviewFailed(e.message)));
81
- return;
128
+ spinner.fail(chalk.red(t().postReviewFailed(e.message)));
129
+ return false;
82
130
  }
131
+ }
83
132
 
84
- if (action === 'approve') {
85
- const refreshSpinner = ora(t().postReviewRefreshing).start();
86
- await refreshContextFiles(process.cwd());
87
- refreshSpinner.succeed(chalk.green(t().postReviewRefreshed));
133
+ async function doApproveFlow(client: GithubClient, owner: string, repo: string, prNumber: number, result: ReviewResult): Promise<void> {
134
+ const wantsMerge = await confirmMerge(t().postReviewMergeConfirm);
135
+ if (wantsMerge) {
136
+ await doPostComment(client, owner, repo, prNumber, result);
137
+ const strategy = await selectMergeStrategy(t().postReviewSelectMerge);
138
+ const mergeSpinner = ora(t().postReviewMerging(prNumber)).start();
139
+ try {
140
+ await client.mergePR(owner, repo, prNumber, strategy);
141
+ mergeSpinner.succeed(chalk.green(t().postReviewMerged(prNumber)));
142
+ } catch (e: any) {
143
+ mergeSpinner.fail(chalk.red(t().postReviewMergeFailed(e.message)));
144
+ }
88
145
  }
146
+ const refreshSpinner = ora(t().postReviewRefreshing).start();
147
+ await refreshContextFiles(process.cwd());
148
+ refreshSpinner.succeed(chalk.green(t().postReviewRefreshed));
89
149
  }
90
150
 
91
151
  // ─── CLI command ──────────────────────────────────────────────────────────────
@@ -2,6 +2,67 @@ import chalk from 'chalk';
2
2
  import { ReviewResult, AgentFinding } from '../types';
3
3
  import { t } from '../i18n';
4
4
 
5
+ const SEVERITY_EMOJI: Record<string, string> = {
6
+ critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: '⚪',
7
+ };
8
+
9
+ const RISK_EMOJI: Record<string, string> = {
10
+ CRITICAL: '🔴', HIGH: '🟠', MEDIUM: '🟡', LOW: '🟢',
11
+ };
12
+
13
+ export function buildAivComment(result: ReviewResult): string {
14
+ const encoded = Buffer.from(JSON.stringify(result)).toString('base64');
15
+ const header = `<!-- aiv-review:${encoded} -->`;
16
+
17
+ const riskEmoji = RISK_EMOJI[result.riskLabel] ?? '⚪';
18
+ const lines: string[] = [
19
+ header,
20
+ '',
21
+ `## 🤖 aiv Review — PR #${result.prNumber} · ${result.prTitle}`,
22
+ '',
23
+ `**Risk Score:** ${riskEmoji} \`${result.riskScore}/100 ${result.riskLabel}\` · Generated: ${result.generatedAt}`,
24
+ '',
25
+ '---',
26
+ '',
27
+ '### Executive Summary',
28
+ result.executiveSummary,
29
+ ];
30
+
31
+ if (result.securityIssues.length > 0) {
32
+ lines.push('', '---', '', '### 🔴 Security Issues');
33
+ for (const f of result.securityIssues) lines.push(...findingMd(f));
34
+ }
35
+ if (result.businessRisks.length > 0) {
36
+ lines.push('', '---', '', '### 🟡 Business Risks');
37
+ for (const f of result.businessRisks) lines.push(...findingMd(f));
38
+ }
39
+ if (result.architectureIssues.length > 0) {
40
+ lines.push('', '---', '', '### 🔵 Architecture Issues');
41
+ for (const f of result.architectureIssues) lines.push(...findingMd(f));
42
+ }
43
+ if (result.possibleRegressions.length > 0) {
44
+ lines.push('', '---', '', '### ⚠️ Possible Regressions');
45
+ for (const r of result.possibleRegressions) lines.push(`- ${r}`);
46
+ }
47
+
48
+ lines.push('', '---', '', '<details>', '<summary>Agent Summaries</summary>', '');
49
+ for (const a of result.agents) {
50
+ lines.push(`**${a.agentName.toUpperCase()}** \`[${a.riskScore}/100]\``, a.summary, '');
51
+ }
52
+ lines.push('</details>', '', '---', '*Generated by [aiv](https://www.npmjs.com/package/@ateriss_/aiv-cli)*');
53
+
54
+ return lines.join('\n');
55
+ }
56
+
57
+ function findingMd(f: AgentFinding): string[] {
58
+ const emoji = SEVERITY_EMOJI[f.severity] ?? '⚪';
59
+ const out = [`\n**${emoji} [${f.severity.toUpperCase()}] ${f.title}**`];
60
+ if (f.file) out.push(`\`${f.file}\``);
61
+ out.push(f.description);
62
+ if (f.suggestion) out.push(`> 💡 ${f.suggestion}`);
63
+ return out;
64
+ }
65
+
5
66
  export function renderReview(result: ReviewResult): void {
6
67
  const tr = t();
7
68
  const riskColor = riskChalk(result.riskLabel);
@@ -42,7 +42,8 @@ export async function selectPR(prs: PullRequest[], message: string): Promise<Pul
42
42
  return selected === CANCEL ? null : (prs.find(pr => pr.number === selected) ?? null);
43
43
  }
44
44
 
45
- export type PostReviewAction = 'approve' | 'request_changes' | 'skip';
45
+ export type PostReviewAction = 'approve' | 'request_changes' | 'post_comment' | 'skip';
46
+ export type MergeStrategy = 'merge' | 'squash' | 'rebase';
46
47
 
47
48
  export async function selectPostReviewAction(message: string): Promise<PostReviewAction> {
48
49
  const inquirer = await getInquirer();
@@ -52,18 +53,47 @@ export async function selectPostReviewAction(message: string): Promise<PostRevie
52
53
  name: 'action',
53
54
  message,
54
55
  choices: [
55
- { name: chalk.green('✔ Approve PR'), value: 'approve', short: 'Approve' },
56
- { name: chalk.yellow('⚑ Request Changes'), value: 'request_changes', short: 'Request Changes' },
56
+ { name: chalk.green('✔ Approve PR'), value: 'approve', short: 'Approve' },
57
+ { name: chalk.yellow('⚑ Request Changes'), value: 'request_changes', short: 'Request Changes' },
58
+ { name: chalk.cyan('💬 Post as PR comment'), value: 'post_comment', short: 'Post Comment' },
57
59
  new inquirer.Separator('─'.repeat(42)),
58
- { name: chalk.dim('↩ Skip'), value: 'skip', short: 'Skip' },
60
+ { name: chalk.dim('↩ Skip'), value: 'skip', short: 'Skip' },
59
61
  ],
60
- pageSize: 4,
62
+ pageSize: 5,
61
63
  loop: false,
62
64
  }]);
63
65
 
64
66
  return answers['action'] as PostReviewAction;
65
67
  }
66
68
 
69
+ export async function confirmMerge(message: string): Promise<boolean> {
70
+ const inquirer = await getInquirer();
71
+ const answers = await inquirer.prompt([{
72
+ type: 'confirm',
73
+ name: 'ok',
74
+ message,
75
+ default: false,
76
+ }]);
77
+ return Boolean(answers['ok']);
78
+ }
79
+
80
+ export async function selectMergeStrategy(message: string): Promise<MergeStrategy> {
81
+ const inquirer = await getInquirer();
82
+ const answers = await inquirer.prompt([{
83
+ type: 'list',
84
+ name: 'strategy',
85
+ message,
86
+ choices: [
87
+ { name: chalk.cyan('Squash and merge'), value: 'squash', short: 'Squash' },
88
+ { name: 'Merge commit', value: 'merge', short: 'Merge' },
89
+ { name: chalk.dim('Rebase and merge'), value: 'rebase', short: 'Rebase' },
90
+ ],
91
+ pageSize: 3,
92
+ loop: false,
93
+ }]);
94
+ return answers['strategy'] as MergeStrategy;
95
+ }
96
+
67
97
  export async function confirmReview(pr: PullRequest, label: string): Promise<boolean> {
68
98
  const inquirer = await getInquirer();
69
99
 
package/src/git/github.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { PullRequest, PRDiff, PRFile } from '../types';
1
+ import { PullRequest, PRDiff, PRFile, ReviewResult } from '../types';
2
+
3
+ const AIV_COMMENT_TAG = '<!-- aiv-review:';
2
4
 
3
5
  const GITHUB_API = 'https://api.github.com';
4
6
 
@@ -59,6 +61,52 @@ export class GithubClient {
59
61
  return { pr, files, rawDiff };
60
62
  }
61
63
 
64
+ async findAivReview(owner: string, repo: string, prNumber: number): Promise<ReviewResult | null> {
65
+ const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=100`;
66
+ const res = await this.fetch(url);
67
+ if (!res.ok) return null;
68
+
69
+ const comments = await res.json() as any[];
70
+ for (const c of comments) {
71
+ const body: string = c.body ?? '';
72
+ if (!body.startsWith(AIV_COMMENT_TAG)) continue;
73
+ const end = body.indexOf(' -->');
74
+ if (end === -1) continue;
75
+ try {
76
+ const encoded = body.slice(AIV_COMMENT_TAG.length, end).trim();
77
+ return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as ReviewResult;
78
+ } catch { continue; }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ async postComment(owner: string, repo: string, prNumber: number, body: string): Promise<void> {
84
+ const url = `${GITHUB_API}/repos/${owner}/${repo}/issues/${prNumber}/comments`;
85
+ const { default: fetch } = await import('node-fetch');
86
+ const res = await fetch(url, {
87
+ method: 'POST',
88
+ headers: { ...this.headers, 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({ body }),
90
+ }) as unknown as Response;
91
+ if (!res.ok) await this.throwError(res, `post comment on PR #${prNumber}`);
92
+ }
93
+
94
+ async mergePR(
95
+ owner: string,
96
+ repo: string,
97
+ prNumber: number,
98
+ mergeMethod: 'merge' | 'squash' | 'rebase' = 'squash',
99
+ ): Promise<void> {
100
+ const url = `${GITHUB_API}/repos/${owner}/${repo}/pulls/${prNumber}/merge`;
101
+ const { default: fetch } = await import('node-fetch');
102
+ const res = await fetch(url, {
103
+ method: 'PUT',
104
+ headers: { ...this.headers, 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({ merge_method: mergeMethod }),
106
+ }) as unknown as Response;
107
+ if (!res.ok) await this.throwError(res, `merge PR #${prNumber}`);
108
+ }
109
+
62
110
  async submitReview(
63
111
  owner: string,
64
112
  repo: string,
package/src/i18n/en.ts CHANGED
@@ -175,6 +175,21 @@ export const en = {
175
175
  postReviewFailed: (msg: string) => `Failed to submit review: ${msg}`,
176
176
  postReviewRefreshing: 'Refreshing project context...',
177
177
  postReviewRefreshed: 'Context updated.',
178
+ postReviewMergeConfirm: 'Merge this PR now?',
179
+ postReviewSelectMerge: 'Select merge strategy:',
180
+ postReviewMerging: (n: number) => `Merging PR #${n}...`,
181
+ postReviewMerged: (n: number) => ` PR #${n} merged.`,
182
+ postReviewMergeFailed: (msg: string) => `Failed to merge: ${msg}`,
183
+ postReviewMergeStrategyMerge: 'Merge commit',
184
+ postReviewMergeStrategySquash: 'Squash and merge',
185
+ postReviewMergeStrategyRebase: 'Rebase and merge',
186
+ postReviewPostingComment: 'Posting review as PR comment...',
187
+ postReviewCommentPosted: (n: number) => ` Review posted as comment on PR #${n}.`,
188
+ postReviewCommentFailed: (msg: string) => `Failed to post comment: ${msg}`,
189
+ reviewCachedFound: 'Found a previous aiv analysis on this PR.',
190
+ reviewCachedUse: 'Use cached analysis?',
191
+ reviewCachedUsing: 'Using cached analysis.',
192
+ reviewCachedSkipping: 'Running fresh analysis...',
178
193
 
179
194
  // ── context generate ───────────────────────────────────────────────────────
180
195
  contextGenerateTitle: '\n Generating context and rules with AI...\n',
package/src/i18n/es.ts CHANGED
@@ -177,6 +177,21 @@ export const es: TranslationKeys = {
177
177
  postReviewFailed: (msg: string) => `Error al enviar la revisión: ${msg}`,
178
178
  postReviewRefreshing: 'Actualizando contexto del proyecto...',
179
179
  postReviewRefreshed: 'Contexto actualizado.',
180
+ postReviewMergeConfirm: '¿Hacer merge de este PR ahora?',
181
+ postReviewSelectMerge: 'Selecciona la estrategia de merge:',
182
+ postReviewMerging: (n: number) => `Haciendo merge del PR #${n}...`,
183
+ postReviewMerged: (n: number) => ` PR #${n} mergeado.`,
184
+ postReviewMergeFailed: (msg: string) => `Error al hacer merge: ${msg}`,
185
+ postReviewMergeStrategyMerge: 'Merge commit',
186
+ postReviewMergeStrategySquash: 'Squash and merge',
187
+ postReviewMergeStrategyRebase: 'Rebase and merge',
188
+ postReviewPostingComment: 'Publicando revisión como comentario del PR...',
189
+ postReviewCommentPosted: (n: number) => ` Revisión publicada como comentario en el PR #${n}.`,
190
+ postReviewCommentFailed: (msg: string) => `Error al publicar comentario: ${msg}`,
191
+ reviewCachedFound: 'Se encontró un análisis previo de aiv en este PR.',
192
+ reviewCachedUse: '¿Usar el análisis en caché?',
193
+ reviewCachedUsing: 'Usando análisis en caché.',
194
+ reviewCachedSkipping: 'Ejecutando análisis nuevo...',
180
195
 
181
196
  // ── context generate ───────────────────────────────────────────────────────
182
197
  contextGenerateTitle: '\n Generando contexto y reglas con IA...\n',
package/tsup.config.ts CHANGED
@@ -9,4 +9,5 @@ export default defineConfig({
9
9
  splitting: false,
10
10
  sourcemap: false,
11
11
  dts: false,
12
+ noExternal: [/.*/],
12
13
  });