@kernel-sig/console 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- package/scripts/verify-analysis-pipeline.py +303 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""评审状态解析与合并门禁判定。
|
|
2
|
+
|
|
3
|
+
AtomGit 的 PR 对象**不携带**评审关系,评审状态由标签与机器人评论承载。
|
|
4
|
+
实测已合并 PR 的标签组合为:
|
|
5
|
+
|
|
6
|
+
['openeuler-cla/yes', 'sig/Kernel', 'ci_successful', 'lgtm', 'approved']
|
|
7
|
+
|
|
8
|
+
门禁链条:CLA → CI → 关联 Issue → LGTM → 可合入。
|
|
9
|
+
|
|
10
|
+
``approved`` **不在**这条链上:实测 200 个 open PR 中 0 个带该标签,
|
|
11
|
+
而 200 个已合并 PR 中 199 个带 —— 它是合入时补记的标记,不是前置条件。
|
|
12
|
+
|
|
13
|
+
评审人身份同样不在标签里(PR 上用的是整体 ``lgtm``),
|
|
14
|
+
而在人类的 ``/lgtm`` 评论与机器人的确认回执中。
|
|
15
|
+
|
|
16
|
+
本模块把标签与评论解析为结构化的评审事件与门禁状态,
|
|
17
|
+
是 SLA 统计、评审人工作量、等待时长等一切评审指标的基础。
|
|
18
|
+
|
|
19
|
+
纯函数实现,不依赖数据库与网络,便于独立测试与在 worker 中复用。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Iterable, Sequence
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from enum import StrEnum
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# 标签解析
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EventType(StrEnum):
|
|
36
|
+
"""评审事件类型。**全平台唯一的一份定义**。
|
|
37
|
+
|
|
38
|
+
数据库枚举、解析器与统计逻辑共用此枚举 ——
|
|
39
|
+
同一概念若在两处各写一份,取值集合必然漂移,
|
|
40
|
+
表现为"解析出的值写不进数据库"。
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
LGTM = "lgtm"
|
|
44
|
+
APPROVE = "approve"
|
|
45
|
+
ACK = "ack"
|
|
46
|
+
REJECT = "reject"
|
|
47
|
+
CI_PASS = "ci_pass"
|
|
48
|
+
CI_FAIL = "ci_fail"
|
|
49
|
+
CI_RUNNING = "ci_running"
|
|
50
|
+
CLA_SIGNED = "cla_signed"
|
|
51
|
+
CLA_DENIED = "cla_denied"
|
|
52
|
+
CLOSE = "close"
|
|
53
|
+
REOPEN = "reopen"
|
|
54
|
+
RETEST = "retest"
|
|
55
|
+
LABEL_ADDED = "label_added"
|
|
56
|
+
LABEL_REMOVED = "label_removed"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class GateStage(StrEnum):
|
|
60
|
+
"""PR 在评审流程中的阶段。"""
|
|
61
|
+
|
|
62
|
+
DRAFT = "draft"
|
|
63
|
+
CLA_PENDING = "cla_pending"
|
|
64
|
+
CLA_DENIED = "cla_denied"
|
|
65
|
+
CI_PENDING = "ci_pending"
|
|
66
|
+
CI_RUNNING = "ci_running"
|
|
67
|
+
CI_FAILED = "ci_failed"
|
|
68
|
+
NEEDS_ISSUE = "needs_issue"
|
|
69
|
+
REVIEW_PENDING = "review_pending"
|
|
70
|
+
REJECTED = "rejected"
|
|
71
|
+
READY_TO_MERGE = "ready_to_merge"
|
|
72
|
+
MERGED = "merged"
|
|
73
|
+
CLOSED = "closed"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# 标签取值来自对真实仓库的实测统计(300 个 open PR 的标签分布),
|
|
77
|
+
# 而非文档或猜测。注意 ``ci_failed`` 才是实际最常用的 CI 失败标签,
|
|
78
|
+
# 仅处理 ci_must_go_failed / ci_block_force_merge 会把失败的 PR
|
|
79
|
+
# 误判为"CI 未完成",向维护者展示完全错误的阻塞原因。
|
|
80
|
+
#
|
|
81
|
+
# 顺序要求:具体规则必须排在通用规则之前,否则 lgtm-<user> 会被 lgtm 抢先匹配。
|
|
82
|
+
LABEL_RULES: tuple[tuple[re.Pattern[str], EventType], ...] = (
|
|
83
|
+
(re.compile(r"^lgtm-(?P<user>.+)$"), EventType.LGTM),
|
|
84
|
+
(re.compile(r"^lgtm$"), EventType.LGTM),
|
|
85
|
+
(re.compile(r"^approved$"), EventType.APPROVE),
|
|
86
|
+
# Acked 是正向评审信号,但并非合并门禁的必需项
|
|
87
|
+
# (实测已合并 PR 的门禁为 cla + ci_successful + lgtm + approved),
|
|
88
|
+
# 因此单独记录而不计入 approved 判定。
|
|
89
|
+
(re.compile(r"^Acked$"), EventType.ACK),
|
|
90
|
+
(re.compile(r"^NACK$"), EventType.REJECT),
|
|
91
|
+
(re.compile(r"^openeuler-cla/yes$"), EventType.CLA_SIGNED),
|
|
92
|
+
(re.compile(r"^openeuler-cla/no$"), EventType.CLA_DENIED),
|
|
93
|
+
(re.compile(r"^ci_successful$"), EventType.CI_PASS),
|
|
94
|
+
(re.compile(r"^ci_failed$"), EventType.CI_FAIL),
|
|
95
|
+
(re.compile(r"^ci_must_go_failed$"), EventType.CI_FAIL),
|
|
96
|
+
(re.compile(r"^ci_block_force_merge$"), EventType.CI_FAIL),
|
|
97
|
+
(re.compile(r"^ci_processing$"), EventType.CI_RUNNING),
|
|
98
|
+
# 同义标签:连字符与下划线两种写法在仓库中并存
|
|
99
|
+
(re.compile(r"^ci-process$"), EventType.CI_RUNNING),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# 已知但与评审门禁无关的标签。
|
|
103
|
+
# ``needs-issue`` 单独处理(它是门禁条件,不是可忽略的分类标签)。
|
|
104
|
+
_IGNORED_LABEL_PREFIXES = ("sig/", "kind/", "ai-")
|
|
105
|
+
|
|
106
|
+
# openEuler 硬规则:PR 必须至少关联一个 Issue(CI 机器人会明确提示)
|
|
107
|
+
NEEDS_ISSUE_LABEL = "needs-issue"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class ParsedEvent:
|
|
112
|
+
"""从标签或评论解析出的一条评审事件。"""
|
|
113
|
+
|
|
114
|
+
event_type: EventType
|
|
115
|
+
actor_login: str
|
|
116
|
+
occurred_at: datetime
|
|
117
|
+
source: str # "label" | "comment"
|
|
118
|
+
raw: str
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def actor(self) -> str:
|
|
122
|
+
"""空 actor 表示"非特定人触发"(如整体 lgtm 标签)。"""
|
|
123
|
+
return self.actor_login
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class MergeGate:
|
|
128
|
+
"""合并门禁状态。
|
|
129
|
+
|
|
130
|
+
门禁链条:CLA → CI → 关联 Issue → LGTM → Approved。
|
|
131
|
+
全部满足才可合入;任一环节被显式否决(CLA 拒绝 / CI 失败 / NACK)
|
|
132
|
+
都优先于"未完成"展示 —— 维护者需要看到的是"出问题了",
|
|
133
|
+
而不是"还在等"。
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
cla_signed: bool = False
|
|
137
|
+
cla_denied: bool = False
|
|
138
|
+
ci_passed: bool = False
|
|
139
|
+
ci_failed: bool = False
|
|
140
|
+
ci_running: bool = False
|
|
141
|
+
approved: bool = False
|
|
142
|
+
rejected: bool = False
|
|
143
|
+
needs_issue: bool = False
|
|
144
|
+
lgtm_actors: set[str] = field(default_factory=set)
|
|
145
|
+
has_plain_lgtm: bool = False
|
|
146
|
+
is_draft: bool = False
|
|
147
|
+
is_merged: bool = False
|
|
148
|
+
is_closed: bool = False
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def has_lgtm(self) -> bool:
|
|
152
|
+
"""存在整体 lgtm 标签,或至少一位具名评审人给出 lgtm。"""
|
|
153
|
+
return self.has_plain_lgtm or bool(self.lgtm_actors)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def ci_blocked(self) -> bool:
|
|
157
|
+
return self.ci_failed
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def ready_to_merge(self) -> bool:
|
|
161
|
+
"""维护者当前即可合入。
|
|
162
|
+
|
|
163
|
+
**不要求 approved 标签**。实测数据:200 个 open PR 中 0 个带 approved,
|
|
164
|
+
而 200 个已合并 PR 中 199 个带 approved —— 说明 approved 是**合入时**
|
|
165
|
+
由机器人补记的标记,而非合入前需要等待的门禁条件。
|
|
166
|
+
若把它作为前置条件,"可合入"状态在 open PR 上永不可能出现,
|
|
167
|
+
维护者最需要的那份待办队列会直接失效。
|
|
168
|
+
"""
|
|
169
|
+
return (
|
|
170
|
+
self.cla_signed
|
|
171
|
+
and not self.cla_denied
|
|
172
|
+
and self.ci_passed
|
|
173
|
+
and not self.ci_failed
|
|
174
|
+
and self.has_lgtm
|
|
175
|
+
and not self.rejected
|
|
176
|
+
and not self.needs_issue
|
|
177
|
+
and not self.is_draft
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def is_approved(self) -> bool:
|
|
182
|
+
"""approved 标签是否已打上(合入前后的标记,非门禁前置条件)。"""
|
|
183
|
+
return self.approved
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def blocking_reasons(self) -> list[str]:
|
|
187
|
+
"""按门禁链条顺序列出尚未满足的条件,供界面直接展示。"""
|
|
188
|
+
if self.is_merged or self.is_closed:
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
reasons: list[str] = []
|
|
192
|
+
if self.is_draft:
|
|
193
|
+
reasons.append("草稿状态")
|
|
194
|
+
if self.cla_denied:
|
|
195
|
+
reasons.append("CLA 未通过")
|
|
196
|
+
elif not self.cla_signed:
|
|
197
|
+
reasons.append("CLA 未签署")
|
|
198
|
+
|
|
199
|
+
if self.ci_failed:
|
|
200
|
+
reasons.append("CI 未通过")
|
|
201
|
+
elif self.ci_running:
|
|
202
|
+
reasons.append("CI 进行中")
|
|
203
|
+
elif not self.ci_passed:
|
|
204
|
+
reasons.append("CI 未完成")
|
|
205
|
+
|
|
206
|
+
if self.needs_issue:
|
|
207
|
+
reasons.append("未关联 Issue")
|
|
208
|
+
if self.rejected:
|
|
209
|
+
reasons.append("已被 NACK")
|
|
210
|
+
if not self.has_lgtm:
|
|
211
|
+
reasons.append("缺少 LGTM")
|
|
212
|
+
return reasons
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def stage(self) -> GateStage:
|
|
216
|
+
if self.is_merged:
|
|
217
|
+
return GateStage.MERGED
|
|
218
|
+
if self.is_closed:
|
|
219
|
+
return GateStage.CLOSED
|
|
220
|
+
if self.is_draft:
|
|
221
|
+
return GateStage.DRAFT
|
|
222
|
+
if self.cla_denied:
|
|
223
|
+
return GateStage.CLA_DENIED
|
|
224
|
+
if not self.cla_signed:
|
|
225
|
+
return GateStage.CLA_PENDING
|
|
226
|
+
if self.ci_failed:
|
|
227
|
+
return GateStage.CI_FAILED
|
|
228
|
+
if self.ci_running:
|
|
229
|
+
return GateStage.CI_RUNNING
|
|
230
|
+
if not self.ci_passed:
|
|
231
|
+
return GateStage.CI_PENDING
|
|
232
|
+
if self.needs_issue:
|
|
233
|
+
return GateStage.NEEDS_ISSUE
|
|
234
|
+
if self.rejected:
|
|
235
|
+
return GateStage.REJECTED
|
|
236
|
+
if not self.has_lgtm:
|
|
237
|
+
return GateStage.REVIEW_PENDING
|
|
238
|
+
return GateStage.READY_TO_MERGE
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# 机器人评论中的指令
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
# openEuler 的 CI 机器人响应这些指令,形如 "/lgtm"、"/approve"
|
|
246
|
+
COMMAND_PATTERN = re.compile(r"^\s*/(?P<cmd>lgtm|approve|close|reopen|retest)\s*$", re.IGNORECASE)
|
|
247
|
+
|
|
248
|
+
# 机器人账号识别。
|
|
249
|
+
#
|
|
250
|
+
# 仅匹配后缀不够:仓库里实际的机器人叫 devstation-robot、ci-robot1、
|
|
251
|
+
# openeuler-infra-bot —— 前两者以 "robot" 而非 "bot" 结尾,只匹配后缀会漏掉
|
|
252
|
+
# 合计 600 多个 PR 的自动化账号,让评审工作量与作者榜单失去意义。
|
|
253
|
+
#
|
|
254
|
+
# 但也不能放宽到"包含 robot 前缀":那会把 robotics-team 这类正常名称误判。
|
|
255
|
+
# 因此按分隔符切词,词段须**恰好**是 bot/robot 加可选数字。
|
|
256
|
+
_BOT_SEGMENT_PATTERN = re.compile(r"^(?:bot|robot)\d*$")
|
|
257
|
+
BOT_SUFFIXES = ("-bot", "_bot", "[bot]")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def is_bot(login: str | None) -> bool:
|
|
261
|
+
if not login:
|
|
262
|
+
return False
|
|
263
|
+
lowered = login.lower()
|
|
264
|
+
if lowered.endswith(BOT_SUFFIXES):
|
|
265
|
+
return True
|
|
266
|
+
return any(
|
|
267
|
+
_BOT_SEGMENT_PATTERN.match(segment) for segment in re.split(r"[-_.\s]+", lowered) if segment
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse_label(name: str, actor_login: str, occurred_at: datetime) -> ParsedEvent | None:
|
|
272
|
+
"""把单个标签解析为评审事件。无法识别时返回 None。"""
|
|
273
|
+
for pattern, kind in LABEL_RULES:
|
|
274
|
+
match = pattern.match(name)
|
|
275
|
+
if not match:
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
# lgtm-<user> 的 actor 是标签里携带的用户名;
|
|
279
|
+
# 整体 lgtm 标签没有具体人(actor 记为空)
|
|
280
|
+
actor = match.groupdict().get("user") or actor_login
|
|
281
|
+
|
|
282
|
+
return ParsedEvent(
|
|
283
|
+
event_type=kind,
|
|
284
|
+
actor_login=actor or "",
|
|
285
|
+
occurred_at=occurred_at,
|
|
286
|
+
source="label",
|
|
287
|
+
raw=name,
|
|
288
|
+
)
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def parse_comment_command(body: str, actor_login: str, occurred_at: datetime) -> ParsedEvent | None:
|
|
293
|
+
"""解析评论中的机器人指令。
|
|
294
|
+
|
|
295
|
+
openEuler 的 CI 机器人接受 ``/lgtm`` ``/approve`` ``/close`` 等指令,
|
|
296
|
+
这些是评审动作的直接记录,比标签更精确地反映"谁在何时做了什么"。
|
|
297
|
+
"""
|
|
298
|
+
for line in body.splitlines():
|
|
299
|
+
match = COMMAND_PATTERN.match(line)
|
|
300
|
+
if not match:
|
|
301
|
+
continue
|
|
302
|
+
cmd = match.group("cmd").lower()
|
|
303
|
+
mapping = {
|
|
304
|
+
"lgtm": EventType.LGTM,
|
|
305
|
+
"approve": EventType.APPROVE,
|
|
306
|
+
"close": EventType.CLOSE,
|
|
307
|
+
"reopen": EventType.REOPEN,
|
|
308
|
+
"retest": EventType.RETEST,
|
|
309
|
+
}
|
|
310
|
+
return ParsedEvent(
|
|
311
|
+
event_type=mapping[cmd],
|
|
312
|
+
actor_login=actor_login,
|
|
313
|
+
occurred_at=occurred_at,
|
|
314
|
+
source="comment",
|
|
315
|
+
raw=f"/{cmd}",
|
|
316
|
+
)
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def parse_events(
|
|
321
|
+
labels: Iterable[tuple[str, datetime]],
|
|
322
|
+
comments: Iterable[tuple[str, str, datetime]],
|
|
323
|
+
) -> list[ParsedEvent]:
|
|
324
|
+
"""把标签与评论解析为按时间排序的事件列表。
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
labels: ``(标签名, 时间)`` 序列。标签本身不携带时间,
|
|
328
|
+
调用方应传入可获得的最近时间(如 PR 更新时间)。
|
|
329
|
+
comments: ``(正文, 作者, 时间)`` 序列。
|
|
330
|
+
"""
|
|
331
|
+
events: list[ParsedEvent] = []
|
|
332
|
+
|
|
333
|
+
for name, occurred_at in labels:
|
|
334
|
+
event = parse_label(name, actor_login="", occurred_at=occurred_at)
|
|
335
|
+
if event is not None:
|
|
336
|
+
events.append(event)
|
|
337
|
+
|
|
338
|
+
for body, author, occurred_at in comments:
|
|
339
|
+
if is_bot(author):
|
|
340
|
+
# 机器人会复述用户指令(如 "LGTM" 回执),不能当作人类评审动作,
|
|
341
|
+
# 否则评审工作量会被系统性高估
|
|
342
|
+
continue
|
|
343
|
+
event = parse_comment_command(body, actor_login=author, occurred_at=occurred_at)
|
|
344
|
+
if event is not None:
|
|
345
|
+
events.append(event)
|
|
346
|
+
|
|
347
|
+
events.sort(key=lambda e: e.occurred_at)
|
|
348
|
+
return events
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def derive_gate(
|
|
352
|
+
label_names: Sequence[str],
|
|
353
|
+
*,
|
|
354
|
+
is_draft: bool = False,
|
|
355
|
+
state: str = "open",
|
|
356
|
+
) -> MergeGate:
|
|
357
|
+
"""从标签列表推导当前门禁状态。
|
|
358
|
+
|
|
359
|
+
这是"当前状态"的唯一判定入口;历史事件由 :func:`parse_events` 提供。
|
|
360
|
+
"""
|
|
361
|
+
gate = MergeGate(
|
|
362
|
+
is_draft=is_draft,
|
|
363
|
+
is_merged=state == "merged",
|
|
364
|
+
is_closed=state == "closed",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
for name in label_names:
|
|
368
|
+
if name == NEEDS_ISSUE_LABEL:
|
|
369
|
+
gate.needs_issue = True
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
for pattern, kind in LABEL_RULES:
|
|
373
|
+
match = pattern.match(name)
|
|
374
|
+
if not match:
|
|
375
|
+
continue
|
|
376
|
+
if kind is EventType.LGTM:
|
|
377
|
+
actor = match.groupdict().get("user")
|
|
378
|
+
if actor:
|
|
379
|
+
gate.lgtm_actors.add(actor)
|
|
380
|
+
else:
|
|
381
|
+
gate.has_plain_lgtm = True
|
|
382
|
+
elif kind is EventType.APPROVE:
|
|
383
|
+
gate.approved = True
|
|
384
|
+
elif kind is EventType.REJECT:
|
|
385
|
+
gate.rejected = True
|
|
386
|
+
elif kind is EventType.CLA_SIGNED:
|
|
387
|
+
gate.cla_signed = True
|
|
388
|
+
elif kind is EventType.CLA_DENIED:
|
|
389
|
+
gate.cla_denied = True
|
|
390
|
+
elif kind is EventType.CI_PASS:
|
|
391
|
+
gate.ci_passed = True
|
|
392
|
+
elif kind is EventType.CI_FAIL:
|
|
393
|
+
gate.ci_failed = True
|
|
394
|
+
elif kind is EventType.CI_RUNNING:
|
|
395
|
+
gate.ci_running = True
|
|
396
|
+
break
|
|
397
|
+
|
|
398
|
+
return gate
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def unrecognized_labels(label_names: Iterable[str]) -> list[str]:
|
|
402
|
+
"""返回既未匹配评审规则、也不属于已知无关类别的标签。
|
|
403
|
+
|
|
404
|
+
用于诊断:当社区新增标签时,能第一时间发现解析规则未覆盖,
|
|
405
|
+
而不是让评审状态静默出错。
|
|
406
|
+
"""
|
|
407
|
+
result: list[str] = []
|
|
408
|
+
for name in label_names:
|
|
409
|
+
if name == NEEDS_ISSUE_LABEL:
|
|
410
|
+
continue
|
|
411
|
+
if any(pattern.match(name) for pattern, _ in LABEL_RULES):
|
|
412
|
+
continue
|
|
413
|
+
if name.startswith(_IGNORED_LABEL_PREFIXES):
|
|
414
|
+
continue
|
|
415
|
+
result.append(name)
|
|
416
|
+
return result
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""SIG 名单与模块归属的解析。
|
|
2
|
+
|
|
3
|
+
来源是 ``openeuler/community`` 仓库的两份 Markdown:
|
|
4
|
+
|
|
5
|
+
- ``sig/Kernel/README.md`` —— Maintainer 列表
|
|
6
|
+
- ``sig/Kernel/committers.md`` —— 按模块划分的 Committer 表
|
|
7
|
+
|
|
8
|
+
两份都是人维护的文档,格式会变。这里同样按"尽力而为"处理:解析不出来的行
|
|
9
|
+
跳过,不让一处格式变化导致整份名单同步失败。
|
|
10
|
+
|
|
11
|
+
一个容易被忽略的细节:表格里 ``[@handle](url)`` 的 handle 与 URL 末段的 id
|
|
12
|
+
**经常不同**(形如 ``[@display-name](https://atomgit.com/account-id)``)。
|
|
13
|
+
handle 是人写的展示名,URL 里才是平台账号。两者都保留 —— 用 handle 匹配
|
|
14
|
+
PR 作者会漏,用 URL id 展示给人看又会认不出来是谁。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
# | kernel | kernel/\* | [@display-name](https://atomgit.com/account-id) |
|
|
23
|
+
_TABLE_ROW = re.compile(r"^\|(.+)\|\s*$")
|
|
24
|
+
_SEPARATOR_ROW = re.compile(r"^\|[\s:|-]+\|\s*$")
|
|
25
|
+
_SECTION = re.compile(r"^##\s+(.+?)\s*$")
|
|
26
|
+
|
|
27
|
+
# [@handle](https://atomgit.com/real_id) / [@handle](https://gitee.com/real_id)
|
|
28
|
+
_ACCOUNT = re.compile(r"\[@?([^\]\s]+)\]\(\s*(https?://[^)\s]+)\s*\)")
|
|
29
|
+
|
|
30
|
+
# Markdown 里混进来的排版残留:<img width=40/>、<br/>、转义的 \*
|
|
31
|
+
_HTML_TAG = re.compile(r"<[^>]+>")
|
|
32
|
+
|
|
33
|
+
# README 的 Maintainer 行:- 显示名 [@handle](https://gitee.com/account-id)
|
|
34
|
+
_MAINTAINER = re.compile(r"^[-*]\s*([^\[]+?)\s*\[@?([^\]]+)\]\(\s*(https?://[^)\s]+)\s*\)")
|
|
35
|
+
|
|
36
|
+
_PLATFORM_HOSTS = {
|
|
37
|
+
"atomgit.com": "atomgit",
|
|
38
|
+
"gitee.com": "gitee",
|
|
39
|
+
"github.com": "github",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class AccountRef:
|
|
45
|
+
"""一个平台账号。"""
|
|
46
|
+
|
|
47
|
+
handle: str
|
|
48
|
+
"""文档里显示的 handle。用于展示。"""
|
|
49
|
+
|
|
50
|
+
platform: str
|
|
51
|
+
"""atomgit / gitee / github / unknown。"""
|
|
52
|
+
|
|
53
|
+
account_id: str
|
|
54
|
+
"""URL 末段的账号 id。用于匹配数据。"""
|
|
55
|
+
|
|
56
|
+
url: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class SubsystemRow:
|
|
61
|
+
module: str
|
|
62
|
+
section: str
|
|
63
|
+
paths: list[str] = field(default_factory=list)
|
|
64
|
+
committers: list[AccountRef] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class MemberEntry:
|
|
69
|
+
name: str
|
|
70
|
+
role: str
|
|
71
|
+
accounts: list[AccountRef] = field(default_factory=list)
|
|
72
|
+
email: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_accounts(cell: str) -> list[AccountRef]:
|
|
76
|
+
"""从表格单元格里抽出全部账号引用。"""
|
|
77
|
+
accounts: list[AccountRef] = []
|
|
78
|
+
for handle, url in _ACCOUNT.findall(cell):
|
|
79
|
+
host = re.sub(r"^https?://(?:www\.)?", "", url).split("/")[0].lower()
|
|
80
|
+
platform = _PLATFORM_HOSTS.get(host, "unknown")
|
|
81
|
+
tail = [part for part in url.rstrip("/").split("/") if part]
|
|
82
|
+
accounts.append(
|
|
83
|
+
AccountRef(
|
|
84
|
+
handle=handle.strip(),
|
|
85
|
+
platform=platform,
|
|
86
|
+
account_id=tail[-1] if tail else handle,
|
|
87
|
+
url=url,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return accounts
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cell_to_paths(cell: str) -> list[str]:
|
|
94
|
+
"""把「文件」列拆成路径前缀列表。
|
|
95
|
+
|
|
96
|
+
上游用 ``<br/>`` 分隔多个路径,用 ``\\*`` 表示前缀通配,还会混进
|
|
97
|
+
``<img>`` 这类排版残留。这些都是展示层的写法,落到数据上只需要
|
|
98
|
+
干净的路径前缀。
|
|
99
|
+
"""
|
|
100
|
+
# <br/> 与 <img .../> 一并换成换行/空白,剩下的才是路径
|
|
101
|
+
text = _HTML_TAG.sub("\n", cell)
|
|
102
|
+
paths: list[str] = []
|
|
103
|
+
for chunk in re.split(r"[\n,]+", text):
|
|
104
|
+
# 去掉通配符后可能出现 `arch//include/...` 这样的空目录段 —— 通配符
|
|
105
|
+
# 本身代表一层目录,删掉它就留下了一个空段
|
|
106
|
+
path = chunk.strip().replace("\\*", "").replace("*", "").strip().strip("/")
|
|
107
|
+
path = re.sub(r"/{2,}", "/", path)
|
|
108
|
+
if not path or path.startswith("文件"):
|
|
109
|
+
continue
|
|
110
|
+
paths.append(f"{path}/")
|
|
111
|
+
return paths
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_committers(text: str) -> list[SubsystemRow]:
|
|
115
|
+
"""解析 committers.md 的模块表。"""
|
|
116
|
+
rows: list[SubsystemRow] = []
|
|
117
|
+
section = ""
|
|
118
|
+
|
|
119
|
+
for line in text.splitlines():
|
|
120
|
+
if match := _SECTION.match(line):
|
|
121
|
+
section = match.group(1).strip()
|
|
122
|
+
continue
|
|
123
|
+
if not _TABLE_ROW.match(line) or _SEPARATOR_ROW.match(line):
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
127
|
+
if len(cells) < 3:
|
|
128
|
+
continue
|
|
129
|
+
module, files, committers = cells[0], cells[1], "|".join(cells[2:])
|
|
130
|
+
|
|
131
|
+
# 表头行。列名里带 <img> 排版标签,所以用包含匹配而不是相等。
|
|
132
|
+
if "模块" in module or module.lower() == "module":
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
accounts = parse_accounts(committers)
|
|
136
|
+
if not module or not accounts:
|
|
137
|
+
# 没有 Committer 的行是说明行,不是归属关系
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
rows.append(
|
|
141
|
+
SubsystemRow(
|
|
142
|
+
module=module,
|
|
143
|
+
section=section,
|
|
144
|
+
paths=_cell_to_paths(files),
|
|
145
|
+
committers=accounts,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return rows
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_maintainers(text: str) -> list[MemberEntry]:
|
|
152
|
+
"""解析 README 的 Maintainer 列表。
|
|
153
|
+
|
|
154
|
+
只认 ``### Maintainer列表`` 之后的那一段,避免把正文里出现的
|
|
155
|
+
其他 ``[@who](url)`` 当成名单。
|
|
156
|
+
"""
|
|
157
|
+
entries: list[MemberEntry] = []
|
|
158
|
+
in_section = False
|
|
159
|
+
|
|
160
|
+
for line in text.splitlines():
|
|
161
|
+
if line.startswith("#"):
|
|
162
|
+
heading = line.lstrip("#").strip()
|
|
163
|
+
in_section = "maintainer" in heading.lower() or "maintainer" in heading
|
|
164
|
+
continue
|
|
165
|
+
if not in_section:
|
|
166
|
+
continue
|
|
167
|
+
match = _MAINTAINER.match(line.strip())
|
|
168
|
+
if match is None:
|
|
169
|
+
continue
|
|
170
|
+
name, handle, url = match.groups()
|
|
171
|
+
entries.append(
|
|
172
|
+
MemberEntry(
|
|
173
|
+
name=name.strip(),
|
|
174
|
+
role="maintainer",
|
|
175
|
+
accounts=parse_accounts(f"[@{handle}]({url})"),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
return entries
|