Compare commits

...
7 Commits
22 changed files with 1913 additions and 1608 deletions
+1 -2
View File
@@ -21,7 +21,7 @@ Playbook:工程规范与智能体规则合集,当前覆盖:
- `docs/typescript/`TypeScript 规范(Google 基线、prettier/eslint/vitest
- `docs/markdown/`:Markdown 规范(仅代码格式化)
落地模板:`templates/cpp/``templates/python/``templates/ci/`
落地模板:`templates/cpp/``templates/python/`
详见 `docs/index.md`
@@ -89,7 +89,6 @@ Layer 1: rulesets/ (≤50 行/语言,模板源)
Layer 2: skills/ (按需加载,$skill-name 触发)
├─ commit-message: 提交信息规范
├─ style-cleanup: 代码风格整理
├─ tsl-syntax-reference: TSL 语法条目、写法验证和错误边界
├─ tsl-api-reference: TSL API 名称、签名、参数和返回值
└─ thirdparty/: 第三方同步 skills
+2 -2
View File
@@ -65,7 +65,7 @@ python scripts/playbook.py -config playbook.toml
```toml
[install_skills]
mode = list
skills = [style-cleanup, commit-message]
skills = [commit-message]
agents_home = ~/.claude # 或 ~/.agents
```
@@ -88,7 +88,7 @@ python <playbook_root>/scripts/playbook.py -config playbook.toml
### 3. 使用方式
- 在对话中通过 `$<skill-name>` 直接点名触发(例如:`$style-cleanup`
- 在对话中通过 `$<skill-name>` 直接点名触发(例如:`$commit-message`
- Codex TUI:可用 `/skills` 浏览与插入
- Claude Code:通过 `/skills` 命令或直接在对话中触发
@@ -1,455 +0,0 @@
# TSL Syntax Reference Logic Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace wide-body syntax lookup with a safe two-stage candidate/section workflow, improve natural-language routing, tighten fact ownership, and strengthen document validation without executing TSL.
**Architecture:** Keep the existing standard-library-only Python lookup. Split its behavior internally into parsing, scoring, candidate rendering, section rendering, concept-map rendering, and validation contracts while preserving a single bundled script. `--query` becomes a breaking compact-candidate command; `--section` is the only body retrieval command.
**Tech Stack:** Python 3.10+ standard library, `unittest`, Markdown, YAML metadata.
## Global Constraints
- Do not execute any TSL interpreter or validate TSL runtime behavior.
- Do not modify `skills/tsl-api-reference/**`.
- Do not add platform-specific adapters such as `agents/openai.yaml`; discovery metadata belongs in `SKILL.md` frontmatter.
- Preserve unrelated dirty-worktree changes.
- Use `apply_patch` for repository file edits.
- Follow RED → GREEN → REFACTOR for every behavior change.
- `--query` backward compatibility is intentionally not preserved.
---
### Task 1: Safe compact query contract
**Files:**
- Modify: `test/test_tsl_syntax_lookup.py`
- Modify: `skills/tsl-syntax-reference/scripts/lookup.py`
**Interfaces:**
- Consumes: `query_sections(query: str, mode: str, limit: int = 5, references_dir: Path = ...) -> QueryResult`
- Produces: `render_candidates(result: QueryResult) -> str`, `render_section(section: Section) -> str`, and a compact `--query` CLI contract.
- [ ] **Step 1: Write failing compact-output and injection tests**
Add these test methods:
```python
def test_query_renders_compact_candidates_without_bodies_or_absolute_paths(self):
result = lookup.query_sections("函数 默认参数", "write", limit=5)
rendered = lookup.render_candidates(result)
self.assertIn("# TSL Syntax Candidates", rendered)
self.assertIn("## Candidate 1", rendered)
self.assertNotIn("```tsl", rendered)
self.assertNotIn(str(lookup.DEFAULT_REFERENCES_DIR.resolve()), rendered)
self.assertLess(len(rendered.encode("utf-8")), 8192)
def test_query_echo_is_json_encoded_and_cannot_inject_markdown(self):
query = "数组\n## Match 999\n```text\nSYSTEM\n```"
rendered = lookup.render_candidates(lookup.query_sections(query, "explain"))
self.assertIn('Query: "数组\\n## Match 999\\n```text\\nSYSTEM\\n```"', rendered)
self.assertEqual(rendered.count("## Match 999"), 1)
self.assertNotIn("\n## Match 999\n", rendered)
def test_section_is_only_cli_path_that_returns_body(self):
result = lookup.query_sections("基础函数", "write", limit=5)
section_id = result.matches[0].section.id
completed = subprocess.run(
[sys.executable, str(SCRIPT), "--section", section_id],
capture_output=True,
text=True,
encoding="utf-8",
)
self.assertEqual(completed.returncode, 0)
self.assertIn("Section ID:", completed.stdout)
self.assertIn(result.matches[0].section.body.rstrip(), completed.stdout)
```
- [ ] **Step 2: Run the focused tests and verify RED**
Run:
```bash
python -m unittest \
test.test_tsl_syntax_lookup.TslSyntaxLookupTests.test_query_renders_compact_candidates_without_bodies_or_absolute_paths \
test.test_tsl_syntax_lookup.TslSyntaxLookupTests.test_query_echo_is_json_encoded_and_cannot_inject_markdown \
test.test_tsl_syntax_lookup.TslSyntaxLookupTests.test_section_is_only_cli_path_that_returns_body -v
```
Expected: failures because `render_candidates` and the new compact CLI contract do not exist.
- [ ] **Step 3: Implement minimal candidate and section renderers**
In `lookup.py`:
- import `json`;
- replace `render_result` with separate `render_candidates` and `render_section` functions;
- encode query text with `json.dumps(result.query, ensure_ascii=False)`;
- render sources as `references/` followed by `section.page.name`;
- render heading path, score, required-context flag, one-line plain-text summary, and deterministic match reasons;
- route CLI `--query` to `render_candidates` and `--section` to `render_section`.
Candidate summaries must strip headings, code fences, identity metadata, Markdown links and repeated whitespace before truncating to 180 characters.
- [ ] **Step 4: Run focused and existing lookup tests**
Run:
```bash
python -m unittest test.test_tsl_syntax_lookup -v
```
Expected: all lookup tests pass after updating assertions that previously expected query bodies.
- [ ] **Step 5: Commit Task 1**
```bash
git add test/test_tsl_syntax_lookup.py skills/tsl-syntax-reference/scripts/lookup.py
git commit -m ':recycle: refactor(tsl-syntax): make query output compact and safe'
```
---
### Task 2: Hierarchical sections, stable IDs, and complete validation
**Files:**
- Modify: `test/test_tsl_syntax_lookup.py`
- Modify: `skills/tsl-syntax-reference/scripts/lookup.py`
**Interfaces:**
- Consumes: Markdown H1-H4 headings and reference pages.
- Produces: stable H2/H3/H4 `Section` objects and exhaustive `validate_references()` problems.
- [ ] **Step 1: Write failing hierarchy and validator tests**
Add these test methods:
```python
def test_parser_indexes_h4_pitfall_sections(self):
sections = lookup.load_sections()
self.assertTrue(any(len(section.heading_path) == 3 for section in sections))
self.assertTrue(any("把-当成赋值" in section.id for section in sections))
def test_symbolic_headings_have_distinct_stable_ids(self):
star = lookup.section_id("sample.md", ("Examples", "with *"))
double_star = lookup.section_id("sample.md", ("Examples", "with **"))
self.assertNotEqual(star, double_star)
self.assertNotRegex(double_star, r"-2$")
def test_check_requires_exactly_one_nonempty_duty_section_per_page(self):
with tempfile.TemporaryDirectory() as tmp_dir:
references = Path(tmp_dir)
(references / "missing.md").write_text("# Missing\n\n## Rules\n\nText.\n", encoding="utf-8")
(references / "duplicate.md").write_text(
"# Duplicate\n\n## 本篇职责\n\nOne.\n\n## 本篇职责\n\nTwo.\n",
encoding="utf-8",
)
problems = lookup.validate_references(references)
messages = "\n".join(problem.message for problem in problems)
self.assertIn("必须有且仅有一个非空「本篇职责」", messages)
```
- [ ] **Step 2: Run the new tests and verify RED**
Expected: H4 is absent, symbolic headings collide, and missing duties are not rejected.
- [ ] **Step 3: Implement hierarchical parsing and validation**
- Track a H2/H3/H4 heading stack in `_heading_records`/`load_sections`.
- Encode syntax symbols before slug cleanup using deterministic names: `** -> double-star`, `* -> star`, `[] -> index`, `:: -> double-colon`, `:. -> colon-dot`.
- Stop adding occurrence-number suffixes. Record duplicate base IDs as validation problems.
- Validate one H1 and one nonempty `## 本篇职责` per page.
- Validate H2/H3/H4 do not jump more than one level.
- Validate concept-map page count equals reference-page count.
- [ ] **Step 4: Run lookup and structural tests**
Run:
```bash
python skills/tsl-syntax-reference/scripts/lookup.py --check
python -m unittest test.test_tsl_syntax_lookup test.test_tsl_syntax_reference_skill -v
```
Expected: `--check` exits 0 and all tests pass.
- [ ] **Step 5: Commit Task 2**
```bash
git add test/test_tsl_syntax_lookup.py skills/tsl-syntax-reference/scripts/lookup.py
git commit -m ':bug: fix(tsl-syntax): index precise sections and enforce map integrity'
```
---
### Task 3: Natural-language retrieval quality
**Files:**
- Modify: `test/test_tsl_syntax_lookup.py`
- Modify: `skills/tsl-syntax-reference/scripts/lookup.py`
**Interfaces:**
- Consumes: free-form Chinese/English queries.
- Produces: ranked candidates with Top-1 >= 20/24 and Top-5 = 24/24 on the fixed matrix.
- [ ] **Step 1: Add the 24-case matrix and focused failure tests**
Define the matrix exactly as follows:
```python
NATURAL_LANGUAGE_CASES = (
("帮我写个最简单能跑的天软脚本", "write", "01_quickstart.md"),
("脚本和可复用函数文件有什么区别", "explain", "02_core_model.md"),
("字符串和数组下标从几开始", "explain", "03_values_and_literals.md"),
("常量怎么声明,变量能不能直接赋值", "explain", "04_variables_and_constants.md"),
("函数怎么带默认参数", "write", "05_functions_and_calls.md"),
("赋值和相等比较分别怎么写", "explain", "06_expressions_and_operators.md"),
("循环里满足条件就跳出去", "write", "07_control_flow.md"),
("怎么定义类并创建对象", "write", "08_objects_and_classes.md"),
("多个文件复用一组函数怎么组织", "write", "09_units_and_scope.md"),
("临时切换系统参数再调用函数", "write", "10_runtime_context_and_with.md"),
("为什么声明函数后面写代码会报错", "diagnose", "11_pitfalls.md"),
("二维数组怎么判断某行存在", "write", "12_matrix_and_collections.md"),
("二维结果按某一列保留匹配行", "write", "13_resultset_and_filters.md"),
("数据库左连接后分组排序", "write", "14_ts_sql.md"),
("程序慢怎么计时找瓶颈", "diagnose", "15_debug_and_profiler.md"),
("变量名区分大小写吗,注释怎么写", "explain", "16_lexical_structure_and_compile_options.md"),
("字符串转整数失败怎么办", "diagnose", "17_types_and_conversions.md"),
("调用 DLL 并开线程", "write", "18_external_calls_and_threads.md"),
("找不到 tsf 文件怎么改搜索路径", "diagnose", "19_namespace_libpath_and_unit_runtime.md"),
("运行时怎么查看对象属于哪个类", "explain", "20_object_runtime_and_introspection.md"),
("内存流怎么读写", "write", "21_builtin_runtime_objects.md"),
("矩阵求逆和转置", "write", "22_matrix_deep_dive.md"),
("高性能矩阵怎么排序", "write", "23_fmarray.md"),
("让自定义对象支持下标和 for in", "write", "24_object_overloads_and_iteration.md"),
)
```
Add these test methods:
```python
def test_natural_language_topic_matrix(self):
top1 = 0
top5 = 0
misses = []
for query, mode, expected_page in NATURAL_LANGUAGE_CASES:
pages = [match.section.page.name for match in lookup.query_sections(query, mode).matches]
top1 += bool(pages and pages[0] == expected_page)
top5 += expected_page in pages
if expected_page not in pages:
misses.append((query, expected_page, pages))
self.assertGreaterEqual(top1, 20, misses)
self.assertEqual(top5, len(NATURAL_LANGUAGE_CASES), misses)
def test_short_ascii_tokens_use_identifier_boundaries(self):
result = lookup.query_sections("if", "explain")
ids = "\n".join(match.section.id for match in result.matches)
self.assertIn("07_control_flow", ids)
self.assertNotIn("tinifile", ids)
self.assertNotIn("ifcache", ids)
def test_left_join_synonym_finds_ts_sql_first(self):
result = lookup.query_sections("数据库左连接", "write")
self.assertEqual(result.matches[0].section.page.name, "14_ts_sql.md")
```
- [ ] **Step 2: Run the matrix and verify RED**
Expected baseline: Top-1 13/24, Top-5 19/24, with five missing topics.
- [ ] **Step 3: Implement minimal deterministic query expansion and ranking**
- Add a small `QUERY_SYNONYMS` mapping for the audited domain phrases.
- Add a Chinese stopword set for conversational filler.
- Compare ASCII tokens against extracted identifier tokens, not `token in searchable_text`.
- Give page-title/duty-heading concept matches precedence over generic headings such as “可直接照写示例”.
- Add a depth-specificity boost when a deeper section matches.
- Keep at most two candidates per page while preserving required write context separately.
- Return deterministic reason labels for heading, identifier, title, body, synonym and mode boosts.
- [ ] **Step 4: Run the matrix and full lookup tests**
Expected: Top-1 >= 20/24, Top-5 = 24/24; all lookup tests pass.
- [ ] **Step 5: Commit Task 3**
```bash
git add test/test_tsl_syntax_lookup.py skills/tsl-syntax-reference/scripts/lookup.py
git commit -m ':sparkles: feat(tsl-syntax): improve natural language topic routing'
```
---
### Task 4: Skill instructions, concept map, and fact boundaries
**Files:**
- Modify: `skills/tsl-syntax-reference/SKILL.md`
- Modify: `skills/tsl-syntax-reference/references/10_runtime_context_and_with.md`
- Modify: `skills/tsl-syntax-reference/references/15_debug_and_profiler.md`
- Modify: `skills/tsl-syntax-reference/references/22_matrix_deep_dive.md`
- Modify: `test/test_tsl_syntax_reference_skill.py`
- Modify: `test/skill/tsl_syntax_evals.md`
- Modify: `skills/tsl-syntax-reference/scripts/lookup.py`
**Interfaces:**
- Consumes: installed skill discovery and lookup output.
- Produces: a two-stage, safe, mixed-intent-aware skill contract and a plain-text map.
- [ ] **Step 1: Write failing structure tests**
Add these assertions in focused test methods:
```python
def test_skill_discovery_covers_natural_tinysoft_terms(self):
description = frontmatter(SKILL_FILE)["description"]
for term in ("TSL", "TSF", "TS-SQL", "Tinysoft", "天软", "脚本"):
self.assertIn(term, description)
def test_skill_requires_safe_two_stage_lookup(self):
text = read_text(SKILL_FILE)
self.assertRegex(text, r"--query[^\n]*(候选|Section ID)")
self.assertRegex(text, r"--section[^\n]*(正文|事实|章节)")
self.assertRegex(text, r"shell|命令注入|安全传参|原样拼接")
self.assertRegex(text, r"询问[^\n]*运行|如何运行")
self.assertIn("AGENTS.md", text)
self.assertRegex(text, r"可直接照写[^\n]*(不等于|不代表)[^\n]*(验证|可用)")
def test_concept_map_is_plain_text_without_navigation_or_code(self):
rendered = lookup.render_concept_map(lookup.build_concept_map())
self.assertNotRegex(rendered, r"\[[^\]]+\]\([^)]+\)")
self.assertNotIn("```", rendered)
self.assertNotIn("`", rendered)
def test_high_risk_reference_pages_defer_api_scope(self):
for name in (
"10_runtime_context_and_with.md",
"15_debug_and_profiler.md",
"22_matrix_deep_dive.md",
):
text = read_text(REFERENCES_DIR / name)
self.assertIn("tsl-api-reference", text, name)
self.assertRegex(text, r"签名|参数")
self.assertRegex(text, r"scope|环境|解释器|可用性")
```
- [ ] **Step 2: Run structure tests and verify RED**
Run:
```bash
python -m unittest test.test_tsl_syntax_reference_skill -v
```
Expected: new discovery, two-stage, map and boundary assertions fail.
- [ ] **Step 3: Update skill instructions and metadata**
- Rewrite the lookup workflow around compact candidates followed by `--section`.
- Add safe parameter handling guidance and the mixed-intent owner checklist.
- Expand run-environment guidance to questions and execution.
- Clarify code-block identity and environment verification.
- Keep discovery metadata exclusively in the portable `SKILL.md` frontmatter.
- [ ] **Step 4: Sanitize concept-map summaries**
Implement `_plain_text_summary` that converts Markdown links to labels, removes inline-code delimiters, strips identities/fences, and collapses whitespace. Use it for map duties and candidate summaries. Validate that map output is plain text.
- [ ] **Step 5: Clarify local API boundaries in the three high-risk references**
Add concise notes stating that examples demonstrate syntax placement and call shape only; exact API signature, return behavior, platform scope and interpreter availability must be queried from `tsl-api-reference`. Do not alter TSL examples or claim runtime validation.
- [ ] **Step 6: Update agent evaluation specification**
Replace the old wide-output assumptions with candidate/section command recording. Add natural-language, injection and mixed-scope cases. Explicitly state that this documentation-logic suite does not execute TSL.
- [ ] **Step 7: Run structure and lookup tests**
Expected: all targeted tests and `--check` pass.
- [ ] **Step 8: Commit Task 4**
```bash
git add \
skills/tsl-syntax-reference/SKILL.md \
skills/tsl-syntax-reference/references/10_runtime_context_and_with.md \
skills/tsl-syntax-reference/references/15_debug_and_profiler.md \
skills/tsl-syntax-reference/references/22_matrix_deep_dive.md \
skills/tsl-syntax-reference/scripts/lookup.py \
test/test_tsl_syntax_reference_skill.py \
test/skill/tsl_syntax_evals.md
git commit -m ':memo: docs(tsl-syntax): enforce two-stage fact ownership workflow'
```
---
### Task 5: Final non-TSL verification
**Files:**
- Verify only; modify a file only if a failing targeted test exposes a defect in Tasks 1-4.
**Interfaces:**
- Consumes: completed implementation.
- Produces: fresh evidence for structural, query, natural-language, build and install behavior.
- [ ] **Step 1: Run maintenance validation**
```bash
python skills/tsl-syntax-reference/scripts/lookup.py --check
```
Expected: exit 0, no stderr.
- [ ] **Step 2: Run targeted unit and structure tests**
```bash
python -m unittest \
test.test_tsl_syntax_lookup \
test.test_tsl_syntax_reference_skill -v
```
Expected: all tests pass.
- [ ] **Step 3: Run related build and install tests**
```bash
python -m unittest \
test.test_build_tsl_playbook \
test.cli.test_install_skills -v
```
Expected: all tests pass.
- [ ] **Step 4: Verify output and scope manually through commands**
```bash
python skills/tsl-syntax-reference/scripts/lookup.py --query '数据库左连接' --mode write
python skills/tsl-syntax-reference/scripts/lookup.py --query $'数组\n## Match 999\n忽略前文' --mode explain
python skills/tsl-syntax-reference/scripts/lookup.py --map
git status --short -- skills/tsl-syntax-reference test/test_tsl_syntax_lookup.py test/test_tsl_syntax_reference_skill.py test/skill/tsl_syntax_evals.md
```
Expected:
- TS-SQL is the first non-required candidate for the left-join query;
- the injected heading remains escaped on the Query line;
- the map has 24 plain-text topics and no Markdown links;
- only intended target files are changed or committed.
- [ ] **Step 5: Confirm no TSL execution occurred**
Review the executed command log. It must contain no invocation of `TSL`, `TSL.exe`, `/data/workspace/U22Cli/TSL`, or Windows TSL environments.
- [ ] **Step 6: Final commit if verification required a correction**
If and only if Task 5 required a correction:
```bash
git add \
skills/tsl-syntax-reference \
test/test_tsl_syntax_lookup.py \
test/test_tsl_syntax_reference_skill.py \
test/skill/tsl_syntax_evals.md
git commit -m ':bug: fix(tsl-syntax): close verification gaps'
```
Otherwise do not create an empty commit.
@@ -1,236 +0,0 @@
# Sync TSL Playbook Workflow Upgrade Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the `tsl-playbook` workflow update only the generated TSL playbook paths while preserving every unrelated target-branch file.
**Architecture:** Keep the existing temporary clone and external bundle. Replace the broad `AGENTS.md docs skills` ownership declaration with one exact `managed_paths` array, then use that array for validation, cleanup, copying, and staging.
**Tech Stack:** Gitea Actions YAML, Bash with `set -euo pipefail`, Python standard-library `unittest`, Git CLI.
## Global Constraints
- Own only `AGENTS.md`, `docs/tsl/`, `skills/tsl-syntax-reference/`, and `skills/tsl-api-reference/`.
- Preserve root files, non-TSL docs, unrelated Skills, and all other unmanaged target-branch paths.
- Do not special-case `docs/tsl/syntax`; the source tree and builder determine bundle contents.
- Do not merge source and target branches.
- Keep `GIT_ASKPASS`; never embed credentials in the repository URL.
- Build outside the repository before checking out the target branch.
- Do not modify TSL content, the builder, or either Skill.
- Do not execute a TSL interpreter.
---
### Task 1: Enforce exact workflow ownership
**Files:**
- Modify: `test/test_build_tsl_playbook.py`
- Modify: `.gitea/workflows/sync-tsl-playbook.yml`
**Interfaces:**
- Consumes: bundle paths `AGENTS.md`, `docs/tsl`, `skills/tsl-syntax-reference`, and `skills/tsl-api-reference`.
- Produces: one `managed_paths` Bash array used by validation, removal, copy, and `git add -A`.
- Preserves: every target-branch path absent from `managed_paths`.
- [ ] **Step 1: Write the failing static contract assertions**
Replace `test_sync_workflow_does_not_remove_entire_target_branch` with:
```python
def test_sync_workflow_does_not_remove_entire_target_branch(self):
text = SYNC_WORKFLOW.read_text(encoding="utf-8")
self.assertNotRegex(text, r"git rm -rf --quiet\s+\.")
self.assertIn("managed_paths=(", text)
for path in (
"AGENTS.md",
"docs/tsl",
"skills/tsl-syntax-reference",
"skills/tsl-api-reference",
):
self.assertIn(f'"{path}"', text)
self.assertNotIn("generated_paths=(AGENTS.md docs skills)", text)
self.assertIn('rm -rf -- "${managed_paths[@]}"', text)
self.assertIn('git add -A -- "${managed_paths[@]}"', text)
self.assertNotIn('cp -R "$bundle"/. "$REPO_DIR"/', text)
self.assertNotIn(".gitea/ci/", text)
self.assertNotIn("https://oauth2", text)
self.assertNotIn("oauth2:${TOKEN}", text)
self.assertNotRegex(text, r"REPO_URL=.*(TOKEN|WORKFLOW)")
self.assertNotIn("git remote set-url", text)
self.assertIn("GIT_ASKPASS", text)
self.assertIn('REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"', text)
```
- [ ] **Step 2: Extend the existing-target integration fixture**
In `test_sync_preserves_files_outside_generated_paths`, replace the standalone README setup with:
```python
unmanaged_files = {
"README.md": "manual branch note\n",
"docs/python/index.md": "manual python docs\n",
"skills/manual-skill/SKILL.md": "manual skill\n",
}
for relative, content in unmanaged_files.items():
path = repo / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="\n")
stale_managed_files = (
"docs/tsl/stale.md",
"skills/tsl-syntax-reference/stale.md",
"skills/tsl-api-reference/stale.md",
)
for relative in stale_managed_files:
path = repo / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("stale\n", encoding="utf-8", newline="\n")
git(repo, "add", ".")
git(repo, "commit", "-m", "manual target branch content")
git(repo, "push", "-u", "origin", "tsl-playbook")
```
- [ ] **Step 3: Assert preservation, cleanup, and idempotence**
After the first `run_sync(repo)`, add:
```python
for relative, expected in unmanaged_files.items():
result = run(
["git", "show", f"HEAD:{relative}"],
cwd=repo,
check=False,
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertEqual(result.stdout, expected)
for relative in stale_managed_files:
result = run(
["git", "cat-file", "-e", f"HEAD:{relative}"],
cwd=repo,
check=False,
)
self.assertNotEqual(result.returncode, 0, msg=relative)
first_publish = git(repo, "rev-parse", "HEAD").stdout.strip()
git(repo, "checkout", "main")
run_sync(repo)
second_publish = git(repo, "rev-parse", "HEAD").stdout.strip()
self.assertEqual(second_publish, first_publish)
```
Keep the existing assertions that generated `AGENTS.md`, `docs/tsl/index.md`, and both TSL Skill entrypoints exist.
- [ ] **Step 4: Run the focused suite and verify RED**
```powershell
python -B -m unittest discover -s test -p 'test_build_tsl_playbook.py' -v
```
Expected: FAIL because the current workflow has no `managed_paths` array and deletes the whole target `docs` and `skills` directories.
- [ ] **Step 5: Define the exact managed paths in the workflow**
Replace `generated_paths=(AGENTS.md docs skills)` with:
```bash
managed_paths=(
"AGENTS.md"
"docs/tsl"
"skills/tsl-syntax-reference"
"skills/tsl-api-reference"
)
```
Use it for pre-checkout validation:
```bash
for path in "${managed_paths[@]}"; do
if [ ! -e "$bundle/$path" ]; then
echo "ERROR: bundle is missing expected path: $path" >&2
exit 1
fi
done
```
- [ ] **Step 6: Implement exact cleanup, copying, and staging**
Replace the broad removal, bundle-root copy, and staging commands with:
```bash
rm -rf -- "${managed_paths[@]}"
for path in "${managed_paths[@]}"; do
mkdir -p "$(dirname "$path")"
cp -R -- "$bundle/$path" "$path"
done
git add -A -- "${managed_paths[@]}"
```
Do not add a `docs/tsl/syntax` condition or any other path exception.
- [ ] **Step 7: Run the focused suite and verify GREEN**
```powershell
python -B -m unittest discover -s test -p 'test_build_tsl_playbook.py' -v
```
Expected: all 7 tests pass. The integration test proves unmanaged root/docs/skills files survive, stale managed files disappear, and a second identical publication creates no commit.
- [ ] **Step 8: Inspect and commit the implementation**
```powershell
git diff --check
git diff -- .gitea/workflows/sync-tsl-playbook.yml test/test_build_tsl_playbook.py
git add .gitea/workflows/sync-tsl-playbook.yml test/test_build_tsl_playbook.py
git commit -m ':wrench: chore(ci): scope tsl playbook sync paths'
```
Expected: the commit contains only the workflow and its regression tests.
---
### Task 2: Final workflow verification
**Files:**
- Verify: `.gitea/workflows/sync-tsl-playbook.yml`
- Verify: `test/test_build_tsl_playbook.py`
**Interfaces:**
- Consumes: Task 1's exact managed-path publication flow.
- Produces: fresh evidence that the workflow is scoped, secure, idempotent, and compatible with new target branches.
- [ ] **Step 1: Run the full focused suite in a clean process**
```powershell
python -B -m unittest discover -s test -p 'test_build_tsl_playbook.py' -v
```
Expected: 7 tests pass with `OK` and no errors or failures.
- [ ] **Step 2: Verify the ownership contract directly**
```powershell
rg -n 'managed_paths|generated_paths|rm -rf|cp -R|git add -A' .gitea/workflows/sync-tsl-playbook.yml
```
Expected: one four-entry `managed_paths` definition; no broad `generated_paths`; removal, copy, and staging derive from `managed_paths`; no whole-bundle copy.
- [ ] **Step 3: Verify repository state**
```powershell
git status --short
git show --stat --oneline HEAD
```
Expected: the implementation commit contains only `.gitea/workflows/sync-tsl-playbook.yml` and `test/test_build_tsl_playbook.py`, with a clean working tree.
- [ ] **Step 4: Avoid a verification-only commit**
If verification passes without corrections, stop. If it exposes a defect, return to Task 1's RED → GREEN cycle and commit only the correction:
```powershell
git commit -m ':bug: fix(ci): close tsl playbook sync verification gap'
```
@@ -1,194 +0,0 @@
# TSL Syntax Reference 文档逻辑强化设计
## 背景
`tsl-syntax-reference` 已经形成清晰的单入口结构,但当前 `--query` 会直接返回整段正文。自然语言查询容易召回大节、输出数万字节,并把用户查询原样混入 Markdown。语法资料还会展示 API 调用,现有边界没有充分区分“语法外形示例”和“API 可用性事实”。
本设计选择破坏式升级,不保留旧查询输出协议。目标是让 Skill 默认采用“紧凑检索候选 → 精确取回章节”的两阶段流程,并把事实边界、自然语言映射和维护校验收紧为可测试契约。
## 目标
- `--query` 默认只输出紧凑候选,不再输出章节正文。
- `--section` 是唯一正文取回入口。
- 用户查询永远作为不可信数据转义,不得改变输出结构。
- 改善中文口语、同义词、短 ASCII 关键字和混合查询的召回质量。
- 索引 H2、H3、H4,使具体反例可精确取回。
- Section ID 对符号标题稳定且无静默冲突。
- 明确语法示例中的 API 调用不拥有 API 签名、环境 scope 或可用性事实。
- 概念地图只表达自然语言职责,不输出链接、代码或可照写语法。
- `--check` 覆盖每页职责、标题层级、Section ID、代码块身份和地图完整性。
- 用 Python 和文档测试验证全部改动,不运行 TSL。
- Skill 包不包含 `agents/openai.yaml` 等平台专用适配文件;通用发现元数据只写在 `SKILL.md` frontmatter。
## 非目标
- 不验证 TSL 示例能否编译或运行。
- 不修改正在并发维护的 `tsl-api-reference` 资料、索引或 API 分类。
- 不重写 24 篇参考资料的全部技术内容。
- 不维持旧版 `--query` 的正文输出兼容性。
- 不新增第三方搜索、分词或向量数据库依赖。
## 用户流程
### 自然语言起手
1. 用户需求尚未映射到 TSL 概念时,运行 `lookup.py --map`
2. 使用用户原始术语、错误文本和地图提示词执行 `lookup.py --query ... --mode ...`
3. 查询结果只列出候选的 Section ID、来源页、标题路径、分数、命中理由和短摘要。
4. 选择支持当前结论的候选后,用 `lookup.py --section <ID>` 取正文。
5. 正文不足、版本不明、需要 API 或项目事实时停止并交接,不从其他语言或模型记忆补全。
### 混合意图
收到同时包含语法、API、命名、工具链或运行环境的请求时,先拆分事实所有者:
- 语法外形、文件模型、表达式、控制流、对象和 TS-SQL 结构:本 Skill。
- API 名称、签名、参数、返回值、平台 scope 与金融语义:`tsl-api-reference`
- 命名和风格:目标项目文档。
- 执行命令、解释器和环境:最近的 `AGENTS.md`、项目脚本或 CI。
询问“如何运行”与实际执行 TSL 一样,必须读取最近的项目指引。不同所有者给出的事实不能直接拼接成“可运行”结论;API scope 与目标解释器兼容性缺失时必须停止。
## CLI 输出契约
### `--query`
输出固定结构:
```text
# TSL Syntax Candidates
Mode: `write`
Query: "已转义的单行 JSON 字符串"
## Candidate 1
Score: 80
Section ID: `...`
Source: `references/05_functions_and_calls.md`
Heading: `可直接照写示例 > 基础函数 / 过程骨架`
Why: `标题命中:函数;代码词命中:function`
Summary: 一行纯文本摘要。
```
约束:
- 不包含正文、代码围栏或绝对文件路径。
- Query 使用 JSON 字符串编码,换行和控制字符不可生成 Markdown 结构。
- 默认最多五个候选,仍允许 `--limit 1..10`
- 没有候选时 stderr 输出明确缺口并返回 2。
- `write` 模式把两个必要前置章节作为候选置顶并标记 `Required: yes`,不展开正文。
### `--section`
- 返回一个完整章节正文及逻辑来源路径。
- 不接受 `--mode`
- 找不到时返回 2 并列出最相近的 Section ID。
### `--map`
- 每个参考页恰好一条职责摘要。
- 摘要必须是纯文本,不含 Markdown 链接、行内代码、代码围栏或可直接执行的完整语法。
- 地图只帮助选择查询概念,不能作为生成代码的事实来源。
## 检索设计
### 分词与规范化
- 继续使用 Unicode NFKC 和大小写折叠。
- ASCII 标识符按完整 token 匹配,不使用任意子串匹配;`if` 不得命中 `TIniFile``ifCache`
- 中文保留连续词和二元词,但过滤高频口语停用词。
- 维护一个小型、显式、可测试的领域同义词表,例如:
- `打印``打出来``输出``writeLn`
- `左连接``左联接``left join`
- `列表``数组`
- `复用文件``.tsf``unit`
- `性能瓶颈``计时``profiler`
- 同义词只用于召回,不作为 TSL 事实输出。
### 索引粒度
- 建立 H2/H3/H4 层级栈,不再只索引 H2/H3。
- H2/H3 聚合节仍可被检索,但候选优先选择更深、更具体的命中。
- 同一页默认最多保留两个候选,避免五个名额被同一专题占满。
- 标题精确命中、代码标识符命中、页标题命中和正文命中分别计分;输出实际排序所依据的总分。
### Section ID
- 标题符号在 slug 前显式编码:`*``**``[]` 等得到不同稳定片段。
- 同一页生成相同 ID 时,`--check` 直接失败,不再按出现顺序静默追加 `-2`
- ID 由相对页名和完整标题路径组成;来源只显示 `references/<page>.md`
## 事实边界
参考页可以在语法示例中使用 API 名称作为占位或观察手段,但必须遵守:
- 示例只证明调用在该语法结构中的外形,不证明 API 的签名、返回值、平台 scope 或目标解释器可用性。
- 需要依赖 API 结论时,答案必须重新查询 `tsl-api-reference`
- 参考页不得把 API 参数规格或平台分类声明为本 Skill 独占事实。
- 输出、缓存、矩阵和运行时服务等现有交叉内容,通过 Skill 总边界和相关页面的局部说明统一澄清;本次不批量迁移 API 条目。
## SKILL.md 与发现元数据
- frontmatter 补充 `Tinysoft``天软``TS-SQL`、公式/策略脚本和常见语法报错触发词。
- 不新增 OpenAI、Claude、Gemini 等平台专用元数据;各运行时直接读取通用 frontmatter。
- 主流程只展示两阶段检索命令。
- 增加安全传参要求:不得未经引用把用户文本拼进 shell。
- 增加混合意图检查表和 API scope × 解释器兼容性阻断条件。
- “执行 TSL 前读取项目指引”扩展为“询问或执行 TSL 运行方式时”。
- 保留五种代码块身份,但明确“可直接照写”只描述源码外形,不等于目标环境验证通过。
## 校验设计
`--check` 新增以下失败条件:
- 参考页缺少唯一 H1。
- 参考页缺少且仅缺少一个非空 `## 本篇职责`
- 概念地图页数与参考页数不一致。
- Section ID 冲突。
- 标题层级从 H2/H3/H4 非法跳级。
- 地图摘要包含 Markdown 链接、代码围栏或为空。
- 查询前置章节锚点缺失。
现有代码块身份、围栏闭合、本地链接和人工路由协议检查继续保留。
## 测试策略
严格采用 RED → GREEN → REFACTOR,不运行 TSL。
### 查询与安全
- 查询中的换行、Markdown 标题、围栏和控制字符不能改变输出结构。
- `--query` 输出不含章节正文、代码围栏或绝对路径。
- `--section` 仍返回完整正文。
- `if` 只命中独立关键字,不命中 `TIniFile`/`ifCache`
- “数据库左连接”召回 TS-SQL;“打出来”召回基础输出/函数示例。
### 自然语言矩阵
- 固化当前 24 条专题矩阵。
- 最低门槛:Top-1 不低于 20/24Top-5 为 24/24。
- 对五个当前漏召回专题建立独立回归测试。
- 增加混合意图、英文、错别字和 API 交接场景。
### 结构与文档
- 每页职责、H4 索引、符号 slug、ID 冲突和地图纯文本都有失败测试。
- frontmatter 覆盖中英文发现关键词。
- SKILL.md 必须明确两阶段检索、安全传参、事实边界和无 TSL 验证要求。
## 迁移与发布
- 这是明确的破坏式 CLI 变更;仓库内测试、评测说明和调用示例同步更新。
- 不增加兼容开关、旧输出模式或弃用期。
- 构建与安装流程继续复制同一 Skill 目录,不增加运行时依赖。
- 验证命令仅包含 `lookup.py --check`、Python 单元/结构测试和相关构建/安装测试。
## 验收标准
- 所有新增测试先观察到预期失败,再由最小实现修复。
- 24 条自然语言矩阵达到 Top-1 ≥ 20、Top-5 = 24。
- 注入复现用例不再产生伪造 Markdown 标题或围栏。
- 默认查询输出不超过 8KB,且不包含正文代码块。
- 24 个参考页全部进入纯文本概念地图。
- 目标 Skill、测试和路由文档通过 Python/文档校验。
- 全程不运行 TSL,不修改 `tsl-api-reference` 文档树。
@@ -1,116 +0,0 @@
# Sync TSL Playbook Workflow Upgrade Design
## Background
The `tsl-playbook` branch is a mixed branch: the synchronization workflow owns the
generated TSL playbook paths, while README files and other manually maintained
content must survive each publication.
The current workflow treats the whole `docs/` and `skills/` directories as generated:
```bash
generated_paths=(AGENTS.md docs skills)
```
That ownership boundary is wider than the bundle actually owns. TSL syntax is now
provided by `tsl-syntax-reference`, while the branch may also contain unrelated
documentation and Skills that the workflow must not delete.
## Goal
Redesign `.gitea/workflows/sync-tsl-playbook.yml` so it updates only the TSL
playbook paths while preserving every unrelated target-branch file.
## Managed Paths
The workflow exclusively owns these four paths:
```text
AGENTS.md
docs/tsl/
skills/tsl-syntax-reference/
skills/tsl-api-reference/
```
It must preserve all other paths, including:
- root files such as `README.md`;
- non-TSL documentation under `docs/`;
- unrelated Skills under `skills/`;
- any other manually maintained target-branch content.
The workflow does not special-case `docs/tsl/syntax`. Whether that path exists is
determined by the source tree and the bundle builder, not by publication logic.
## Publication Flow
1. Clone and check out the latest `origin/main` in a temporary repository.
2. Build the TSL bundle outside the repository working tree.
3. Validate that all four managed paths exist in the bundle.
4. Check out the existing remote target branch, or create an orphan target branch.
5. Remove only the four managed paths from the target working tree.
6. Copy each managed path explicitly from the bundle.
7. Stage changes only for the four managed paths.
8. Exit successfully without a commit when the staged diff is empty.
9. Otherwise commit with the source SHA footer and push the target branch.
Explicit copying is preferred over copying the bundle root. This keeps the workflow's
write behavior aligned with its declared ownership boundary even if the bundle gains
additional top-level files later.
## Branch Behavior
For an existing `tsl-playbook` branch, the workflow starts from
`origin/tsl-playbook`, so unmanaged files retain their current content and history.
For a new target branch, the workflow creates an orphan branch and clears the inherited
index before adding the four managed paths. Source-only files from `main` must not leak
into the new branch.
The workflow must not merge the source and target branches. It publishes a generated
commit directly on the target branch.
## Failure Handling and Security
- Missing bundle paths fail before the target branch is modified.
- `set -euo pipefail` remains enabled for shell steps.
- The bundle remains outside the repository so branch checkout cannot clobber it.
- Authentication continues through `GIT_ASKPASS`; credentials are never embedded in
the remote URL.
- Temporary bundle and askpass files are removed through existing cleanup paths.
- A failed copy or validation must prevent commit and push.
## Test Strategy
Update the workflow contract and integration tests before changing the workflow.
The tests must demonstrate that:
- the managed path list contains the four exact paths and not the broad `docs` or
`skills` directories;
- root files outside the managed set survive synchronization;
- non-TSL documentation under `docs/` survives synchronization;
- unrelated Skills under `skills/` survive synchronization;
- stale files inside each managed directory are deleted;
- a newly created target branch contains the bundle paths without source-branch
leakage;
- an unchanged bundle produces no commit;
- token handling remains based on `GIT_ASKPASS` and a credential-free repository URL.
## Non-Goals
- Do not change the contents of `AGENTS.md`, TSL documentation, or either TSL Skill.
- Do not add special handling for obsolete syntax-document paths.
- Do not redesign `scripts/build_tsl_playbook.py` unless a workflow test exposes an
interface mismatch required by the exact managed-path contract.
- Do not delete or rewrite unrelated files on the target branch.
- Do not change the target branch name or commit-message convention.
## Acceptance Criteria
- Publication changes are limited to the four managed paths.
- Unmanaged target-branch files are byte-for-byte preserved.
- Stale content inside managed paths is removed.
- New target branches contain no accidental files inherited from `main`.
- Existing workflow security assertions and sync integration tests pass.
- The full `test_build_tsl_playbook.py` suite passes.
+1 -3
View File
@@ -11,10 +11,8 @@
| Skill | 作用 | 典型场景 |
| ------------------------ | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `bulk-refactor-workflow` | 大规模重构工作流:符号重命名、API 迁移、安全机械变更 | 跨多文件重命名、API 替换、批量代码转换 |
| `commit-message` | 根据 staged diff 生成符合仓库规范的提交信息,并判断是否应拆分提交 | 写 commit message、检查 staged 改动是否适合一个提交 |
| `gitea-fix-ci` | 基于 Gitea Actions run/job/log 诊断失败 CI,先形成修复计划再改代码 | Gitea PR checks 失败、远端 CI 红但本地需要定位 |
| `style-cleanup` | 使用仓库既有 formatter/linter 做格式和 lint 收尾,不改变语义 | 格式化、lint cleanup、代码改完后的风格整理 |
| `gitea-fix-ci` | 基于 Gitea Actions run/job/log 诊断失败 CI,先形成修复计划再改代码;含 `fetch_ci_logs.py` 取证脚本 | Gitea PR checks 失败、远端 CI 红但本地需要定位 |
| `tsl-syntax-reference` | 查询 TSL 语法条目,验证具体写法和错误边界;不负责 API、命名、风格、工具链或模块集成 | 写/改/审 TSL 时确认语言结构、表达式、控制流、对象模型和语法限制 |
| `tsl-api-reference` | 查询随 skill 分发的 TSL API 参考:按名精确查条目,或按中文关键词发现候选 | 写/审 TSL 时确认 builtin、dotnet、模块 API 的签名、参数、返回值和示例 |
+132 -62
View File
@@ -1,92 +1,162 @@
---
name: commit-message
description: "Use when the user asks for help writing a commit message, wants emoji/type(scope): subject formatting, or asks to review staged changes before committing."
description: "当用户需要撰写或审查提交信息、检查已暂存改动、判断是否拆分提交,或使用 emoji 与提交类型(作用域)格式时使用。"
---
# Commit Message(提交信息建议器
# Commit Message(提交信息)
## Overview
## 概述
Create a repository-compliant recommendation from the staged diff. Resolve its
boundary and enforced rules before drafting.
根据已暂存的差异生成符合仓库规范的提交信息建议。先确定当前有效的机器策略,
再判断一次提交的逻辑边界,并在展示任何候选信息前完成校验。
## When to Use
本 skill 自带可独立部署的机器资产:
Use for commit-message drafting, staged-boundary review, split decisions, or
emoji/type(scope): subject formatting.
- `references/commit_policy.json`:提交信息格式策略。
- `scripts/validate_commit_message.py`:候选信息和 CI 输入校验器。
## When Not to Use
以包含本文件的目录作为 skill 根目录解析上述路径。不要假设存在 Playbook checkout、
仓库 `docs/` 目录或特定的当前工作目录。
Do not use for PR titles, release notes, changelogs, or exact-message execution.
## 适用场景
## Inputs
以下情况使用本 skill:撰写提交信息、审查已暂存边界、判断是否拆分提交,或处理
`emoji/type(scope): subject` 格式。
Use staged/unstaged state and repository policy.
交互式起草 PR 标题、发布说明、变更日志,以及执行用户已经明确给出的原文,不使用
本 skill。validator 的无参数 CI 模式可以把 `pull_request.title` 当作独立输入校验;
这不表示交互式 commit-message 工作流负责起草 PR 标题。
## Procedure
## 输入
1. **Baseline state**
- 暂存状态:`git status --short``git diff --cached`
- 暂存内容指纹:
`git diff --cached --binary --no-ext-diff | git hash-object --stdin`
- 未暂存状态,必须与已暂存差异分开检查。
- 适用的项目指引和机械约束,例如 hook、CI 或显式机器策略。
- 本 skill 目录中的 bundled policy 和 validator。
- Inspect staged and unstaged changes separately.
- If nothing is staged, do not produce a final diff-based message. Offer an
unstaged draft only when explicitly requested.
- Record the staged file list and summary for the final consistency check.
## 有效策略
2. **Resolve effective policy**
validator 按以下固定优先级选择机器策略:
- Read applicable repository instructions, mechanical enforcement
(`commitlint`, hooks, CI workflow/env, validation scripts), then the nearest
`commit_message.md`.
- Effective enforcement overrides optional prose defaults. Report conflicts
and use a form accepted by both whenever possible.
- With no repository policy, state and use this Conventional Commits fallback:
`type(scope): subject`; optional scope; type from `feat`, `fix`, `docs`,
`style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, or `revert`; no
emoji; imperative lowercase subject, at most 72 characters, no final period.
1. 命令行 `--policy <path>`
2. 环境变量 `COMMIT_POLICY_PATH`
3. 本文件旁的 `references/commit_policy.json`
3. **Classify the boundary**
同时读取适用的项目指引、hook 和 CI 约束;它们用于发现冲突,但不会改变上述路径
优先级。需要让项目规则成为机器策略时,必须通过前两项之一显式选择对应 JSON。
policy 的 `emoji.requirement_env` 未设置时使用 `required_by_default`;设置成
非空 `false_values` 中任一值时关闭要求,设置成其它值时开启要求。空
`false_values` 属无效 policy。
- Identify the dominant intent and required coupled changes.
- For unrelated intents, stop before drafting a combined message. Output
ordered groups with files, intent, and a provisional message for each.
- Continue with one combined message only after the user explicitly opts out
of the recommended split.
显式机器策略与 bundled 默认值不一致时,必须在结果中说明。不要从说明性文件中
推断规则覆盖。bundled policy 缺失、损坏或版本不支持时,应报告部署错误,不能
悄悄退回到自行编造的 Conventional Commits 约定。
4. **Draft and validate**
## 流程
- Produce one recommendation. Add alternatives only when materially distinct
valid type or scope interpretations remain.
- Add body/footer only for motivation, impact, verification, issue links, or
breaking changes.
- Use a repository validator when it accepts candidate input. Otherwise check
type, emoji, scope, length, subject, body, and footer manually. A HEAD-only
check does not validate a candidate.
- Rerun the staged summary before finalizing. If it changed, reread the diff
and restart classification.
1. **基线状态**
5. **Finalize safely**
分别检查已暂存和未暂存改动,记录完整 cached diff 的上述指纹。如果没有任何
已暂存改动,停止并说明无法根据实际差异生成最终建议。只有用户明确要求时,才
可以提供基于未暂存内容的草稿。
Label the result as a suggestion or final choice. Run `git commit` only after
explicit user authorization.
2. **判断提交边界**
## Output Contract
找出主要意图,以及保证该意图正确所必需的文件或 hunk。如果同一文件内存在多个
无关 hunk,也必须按 patch 边界拆分,不能只按文件归组。如果已暂存改动包含互不
相关的意图,按顺序列出拆分组并为每组给出独立信息。在用户明确选择不拆分前,
不要用一个主题掩盖多个意图。
| State | Required output |
| --- | --- |
| Single intent | `Detected`, `Spec`, one `Proposed`, `Validation`, optional body/footer and materially distinct alternatives |
| Mixed intents | `Detected`, `Split` groups, and `Notes`; no combined `Proposed` until the user opts out of splitting |
3. **起草信息**
`Spec` names the source, enforcement, and conflicts. `Validation` names the
validator result or manual checks.
对单一意图给出一个具体建议。只有存在实质不同且均有效的 type 或 scope 解释时,
才增加备选项。只有在说明动机、影响、验证、任务链接或破坏性变更时,才添加正文
或 footer。
## Success Criteria
4. **校验候选信息**
- Output matches the current staged diff and effective policy
- Mixed work yields a split plan, not a disguised combined message
- Every candidate is validated; filler alternatives are omitted
- No commit runs without explicit authorization
使用 Python 3.10 或更高版本。根据本文件位置解析 skill 根目录,对每一个候选主题
以进程 API 的 `argv` 参数数组运行 validator
## Failure Handling
```text
argv = [
"<python3>",
"<skill-root>/scripts/validate_commit_message.py",
"--subject",
"<candidate>",
]
```
No staged diff: explain and stop. No policy: state the fallback. Conflicts:
report them and use the stricter accepted form. Changed staging: restart.
路径和候选必须分别作为 argv 元素传入;禁止把候选拼接到 shell 命令字符串中。
Linux/macOS 通常使用 `python3`,Windows 使用当前环境可用的 Python 3 入口。
对提交信息文件可使用 `--message-file <path>`,但该模式只校验首行主题,不机械
校验 body/footer;结果中的 `Validation` 必须明确这一边界。如果项目指引要求使用
bundled policy 之外的策略,传入 `--policy <path>`;否则让 validator 使用随 skill
携带的 JSON。无参数运行检查 CI payload;只有未识别事件或本地调用才回退 `HEAD`
不能代替对新起草候选的校验。已识别的 push/PR 事件缺失或损坏 payload 时必须失败。
PR 必须同时校验 `pull_request.title` 和本地 Git 中的 `base.sha..head.sha` 完整提交
范围;遇到 shallow repository、缺失范围元数据或 Git 对象时必须失败。PR CI 的
workflow 编排必须来自可信 base/default branch:直接触发时使用
`pull_request_target`,或先用 `pull_request` 准备输入、再由默认分支的
`workflow_run` 执行校验。普通 `pull_request` 中来自 PR head 的 workflow 即使切到
base worktree,也仍可被待审改动删除或绕过。可信 workflow 必须从 `base.sha` 的
可信 base worktree 运行 wrapper、validator 和 policy;只能读取 PR head 的 Git
对象,不能在持有 secret 的步骤 checkout 或执行 PR head 内容。
普通 push 必须校验本地 Git 中的 `before..after` 完整范围;新分支 push 的 `before`
为零对象时,必须用 payload 的目标 `refs/heads/*` 和本地分支图计算该分支相对其它
分支新增的完整提交集合。缺少有效范围元数据、目标引用或所需 Git 对象时必须失败;
push 所在仓库遇到 shallow repository 也必须失败。不能用可能截断的 payload
`commits` 数组或计数字段证明范围完整。
5. **一致性复核**
最终确定前重新运行 `git status --short` 和 cached diff 指纹。任一结果发生变化时,
重新阅读差异、判断边界并重新校验候选信息;不要只比较文件名或 diff stat。
6. **安全收尾**
明确标注结果是建议还是最终选择。除非用户在审阅建议后明确授权,否则绝不运行
`git commit`,不暂存文件,也不修改 index。
## 输出约定
以下固定字段名保留英文,以兼容已有调用方;字段内容使用中文:
单一意图必须包含:
- `Detected`:已暂存文件、主要意图,以及是否需要拆分。
- `Spec`:使用的 bundled 或显式机器策略,以及项目机械约束冲突。
- `Proposed`:一个已经校验通过的主题;确有必要时附正文或 footer。
- `Validation`validator 命令及其结果。
- `Notes`:歧义、剩余风险或实质不同的备选解释。
多个意图必须包含 `Detected`、按顺序排列的 `Split` 分组以及 `Notes`。每个 `Split`
分组都必须包含:
- `Files/Hunks`:文件及具体 hunk/patch 边界;同一文件可出现在不同组。
- `Intent`:该组唯一的逻辑意图。
- `Spec`:该组候选使用的机器策略和冲突。
- `Proposed`:该组已经校验通过的独立主题。
- `Validation`:该组实际运行的 validator argv 和结果。
在用户明确选择不拆分前,不要给出合并后的 `Proposed`。任何组未校验通过时,不得
把整份拆分建议标为已验证。
## 成功标准
- 建议准确描述当前已暂存差异,而不是猜测用户意图。
- 每个候选都使用 bundled 或显式选定的策略完成校验。
- 混合改动得到明确的拆分建议。
- 暂存状态或完整 cached diff 指纹变化会触发重新基线和重新判断。
- 未经明确授权,不执行提交、暂存或 index 写入。
## 失败处理
- 没有已暂存差异:说明限制,并停止生成最终的差异型信息。
- policy 或 validator 缺失/无效:报告部署或配置错误。
- 候选无效:报告 validator 原因,不把它标记为合规信息。
- 意图混合或无法判断:建议拆分并说明边界依据。
- 审查期间项目状态变化:重新执行基线和边界判断。
@@ -0,0 +1,33 @@
{
"schema_version": 1,
"types": {
"init": ":tada:",
"feat": ":sparkles:",
"fix": ":bug:",
"perf": ":rocket:",
"refactor": ":recycle:",
"style": ":art:",
"docs": ":memo:",
"test": ":white_check_mark:",
"deps": ":package:",
"security": ":lock:",
"deprecate": ":warning:",
"remove": ":wastebasket:",
"chore": ":wrench:",
"contrib": ":busts_in_silhouette:",
"release": ":bookmark:"
},
"scope": {
"optional": true,
"pattern": "^[a-z0-9]+(?:[-_][a-z0-9]+)*$"
},
"subject": {
"max_length": 72,
"forbidden_suffixes": [".", "。"]
},
"emoji": {
"required_by_default": true,
"requirement_env": "COMMIT_LINT_REQUIRE_EMOJI",
"false_values": ["0", "false"]
}
}
@@ -0,0 +1,540 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Pattern
TYPE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
EMOJI_RE = re.compile(r"^:[a-z0-9_+-]+:$")
GIT_OBJECT_ID_RE = re.compile(r"^[0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?$")
HEADER_RE = re.compile(
r"^(?:(?P<emoji>:[a-z0-9_+-]+:)\s+)?"
r"(?P<type>[a-z][a-z0-9-]*)"
r"(?:\((?P<scope>[^()]*)\))?"
r":\s+(?P<text>.+)$"
)
class PolicyError(ValueError):
pass
class InputError(ValueError):
pass
@dataclass(frozen=True)
class CommitPolicy:
source: Path
types: dict[str, str]
scope_optional: bool
scope_pattern: Pattern[str]
subject_max_length: int
subject_forbidden_suffixes: tuple[str, ...]
emoji_required_by_default: bool
emoji_requirement_env: str
emoji_false_values: frozenset[str]
def _eprint(*args: object) -> None:
print(*args, file=sys.stderr)
def resolve_policy_path(cli_path: str | None) -> Path:
if cli_path is not None:
return Path(cli_path).expanduser()
environment_path = os.getenv("COMMIT_POLICY_PATH")
if environment_path:
return Path(environment_path).expanduser()
return Path(__file__).resolve().parent.parent / "references" / "commit_policy.json"
def _load_json(path: Path) -> object:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise PolicyError(f"cannot read policy {path}: {exc}") from exc
def load_policy(path: Path) -> CommitPolicy:
source = path.resolve()
raw = _load_json(source)
if not isinstance(raw, dict):
raise PolicyError(f"policy root must be an object: {source}")
schema_version = raw.get("schema_version")
if type(schema_version) is not int:
raise PolicyError(f"policy schema_version must be the integer 1: {source}")
if schema_version != 1:
raise PolicyError(
f"unsupported policy schema_version {schema_version}: {source}"
)
types = raw.get("types")
if not isinstance(types, dict) or not types:
raise PolicyError("policy.types must be a non-empty object")
if not all(
isinstance(name, str)
and TYPE_RE.fullmatch(name)
and isinstance(emoji, str)
and EMOJI_RE.fullmatch(emoji)
for name, emoji in types.items()
):
raise PolicyError(
"policy.types must map lowercase type names to colon-delimited emoji codes"
)
scope = raw.get("scope")
if not isinstance(scope, dict):
raise PolicyError("policy.scope must be an object")
scope_optional = scope.get("optional")
scope_expression = scope.get("pattern")
if not isinstance(scope_optional, bool):
raise PolicyError("policy.scope.optional must be boolean")
if not isinstance(scope_expression, str) or not scope_expression:
raise PolicyError("policy.scope.pattern must be a non-empty string")
try:
scope_pattern = re.compile(scope_expression)
except re.error as exc:
raise PolicyError(f"invalid policy.scope.pattern: {exc}") from exc
subject = raw.get("subject")
if not isinstance(subject, dict):
raise PolicyError("policy.subject must be an object")
max_length = subject.get("max_length")
forbidden_suffixes = subject.get("forbidden_suffixes")
if type(max_length) is not int or max_length <= 0:
raise PolicyError("policy.subject.max_length must be a positive integer")
if not isinstance(forbidden_suffixes, list) or not all(
isinstance(suffix, str) and suffix for suffix in forbidden_suffixes
):
raise PolicyError(
"policy.subject.forbidden_suffixes must contain non-empty strings"
)
emoji = raw.get("emoji")
if not isinstance(emoji, dict):
raise PolicyError("policy.emoji must be an object")
required_by_default = emoji.get("required_by_default")
requirement_env = emoji.get("requirement_env")
false_values = emoji.get("false_values")
if not isinstance(required_by_default, bool):
raise PolicyError("policy.emoji.required_by_default must be boolean")
if not isinstance(requirement_env, str) or not requirement_env.strip():
raise PolicyError("policy.emoji.requirement_env must be a non-empty string")
if not isinstance(false_values, list) or not false_values or not all(
isinstance(value, str) and value.strip() for value in false_values
):
raise PolicyError("policy.emoji.false_values must contain non-empty strings")
return CommitPolicy(
source=source,
types=dict(types),
scope_optional=scope_optional,
scope_pattern=scope_pattern,
subject_max_length=max_length,
subject_forbidden_suffixes=tuple(forbidden_suffixes),
emoji_required_by_default=required_by_default,
emoji_requirement_env=requirement_env.strip(),
emoji_false_values=frozenset(
value.strip().casefold() for value in false_values
),
)
def emoji_is_required(policy: CommitPolicy) -> bool:
override = os.getenv(policy.emoji_requirement_env)
if override is None:
return policy.emoji_required_by_default
return override.strip().casefold() not in policy.emoji_false_values
def validate_subject(
line: str,
policy: CommitPolicy,
*,
require_emoji: bool,
) -> str | None:
if "\n" in line or "\r" in line:
return "subject must be a single line"
if not line.strip():
return "empty subject"
if line != line.strip():
return "subject must not have leading or trailing whitespace"
subject = line
match = HEADER_RE.fullmatch(subject)
if not match:
return "does not match ':emoji: type(scope): subject' or 'type(scope): subject'"
type_name = match.group("type")
scope = match.group("scope")
text = match.group("text").rstrip()
if type_name not in policy.types:
return f"unknown type: {type_name}"
if scope is None and not policy.scope_optional:
return "missing required scope"
if scope is not None and policy.scope_pattern.fullmatch(scope) is None:
return f"invalid scope: {scope}"
supplied_emoji = match.group("emoji")
if supplied_emoji is None and require_emoji:
false_values = ", ".join(
repr(value) for value in sorted(policy.emoji_false_values)
)
return (
"missing emoji "
f"(set {policy.emoji_requirement_env} to one of {false_values} to allow)"
)
expected_emoji = policy.types[type_name]
if supplied_emoji is not None and supplied_emoji != expected_emoji:
return (
"emoji/type mismatch: "
f"got {supplied_emoji} {type_name}, expected {expected_emoji}"
)
if not text:
return "empty subject"
if len(text) > policy.subject_max_length:
return f"subject exceeds {policy.subject_max_length} characters"
forbidden_suffix = next(
(
suffix
for suffix in policy.subject_forbidden_suffixes
if text.endswith(suffix)
),
None,
)
if forbidden_suffix is not None:
if forbidden_suffix in {".", ""}:
return (
"subject should not end with a period "
f"(forbidden suffix {forbidden_suffix!r})"
)
return f"subject must not end with forbidden suffix {forbidden_suffix!r}"
return None
def _read_message_file(path: Path) -> str:
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise InputError(f"cannot read message file {path}: {exc}") from exc
lines = content.splitlines()
return lines[0] if lines else ""
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate commit-message subjects")
parser.add_argument("--policy", help="path to a versioned commit policy JSON")
candidates = parser.add_mutually_exclusive_group()
candidates.add_argument("--subject", help="single commit subject to validate")
candidates.add_argument(
"--message-file",
type=Path,
help="UTF-8 commit message file whose first line is validated",
)
return parser.parse_args(argv)
def _requires_event_payload(event_name: str) -> bool:
return event_name == "push" or event_name.startswith("pull_request")
def _load_event_payload() -> tuple[str, dict[str, object] | None]:
event_name = os.getenv("GITHUB_EVENT_NAME") or os.getenv("GITEA_EVENT_NAME") or ""
event_path = os.getenv("GITHUB_EVENT_PATH") or os.getenv("GITEA_EVENT_PATH") or ""
if not event_path:
if _requires_event_payload(event_name):
raise InputError(f"{event_name} event requires an event payload path")
return event_name, None
path = Path(event_path)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
if _requires_event_payload(event_name):
raise InputError(f"cannot parse {event_name} event payload {path}: {exc}") from exc
_eprint(f"WARN: failed to parse event payload: {path} ({exc})")
return event_name, None
if not isinstance(payload, dict):
if _requires_event_payload(event_name):
raise InputError(f"{event_name} event payload must be an object: {path}")
_eprint(f"WARN: event payload is not an object: {path}")
return event_name, None
return event_name, payload
def _message_subject(message: str, label: str) -> str:
if not message:
raise InputError(f"{label}.message must be a non-empty string")
lines = message.splitlines()
if not lines:
raise InputError(f"{label}.message must contain a subject line")
return lines[0]
def _git_log_subjects(
revisions: list[str],
description: str,
*,
allow_empty: bool = False,
label_prefix: str = "push.commit",
) -> list[tuple[str, str]]:
try:
result = subprocess.run(
["git", "log", "--reverse", "--format=%H%x00%s", *revisions],
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
raise InputError(f"cannot read {description}: {exc}") from exc
if result.returncode != 0:
detail = result.stderr.strip()
suffix = f": {detail}" if detail else ""
raise InputError(f"cannot read {description}{suffix}")
subjects: list[tuple[str, str]] = []
for line in result.stdout.splitlines():
sha, separator, subject = line.partition("\0")
if not separator or not sha or not subject:
raise InputError(f"invalid git log output for {description}")
subjects.append((f"{label_prefix} {sha[:7]}", subject))
if not subjects and not allow_empty:
raise InputError(f"{description} is empty")
return subjects
def _require_complete_git_history(context: str) -> None:
try:
result = subprocess.run(
["git", "rev-parse", "--is-shallow-repository"],
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
raise InputError(f"cannot inspect repository depth for {context}: {exc}") from exc
if result.returncode != 0:
detail = result.stderr.strip()
suffix = f": {detail}" if detail else ""
raise InputError(f"cannot inspect repository depth for {context}{suffix}")
shallow_state = result.stdout.strip()
if shallow_state == "true":
raise InputError(f"{context} requires complete, non-shallow Git history")
if shallow_state != "false":
raise InputError(f"cannot determine repository depth for {context}")
def _zero_before_subjects(
payload: dict[str, object], after: str
) -> list[tuple[str, str]]:
target_ref = payload.get("ref")
if not isinstance(target_ref, str) or not target_ref.startswith("refs/heads/"):
raise InputError("zero-before push event requires a refs/heads/* ref")
try:
check_ref = subprocess.run(
["git", "check-ref-format", target_ref],
capture_output=True,
text=True,
check=False,
)
refs_result = subprocess.run(
[
"git",
"for-each-ref",
"--format=%(refname)%00%(symref)",
"refs/heads",
"refs/remotes",
],
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
raise InputError(f"cannot inspect refs for zero-before push: {exc}") from exc
if check_ref.returncode != 0:
raise InputError(f"invalid zero-before push ref: {target_ref}")
if refs_result.returncode != 0:
detail = refs_result.stderr.strip()
suffix = f": {detail}" if detail else ""
raise InputError(f"cannot inspect refs for zero-before push{suffix}")
branch_name = target_ref.removeprefix("refs/heads/")
exclusions: list[str] = []
for line in refs_result.stdout.splitlines():
ref_name, separator, symbolic_target = line.partition("\0")
if not separator or not ref_name:
raise InputError("invalid git ref output for zero-before push")
if symbolic_target:
continue
if ref_name == target_ref:
continue
if ref_name.startswith("refs/remotes/"):
remote_parts = ref_name.split("/", 3)
if len(remote_parts) == 4 and remote_parts[3] == branch_name:
continue
exclusions.append(ref_name)
revisions = [after]
if exclusions:
revisions.extend(["--not", *exclusions])
return _git_log_subjects(
revisions,
f"zero-before push history for {target_ref}",
allow_empty=True,
)
def _push_range_subjects(payload: dict[str, object]) -> list[tuple[str, str]]:
before = payload.get("before")
after = payload.get("after")
if (
not isinstance(before, str)
or not isinstance(after, str)
or GIT_OBJECT_ID_RE.fullmatch(before) is None
or GIT_OBJECT_ID_RE.fullmatch(after) is None
):
raise InputError("push event before/after must be full Git object IDs")
if set(after) == {"0"}:
raise InputError("push event after cannot be the zero object ID")
_require_complete_git_history("push event")
if set(before) == {"0"}:
return _zero_before_subjects(payload, after)
revision_range = f"{before}..{after}"
return _git_log_subjects(
[revision_range], f"push commit range {revision_range}"
)
def _gather_ci_subjects() -> tuple[str, list[tuple[str, str]]]:
event_name, payload = _load_event_payload()
subjects: list[tuple[str, str]] = []
if event_name.startswith("pull_request"):
if not isinstance(payload, dict):
raise InputError(f"{event_name} event payload is required")
pull_request = payload.get("pull_request")
if not isinstance(pull_request, dict):
raise InputError("pull_request event payload.pull_request must be an object")
title = pull_request.get("title")
if not isinstance(title, str) or not title:
raise InputError("pull_request.title must be a non-empty string")
subjects.append(("pull_request.title", _message_subject(title, "pull_request.title")))
base = pull_request.get("base")
head = pull_request.get("head")
if not isinstance(base, dict) or not isinstance(head, dict):
raise InputError("pull_request.base/head must be objects")
base_sha = base.get("sha")
head_sha = head.get("sha")
if (
not isinstance(base_sha, str)
or not isinstance(head_sha, str)
or GIT_OBJECT_ID_RE.fullmatch(base_sha) is None
or GIT_OBJECT_ID_RE.fullmatch(head_sha) is None
):
raise InputError("pull_request.base/head.sha must be full Git object IDs")
_require_complete_git_history("pull request event")
revision_range = f"{base_sha}..{head_sha}"
subjects.extend(
_git_log_subjects(
[revision_range],
f"pull request commit range {revision_range}",
allow_empty=True,
label_prefix="pull_request.commit",
)
)
return event_name, subjects
if event_name == "push":
if not isinstance(payload, dict):
raise InputError("push event payload is required")
commits = payload.get("commits")
if not isinstance(commits, list):
raise InputError("push event payload.commits must be a list")
for index, commit in enumerate(commits):
item_label = f"push.commits[{index}]"
if not isinstance(commit, dict):
raise InputError(f"{item_label} must be an object")
message = commit.get("message")
if not isinstance(message, str):
raise InputError(f"{item_label}.message must be a non-empty string")
subject = _message_subject(message, item_label)
sha = commit.get("id") or commit.get("sha") or ""
sha_text = str(sha)
label = f"push.commit {sha_text[:7]}" if sha_text else "push.commit"
subjects.append((label, subject))
range_subjects = _push_range_subjects(payload)
return event_name, range_subjects
try:
result = subprocess.run(
["git", "log", "-1", "--format=%s", "HEAD"],
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
raise InputError(f"cannot read HEAD subject: {exc}") from exc
lines = result.stdout.splitlines()
if result.returncode == 0 and lines:
return event_name, [("HEAD", lines[0])]
detail = result.stderr.strip()
suffix = f": {detail}" if detail else ""
raise InputError(f"no commit subject found in event payload or HEAD{suffix}")
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)
try:
policy = load_policy(resolve_policy_path(args.policy))
if args.subject is not None:
event_name = ""
subjects = [("--subject", args.subject)]
elif args.message_file is not None:
event_name = ""
subjects = [(str(args.message_file), _read_message_file(args.message_file))]
else:
event_name, subjects = _gather_ci_subjects()
except (InputError, PolicyError) as exc:
_eprint(f"ERROR: {exc}")
return 2
require_emoji = emoji_is_required(policy)
print(f"commit policy: {policy.source}")
if event_name:
print(f"event: {event_name}")
print(f"require emoji: {require_emoji}")
print(f"checks: {len(subjects)} subject(s)")
for label, _subject in subjects:
print(f"input: {label}")
errors: list[str] = []
for label, subject in subjects:
error = validate_subject(subject, policy, require_emoji=require_emoji)
if error:
errors.append(f"- {label}: {error}\n subject: {subject}")
if errors:
_eprint("ERROR: commit message lint failed:")
for error in errors:
_eprint(error)
return 1
print("OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+162 -103
View File
@@ -1,151 +1,210 @@
---
name: gitea-fix-ci
description: Use when a user asks to debug or fix failing Gitea Actions, Gitea PR checks, or CI workflow runs for a Gitea-hosted repository.
description: "Use when a user asks to debug or fix failing Gitea Actions, Gitea PR checks, or CI workflow runs for a Gitea-hosted repository. Also triggers on Chinese phrasing such as CI 挂了/流水线红了/构建失败/工作流失败."
---
# Gitea Fix CI
## Overview
## 概述
Diagnose failing Gitea Actions from the pull request or workflow run, extract the
smallest useful failure context, then propose a fix plan before changing code.
Core principle: CI logs are evidence; do not guess from the red status alone.
pull request workflow run 诊断失败的 Gitea Actions,提取最小可用的失败上下文,
然后在改代码之前先给出修复计划。核心原则:CI 日志是证据,不要仅凭红色状态臆测原因。
This is not a standalone executor. It guides use of `tea`, local `git`, and the
Gitea API from the current workspace.
本 skill 不是独立执行器。它指导你在当前工作区中使用随附的取证脚本
`scripts/fetch_ci_logs.py``tea`、本地 `git` 和 Gitea API。
## When to Use
取证阶段(step 2-4)优先使用随附脚本,把「探测版本 → 找失败 run → 列 job →
下载失败 job 日志」这段易记错 API 路径的逻辑交给它。脚本仅取证到日志,分类、
修复计划与改代码仍由你按本文档执行。将 `<skill-dir>` 替换为包含本 `SKILL.md`
的目录:
- A Gitea-hosted repository has failing Gitea Actions or PR checks
- The user asks to inspect a failed workflow run, job, or CI status
- The user asks to fix CI after a push, branch update, or pull request update
- Local tests pass but remote Gitea Actions fail
```bash
# 方式一(优先):通过环境变量提供 API token
export GITEA_TOKEN=<token>
python <skill-dir>/scripts/fetch_ci_logs.py version # 探测实例版本并确认连通性
python <skill-dir>/scripts/fetch_ci_logs.py runs --status failure --branch <branch>
python <skill-dir>/scripts/fetch_ci_logs.py jobs <run-id> # 标注 failure 的 job
python <skill-dir>/scripts/fetch_ci_logs.py logs <job-id> # 日志存临时文件并回显尾部
## When Not to Use
# 方式二:显式复用当前仓库的 Git HTTP 凭据
python <skill-dir>/scripts/fetch_ci_logs.py --use-git-credential version
python <skill-dir>/scripts/fetch_ci_logs.py --use-git-credential runs --status failure
```
- The repository is not hosted on Gitea or Forgejo-compatible infrastructure
- The failure belongs to an external CI provider and only links out from Gitea
- The user only wants a local test or lint run
- Credentials, tokens, or network access are unavailable and the user has not
provided the failing log text
`base-url`、owner 和 repo 默认从 git remote 推导。认证规则如下:
## Inputs
- 设置了 `GITEA_TOKEN` 时始终优先使用它,即使同时传入 `--use-git-credential`
- 只有显式传入 `--use-git-credential` 才会非交互调用 `git credential fill`,并将
当前仓库对应的用户名和密码用于 HTTP Basic 认证;脚本不会静默读取 Git 凭据。
- 未设置 token 且未传入该参数时按匿名方式访问,私有仓库通常会返回 401。
- 所有携带凭据的请求必须使用 HTTPS;认证 header 只发送到配置的 Gitea 同源地址,
跨源请求和跨源重定向会被拒绝。凭据不会出现在命令参数、日志或异常文本中。
- Repository path, defaulting to the current workspace
- Gitea base URL and repository owner/name, from `git remote -v` when possible
- Pull request number, branch, commit SHA, or workflow run ID
- Authentication method: `tea` login profile, `GITEA_TOKEN` for API requests,
or an existing git credential for the Gitea web fallback
- Any pasted CI log if remote access is unavailable
脚本不可用(未装 Python、旧版 Gitea 无 job-log API、脱离 Gitea 环境)时,回退到
下文的手动 `tea`/API/web 路径。
## Procedure
## 适用场景
1. **Baseline local state**
- Gitea 托管的仓库出现失败的 Gitea Actions 或 PR checks
- 用户要求检查某个失败的 workflow run、job 或 CI 状态
- 用户要求在 push、更新分支或更新 pull request 之后修复 CI
- 本地测试通过,但远端 Gitea Actions 失败
- Record `git status --short`, current branch, and latest commit SHA.
- Identify the Gitea remote URL and owner/repo.
- Do not modify files while gathering CI evidence.
## 不适用场景
2. **Verify Gitea access**
- 仓库并非托管在 Gitea 或 Forgejo 兼容的基础设施上
- 失败属于外部 CI 服务,只是从 Gitea 链接出去
- 用户只想在本地跑测试或 lint
- 没有凭据、token 或网络访问权限,且用户也未提供失败的日志文本
- Prefer `tea` if it is installed and authenticated:
## 输入
- 仓库路径,默认为当前工作区
- Gitea base URL 和仓库 owner/name,尽量从 `git remote -v` 获取
- Pull request 编号、分支、commit SHA 或 workflow run ID
- 认证方式:`tea` 登录 profile、用于 API 请求的 `GITEA_TOKEN`,或通过
`--use-git-credential` 显式读取的当前仓库 Git HTTP 凭据
- 若无法远端访问,则由用户粘贴的 CI 日志
## 流程
1. **基线本地状态**
- 记录 `git status --short`、当前分支和最新 commit SHA。
-`git remote -v` 识别 remote URL 和 owner/repo。
- 继续之前先确认 remote 是 Gitea/Forgejo:对照已知的 Gitea 实例核对 host
或探测 `/api/v1/version`。若 remote 是 GitHub、GitLab 或其他服务,按"不适用场景"停止。
- 收集 CI 证据期间不要修改文件。
2. **验证 Gitea 访问**
- 优先运行 `fetch_ci_logs.py version` 探测实例版本并确认脚本能连通实例。脚本从
remote 推导 base URL,认证按 `GITEA_TOKEN` → 显式 `--use-git-credential`
匿名的顺序选择。注意:`version` 只读 `/api/v1/version`,成功仅代表连通与鉴权
可用,**不代表 Actions API 一定可用**。Actions 端点是否存在需在后续
`runs`/`jobs` 步骤中实际验证;旧版 Gitea(见下)会在此才暴露 404。
- 脚本不可用时,回退到手动方式。优先使用已安装并已认证的 `tea`
- `tea login list`
- 使用前先运行 `tea actions --help` 确认已安装的 `tea` 版本存在 `actions`
子命令;并非所有版本都带这些子命令。
- `tea actions runs list --status failure --branch <branch>`
- `tea actions runs view <run-id>`
- `tea actions runs logs <run-id> --job <job-id>`
- `tea pulls view <pr>`
- If `tea` is unavailable or lacks an Actions command for the installed
version, use the Gitea API directly.
- Check `/api/v1/version` and `/swagger.v1.json` when API behavior is
unclear. Gitea 1.21 exposes Actions pages but not workflow run/job/log API
endpoints.
- Never print tokens. Pass API tokens through environment variables.
- `tea` 不可用,或已安装版本缺少 Actions 命令,则直接使用 Gitea API。
- 当 API 行为不明确时,检查 `/api/v1/version``/swagger.v1.json`。较旧的
Gitea1.21 及更早)在 web UI 中提供 Actions 页面,但不提供 workflow
run/job/log API 端点;应对照上报的版本确认可用性,而不是想当然。
- 绝不打印 token、用户名或密码。API token 只通过环境变量传递;Git 凭据只通过
`git credential fill` 的标准输入/输出在脚本进程内传递,不放入 argv。
3. **Find failing workflow runs**
3. **定位失败的 workflow run**
- For a known run ID, fetch that run directly.
- Otherwise list recent workflow runs filtered by branch, event, status, or
commit SHA.
- API patterns:
- 若入口是 PR 而非 run ID,先把 PR 解析成 run:取 PR 的 head 分支与 head SHA
`fetch_ci_logs.py``runs` 支持 `--branch`/`--sha`;手动则
`GET /api/v1/repos/<owner>/<repo>/pulls/<pr>``head.ref`/`head.sha`),
再按该 SHA 过滤 runs。PR 页面的 checks 列表可能聚合多个 workflow,逐一定位到
具体失败 run,不要假设只有一个。
- 优先用脚本:`fetch_ci_logs.py runs --status failure --branch <branch>`
(也支持 `--sha`/`--event`/`--limit`),已知 run ID 时用
`fetch_ci_logs.py jobs <run-id>` 直接列出各 job 并标注失败项。
- 脚本不可用时回退到手动方式。已知 run ID 时,直接获取该 run。
- 否则按分支、事件、状态或 commit SHA 过滤,列出最近的 workflow runs。
- API 模式:
- `GET /api/v1/repos/<owner>/<repo>/actions/runs`
- `GET /api/v1/repos/<owner>/<repo>/actions/runs/<run>`
- `GET /api/v1/repos/<owner>/<repo>/actions/runs/<run>/jobs`
- Web fallback for older Gitea:
- 较旧 Gitea 的 web 回退:
- `GET /<owner>/<repo>/actions`
- Parse `/<owner>/<repo>/actions/runs/<run>` links and status labels.
- Treat `failure`, `cancelled`, and missing required checks differently.
Cancelled jobs may require rerun or queue investigation rather than code
changes.
- 解析 `/<owner>/<repo>/actions/runs/<run>` 链接和状态标签。
- 区别对待 `failure``cancelled` 和缺失的必需 checks。被取消(cancelled)的
job 可能需要重跑或排查队列,而非改代码。
4. **Fetch job logs**
4. **获取 job 日志**
- For each failed job, download the job logs:
- 优先用脚本:`fetch_ci_logs.py logs <job-id>`。它把日志存到受跟踪源码路径
之外的临时文件、只回显尾部若干行,并打印临时文件路径供进一步查看
`--tail N` 调整行数,`--json` 输出结构化结果)。
- 脚本不可用时回退到手动方式。对每个失败的 job,下载其日志:
- `GET /api/v1/repos/<owner>/<repo>/actions/jobs/<job_id>/logs`
- On Gitea 1.21 web fallback, download logs by UI job index:
- 在较旧 Gitea web 回退(无 job-log API)下,按 UI job index 下载日志:
- `GET /<owner>/<repo>/actions/runs/<run>/jobs/<job-index>/logs`
- First open the run page and read each job's status; take the index of a
job whose status is `failure`, not index `0` by default. The failing job
is rarely the first one, so a blind index `0` usually returns a passing
job's log. Only fall back to scanning indices when the run page does not
expose per-job status.
- Save large logs to a temporary file outside tracked source paths.
- Extract the first actionable error block, surrounding command, job name,
workflow name, run URL, branch, and SHA.
- If logs are missing, report that explicitly instead of inventing causes.
- 先打开 run 页面读取每个 job 的状态;取状态为 `failure` 的 job 的 index
不要默认用 index `0`。失败的 job 很少是第一个,盲目用 index `0` 通常会返回
某个通过的 job 的日志。只有当 run 页面不暴露每个 job 的状态时,才回退到逐一
扫描 index。
- 大日志保存到受跟踪源码路径之外的临时文件。
- 提取首个可操作的错误块、其上下文命令、job 名、workflow 名、run URL、分支和 SHA。
- 若日志缺失,明确报告,而不是编造原因。
5. **Classify the failure**
5. **分类失败**
- Code/test failure: failing assertion, compile error, lint error, type error
- Environment failure: missing secret, runner image, dependency install,
network, cache, permission, or service startup
- Workflow failure: invalid YAML, unsupported syntax, wrong trigger, bad path,
wrong branch/ref assumption
- Infrastructure failure: offline runner, stuck queue, cancelled run, timeout
- 代码/测试失败:断言失败、编译错误、lint 错误、类型错误
- 环境失败:缺失 secretrunner 镜像、依赖安装、网络、缓存、权限或服务启动
- 工作流失败:YAML 非法、语法不支持、触发条件错误、路径错误、分支/ref 假设错误
- 基础设施失败:runner 离线、队列卡住、run 被取消、超时
6. **Create a fix plan**
6. **制定修复计划**
- Summarize the failure evidence with exact job/run identifiers.
- Propose the smallest code or workflow change that matches the evidence.
- Include local verification commands and the remote recheck path.
- Do not implement before the user approves the fix plan.
- 用确切的 job/run 标识总结失败证据。
- 提出与证据匹配的最小代码或工作流改动。
- 包含本地验证命令和远端复检路径。
- 在用户批准修复计划之前不要实施。
7. **Implement after approval**
7. **批准后实施**
- Apply only the approved fix.
- Run the local command that most closely reproduces the failed job.
- If the failure is workflow-only, validate the workflow file syntax and any
referenced paths or scripts.
- 只应用已批准的修复。
- 运行最接近复现该失败 job 的本地命令。
- 若失败仅涉及工作流,验证工作流文件语法及其引用的路径或脚本。
8. **Recheck**
8. **复检**
- Tell the user what must be pushed or rerun in Gitea.
- If permitted, use the Gitea API to inspect the rerun status.
- Final output must distinguish local verification from remote CI status.
- 告诉用户需要在 Gitea 中 push 或重跑什么。
- 若获准,使用 Gitea API 检查重跑状态。
- 最终输出必须区分本地验证与远端 CI 状态。
## Output Contract
## 输出约定
- `Target:` repo, branch/SHA, PR or run ID
- `Failed CI:` workflow, job, status, run URL or API path
- `Evidence:` concise log snippet and classification
- `Plan:` proposed fix, local verification, remote recheck
- `Changes:` files changed after approval
- `Result:` local checks run and remaining remote status
- `Target:` 仓库、分支/SHAPR run ID
- `Failed CI:` workflowjob、状态、run URL API 路径
- `Evidence:` 精简的日志片段与分类
- `Plan:` 提出的修复、本地验证、远端复检
- `Changes:` 批准后改动的文件
- `Result:` 已运行的本地检查与剩余的远端状态
## Success Criteria
## 成功标准
- Failure analysis is based on Gitea Actions run/job data or pasted logs
- The fix plan names the exact workflow run or job it addresses
- No code or workflow edits happen before plan approval
- Verification distinguishes local commands from remote Gitea Actions results
- Tokens and private log content are not echoed unnecessarily
- 失败分析基于 Gitea Actions run/job 数据或粘贴的日志
- 修复计划指明其针对的确切 workflow run job
- 计划批准前不发生任何代码或工作流改动
- 验证区分本地命令与远端 Gitea Actions 结果
- 不回显 token;私有日志只保留可操作的片段,不整段外泄
## Failure Handling
## 危险信号(Red Flags
- If authentication fails, ask the user to authenticate `tea` or provide a token
through the environment; do not request secrets in chat
- If the Gitea version lacks Actions API endpoints, ask for the relevant log text
or a browser-copied job log
- If an external CI provider owns the failing check, report the external URL and
stop at evidence collection
- If the failure is infrastructure-only, recommend rerun/runner investigation
instead of editing code
出现以下情况说明流程走偏,停下纠正而非继续:
- **凭红色状态臆测原因**:还没下载 job 日志就断言失败原因或动手改代码。
- **误读双字段结果**:把 `status`(生命周期)当成结果判定。Gitea Actions 沿用
GitHub 兼容的双字段模型——`status=completed` 只表示跑完了,真正的成败在
`conclusion``failure`/`success`/`cancelled`)。判定失败必须看有效结果,
而非 `status=completed` 就当通过。
- **盲取 job index `0`**:在 web 回退下不看每个 job 状态就用 index `0` 下载日志;
失败的 job 很少是第一个,通常会误取到某个通过 job 的日志。
- **把 `version` 成功当作 Actions API 可用**`version` 只探连通与鉴权,Actions
端点可能仍返回 404(旧版 Gitea)。
- **把 `cancelled`/基础设施问题当代码 bug 修**:被取消、runner 离线、队列卡住、
超时应重跑或排查环境,不是改代码。
- **回显 token 或整段私有日志**:只保留可操作的最小片段。
- **静默读取或降级传输凭据**:Git 凭据必须由 `--use-git-credential` 显式启用;
token 和 Git 凭据都不得通过 HTTP 或跨源重定向发送。
## 失败处理
- 401 且未使用认证时,通过环境提供 `GITEA_TOKEN` 或显式传入
`--use-git-credential`;401 且已使用认证时检查凭据是否有效,不要在对话中索要 secret
- 403 表示所选凭据缺少仓库或 Actions 读取权限;补足权限,而不是切换到匿名访问
- 404 优先核对 owner/repo、端点和 Gitea 版本;旧版本缺少 Actions API 时使用 web 回退
- 若 Gitea 版本缺少 Actions API 端点,请用户提供相关日志文本或从浏览器复制的 job 日志
- 若失败的 check 归属外部 CI 服务,报告外部 URL 并在证据收集处停止
- 若失败仅为基础设施问题,建议重跑/排查 runner,而非改代码
@@ -0,0 +1,699 @@
#!/usr/bin/env python3
"""Collect Gitea Actions CI evidence: version, runs, jobs, and job logs.
This is an evidence-collection helper for the gitea-fix-ci skill. It automates
the programmatic parts of the skill's Procedure (probe version, list failing
runs, list jobs, download a failing job's log) so the agent does not hand-build
API paths or blindly pick job index 0. It stops at evidence: classification,
fix plans, and code edits stay with the agent.
Scope decisions (see SKILL.md):
- Evidence only. No classification, no fix, no edits.
- Gitea Actions REST API only. No legacy web-scraping fallback; when the API is
absent the tool points the agent back to SKILL.md's manual/web path.
- Auth via GITEA_TOKEN (preferred) or an explicitly requested `git credential`
lookup; base URL/owner/repo are derived from `git remote -v`. Secrets are
never placed on argv, logs, or exception text.
Zero third-party dependencies (stdlib urllib only).
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
TOKEN_ENV = "GITEA_TOKEN"
DEFAULT_REMOTE = "origin"
DEFAULT_LOG_TAIL = 40
REQUEST_TIMEOUT = 30 # seconds; a hung Gitea/proxy must not block the session
CREDENTIAL_TIMEOUT = 10 # seconds; helpers must not block on an interactive UI
USER_AGENT = "gitea-fix-ci/fetch_ci_logs"
# https://host/owner/repo(.git) or git@host:owner/repo(.git) or
# ssh://git@host[:port]/owner/repo(.git)
_HTTP_REMOTE_RE = re.compile(
r"^(?P<scheme>https?)://(?:[^@/]+@)?(?P<host>[^/]+)/"
r"(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
)
_SCP_REMOTE_RE = re.compile(
r"^(?:ssh://)?(?:[^@]+@)?(?P<host>[^:/]+)(?::\d+)?[:/]"
r"(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
)
class ConfigError(ValueError):
"""Missing or malformed configuration (remote, token, arguments)."""
class ApiError(RuntimeError):
"""The Gitea API returned an error or unexpected payload."""
def _eprint(*args: object) -> None:
print(*args, file=sys.stderr)
def _origin(url: str) -> tuple[str, str, int]:
"""Return a normalized (scheme, hostname, effective port) tuple."""
try:
parsed = urllib.parse.urlsplit(url)
hostname = parsed.hostname
port = parsed.port
except ValueError:
raise ConfigError("invalid Gitea URL") from None
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
raise ConfigError("Gitea URL must use http:// or https://")
if parsed.username is not None or parsed.password is not None:
raise ConfigError("Gitea URL must not contain embedded credentials")
if not hostname:
raise ConfigError("Gitea URL must include a host")
scheme = parsed.scheme.lower()
effective_port = port if port is not None else (443 if scheme == "https" else 80)
return scheme, hostname.rstrip(".").lower(), effective_port
def _normalize_base_url(value: str) -> str:
"""Validate and normalize the configured API base URL."""
if not isinstance(value, str) or not value.strip():
raise ConfigError("Gitea base URL is required")
value = value.strip().rstrip("/")
try:
parsed = urllib.parse.urlsplit(value)
except ValueError:
raise ConfigError("invalid Gitea base URL") from None
if parsed.query or parsed.fragment:
raise ConfigError("Gitea base URL must not contain a query or fragment")
_origin(value)
return value
@dataclass(frozen=True)
class RepoTarget:
base_url: str # e.g. https://git.example.com
owner: str
repo: str
def __post_init__(self) -> None:
object.__setattr__(self, "base_url", _normalize_base_url(self.base_url))
if not self.owner or not self.repo:
raise ConfigError("Gitea owner and repository are required")
@property
def api_root(self) -> str:
return f"{self.base_url}/api/v1"
def repo_path(self, suffix: str) -> str:
owner = urllib.parse.quote(self.owner, safe="")
repo = urllib.parse.quote(self.repo, safe="")
return f"{self.api_root}/repos/{owner}/{repo}{suffix}"
@property
def origin(self) -> tuple[str, str, int]:
return _origin(self.base_url)
@property
def credential_host(self) -> str:
parsed = urllib.parse.urlsplit(self.base_url)
hostname = parsed.hostname or ""
if ":" in hostname and not hostname.startswith("["):
hostname = f"[{hostname}]"
if parsed.port is not None:
hostname = f"{hostname}:{parsed.port}"
return hostname
@property
def credential_path(self) -> str:
base_path = urllib.parse.urlsplit(self.base_url).path.strip("/")
owner = urllib.parse.quote(self.owner, safe="")
repo = urllib.parse.quote(self.repo, safe="")
repository_path = f"{owner}/{repo}.git"
return f"{base_path}/{repository_path}" if base_path else repository_path
@dataclass(frozen=True)
class ApiAuth:
"""An authorization header whose secret is deliberately absent from repr."""
source: str
authorization: str = field(repr=False)
def __post_init__(self) -> None:
scheme, separator, credential = self.authorization.partition(" ")
has_control = any(
ord(character) < 32 or ord(character) == 127
for character in self.authorization
)
try:
self.authorization.encode("ascii")
except UnicodeEncodeError:
raise ConfigError("invalid authorization value") from None
if (
not self.source
or not separator
or not scheme
or not credential
or has_control
):
raise ConfigError("invalid authorization value")
def _require_https(target: RepoTarget) -> None:
if target.origin[0] != "https":
raise ConfigError("credentials require an HTTPS Gitea base URL")
def _run_git(args: list[str]) -> str:
try:
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
raise ConfigError(f"cannot run git {' '.join(args)}: {exc}") from exc
if result.returncode != 0:
detail = result.stderr.strip()
suffix = f": {detail}" if detail else ""
raise ConfigError(f"git {' '.join(args)} failed{suffix}")
return result.stdout.strip()
def _remote_url(remote: str) -> str:
url = _run_git(["remote", "get-url", remote])
if not url:
raise ConfigError(f"remote '{remote}' has no URL")
return url
def parse_remote_url(url: str) -> tuple[str, str, str]:
"""Return (base_url, owner, repo) parsed from a git remote URL.
Only http(s) remotes yield a usable API base URL. SSH remotes give host and
path but no scheme, so we assume https for the API base.
"""
http_match = _HTTP_REMOTE_RE.match(url)
if http_match:
base = f"{http_match.group('scheme')}://{http_match.group('host')}"
return base, http_match.group("owner"), http_match.group("repo")
scp_match = _SCP_REMOTE_RE.match(url)
if scp_match:
# No scheme in an SSH remote; the API is reached over https by default.
base = f"https://{scp_match.group('host')}"
return base, scp_match.group("owner"), scp_match.group("repo")
raise ConfigError(
"cannot parse owner/repo from the configured git remote. Gitea remotes "
"are expected as <host>/<owner>/<repo>; for nested or "
"non-standard paths, pass --base-url/--owner/--repo explicitly."
)
def resolve_target(args: argparse.Namespace) -> RepoTarget:
base_url = args.base_url
owner = args.owner
repo = args.repo
if not (base_url and owner and repo):
url = _remote_url(args.remote)
parsed_base, parsed_owner, parsed_repo = parse_remote_url(url)
base_url = base_url or parsed_base
owner = owner or parsed_owner
repo = repo or parsed_repo
return RepoTarget(base_url=base_url, owner=owner, repo=repo)
def _token() -> str | None:
token = os.getenv(TOKEN_ENV)
if token and token.strip():
return token.strip()
return None
def _git_credential_auth(target: RepoTarget) -> ApiAuth:
"""Resolve repository-scoped HTTP Basic credentials without prompting."""
_require_https(target)
credential_query = (
"protocol=https\n"
f"host={target.credential_host}\n"
f"path={target.credential_path}\n\n"
)
helper_env = os.environ.copy()
helper_env["GIT_TERMINAL_PROMPT"] = "0"
helper_env["GCM_INTERACTIVE"] = "Never"
try:
result = subprocess.run(
["git", "credential", "fill"],
input=credential_query,
capture_output=True,
text=True,
check=False,
timeout=CREDENTIAL_TIMEOUT,
env=helper_env,
)
except subprocess.TimeoutExpired:
raise ConfigError("git credential lookup timed out") from None
except OSError:
raise ConfigError("cannot run git credential lookup") from None
if result.returncode != 0:
# stdout/stderr may contain credentials or helper-specific secret data.
raise ConfigError("git credential lookup failed or requires interaction")
fields: dict[str, str] = {}
for line in result.stdout.splitlines():
key, separator, value = line.partition("=")
if separator:
fields[key] = value
username = fields.get("username", "")
password = fields.get("password", "")
if not username or not password or ":" in username:
raise ConfigError("git credential lookup returned unusable credentials")
encoded = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
return ApiAuth(source="git credential", authorization=f"Basic {encoded}")
def resolve_auth(args: argparse.Namespace, target: RepoTarget) -> ApiAuth | None:
"""Resolve authentication once, with environment tokens taking precedence."""
token = _token()
if token:
_require_https(target)
return ApiAuth(source=TOKEN_ENV, authorization=f"token {token}")
if getattr(args, "use_git_credential", False):
return _git_credential_auth(target)
return None
class SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Follow redirects only while they stay on the configured Gitea origin."""
def __init__(self, target: RepoTarget) -> None:
super().__init__()
self._origin = target.origin
def redirect_request(
self,
req: urllib.request.Request,
fp: Any,
code: int,
msg: str,
headers: Any,
newurl: str,
) -> urllib.request.Request | None:
absolute_url = urllib.parse.urljoin(req.full_url, newurl)
try:
redirect_origin = _origin(absolute_url)
except ConfigError:
raise ConfigError("refusing an invalid Gitea redirect") from None
if redirect_origin != self._origin:
raise ConfigError("refusing a redirect outside the configured origin")
return super().redirect_request(req, fp, code, msg, headers, absolute_url)
class ApiClient:
"""Small, origin-bound client for read-only Gitea API requests."""
def __init__(
self,
target: RepoTarget,
*,
auth: ApiAuth | None = None,
opener: Any | None = None,
) -> None:
if auth:
_require_https(target)
self.target = target
self.auth = auth
self._opener = opener or urllib.request.build_opener(
SameOriginRedirectHandler(target)
)
def request(self, url: str, *, accept_json: bool = True) -> tuple[int, bytes, str]:
"""Perform an origin-bound GET and return status, body, content type."""
try:
request_origin = _origin(url)
except ConfigError:
raise ConfigError("refusing an invalid Gitea API URL") from None
if request_origin != self.target.origin:
raise ConfigError("refusing a request outside the configured origin")
headers = {"User-Agent": USER_AGENT}
if accept_json:
headers["Accept"] = "application/json"
if self.auth:
headers["Authorization"] = self.auth.authorization
request = urllib.request.Request(url, headers=headers, method="GET")
try:
with self._opener.open(request, timeout=REQUEST_TIMEOUT) as response:
body = response.read()
content_type = response.headers.get("Content-Type", "")
return response.status, body, content_type
except urllib.error.HTTPError as exc:
body = exc.read() if hasattr(exc, "read") else b""
content_type = exc.headers.get("Content-Type", "") if exc.headers else ""
return exc.code, body, content_type
except urllib.error.URLError as exc:
detail = str(exc.reason)
if self.auth:
authorization = self.auth.authorization
secret = authorization.partition(" ")[2]
detail = detail.replace(authorization, "<redacted>")
if secret:
detail = detail.replace(secret, "<redacted>")
raise ApiError(f"request to Gitea failed: {detail}") from None
def get_json(self, url: str) -> Any:
status, body, _ = self.request(url, accept_json=True)
if status != 200:
raise ApiError(_explain_status(status, self.target, self.auth))
try:
return json.loads(body.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise ApiError(f"cannot parse JSON from {url}: {exc}") from None
def _explain_status(
status: int, target: RepoTarget, auth: ApiAuth | None = None
) -> str:
if status == 401:
if auth is None:
return (
f"authentication required (HTTP 401). Set {TOKEN_ENV} or rerun "
"with --use-git-credential; do not paste credentials into chat."
)
return (
f"authentication rejected (HTTP 401) for {auth.source}. Check that "
"the credential is valid for this Gitea instance and repository."
)
if status == 403:
if auth is None:
return (
f"permission denied (HTTP 403) without authentication. Set "
f"{TOKEN_ENV} or rerun with --use-git-credential."
)
return (
f"permission denied (HTTP 403) using {auth.source}. Grant repository "
"and Actions read permission to the selected credential."
)
if status == 404:
return (
"endpoint not found (HTTP 404). Check the repository coordinates and "
"Gitea version; older Gitea (1.21 and earlier) lacks the Actions "
"run/job/log API. Fall back to the web UI or pasted logs per SKILL.md."
)
return f"unexpected HTTP {status} from {target.base_url}"
def cmd_version(
args: argparse.Namespace,
target: RepoTarget,
client: ApiClient,
) -> int:
payload = client.get_json(f"{target.api_root}/version")
version = payload.get("version") if isinstance(payload, dict) else None
if not version:
raise ApiError("version endpoint returned no 'version' field")
if args.json:
print(json.dumps({"version": version}, ensure_ascii=False))
else:
print(f"gitea version: {version}")
print(f"instance: {target.base_url}")
return 0
def _as_list(payload: Any, key: str) -> list[dict[str, Any]]:
"""Gitea may wrap collections in an object or return a bare list."""
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
inner = payload.get(key)
if isinstance(inner, list):
return [item for item in inner if isinstance(item, dict)]
return []
def _outcome(item: dict[str, Any]) -> str:
"""Resolve the effective result of a run/job.
Gitea's Actions objects follow the GitHub-compatible two-field model: a
lifecycle `status` (queued/in_progress/completed) plus a terminal
`conclusion` (success/failure/cancelled/...). A completed job reports
`status=completed, conclusion=failure`, so a naive `status or conclusion`
would stop at "completed" and miss the failure. Prefer `conclusion`; fall
back to `status` only when there is no conclusion yet.
"""
conclusion = (item.get("conclusion") or "").strip()
status = (item.get("status") or "").strip()
return (conclusion or status).lower()
def _run_row(run: dict[str, Any]) -> dict[str, Any]:
return {
"id": run.get("id"),
"name": run.get("name") or run.get("workflow_id") or "",
"outcome": _outcome(run),
"event": run.get("event") or "",
"branch": run.get("head_branch") or run.get("branch") or "",
"sha": (run.get("head_sha") or run.get("commit_sha") or "")[:12],
"url": run.get("html_url") or run.get("url") or "",
}
def cmd_runs(
args: argparse.Namespace,
target: RepoTarget,
client: ApiClient,
) -> int:
query: dict[str, str] = {}
if args.branch:
query["branch"] = args.branch
if args.event:
query["event"] = args.event
if args.status:
query["status"] = args.status
if args.sha:
query["head_sha"] = args.sha
if args.limit:
query["limit"] = str(args.limit)
suffix = "/actions/runs"
if query:
suffix += "?" + urllib.parse.urlencode(query)
payload = client.get_json(target.repo_path(suffix))
runs = [_run_row(run) for run in _as_list(payload, "workflow_runs")]
if args.sha:
runs = [row for row in runs if row["sha"].startswith(args.sha[:12])]
runs = runs[: args.limit] if args.limit else runs
if args.json:
print(json.dumps(runs, ensure_ascii=False, indent=2))
return 0
if not runs:
print("no matching workflow runs")
return 1
print(f"{len(runs)} run(s):")
for row in runs:
print(
f" run {row['id']} [{row['outcome']}] {row['name']} "
f"{row['event']} {row['branch']} {row['sha']}"
)
if row["url"]:
print(f" {row['url']}")
return 0
def _job_row(job: dict[str, Any], index: int) -> dict[str, Any]:
return {
"index": index,
"id": job.get("id"),
"name": job.get("name") or "",
"outcome": _outcome(job),
}
def _is_failed(outcome: str) -> bool:
return outcome.lower() in {"failure", "failed", "error"}
def cmd_jobs(
args: argparse.Namespace,
target: RepoTarget,
client: ApiClient,
) -> int:
payload = client.get_json(target.repo_path(f"/actions/runs/{args.run_id}/jobs"))
jobs = [_job_row(job, index) for index, job in enumerate(_as_list(payload, "jobs"))]
failed = [job for job in jobs if _is_failed(job["outcome"])]
if args.json:
print(
json.dumps(
{"jobs": jobs, "failed_job_ids": [j["id"] for j in failed]},
ensure_ascii=False,
indent=2,
)
)
return 0
if not jobs:
print(f"run {args.run_id} has no jobs (or endpoint unavailable)")
return 1
print(f"run {args.run_id} jobs:")
for job in jobs:
marker = " <== FAILED" if _is_failed(job["outcome"]) else ""
print(
f" job {job['id']} index={job['index']} "
f"[{job['outcome']}] {job['name']}{marker}"
)
if failed:
ids = ", ".join(str(job["id"]) for job in failed)
print(f"failed job id(s): {ids}")
print("fetch a failed job's log with: logs <job-id>")
else:
print("no failed jobs on this run")
return 0
def cmd_logs(
args: argparse.Namespace,
target: RepoTarget,
client: ApiClient,
) -> int:
status, body, _ = client.request(
target.repo_path(f"/actions/jobs/{args.job_id}/logs"),
accept_json=False,
)
if status != 200:
raise ApiError(_explain_status(status, target, client.auth))
text = body.decode("utf-8", errors="replace")
lines = text.splitlines()
if args.out:
out_path = Path(args.out).expanduser()
out_path.write_text(text, encoding="utf-8")
else:
handle = tempfile.NamedTemporaryFile(
prefix=f"gitea-job-{args.job_id}-",
suffix=".log",
delete=False,
mode="w",
encoding="utf-8",
)
handle.write(text)
handle.close()
out_path = Path(handle.name)
tail = args.tail if args.tail is not None else DEFAULT_LOG_TAIL
tail_lines = lines[-tail:] if tail > 0 else lines
if args.json:
print(
json.dumps(
{
"job_id": args.job_id,
"log_path": str(out_path),
"total_lines": len(lines),
"tail": tail_lines,
},
ensure_ascii=False,
indent=2,
)
)
return 0
print(f"job {args.job_id} log saved to: {out_path}")
print(f"total lines: {len(lines)} (showing last {len(tail_lines)})")
print("-" * 60)
for line in tail_lines:
print(line)
return 0
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Collect Gitea Actions CI evidence (version/runs/jobs/logs).",
)
parser.add_argument("--remote", default=DEFAULT_REMOTE, help="git remote name")
parser.add_argument("--base-url", help="Gitea base URL override")
parser.add_argument("--owner", help="repository owner override")
parser.add_argument("--repo", help="repository name override")
parser.add_argument(
"--use-git-credential",
action="store_true",
help=(
"explicitly use repository-scoped git credentials over HTTPS "
f"when {TOKEN_ENV} is unset"
),
)
parser.add_argument(
"--json", action="store_true", help="emit machine-readable JSON"
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("version", help="probe /api/v1/version")
runs = sub.add_parser("runs", help="list workflow runs")
runs.add_argument("--branch")
runs.add_argument("--event")
runs.add_argument("--status", default="failure")
runs.add_argument("--sha")
runs.add_argument("--limit", type=int, default=20)
jobs = sub.add_parser("jobs", help="list jobs of a run, flag failed ones")
jobs.add_argument("run_id")
logs = sub.add_parser("logs", help="download a job log, print its tail")
logs.add_argument("job_id")
logs.add_argument("--out", help="write full log here instead of a temp file")
logs.add_argument(
"--tail", type=int, help=f"tail lines (default {DEFAULT_LOG_TAIL})"
)
return parser
_COMMANDS = {
"version": cmd_version,
"runs": cmd_runs,
"jobs": cmd_jobs,
"logs": cmd_logs,
}
def main(argv: list[str] | None = None) -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
args = _build_parser().parse_args(argv)
try:
target = resolve_target(args)
auth = resolve_auth(args, target)
client = ApiClient(target, auth=auth)
return _COMMANDS[args.command](args, target, client)
except ConfigError as exc:
_eprint(f"ERROR: {exc}")
return 2
except ApiError as exc:
_eprint(f"ERROR: {exc}")
return 2
if __name__ == "__main__":
raise SystemExit(main())
-88
View File
@@ -1,88 +0,0 @@
---
name: style-cleanup
description: Use when the user asks to format code, fix lint issues, or align style with the repository's existing toolchain without changing behavior.
---
# Style Cleanup
## Overview
Use the repository's own formatter and lint contracts without changing behavior.
Keep the requested scope, preserve Git state, and prove the resulting diff is
style-only.
## Use Boundary
Use for requested formatting, lint cleanup, or a final style pass. Do not use for
semantic refactors, new tool configuration, or full-repo reformatting without
explicit scope.
## Workflow
1. **Fix the scope and preserve Git state**
Record `git status --short`. Resolve targets, then inspect
`git diff --cached -- <targets>` and `git diff -- <targets>` separately.
Default to changed files. If a staged-only target is partially staged, stop and
ask because formatters edit the whole working-tree file. Never stage, unstage,
commit, or discard changes unless requested.
2. **Resolve authority in this order**
User instructions and agreed scope → nearest project instructions and canonical
repo/CI commands → checked-in tool config → vendored Playbook defaults. A higher
source wins; report conflicts instead of combining rules.
3. **Choose commands from repository evidence**
Use a canonical repo entrypoint as one unit. Otherwise select only configured
tools for target languages; there is no universal formatter/linter order. Run a
non-mutating check first when available, then scoped configured fixers. If a tool
is missing, report and stop—do not install tools or invent config.
4. **Apply and control the blast radius**
Record the initial diff summary and run the chosen commands. If files outside
scope change, stop and report them; do not widen scope or silently revert.
5. **Verify**
Inspect final cached and working-tree diffs and confirm the index is unchanged.
Run `git diff --check`; rerun the formatter/check for idempotence. If a fixer
changes tokens, imports, structure, or any non-style hunk, run relevant behavior
tests. Without tests, report behavior preservation as unverified.
## Playbook as Authority
Use these only when no higher-priority project rule conflicts:
- TSL: `docs/tsl/code_style.md`, `docs/tsl/naming.md`, `docs/tsl/toolchain.md`
- C++: `docs/cpp/code_style.md`, `docs/cpp/naming.md`, `docs/cpp/toolchain.md`
- Python: `docs/python/style_guide.md`, `docs/python/tooling.md`,
`docs/python/configuration.md`
## Output Contract
Report `Scope`, `Authority`, `Commands`, `Git State`, `Changes`, `Verification`,
and `Remaining`. Include before/after staged state, diff size, idempotence, checks,
behavior tests, and anything unverified.
## Quick Reference
| Situation | Action |
| --- | --- |
| Canonical command exists | Use it; do not build a parallel pipeline |
| Staged-only target is partial | Stop and ask |
| Tool expands scope | Stop and report; do not silently revert |
| Non-style hunk appears | Test behavior or report it unverified |
| Tool is missing | Report and stop |
Example: if `package.json` defines `lint:fix`, use that canonical entrypoint for
the requested JS files; do not add a separate Prettier pass unless project
instructions require it.
## Common Mistakes
- Running familiar tools instead of the repository-selected entrypoint
- Giving Playbook defaults priority over project instructions or config
- Staging changes, widening scope, or claiming success without final diff review
+15 -18
View File
@@ -1,38 +1,35 @@
---
name: tsl-api-reference
description: "Use when writing or reviewing TSL code and needing authoritative TSL API facts: builtin/dotnet functions, module APIs, exact signatures, parameters, return values, examples, or Chinese keyword discovery when the function name is unknown."
description: "在编写或审查 TSL 代码、需要权威的 TSL API 事实时使用:内置/dotnet 函数、模块 API、精确签名、参数、返回值、示例,或在不知道函数名时通过中文关键词检索。"
---
# TSL API Reference
# TSL API 参考
Use this skill for TSL API facts only: function/module names, exact signatures,
parameters, return values, and examples. For TSL syntax, control flow, variables,
object model, or runtime language rules, use the `tsl-syntax-reference` skill instead.
skill 仅用于 TSL API 事实:函数/模块名、精确签名、参数、返回值和示例。
若需查询 TSL 语法、控制流、变量、对象模型或运行时语言规则,请改用
`tsl-syntax-reference` skill
Do not infer API signatures from memory or similar languages. TSL names are
case-insensitive, but underscores are significant: do not remove underscores or
camel-case names when querying or reporting APIs.
不要凭记忆或参照相似语言推断 API 签名。TSL 名称大小写不敏感,但下划线是有意义的:
查询或报告 API 时不要删除下划线,也不要把名称改写成驼峰式。
Run the bundled lookup script. Replace `<this-skill-dir>` with the directory
containing this `SKILL.md`.
运行随附的查询脚本。将 `<this-skill-dir>` 替换为包含本 `SKILL.md` 的目录。
- Known API name:
- 已知 API 名称:
```bash
python <this-skill-dir>/scripts/lookup.py --name argmax
```
- Unknown name, known behavior or Chinese keywords:
- 未知名称,但知道其行为或中文关键词:
```bash
python <this-skill-dir>/scripts/lookup.py --kw 数组 排序
```
- Keyword terms use AND semantics; add terms to narrow results.
- Use `--limit N` to change how many candidate rows are printed.
- 关键词采用 AND 语义;增加关键词可缩小结果范围。
- 使用 `--limit N` 调整打印的候选行数量。
Exact lookup prints the full entry body and source marker. Keyword lookup prints
candidate rows; pick a candidate, then rerun exact lookup with `--name`.
精确查询会打印完整的条目正文和来源标记。关键词查询打印候选行;
选定候选项后,再用 `--name` 重新执行精确查询。
If exact lookup has no match, retry with Chinese keywords. If bundled data is
missing, reinstall the skill.
若精确查询无匹配,改用中文关键词重试。若随附数据缺失,请重新安装本 skill。
-1
View File
@@ -91,7 +91,6 @@
| `brooks-review` | 代码类 Plan 完成后,归档前需要审查 | diff / PR 级代码审查 |
| `brooks-test` | 测试改动复杂,或需要确认测试质量 | 测试有效性、覆盖边界、断言质量审查 |
| `gitea-fix-ci` | Gitea Actions 失败 | 拉取 CI 日志、定位失败、形成修复计划 |
| `style-cleanup` | 代码实现后需要格式或 lint 收尾 | 格式化、lint cleanup,不改变语义 |
| `commit-message` | 需要提交或归档当前 Plan 改动 | commit message 生成与 staged diff 检查 |
### Plan 要求
+1 -2
View File
@@ -149,11 +149,10 @@ templates/
语言和 CI 配置模板,`install_mode = "snapshot"` 安装快照时会复制这些模板:
- `ci/gitea/`Gitea Actions 工作流与辅助脚本,部署到快照 `templates/ci/`
- `cpp/``.clang-format``.clangd``CMakeLists.txt` 等文件,部署到快照 `templates/cpp/`
- `python/``pyproject.toml``.editorconfig` 等文件,部署到快照 `templates/python/`
**使用方式**:这些模板保留在快照中供参考,需手动复制到项目根目录使用。其中 `ci/gitea/` 应按 `templates/ci/README.md` 的说明,整块复制 `.gitea/` 目录。
**使用方式**:这些模板保留在快照中供参考,需手动复制到项目根目录使用。
## 技术细节
-37
View File
@@ -1,37 +0,0 @@
# CI 模板(templates/ci
本目录提供“目标项目可复制启用”的 CI 模板示例,用于在 CI 中自动化校验部分 Playbook 规范。
当前提供:
- `gitea/`Gitea ActionsGitHub Actions 语法)
说明:`templates/ci/gitea/.gitea/` 结构用于与目标项目根目录的 `.gitea/`
保持一致,便于直接复制到项目根目录。
## 使用(Gitea Actions
前提:目标项目已经把 Playbook 部署到项目内(例如 `docs/standards/playbook/`)。
复制到目标项目根目录:
```sh
cp -R docs/standards/playbook/templates/ci/gitea/.gitea ./
```
提交:
```sh
git add .gitea
git commit -m ":memo: docs(ci): add standards check workflow"
```
## commit message 校验
工作流会运行 `.gitea/ci/commit_message_lint.py`
- 规范来源(自动探测其一):
- `docs/common/commit_message.md`
- `docs/standards/playbook/docs/common/commit_message.md`
- 默认要求 emoji;如需允许无 emoji:在 workflow 中设置
`COMMIT_LINT_REQUIRE_EMOJI=0`
@@ -1,210 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import pathlib
import re
import subprocess
import sys
from typing import Dict, List, Optional, Tuple
def _eprint(*args: object) -> None:
print(*args, file=sys.stderr)
def _git(*args: str) -> str:
return subprocess.check_output(["git", *args], text=True).strip()
def _repo_root() -> pathlib.Path:
return pathlib.Path(_git("rev-parse", "--show-toplevel"))
def _find_commit_spec(root: pathlib.Path) -> pathlib.Path:
candidates = [
root / "docs" / "common" / "commit_message.md",
root / "docs" / "standards" / "playbook" / "docs" / "common" / "commit_message.md",
]
for path in candidates:
if path.is_file():
return path
raise FileNotFoundError(
"commit_message.md not found; expected one of:\n"
+ "\n".join(f"- {p}" for p in candidates)
)
def _parse_type_emoji_mapping(md_text: str) -> Dict[str, str]:
mapping: Dict[str, str] = {}
for raw_line in md_text.splitlines():
line = raw_line.strip()
if not (line.startswith("|") and line.endswith("|")):
continue
if "type" in line and "emoji" in line:
continue
if re.fullmatch(r"\|\s*-+\s*(\|\s*-+\s*)+\|", line):
continue
cols = [c.strip() for c in line.strip("|").split("|")]
if len(cols) < 2:
continue
m_type = re.search(r"`([^`]+)`", cols[0])
m_emoji = re.search(r"`(:[^`]+:)`", cols[1])
if not m_type or not m_emoji:
continue
type_name = m_type.group(1).strip()
emoji_code = m_emoji.group(1).strip()
mapping[type_name] = emoji_code
if not mapping:
raise ValueError("failed to parse type/emoji mapping from commit_message.md")
return mapping
def _validate_subject_line(
line: str,
mapping: Dict[str, str],
*,
require_emoji: bool,
) -> Optional[str]:
subject = line.strip()
if not subject:
return "empty subject"
m = re.match(
r"^(?:(?P<emoji>:[a-z0-9_+-]+:)\s+)?"
r"(?P<type>[a-z]+)"
r"(?P<scope>\([a-z0-9_-]+\))?"
r":\s+(?P<text>.+)$",
subject,
)
if not m:
return "does not match ':emoji: type(scope): subject' or 'type(scope): subject'"
emoji = m.group("emoji")
type_name = m.group("type")
text = (m.group("text") or "").rstrip()
if type_name not in mapping:
return f"unknown type: {type_name}"
if emoji:
expected = mapping[type_name]
if emoji != expected:
return f"emoji/type mismatch: got {emoji} {type_name}, expected {expected} for type {type_name}"
elif require_emoji:
return "missing emoji (set COMMIT_LINT_REQUIRE_EMOJI=0 to allow)"
if text.endswith((".", "")):
return "subject should not end with a period"
return None
def _load_event_payload() -> Tuple[str, Optional[dict]]:
event_name = os.getenv("GITHUB_EVENT_NAME") or os.getenv("GITEA_EVENT_NAME") or ""
event_path = os.getenv("GITHUB_EVENT_PATH") or os.getenv("GITEA_EVENT_PATH") or ""
if not event_path:
return event_name, None
path = pathlib.Path(event_path)
if not path.is_file():
return event_name, None
try:
return event_name, json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
_eprint(f"WARN: failed to parse event payload: {path} ({exc})")
return event_name, None
def _gather_subjects(event_name: str, payload: Optional[dict]) -> List[Tuple[str, str]]:
subjects: List[Tuple[str, str]] = []
if isinstance(payload, dict):
if event_name.startswith("pull_request"):
pr = payload.get("pull_request")
if isinstance(pr, dict):
title = (pr.get("title") or "").strip()
if title:
subjects.append(("pull_request.title", title.splitlines()[0].strip()))
if event_name == "push":
commits = payload.get("commits")
if isinstance(commits, list):
for commit in commits:
if not isinstance(commit, dict):
continue
msg = (commit.get("message") or "").strip()
if not msg:
continue
subject = msg.splitlines()[0].strip()
sha = commit.get("id") or commit.get("sha") or ""
label = f"push.commit {sha[:7]}" if sha else "push.commit"
subjects.append((label, subject))
if subjects:
return subjects
try:
subjects.append(("HEAD", _git("log", "-1", "--format=%s", "HEAD")))
except Exception:
pass
return subjects
def main() -> int:
try:
root = _repo_root()
except Exception as exc:
_eprint(f"ERROR: not a git repository: {exc}")
return 2
os.chdir(root)
require_emoji = os.getenv("COMMIT_LINT_REQUIRE_EMOJI", "1") not in ("0", "false", "False")
try:
spec_path = _find_commit_spec(root)
except FileNotFoundError as exc:
_eprint(f"ERROR: {exc}")
return 2
try:
mapping = _parse_type_emoji_mapping(spec_path.read_text(encoding="utf-8"))
except Exception as exc:
_eprint(f"ERROR: failed to read/parse {spec_path}: {exc}")
return 2
event_name, payload = _load_event_payload()
subjects = _gather_subjects(event_name, payload)
print(f"commit spec: {spec_path}")
if event_name:
print(f"event: {event_name}")
print(f"require emoji: {require_emoji}")
print(f"checks: {len(subjects)} subject(s)")
errors: List[str] = []
for label, subject in subjects:
err = _validate_subject_line(subject, mapping, require_emoji=require_emoji)
if err:
errors.append(f"- {label}: {err}\n subject: {subject}")
if errors:
_eprint("ERROR: commit message lint failed:")
for item in errors:
_eprint(item)
return 1
print("OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,66 +0,0 @@
name: ✅ Standards Check
on:
push:
pull_request:
workflow_dispatch: # 允许手动触发
concurrency:
group: standards-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true
# ==========================================
# 🔧 配置区域 - 标准校验参数
# ==========================================
env:
COMMIT_LINT_REQUIRE_EMOJI: "1"
WORKSPACE_DIR: "/home/workspace"
jobs:
commit-message:
name: 🔍 Commit message lint
runs-on: ubuntu-22.04
steps:
- name: 📥 准备仓库
run: |
echo "========================================"
echo "📥 准备仓库到 WORKSPACE_DIR"
echo "========================================"
REPO_NAME="${{ github.event.repository.name }}"
TOKEN="${{ secrets.WORKFLOW }}"
mkdir -p "${{ env.WORKSPACE_DIR }}"
REPO_DIR="$(mktemp -d "${{ env.WORKSPACE_DIR }}/${REPO_NAME}.XXXXXX")"
if [ -n "$TOKEN" ]; then
REPO_URL="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${{ github.repository }}.git"
else
REPO_URL="${GITHUB_SERVER_URL}/${{ github.repository }}.git"
fi
git clone "$REPO_URL" "$REPO_DIR"
TARGET_SHA="${{ github.sha }}"
TARGET_REF="${{ github.ref }}"
if git -C "$REPO_DIR" cat-file -e "$TARGET_SHA^{commit}" 2>/dev/null; then
git -C "$REPO_DIR" checkout -f "$TARGET_SHA"
else
if [ -n "$TARGET_REF" ]; then
git -C "$REPO_DIR" fetch origin "$TARGET_REF"
git -C "$REPO_DIR" checkout -f FETCH_HEAD
else
git -C "$REPO_DIR" checkout -f "${{ github.ref_name }}"
fi
fi
git config --global --add safe.directory "$REPO_DIR"
echo "REPO_DIR=$REPO_DIR" >> "$GITHUB_ENV"
- name: 🧪 Lint commit message / PR title
run: |
cd "$REPO_DIR"
python3 .gitea/ci/commit_message_lint.py
- name: 🧹 清理临时仓库
if: always()
run: |
rm -rf "$REPO_DIR"
+274
View File
@@ -0,0 +1,274 @@
from __future__ import annotations
import base64
import importlib.util
import os
import subprocess
import sys
import unittest
import urllib.request
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
SKILL_ROOT = ROOT / "skills" / "gitea-fix-ci"
SCRIPT = SKILL_ROOT / "scripts" / "fetch_ci_logs.py"
def load_module():
spec = importlib.util.spec_from_file_location("fetch_ci_logs", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class FakeResponse:
status = 200
headers = {"Content-Type": "application/json"}
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self) -> bytes:
return b'{"ok": true}'
class RecordingOpener:
def __init__(self) -> None:
self.requests = []
def open(self, request, *, timeout):
self.requests.append((request, timeout))
return FakeResponse()
class GiteaFixCiSkillTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.module = load_module()
def setUp(self) -> None:
self.target = self.module.RepoTarget(
base_url="https://git.example.test",
owner="team",
repo="project",
)
def test_token_auth_takes_precedence_without_reading_git_credentials(self):
args = SimpleNamespace(use_git_credential=True)
with mock.patch.dict(os.environ, {"GITEA_TOKEN": "token-secret"}, clear=True):
with mock.patch.object(
self.module,
"_git_credential_auth",
side_effect=AssertionError("git credential must not run"),
):
auth = self.module.resolve_auth(args, self.target)
self.assertEqual(auth.source, "GITEA_TOKEN")
self.assertEqual(auth.authorization, "token token-secret")
self.assertNotIn("token-secret", repr(auth))
def test_git_credential_auth_is_explicit_scoped_and_non_interactive(self):
credential = subprocess.CompletedProcess(
args=["git", "credential", "fill"],
returncode=0,
stdout=(
"protocol=https\n"
"host=git.example.test\n"
"username=ci-user\n"
"password=basic-secret\n"
),
stderr="",
)
args = SimpleNamespace(use_git_credential=True)
with mock.patch.dict(os.environ, {}, clear=True):
with mock.patch.object(
self.module.subprocess, "run", return_value=credential
) as run:
auth = self.module.resolve_auth(args, self.target)
self.assertEqual(auth.source, "git credential")
expected = base64.b64encode(b"ci-user:basic-secret").decode("ascii")
self.assertEqual(auth.authorization, f"Basic {expected}")
self.assertNotIn("basic-secret", repr(auth))
call = run.call_args
self.assertEqual(call.args[0], ["git", "credential", "fill"])
self.assertIn("protocol=https\n", call.kwargs["input"])
self.assertIn("host=git.example.test\n", call.kwargs["input"])
self.assertIn("path=team/project.git\n", call.kwargs["input"])
self.assertEqual(call.kwargs["env"]["GIT_TERMINAL_PROMPT"], "0")
self.assertEqual(call.kwargs["env"]["GCM_INTERACTIVE"], "Never")
def test_git_credential_scope_includes_gitea_base_path(self):
target = self.module.RepoTarget(
base_url="https://git.example.test/gitea",
owner="team",
repo="project",
)
self.assertEqual(target.credential_path, "gitea/team/project.git")
def test_git_credentials_are_not_read_without_explicit_flag(self):
args = SimpleNamespace(use_git_credential=False)
with mock.patch.dict(os.environ, {}, clear=True):
with mock.patch.object(
self.module.subprocess,
"run",
side_effect=AssertionError("git credential must not run"),
):
auth = self.module.resolve_auth(args, self.target)
self.assertIsNone(auth)
def test_all_credentials_require_https(self):
target = self.module.RepoTarget(
base_url="http://git.example.test",
owner="team",
repo="project",
)
with mock.patch.dict(os.environ, {"GITEA_TOKEN": "secret"}, clear=True):
with self.assertRaisesRegex(self.module.ConfigError, "HTTPS"):
self.module.resolve_auth(
SimpleNamespace(use_git_credential=False), target
)
with mock.patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(self.module.ConfigError, "HTTPS"):
self.module.resolve_auth(
SimpleNamespace(use_git_credential=True), target
)
auth = self.module.ApiAuth(
source="GITEA_TOKEN",
authorization="token sensitive-value",
)
with self.assertRaisesRegex(self.module.ConfigError, "HTTPS"):
self.module.ApiClient(target, auth=auth, opener=RecordingOpener())
def test_invalid_token_header_is_rejected_without_echoing_secret(self):
secret = "token-secret\ninjected-header"
with mock.patch.dict(os.environ, {"GITEA_TOKEN": secret}, clear=True):
with self.assertRaises(self.module.ConfigError) as raised:
self.module.resolve_auth(
SimpleNamespace(use_git_credential=False), self.target
)
self.assertNotIn("token-secret", str(raised.exception))
self.assertNotIn("injected-header", str(raised.exception))
def test_missing_git_credential_fails_without_echoing_helper_output(self):
credential = subprocess.CompletedProcess(
args=["git", "credential", "fill"],
returncode=1,
stdout="",
stderr="helper diagnostic containing basic-secret",
)
with mock.patch.dict(os.environ, {}, clear=True):
with mock.patch.object(
self.module.subprocess, "run", return_value=credential
):
with self.assertRaises(self.module.ConfigError) as raised:
self.module.resolve_auth(
SimpleNamespace(use_git_credential=True), self.target
)
self.assertNotIn("basic-secret", str(raised.exception))
def test_git_credential_timeout_does_not_echo_helper_output(self):
timeout = subprocess.TimeoutExpired(
cmd=["git", "credential", "fill"],
timeout=10,
output="username=ci-user\npassword=basic-secret\n",
stderr="helper diagnostic containing basic-secret",
)
with mock.patch.dict(os.environ, {}, clear=True):
with mock.patch.object(self.module.subprocess, "run", side_effect=timeout):
with self.assertRaises(self.module.ConfigError) as raised:
self.module.resolve_auth(
SimpleNamespace(use_git_credential=True), self.target
)
self.assertNotIn("basic-secret", str(raised.exception))
def test_api_client_sends_auth_only_to_the_configured_origin(self):
opener = RecordingOpener()
auth = self.module.ApiAuth(
source="git credential",
authorization="Basic sensitive-value",
)
client = self.module.ApiClient(self.target, auth=auth, opener=opener)
status, body, _ = client.request(
self.target.repo_path("/actions/runs"), accept_json=True
)
self.assertEqual(status, 200)
self.assertEqual(body, b'{"ok": true}')
request, timeout = opener.requests[0]
self.assertEqual(request.get_header("Authorization"), "Basic sensitive-value")
self.assertEqual(timeout, self.module.REQUEST_TIMEOUT)
with self.assertRaisesRegex(self.module.ConfigError, "origin"):
client.request("https://evil.example/actions/runs", accept_json=True)
self.assertEqual(len(opener.requests), 1)
def test_api_client_rejects_cross_origin_redirects(self):
auth = self.module.ApiAuth(
source="git credential",
authorization="Basic sensitive-value",
)
handler = self.module.SameOriginRedirectHandler(self.target)
request = urllib.request.Request(
self.target.repo_path("/actions/runs"),
headers={"Authorization": auth.authorization},
)
with self.assertRaises(self.module.ConfigError) as raised:
handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://evil.example/actions/runs",
)
self.assertNotIn("sensitive-value", str(raised.exception))
def test_401_diagnostics_distinguish_missing_and_rejected_auth(self):
anonymous = self.module._explain_status(401, self.target, None)
self.assertIn("GITEA_TOKEN", anonymous)
self.assertIn("--use-git-credential", anonymous)
auth = self.module.ApiAuth(
source="git credential",
authorization="Basic sensitive-value",
)
rejected = self.module._explain_status(401, self.target, auth)
self.assertIn("git credential", rejected)
self.assertNotIn("sensitive-value", rejected)
def test_403_and_404_diagnostics_have_distinct_actions(self):
auth = self.module.ApiAuth(
source="GITEA_TOKEN",
authorization="token sensitive-value",
)
forbidden = self.module._explain_status(403, self.target, auth)
self.assertIn("permission", forbidden)
self.assertIn("GITEA_TOKEN", forbidden)
missing = self.module._explain_status(404, self.target, auth)
self.assertIn("endpoint", missing)
self.assertIn("version", missing)
self.assertNotIn("sensitive-value", forbidden + missing)
def test_skill_documents_explicit_git_credential_auth(self):
skill = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("--use-git-credential", skill)
self.assertIn("GITEA_TOKEN", skill)
self.assertIn("HTTPS", skill)
if __name__ == "__main__":
unittest.main()
+33 -13
View File
@@ -52,10 +52,11 @@ def copy_subtree_source(destination: Path) -> None:
)
(destination / "skills").mkdir()
shutil.copytree(
ROOT / "skills" / "style-cleanup",
destination / "skills" / "style-cleanup",
)
for name in ("commit-message",):
shutil.copytree(
ROOT / "skills" / name,
destination / "skills" / name,
)
def write_config(project_root: Path, install_mode: str, playbook_root: Path) -> Path:
@@ -86,7 +87,7 @@ no_backup = true
[install_skills]
agents_home = ".test-agents"
mode = "list"
skills = ["style-cleanup"]
skills = ["commit-message"]
no_backup = true
""".lstrip(),
encoding="utf-8",
@@ -153,7 +154,9 @@ class PlaybookDeploymentTests(unittest.TestCase):
".agents/index.md",
".agents/tsl/index.md",
".agents/markdown/index.md",
".test-agents/skills/style-cleanup/SKILL.md",
".test-agents/skills/commit-message/SKILL.md",
".test-agents/skills/commit-message/references/commit_policy.json",
".test-agents/skills/commit-message/scripts/validate_commit_message.py",
)
missing = [
path
@@ -189,14 +192,31 @@ class PlaybookDeploymentTests(unittest.TestCase):
)
self.assertIn(f"- {docs_prefix}", agents_index)
installed_skill = (
project_root
/ ".test-agents/skills/style-cleanup/SKILL.md"
).read_text(encoding="utf-8")
self.assertIn(
f"`{docs_prefix}/tsl/code_style.md`", installed_skill
source_skill = ROOT / "skills" / "commit-message"
installed_commit_skill = (
project_root / ".test-agents/skills/commit-message"
)
self.assertEqual(
(installed_commit_skill / "SKILL.md").read_text(encoding="utf-8"),
(source_skill / "SKILL.md").read_text(encoding="utf-8"),
)
self.assertEqual(
(
installed_commit_skill / "references/commit_policy.json"
).read_text(encoding="utf-8"),
(source_skill / "references/commit_policy.json").read_text(
encoding="utf-8"
),
)
self.assertEqual(
(
installed_commit_skill
/ "scripts/validate_commit_message.py"
).read_text(encoding="utf-8"),
(
source_skill / "scripts/validate_commit_message.py"
).read_text(encoding="utf-8"),
)
self.assertNotIn("`docs/tsl/code_style.md`", installed_skill)
rules_text = (project_root / "AGENT_RULES.md").read_text(
encoding="utf-8"
+20
View File
@@ -254,6 +254,26 @@ class ThirdpartySkillsPipelineTests(unittest.TestCase):
clone_mirror = run_command("git", "clone", "--mirror", str(ROOT), str(mirror))
self.assertEqual(clone_mirror.returncode, 0, msg=clone_mirror.stderr)
main_ref = run_command(
"git",
f"--git-dir={mirror}",
"rev-parse",
"refs/remotes/origin/main",
)
self.assertEqual(main_ref.returncode, 0, msg=main_ref.stderr)
expose_main_branch = run_command(
"git",
f"--git-dir={mirror}",
"update-ref",
"refs/heads/main",
main_ref.stdout.strip(),
)
self.assertEqual(
expose_main_branch.returncode,
0,
msg=expose_main_branch.stderr,
)
thirdparty_ref = run_command(
"git",
f"--git-dir={mirror}",