Compare commits
7
Commits
d83707de23
...
31bcd80d3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31bcd80d3f | ||
|
|
ef1ea20f62 | ||
|
|
67cab60a9f | ||
|
|
736663a0b0 | ||
|
|
09314a2bdb | ||
|
|
3117ca5ae4 | ||
|
|
38ea8caaeb |
@@ -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
|
||||
|
||||
@@ -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/24,Top-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
@@ -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
@@ -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
@@ -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`。较旧的
|
||||
Gitea(1.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 错误、类型错误
|
||||
- 环境失败:缺失 secret、runner 镜像、依赖安装、网络、缓存、权限或服务启动
|
||||
- 工作流失败: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:` 仓库、分支/SHA、PR 或 run ID
|
||||
- `Failed CI:` workflow、job、状态、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())
|
||||
@@ -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
|
||||
@@ -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。
|
||||
|
||||
@@ -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
@@ -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/` 目录。
|
||||
**使用方式**:这些模板保留在快照中供参考,需手动复制到项目根目录使用。
|
||||
|
||||
## 技术细节
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# CI 模板(templates/ci)
|
||||
|
||||
本目录提供“目标项目可复制启用”的 CI 模板示例,用于在 CI 中自动化校验部分 Playbook 规范。
|
||||
|
||||
当前提供:
|
||||
|
||||
- `gitea/`:Gitea Actions(GitHub 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"
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user