diff --git a/.gitignore b/.gitignore index 7041ac7..644ed48 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ __pycache__/ .gstack/ .worktrees/ .codebuddy/ +.teamai/ # Local-only drafts and scratch notes (kept out of the open-source repo) eval/ diff --git a/README.md b/README.md index 7edfe7f..eb12863 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ The CLI picks a provider automatically from the repo URL: | `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | Inspect and resolve domain-drift signals; `--apply` reassigns the repo to the recommended domain and refreshes the aggregate views | | `teamai digest` | Generate a team AI usage weekly digest (skill leaderboard, new/updated skills, session summaries) | | `teamai hooks` | Manage AI-tool hooks (list / inject / remove) | +| `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI pipeline integration: extract knowledge from MR/PR, post as comments, and write to team repo after merge. With `--individual-comments`, each suggestion is posted separately with reaction/reject support (GitHub 👎 / TGit ☝️) | | `teamai uninstall [--force]` | Uninstall teamai: remove hooks, rules, skills, env, docs, and `~/.teamai/` | | `teamai doctor` | Diagnose configuration problems | @@ -361,6 +362,42 @@ Auto-update runs on the Stop hook at the end of a session. It can be controlled The user-level `updatePolicy` always wins over the team-level `autoUpdate`. +## CI Integration + +TeamAI can integrate into your CI pipeline to automatically extract knowledge from every MR/PR: + +``` +MR opened/updated → CI extracts learning + codebase suggestions → posts as comments + → Reviewer rejects unwanted suggestions (GitHub 👎 / TGit ☝️) + → MR merged → CI writes approved items to team knowledge repo +``` + +### Quick Start + +```bash +# Comment mode: post suggestions to MR (run on PR open/update) +teamai ci extract-mr --url "$MR_URL" --mode comment --individual-comments + +# Write mode: write approved items to knowledge repo (run after merge) +teamai ci extract-mr --url "$MR_URL" --mode write --team-repo ./team-repo --individual-comments +``` + +### CI Templates + +Ready-to-use templates in `examples/ci/`: + +| File | Platform | +|------|----------| +| `github-actions-mr-extract.yml` | GitHub Actions | +| `coding-ci-mr-extract.yaml` | Coding CI (TGit + ZhiYan QCI) | + +### Reject Interaction + +| Platform | How to reject | Default | +|----------|--------------|---------| +| GitHub | Add 👎 reaction to the suggestion comment | Write all | +| TGit | Add ☝️ emoji to the suggestion note | Write all | + ## License [MIT](LICENSE) diff --git a/README.zh-CN.md b/README.zh-CN.md index a3a1fb2..c4ae919 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -89,6 +89,7 @@ CLI 会根据用户传入的 repo URL 自动选择 provider: | `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | 浏览并处理域漂移信号;`--apply` 把仓库重新归类到推荐域并刷新聚合视图 | | `teamai digest` | 生成团队 AI 使用周报(skill 排行、新增/更新 skill、session 摘要) | | `teamai hooks` | 管理 AI 工具 hooks(list / inject / remove) | +| `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI 流水线集成:从 MR/PR 中提取知识,发布为评论,合并后写入团队知识仓库。使用 `--individual-comments` 时每条建议单独发布,支持 reaction/reject 交互(GitHub 👎 / TGit ☝️) | | `teamai uninstall [--force]` | 卸载 teamai:移除 hooks、rules、skills、env、docs、~/.teamai/ | | `teamai doctor` | 诊断配置问题 | @@ -361,6 +362,42 @@ npm update -g teamai-cli # 或手动触发 npm 升级 用户级 `updatePolicy` 始终优先于团队级 `autoUpdate`。 +## CI 集成 + +TeamAI 可以集成到 CI 流水线中,从每次 MR/PR 自动提取知识: + +``` +MR 创建/更新 → CI 提取 learning + codebase 建议 → 以评论形式发布 + → Reviewer 拒绝不需要的建议(GitHub 👎 / TGit ☝️) + → MR 合并 → CI 将已通过的条目写入团队知识仓库 +``` + +### 快速开始 + +```bash +# Comment 模式:将建议发布到 MR(在 PR 打开/更新时运行) +teamai ci extract-mr --url "$MR_URL" --mode comment --individual-comments + +# Write 模式:将已通过的条目写入知识仓库(在合并后运行) +teamai ci extract-mr --url "$MR_URL" --mode write --team-repo ./team-repo --individual-comments +``` + +### CI 模板 + +`examples/ci/` 目录下提供了开箱即用的模板: + +| 文件 | 平台 | +|------|------| +| `github-actions-mr-extract.yml` | GitHub Actions | +| `coding-ci-mr-extract.yaml` | Coding CI(TGit + 智研 QCI) | + +### 拒绝交互 + +| 平台 | 拒绝方式 | 默认行为 | +|------|---------|---------| +| GitHub | 对建议评论添加 👎 reaction | 全部写入 | +| TGit | 对建议 note 添加 ☝️ emoji | 全部写入 | + ## 许可证 [MIT](LICENSE) diff --git a/examples/ci/coding-ci-mr-extract.yaml b/examples/ci/coding-ci-mr-extract.yaml index 252fc94..89c3243 100644 --- a/examples/ci/coding-ci-mr-extract.yaml +++ b/examples/ci/coding-ci-mr-extract.yaml @@ -53,8 +53,8 @@ stages: --write-mode direct - | cd team-repo - git config user.name "teamai-ci" - git config user.email "teamai-ci@tencent.com" + # git user.name/email 由 teamai ci extract-mr 自动从 token 对应账号获取 + # 确保 TAI_PAT_TOKEN 对应的账号对知识仓库有 push 权限即可 git add learnings/ docs/ .teamai/ git diff --cached --quiet || \ git commit -m "[teamai] Extract knowledge from MR !${CI_MERGE_REQUEST_IID}" diff --git a/src/__tests__/read-rejections.test.ts b/src/__tests__/read-rejections.test.ts new file mode 100644 index 0000000..eac01d7 --- /dev/null +++ b/src/__tests__/read-rejections.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { extractMarkerId, shouldWrite } from '../ci/read-rejections.js'; +import type { RejectionResult } from '../ci/read-rejections.js'; + +describe('extractMarkerId', () => { + it('提取 learning marker', () => { + expect(extractMarkerId('\nsome content')).toBe('learning'); + }); + + it('提取 suggestion marker', () => { + expect(extractMarkerId('\ncontent')).toBe('suggestion:1'); + }); + + it('提取 suggestion:2 marker', () => { + expect(extractMarkerId('')).toBe('suggestion:2'); + }); + + it('无 marker 返回 null', () => { + expect(extractMarkerId('普通 comment 内容')).toBeNull(); + }); + + it('其他 teamai marker 不匹配', () => { + expect(extractMarkerId('')).toBeNull(); + }); +}); + +describe('shouldWrite', () => { + const rejections: RejectionResult = { + rejectedIds: new Set(['suggestion:2']), + approvedIds: new Set(['learning', 'suggestion:1']), + allIds: new Set(['learning', 'suggestion:1', 'suggestion:2']), + }; + + describe('GitHub (默认写入,👎 = reject)', () => { + it('不在 rejectedIds 中 → 写入', () => { + expect(shouldWrite('learning', rejections, 'github')).toBe(true); + expect(shouldWrite('suggestion:1', rejections, 'github')).toBe(true); + }); + + it('在 rejectedIds 中 → 不写入', () => { + expect(shouldWrite('suggestion:2', rejections, 'github')).toBe(false); + }); + + it('未知 id(不在任何集合中)→ 写入(默认)', () => { + expect(shouldWrite('suggestion:99', rejections, 'github')).toBe(true); + }); + }); + + describe('TGit (默认写入,🚫 emoji = reject)', () => { + it('不在 rejectedIds 中 → 写入', () => { + expect(shouldWrite('learning', rejections, 'tgit')).toBe(true); + expect(shouldWrite('suggestion:1', rejections, 'tgit')).toBe(true); + }); + + it('在 rejectedIds 中 → 不写入', () => { + expect(shouldWrite('suggestion:2', rejections, 'tgit')).toBe(false); + }); + + it('未知 id(不在任何集合中)→ 写入(默认)', () => { + expect(shouldWrite('suggestion:99', rejections, 'tgit')).toBe(true); + }); + }); +}); diff --git a/src/ci/extract-mr.ts b/src/ci/extract-mr.ts index 424658c..3d63998 100644 --- a/src/ci/extract-mr.ts +++ b/src/ci/extract-mr.ts @@ -18,7 +18,8 @@ import { appendPendingReview } from '../review-store.js'; import { pushRepoDirectly } from '../utils/git.js'; import { log } from '../utils/logger.js'; import type { LearningDraft, CodebaseSuggestion } from '../types.js'; -import { postOrUpdateMrComment } from './mr-comment.js'; +import { postOrUpdateMrComment, postIndividualComments, parseMrUrl } from './mr-comment.js'; +import { readRejections, shouldWrite } from './read-rejections.js'; // ─── 类型 ──────────────────────────────────────────────── @@ -31,6 +32,62 @@ export interface CiExtractMrOptions { writeMode?: 'direct' | 'pending-review'; output?: string; dryRun?: boolean; + individualComments?: boolean; +} + +// ─── Git User 配置 ─────────────────────────────────────── + +/** + * 自动配置 git user.name 和 user.email。 + * + * 通过 provider API 获取当前 token 对应的用户信息: + * - GitHub: GITHUB_TOKEN → GET /user + * - TGit: TAI_PAT_TOKEN → GET /api/v3/user + */ +async function configureGitUser(repoPath: string, provider: 'github' | 'tgit'): Promise { + const { execFileSync } = await import('node:child_process'); + let name = 'teamai-ci'; + let email = 'teamai-ci@noreply'; + + try { + if (provider === 'github') { + const token = process.env['GITHUB_TOKEN']; + if (token) { + const resp = await fetch('https://api.github.com/user', { + headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'teamai-cli' }, + signal: AbortSignal.timeout(8000), + }); + if (resp.ok) { + const user = (await resp.json()) as { login: string; email: string | null }; + name = user.login; + email = user.email ?? `${user.login}@users.noreply.github.com`; + } + } + } else { + const token = process.env['TAI_PAT_TOKEN']; + if (token) { + const resp = await fetch('https://git.woa.com/api/v3/user', { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(8000), + }); + if (resp.ok) { + const user = (await resp.json()) as { username: string; email: string }; + name = user.username; + email = user.email; + } + } + } + } catch { + log.debug('无法获取用户信息,使用默认 git user'); + } + + try { + execFileSync('git', ['config', 'user.name', name], { cwd: repoPath, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', email], { cwd: repoPath, stdio: 'ignore' }); + log.debug(`Git user: ${name} <${email}>`); + } catch { + log.debug('git config 失败(非 git 仓库),跳过'); + } } // ─── 写入逻辑 ──────────────────────────────────────────── @@ -102,6 +159,8 @@ async function writeKnowledgeToRepo( // 提交并推送 if (!dryRun && changedFiles.length > 0) { try { + const provider = mrUrl.includes('github.com') ? 'github' as const : 'tgit' as const; + await configureGitUser(teamRepo, provider); await pushRepoDirectly(teamRepo, `[teamai] CI extract knowledge from MR`, changedFiles); log.success('已推送到团队仓库'); } catch (err) { @@ -185,29 +244,62 @@ export async function ciExtractMr(opts: CiExtractMrOptions): Promise { // 执行 comment if (opts.mode === 'comment' || opts.mode === 'both') { - const result = await postOrUpdateMrComment( - opts.url, - learning, - suggestions, - opts.commentMarker, - opts.dryRun, - ); - if (result.created) { - log.success('MR comment 已发布'); + if (opts.individualComments) { + const { posted } = await postIndividualComments(opts.url, learning, suggestions, opts.dryRun); + log.success(`已发布 ${posted} 条独立建议 comment`); } else { - log.success('MR comment 已更新'); - } - if (result.url) { - log.info(`Comment URL: ${result.url}`); + const result = await postOrUpdateMrComment( + opts.url, + learning, + suggestions, + opts.commentMarker, + opts.dryRun, + ); + if (result.created) { + log.success('MR comment 已发布'); + } else { + log.success('MR comment 已更新'); + } + if (result.url) { + log.info(`Comment URL: ${result.url}`); + } } } // 执行 write if (opts.mode === 'write' || opts.mode === 'both') { + // 当使用 individual comments 时,读取 rejection 状态进行过滤 + let filteredLearning = learning; + let filteredSuggestions = suggestions; + + if (opts.individualComments && !opts.dryRun) { + const parsed = parseMrUrl(opts.url); + const rejections = await readRejections(opts.url); + + if (rejections.allIds.size > 0) { + // 过滤 learning + if (learning && !shouldWrite('learning', rejections, parsed.provider)) { + log.info('Learning 被 reject,跳过写入'); + filteredLearning = undefined; + } + + // 过滤 suggestions + if (suggestions) { + filteredSuggestions = suggestions.filter((_, i) => + shouldWrite(`suggestion:${i + 1}`, rejections, parsed.provider), + ); + const rejected = suggestions.length - filteredSuggestions.length; + if (rejected > 0) { + log.info(`${rejected} 条 codebase 建议被 reject,已排除`); + } + } + } + } + await writeKnowledgeToRepo( opts.teamRepo!, - learning, - suggestions, + filteredLearning, + filteredSuggestions, opts.writeMode ?? 'direct', opts.url, opts.dryRun, diff --git a/src/ci/mr-comment.ts b/src/ci/mr-comment.ts index 066c86c..d1affc5 100644 --- a/src/ci/mr-comment.ts +++ b/src/ci/mr-comment.ts @@ -340,3 +340,168 @@ export async function postOrUpdateMrComment( } return postTGitComment(projectId, mrGlobalId, body); } + +// ─── 逐条发布(Individual Comments)───────────────────── + +/** + * 格式化单条建议为独立 comment 内容。 + */ +export function formatIndividualComment( + type: 'learning' | 'suggestion', + index: number, + content: { title?: string; body?: string; section?: string; action?: string; preview?: string }, + provider: 'github' | 'tgit', +): string { + const markerId = type === 'learning' ? 'learning' : `suggestion:${index}`; + const marker = ``; + + const lines: string[] = [marker, '']; + + if (type === 'learning') { + lines.push(`## TeamAI Learning`, ''); + lines.push(`**${content.title}**`, ''); + if (content.body) { + lines.push('
', '展开完整内容', ''); + lines.push('```markdown'); + lines.push(content.body); + lines.push('```'); + lines.push('', '
'); + } + } else { + lines.push(`## TeamAI Codebase 建议 #${index}`, ''); + lines.push(`**章节**: ${content.section} | **操作**: ${content.action}`, ''); + if (content.preview) { + lines.push('```'); + lines.push(content.preview.slice(0, 300)); + lines.push('```'); + } + } + + lines.push(''); + if (provider === 'github') { + lines.push('> 💡 不写入此条?请对本条 comment 加 👎 reaction'); + } else { + lines.push('> 💡 不写入此条?请对本条评论加 ☝️ emoji reaction'); + } + lines.push(''); + lines.push('_Auto-generated by `teamai ci extract-mr`_'); + + return lines.join('\n'); +} + +/** + * 逐条发布 learning 和 suggestions 为独立的 comment/note。 + * + * 每条建议带唯一 marker,支持后续 readRejections 读取状态。 + * TGit 上发布为 conversation note,reviewer 通过 ☝️ emoji reaction 来 reject。 + */ +export async function postIndividualComments( + mrUrl: string, + learning: LearningDraft | undefined, + suggestions: CodebaseSuggestion[] | undefined, + dryRun?: boolean, +): Promise<{ posted: number }> { + const parsed = parseMrUrl(mrUrl); + let posted = 0; + + const items: Array<{ markerId: string; body: string }> = []; + + // Learning + if (learning) { + const body = formatIndividualComment( + 'learning', 0, + { title: learning.title, body: learning.content }, + parsed.provider, + ); + items.push({ markerId: 'learning', body }); + } + + // Suggestions + if (suggestions) { + for (let i = 0; i < suggestions.length; i++) { + const s = suggestions[i]; + const body = formatIndividualComment( + 'suggestion', i + 1, + { section: s.section, action: s.action, preview: s.content }, + parsed.provider, + ); + items.push({ markerId: `suggestion:${i + 1}`, body }); + } + } + + if (items.length === 0) return { posted: 0 }; + + if (dryRun) { + for (const item of items) { + log.info(`dry-run [${item.markerId}]:`); + console.log(item.body); + console.log('---'); + } + return { posted: items.length }; + } + + if (parsed.provider === 'github') { + for (const item of items) { + const marker = ``; + const existing = await findGitHubComment(parsed.owner, parsed.repo, parsed.number, marker); + if (existing) { + await updateGitHubComment(parsed.owner, parsed.repo, existing.id, item.body); + } else { + await postGitHubComment(parsed.owner, parsed.repo, parsed.number, item.body); + } + posted++; + } + } else { + // TGit: 作为 code review comment(关联文件),带 resolve 按钮 + const projectId = encodeURIComponent(`${parsed.owner}/${parsed.repo}`); + const mrGlobalId = await getMrGlobalId(projectId, parsed.number); + + // 获取 MR 变更文件列表,取第一个文件作为 code review comment 锚点 + // 使用 /repository/compare API(/changes 对某些 MR 状态不可用) + const mrListResp = await tgitRequest( + `/projects/${projectId}/merge_requests?iid=${parsed.number}`, + 'GET', + ); + let anchorFile = 'README.md'; + if (mrListResp.ok) { + const mrList = (await mrListResp.json()) as Array<{ source_branch: string; target_branch: string }>; + if (mrList.length > 0) { + const { source_branch, target_branch } = mrList[0]; + const compareResp = await tgitRequest( + `/projects/${projectId}/repository/compare?from=${target_branch}&to=${source_branch}`, + 'GET', + ); + if (compareResp.ok) { + const compareData = (await compareResp.json()) as { diffs?: Array<{ new_path: string }> }; + if (compareData.diffs && compareData.diffs.length > 0) { + anchorFile = compareData.diffs[0].new_path; + } + } + } + } + + for (const item of items) { + const marker = ``; + // 检查是否已存在 + const existingNote = await findTGitComment(projectId, mrGlobalId, marker); + if (existingNote) { + await updateTGitComment(projectId, mrGlobalId, existingNote.id, item.body); + } else { + // 创建 code review comment(关联到文件第 1 行) + const resp = await tgitRequest( + `/projects/${projectId}/merge_requests/${mrGlobalId}/notes`, + 'POST', + { body: item.body, path: anchorFile, line: 1, line_type: 'new' }, + ); + if (!resp.ok) { + // fallback: 创建普通 note + await postTGitComment(projectId, mrGlobalId, item.body); + } + } + posted++; + } + } + + log.success(`已发布 ${posted} 条独立建议`); + return { posted }; +} diff --git a/src/ci/read-rejections.ts b/src/ci/read-rejections.ts new file mode 100644 index 0000000..41718ca --- /dev/null +++ b/src/ci/read-rejections.ts @@ -0,0 +1,200 @@ +// -*- coding: utf-8 -*- +/** + * 读取 MR/PR 上 reviewer 对知识建议的交互状态。 + * + * - GitHub: 👎 reaction = reject,无 reaction = approve(默认写入) + * - TGit: resolve_state=1 = approve,resolve_state=0 = reject(默认不写入) + */ + +import { parseMrUrl } from './mr-comment.js'; +import { gfGetOAuthToken } from '../providers/tgit/gf-cli.js'; +import { log } from '../utils/logger.js'; + +const API_TIMEOUT_MS = 15_000; + +// ─── 类型 ──────────────────────────────────────────────── + +export interface RejectionResult { + /** 被 reject 的 marker id 集合 (如 "learning", "suggestion:1") */ + rejectedIds: Set; + /** 被 approve 的 marker id 集合 */ + approvedIds: Set; + /** 所有找到的 marker id */ + allIds: Set; +} + +// ─── Marker 解析 ───────────────────────────────────────── + +const MARKER_REGEX = //; + +/** + * 从 comment body 中提取 marker id。 + */ +export function extractMarkerId(body: string): string | null { + const match = body.match(MARKER_REGEX); + return match ? match[1] : null; +} + +// ─── GitHub ───────────────────────────────────────────── + +interface GitHubComment { + id: number; + body: string; +} + +interface GitHubReaction { + content: string; // "+1", "-1", "laugh", etc. +} + +async function githubRequest(path: string): Promise { + const token = process.env['GITHUB_TOKEN']; + if (!token) throw new Error('未设置 GITHUB_TOKEN'); + return fetch(`https://api.github.com${path}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'teamai-cli', + }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); +} + +async function readGitHubRejections(owner: string, repo: string, prNumber: string): Promise { + const result: RejectionResult = { rejectedIds: new Set(), approvedIds: new Set(), allIds: new Set() }; + + // 读取所有 comments + const resp = await githubRequest(`/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=100`); + if (!resp.ok) return result; + const comments = (await resp.json()) as GitHubComment[]; + + for (const comment of comments) { + const markerId = extractMarkerId(comment.body); + if (!markerId) continue; + result.allIds.add(markerId); + + // 读取 reactions + const reactResp = await githubRequest( + `/repos/${owner}/${repo}/issues/comments/${comment.id}/reactions?per_page=100`, + ); + if (!reactResp.ok) { + result.approvedIds.add(markerId); // 读取失败默认 approve + continue; + } + const reactions = (await reactResp.json()) as GitHubReaction[]; + const hasThumbsDown = reactions.some((r) => r.content === '-1'); + + if (hasThumbsDown) { + result.rejectedIds.add(markerId); + } else { + result.approvedIds.add(markerId); + } + } + + return result; +} + +// ─── TGit ─────────────────────────────────────────────── + +/** TGit emoji 编号 8 = ☝️(竖起食指),作为 reject 信号 */ +const TGIT_REJECT_EMOJI = 8; + +interface TGitNoteComment { + comment: number; + author: { username: string }; +} + +interface TGitNote { + id: number; + body: string; + resolve_state: number; + file_path: string | null; + comments: TGitNoteComment[]; +} + +function getTGitToken(): string { + const envToken = process.env['TAI_PAT_TOKEN']; + if (envToken) return envToken; + const oauthToken = gfGetOAuthToken(); + if (oauthToken) return oauthToken; + throw new Error('未设置 TAI_PAT_TOKEN 且无法获取 OAuth token'); +} + +async function tgitRequest(path: string): Promise { + const token = getTGitToken(); + return fetch(`https://git.woa.com/api/v3${path}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); +} + +async function getMrGlobalId(projectId: string, mrIid: string): Promise { + const resp = await tgitRequest(`/projects/${projectId}/merge_requests?iid=${mrIid}`); + if (!resp.ok) throw new Error(`TGit 查询 MR 失败 (${resp.status})`); + const mrs = (await resp.json()) as Array<{ id: number; iid: number }>; + const mr = mrs.find((m) => String(m.iid) === mrIid); + if (!mr) throw new Error(`TGit MR !${mrIid} 不存在`); + return mr.id; +} + +async function readTGitRejections(owner: string, repo: string, mrIid: string): Promise { + const result: RejectionResult = { rejectedIds: new Set(), approvedIds: new Set(), allIds: new Set() }; + + const projectId = encodeURIComponent(`${owner}/${repo}`); + const mrGlobalId = await getMrGlobalId(projectId, mrIid); + + // 读取所有 notes + const resp = await tgitRequest(`/projects/${projectId}/merge_requests/${mrGlobalId}/notes?per_page=100`); + if (!resp.ok) return result; + const notes = (await resp.json()) as TGitNote[]; + + for (const note of notes) { + const markerId = extractMarkerId(note.body); + if (!markerId) continue; + result.allIds.add(markerId); + + // TGit: emoji 编号 8 (☝️) = reject,无 emoji = approve(默认写入) + const hasRejectEmoji = (note.comments ?? []).some((c) => c.comment === TGIT_REJECT_EMOJI); + if (hasRejectEmoji) { + result.rejectedIds.add(markerId); + } else { + result.approvedIds.add(markerId); + } + } + + return result; +} + +// ─── Public API ───────────────────────────────────────── + +/** + * 读取 MR/PR 上 reviewer 对知识建议的交互状态。 + * + * 返回被 reject 和 approve 的 marker id 集合。 + * - GitHub: 默认写入,👎 = reject + * - TGit: 默认不写入,点"解决" = approve + */ +export async function readRejections(mrUrl: string): Promise { + const parsed = parseMrUrl(mrUrl); + + if (parsed.provider === 'github') { + log.debug('读取 GitHub reactions...'); + return readGitHubRejections(parsed.owner, parsed.repo, parsed.number); + } + + log.debug('读取 TGit emoji reactions...'); + return readTGitRejections(parsed.owner, parsed.repo, parsed.number); +} + +/** + * 根据 rejection 结果判断某条建议是否应该写入。 + * + * 两个平台逻辑统一:默认写入,被 reject 的不写入。 + * - GitHub: 👎 reaction = reject + * - TGit: ☝️ emoji (编号 8) = reject + */ +export function shouldWrite(markerId: string, rejections: RejectionResult, _provider: 'github' | 'tgit'): boolean { + return !rejections.rejectedIds.has(markerId); +} diff --git a/src/index.ts b/src/index.ts index 1da9849..2823e71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -710,6 +710,7 @@ ciCmd .option('--comment-marker ', 'HTML comment anchor for idempotent updates', '') .option('--write-mode ', 'Write strategy: direct | pending-review', 'direct') .option('--output ', 'Write artifacts to directory') + .option('--individual-comments', 'Post each suggestion as separate comment with reaction/resolve support') .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const { ciExtractMr } = await import('./ci/extract-mr.js');