@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.
Files changed (198) hide show
  1. package/.env.example +50 -0
  2. package/LICENSE +127 -0
  3. package/Makefile +66 -0
  4. package/README.md +279 -0
  5. package/backend/.dockerignore +12 -0
  6. package/backend/Dockerfile +43 -0
  7. package/backend/alembic/env.py +75 -0
  8. package/backend/alembic/script.py.mako +25 -0
  9. package/backend/alembic/versions/0001_initial.py +88 -0
  10. package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
  11. package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
  12. package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
  13. package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
  14. package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
  15. package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
  16. package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
  17. package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
  18. package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
  19. package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
  20. package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
  21. package/backend/alembic.ini +40 -0
  22. package/backend/app/__init__.py +0 -0
  23. package/backend/app/api/__init__.py +0 -0
  24. package/backend/app/api/deps.py +74 -0
  25. package/backend/app/api/v1/__init__.py +0 -0
  26. package/backend/app/api/v1/ai.py +386 -0
  27. package/backend/app/api/v1/analytics.py +48 -0
  28. package/backend/app/api/v1/attention.py +142 -0
  29. package/backend/app/api/v1/auth.py +106 -0
  30. package/backend/app/api/v1/classification.py +192 -0
  31. package/backend/app/api/v1/health.py +29 -0
  32. package/backend/app/api/v1/issues.py +143 -0
  33. package/backend/app/api/v1/pulls.py +388 -0
  34. package/backend/app/api/v1/repositories.py +273 -0
  35. package/backend/app/api/v1/router.py +32 -0
  36. package/backend/app/api/v1/sig.py +565 -0
  37. package/backend/app/api/v1/users.py +87 -0
  38. package/backend/app/api/v1/webhooks.py +135 -0
  39. package/backend/app/core/__init__.py +0 -0
  40. package/backend/app/core/config.py +72 -0
  41. package/backend/app/core/crypto.py +74 -0
  42. package/backend/app/core/db.py +79 -0
  43. package/backend/app/core/exceptions.py +60 -0
  44. package/backend/app/core/logging.py +69 -0
  45. package/backend/app/core/permissions.py +87 -0
  46. package/backend/app/core/queue.py +57 -0
  47. package/backend/app/core/security.py +69 -0
  48. package/backend/app/domain/__init__.py +0 -0
  49. package/backend/app/domain/attention.py +618 -0
  50. package/backend/app/domain/classification.py +689 -0
  51. package/backend/app/domain/meeting.py +408 -0
  52. package/backend/app/domain/release.py +163 -0
  53. package/backend/app/domain/review.py +416 -0
  54. package/backend/app/domain/sig.py +178 -0
  55. package/backend/app/domain/sig_info.py +201 -0
  56. package/backend/app/integrations/__init__.py +0 -0
  57. package/backend/app/integrations/atomgit/__init__.py +0 -0
  58. package/backend/app/integrations/atomgit/client.py +505 -0
  59. package/backend/app/integrations/atomgit/models.py +311 -0
  60. package/backend/app/integrations/llm/__init__.py +0 -0
  61. package/backend/app/integrations/llm/prompts.py +222 -0
  62. package/backend/app/integrations/llm/provider.py +326 -0
  63. package/backend/app/main.py +242 -0
  64. package/backend/app/middleware/__init__.py +0 -0
  65. package/backend/app/middleware/audit.py +129 -0
  66. package/backend/app/middleware/request_context.py +42 -0
  67. package/backend/app/models/__init__.py +112 -0
  68. package/backend/app/models/ai.py +214 -0
  69. package/backend/app/models/attention.py +131 -0
  70. package/backend/app/models/attention_settings.py +61 -0
  71. package/backend/app/models/audit.py +53 -0
  72. package/backend/app/models/base.py +35 -0
  73. package/backend/app/models/classification.py +144 -0
  74. package/backend/app/models/credential.py +56 -0
  75. package/backend/app/models/issue.py +132 -0
  76. package/backend/app/models/meeting.py +189 -0
  77. package/backend/app/models/pull_request.py +288 -0
  78. package/backend/app/models/repository.py +196 -0
  79. package/backend/app/models/sig.py +194 -0
  80. package/backend/app/models/user.py +44 -0
  81. package/backend/app/schemas/__init__.py +0 -0
  82. package/backend/app/schemas/ai.py +120 -0
  83. package/backend/app/schemas/attention.py +43 -0
  84. package/backend/app/schemas/auth.py +26 -0
  85. package/backend/app/schemas/classification.py +58 -0
  86. package/backend/app/schemas/common.py +43 -0
  87. package/backend/app/schemas/pull_request.py +214 -0
  88. package/backend/app/schemas/repository.py +94 -0
  89. package/backend/app/schemas/sig.py +246 -0
  90. package/backend/app/schemas/user.py +79 -0
  91. package/backend/app/services/__init__.py +0 -0
  92. package/backend/app/services/ai_service.py +569 -0
  93. package/backend/app/services/analytics_service.py +390 -0
  94. package/backend/app/services/attention_queue.py +451 -0
  95. package/backend/app/services/attention_service.py +432 -0
  96. package/backend/app/services/auth_service.py +74 -0
  97. package/backend/app/services/classification_service.py +658 -0
  98. package/backend/app/services/credential_service.py +105 -0
  99. package/backend/app/services/pull_query.py +273 -0
  100. package/backend/app/services/release_service.py +446 -0
  101. package/backend/app/services/repository_service.py +132 -0
  102. package/backend/app/services/sig_service.py +385 -0
  103. package/backend/app/services/sync_service.py +752 -0
  104. package/backend/app/services/user_service.py +68 -0
  105. package/backend/app/worker.py +389 -0
  106. package/backend/entrypoint.sh +10 -0
  107. package/backend/pyproject.toml +68 -0
  108. package/backend/tests/test_analysis_api.py +154 -0
  109. package/backend/tests/test_atomgit_client.py +360 -0
  110. package/backend/tests/test_atomgit_models.py +257 -0
  111. package/backend/tests/test_attention.py +291 -0
  112. package/backend/tests/test_audit_middleware.py +135 -0
  113. package/backend/tests/test_classification.py +498 -0
  114. package/backend/tests/test_config.py +41 -0
  115. package/backend/tests/test_crypto.py +68 -0
  116. package/backend/tests/test_exceptions.py +62 -0
  117. package/backend/tests/test_health.py +63 -0
  118. package/backend/tests/test_llm_provider.py +320 -0
  119. package/backend/tests/test_meeting_domain.py +169 -0
  120. package/backend/tests/test_permissions.py +69 -0
  121. package/backend/tests/test_pull_query_wiring.py +66 -0
  122. package/backend/tests/test_pull_schemas.py +82 -0
  123. package/backend/tests/test_release_domain.py +82 -0
  124. package/backend/tests/test_review_parser.py +301 -0
  125. package/backend/tests/test_schemas_user.py +97 -0
  126. package/backend/tests/test_security.py +92 -0
  127. package/cli/index.js +338 -0
  128. package/compose.yaml +105 -0
  129. package/frontend/.dockerignore +4 -0
  130. package/frontend/Dockerfile +27 -0
  131. package/frontend/index.html +14 -0
  132. package/frontend/nginx.conf +47 -0
  133. package/frontend/package-lock.json +5020 -0
  134. package/frontend/package.json +35 -0
  135. package/frontend/src/api/ai.ts +124 -0
  136. package/frontend/src/api/analytics.ts +66 -0
  137. package/frontend/src/api/attention.ts +181 -0
  138. package/frontend/src/api/auth.ts +74 -0
  139. package/frontend/src/api/classification.ts +170 -0
  140. package/frontend/src/api/issues.ts +90 -0
  141. package/frontend/src/api/pulls.ts +233 -0
  142. package/frontend/src/api/repositories.ts +95 -0
  143. package/frontend/src/api/sig.ts +232 -0
  144. package/frontend/src/app/antd-theme.ts +94 -0
  145. package/frontend/src/app/providers.tsx +59 -0
  146. package/frontend/src/app/router.tsx +411 -0
  147. package/frontend/src/app/search.ts +30 -0
  148. package/frontend/src/components/ClassificationBadge.tsx +58 -0
  149. package/frontend/src/components/GateBadge.tsx +13 -0
  150. package/frontend/src/components/SeverityBadge.tsx +20 -0
  151. package/frontend/src/components/layout/AppShell.tsx +16 -0
  152. package/frontend/src/components/layout/AuthLayout.tsx +32 -0
  153. package/frontend/src/components/layout/Sidebar.tsx +223 -0
  154. package/frontend/src/components/layout/TopBar.tsx +47 -0
  155. package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
  156. package/frontend/src/components/pulls/FacetRail.tsx +199 -0
  157. package/frontend/src/components/pulls/LabelChips.tsx +87 -0
  158. package/frontend/src/components/ui/alert.tsx +30 -0
  159. package/frontend/src/components/ui/badge.tsx +53 -0
  160. package/frontend/src/components/ui/button.tsx +51 -0
  161. package/frontend/src/components/ui/card.tsx +64 -0
  162. package/frontend/src/components/ui/chart-theme.ts +65 -0
  163. package/frontend/src/components/ui/data-table.tsx +39 -0
  164. package/frontend/src/components/ui/echart.tsx +70 -0
  165. package/frontend/src/components/ui/empty-state.tsx +22 -0
  166. package/frontend/src/components/ui/input.tsx +39 -0
  167. package/frontend/src/components/ui/lazy-chart.tsx +21 -0
  168. package/frontend/src/components/ui/skeleton.tsx +19 -0
  169. package/frontend/src/hooks/use-current-repository.ts +44 -0
  170. package/frontend/src/hooks/use-current-user.ts +38 -0
  171. package/frontend/src/lib/api-client.ts +93 -0
  172. package/frontend/src/lib/css-color.ts +60 -0
  173. package/frontend/src/lib/utils.ts +45 -0
  174. package/frontend/src/main.tsx +22 -0
  175. package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
  176. package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
  177. package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
  178. package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
  179. package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
  180. package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
  181. package/frontend/src/pages/login/LoginPage.tsx +95 -0
  182. package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
  183. package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
  184. package/frontend/src/pages/members/MembersPage.tsx +534 -0
  185. package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
  186. package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
  187. package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
  188. package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
  189. package/frontend/src/pages/setup/SetupPage.tsx +144 -0
  190. package/frontend/src/styles/globals.css +147 -0
  191. package/frontend/tsconfig.json +22 -0
  192. package/frontend/vite.config.ts +57 -0
  193. package/package.json +48 -0
  194. package/scripts/e2e-auth-flow.py +131 -0
  195. package/scripts/e2e-pr-detail.py +128 -0
  196. package/scripts/e2e-verify.py +773 -0
  197. package/scripts/verify-ai-pipeline.py +616 -0
  198. package/scripts/verify-analysis-pipeline.py +303 -0
