Skip to content

Commit 24eb2fc

Browse files
committed
feat: add read-only submission PR intake
1 parent 49a4f60 commit 24eb2fc

4 files changed

Lines changed: 471 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Scan plugin submission pull requests
2+
3+
on:
4+
schedule:
5+
- cron: "7,37 * * * *"
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
pull-requests: read
11+
12+
concurrency:
13+
group: submission-pr-intake
14+
cancel-in-progress: false
15+
16+
jobs:
17+
scan:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v6
21+
- name: Scan open pull requests without mutating them
22+
env:
23+
GITHUB_TOKEN: ${{ github.token }}
24+
run: python3 scripts/reconcile_submission_prs.py --output pr-intake.json
25+
- name: Upload the intake ledger
26+
uses: actions/upload-artifact@v4
27+
with:
28+
name: submission-pr-intake-${{ github.run_id }}
29+
path: pr-intake.json
30+
if-no-files-found: error
31+
retention-days: 14
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# 插件收录 PR 自动处理方案
2+
3+
## 定位
4+
5+
外部 PR 是插件收录申请,不作为可合并代码来源。正式目录只由仓库 `main` 上的可信自动化读取公开仓库信息后生成。自动化不得检出、安装或执行贡献者分支及候选插件代码。
6+
7+
## 分批落地
8+
9+
1. **只读收件箱**:每 30 分钟扫描开放 PR,提取插件仓库,识别已收录、候选、新申请、缺失地址和地址歧义,输出 Action Summary 与 JSON 台账;不评论、不关闭、不发布。
10+
2. **已收录闭环**:远端回读确认插件已经存在于 `data/plugins.json` 后,自动回复目录、提交和报告链接,添加状态标签并关闭 PR。
11+
3. **定向复核与发布**:未收录申请按仓库 ID 进入定向规则和模型复核;高置信度结果通过现有收录事务更新数据、双语 README、CHANGELOG 和报告,合并后再关闭申请 PR。
12+
4. **异常治理**:信息不足时请求补充并保留 PR;确定不符合时说明规则并关闭;GitHub 或模型异常只重试,不把系统故障当成拒绝;超时申请按规则归档。
13+
14+
## 安全边界
15+
16+
- 工作流只从默认分支运行可信代码。
17+
- 不使用外部 PR 的脚本、Action、构建结果或测试声明作为自动执行入口。
18+
- PR 正文、补丁和候选 README 均视为不可信数据。
19+
- 评论和关闭必须在远端正式目录回读成功之后执行。
20+
- 使用 repository ID 去重,使用隐藏状态标识保证评论和关闭幂等。
21+
22+
## 第一批状态
23+
24+
第一批工作流 `.github/workflows/submission-pr-intake.yml` 只申请 `contents: read``pull-requests: read` 权限。扫描结果保留 14 天,不产生仓库或 PR 写操作。
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
#!/usr/bin/env python3
2+
"""Read-only intake scanner for plugin submission pull requests."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import datetime as dt
8+
import json
9+
import os
10+
import re
11+
import urllib.error
12+
import urllib.parse
13+
import urllib.request
14+
from pathlib import Path
15+
from typing import Any
16+
17+
18+
ROOT = Path(__file__).resolve().parents[1]
19+
PLUGINS_PATH = ROOT / "data" / "plugins.json"
20+
CANDIDATES_PATH = ROOT / "data" / "topic-candidates.json"
21+
SELF_REPOSITORY = "hacksing/dsh-plugins"
22+
GITHUB_REPOSITORY_URL = re.compile(
23+
r"https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?=\.git(?:\s|$)|[\s)\]}>,。;、]|$)",
24+
re.IGNORECASE,
25+
)
26+
EXPLICIT_REPOSITORY_URL = re.compile(
27+
r"(?:plugin\s+repository|repository|repo|插件仓库|仓库)\s*[::]\s*"
28+
r"(https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?)",
29+
re.IGNORECASE,
30+
)
31+
32+
33+
class IntakeError(RuntimeError):
34+
"""A deterministic intake failure."""
35+
36+
37+
class GitHubClient:
38+
def __init__(self, repository: str, token: str = "") -> None:
39+
self.repository = repository
40+
self.headers = {
41+
"Accept": "application/vnd.github+json",
42+
"User-Agent": "dsh-plugins-submission-intake",
43+
"X-GitHub-Api-Version": "2022-11-28",
44+
}
45+
if token:
46+
self.headers["Authorization"] = f"Bearer {token}"
47+
48+
def get_json(self, url: str) -> Any:
49+
request = urllib.request.Request(url, headers=self.headers)
50+
try:
51+
with urllib.request.urlopen(request, timeout=30) as response:
52+
return json.load(response)
53+
except urllib.error.HTTPError as exc:
54+
raise IntakeError(f"GitHub API returned HTTP {exc.code} for {url}") from exc
55+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
56+
raise IntakeError(f"GitHub API request failed for {url}: {exc}") from exc
57+
58+
def list_open_pull_requests(self) -> list[dict[str, Any]]:
59+
pulls: list[dict[str, Any]] = []
60+
for page in range(1, 11):
61+
query = urllib.parse.urlencode(
62+
{"state": "open", "per_page": 100, "page": page, "sort": "created"}
63+
)
64+
batch = self.get_json(
65+
f"https://api.github.com/repos/{self.repository}/pulls?{query}"
66+
)
67+
if not isinstance(batch, list):
68+
raise IntakeError("GitHub pull request response was not a list")
69+
pulls.extend(batch)
70+
if len(batch) < 100:
71+
return pulls
72+
raise IntakeError("more than 1,000 open pull requests; refusing a partial scan")
73+
74+
def list_pull_request_files(self, number: int) -> list[dict[str, Any]]:
75+
files: list[dict[str, Any]] = []
76+
for page in range(1, 4):
77+
query = urllib.parse.urlencode({"per_page": 100, "page": page})
78+
batch = self.get_json(
79+
f"https://api.github.com/repos/{self.repository}/pulls/{number}/files?{query}"
80+
)
81+
if not isinstance(batch, list):
82+
raise IntakeError("GitHub pull request files response was not a list")
83+
files.extend(batch)
84+
if len(batch) < 100:
85+
return files
86+
raise IntakeError(f"pull request #{number} changes more than 300 files")
87+
88+
89+
def load_json(path: Path, default: Any) -> Any:
90+
if not path.exists():
91+
return default
92+
return json.loads(path.read_text(encoding="utf-8"))
93+
94+
95+
def normalize_repository_url(value: str) -> str | None:
96+
match = GITHUB_REPOSITORY_URL.search(str(value or "").strip())
97+
if not match:
98+
return None
99+
owner = match.group(1)
100+
name = match.group(2)
101+
if name.casefold().endswith(".git"):
102+
name = name[:-4]
103+
return f"https://github.com/{owner}/{name}"
104+
105+
106+
def repository_key(value: str) -> str:
107+
normalized = normalize_repository_url(value)
108+
if not normalized:
109+
return ""
110+
return normalized.removeprefix("https://github.com/").casefold()
111+
112+
113+
def extract_repository_urls(
114+
pull_request: dict[str, Any], files: list[dict[str, Any]]
115+
) -> tuple[str, list[str]]:
116+
body = str(pull_request.get("body") or "")
117+
explicit = []
118+
for match in EXPLICIT_REPOSITORY_URL.finditer(body):
119+
normalized = normalize_repository_url(match.group(1))
120+
if normalized and repository_key(normalized) != SELF_REPOSITORY:
121+
explicit.append(normalized)
122+
explicit = list(dict.fromkeys(explicit))
123+
if len(explicit) == 1:
124+
return "explicit", explicit
125+
if len(explicit) > 1:
126+
return "ambiguous", explicit
127+
128+
text = "\n".join(
129+
[
130+
str(pull_request.get("title") or ""),
131+
body,
132+
*[str(item.get("patch") or "") for item in files],
133+
]
134+
)
135+
discovered = []
136+
for match in GITHUB_REPOSITORY_URL.finditer(text):
137+
normalized = normalize_repository_url(match.group(0))
138+
if normalized and repository_key(normalized) != SELF_REPOSITORY:
139+
discovered.append(normalized)
140+
discovered = list(dict.fromkeys(discovered))
141+
if len(discovered) == 1:
142+
return "inferred", discovered
143+
if len(discovered) > 1:
144+
return "ambiguous", discovered
145+
return "missing", []
146+
147+
148+
def classify_repository(
149+
url: str,
150+
catalog: dict[str, Any],
151+
candidate_payload: dict[str, Any],
152+
) -> dict[str, Any]:
153+
key = repository_key(url)
154+
for plugin in catalog.get("plugins", []):
155+
if repository_key(str(plugin.get("url", ""))) == key:
156+
return {
157+
"intake_status": "already_published",
158+
"plugin_name": plugin.get("name"),
159+
"category": plugin.get("category"),
160+
"repository_id": plugin.get("repository_id"),
161+
}
162+
matches = [
163+
candidate
164+
for candidate in candidate_payload.get("candidates", [])
165+
if repository_key(str(candidate.get("url", ""))) == key
166+
]
167+
if matches:
168+
candidate = matches[0]
169+
return {
170+
"intake_status": f"candidate_{candidate.get('status', 'unknown')}",
171+
"candidate_status": candidate.get("status", "unknown"),
172+
"candidate_reasons": candidate.get("reasons", []),
173+
"category_suggestion": candidate.get("category_suggestion"),
174+
"repository_id": candidate.get("repository_id"),
175+
}
176+
return {"intake_status": "new_submission"}
177+
178+
179+
def is_automation_pull_request(pull_request: dict[str, Any]) -> bool:
180+
head = pull_request.get("head") or {}
181+
user = pull_request.get("user") or {}
182+
return (
183+
str(head.get("ref", "")) == "automation/topic-sync"
184+
or str(user.get("type", "")).casefold() == "bot"
185+
or str(user.get("login", "")).casefold().endswith("[bot]")
186+
)
187+
188+
189+
def build_report(client: GitHubClient) -> dict[str, Any]:
190+
catalog = load_json(PLUGINS_PATH, {"plugins": []})
191+
candidates = load_json(CANDIDATES_PATH, {"candidates": []})
192+
submissions = []
193+
skipped_automation = 0
194+
for pull_request in client.list_open_pull_requests():
195+
if is_automation_pull_request(pull_request):
196+
skipped_automation += 1
197+
continue
198+
number = int(pull_request["number"])
199+
files = client.list_pull_request_files(number)
200+
extraction, urls = extract_repository_urls(pull_request, files)
201+
record: dict[str, Any] = {
202+
"author": (pull_request.get("user") or {}).get("login"),
203+
"draft": bool(pull_request.get("draft")),
204+
"extraction": extraction,
205+
"pr_number": number,
206+
"pr_title": pull_request.get("title"),
207+
"pr_url": pull_request.get("html_url"),
208+
"repository_urls": urls,
209+
}
210+
if extraction == "missing":
211+
record["intake_status"] = "not_identified"
212+
elif extraction == "ambiguous":
213+
record["intake_status"] = "ambiguous_repository"
214+
else:
215+
record["repository_url"] = urls[0]
216+
record.update(classify_repository(urls[0], catalog, candidates))
217+
submissions.append(record)
218+
219+
counts: dict[str, int] = {}
220+
for item in submissions:
221+
status = str(item["intake_status"])
222+
counts[status] = counts.get(status, 0) + 1
223+
return {
224+
"counts": counts,
225+
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
226+
"mode": "read_only",
227+
"open_prs_scanned": len(submissions),
228+
"repository": client.repository,
229+
"schema_version": 1,
230+
"skipped_automation_prs": skipped_automation,
231+
"submissions": submissions,
232+
}
233+
234+
235+
def markdown_summary(report: dict[str, Any]) -> str:
236+
lines = [
237+
"# 插件收录 PR 只读扫描",
238+
"",
239+
f"- 开放 PR:{report['open_prs_scanned']}",
240+
f"- 跳过自动化 PR:{report['skipped_automation_prs']}",
241+
"- 当前模式:只读,不评论、不关闭、不发布",
242+
"",
243+
"| PR | 插件仓库 | 识别结果 | 下一批动作 |",
244+
"| --- | --- | --- | --- |",
245+
]
246+
actions = {
247+
"already_published": "第二批:回复并关闭",
248+
"new_submission": "第三批:定向复核",
249+
"not_identified": "第四批:请求补充信息",
250+
"ambiguous_repository": "第四批:请求明确唯一仓库",
251+
}
252+
for item in report["submissions"]:
253+
status = str(item["intake_status"])
254+
action = actions.get(status, "第三批:根据候选状态处理")
255+
pr = f"[#{item['pr_number']}]({item.get('pr_url') or ''})"
256+
repository = item.get("repository_url") or "、".join(item.get("repository_urls", [])) or "未识别"
257+
repository = repository.replace("|", "\\|")
258+
lines.append(f"| {pr} | {repository} | `{status}` | {action} |")
259+
return "\n".join(lines) + "\n"
260+
261+
262+
def write_outputs(report: dict[str, Any], output: Path) -> None:
263+
output.write_text(
264+
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
265+
encoding="utf-8",
266+
)
267+
summary = markdown_summary(report)
268+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
269+
if summary_path:
270+
with Path(summary_path).open("a", encoding="utf-8") as handle:
271+
handle.write(summary)
272+
print(summary, end="")
273+
274+
275+
def main() -> None:
276+
parser = argparse.ArgumentParser(description=__doc__)
277+
parser.add_argument(
278+
"--repository", default=os.environ.get("GITHUB_REPOSITORY", "HackSing/dsh-plugins")
279+
)
280+
parser.add_argument("--output", type=Path, default=Path("pr-intake.json"))
281+
args = parser.parse_args()
282+
client = GitHubClient(args.repository, os.environ.get("GITHUB_TOKEN", ""))
283+
report = build_report(client)
284+
write_outputs(report, args.output)
285+
286+
287+
if __name__ == "__main__":
288+
main()

0 commit comments

Comments
 (0)