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:
wauxing 2026-07-07 10:43:08 +08:00
parent 7aa6f1b757
commit d9d3bbc30c
7 changed files with 1614 additions and 0 deletions

View File

@ -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)
},
},
}
}

View File

@ -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)
}
}

View File

@ -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
# applydry-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→ 无二次确认

View File

@ -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,
}

View File

@ -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

View File

@ -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-cliissue 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 封装与通用 helpersbest-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):
"""采集所有 PRopen + 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 的 bodyrelease +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())

View File

@ -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)