@@ -0,0 +1,288 @@
1
+ """Pull Request 及其关联数据。"""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+
7
+ from sqlalchemy import (
8
+ BigInteger,
9
+ Boolean,
10
+ DateTime,
11
+ Enum,
12
+ ForeignKey,
13
+ Index,
14
+ Integer,
15
+ String,
16
+ Text,
17
+ UniqueConstraint,
18
+ func,
19
+ true,
20
+ )
21
+ from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
22
+ from sqlalchemy.orm import Mapped, mapped_column
23
+
24
+ from app.domain.review import EventType as ReviewEventType
25
+ from app.models.base import Base, TimestampMixin
26
+
27
+
28
+ class PRState(StrEnum):
29
+ OPEN = "open"
30
+ CLOSED = "closed"
31
+ MERGED = "merged"
32
+
33
+
34
+ class PullRequest(Base, TimestampMixin):
35
+ __tablename__ = "ksp_pull_request"
36
+ __table_args__ = (
37
+ UniqueConstraint("repository_id", "number", name="uq_ksp_pull_request_repo_number"),
38
+ Index("ix_ksp_pull_request_repo_state_updated", "repository_id", "state", "updated_at"),
39
+ Index("ix_ksp_pull_request_label_names", "label_names", postgresql_using="gin"),
40
+ Index("ix_ksp_pull_request_repo_synced", "repository_id", "detail_synced_at"),
41
+ )
42
+
43
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
44
+ repository_id: Mapped[uuid.UUID] = mapped_column(
45
+ UUID(as_uuid=True),
46
+ ForeignKey("ksp_repository.id", ondelete="CASCADE"),
47
+ nullable=False,
48
+ index=True,
49
+ )
50
+ number: Mapped[int] = mapped_column(Integer, nullable=False)
51
+
52
+ title: Mapped[str] = mapped_column(Text, nullable=False)
53
+ body: Mapped[str | None] = mapped_column(Text, nullable=True)
54
+ state: Mapped[PRState] = mapped_column(
55
+ Enum(PRState, name="pull_state", values_callable=lambda e: [m.value for m in e]),
56
+ nullable=False,
57
+ index=True,
58
+ )
59
+ draft: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
60
+
61
+ author_login: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
62
+ author_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
63
+ author_avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
64
+
65
+ source_branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
66
+ target_branch: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
67
+
68
+ # 原始标签快照 + 便于 GIN 索引查询的扁平数组
69
+ labels: Mapped[list | None] = mapped_column(JSONB, nullable=True)
70
+ label_names: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
71
+
72
+ added_lines: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
73
+ removed_lines: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
74
+ changed_files: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
75
+ # AtomGit 的 notes 字段直接给出评论数,是变更检测的信号之一
76
+ comments_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
77
+
78
+ mergeable: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
79
+ merge_conflict: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
80
+ merge_check_detail: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
81
+
82
+ # 门禁阶段。由 label_names 推导后落库,属于刻意的反规范化:
83
+ # 列表页需要按"待评审/可合入"筛选并排序,1000+ 条记录不可能在应用层
84
+ # 逐条推导再分页。每次列表同步时重算,来源与推导逻辑唯一(domain.review)。
85
+ gate_stage: Mapped[str] = mapped_column(
86
+ String(32), nullable=False, default="cla_pending", server_default="cla_pending", index=True
87
+ )
88
+
89
+ atomgit_created_at: Mapped[datetime | None] = mapped_column(
90
+ DateTime(timezone=True), nullable=True
91
+ )
92
+ atomgit_updated_at: Mapped[datetime | None] = mapped_column(
93
+ DateTime(timezone=True), nullable=True
94
+ )
95
+ merged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
96
+ closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
97
+
98
+ html_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
99
+
100
+ first_seen_at: Mapped[datetime] = mapped_column(
101
+ DateTime(timezone=True), server_default=func.now(), nullable=False
102
+ )
103
+ last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
104
+ detail_synced_at: Mapped[datetime | None] = mapped_column(
105
+ DateTime(timezone=True), nullable=True
106
+ )
107
+ # 内容变化后置位,详情同步处理后清除。
108
+ # 不能用「last_synced_at > detail_synced_at」代替 —— 列表同步每次都更新
109
+ # last_synced_at,会让全部 PR 恒久处于"待拉详情"状态,每轮产生数千次请求。
110
+ needs_detail: Mapped[bool] = mapped_column(
111
+ Boolean, nullable=False, default=True, server_default=true()
112
+ )
113
+ # 内容指纹:只有变化才需要重新触发 AI 分析
114
+ content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
115
+
116
+ def __repr__(self) -> str:
117
+ return f"<PullRequest #{self.number} {self.state.value}>"
118
+
119
+
120
+ class PRCommit(Base):
121
+ """PR 包含的提交。序号对应补丁在 PR 中的顺序(0001、0002…)。"""
122
+
123
+ __tablename__ = "ksp_pr_commit"
124
+ __table_args__ = (UniqueConstraint("pull_request_id", "sha", name="uq_ksp_pr_commit_pr_sha"),)
125
+
126
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
127
+ pull_request_id: Mapped[uuid.UUID] = mapped_column(
128
+ UUID(as_uuid=True),
129
+ ForeignKey("ksp_pull_request.id", ondelete="CASCADE"),
130
+ nullable=False,
131
+ index=True,
132
+ )
133
+ sha: Mapped[str] = mapped_column(String(64), nullable=False)
134
+ sequence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
135
+
136
+ subject: Mapped[str] = mapped_column(Text, nullable=False, default="")
137
+ message: Mapped[str] = mapped_column(Text, nullable=False, default="")
138
+ author_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
139
+ author_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
140
+ committed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
141
+
142
+ # 补丁格式审查的落点:openEuler 要求 Signed-off-by 链完整
143
+ signed_off_bys: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
144
+
145
+
146
+ class PRFile(Base):
147
+ """PR 变更文件。patch 原文保留,供后续逐补丁审查与 backport 对比。"""
148
+
149
+ __tablename__ = "ksp_pr_file"
150
+ __table_args__ = (
151
+ UniqueConstraint("pull_request_id", "filename", name="uq_ksp_pr_file_pr_filename"),
152
+ )
153
+
154
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
155
+ pull_request_id: Mapped[uuid.UUID] = mapped_column(
156
+ UUID(as_uuid=True),
157
+ ForeignKey("ksp_pull_request.id", ondelete="CASCADE"),
158
+ nullable=False,
159
+ index=True,
160
+ )
161
+ filename: Mapped[str] = mapped_column(Text, nullable=False)
162
+ status: Mapped[str | None] = mapped_column(String(32), nullable=True)
163
+ additions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
164
+ deletions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
165
+
166
+ # 内核仓库禁止提交二进制文件,此标记直接支撑该规则的自动判定
167
+ is_binary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
168
+ too_large: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
169
+ diff: Mapped[str | None] = mapped_column(Text, nullable=True)
170
+
171
+
172
+ class PRComment(Base):
173
+ """PR 评论原文。
174
+
175
+ 评论此前只被解析成评审事件就丢弃,界面上无从看到讨论内容。
176
+ 但评审意见、维护者的提问、以及 openEuler 机器人自动产生的变更摘要
177
+ 与代码审查结论都只存在于评论里,因此原文必须留存并展示。
178
+
179
+ 以 AtomGit 的评论 id 为唯一键,重复同步不会产生重复行。
180
+ """
181
+
182
+ __tablename__ = "ksp_pr_comment"
183
+ __table_args__ = (
184
+ UniqueConstraint("pull_request_id", "comment_id", name="uq_ksp_pr_comment_identity"),
185
+ # 评论按时间正序展示,因此按 (PR, 上游创建时间) 建索引
186
+ Index("ix_ksp_pr_comment_pr_created", "pull_request_id", "atomgit_created_at"),
187
+ )
188
+
189
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
190
+ pull_request_id: Mapped[uuid.UUID] = mapped_column(
191
+ UUID(as_uuid=True),
192
+ ForeignKey("ksp_pull_request.id", ondelete="CASCADE"),
193
+ nullable=False,
194
+ index=True,
195
+ )
196
+ # AtomGit 侧的评论 id。以字符串存储:上游 id 类型不稳定(实测有整数,
197
+ # 也有形如 "note_123" 的字符串),转成整数会在遇到后者时直接崩
198
+ comment_id: Mapped[str] = mapped_column(String(64), nullable=False)
199
+ # 行内评论会带上被评论的文件;整体评论为空
200
+ comment_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
201
+ discussion_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
202
+
203
+ author_login: Mapped[str] = mapped_column(String(128), nullable=False, default="")
204
+ author_avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
205
+ body: Mapped[str] = mapped_column(Text, nullable=False, default="")
206
+
207
+ atomgit_created_at: Mapped[datetime | None] = mapped_column(
208
+ DateTime(timezone=True), nullable=True
209
+ )
210
+ atomgit_updated_at: Mapped[datetime | None] = mapped_column(
211
+ DateTime(timezone=True), nullable=True
212
+ )
213
+ last_synced_at: Mapped[datetime | None] = mapped_column(
214
+ DateTime(timezone=True), nullable=True
215
+ )
216
+
217
+ def __repr__(self) -> str:
218
+ return f"<PRComment {self.comment_id} by={self.author_login}>"
219
+
220
+
221
+ class ReviewEventSource(StrEnum):
222
+ LABEL = "label"
223
+ COMMENT = "comment"
224
+ WEBHOOK = "webhook"
225
+
226
+
227
+ class ReviewEvent(Base):
228
+ """结构化的评审事件。
229
+
230
+ 这是评审子系统的基石 —— AtomGit 把评审状态承载在标签与评论里,
231
+ 必须解析成结构化事件,才能计算评审 SLA、评审人工作量与等待时长。
232
+
233
+ 唯一约束保证幂等:重复同步不会产生重复事件。
234
+ """
235
+
236
+ __tablename__ = "ksp_review_event"
237
+ __table_args__ = (
238
+ UniqueConstraint(
239
+ "pull_request_id",
240
+ "event_type",
241
+ "actor_login",
242
+ "occurred_at",
243
+ name="uq_ksp_review_event_identity",
244
+ ),
245
+ Index("ix_ksp_review_event_pr_occurred", "pull_request_id", "occurred_at"),
246
+ Index("ix_ksp_review_event_actor_occurred", "actor_login", "occurred_at"),
247
+ )
248
+
249
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
250
+ pull_request_id: Mapped[uuid.UUID] = mapped_column(
251
+ UUID(as_uuid=True),
252
+ ForeignKey("ksp_pull_request.id", ondelete="CASCADE"),
253
+ nullable=False,
254
+ index=True,
255
+ )
256
+
257
+ event_type: Mapped[ReviewEventType] = mapped_column(
258
+ Enum(
259
+ ReviewEventType,
260
+ name="review_event_type",
261
+ values_callable=lambda e: [m.value for m in e],
262
+ ),
263
+ nullable=False,
264
+ index=True,
265
+ )
266
+ actor_login: Mapped[str] = mapped_column(String(128), nullable=False, default="", index=True)
267
+ # 若能匹配到平台用户则关联,用于归属评审工作量
268
+ actor_user_id: Mapped[uuid.UUID | None] = mapped_column(
269
+ UUID(as_uuid=True), ForeignKey("ksp_user.id", ondelete="SET NULL"), nullable=True
270
+ )
271
+
272
+ source: Mapped[ReviewEventSource] = mapped_column(
273
+ Enum(
274
+ ReviewEventSource,
275
+ name="review_event_source",
276
+ values_callable=lambda e: [m.value for m in e],
277
+ ),
278
+ nullable=False,
279
+ )
280
+ # 原始证据(标签名或评论片段),解析规则演进时可回溯
281
+ raw_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
282
+ occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
283
+ created_at: Mapped[datetime] = mapped_column(
284
+ DateTime(timezone=True), server_default=func.now(), nullable=False
285
+ )
286
+
287
+ def __repr__(self) -> str:
288
+ return f"<ReviewEvent {self.event_type.value} by={self.actor_login}>"
@@ -0,0 +1,196 @@
1
+ """纳管仓库与同步游标。"""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+
7
+ from sqlalchemy import (
8
+ BigInteger,
9
+ Boolean,
10
+ DateTime,
11
+ Enum,
12
+ ForeignKey,
13
+ Integer,
14
+ String,
15
+ Text,
16
+ UniqueConstraint,
17
+ func,
18
+ )
19
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
20
+ from sqlalchemy.orm import Mapped, mapped_column
21
+
22
+ from app.models.base import Base, TimestampMixin
23
+
24
+ DEFAULT_API_BASE_URL = "https://api.atomgit.com/api/v5"
25
+
26
+
27
+ class Repository(Base, TimestampMixin):
28
+ __tablename__ = "ksp_repository"
29
+ __table_args__ = (UniqueConstraint("owner", "name", name="uq_ksp_repository_owner_name"),)
30
+
31
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
32
+ owner: Mapped[str] = mapped_column(String(128), nullable=False)
33
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
34
+ display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
35
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
36
+
37
+ # 可配置:AtomGit 与 GitCode 共用后端,两者 token 通用
38
+ api_base_url: Mapped[str] = mapped_column(
39
+ String(255), nullable=False, default=DEFAULT_API_BASE_URL
40
+ )
41
+ credential_id: Mapped[uuid.UUID | None] = mapped_column(
42
+ UUID(as_uuid=True), ForeignKey("ksp_credential.id", ondelete="SET NULL"), nullable=True
43
+ )
44
+
45
+ default_branch: Mapped[str | None] = mapped_column(String(128), nullable=True)
46
+ # 关注的分支,如 ["OLK-6.6", "OLK-5.10", "master"]
47
+ tracked_branches: Mapped[list | None] = mapped_column(JSONB, nullable=True)
48
+
49
+ sync_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
50
+ sync_interval_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=300)
51
+ # 首次同步回填的历史窗口(天)。全量回填 1000+ PR 会耗时过长,
52
+ # 故默认只回填近半年,其余由对账任务渐进补齐。
53
+ backfill_days: Mapped[int] = mapped_column(Integer, nullable=False, default=180)
54
+
55
+ # webhook 无签名头,改用 URL 路径内嵌随机密钥 + IP 白名单
56
+ webhook_secret: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True)
57
+
58
+ last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
59
+ last_sync_error: Mapped[str | None] = mapped_column(Text, nullable=True)
60
+
61
+ @property
62
+ def full_name(self) -> str:
63
+ return f"{self.owner}/{self.name}"
64
+
65
+ def __repr__(self) -> str:
66
+ return f"<Repository {self.full_name}>"
67
+
68
+
69
+ class SyncResource(StrEnum):
70
+ PULLS = "pulls"
71
+ ISSUES = "issues"
72
+ LABELS = "labels"
73
+
74
+
75
+ class SyncCursor(Base):
76
+ """每仓库每资源的同步水位。
77
+
78
+ 用于增量同步:只处理 updated_at 晚于水位的对象。
79
+ """
80
+
81
+ __tablename__ = "ksp_sync_cursor"
82
+ __table_args__ = (
83
+ UniqueConstraint("repository_id", "resource", name="uq_ksp_sync_cursor_repo_resource"),
84
+ )
85
+
86
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
87
+ repository_id: Mapped[uuid.UUID] = mapped_column(
88
+ UUID(as_uuid=True), ForeignKey("ksp_repository.id", ondelete="CASCADE"), nullable=False
89
+ )
90
+ resource: Mapped[SyncResource] = mapped_column(
91
+ Enum(
92
+ SyncResource,
93
+ name="sync_resource",
94
+ values_callable=lambda e: [m.value for m in e],
95
+ ),
96
+ nullable=False,
97
+ )
98
+
99
+ last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
100
+ last_seen_updated_at: Mapped[datetime | None] = mapped_column(
101
+ DateTime(timezone=True), nullable=True
102
+ )
103
+ total_seen: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
104
+ # 触发限流后的退避截止时间
105
+ backoff_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
106
+
107
+
108
+ class SyncStatus(StrEnum):
109
+ RUNNING = "running"
110
+ SUCCEEDED = "succeeded"
111
+ FAILED = "failed"
112
+ PARTIAL = "partial"
113
+
114
+
115
+ class SyncTrigger(StrEnum):
116
+ SCHEDULED = "scheduled"
117
+ MANUAL = "manual"
118
+ WEBHOOK = "webhook"
119
+
120
+
121
+ class SyncRun(Base):
122
+ """单次同步执行记录,支撑"同步健康度"面板。"""
123
+
124
+ __tablename__ = "ksp_sync_run"
125
+
126
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
127
+ repository_id: Mapped[uuid.UUID] = mapped_column(
128
+ UUID(as_uuid=True),
129
+ ForeignKey("ksp_repository.id", ondelete="CASCADE"),
130
+ nullable=False,
131
+ index=True,
132
+ )
133
+ resource: Mapped[SyncResource] = mapped_column(
134
+ Enum(
135
+ SyncResource,
136
+ name="sync_resource",
137
+ values_callable=lambda e: [m.value for m in e],
138
+ ),
139
+ nullable=False,
140
+ )
141
+ trigger: Mapped[SyncTrigger] = mapped_column(
142
+ Enum(
143
+ SyncTrigger,
144
+ name="sync_trigger",
145
+ values_callable=lambda e: [m.value for m in e],
146
+ ),
147
+ nullable=False,
148
+ default=SyncTrigger.SCHEDULED,
149
+ )
150
+ status: Mapped[SyncStatus] = mapped_column(
151
+ Enum(
152
+ SyncStatus,
153
+ name="sync_status",
154
+ values_callable=lambda e: [m.value for m in e],
155
+ ),
156
+ nullable=False,
157
+ default=SyncStatus.RUNNING,
158
+ )
159
+
160
+ started_at: Mapped[datetime] = mapped_column(
161
+ DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
162
+ )
163
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
164
+
165
+ api_calls: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
166
+ items_fetched: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
167
+ items_created: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
168
+ items_updated: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
169
+ items_failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
170
+ error: Mapped[str | None] = mapped_column(Text, nullable=True)
171
+
172
+ @property
173
+ def duration_seconds(self) -> float | None:
174
+ if self.finished_at is None:
175
+ return None
176
+ return (self.finished_at - self.started_at).total_seconds()
177
+
178
+
179
+ class WebhookDelivery(Base):
180
+ """Webhook 投递记录,以 delivery_id 为主键天然实现幂等。
181
+
182
+ 重复投递在 INSERT 冲突时即被识别丢弃。
183
+ """
184
+
185
+ __tablename__ = "ksp_webhook_delivery"
186
+
187
+ delivery_id: Mapped[str] = mapped_column(String(64), primary_key=True)
188
+ repository_id: Mapped[uuid.UUID | None] = mapped_column(
189
+ UUID(as_uuid=True), ForeignKey("ksp_repository.id", ondelete="CASCADE"), nullable=True
190
+ )
191
+ event: Mapped[str] = mapped_column(String(64), nullable=False)
192
+ recognized: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
193
+ received_at: Mapped[datetime] = mapped_column(
194
+ DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
195
+ )
196
+ processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,194 @@
1
+ """SIG 成员与模块归属。
2
+
3
+ 三个来源,职责不同,都保留:
4
+
5
+ - **成员名单**来自 ``sig/Kernel/sig-info.yaml`` —— openEuler 的机器可读 SIG
6
+ 元数据文件,字段完整(姓名、邮箱、组织、两个平台账号),133 人。
7
+ 它才是权威名单:谁有权评审、谁能合入、谁负责哪个分支,以它为准。
8
+ - ``sig/Kernel/README.md`` 与 ``committers.md`` 是给人看的文档,
9
+ README 只列了 4 位维护者的展示名,committers.md 是模块分工表。
10
+ 两者只在 yaml 取不到时兜底,不作为主来源。
11
+ - **模块归属**仍来自 ``committers.md`` 的"模块 / 文件 / Committer"表 ——
12
+ yaml 里没有内核路径信息。它把路径映射到具体的 Committer,
13
+ 是"这个 PR 该找谁看"的依据。
14
+
15
+ 成员表刻意只存名单,不存贡献统计。贡献统计按需从 PR / 评审事件里现算 ——
16
+ 存下来就要考虑失效,而名单是低频变化的,两者寿命不同,混在一张表里
17
+ 会让一个每天变的数字和一份权威名单互相拖累。
18
+ """
19
+
20
+ import uuid
21
+ from datetime import datetime
22
+ from enum import StrEnum
23
+
24
+ from sqlalchemy import (
25
+ Boolean,
26
+ DateTime,
27
+ ForeignKey,
28
+ Index,
29
+ Integer,
30
+ String,
31
+ Text,
32
+ UniqueConstraint,
33
+ )
34
+ from sqlalchemy.dialects.postgresql import ARRAY, UUID
35
+ from sqlalchemy.orm import Mapped, mapped_column
36
+
37
+ from app.models.base import Base, TimestampMixin
38
+
39
+
40
+ class MemberRole(StrEnum):
41
+ MAINTAINER = "maintainer"
42
+ COMMITTER = "committer"
43
+ CONTRIBUTOR = "contributor"
44
+
45
+
46
+ class MemberSource(StrEnum):
47
+ """名单来源。
48
+
49
+ ``SIG_INFO`` 是权威来源(openEuler 的机器可读 SIG 元数据文件),
50
+ 其余两个是给人看的文档,只在 yaml 取不到时兜底。
51
+ """
52
+
53
+ SIG_INFO = "sig_info_yaml"
54
+ README = "sig_readme"
55
+ COMMITTERS = "committers_md"
56
+ MANUAL = "manual"
57
+
58
+
59
+ class SIGMember(Base, TimestampMixin):
60
+ __tablename__ = "ksp_sig_member"
61
+ __table_args__ = (
62
+ Index("ix_ksp_sig_member_role", "role"),
63
+ Index("ix_ksp_sig_member_atomgit", "atomgit_id"),
64
+ Index("ix_ksp_sig_member_email", "email"),
65
+ )
66
+
67
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
68
+
69
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
70
+
71
+ # 三个平台的登录名。同一人往往只在一部分平台上留名,实测 atomgit_id
72
+ # 覆盖率 86%、gitee_id 88%、github_id 0%,因此都不能设为必填。
73
+ atomgit_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
74
+ gitee_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
75
+ github_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
76
+ email: Mapped[str | None] = mapped_column(String(255), nullable=True)
77
+
78
+ role: Mapped[str] = mapped_column(
79
+ String(16), nullable=False, default=MemberRole.COMMITTER.value
80
+ )
81
+ """最高角色。一个人可以同时是维护者与某仓库的 committer。"""
82
+
83
+ roles: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
84
+ """全部角色。只存最高角色会丢掉"他还是某仓库的 committer"这一信息。"""
85
+
86
+ org: Mapped[str | None] = mapped_column(String(128), nullable=True)
87
+
88
+ repositories: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
89
+ """参与的仓库。"""
90
+
91
+ admin_repositories: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
92
+ """担任 repo_admin 的仓库。"""
93
+
94
+ kept_branches: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
95
+ """作为 keeper 负责的分支。分支页据此显示"该分支谁负责"。"""
96
+
97
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
98
+ source: Mapped[str] = mapped_column(
99
+ String(24), nullable=False, default=MemberSource.COMMITTERS.value
100
+ )
101
+ source_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
102
+ last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
103
+
104
+ def __repr__(self) -> str:
105
+ return f"<SIGMember {self.name} {self.role}>"
106
+
107
+
108
+ class SubsystemOwner(Base, TimestampMixin):
109
+ """模块 → Committer 的映射,来自 ``committers.md``。
110
+
111
+ ``paths`` 是内核路径前缀。上游用 ``mm/``、``net/bpf/`` 这类前缀,
112
+ 也用过 ``kernel/*``、``include/linux/kvm*`` 这类通配写法,
113
+ 因此按前缀匹配、不做 glob 展开 —— 展开需要完整文件树,
114
+ 而这里只需要回答"改了这个文件该找谁"。
115
+
116
+ 唯一键是 **(章节, 模块)** 而不是模块名:模块名只在章节内唯一,
117
+ 实测 ``smmu`` 在架构与驱动两个章节下各有一行,指的是两批人。
118
+ 用模块名做键会让第二行撞约束,整份名单一起同步失败。
119
+ """
120
+
121
+ __tablename__ = "ksp_subsystem_owner"
122
+ __table_args__ = (
123
+ UniqueConstraint("section", "module", name="uq_ksp_subsystem_owner_section_module"),
124
+ Index("ix_ksp_subsystem_owner_paths", "paths", postgresql_using="gin"),
125
+ )
126
+
127
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
128
+
129
+ module: Mapped[str] = mapped_column(String(64), nullable=False)
130
+ section: Mapped[str | None] = mapped_column(String(64), nullable=True)
131
+ """committers.md 里的二级标题,如 ``kernel core``、``drivers``。"""
132
+
133
+ paths: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
134
+ committer_logins: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
135
+ """上游写的评审人 handle(人写的展示名)。与 ``SIGMember.atomgit_id`` 未必字面相同。"""
136
+
137
+ sequence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
138
+ source_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
139
+ last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
140
+
141
+ def __repr__(self) -> str:
142
+ return f"<SubsystemOwner {self.section}/{self.module}>"
143
+
144
+
145
+ class ReleaseBranch(Base, TimestampMixin):
146
+ """分支/版本登记表。
147
+
148
+ 能从分支名推出来的(产品线、系列、SP 序号)由 ``domain.release`` 算,
149
+ 这张表只存**推不出来的事实**:上游基线、生命周期、维护策略说明。
150
+ 猜这些字段会造出一个看起来权威实际是编的值,所以留给人填。
151
+
152
+ **一个分支属于一个仓库**,这条是 sig-info.yaml 明写的
153
+ (``repo_branch`` 段里每条都带 ``repo``),只是早先写入时被丢掉了 ——
154
+ 结果这张表成了全局表,纳管第二个仓库时,OLK-6.6 会出现在
155
+ src-openeuler/kernel 的分支页上。同名分支在两边都存在
156
+ (master、openEuler-24.03-LTS-SP4 都是),所以这一列不是可有可无的。
157
+ """
158
+
159
+ __tablename__ = "ksp_release_branch"
160
+ __table_args__ = (
161
+ UniqueConstraint("repository_id", "name", name="uq_ksp_release_branch_repo_name"),
162
+ Index("ix_ksp_release_branch_keepers", "keepers", postgresql_using="gin"),
163
+ )
164
+
165
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
166
+
167
+ repository_id: Mapped[uuid.UUID | None] = mapped_column(
168
+ UUID(as_uuid=True), ForeignKey("ksp_repository.id", ondelete="CASCADE"), nullable=True
169
+ )
170
+ """所属仓库。为空表示上游还没说明它属于哪个仓库(或那个仓库尚未纳管)。"""
171
+
172
+ name: Mapped[str] = mapped_column(String(64), nullable=False)
173
+ """规范化后的分支名,与 ``domain.release.normalize_branch_name`` 一致。"""
174
+
175
+ aliases: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
176
+ """上游出现过的其他写法(如 ``openEuler-24.03-LTS_SP1``)。
177
+ 库里的 ``target_branch`` 存的是原始名,靠这个列表才能把两边对上。"""
178
+
179
+ kind: Mapped[str] = mapped_column(String(16), nullable=False, default="other")
180
+ series: Mapped[str | None] = mapped_column(String(32), nullable=True)
181
+ sp: Mapped[int | None] = mapped_column(Integer, nullable=True)
182
+ upstream_base: Mapped[str | None] = mapped_column(String(16), nullable=True)
183
+
184
+ state: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
185
+ """active / maintenance / eol。"""
186
+
187
+ note: Mapped[str | None] = mapped_column(Text, nullable=True)
188
+ """维护策略说明。来自 sig/Kernel/README.md 的人工摘要。"""
189
+
190
+ keepers: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
191
+ """分支负责人账号,来自 sig-info.yaml 的 branches 段。"""
192
+
193
+ def __repr__(self) -> str:
194
+ return f"<ReleaseBranch {self.name} {self.state}>"