forked from Gitlink/gitlink-cli
chore: move community-ops-sweep workflow to project root workflows/
Move examples/workflows/community-ops-sweep to workflows/community-ops-sweep to promote it from an example to a first-class project workflow. Also includes pending org shortcuts modifications.
This commit is contained in:
parent
7aa6f1b757
commit
d9d3bbc30c
|
|
@ -88,6 +88,115 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "teams",
|
||||
Description: "List teams in an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true},
|
||||
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id")
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams", id), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create-team",
|
||||
Description: "Create a team in an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true},
|
||||
{Name: "name", Short: "n", Usage: tr.T("flag.org.name"), Required: true},
|
||||
{Name: "description", Short: "d", Usage: tr.T("flag.description")},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id")
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
"nickname": name,
|
||||
}
|
||||
if d := ctx.Arg("description"); d != "" {
|
||||
payload["description"] = d
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-member",
|
||||
Description: "Remove a member from an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true},
|
||||
{Name: "uid", Short: "u", Usage: "User ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id")
|
||||
uid, _ := ctx.RequireArg("uid")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/organization_users/%s", id, uid), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "nickname",
|
||||
Description: "Set or view a member's nickname in an organization",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true},
|
||||
{Name: "uid", Short: "u", Usage: "User ID", Required: true},
|
||||
{Name: "nickname", Short: "n", Usage: "New nickname (omit to view current)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id")
|
||||
uid, _ := ctx.RequireArg("uid")
|
||||
nickname := ctx.Arg("nickname")
|
||||
if nickname != "" {
|
||||
payload := map[string]interface{}{
|
||||
"nickname": nickname,
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/organizations/%s/organization_users/%s", id, uid), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s/organization_users/%s", id, uid), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "uid",
|
||||
Description: "Look up a user's numeric ID by login name",
|
||||
Flags: []common.Flag{
|
||||
{Name: "login", Short: "l", Usage: "User login name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
login, err := ctx.RequireArg("login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,3 +112,165 @@ func TestOrgCreate(t *testing.T) {
|
|||
t.Errorf("expected POST, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// --- teams ---
|
||||
|
||||
func TestOrgTeams(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/organizations/5/teams.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(1),
|
||||
"teams": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"name": "dev-team",
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"id": "5",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "teams", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("teams failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- create-team ---
|
||||
|
||||
func TestOrgCreateTeam(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
payload := common.DecodeJSON(t, r)
|
||||
if payload["name"] != "new-team" {
|
||||
t.Fatalf("expected name=new-team, got %v", payload["name"])
|
||||
}
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"name": "new-team",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"id": "5",
|
||||
"name": "new-team",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "create-team", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create-team failed: %v", err)
|
||||
}
|
||||
if requestMethod != "POST" {
|
||||
t.Errorf("expected POST, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// --- remove-member ---
|
||||
|
||||
func TestOrgRemoveMember(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"ok": true,
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"id": "5",
|
||||
"uid": "42",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "remove-member", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("remove-member failed: %v", err)
|
||||
}
|
||||
if requestMethod != "DELETE" {
|
||||
t.Errorf("expected DELETE, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// --- nickname (view) ---
|
||||
|
||||
func TestOrgNicknameView(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/organizations/5/organization_users/42.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"nickname": "thename",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"id": "5",
|
||||
"uid": "42",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "nickname", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("nickname failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- nickname (set) ---
|
||||
|
||||
func TestOrgNicknameSet(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
payload := common.DecodeJSON(t, r)
|
||||
if payload["nickname"] != "newname" {
|
||||
t.Fatalf("expected nickname=newname, got %v", payload["nickname"])
|
||||
}
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"nickname": "newname",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"id": "5",
|
||||
"uid": "42",
|
||||
"nickname": "newname",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "nickname", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("nickname set failed: %v", err)
|
||||
}
|
||||
if requestMethod != "PUT" {
|
||||
t.Errorf("expected PUT, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// --- uid ---
|
||||
|
||||
func TestOrgUID(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/users/baoerjun.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(148287),
|
||||
"login": "baoerjun",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"login": "baoerjun",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "uid", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("uid failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
# community-ops-sweep —— 社区运营增量对账 sweep
|
||||
|
||||
## 概述
|
||||
|
||||
一个 [Claude Code Workflow](../../../README.md) 实现的**增量对账式**社区运营自动化:
|
||||
运行时给定一个 `since` 时间点(或默认读上次 checkpoint),处理该时间点后的所有**新增 Issue** 与**合并 PR**,按需更新社区周报与 Release Notes。
|
||||
|
||||
**一次调用、时间窗界定、5 项事件一网打尽**:R1 新 Issue triage → R2 建议 owner → R3 周报 → R4 Release Notes → R5 PR→Issue 关联闭环。
|
||||
|
||||
> 完整设计见 [`docs/superpowers/specs/2026-07-01-community-ops-sweep-design.md`](/docs/superpowers/specs/2026-07-01-community-ops-sweep-design.md)
|
||||
|
||||
## 架构
|
||||
|
||||
三层,JSON 文件交接(判断与计算分离):
|
||||
|
||||
```
|
||||
CC Workflow (JS) ……… 编排:按 phase 调度
|
||||
├── LLM agent 层 …… 只做判断:分类 / PR 关联 / owner 建议 / 散文增强
|
||||
│ ↕ JSON 文件
|
||||
└── Python 引擎 …… 只做计算:采集 / 归一化 / id 解析 / 合并 / 守卫 / 渲染 / 写回
|
||||
```
|
||||
|
||||
确定性引擎(`scripts/community_ops_sweep.py`)复用 `community-ops-automation` 的采集/归一化/渲染管线,真实字段名实测自 `baoerjun/gitlink-cli`。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- **gitlink-cli** ≥ 0.2.0(`npm install -g @gitlink-ai/cli`)
|
||||
- **Python** 3.9+(标准库,无第三方依赖)
|
||||
- **gitlink-cli auth login**(认证)
|
||||
- **Claude Code**(运行 workflow)
|
||||
|
||||
## 运行方式
|
||||
|
||||
### 在 Claude Code 中
|
||||
|
||||
```bash
|
||||
# 作为 slash 命令(若已放入 .claude/workflows/)
|
||||
/community-ops-sweep owner=baoerjun repo=gitlink-cli
|
||||
|
||||
# 或通过 Workflow 工具
|
||||
# 带入参数:since 可选(ISO 时间或留空读 .last-sweep)、apply 默认 false
|
||||
```
|
||||
|
||||
### 单独跑确定性引擎
|
||||
|
||||
```bash
|
||||
# collect:采集 since 后的新 issue + 合并 PR
|
||||
python scripts/community_ops_sweep.py collect --owner baoerjun --repo gitlink-cli --since 2026-06-01T00:00:00Z
|
||||
|
||||
# plan:读 candidates + LLM decisions → 写计划
|
||||
python scripts/community_ops_sweep.py plan --candidates candidates.json --triage triage.json --owners owners.json --links links.json
|
||||
|
||||
# apply:dry-run 预览,--apply 才真写
|
||||
python scripts/community_ops_sweep.py apply --plan plan.json --owner baoerjun --repo gitlink-cli --apply
|
||||
|
||||
# checkpoint:推进 .last-sweep + 追加 triage-log
|
||||
python scripts/community_ops_sweep.py checkpoint --owner baoerjun --repo gitlink-cli
|
||||
```
|
||||
|
||||
### 跑单测
|
||||
|
||||
```bash
|
||||
python3 tests/test_sweep.py # 18 个确定性逻辑回归
|
||||
```
|
||||
|
||||
## 文件布局
|
||||
|
||||
```
|
||||
community-ops-sweep/
|
||||
community-ops-sweep.wf.js # CC workflow (JS)
|
||||
scripts/community_ops_sweep.py # 确定性引擎
|
||||
routing-rules.example.yaml # R2 路由规则(opt-in auto_assign)
|
||||
tests/test_sweep.py # 引擎单测
|
||||
README.md
|
||||
```
|
||||
|
||||
## 安全
|
||||
|
||||
- 默认 **dry-run**,`--apply` 才写
|
||||
- 标签**整体替换**发期望全集(增删统一,不丢标签)
|
||||
- 关 issue 前自动守卫(已关跳过)
|
||||
- **绝不自动合并 PR**
|
||||
- 可逆写(关可 reopen、release 可 edit)→ 无二次确认
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
export const meta = {
|
||||
name: 'community-ops-sweep',
|
||||
description: 'Incremental community ops sweep — triage new issues (R1), suggest owners (R2), link merged PRs→issues (R5), generate weekly report + release notes (R3/R4), upload report to Wiki. Default dry-run; --apply to write.',
|
||||
phases: [
|
||||
{ title: 'Collect', detail: 'Python engine gathers new issues + merged/open PRs + contributor stats since checkpoint' },
|
||||
{ title: 'Triage', detail: 'LLM agents classify, suggest owners, link PRs — in parallel' },
|
||||
{ title: 'Summarize', detail: 'LLM generates executive summary from collected data' },
|
||||
{ title: 'Plan', detail: 'Python engine builds deterministic write plan + generates seven-section report' },
|
||||
{ title: 'Apply', detail: 'Execute (or dry-run) write plan + publish to Wiki + advance checkpoint' },
|
||||
],
|
||||
}
|
||||
|
||||
const cfg = args || {}
|
||||
const OWNER = cfg.owner
|
||||
const REPO = cfg.repo
|
||||
const APPLY = cfg.apply === true
|
||||
const REPORT_ISSUE = cfg.reportIssue || ''
|
||||
const RELEASE_ID = cfg.releaseId || ''
|
||||
const SINCE = cfg.since || ''
|
||||
const PUBLISH_WIKI = cfg.publishWiki === true
|
||||
const WIKI_DIR = cfg.wikiDir || '周报专区'
|
||||
const REPORT_FLAG = REPORT_ISSUE ? `--report-issue ${REPORT_ISSUE}` : ''
|
||||
const RELEASE_FLAG = RELEASE_ID ? `--release-id ${RELEASE_ID}` : ''
|
||||
const WIKI_FLAG = PUBLISH_WIKI ? `--publish-wiki --wiki-dir ${WIKI_DIR}` : ''
|
||||
|
||||
const ENGINE = 'examples/workflows/community-ops-sweep/scripts/community_ops_sweep.py'
|
||||
const CANDIDATES = '/tmp/sweep-candidates.json'
|
||||
const TRIAGE = '/tmp/sweep-triage.json'
|
||||
const OWNERS = '/tmp/sweep-owners.json'
|
||||
const LINKS = '/tmp/sweep-links.json'
|
||||
const PLAN = '/tmp/sweep-plan.json'
|
||||
const SUMMARY = '/tmp/sweep-summary.json'
|
||||
const SUMMARY_ARG = `--summary ${SUMMARY}`
|
||||
|
||||
const SINCE_ARG = SINCE ? `--since ${SINCE}` : ''
|
||||
|
||||
if (!OWNER || !REPO) {
|
||||
throw new Error('Missing required args: owner, repo')
|
||||
}
|
||||
|
||||
phase('Collect')
|
||||
log(`Collecting since=${SINCE || 'checkpoint'} for ${OWNER}/${REPO}`)
|
||||
const collectOut = await agent(
|
||||
`Run the sweep engine collect:\n` +
|
||||
` python3 ${ENGINE} collect --owner ${OWNER} --repo ${REPO} ${SINCE_ARG} --out ${CANDIDATES}\n` +
|
||||
`Then Read ${CANDIDATES} and return a compact summary: how many new_issues, merged_prs, open_issues, and the tag_map keys. Also list each issue's number, title and current labels.`,
|
||||
{ label: 'collect', phase: 'Collect' }
|
||||
)
|
||||
log(`Collect result: ${collectOut}`)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase Triage — three LLM agents, independent → parallel
|
||||
// -----------------------------------------------------------------------
|
||||
phase('Triage')
|
||||
log('R1 classify + R2 suggest owners + R5 link PRs — in parallel')
|
||||
const triagePrompts = [
|
||||
// R1: classify new issues
|
||||
() => agent(
|
||||
`## Task: Classify new issues for triage (R1)\n\n` +
|
||||
`1. Read the file ${CANDIDATES}. It contains:\n` +
|
||||
` - "issues": new issues with fields number, title, description, tags, labels\n` +
|
||||
` - "tag_map": available label names → IDs. ONLY use labels from tag_map keys.\n\n` +
|
||||
`2. For EACH issue, determine:\n` +
|
||||
` - tag_names: labels to ADD (pick from tag_map keys, e.g. "bug","feature","enhancement","question","docs","P1","P2","P3")\n` +
|
||||
` - remove_tag_names: labels to REMOVE if any current ones are wrong\n` +
|
||||
` - recommended_action: one of "schedule_fix","duplicate","answered","spam","invalid". Use "schedule_fix" for normal issues.\n` +
|
||||
` (duplicate/answered/spam/invalid → will be auto-closed)\n\n` +
|
||||
`3. Write your decision as a single-line JSON array to ${TRIAGE} using Bash:\n` +
|
||||
` cat > ${TRIAGE} << 'EOD'\n [{"issue":"<num>","tag_names":["<name>"],"remove_tag_names":["<name>"],"recommended_action":"<action>"}]\n EOD\n` +
|
||||
` Make sure each issue has an entry if there are decisions. Skip issues that need no changes.\n\n` +
|
||||
`4. Confirm: cat ${TRIAGE}`,
|
||||
{ label: 'triage:R1-classify', phase: 'Triage' }
|
||||
),
|
||||
// R2: suggest owners
|
||||
() => agent(
|
||||
`## Task: Suggest owners for new issues (R2)\n\n` +
|
||||
`1. Read the file ${CANDIDATES}.\n\n` +
|
||||
`2. For EACH issue, suggest 1-3 likely owners based on:\n` +
|
||||
` - The issue content (title, description)\n` +
|
||||
` - Author logins of recent merged PRs (in "merged_prs")\n` +
|
||||
` - General domain familiarity\n\n` +
|
||||
`3. Output: write a JSON array to ${OWNERS} using Bash:\n` +
|
||||
` cat > ${OWNERS} << 'EOD'\n [{"issue":"<num>","suggested_owners":["<login>"],"evidence":"<why>"}]\n EOD\n` +
|
||||
` If you cannot suggest anyone for an issue, omit it.\n\n` +
|
||||
`4. Confirm: cat ${OWNERS}`,
|
||||
{ label: 'triage:R2-owners', phase: 'Triage' }
|
||||
),
|
||||
// R5: link merged PRs → issues
|
||||
() => agent(
|
||||
`## Task: Link merged PRs to issues (R5)\n\n` +
|
||||
`1. Read the file ${CANDIDATES}. Focus on the "merged_prs" list.\n\n` +
|
||||
`2. For EACH merged PR, parse its "description" (body) for patterns like:\n` +
|
||||
` - "fixes #N", "closes #N", "resolves #N"\n` +
|
||||
` - Chinese equivalents: "修复 #N", "关闭 #N", "解决 #N"\n` +
|
||||
` - Semantic references that clearly identify a specific issue number\n\n` +
|
||||
`3. Output: write a JSON array to ${LINKS} using Bash:\n` +
|
||||
` cat > ${LINKS} << 'EOD'\n [{"pr":"<num>","linked_issue_numbers":["<num>"]}]\n EOD\n` +
|
||||
` If no PR references any issue, write an empty array [].\n\n` +
|
||||
`4. Confirm: cat ${LINKS}`,
|
||||
{ label: 'triage:R5-link', phase: 'Triage' }
|
||||
),
|
||||
]
|
||||
|
||||
await Promise.all(triagePrompts.map(fn => fn()))
|
||||
log('Triage complete — decision files written')
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase Summarize — LLM generates executive summary
|
||||
// -----------------------------------------------------------------------
|
||||
phase('Summarize')
|
||||
log('Generating executive summary from collected data')
|
||||
const summaryOut = await agent(
|
||||
`## Task: Generate weekly report executive summary\n\n` +
|
||||
`1. Read the file ${CANDIDATES}. It contains:\n` +
|
||||
` - "issues": new issues this period (number, title, labels, author_login, state)\n` +
|
||||
` - "merged_prs": merged PRs (number, title, author_login)\n` +
|
||||
` - "open_prs": pending PRs\n` +
|
||||
` - "current_counts": issue/PR counts\n` +
|
||||
` - "contributors": top contributors with issue/PR counts\n` +
|
||||
` - "trends": trend arrows (↑↓→) compared to last period\n\n` +
|
||||
`2. Write a 3-5 sentence executive summary in Chinese to ${SUMMARY} using Bash:\n` +
|
||||
` cat > ${SUMMARY} << 'EOD'\n {"summary": "本周社区新增 N 个 Issue,合并 M 个 PR。……"}\n EOD\n\n` +
|
||||
`3. The summary should cover:\n` +
|
||||
` - Overall activity level (busy/calm/normal)\n` +
|
||||
` - Key highlights (major features merged, important bugs fixed)\n` +
|
||||
` - Notable trends (↑ or ↓ compared to last week)\n` +
|
||||
` - Any concerns or action items\n\n` +
|
||||
`4. Confirm: cat ${SUMMARY}`,
|
||||
{ label: 'summarize', phase: 'Summarize' }
|
||||
)
|
||||
log(`Summary: ${summaryOut}`)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase Plan — deterministic engine assembles write plan + generates report
|
||||
// -----------------------------------------------------------------------
|
||||
phase('Plan')
|
||||
const planOut = await agent(
|
||||
`Run the sweep engine plan:\n` +
|
||||
` python3 ${ENGINE} plan --candidates ${CANDIDATES} --triage ${TRIAGE} --owners ${OWNERS} --links ${LINKS} ${SUMMARY_ARG} --out ${PLAN}\n` +
|
||||
`Then Read ${PLAN} and summarize: how many writes, by op (update_tags/update_status/comment/update_assigner), which issues affected. Show the writes table.`,
|
||||
{ label: 'plan', phase: 'Plan' }
|
||||
)
|
||||
log(`Plan: ${planOut}`)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase Apply — execute (or dry-run) + checkpoint
|
||||
// -----------------------------------------------------------------------
|
||||
phase('Apply')
|
||||
const mode = APPLY ? '--apply' : ''
|
||||
const applyOut = await agent(
|
||||
APPLY
|
||||
? `Execute the write plan (--apply):\n python3 ${ENGINE} apply --plan ${PLAN} --owner ${OWNER} --repo ${REPO} --apply ${REPORT_FLAG} ${RELEASE_FLAG} ${WIKI_FLAG}\nThen: python3 ${ENGINE} checkpoint --owner ${OWNER} --repo ${REPO} --plan ${PLAN}\nReturn a summary of writes executed and where the report was published.`
|
||||
: `Dry-run the write plan (no writes executed):\n python3 ${ENGINE} apply --plan ${PLAN} --owner ${OWNER} --repo ${REPO} ${REPORT_FLAG} ${RELEASE_FLAG} ${WIKI_FLAG}\nSummarize what WOULD be written.`,
|
||||
{ label: 'apply', phase: 'Apply' }
|
||||
)
|
||||
log(`Apply: ${applyOut}`)
|
||||
|
||||
return {
|
||||
mode: APPLY ? 'applied' : 'dry-run',
|
||||
owner: OWNER, repo: REPO,
|
||||
collect: collectOut,
|
||||
plan: planOut,
|
||||
apply: applyOut,
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# 路由规则(R2 owner 建议 + 可选 auto_assign)
|
||||
|
||||
# rules:首次命中生效,顺序即优先级
|
||||
# 默认用途:R2 建议 owner 的依据(LLM 参考 + 确定性兜底)
|
||||
# auto_assign: true → 该规则命中的 issue 自动指派(内部团队仓,opt-in)
|
||||
|
||||
rules:
|
||||
- match:
|
||||
labels: [bug]
|
||||
any_keyword: [auth, 认证, 登录, token, oauth, session]
|
||||
assigner: auth-owner
|
||||
auto_assign: false
|
||||
|
||||
- match:
|
||||
labels: [bug]
|
||||
any_keyword: [ci, build, 构建, pipeline, deploy]
|
||||
assigner: ci-owner
|
||||
auto_assign: false
|
||||
|
||||
- match:
|
||||
labels: [docs]
|
||||
assigner: doc-maintainer
|
||||
auto_assign: false
|
||||
|
||||
- match:
|
||||
labels: [enhancement, feature]
|
||||
assigner: product-owner
|
||||
auto_assign: false
|
||||
|
||||
- match:
|
||||
labels: [security]
|
||||
assigner: security-team
|
||||
auto_assign: false
|
||||
|
||||
# 所有未命中规则 → 兜底
|
||||
default_assigner: maintainer
|
||||
auto_assign: false
|
||||
|
|
@ -0,0 +1,910 @@
|
|||
#!/usr/bin/env python3
|
||||
"""community_ops_sweep — 社区运营增量对账 sweep 的确定性引擎。
|
||||
|
||||
三层架构里的「Python 引擎层」:只做事实性计算(采集 / 归一化 / id 解析 / 合并 /
|
||||
守卫 / 渲染 / 写回);判断(分类 / PR 关联 / owner 建议)由 CC workflow 的 LLM
|
||||
agent 产出 JSON 喂入。设计见
|
||||
docs/superpowers/specs/2026-07-01-community-ops-sweep-design.md。
|
||||
|
||||
子命令:
|
||||
collect 采集 since 之后的新 issue + 合并 PR + 标签/分配人映射 → candidates.json
|
||||
plan 读 candidates + LLM decision JSON → plan.json(写计划)
|
||||
apply 执行 plan.json(默认 dry-run,--apply 才真写)
|
||||
checkpoint 推进 .last-sweep + 追加 docs/issue-triage-log.md
|
||||
|
||||
字段名实测自 baoerjun/gitlink-cli:issue 在 data.issues[],PR 在 data.pulls[]。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 复用 community-ops-automation 的 CLI 封装与通用 helpers(best-effort:缺失
|
||||
# 也不影响纯逻辑导入与单测)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_GW_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "community-ops-automation"
|
||||
/ "scripts"
|
||||
/ "gitlink_workflow.py"
|
||||
)
|
||||
try:
|
||||
_spec = importlib.util.spec_from_file_location("_gw", _GW_PATH)
|
||||
_gw = importlib.util.module_from_spec(_spec) # type: ignore[union-attr]
|
||||
sys.modules["_gw"] = _gw
|
||||
_spec.loader.exec_module(_gw) # type: ignore[union-attr]
|
||||
run_gitlink_cli = _gw.run_gitlink_cli
|
||||
first_value = _gw.first_value
|
||||
extract_first_list = _gw.extract_first_list
|
||||
parse_datetime = _gw.parse_datetime
|
||||
WorkflowError = _gw.WorkflowError
|
||||
except Exception: # pragma: no cover - 纯逻辑单测不应依赖 _gw
|
||||
_gw = None
|
||||
run_gitlink_cli = None # type: ignore[assignment]
|
||||
first_value = lambda item, keys, default=None: next( # noqa: E731
|
||||
(item[k] for k in keys if isinstance(item, dict) and k in item and item[k] not in (None, "", [])), default
|
||||
)
|
||||
extract_first_list = lambda payload, keys: payload if isinstance(payload, list) else [] # noqa: E731
|
||||
parse_datetime = lambda v: None # type: ignore[assignment] # noqa: E731
|
||||
|
||||
class WorkflowError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
DEFAULT_POLICY = {
|
||||
"status_map": {
|
||||
"duplicate": "closed",
|
||||
"answered": "closed",
|
||||
"spam": "closed",
|
||||
"invalid": "closed",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 纯逻辑(确定性、可单测)—— TDD 覆盖
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _id_sort_key(x: Any) -> tuple:
|
||||
try:
|
||||
return (0, int(x))
|
||||
except (TypeError, ValueError):
|
||||
return (1, str(x))
|
||||
|
||||
|
||||
def merge_tag_ids(current_ids, add_names, remove_names, tag_map):
|
||||
"""整体替换语义:返回 (current ∪ add) \\ remove;白名单过滤、去重、按数值排序。"""
|
||||
result = {str(x) for x in (current_ids or []) if x is not None and str(x) != ""}
|
||||
for name in add_names or []:
|
||||
tid = (tag_map or {}).get(name)
|
||||
if tid is not None:
|
||||
result.add(str(tid))
|
||||
for name in remove_names or []:
|
||||
tid = (tag_map or {}).get(name)
|
||||
if tid is not None:
|
||||
result.discard(str(tid))
|
||||
return sorted(result, key=_id_sort_key)
|
||||
|
||||
|
||||
def map_status(recommended_action, policy):
|
||||
"""recommended_action → 目标状态(如 duplicate→closed);未映射或 None → None(不改)。"""
|
||||
if not recommended_action:
|
||||
return None
|
||||
sm = (policy or {}).get("status_map", {}) or {}
|
||||
return sm.get(recommended_action)
|
||||
|
||||
|
||||
def _issue_current_tag_ids(issue: dict) -> list[str]:
|
||||
out = []
|
||||
for t in issue.get("tags") or []:
|
||||
if isinstance(t, dict) and t.get("id") is not None:
|
||||
out.append(str(t["id"]))
|
||||
return out
|
||||
|
||||
|
||||
def coerce_dates(item, keys=("updated_at", "created_at")):
|
||||
"""把 item 中字符串日期字段解析回 datetime(磁盘 round-trip 后的修复)。
|
||||
|
||||
datetime/None 原样保留;只解析 str。缺失的键补齐为 None。非 dict 原样返回。"""
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
out = dict(item)
|
||||
for k in keys:
|
||||
v = out.get(k)
|
||||
if isinstance(v, str):
|
||||
out[k] = parse_datetime(v)
|
||||
elif k not in out:
|
||||
out[k] = None
|
||||
return out
|
||||
|
||||
|
||||
def _render_owner_comment(evidence, suggested) -> str:
|
||||
names = "、".join(f"@{s}" for s in suggested)
|
||||
ev = f"(依据:{evidence})" if evidence else ""
|
||||
return (
|
||||
f"🤖 建议负责人:{names}{ev}。\n"
|
||||
"如非本人负责,请自行重新指派或在下方留言说明。"
|
||||
)
|
||||
|
||||
|
||||
def build_plan(candidates, triage, owners, links, routing, policy, tag_map):
|
||||
"""把 LLM 判断 + 确定性规则汇编成写计划。纯函数:同输入 → 同输出。"""
|
||||
policy = policy or {}
|
||||
routing = routing or {}
|
||||
tag_map = tag_map or {}
|
||||
writes: list[dict] = []
|
||||
|
||||
issues = candidates.get("issues", []) or []
|
||||
by_num = {str(i.get("number")): i for i in issues if i.get("number") is not None}
|
||||
open_issues = {str(x) for x in (candidates.get("open_issues") or [])}
|
||||
|
||||
# R1 triage:标签增删 + 状态映射
|
||||
for t in triage or []:
|
||||
num = str(t.get("issue"))
|
||||
issue = by_num.get(num)
|
||||
if issue is None:
|
||||
continue # 校验:候选里没有 → 跳过,不盲写
|
||||
current = _issue_current_tag_ids(issue)
|
||||
new_ids = merge_tag_ids(
|
||||
current, t.get("tag_names", []), t.get("remove_tag_names", []), tag_map
|
||||
)
|
||||
if sorted(new_ids, key=_id_sort_key) != sorted(current, key=_id_sort_key):
|
||||
writes.append({
|
||||
"op": "update_tags", "issue": num, "tag_ids": new_ids,
|
||||
"tag_names": t.get("tag_names", []) or [],
|
||||
"remove_tag_names": t.get("remove_tag_names", []) or [],
|
||||
})
|
||||
state = map_status(t.get("recommended_action"), policy)
|
||||
if state:
|
||||
writes.append({
|
||||
"op": "update_status", "issue": num, "state": state,
|
||||
"reason": f"triage: {t.get('recommended_action')}",
|
||||
})
|
||||
|
||||
# R2 owner 建议(默认评论;routing.auto_assign=true 才真指派)
|
||||
auto_assign = bool(routing.get("auto_assign"))
|
||||
assigner_map = candidates.get("assigner_map") or {}
|
||||
for o in owners or []:
|
||||
num = str(o.get("issue"))
|
||||
if num not in by_num:
|
||||
continue
|
||||
suggested = o.get("suggested_owners") or []
|
||||
if not suggested:
|
||||
continue
|
||||
writes.append({
|
||||
"op": "comment", "issue": num, "kind": "owner_suggest",
|
||||
"body": _render_owner_comment(o.get("evidence"), suggested),
|
||||
})
|
||||
if auto_assign:
|
||||
aids = [str(assigner_map[s]) for s in suggested if s in assigner_map]
|
||||
if aids:
|
||||
writes.append({"op": "update_assigner", "issue": num, "assigner_ids": aids})
|
||||
|
||||
# R5 PR→Issue 关联闭环:只关「存在且 open」的(幂等 + 防幻觉编号)
|
||||
for l in links or []:
|
||||
pr = l.get("pr")
|
||||
for inum in l.get("linked_issue_numbers", []) or []:
|
||||
inum = str(inum)
|
||||
if inum not in open_issues:
|
||||
continue # 校验:不存在或非 open → 跳过
|
||||
writes.append({
|
||||
"op": "update_status", "issue": inum, "state": "closed",
|
||||
"reason": f"fixed by PR#{pr}",
|
||||
})
|
||||
writes.append({
|
||||
"op": "comment", "issue": inum, "kind": "link_close",
|
||||
"body": f"🤖 PR#{pr} 已合并且语义关联本 issue,自动关闭(可 reopen)。",
|
||||
})
|
||||
|
||||
return {"writes": writes}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 归一化(实测字段名)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _issue_state(item: dict) -> str:
|
||||
sid = first_value(item, ("status_id", "state_id"), None)
|
||||
if sid in (5, "5", "closed", "close"):
|
||||
return "closed"
|
||||
return "open"
|
||||
|
||||
|
||||
def normalize_issue(item: dict) -> dict:
|
||||
author = item.get("author") or {}
|
||||
tags = item.get("tags") or []
|
||||
return {
|
||||
"id": str(first_value(item, ("number", "project_issues_index", "iid"), "")),
|
||||
"number": str(first_value(item, ("number", "project_issues_index", "iid"), "")),
|
||||
"title": str(first_value(item, ("subject", "title", "name"), "(untitled)")),
|
||||
"description": str(first_value(item, ("description", "body", "content"), "")),
|
||||
"state": _issue_state(item),
|
||||
"labels": [str(t.get("name")) for t in tags if isinstance(t, dict) and t.get("name")],
|
||||
"tags": tags,
|
||||
"author_login": author.get("login") or author.get("name") or "",
|
||||
"author_id": author.get("id"),
|
||||
"created_at": parse_datetime(first_value(item, ("created_at", "createdAt"), None)),
|
||||
"updated_at": parse_datetime(first_value(item, ("updated_at", "updatedAt"), None)),
|
||||
}
|
||||
|
||||
|
||||
def normalize_pr(pull: dict) -> dict:
|
||||
inner = pull.get("issue") or {}
|
||||
author = inner.get("author") or pull.get("author") or {}
|
||||
state = str(first_value(pull, ("status", "state"), "open")).lower()
|
||||
merged = state == "merged"
|
||||
return {
|
||||
"id": str(first_value(pull, ("index", "number", "pull_request_number"), "")),
|
||||
"number": str(first_value(pull, ("index", "number", "pull_request_number"), "")),
|
||||
"title": str(first_value(pull, ("title", "name"), "(untitled)")),
|
||||
"description": str(first_value(pull, ("body", "description", "content"), "")),
|
||||
"state": state,
|
||||
"merged": merged,
|
||||
"base": pull.get("base"),
|
||||
"head": pull.get("head"),
|
||||
"author_login": author.get("login") or author.get("name") or "",
|
||||
"author_id": author.get("id"),
|
||||
"merged_at": parse_datetime(first_value(pull, ("merged_at", "merge_time"), None)),
|
||||
"created_at": parse_datetime(first_value(pull, ("created_at", "pr_created_unix"), None)),
|
||||
"updated_at": parse_datetime(first_value(pull, ("updated_at", "created_at", "pr_created_unix"), None)),
|
||||
"labels": [str(t.get("name")) for t in (inner.get("issue_tags") or []) if isinstance(t, dict) and t.get("name")],
|
||||
}
|
||||
|
||||
|
||||
def _load_tag_map(owner, repo) -> dict[str, str]:
|
||||
if run_gitlink_cli is None:
|
||||
return {}
|
||||
payload = run_gitlink_cli(["issue", "+tags", "--only-name"], owner, repo)
|
||||
items = extract_first_list(payload, ("issue_tags", "tags", "items", "list"))
|
||||
return {str(t.get("name")): str(t.get("id")) for t in items if isinstance(t, dict) and t.get("name") and t.get("id") is not None}
|
||||
|
||||
|
||||
def _load_assigner_map(owner, repo) -> dict[str, str]:
|
||||
if run_gitlink_cli is None:
|
||||
return {}
|
||||
payload = run_gitlink_cli(["issue", "+assigners"], owner, repo)
|
||||
items = extract_first_list(payload, ("assigners", "issue_assigners", "users", "items", "list"))
|
||||
out = {}
|
||||
for u in items:
|
||||
if not isinstance(u, dict):
|
||||
continue
|
||||
login = u.get("login") or u.get("name") or u.get("username")
|
||||
if login and u.get("id") is not None:
|
||||
out[str(login)] = str(u["id"])
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# I/O 子命令
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _read_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
||||
|
||||
|
||||
def _parse_since(value: str | None, checkpoint: Path | None) -> datetime:
|
||||
if value:
|
||||
dt = parse_datetime(value)
|
||||
if dt is None:
|
||||
raise WorkflowError(f"无法解析 --since: {value}")
|
||||
return dt
|
||||
if checkpoint and checkpoint.exists():
|
||||
dt = parse_datetime(checkpoint.read_text(encoding="utf-8").strip())
|
||||
if dt is not None:
|
||||
return dt
|
||||
return datetime.now(timezone.utc) - timedelta(days=7)
|
||||
|
||||
|
||||
def _collect_all_prs(owner: str, repo: str):
|
||||
"""采集所有 PR(open + merged + closed),返回 (open_prs, merged_prs, all_pr_authors)。"""
|
||||
all_prs: list[dict] = []
|
||||
for state in ("open", "merged", "closed"):
|
||||
raw = run_gitlink_cli(["pr", "+list", "--state", state, "--limit", "200"], owner, repo)
|
||||
items = [
|
||||
normalize_pr(p)
|
||||
for p in extract_first_list(raw, ("pulls", "pull_requests", "issues", "items", "list"))
|
||||
if isinstance(p, dict)
|
||||
]
|
||||
all_prs.extend(items)
|
||||
# 去重 (同一条 PR 可能出现在多个 state 查询中)
|
||||
seen: set[str] = set()
|
||||
deduped: list[dict] = []
|
||||
for p in all_prs:
|
||||
if p["number"] not in seen:
|
||||
seen.add(p["number"])
|
||||
deduped.append(p)
|
||||
open_prs = [p for p in deduped if p["state"] == "open"]
|
||||
merged_prs = [p for p in deduped if p["merged"] or p["state"] == "merged"]
|
||||
authors = sorted({p["author_login"] for p in deduped if p["author_login"]})
|
||||
return open_prs, merged_prs, authors
|
||||
|
||||
|
||||
def _contributor_ranking(issues: list[dict], merged_prs: list[dict]) -> list[dict]:
|
||||
"""统计贡献者活跃度:合并 PR 数 + 提交 Issue 数。"""
|
||||
scores: dict[str, dict] = {}
|
||||
for i in issues:
|
||||
login = i.get("author_login", "")
|
||||
if not login:
|
||||
continue
|
||||
if login not in scores:
|
||||
scores[login] = {"login": login, "issues": 0, "prs": 0}
|
||||
scores[login]["issues"] += 1
|
||||
for p in merged_prs:
|
||||
login = p.get("author_login", "")
|
||||
if not login:
|
||||
continue
|
||||
if login not in scores:
|
||||
scores[login] = {"login": login, "issues": 0, "prs": 0}
|
||||
scores[login]["prs"] += 1
|
||||
ranked = sorted(scores.values(), key=lambda x: x["prs"] + x["issues"], reverse=True)
|
||||
return ranked[:10]
|
||||
|
||||
|
||||
def _load_previous_summary(checkpoint_dir: Path) -> dict | None:
|
||||
"""读取上一周期的汇总数据用于趋势对比。"""
|
||||
path = checkpoint_dir / ".last-sweep-summary.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _save_current_summary(checkpoint_dir: Path, summary: dict) -> None:
|
||||
"""保存本周期的汇总数据供下一次趋势对比。"""
|
||||
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
(checkpoint_dir / ".last-sweep-summary.json").write_text(
|
||||
json.dumps(summary, ensure_ascii=False, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def cmd_collect(args) -> int:
|
||||
owner, repo = args.owner, args.repo
|
||||
checkpoint = Path(args.checkpoint)
|
||||
since = _parse_since(args.since, checkpoint)
|
||||
|
||||
raw_issues = run_gitlink_cli(["issue", "+list", "--limit", "200"], owner, repo)
|
||||
all_issues = [
|
||||
normalize_issue(i)
|
||||
for i in extract_first_list(raw_issues, ("issues", "issue_list", "items", "list"))
|
||||
if isinstance(i, dict)
|
||||
]
|
||||
new_issues = [i for i in all_issues if i["created_at"] and i["created_at"] >= since]
|
||||
open_issue_numbers = sorted({i["number"] for i in all_issues if i["state"] == "open"})
|
||||
closed_issues = [i for i in all_issues if i["state"] == "closed"]
|
||||
|
||||
open_prs, merged_prs, pr_authors = _collect_all_prs(owner, repo)
|
||||
|
||||
# 趋势对比:加载上次汇总
|
||||
previous = _load_previous_summary(checkpoint.parent)
|
||||
current_counts = {
|
||||
"issues_total": len(all_issues), "issues_open": len(open_issue_numbers),
|
||||
"issues_closed": len(closed_issues), "prs_open": len(open_prs),
|
||||
"prs_merged": len(merged_prs), "contributors": len(
|
||||
{i.get("author_login", "") for i in all_issues if i.get("author_login")} | set(pr_authors)
|
||||
),
|
||||
}
|
||||
trends = _compute_trends(current_counts, previous)
|
||||
|
||||
# 贡献者排行
|
||||
contributors = _contributor_ranking(all_issues, merged_prs)
|
||||
|
||||
# 保存当前汇总供下一次对比
|
||||
_save_current_summary(checkpoint.parent, current_counts)
|
||||
|
||||
candidates = {
|
||||
"owner": owner, "repo": repo, "since": since.isoformat(),
|
||||
"issues": new_issues, "open_issues": open_issue_numbers,
|
||||
"closed_issues": [c["number"] for c in closed_issues],
|
||||
"merged_prs": merged_prs, "open_prs": open_prs,
|
||||
"trends": trends, "contributors": contributors,
|
||||
"current_counts": current_counts, "previous_counts": previous,
|
||||
"tag_map": _load_tag_map(owner, repo),
|
||||
"assigner_map": _load_assigner_map(owner, repo),
|
||||
}
|
||||
_write_json(Path(args.out), candidates)
|
||||
print(json.dumps({
|
||||
"since": since.isoformat(), "new_issues": len(new_issues),
|
||||
"merged_prs": len(merged_prs), "open_issues": len(open_issue_numbers),
|
||||
"open_prs": len(open_prs), "contributors": len(contributors),
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def _compute_trends(current: dict, previous: dict | None) -> dict[str, str]:
|
||||
"""对比上周期数据,返回趋势箭头。"""
|
||||
if not previous:
|
||||
return {}
|
||||
trends = {}
|
||||
for key in ("issues_total", "issues_open", "issues_closed", "prs_open", "prs_merged", "contributors"):
|
||||
prev = previous.get(key, 0)
|
||||
curr = current.get(key, 0)
|
||||
if isinstance(prev, (int, float)) and isinstance(curr, (int, float)):
|
||||
if curr > prev:
|
||||
trends[key] = f"↑{curr - prev}"
|
||||
elif curr < prev:
|
||||
trends[key] = f"↓{prev - curr}"
|
||||
else:
|
||||
trends[key] = "→"
|
||||
return trends
|
||||
|
||||
|
||||
def cmd_plan(args) -> int:
|
||||
candidates = _read_json(Path(args.candidates))
|
||||
tag_map = candidates.get("tag_map") or {}
|
||||
policy = _read_json(Path(args.policy)) if args.policy else DEFAULT_POLICY
|
||||
routing = _read_json(Path(args.routing)) if args.routing else {}
|
||||
triage = _read_json(Path(args.triage)) if args.triage and Path(args.triage).exists() else []
|
||||
owners = _read_json(Path(args.owners)) if args.owners and Path(args.owners).exists() else []
|
||||
links = _read_json(Path(args.links)) if args.links and Path(args.links).exists() else []
|
||||
|
||||
# 读取 LLM 生成的执行摘要
|
||||
summary_path = getattr(args, "summary", None)
|
||||
if summary_path and Path(summary_path).exists():
|
||||
summary_data = _read_json(Path(summary_path))
|
||||
candidates["executive_summary"] = summary_data.get("summary", "") if isinstance(summary_data, dict) else ""
|
||||
|
||||
plan = build_plan(candidates, triage, owners, links, routing, policy, tag_map)
|
||||
# R3: 周报 Markdown 生成(七段式);R4: Release Notes
|
||||
plan["report_md"] = _generate_report(candidates, plan)
|
||||
plan["release_notes_md"] = _render_release_notes(candidates)
|
||||
_write_json(Path(args.out), plan)
|
||||
print(json.dumps({"writes": len(plan["writes"])}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def _build_command(w: dict) -> list[str] | None:
|
||||
op = w.get("op")
|
||||
num = str(w.get("issue"))
|
||||
if op == "update_tags":
|
||||
return ["issue", "+update", "--number", num, "--tag-ids", ",".join(w.get("tag_ids") or [])]
|
||||
if op == "update_status":
|
||||
return ["issue", "+update", "--number", num, "--state", str(w.get("state"))]
|
||||
if op == "comment":
|
||||
return ["issue", "+comment", "--number", num, "--body", str(w.get("body"))]
|
||||
if op == "update_assigner":
|
||||
return ["issue", "+update", "--number", num, "--assigner-ids", ",".join(w.get("assigner_ids") or [])]
|
||||
return None
|
||||
|
||||
|
||||
def _exec_or_dry(args, cmd, label) -> bool:
|
||||
"""dry-run 打印;--apply 才真执行。返回是否执行成功。"""
|
||||
if not args.apply:
|
||||
preview = " ".join(cmd[:4]) + (" …" if len(cmd) > 4 else "")
|
||||
print(f"[dry-run] {label}: {preview}")
|
||||
return False
|
||||
try:
|
||||
run_gitlink_cli(cmd, args.owner, args.repo)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[fail] {label}: {exc}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def _publish_report(args, plan) -> None:
|
||||
"""R3:把 report_md 作为评论发到指定 issue。"""
|
||||
body = (plan.get("report_md") or "").strip()
|
||||
issue = getattr(args, "report_issue", None)
|
||||
if not body or not issue:
|
||||
return
|
||||
_exec_or_dry(args, ["issue", "+comment", "--number", str(issue), "--body", body],
|
||||
f"report→issue#{issue}")
|
||||
|
||||
|
||||
def _publish_release_notes(args, plan) -> None:
|
||||
"""R4:把 release_notes_md 合并进指定 release 的 body(release +update --body 整体替换,先读后拼)。"""
|
||||
body = (plan.get("release_notes_md") or "").strip()
|
||||
rid = getattr(args, "release_id", None)
|
||||
if not body or not rid:
|
||||
return
|
||||
existing = ""
|
||||
if args.apply:
|
||||
try:
|
||||
rv = run_gitlink_cli(["release", "+view", "--id", str(rid)], args.owner, args.repo)
|
||||
data = rv.get("data", rv) if isinstance(rv, dict) else {}
|
||||
existing = str(first_value(data, ("body", "description", "content", "version_content"), "") or "").rstrip()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[warn] read release body failed: {exc}", file=sys.stderr)
|
||||
merged = (existing + "\n\n" + body) if existing else body
|
||||
_exec_or_dry(args, ["release", "+update", "--id", str(rid), "--body", merged],
|
||||
f"notes→release#{rid}")
|
||||
|
||||
|
||||
def cmd_apply(args) -> int:
|
||||
plan = _read_json(Path(args.plan))
|
||||
writes = plan.get("writes", [])
|
||||
done = 0
|
||||
for w in writes:
|
||||
cmd = _build_command(w)
|
||||
if cmd is None:
|
||||
continue
|
||||
if _exec_or_dry(args, cmd, f"#{w.get('issue')} {w.get('op')}"):
|
||||
done += 1
|
||||
_publish_report(args, plan)
|
||||
_publish_release_notes(args, plan)
|
||||
_publish_report_to_wiki(args, plan)
|
||||
n = done if args.apply else len(writes)
|
||||
print(f"{'applied' if args.apply else 'dry-run'}: {n} write(s)")
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Wiki 发布(R3-wiki:周报上传到 Wiki 周报目录)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_WIKI_CLI_BIN = "gitlink-cli"
|
||||
|
||||
|
||||
def _find_wiki_cli() -> str:
|
||||
"""找到 gitlink-cli 可执行文件;优先复用 _gw 的 which 逻辑。"""
|
||||
if _gw is not None and hasattr(_gw, "shutil_which"):
|
||||
path = _gw.shutil_which(_WIKI_CLI_BIN)
|
||||
if path:
|
||||
return path
|
||||
from shutil import which
|
||||
return which(_WIKI_CLI_BIN) or _WIKI_CLI_BIN
|
||||
|
||||
|
||||
def _run_wiki(args, command: list[str], label: str) -> bool:
|
||||
"""执行 gitlink-cli wiki 子命令;dry-run 打印,--apply 真写。返回是否实际执行。"""
|
||||
cli = _find_wiki_cli()
|
||||
cmd = [cli, "wiki", *command, "--owner", args.owner, "--repo", args.repo]
|
||||
if not args.apply:
|
||||
print(f"[dry-run] {label}: {' '.join(cmd)}")
|
||||
return False
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if proc.returncode != 0:
|
||||
err = proc.stderr.strip() or proc.stdout.strip() or "未知错误"
|
||||
print(f"[fail] {label}: {err}", file=sys.stderr)
|
||||
return False
|
||||
print(f"[ok] {label}")
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[fail] {label}: {exc}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_wiki_dir(args, dir_name: str) -> bool:
|
||||
"""确保 Wiki 侧边栏中存在目录;不存在则创建。"""
|
||||
# 先检查目录是否已存在(通过读 Sidebar 判断)
|
||||
cli = _find_wiki_cli()
|
||||
check_cmd = [cli, "wiki", "+view", "--name", "_Sidebar",
|
||||
"--owner", args.owner, "--repo", args.repo]
|
||||
try:
|
||||
proc = subprocess.run(check_cmd, capture_output=True, text=True, timeout=15)
|
||||
if proc.returncode == 0 and f"- {dir_name}" not in proc.stdout:
|
||||
return _run_wiki(args, ["+mkdir", "--name", dir_name],
|
||||
f"wiki mkdir {dir_name}")
|
||||
except Exception:
|
||||
pass
|
||||
return True # 无法判断时跳过,让 create --dir 自行报错
|
||||
|
||||
|
||||
def _publish_report_to_wiki(args, plan) -> None:
|
||||
"""R3-wiki:把周报上传到 Wiki 周报目录(支持幂等:同日期运行更新而非报错)。"""
|
||||
body = (plan.get("report_md") or "").strip()
|
||||
if not body:
|
||||
return
|
||||
if not getattr(args, "publish_wiki", False):
|
||||
return
|
||||
|
||||
wiki_dir = getattr(args, "wiki_dir", None) or "周报专区"
|
||||
now = datetime.now(timezone.utc)
|
||||
page_name = f"周报-{now.strftime('%Y-%m-%d-%H%M')}"
|
||||
commit_msg = f"自动生成周报 {now.strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
# Step 1: 确保目录存在
|
||||
_ensure_wiki_dir(args, wiki_dir)
|
||||
|
||||
# Step 2: 尝试创建 → 失败则更新(幂等)
|
||||
ok = _run_wiki(args, [
|
||||
"+create", "--name", page_name,
|
||||
"--content", body,
|
||||
"--message", commit_msg,
|
||||
"--dir", wiki_dir,
|
||||
], f"wiki create {page_name} → {wiki_dir}")
|
||||
if not ok:
|
||||
_run_wiki(args, [
|
||||
"+update", "--name", page_name,
|
||||
"--content", body,
|
||||
"--message", commit_msg,
|
||||
], f"wiki update {page_name} (fallback)")
|
||||
|
||||
|
||||
def cmd_checkpoint(args) -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
Path(args.checkpoint).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.checkpoint).write_text(now.isoformat(), encoding="utf-8")
|
||||
log = Path(args.triage_log)
|
||||
entry = _read_json(Path(args.plan)) if args.plan and Path(args.plan).exists() else {"writes": []}
|
||||
batch = [
|
||||
f"\n## 批次 {now.isoformat()}({args.owner}/{args.repo})\n",
|
||||
f"- writes: {len(entry.get('writes', []))}\n",
|
||||
]
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
with log.open("a", encoding="utf-8") as fh:
|
||||
fh.writelines(batch)
|
||||
print(f"checkpoint → {args.checkpoint} ({now.isoformat()})")
|
||||
return 0
|
||||
|
||||
|
||||
def _generate_report(candidates: dict, plan: dict | None = None) -> str:
|
||||
"""生成七段式社区运营周报(纯 Markdown 文本拼接,非 HTML 渲染)。
|
||||
|
||||
Wiki 平台负责将 Markdown 转为页面展示,我们只负责数据→内容的编排。
|
||||
"""
|
||||
repo = candidates.get("repo", "")
|
||||
owner = candidates.get("owner", "")
|
||||
since_str = candidates.get("since", "")[:10]
|
||||
now = datetime.now(timezone.utc)
|
||||
week_end = now.strftime("%Y-%m-%d")
|
||||
week_range = f"{since_str} ~ {week_end}"
|
||||
|
||||
trends = candidates.get("trends", {}) or {}
|
||||
current = candidates.get("current_counts", {}) or {}
|
||||
contributors = candidates.get("contributors", []) or []
|
||||
writes = (plan or {}).get("writes", []) or []
|
||||
|
||||
issues = candidates.get("issues", []) or []
|
||||
merged_prs = candidates.get("merged_prs", []) or []
|
||||
open_prs = candidates.get("open_prs", []) or []
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
# ── 标题 ──
|
||||
lines.append(f"# {repo} 社区运营周报")
|
||||
lines.append("")
|
||||
lines.append(f"> 📅 {week_range} | 🤖 由 community-ops-sweep 自动生成")
|
||||
lines.append("")
|
||||
|
||||
# ── 一、执行摘要 ──
|
||||
lines.append("## 一、📊 执行摘要")
|
||||
lines.append("")
|
||||
summary_placeholder = candidates.get("executive_summary", "").strip()
|
||||
if summary_placeholder:
|
||||
lines.append(summary_placeholder)
|
||||
else:
|
||||
lines.append("> ⚠️ 执行摘要未生成。运行 Workflow 时由 LLM Agent 自动填充。")
|
||||
lines.append("")
|
||||
|
||||
# ── 二、核心指标 ──
|
||||
lines.append("## 二、📈 核心指标")
|
||||
lines.append("")
|
||||
lines.append("| 指标 | 本周数值 | 趋势 |")
|
||||
lines.append("|------|:--------:|:----:|")
|
||||
_metric_row(lines, "Issue 总数", current, "issues_total", trends)
|
||||
_metric_row(lines, "打开 Issue", current, "issues_open", trends)
|
||||
_metric_row(lines, "已关闭 Issue", current, "issues_closed", trends)
|
||||
_metric_row(lines, "待审 PR", current, "prs_open", trends)
|
||||
_metric_row(lines, "已合并 PR", current, "prs_merged", trends)
|
||||
_metric_row(lines, "活跃贡献者", current, "contributors", trends)
|
||||
lines.append("")
|
||||
|
||||
# ── 三、新增 Issue 清单 ──
|
||||
lines.append("## 三、🆕 新增 Issue 清单")
|
||||
lines.append("")
|
||||
if issues:
|
||||
lines.append("| 编号 | 标题 | 标签 | 提交者 |")
|
||||
lines.append("|------|------|------|--------|")
|
||||
for i in issues:
|
||||
num = i.get("number", "-")
|
||||
title = (i.get("title") or "(untitled)")[:60]
|
||||
labels = ", ".join(i.get("labels", [])[:4]) or "-"
|
||||
author = i.get("author_login", "-")
|
||||
lines.append(f"| #{num} | {title} | {labels} | @{author} |")
|
||||
else:
|
||||
lines.append("本周无新增 Issue。")
|
||||
lines.append("")
|
||||
|
||||
# ── 四、PR 活动 ──
|
||||
lines.append("## 四、🔀 PR 活动")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"### 已合并 PR({len(merged_prs)} 个)")
|
||||
if merged_prs:
|
||||
lines.append("")
|
||||
lines.append("| 编号 | 标题 | 作者 | 合并时间 |")
|
||||
lines.append("|------|------|------|----------|")
|
||||
for p in merged_prs[:15]:
|
||||
num = p.get("number", "-")
|
||||
title = (p.get("title") or "(untitled)")[:60]
|
||||
author = p.get("author_login", "-")
|
||||
merged_at = _fmt_dt(p.get("merged_at") or p.get("updated_at"))
|
||||
lines.append(f"| !{num} | {title} | @{author} | {merged_at} |")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("本周无合并 PR。")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"### 待审 PR({len(open_prs)} 个)")
|
||||
if open_prs:
|
||||
lines.append("")
|
||||
lines.append("| 编号 | 标题 | 作者 | 创建时间 |")
|
||||
lines.append("|------|------|------|----------|")
|
||||
for p in open_prs[:15]:
|
||||
num = p.get("number", "-")
|
||||
title = (p.get("title") or "(untitled)")[:60]
|
||||
author = p.get("author_login", "-")
|
||||
created = _fmt_dt(p.get("created_at"))
|
||||
lines.append(f"| !{num} | {title} | @{author} | {created} |")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("无待审 PR 🎉")
|
||||
lines.append("")
|
||||
|
||||
# ── 五、贡献者活跃榜 ──
|
||||
lines.append("## 五、👥 贡献者活跃榜")
|
||||
lines.append("")
|
||||
if contributors:
|
||||
lines.append("| 排名 | 贡献者 | Issue 提交 | PR 合并 | 活跃度 |")
|
||||
lines.append("|:----:|--------|:----------:|:-------:|:------:|")
|
||||
for idx, c in enumerate(contributors, 1):
|
||||
score = c["issues"] + c["prs"]
|
||||
bar = "█" * min(score, 10)
|
||||
lines.append(f"| {idx} | @{c['login']} | {c['issues']} | {c['prs']} | {bar} |")
|
||||
else:
|
||||
lines.append("本周无活跃贡献者。")
|
||||
lines.append("")
|
||||
|
||||
# ── 六、自动 Triage 日志 ──
|
||||
lines.append("## 六、🤖 自动 Triage 日志")
|
||||
lines.append("")
|
||||
if writes:
|
||||
lines.append("| Issue | 操作 | 详情 |")
|
||||
lines.append("|-------|------|------|")
|
||||
for w in writes:
|
||||
issue_num = f"#{w.get('issue', '-')}"
|
||||
op = w.get("op", "-")
|
||||
detail = _triage_detail(w)
|
||||
lines.append(f"| {issue_num} | {op} | {detail} |")
|
||||
else:
|
||||
lines.append("本次 sweep 未执行写操作(无变更或 dry-run)。")
|
||||
lines.append("")
|
||||
|
||||
# ── 七、待办与风险 ──
|
||||
lines.append("## 七、⚠️ 待办与风险")
|
||||
lines.append("")
|
||||
stale_issues = [i for i in issues if _is_stale(i, since_str)]
|
||||
stale_prs = [p for p in open_prs if _is_stale_pr(p, since_str)]
|
||||
if stale_issues:
|
||||
lines.append(f"- 🔴 **{len(stale_issues)} 个 Issue** 超过窗口期未更新,建议优先处理")
|
||||
if stale_prs:
|
||||
lines.append(f"- 🟡 **{len(stale_prs)} 个 PR** 等待 Review 超过窗口期,建议推进")
|
||||
if open_prs:
|
||||
lines.append(f"- 📋 **{len(open_prs)} 个 PR** 待 Review,建议分配 Reviewer")
|
||||
if not stale_issues and not stale_prs:
|
||||
lines.append("- ✅ 当前无超期未处理的 Issue 或 PR。")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append(f"*报告生成时间:{now.strftime('%Y-%m-%d %H:%M:%S UTC')} | 数据来源:[{owner}/{repo}](https://gitlink.org.cn/{owner}/{repo})*")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _metric_row(lines: list[str], label: str, counts: dict, key: str, trends: dict):
|
||||
val = counts.get(key, "-")
|
||||
trend = trends.get(key, "→")
|
||||
lines.append(f"| {label} | {val} | {trend} |")
|
||||
|
||||
|
||||
def _fmt_dt(dt) -> str:
|
||||
if isinstance(dt, datetime):
|
||||
return dt.strftime("%m-%d")
|
||||
if isinstance(dt, str):
|
||||
return dt[:10]
|
||||
return "-"
|
||||
|
||||
|
||||
def _is_stale(issue: dict, since_str: str) -> bool:
|
||||
updated = issue.get("updated_at")
|
||||
if isinstance(updated, datetime):
|
||||
return updated.strftime("%Y-%m-%d") < since_str
|
||||
if isinstance(updated, str):
|
||||
return updated[:10] < since_str
|
||||
return False
|
||||
|
||||
|
||||
def _is_stale_pr(pr: dict, since_str: str) -> bool:
|
||||
updated = pr.get("updated_at")
|
||||
if isinstance(updated, datetime):
|
||||
return updated.strftime("%Y-%m-%d") < since_str
|
||||
if isinstance(updated, str):
|
||||
return updated[:10] < since_str
|
||||
return False
|
||||
|
||||
|
||||
def _triage_detail(w: dict) -> str:
|
||||
op = w.get("op", "")
|
||||
if op == "update_tags":
|
||||
added = ", ".join(w.get("tag_names", []))
|
||||
removed = ", ".join(w.get("remove_tag_names", []))
|
||||
parts = []
|
||||
if added:
|
||||
parts.append(f"+{added}")
|
||||
if removed:
|
||||
parts.append(f"-{removed}")
|
||||
return " ".join(parts) if parts else "标签调整"
|
||||
if op == "update_status":
|
||||
return f"→ {w.get('state', '?')} ({w.get('reason', '')})"
|
||||
if op == "comment":
|
||||
kind = w.get("kind", "")
|
||||
return {"owner_suggest": "建议负责人", "link_close": "自动关闭通知"}.get(kind, "评论")
|
||||
if op == "update_assigner":
|
||||
return f"指派 {', '.join(w.get('assigner_ids', []))}"
|
||||
return "-"
|
||||
|
||||
|
||||
def _render_release_notes(candidates: dict) -> str:
|
||||
if _gw is None:
|
||||
return ""
|
||||
try:
|
||||
repo_info = {"name": candidates.get("repo", ""), "description": ""}
|
||||
issues = [coerce_dates(i) for i in candidates.get("issues", [])]
|
||||
prs = [coerce_dates(p, ("updated_at", "created_at", "merged_at")) for p in candidates.get("merged_prs", [])]
|
||||
summary = _gw.summarize_workflow(
|
||||
repo_info, issues, prs, [],
|
||||
datetime.now(timezone.utc), 7,
|
||||
)
|
||||
return _gw.render_release_notes(summary)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[warn] render_release_notes failed: {exc}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="community-ops-sweep 确定性引擎")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
pc = sub.add_parser("collect", help="采集 → candidates.json")
|
||||
pc.add_argument("--owner", required=True); pc.add_argument("--repo", required=True)
|
||||
pc.add_argument("--since"); pc.add_argument("--out", default="candidates.json")
|
||||
pc.add_argument("--checkpoint", default=".last-sweep")
|
||||
pc.set_defaults(func=cmd_collect)
|
||||
|
||||
pp = sub.add_parser("plan", help="candidates + decisions → plan.json")
|
||||
pp.add_argument("--candidates", default="candidates.json")
|
||||
pp.add_argument("--triage"); pp.add_argument("--owners"); pp.add_argument("--links")
|
||||
pp.add_argument("--summary", help="LLM 生成的执行摘要 JSON 文件")
|
||||
pp.add_argument("--routing"); pp.add_argument("--policy")
|
||||
pp.add_argument("--out", default="plan.json")
|
||||
pp.set_defaults(func=cmd_plan)
|
||||
|
||||
pa = sub.add_parser("apply", help="执行 plan.json(默认 dry-run)")
|
||||
pa.add_argument("--plan", default="plan.json")
|
||||
pa.add_argument("--owner", required=True); pa.add_argument("--repo", required=True)
|
||||
pa.add_argument("--apply", action="store_true")
|
||||
pa.add_argument("--report-issue", dest="report_issue", help="R3:把周报作为评论发到此 issue")
|
||||
pa.add_argument("--release-id", dest="release_id", help="R4:把 release notes 合并进此 release body")
|
||||
pa.add_argument("--publish-wiki", action="store_true", dest="publish_wiki",
|
||||
help="R3-wiki:把周报上传到 Wiki 周报目录")
|
||||
pa.add_argument("--wiki-dir", dest="wiki_dir", default="周报专区",
|
||||
help="Wiki 周报目录名(默认:周报专区)")
|
||||
pa.set_defaults(func=cmd_apply)
|
||||
|
||||
pk = sub.add_parser("checkpoint", help="推进 .last-sweep + 追加 triage-log")
|
||||
pk.add_argument("--owner", required=True); pk.add_argument("--repo", required=True)
|
||||
pk.add_argument("--checkpoint", default=".last-sweep")
|
||||
pk.add_argument("--triage-log", default="docs/issue-triage-log.md")
|
||||
pk.add_argument("--plan")
|
||||
pk.set_defaults(func=cmd_checkpoint)
|
||||
|
||||
args = p.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
#!/usr/bin/env python3
|
||||
"""community_ops_sweep 引擎纯逻辑单测 —— 把"同输入→同 plan"钉成可回归事实。
|
||||
|
||||
只测确定性纯函数(标签增删 / 状态映射 / 链接校验 / plan 组装),不碰 gitlink-cli I/O。
|
||||
纯标准库 unittest(与 gatekeeper test_scoring.py 同风格)。
|
||||
|
||||
运行:
|
||||
python3 examples/workflows/community-ops-sweep/tests/test_sweep.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# 以绝对路径加载被测脚本(非包,按文件载入;先注册 sys.modules 以兼容 dataclass+annotations)
|
||||
_SCRIPT = (
|
||||
Path(__file__).resolve().parent.parent / "scripts" / "community_ops_sweep.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("community_ops_sweep", _SCRIPT)
|
||||
assert _spec and _spec.loader, f"无法定位被测脚本:{_SCRIPT}"
|
||||
sw = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["community_ops_sweep"] = sw
|
||||
_spec.loader.exec_module(sw) # type: ignore[union-attr]
|
||||
|
||||
TAG_MAP = {"bug": "3", "feature": "5", "docs": "7", "question": "9", "enhancement": "11"}
|
||||
POLICY = {
|
||||
"status_map": {"duplicate": "closed", "answered": "closed", "spam": "closed"},
|
||||
}
|
||||
|
||||
|
||||
class TestMergeTagIds(unittest.TestCase):
|
||||
def test_add_to_empty(self):
|
||||
self.assertEqual(sw.merge_tag_ids([], ["bug"], [], TAG_MAP), ["3"])
|
||||
|
||||
def test_add_preserves_existing_union(self):
|
||||
# 整体替换语义:现有 ∪ 新增
|
||||
self.assertEqual(sw.merge_tag_ids(["3", "7"], ["feature"], [], TAG_MAP), ["3", "5", "7"])
|
||||
|
||||
def test_remove_drops_tag(self):
|
||||
self.assertEqual(sw.merge_tag_ids(["3", "7"], [], ["bug"], TAG_MAP), ["7"])
|
||||
|
||||
def test_add_and_remove_together(self):
|
||||
# current[3,7] ∪ {5} \ {7} = {3,5}
|
||||
self.assertEqual(sw.merge_tag_ids(["3", "7"], ["feature"], ["docs"], TAG_MAP), ["3", "5"])
|
||||
|
||||
def test_unknown_add_filtered_by_whitelist(self):
|
||||
self.assertEqual(sw.merge_tag_ids([], ["nonexistent"], [], TAG_MAP), [])
|
||||
|
||||
def test_result_sorted_deduped(self):
|
||||
self.assertEqual(sw.merge_tag_ids(["7", "3"], ["bug"], [], TAG_MAP), ["3", "7"])
|
||||
|
||||
|
||||
class TestMapStatus(unittest.TestCase):
|
||||
def test_duplicate_closes(self):
|
||||
self.assertEqual(sw.map_status("duplicate", POLICY), "closed")
|
||||
|
||||
def test_unknown_action_no_change(self):
|
||||
self.assertIsNone(sw.map_status("schedule_fix", POLICY))
|
||||
|
||||
def test_none_action_no_change(self):
|
||||
self.assertIsNone(sw.map_status(None, POLICY))
|
||||
|
||||
|
||||
class TestBuildPlanTriage(unittest.TestCase):
|
||||
def _candidates(self, issues):
|
||||
return {"issues": issues, "merged_prs": [], "open_issues": {i["number"] for i in issues}}
|
||||
|
||||
def test_plan_adds_tag(self):
|
||||
candidates = self._candidates([{"number": "42", "title": "登录失败", "description": "", "tags": [], "state": "open"}])
|
||||
triage = [{"issue": "42", "tag_names": ["bug"], "remove_tag_names": [], "recommended_action": "schedule_fix"}]
|
||||
plan = sw.build_plan(candidates, triage, [], [], {}, POLICY, TAG_MAP)
|
||||
tag_writes = [w for w in plan["writes"] if w.get("issue") == "42" and w["op"] == "update_tags"]
|
||||
self.assertTrue(any(w["tag_ids"] == ["3"] for w in tag_writes), plan["writes"])
|
||||
|
||||
def test_plan_removes_tag(self):
|
||||
candidates = self._candidates([{"number": "42", "title": "x", "description": "", "tags": [{"id": "3", "name": "bug"}], "state": "open"}])
|
||||
triage = [{"issue": "42", "tag_names": [], "remove_tag_names": ["bug"], "recommended_action": "schedule_fix"}]
|
||||
plan = sw.build_plan(candidates, triage, [], [], {}, POLICY, TAG_MAP)
|
||||
tag_writes = [w for w in plan["writes"] if w.get("issue") == "42" and w["op"] == "update_tags"]
|
||||
# 期望全集为空 → tag_ids == []
|
||||
self.assertTrue(any(w["tag_ids"] == [] for w in tag_writes), plan["writes"])
|
||||
|
||||
def test_plan_closes_duplicate(self):
|
||||
candidates = self._candidates([{"number": "43", "title": "dup", "description": "", "tags": [], "state": "open"}])
|
||||
triage = [{"issue": "43", "tag_names": [], "remove_tag_names": [], "recommended_action": "duplicate"}]
|
||||
plan = sw.build_plan(candidates, triage, [], [], {}, POLICY, TAG_MAP)
|
||||
status_writes = [w for w in plan["writes"] if w.get("issue") == "43" and w["op"] == "update_status"]
|
||||
self.assertTrue(any(w["state"] == "closed" for w in status_writes), plan["writes"])
|
||||
|
||||
|
||||
class TestBuildPlanLinks(unittest.TestCase):
|
||||
def test_link_closes_only_open_existing_issue(self):
|
||||
candidates = {
|
||||
"issues": [],
|
||||
"merged_prs": [{"number": "128", "title": "fix login", "description": "fixes #45", "author": "alice"}],
|
||||
"open_issues": {"45"}, # 45 开着;99 不在 → 不存在/未开
|
||||
}
|
||||
links = [{"pr": "128", "linked_issue_numbers": ["45", "99"]}]
|
||||
plan = sw.build_plan(candidates, [], [], links, {}, POLICY, TAG_MAP)
|
||||
closes = [w for w in plan["writes"] if w["op"] == "update_status" and w.get("state") == "closed"]
|
||||
closed = {w["issue"] for w in closes}
|
||||
self.assertIn("45", closed)
|
||||
self.assertNotIn("99", closed)
|
||||
|
||||
def test_link_no_match_no_write(self):
|
||||
candidates = {"issues": [], "merged_prs": [{"number": "1", "title": "x", "description": "", "author": "a"}], "open_issues": set()}
|
||||
links = [{"pr": "1", "linked_issue_numbers": []}]
|
||||
plan = sw.build_plan(candidates, [], [], links, {}, POLICY, TAG_MAP)
|
||||
self.assertFalse(any(w["op"] == "update_status" for w in plan["writes"]))
|
||||
|
||||
|
||||
class TestBuildPlanOwnerSuggest(unittest.TestCase):
|
||||
def test_owner_suggestion_emits_comment_not_assign(self):
|
||||
# R2 默认建议:写 comment,不写 assign
|
||||
candidates = {"issues": [{"number": "50", "title": "t", "description": "", "tags": [], "state": "open"}], "merged_prs": [], "open_issues": {"50"}}
|
||||
owners = [{"issue": "50", "suggested_owners": ["alice", "bob"], "evidence": "近期 PR#128"}]
|
||||
plan = sw.build_plan(candidates, [], owners, [], {}, POLICY, TAG_MAP)
|
||||
comments = [w for w in plan["writes"] if w.get("issue") == "50" and w["op"] == "comment"]
|
||||
assigns = [w for w in plan["writes"] if w.get("issue") == "50" and w["op"] == "update_assigner"]
|
||||
self.assertTrue(comments, "应有建议评论")
|
||||
self.assertFalse(assigns, "默认不应自动指派")
|
||||
|
||||
|
||||
class TestCoerceDates(unittest.TestCase):
|
||||
"""candidates.json 经磁盘 round-trip 后日期变字符串;渲染前要 coerce 回 datetime。"""
|
||||
|
||||
def test_parses_string_dates(self):
|
||||
out = sw.coerce_dates({"updated_at": "2026-06-15T10:00:00+00:00", "created_at": "2026-06-10T00:00:00+00:00", "x": 1})
|
||||
self.assertIsInstance(out["updated_at"], datetime)
|
||||
self.assertIsInstance(out["created_at"], datetime)
|
||||
self.assertEqual(out["x"], 1)
|
||||
|
||||
def test_datetime_and_none_passthrough(self):
|
||||
dt = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
out = sw.coerce_dates({"updated_at": dt, "created_at": None})
|
||||
self.assertIs(out["updated_at"], dt)
|
||||
self.assertIsNone(out["created_at"])
|
||||
|
||||
def test_custom_keys(self):
|
||||
out = sw.coerce_dates({"merged_at": "2026-06-15T10:00:00+00:00"}, keys=("merged_at",))
|
||||
self.assertIsInstance(out["merged_at"], datetime)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
Reference in New Issue