Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions .github/scripts/luxiao_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Bridge one GitHub review job to the Luxiao Hermes profile safely."""

from __future__ import annotations

import os
import shlex
import subprocess
import sys
import tempfile
import uuid
from pathlib import Path


MAX_DIFF_CHARS = 100_000
REMOTE_REVIEW_TIMEOUT_SECONDS = 600
SSH_OPTIONS = ("-o", "StrictHostKeyChecking=yes", "-o", "BatchMode=yes")


def _write_result(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")


def main() -> int:
if len(sys.argv) != 3:
print("Usage: luxiao_review.py <diff_path> <output_path>", file=sys.stderr)
return 2

diff_path = Path(sys.argv[1])
output_path = Path(sys.argv[2])
if not diff_path.is_file():
_write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n⚠️ Diff 文件不存在。")
return 1

hermes_host = os.environ.get("LUXIAO_HERMES_HOST", "").strip()
remote_script = os.environ.get(
"LUXIAO_REMOTE_SCRIPT", "/home/hermesadmin/scripts/luxiao-run.sh"
).strip()
remote_dir = os.environ.get(
"LUXIAO_REMOTE_DIR", "/home/hermesadmin/.cache/luxiao-review"
).strip()
if not hermes_host:
_write_result(
output_path,
"## 🤖 Luxiao PR 审查报告\n\n⚠️ Runner 未配置 LUXIAO_HERMES_HOST。",
)
return 1

full_diff = diff_path.read_text(encoding="utf-8", errors="replace")
diff_text = full_diff[:MAX_DIFF_CHARS]
truncation_notice = ""
if len(full_diff) > MAX_DIFF_CHARS:
omitted_chars = len(full_diff) - MAX_DIFF_CHARS
omitted_lines = full_diff[MAX_DIFF_CHARS:].count("\n")
truncation_notice = (
"\n\n> ⚠️ Diff 过大,本次输入已明确截断:"
f"省略 {omitted_chars} 个字符、约 {omitted_lines} 行。"
"审查结论必须注明未覆盖范围,不能宣称完成全量审查。"
)
if not diff_text.strip() or diff_text.strip() == "empty":
_write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n✅ 无代码变更。")
return 0

prompt = f"""请审查以下 Pull Request。按照你的审查框架(架构、产品、规范、损伤 + 意图分析 + Merge 建议)给出完整审查报告。

## PR 信息

- 标题: {os.environ.get('PR_TITLE', '')}
- 描述: {os.environ.get('PR_BODY', '')}
- 变更: {os.environ.get('PR_FILES', '')} 个文件, +{os.environ.get('PR_ADDITIONS', '')} / -{os.environ.get('PR_DELETIONS', '')}

## 代码 Diff

{diff_text}{truncation_notice}

