Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ __pycache__/
.gstack/
.worktrees/
.codebuddy/
.teamai/

# Local-only drafts and scratch notes (kept out of the open-source repo)
eval/
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 |

Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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` | 诊断配置问题 |

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions examples/ci/coding-ci-mr-extract.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
63 changes: 63 additions & 0 deletions src/__tests__/read-rejections.test.ts
Original file line number Diff line number Diff line change
@@ -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('<!-- teamai:ci-extract:learning -->\nsome content')).toBe('learning');
});

it('提取 suggestion marker', () => {
expect(extractMarkerId('<!-- teamai:ci-extract:suggestion:1 -->\ncontent')).toBe('suggestion:1');
});

it('提取 suggestion:2 marker', () => {
expect(extractMarkerId('<!-- teamai:ci-extract:suggestion:2 -->')).toBe('suggestion:2');
});

it('无 marker 返回 null', () => {
expect(extractMarkerId('普通 comment 内容')).toBeNull();
});

it('其他 teamai marker 不匹配', () => {
expect(extractMarkerId('<!-- teamai:ci-extract -->')).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);
});
});
});
124 changes: 108 additions & 16 deletions src/ci/extract-mr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ─── 类型 ────────────────────────────────────────────────

Expand All @@ -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<void> {
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 仓库),跳过');
}
}

// ─── 写入逻辑 ────────────────────────────────────────────
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -185,29 +244,62 @@ export async function ciExtractMr(opts: CiExtractMrOptions): Promise<void> {

// 执行 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,
Expand Down
Loading
Loading