请直接输出审查报告,不要多余的前缀。"""

runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir()))
runner_temp.mkdir(parents=True, exist_ok=True)
remote_file = f"{remote_dir}/{uuid.uuid4().hex}.txt"
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", prefix="luxiao-prompt-", suffix=".txt", dir=runner_temp, delete=False
) as prompt_file:
prompt_file.write(prompt)
local_file = Path(prompt_file.name)

try:
subprocess.run(
["ssh", *SSH_OPTIONS, hermes_host, "mkdir", "-p", remote_dir],
check=True,
capture_output=True,
text=True,
timeout=30,
)
subprocess.run(
["scp", *SSH_OPTIONS, str(local_file), f"{hermes_host}:{remote_file}"],
check=True,
capture_output=True,
text=True,
timeout=30,
)
result = subprocess.run(
[
"ssh",
*SSH_OPTIONS,
hermes_host,
"timeout",
"--signal=TERM",
"--kill-after=30s",
f"{REMOTE_REVIEW_TIMEOUT_SECONDS}s",
shlex.quote(remote_script),
shlex.quote(remote_file),
],
capture_output=True,
text=True,
timeout=REMOTE_REVIEW_TIMEOUT_SECONDS + 60,
)
finally:
local_file.unlink(missing_ok=True)
subprocess.run(
["ssh", *SSH_OPTIONS, hermes_host, "rm", "-f", "--", remote_file],
capture_output=True,
text=True,
timeout=30,
check=False,
)

if result.returncode != 0 or not result.stdout.strip():
_write_result(
output_path,
"## 🤖 Luxiao PR 审查报告\n\n"
f"⚠️ Luxiao Agent 调用失败(退出码 {result.returncode})。",
)
return 1

text = result.stdout
markers = ("🤖 PR 审查报告", "PR 审查报告", "## PR 审查", "## 审查报告")
for marker in markers:
if marker in text:
text = text[text.index(marker) :]
break
else:
text = text[-6000:]
_write_result(output_path, "## 🤖 Luxiao PR 审查报告\n\n" + text)
print(f"Review saved to {output_path}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
32 changes: 22 additions & 10 deletions .github/workflows/pr-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,41 @@ permissions:

jobs:
review:
runs-on: [self-hosted, Linux, X64, pr-review]
runs-on: [self-hosted, Linux, X64, sdk-ci, pr-review]
if: github.event.pull_request.draft == false
steps:
- name: Get PR Diff via API
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr diff ${{ github.event.pull_request.number }} -R ${{ github.repository }} > /tmp/pr.diff 2>/dev/null
if [ ! -s /tmp/pr.diff ]; then echo "empty" > /tmp/pr.diff; fi
echo "Diff lines: $(wc -l < /tmp/pr.diff)"
diff_file="${RUNNER_TEMP}/pr-${{ github.event.pull_request.number }}.diff"
gh pr diff ${{ github.event.pull_request.number }} -R ${{ github.repository }} > "${diff_file}" 2>/dev/null
if [ ! -s "${diff_file}" ]; then echo "empty" > "${diff_file}"; fi
echo "DIFF_FILE=${diff_file}" >> "${GITHUB_ENV}"
echo "Diff lines: $(wc -l < "${diff_file}")"

- name: Run Luxiao Review
env:
LUXIAO_HERMES_HOST: ${{ vars.LUXIAO_HERMES_HOST }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_ADDITIONS: ${{ github.event.pull_request.additions }}
PR_DELETIONS: ${{ github.event.pull_request.deletions }}
PR_FILES: ${{ github.event.pull_request.changed_files }}
run: |
python3 /home/opsadmin/scripts/luxiao-review.py "${{ github.event.pull_request.title }}" "${{ github.event.pull_request.body }}" "${{ github.event.pull_request.additions }}" "${{ github.event.pull_request.deletions }}" "${{ github.event.pull_request.changed_files }}" /tmp/pr.diff /tmp/review_result.md
python3 /home/runner-ci/scripts/luxiao-review.py \
"${DIFF_FILE}" \
"${RUNNER_TEMP}/review_result.md"

- name: Post Review Comment
if: always()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ ! -s /tmp/review_result.md ]; then
echo "## 🤖 Luxiao PR 审查报告" > /tmp/review_result.md
echo "" >> /tmp/review_result.md
echo "⚠️ Review agent did not produce output." >> /tmp/review_result.md
review_file="${RUNNER_TEMP}/review_result.md"
if [ ! -s "${review_file}" ]; then
echo "## 🤖 Luxiao PR 审查报告" > "${review_file}"
echo "" >> "${review_file}"
echo "⚠️ Review agent did not produce output." >> "${review_file}"
fi
gh pr comment ${{ github.event.pull_request.number }} --body "$(cat /tmp/review_result.md)"
gh pr comment ${{ github.event.pull_request.number }} --body "$(cat "${review_file}")"
166 changes: 166 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
name: Prepare SDK release

on:
pull_request:
types: [closed]
workflow_dispatch:
inputs:
target_version:
description: "显式 PEP 440 版本;飞书指令使用此字段"
required: false
type: string
release_type:
description: "未指定显式版本时使用的版本增量"
required: false
type: choice
options: [none, prerelease, stable, minor, major]
default: none
source:
description: "发布请求来源"
required: true
type: string

permissions:
contents: read

jobs:
prepare:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main')
# Trusted orchestration never shares an execution identity with pull-request code.
runs-on: [self-hosted, Linux, X64, sdk-orchestrator]
steps:
- name: Resolve one explicit release request
id: release
env:
EVENT_NAME: ${{ github.event_name }}
LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }}
INPUT_TARGET: ${{ inputs.target_version }}
INPUT_TYPE: ${{ inputs.release_type }}
INPUT_SOURCE: ${{ inputs.source }}
SOURCE_PR: ${{ github.event.pull_request.number }}
SOURCE_TITLE: ${{ github.event.pull_request.title }}
shell: bash
run: |
python - <<'PY' >> "${GITHUB_OUTPUT}"
import json
import os

if os.environ["EVENT_NAME"] == "workflow_dispatch":
target = os.environ.get("INPUT_TARGET", "").strip()
release_type = os.environ.get("INPUT_TYPE", "").strip()
if release_type == "none":
release_type = ""
if bool(target) == bool(release_type):
raise SystemExit("Exactly one of target_version or release_type is required")
print(f"target={target}")
print(f"release_type={release_type}")
print(f"source={os.environ['INPUT_SOURCE'].strip()}")
print("skip=false")
else:
mapping = {
"release:prerelease": "prerelease",
"release:stable": "stable",
"release:minor": "minor",
"release:major": "major",
}
labels = json.loads(os.environ.get("LABELS_JSON") or "[]")
selected = [mapping[label] for label in labels if label in mapping]
if len(selected) > 1:
raise SystemExit("A merged PR may have only one release:* label")
if not selected:
print("skip=true")
else:
print("target=")
print(f"release_type={selected[0]}")
print(
"source=PR #{}:{}".format(
os.environ["SOURCE_PR"], os.environ["SOURCE_TITLE"].strip()
)
)
print("skip=false")
PY

- name: Mint repository-scoped GitHub App token
if: steps.release.outputs.skip == 'false'
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.ORULINK_RELEASE_APP_ID }}
private-key: ${{ secrets.ORULINK_RELEASE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: WatcheRobot_python_sdk

- name: Check out main
if: steps.release.outputs.skip == 'false'
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
persist-credentials: true
token: ${{ steps.app-token.outputs.token }}

- uses: actions/setup-python@v6
if: steps.release.outputs.skip == 'false'
with:
python-version: "3.12"

- name: Prepare version branch and pull request
if: steps.release.outputs.skip == 'false'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RELEASE_TYPE: ${{ steps.release.outputs.release_type }}
TARGET_VERSION: ${{ steps.release.outputs.target }}
RELEASE_SOURCE: ${{ steps.release.outputs.source }}
shell: bash
run: |
python -m pip install --upgrade packaging
arguments=(--source "${RELEASE_SOURCE}")
if [[ -n "${TARGET_VERSION}" ]]; then
arguments+=(--target "${TARGET_VERSION}")
else
arguments+=(--release-type "${RELEASE_TYPE}")
fi
version=$(python tools/prepare_release.py "${arguments[@]}")
python tools/check_release_availability.py "${version}" --repository "${GITHUB_REPOSITORY}"
branch="release/watcherobot-${version}"
existing=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${branch}" --state open --json number --jq 'length')
if [[ "${existing}" != "0" ]]; then
echo "Release PR already exists for ${version}; nothing to do."
exit 0
fi
git switch -c "${branch}"
git config user.name "orulink-release-bot"
git config user.email "release-bot@users.noreply.github.com"
git add src/watcherobot/__init__.py CHANGELOG.md
git commit -m "chore(release): 准备发布 watcherobot ${version}" \
-m "由 ${RELEASE_SOURCE} 触发。此提交仅更新 SDK 唯一版本源和中文更新日志,正式发布仍需版本 PR 审查、标签门禁、TestPyPI 验证与 GitHub Environment 人工批准。"
git push origin "${branch}"
body=$(cat <<EOF
## 发布来源

- 来源:${RELEASE_SOURCE}
- 发布类型:${RELEASE_TYPE:-显式版本}
- 目标版本:${version}

## 门禁

- 本 PR 必须人工审查和合并
- 合并后由陆骁创建 annotated tag
- Tag 构建先发布并验证 TestPyPI
- 正式 PyPI 发布需要 GitHub Environment 人工批准

## 已知风险与实机状态

- 本 PR 只准备版本元数据,不代表已完成实机验收
- 稳定版发布前必须补充 RTC 实机验证结果
EOF
)
gh pr create \
--repo "${GITHUB_REPOSITORY}" \
--base main \
--head "${branch}" \
--title "chore(release): 发布 watcherobot ${version}" \
--label "release:version" \
--body "${body}"
Loading
Loading