1 Commits

Author SHA1 Message Date
sysadmin d1d2bc2505 docs: add portable llm-project-workflow skill + templates (#46)
Extract the project's operating rules into a reusable, project-agnostic skill
so any repo can adopt the same safe LLM workflow.

- skills/llm-project-workflow/SKILL.md: issue-first; isolated branch worktrees
  (main checkout = orchestration only); distinct author/reviewer identities and
  profile safety (secrets by reference only; stop if authenticated user == PR
  author); branch naming; start/review/merge/cleanup workflows; fail-closed
  cases; recovery patterns; and an "Adapting to a project" table for the
  forge-specific names.
- templates/: copy/paste prompts for start-issue, review-pr, merge-pr,
  recover-bad-state, worktree-cleanup.
- Link the skill from README.md and docs/llm-workflow-runbooks.md (the runbook
  is framed as the Gitea-specific application of the portable skill).

Docs-only; no code, no secrets, safe placeholder examples only. No change to
MCP runtime, Gitea API, credential storage, or worktree helpers.

Checks: full suite 287 passed / 0 failures; git diff --check clean; secret scan
of skills/ clean.

Closes #46. Refs #38, #39.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 03:26:25 -04:00
17 changed files with 49 additions and 1316 deletions
-41
View File
@@ -274,15 +274,6 @@ The generated launcher snippets contain only `command`, `args`,
`GITEA_MCP_CONFIG`, and `GITEA_MCP_PROFILE` — never a token or password. `GITEA_MCP_CONFIG`, and `GITEA_MCP_PROFILE` — never a token or password.
</details> </details>
### Portable LLM workflow skill
Reusable LLM operating rules are packaged as a portable skill at
[`skills/llm-project-workflow/SKILL.md`](skills/llm-project-workflow/SKILL.md).
It documents issue-first work, isolated branch worktrees, no self-review or
self-merge, profile safety, fail-closed behavior, merge cleanup, and recovery
patterns. Copy the `skills/llm-project-workflow/` directory into other projects
that should use the same workflow.
<details> <details>
<summary><strong>Codex / non-MCP tools</strong></summary> <summary><strong>Codex / non-MCP tools</strong></summary>
@@ -382,35 +373,3 @@ python3 -m pytest tests/ -v
| `test_mirror_refs.py` | Flags, safety defaults, local integration tests | | `test_mirror_refs.py` | Flags, safety defaults, local integration tests |
All tests mock network and keychain access — no real API calls are made. All tests mock network and keychain access — no real API calls are made.
## Troubleshooting
### macOS: `com.apple.provenance` blocks Python execution (#3)
On macOS Sequoia and later, files written by an agent/IDE terminal receive the
`com.apple.provenance` extended attribute, and macOS blocks `Python.app` from
**executing** such files. Symptoms: newly created/restored `.py` files fail to
run (e.g. `create_issue.py` "vanishing" or refusing to execute), while shell
scripts and files created before the session are unaffected. This is a macOS
security feature, not a bug in this project's code.
Workarounds (run from a terminal with **Full Disk Access**, e.g. `Terminal.app`
— not the IDE terminal, or the removal itself may be blocked):
```bash
# Preferred: strip only com.apple.provenance under the repo (dry-run first)
./scripts/clear-provenance --dry-run
./scripts/clear-provenance
# Or a single file
./scripts/clear-provenance /path/to/file.py
# Manual equivalents
xattr -r -d com.apple.provenance /Users/jasonwalker/Development/Gitea-Tools/
xattr -cr /Users/jasonwalker/Development/Gitea-Tools/ # clears ALL xattrs
```
Alternatively, grant Full Disk Access to the terminal app in
**System Settings → Privacy & Security**. `scripts/clear-provenance` removes only
`com.apple.provenance` (leaving other extended attributes intact) and supports
`--dry-run`.
+5 -73
View File
@@ -18,12 +18,6 @@ behavior they rely on already exists (canonical runtime profiles, the
interactive setup menu, identity/eligibility checks, gated review/merge, and interactive setup menu, identity/eligibility checks, gated review/merge, and
audit logging). See [Related documents](#related-documents). audit logging). See [Related documents](#related-documents).
For cross-project use, copy the portable workflow skill at
[`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
It extracts the issue-first, isolated-worktree, no-self-review, profile-safety,
merge-cleanup, fail-closed, and recovery rules into a reusable package that can
be adapted to other repositories.
## Principle: the profile is the role, not the LLM ## Principle: the profile is the role, not the LLM
```text ```text
@@ -180,24 +174,6 @@ under `branches/`. The main repository checkout is an orchestration checkout:
use it for status checks, issue creation/claiming, and creating worktrees, but use it for status checks, issue creation/claiming, and creating worktrees, but
do not edit tracked repository files there. do not edit tracked repository files there.
**Issue → branch → worktree → PR → cleanup.** Every implementation branch is
tied to an issue number so the work is traceable end to end:
| Stage | Form |
|-------|------|
| Issue | `#123` (claimed with `status:in-progress`) |
| Branch | `(fix\|feat\|docs\|chore)/issue-123-<slug>` (review: `review/pr-456-<slug>`) |
| Worktree | `branches/fix-issue-123-<slug>` (slashes → hyphens) |
| PR | body says `Closes #123` (closes) or `Refs #123` (related) |
| Cleanup | remove remote+local branch + worktree folder; drop `status:in-progress` |
`scripts/worktree-start` **rejects** implementation branches that are not
issue-linked (use `--allow-unlinked` only for genuine exceptions). When claiming,
post a comment like
`Claimed. Branch: fix/issue-123-<slug>. Worktree: branches/fix-issue-123-<slug>.`
Gitea has no native issue→branch API field (only a PR's head branch), so this
linkage is enforced by branch name + claim comment + PR body + cleanup.
Branch folders are ignored by git via `branches/`, so dirty work in one issue Branch folders are ignored by git via `branches/`, so dirty work in one issue
does not block starting an unrelated issue in a separate branch folder. No LLM does not block starting an unrelated issue in a separate branch folder. No LLM
may edit another issue's branch folder unless explicitly assigned to that issue. may edit another issue's branch folder unless explicitly assigned to that issue.
@@ -299,19 +275,14 @@ touching anything.
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and - **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.` it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
### Close the issue after merge / Reconciliation ### Close the issue after merge
- **Profile:** issue-manager or merger. - **Profile:** issue-manager or merger.
- **Steps:** verify remote `master` actually contains the merge; close the - **Steps:** verify remote `master` actually contains the merge; close the
issue; release `status:in-progress` (if it cannot be removed, report why). issue (or rely on a `Closes #N` keyword); release `status:in-progress`;
- **If closed but not merged (`merged=false`):** Stop normal flow. Do not delete worktrees. Compare PR content to remote `master`. clean up merged branches.
- **fully landed:** comment it landed, remove `status:in-progress`, clean up. - **Prompt:** `After confirming master contains the merge of PR #N, close issue
- **partially landed:** reopen issue, create corrective PR for missing pieces. #M and delete the merged branch.`
- **not landed:** reopen issue/PR, do not clean up.
- **Direct push to master:** is forbidden except as a documented recovery exception. Final reports must include why, commits, PR metadata, and repaired labels.
- **Final reports:** must include both PR metadata (state, merged flag, merge commit) and Git content (remote master hash, expected content present).
- **Prompt (normal):** `After confirming master contains the merge of PR #N, close issue #M and delete the merged branch.`
- **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.`
### Stop on blocker ### Stop on blocker
@@ -342,44 +313,6 @@ All mutating attempts — allowed, blocked, failed, or succeeded — are audit-l
with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
[`safety-model.md`](safety-model.md)). [`safety-model.md`](safety-model.md)).
## Releases and version tags
Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while
unstable. Pick the bump by the largest change since the last tag:
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
- **MINOR** — new MCP tools, new workflow helpers, new config features;
backward-compatible behavior.
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
Tags are **annotated** (`git tag -a`), created **only from the exact commit on
remote `master`**, **only after the full suite passes**, and carry release notes
referencing the merged PRs/issues. **Never tag** feature branches, dirty
worktrees, unreviewed or self-authored work, or commits not on remote `master`.
Release runbook (see [`../skills/llm-project-workflow/templates/release-tag.md`](../skills/llm-project-workflow/templates/release-tag.md)):
1. `git fetch prgs --prune`.
2. Confirm local `master` equals `prgs/master` (`0 0`) and the tree is clean.
3. Run the full test suite; stop on failure.
4. Review merged issues/PRs since the last tag
(`git log --oneline <last-tag>..prgs/master`).
5. Choose the version bump.
6. `git tag -a <vX.Y.Z> prgs/master -m "<notes referencing #issues / PRs>"`.
7. `git push prgs <vX.Y.Z>`; add release notes if the forge supports it.
`scripts/release-tag` automates steps 17 with these gates built in (SemVer
check, fetch/prune, on-master, clean tree, local==remote master, HEAD on remote
master, no duplicate tag, tests run unless `--skip-tests`, annotated tag only).
It is **safe by default** — no push unless `--push`, and `--dry-run` changes
nothing:
```bash
scripts/release-tag --dry-run v0.4.0
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
```
## Safety notes ## Safety notes
- Never place raw tokens or passwords in any LLM MCP config; reference secrets - Never place raw tokens or passwords in any LLM MCP config; reference secrets
@@ -389,7 +322,6 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
## Related documents ## Related documents
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model. - [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
- [`safety-model.md`](safety-model.md) — trust boundaries and audit logging. - [`safety-model.md`](safety-model.md) — trust boundaries and audit logging.
- [`tool-boundaries.md`](tool-boundaries.md) — per-tool allowed operations. - [`tool-boundaries.md`](tool-boundaries.md) — per-tool allowed operations.
+17 -78
View File
@@ -4,14 +4,9 @@
Auth follows the project convention: credentials are pulled from the macOS Auth follows the project convention: credentials are pulled from the macOS
keychain via `git credential fill` (HTTPS), then sent as Basic auth. keychain via `git credential fill` (HTTPS), then sent as Basic auth.
Modes (default = create labels then apply the one-off MAPPING, preserving the Usage:
original behavior): ./manage_labels.py # create labels, then apply the mapping below
./manage_labels.py --dry # print actions without writing
./manage_labels.py # create labels + apply MAPPING
./manage_labels.py --create-labels # idempotent label creation only
./manage_labels.py --apply-mapping # one-off MAPPING labeling only
./manage_labels.py --add-label 42 chore # add one label to one issue
./manage_labels.py --dry ... # print actions without writing
""" """
import os import os
import sys import sys
@@ -39,7 +34,7 @@ LABELS = [
"description": "Issue is being worked on"}, "description": "Issue is being worked on"},
] ]
# issue number -> label names to apply (one-off backfill) # issue number -> label names to apply
MAPPING = { MAPPING = {
23: ["chore"], 23: ["chore"],
22: ["chore"], 22: ["chore"],
@@ -61,11 +56,6 @@ MAPPING = {
BASE_URL = repo_api_url(HOST, ORG, REPO) BASE_URL = repo_api_url(HOST, ORG, REPO)
USAGE = (
"usage: manage_labels.py [--dry] "
"[--create-labels | --apply-mapping | --add-label <issue> <label>]"
)
def api(method, path, auth, payload=None): def api(method, path, auth, payload=None):
"""Thin wrapper around auth.api_request that prepends BASE_URL and """Thin wrapper around auth.api_request that prepends BASE_URL and
@@ -78,15 +68,19 @@ def api(method, path, auth, payload=None):
return None return None
def _labels_by_name(auth): def main():
"""Return {label name: id} for the repo's existing labels.""" dry = "--dry" in sys.argv
auth = get_auth_header(HOST)
if auth is None:
print("Could not get credentials from git credential fill",
file=sys.stderr)
sys.exit(1)
# 1. Existing labels -> name:id
existing = api("GET", "/labels?limit=100", auth) or [] existing = api("GET", "/labels?limit=100", auth) or []
return {lb["name"]: lb["id"] for lb in existing} by_name = {l["name"]: l["id"] for l in existing}
# 2. Create missing labels
def create_labels(auth, dry=False):
"""Idempotently create the LABELS set; return the resulting name->id map."""
by_name = _labels_by_name(auth)
for spec in LABELS: for spec in LABELS:
if spec["name"] in by_name: if spec["name"] in by_name:
print(f"label exists: {spec['name']}") print(f"label exists: {spec['name']}")
@@ -98,13 +92,8 @@ def create_labels(auth, dry=False):
if created: if created:
by_name[created["name"]] = created["id"] by_name[created["name"]] = created["id"]
print(f"created label: {created['name']} (id {created['id']})") print(f"created label: {created['name']} (id {created['id']})")
return by_name
# 3. Apply mapping
def apply_mapping(auth, by_name=None, dry=False):
"""Apply the one-off MAPPING (PUT replaces each issue's label set)."""
if by_name is None:
by_name = _labels_by_name(auth)
for issue, names in sorted(MAPPING.items(), reverse=True): for issue, names in sorted(MAPPING.items(), reverse=True):
ids = [by_name[n] for n in names if n in by_name] ids = [by_name[n] for n in names if n in by_name]
missing = [n for n in names if n not in by_name] missing = [n for n in names if n not in by_name]
@@ -116,59 +105,9 @@ def apply_mapping(auth, by_name=None, dry=False):
# PUT replaces the issue's labels with exactly this set (idempotent). # PUT replaces the issue's labels with exactly this set (idempotent).
res = api("PUT", f"/issues/{issue}/labels", auth, {"labels": ids}) res = api("PUT", f"/issues/{issue}/labels", auth, {"labels": ids})
if res is not None: if res is not None:
applied = [lb["name"] for lb in res] applied = [l["name"] for l in res]
print(f"#{issue} labeled: {applied}") print(f"#{issue} labeled: {applied}")
def add_label(auth, issue, label, dry=False):
"""Ad-hoc: ADD a single existing label to one issue (append, not replace)."""
by_name = _labels_by_name(auth)
if label not in by_name:
print(f" unknown label '{label}'; create it first (--create-labels)",
file=sys.stderr)
return False
if dry:
print(f"[dry] #{issue} += {label}")
return True
# POST appends to the issue's existing labels (does not replace).
res = api("POST", f"/issues/{issue}/labels", auth, {"labels": [by_name[label]]})
if res is not None:
print(f"#{issue} += {label}")
return True
return False
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
dry = "--dry" in argv or "--dry-run" in argv
auth = get_auth_header(HOST)
if auth is None:
print("Could not get credentials from git credential fill",
file=sys.stderr)
sys.exit(1)
if "--create-labels" in argv:
create_labels(auth, dry=dry)
elif "--apply-mapping" in argv:
apply_mapping(auth, dry=dry)
elif "--add-label" in argv:
i = argv.index("--add-label")
if i + 2 >= len(argv):
print(USAGE, file=sys.stderr)
sys.exit(2)
try:
issue = int(argv[i + 1])
except ValueError:
print(f"--add-label: issue must be a number, got '{argv[i + 1]}'",
file=sys.stderr)
sys.exit(2)
add_label(auth, issue, argv[i + 2], dry=dry)
else:
# Default (backward compatible): create labels, then apply the mapping.
by_name = create_labels(auth, dry=dry)
apply_mapping(auth, by_name, dry=dry)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+1 -110
View File
@@ -14,7 +14,6 @@ Configuration (mcp_config.json):
} }
""" """
import os import os
import re
import sys import sys
import functools import functools
import contextlib import contextlib
@@ -49,71 +48,6 @@ mcp = FastMCP("gitea-tools", instructions=(
)) ))
def extract_linked_issue_numbers(text: str | None, branch_name: str | None = None) -> list[int]:
issues = set()
if text:
pattern = re.compile(r'(?i)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|ref[s]?)\s+#(\d+)')
issues.update(int(m) for m in pattern.findall(text))
if branch_name:
pattern = re.compile(r'(?i)issue-(\d+)')
issues.update(int(m) for m in pattern.findall(branch_name))
return sorted(list(issues))
def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
if not issue_numbers:
return {}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
try:
labels = api_request("GET", f"{base}/labels?limit=100", auth)
label_id = None
for lb in labels:
if lb["name"] == "status:in-progress":
label_id = lb["id"]
break
except Exception as exc:
return {num: f"error fetching repo labels: {_redact(str(exc))}" for num in issue_numbers}
results = {}
if label_id is None:
for num in issue_numbers:
results[num] = "not present"
return results
for num in issue_numbers:
try:
url = f"{base}/issues/{num}"
issue_data = api_request("GET", url, auth)
issue_labels = [lb["name"] for lb in issue_data.get("labels", [])]
if "status:in-progress" in issue_labels:
with _audited("release_in_progress_label", host=h, remote=remote, org=o, repo=r, issue_number=num, request_metadata={"action": "remove status:in-progress"}):
api_request("DELETE", f"{url}/labels/{label_id}", auth)
results[num] = "released"
else:
results[num] = "not present"
except Exception as exc:
results[num] = f"error: {_redact(str(exc))}"
return results
def cleanup_in_progress_for_pr(pr_payload: dict, remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
body = pr_payload.get("body") or ""
title = pr_payload.get("title") or ""
branch = pr_payload.get("head", {}).get("ref") or ""
text = f"{title}\n{body}"
issues = extract_linked_issue_numbers(text, branch)
if not issues:
return {"cleanup_status": "no linked issue found"}
results = release_in_progress_label(issues, remote, host, org, repo)
return {"cleanup_status": results}
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None): def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
@@ -809,20 +743,6 @@ def gitea_edit_pr(
with _audited("edit_pr", host=h, remote=remote, org=o, repo=r, with _audited("edit_pr", host=h, remote=remote, org=o, repo=r,
pr_number=pr_number, request_metadata={"fields": sorted(payload)}): pr_number=pr_number, request_metadata={"fields": sorted(payload)}):
data = api_request("PATCH", url, auth, payload) data = api_request("PATCH", url, auth, payload)
cleanup_status = None
if state == "closed":
cleanup = cleanup_in_progress_for_pr(data, remote, host, org, repo)
cleanup_status = cleanup.get("cleanup_status")
if isinstance(cleanup_status, dict):
for issue_num, st in cleanup_status.items():
if st == "released":
try:
comment_url = f"{repo_api_url(h, o, r)}/issues/{issue_num}/comments"
api_request("POST", comment_url, auth, {"body": f"Tracker cleanup: removed `status:in-progress` from this issue because linked PR #{pr_number} was closed."})
except Exception:
pass
return { return {
"success": True, "success": True,
"number": data["number"], "number": data["number"],
@@ -830,7 +750,6 @@ def gitea_edit_pr(
"body": data.get("body", ""), "body": data.get("body", ""),
"state": data["state"], "state": data["state"],
"url": data["html_url"], "url": data["html_url"],
"cleanup_status": cleanup_status,
} }
@@ -1102,9 +1021,6 @@ def gitea_merge_pr(
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
) )
result["merge_commit"] = (merged or {}).get("merged_commit_sha") result["merge_commit"] = (merged or {}).get("merged_commit_sha")
cleanup = cleanup_in_progress_for_pr(merged or {}, remote, host, org, repo)
result["cleanup_status"] = cleanup.get("cleanup_status")
except Exception: except Exception:
result["merge_commit"] = None result["merge_commit"] = None
except Exception as exc: # noqa: BLE001 — redact before surfacing except Exception as exc: # noqa: BLE001 — redact before surfacing
@@ -1241,14 +1157,7 @@ def gitea_close_issue(
with _audited("close_issue", host=h, remote=remote, org=o, repo=r, with _audited("close_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number, request_metadata={"state": "closed"}): issue_number=issue_number, request_metadata={"state": "closed"}):
api_request("PATCH", url, auth, {"state": "closed"}) api_request("PATCH", url, auth, {"state": "closed"})
return {"success": True, "message": f"Issue #{issue_number} closed."}
cleanup_result = release_in_progress_label([issue_number], remote, host, org, repo)
return {
"success": True,
"message": f"Issue #{issue_number} closed.",
"cleanup_status": cleanup_result
}
@mcp.tool() @mcp.tool()
@@ -1384,24 +1293,6 @@ def gitea_whoami(
} }
@mcp.tool()
def gitea_get_authenticated_user(
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Alias for gitea_whoami. Look up the authenticated Gitea account."""
return gitea_whoami(remote=remote, host=host)
@mcp.tool()
def gitea_get_current_user(
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Alias for gitea_whoami. Look up the authenticated Gitea account."""
return gitea_whoami(remote=remote, host=host)
@mcp.tool() @mcp.tool()
def gitea_get_profile( def gitea_get_profile(
remote: str = "dadeschools", remote: str = "dadeschools",
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# clear-provenance — strip the macOS com.apple.provenance extended attribute so
# Python.app can execute .py files created by agent/IDE terminals (issue #3).
#
# macOS Sequoia+ blocks Python.app from executing files carrying
# com.apple.provenance. Files written by the agent terminal get it; shell
# scripts are unaffected. This is a macOS security feature, not a bug in our
# code — see the Troubleshooting section of the README.
#
# Run from a terminal with Full Disk Access (e.g. Terminal.app), not the IDE
# terminal, or the removal itself may be blocked.
usage() {
cat <<'EOF'
usage: scripts/clear-provenance [--dry-run] [path]
Recursively remove the com.apple.provenance extended attribute under <path>
(default: the repository root). macOS only. Only that attribute is removed;
other extended attributes are left intact.
Examples:
scripts/clear-provenance --dry-run
scripts/clear-provenance
scripts/clear-provenance /path/to/file.py
EOF
}
dry_run=0
while [[ "${1:-}" == --* ]]; do
case "$1" in
--dry-run) dry_run=1 ;;
--help) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
shift
done
if [[ $# -gt 1 ]]; then
usage >&2
exit 2
fi
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
target="${1:-$repo_root}"
if [[ ! -e "$target" ]]; then
printf 'clear-provenance: no such path: %s\n' "$target" >&2
exit 1
fi
# Remove only com.apple.provenance; tolerate files that do not carry it.
if [[ "$dry_run" -eq 1 ]]; then
printf 'clear-provenance: [dry-run] would run: xattr -r -d com.apple.provenance %q\n' "$target"
exit 0
fi
xattr -r -d com.apple.provenance "$target" 2>/dev/null || true
printf 'clear-provenance: removed com.apple.provenance recursively under: %s\n' "$target"
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# release-tag — create an annotated release tag safely from remote master.
# Enforces the documented tagging policy (see docs/llm-workflow-runbooks.md and
# skills/llm-project-workflow/SKILL.md). Never pushes unless --push is given.
#
# Test/CI injection points (env):
# RELEASE_TAG_REMOTE git remote name (default: prgs)
# RELEASE_TAG_TEST_CMD test command run before tagging
# (default: ./venv/bin/python -m pytest tests/ -q)
REMOTE="${RELEASE_TAG_REMOTE:-prgs}"
TEST_CMD="${RELEASE_TAG_TEST_CMD:-./venv/bin/python -m pytest tests/ -q}"
usage() {
cat <<'EOF'
usage: scripts/release-tag [--dry-run] [--skip-tests] [--push]
[--notes-file <path>] <vMAJOR.MINOR.PATCH>
Create an annotated release tag from remote master, only when the tree/branch
are clean and tests pass. Safe by default: no push unless --push; --dry-run
changes nothing.
Options:
--dry-run Print planned actions; create/push nothing.
--skip-tests Skip the test suite (explicit opt-out; prints a warning).
--push Push the tag to the remote after creating it.
--notes-file <path> Use this file's contents as the annotated-tag message.
--help Show this help.
Examples:
scripts/release-tag --dry-run v0.4.0
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
EOF
}
fail() { printf 'release-tag: %s\n' "$1" >&2; exit "${2:-1}"; }
dry_run=0
skip_tests=0
push=0
notes_file=""
version=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) dry_run=1 ;;
--skip-tests) skip_tests=1 ;;
--push) push=1 ;;
--notes-file) shift; notes_file="${1:-}"; [[ -n "$notes_file" ]] || fail "--notes-file needs a path" 2 ;;
--help) usage; exit 0 ;;
-*) usage >&2; exit 2 ;;
*) if [[ -z "$version" ]]; then version="$1"; else usage >&2; exit 2; fi ;;
esac
shift
done
[[ -n "$version" ]] || { usage >&2; exit 2; }
# 1. SemVer validation (before any git/network work).
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
fail "invalid version '$version' (expected vMAJOR.MINOR.PATCH, e.g. v0.4.0)" 2
fi
if [[ -n "$notes_file" && ! -f "$notes_file" ]]; then
fail "notes file not found: $notes_file" 2
fi
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
git_c=(git -C "$repo_root")
# 2. Fetch/prune first.
"${git_c[@]}" fetch "$REMOTE" --prune
# 3. Must be on master.
current_branch="$("${git_c[@]}" symbolic-ref --quiet --short HEAD || echo DETACHED)"
[[ "$current_branch" == "master" ]] || fail "not on master (on '$current_branch'); tag only from master"
# 4. Clean worktree.
[[ -z "$("${git_c[@]}" status --porcelain)" ]] || fail "worktree is dirty; commit/stash before tagging"
# 5. Local master must equal remote master.
local_sha="$("${git_c[@]}" rev-parse master)"
remote_sha="$("${git_c[@]}" rev-parse "$REMOTE/master")"
[[ "$local_sha" == "$remote_sha" ]] || fail "local master ($local_sha) != $REMOTE/master ($remote_sha)"
# 6. HEAD must be that same commit (present on remote master).
head_sha="$("${git_c[@]}" rev-parse HEAD)"
[[ "$head_sha" == "$remote_sha" ]] || fail "HEAD ($head_sha) is not $REMOTE/master; tag only commits on remote master"
# 7. Tag must not already exist locally or on the remote.
if "${git_c[@]}" rev-parse -q --verify "refs/tags/$version" >/dev/null 2>&1; then
fail "tag $version already exists locally"
fi
if "${git_c[@]}" ls-remote --tags "$REMOTE" "refs/tags/$version" | grep -q .; then
fail "tag $version already exists on $REMOTE"
fi
# Annotation message: notes file, or a minimal default.
if [[ -n "$notes_file" ]]; then
notes_arg=(-F "$notes_file")
else
notes_arg=(-m "$version")
fi
# Tests (default on; explicit --skip-tests warns). Not executed in dry-run.
tests_run="no"
if [[ "$skip_tests" -eq 1 ]]; then
printf 'release-tag: WARNING --skip-tests set; NOT running the test suite before tagging.\n' >&2
elif [[ "$dry_run" -eq 1 ]]; then
printf 'release-tag: [dry-run] would run tests: %s\n' "$TEST_CMD"
else
tests_run="yes"
( cd "$repo_root" && eval "$TEST_CMD" ) || fail "tests failed; refusing to tag"
fi
# Create (and optionally push) the annotated tag.
tag_created="no"
tag_pushed="no"
if [[ "$dry_run" -eq 1 ]]; then
printf 'release-tag: [dry-run] would create annotated tag %s at %s\n' "$version" "$head_sha"
[[ "$push" -eq 1 ]] && printf 'release-tag: [dry-run] would push %s to %s\n' "$version" "$REMOTE"
else
"${git_c[@]}" tag -a "$version" "$head_sha" "${notes_arg[@]}"
tag_created="yes"
if [[ "$push" -eq 1 ]]; then
"${git_c[@]}" push "$REMOTE" "$version"
tag_pushed="yes"
fi
fi
printf 'commit: %s\n' "$head_sha"
printf 'tag: %s\n' "$version"
printf 'tests_run: %s\n' "$tests_run"
printf 'tag_created: %s\n' "$tag_created"
printf 'tag_pushed: %s\n' "$tag_pushed"
+6 -37
View File
@@ -3,32 +3,21 @@ set -euo pipefail
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: scripts/worktree-start [--dry-run] [--allow-unlinked] <branch-name> [start-ref] usage: scripts/worktree-start [--dry-run] <branch-name> [start-ref]
Create an issue-linked git worktree under branches/<branch-name-with-slashes-replaced>. Create an issue-specific git worktree under branches/<branch-name-with-slashes-replaced>.
Branch names must be traceable to an issue (or a PR, for review branches):
implementation: (fix|feat|docs|chore)/issue-<number>-<short-description>
review: review/pr-<number>-<short-description>
Use --allow-unlinked to bypass the check (discouraged).
Examples: Examples:
scripts/worktree-start fix/issue-123-example scripts/worktree-start fix/issue-123-example
scripts/worktree-start --dry-run review/pr-456-scope-check prgs/master scripts/worktree-start --dry-run review/pr-123-scope-check prgs/master
EOF EOF
} }
dry_run=0 dry_run=0
allow_unlinked=0 if [[ "${1:-}" == "--dry-run" ]]; then
while [[ "${1:-}" == --* ]]; do dry_run=1
case "$1" in
--dry-run) dry_run=1 ;;
--allow-unlinked) allow_unlinked=1 ;;
--help) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
shift shift
done fi
if [[ $# -lt 1 || $# -gt 2 ]]; then if [[ $# -lt 1 || $# -gt 2 ]]; then
usage >&2 usage >&2
@@ -38,26 +27,6 @@ fi
branch="$1" branch="$1"
start_ref="${2:-prgs/master}" start_ref="${2:-prgs/master}"
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
if [[ "$allow_unlinked" -eq 0 ]]; then
if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \
|| [[ "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then
:
else
cat >&2 <<EOF
Untraceable branch name: $branch
Implementation branches must be issue-linked:
(fix|feat|docs|chore)/issue-<number>-<short-description>
Review branches:
review/pr-<number>-<short-description>
Fix the branch name, or pass --allow-unlinked to override (discouraged).
EOF
exit 2
fi
fi
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)" repo_root="$(cd "$script_dir/.." && pwd)"
worktree_name="${branch//\//-}" worktree_name="${branch//\//-}"
+12 -104
View File
@@ -19,14 +19,6 @@ identity, and cleaned up only after a real merge.
--- ---
## Definitions
- **Merged**: Gitea PR metadata says `merged=true`.
- **Landed**: Equivalent content is present on remote `master`, but PR metadata may not say merged.
- **Closed-not-merged**: PR state is closed and `merged=false`.
- **Reconciled**: A human/LLM verified whether closed-not-merged content landed, partially landed, or was lost, and repaired issue/label/tracker state.
## A. Issue-first rule ## A. Issue-first rule
**No repository change without a tracking issue.** This includes creating, **No repository change without a tracking issue.** This includes creating,
@@ -53,37 +45,7 @@ orchestration and status only (issue creation, `git status`, creating worktrees)
- Branch folders are removed only after the PR is merged/closed **and** cleanup - Branch folders are removed only after the PR is merged/closed **and** cleanup
is explicitly part of the task. is explicitly part of the task.
Every implementation branch **must include its issue number** so it is Preferred helpers (if present in the project):
traceable end to end: **issue → branch → worktree folder → PR → cleanup.**
Allowed implementation patterns:
- `fix/issue-123-short-description`
- `feat/issue-123-short-description`
- `docs/issue-123-short-description`
- `chore/issue-123-short-description`
Review-only branches:
- `review/pr-456-short-description`
Use a filesystem-safe folder under `branches/` by replacing slashes with
hyphens, for example `branches/fix-issue-123-short-description`.
`scripts/worktree-start` **enforces** this: it rejects an implementation branch
that does not match `(fix|feat|docs|chore)/issue-<number>-…` (or a
`review/pr-<number>-…` branch), unless `--allow-unlinked` is passed. Traceability
is maintained by:
- the branch name (contains the issue number),
- a claim comment on the issue, e.g.
`Claimed. Branch: fix/issue-123-short-description. Worktree: branches/fix-issue-123-short-description.`,
- the PR body — `Closes #123` when the PR should close the issue, `Refs #123`
when related but not closing,
- cleanup after merge — remove the remote branch, local branch, and the issue
worktree folder, and drop `status:in-progress`.
For projects using `Gitea-Tools` helpers:
```bash ```bash
scripts/worktree-start fix/issue-123-example # → branches/fix-issue-123-example scripts/worktree-start fix/issue-123-example # → branches/fix-issue-123-example
@@ -141,14 +103,6 @@ Worktree folder = branch with `/` replaced by `-`
10. Push the branch. 10. Push the branch.
11. Open a PR to `master`. 11. Open a PR to `master`.
12. **If you are the author, stop before review/merge.** 12. **If you are the author, stop before review/merge.**
13. **Normal issue work must not directly push to `master`.** PR content should be merged through the forge PR merge mechanism.
14. Direct push to `master` is allowed only as a documented recovery exception. If used, the final report must include:
- why the PR merge path could not be used
- exact commits pushed
- PR metadata state
- issue labels/state repaired
- whether the PR is closed-not-merged
## F. Review workflow ## F. Review workflow
@@ -164,15 +118,13 @@ Worktree folder = branch with `/` replaced by `-`
Only an eligible (non-author) reviewer merges. After a real merge: Only an eligible (non-author) reviewer merges. After a real merge:
1. Confirm remote `master` actually contains the merge commit (A PR is not done just because `master` moved. A PR is done only when: Gitea reports the PR merged or reconciliation documents equivalent content on `master`; remote `master` contains the expected content; linked issues are closed; `status:in-progress` is removed). 1. Confirm remote `master` actually contains the merge commit.
2. Close/release the issue. 2. Close/release the issue; remove `status:in-progress` if used.
3. Whenever an issue is closed, check for `status:in-progress`: remove it, or report why it could not be removed. 3. Delete the remote branch.
4. Do not delete the remote source branch until: PR `merged=true`, or reconciliation confirms content is safely landed, or the issue owner explicitly abandons the work. 4. Remove the local branch.
5. Remove the local branch. 5. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`).
6. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`). Branches/worktrees are cleaned only after the above is verified. 6. Fetch/prune.
7. Fetch/prune. 7. Confirm the main checkout is clean and current (`0 0` vs remote).
8. Confirm the main checkout is clean and current (`0 0` vs remote).
9. Final merge/reconciliation reports must include both: PR metadata (state, merged flag, merge commit/hash) and Git content (remote master hash, expected content present or not).
Never run cleanup before the merge is confirmed on remote `master`. Never run cleanup before the merge is confirmed on remote `master`.
@@ -183,11 +135,7 @@ Never run cleanup before the merge is confirmed on remote `master`.
- No issue exists and one cannot be created. - No issue exists and one cannot be created.
- Worktree state is unclear or unexpected. - Worktree state is unclear or unexpected.
- Branch/PR state conflicts with the prompt (e.g. prompt says "merged" but it is not). - Branch/PR state conflicts with the prompt (e.g. prompt says "merged" but it is not).
- A PR is closed but not merged (closed with `merged=false`). In this case: - A PR is closed but not merged.
- stop normal review/merge
- do not delete branches/worktrees
- do not start dependent work
- run reconciliation
- Local `master` is ahead of remote unexpectedly. - Local `master` is ahead of remote unexpectedly.
- The authenticated user is the PR author (for review/merge). - The authenticated user is the PR author (for review/merge).
- Secrets/tokens appear in the diff. - Secrets/tokens appear in the diff.
@@ -204,10 +152,9 @@ When in doubt, stop and surface the discrepancy; do not guess or work around a g
the commits are preserved on a feature branch (local + remote) first, then the commits are preserved on a feature branch (local + remote) first, then
`git reset --hard <remote>/master` to realign. Never discard commits that are `git reset --hard <remote>/master` to realign. Never discard commits that are
not safely pushed elsewhere. not safely pushed elsewhere.
- **PR closed but not merged (`merged=false`):** do not merge. Run reconciliation: compare PR content to remote `master` and decide: - **PR closed but not merged:** the work is not in mainline. Re-push the branch,
- **fully landed:** comment that content is present on `master`, remove `status:in-progress`, keep/close issue as appropriate, clean up only after content equivalence is confirmed. reopen (or open a replacement) PR, and let an eligible reviewer merge. Do not
- **partially landed:** do not clean up, reopen issue if needed, create corrective issue/PR for missing pieces. assume "closed" means "merged" — verify remote `master` contains the commits.
- **not landed:** reopen issue if needed, reopen PR or create replacement PR, do not clean up source branch/worktree.
- **Branch deleted before merge:** if the commits still exist locally (a branch or - **Branch deleted before merge:** if the commits still exist locally (a branch or
reflog), re-push them and reopen the PR; otherwise recover via reflog), re-push them and reopen the PR; otherwise recover via
`git fsck --lost-found`. Preserve first, then proceed. `git fsck --lost-found`. Preserve first, then proceed.
@@ -226,9 +173,7 @@ Ready-to-copy templates live in [`templates/`](templates/):
- [`review-pr.md`](templates/review-pr.md) — review a PR. - [`review-pr.md`](templates/review-pr.md) — review a PR.
- [`merge-pr.md`](templates/merge-pr.md) — merge a PR (eligible reviewer only). - [`merge-pr.md`](templates/merge-pr.md) — merge a PR (eligible reviewer only).
- [`recover-bad-state.md`](templates/recover-bad-state.md) — recover from bad state. - [`recover-bad-state.md`](templates/recover-bad-state.md) — recover from bad state.
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md) — reconcile a closed-not-merged PR.
- [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge. - [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge.
- [`release-tag.md`](templates/release-tag.md) — create a release tag.
## Adapting to a project ## Adapting to a project
@@ -243,40 +188,3 @@ Replace these project-specific names when copying the skill elsewhere:
| helper scripts | Worktree helpers | `scripts/worktree-start` / `-review` / `-clean` | | helper scripts | Worktree helpers | `scripts/worktree-start` / `-review` / `-clean` |
The rules in §A–§I are project-agnostic and should not change. The rules in §A–§I are project-agnostic and should not change.
## Versioning And Tagging
Releases follow SemVer: **`vMAJOR.MINOR.PATCH`** (use **`v0.x.y`** while
unstable). Choose the bump by the largest change since the last tag:
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
- **MINOR** — new tools/helpers/config features; backward-compatible behavior.
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
Tags must:
- be created **only from `master`** (the exact commit on remote `master`),
- be created **only after the full test suite passes**,
- be **annotated** tags (`git tag -a`), never lightweight,
- include release notes / a changelog summary referencing the merged PRs/issues.
**Never tag** feature branches, dirty worktrees, unreviewed or self-authored
work, or commits not present on remote `master`.
Release process (see [`templates/release-tag.md`](templates/release-tag.md)):
1. `git fetch <remote> --prune`.
2. Verify local `master` equals remote `master` (`0 0`) and the tree is clean.
3. Run the full test suite; stop on any failure.
4. Inspect merged issues/PRs since the last tag
(`git log --oneline <last-tag>..<remote>/master`).
5. Choose the version bump.
6. Create the annotated tag on remote `master` with release notes.
7. Push the tag.
8. Create/update release notes if the forge supports it.
Where present, `scripts/release-tag` automates this with all gates built in
(SemVer, fetch/prune, on-master, clean tree, local==remote master, HEAD on
remote master, no duplicate tag, tests, annotated-only). Safe by default: no
push without `--push`; `--dry-run` changes nothing; `--skip-tests` must be
explicit and warns.
@@ -10,7 +10,6 @@ Rules (llm-project-workflow):
author → STOP. author → STOP.
- Do not merge unless the PR is open, mergeable, and its checks/review pass. - Do not merge unless the PR is open, mergeable, and its checks/review pass.
- No force-merge, no bypassing branch protections. - No force-merge, no bypassing branch protections.
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
Steps: Steps:
1. Verify authenticated identity + active profile. 1. Verify authenticated identity + active profile.
@@ -21,9 +20,9 @@ Steps:
5. Confirm remote master now contains the merge commit. 5. Confirm remote master now contains the merge commit.
Then run the cleanup template (worktree-cleanup.md): Then run the cleanup template (worktree-cleanup.md):
- close/release issue #<n>, remove status:in-progress (if it cannot be removed, report why) - close/release issue #<n>, remove status:in-progress
- delete remote branch, remove local branch + worktree folder - delete remote branch, remove local branch + worktree folder
- fetch/prune; confirm main checkout is clean and current (0 0). - fetch/prune; confirm main checkout is clean and current (0 0).
Handoff: reviewer identity, merge result + commit, cleanup done, issue closed, PR metadata state/merged flag/hash, remote master hash & Git content check. Handoff: reviewer identity, merge result + commit, cleanup done, issue closed.
``` ```
@@ -1,24 +0,0 @@
# Reconcile Closed-Not-Merged PR Prompt
You are reconciling PR `<pr-number>` in `<repo-name>` which is closed but `merged=false`.
Rules:
- Do not delete branches or worktrees before reconciliation is complete.
- Compare the PR's exact content to remote `<default-branch>`.
- Determine if the content is fully landed, partially landed, or not landed.
Workflow:
1. Verify the PR metadata says `state=closed` and `merged=false`.
2. Fetch/prune and inspect remote `<default-branch>`.
3. If fully landed: comment that it landed, remove `status:in-progress`, close issue, and clean up.
4. If partially landed: reopen issue if needed, create corrective PR for missing pieces, do not clean up.
5. If not landed: reopen issue/PR, do not clean up.
Final handoff:
- PR metadata (state, merged flag, hash)
- Git content verification (remote master hash, expected content present or not)
- reconciliation decision (fully/partially/not landed)
- issue/label state repaired
@@ -22,7 +22,8 @@ Act per case:
- Local master ahead of remote: confirm the extra commits live on a branch - Local master ahead of remote: confirm the extra commits live on a branch
pushed to <remote>, THEN git reset --hard <remote>/master. Verify with pushed to <remote>, THEN git reset --hard <remote>/master. Verify with
`git branch --contains <sha>` first. `git branch --contains <sha>` first.
- PR closed but not merged (`merged=false`): stop normal flow and use reconcile-closed-not-merged-pr.md instead. - PR closed but not merged: re-push the branch, reopen/replace the PR, let an
eligible reviewer merge. Do not merge your own.
- Branch deleted before merge: recover commits from a local branch/reflog (or - Branch deleted before merge: recover commits from a local branch/reflog (or
git fsck --lost-found), re-push, reopen the PR. git fsck --lost-found), re-push, reopen the PR.
- Unauthorized untracked file: do not commit it; leave pre-existing artifacts. - Unauthorized untracked file: do not commit it; leave pre-existing artifacts.
@@ -1,53 +0,0 @@
# Template: cut a release tag
Copy, fill the `<...>` fields, and paste as the task prompt. Tagging is
irreversible-ish and outward-facing — fail closed on any doubt.
> If the project ships `scripts/release-tag`, prefer it — it enforces every gate
> below automatically and is safe by default (no push without `--push`,
> `--dry-run` changes nothing):
>
> ```bash
> scripts/release-tag --dry-run <vX.Y.Z>
> scripts/release-tag <vX.Y.Z> --notes-file <path>
> scripts/release-tag <vX.Y.Z> --notes-file <path> --push
> ```
>
> The manual steps below are the fallback / what the script does.
```text
Task: cut release <vX.Y.Z> from master.
Rules (llm-project-workflow — versioning & tagging):
- SemVer: vMAJOR.MINOR.PATCH (v0.x.y while unstable).
- PATCH: bug fixes, docs, tests, wrappers, non-breaking workflow polish.
- MINOR: new tools/helpers/config features, backward-compatible behavior.
- MAJOR: breaking config/schema/API or changed MCP contract.
- Tag ONLY from clean, tested remote master. Annotated tags only (git tag -a).
- NEVER tag: feature branches, dirty worktrees, unreviewed/self-authored work,
or commits not present on remote master.
Steps:
1. git fetch <remote> --prune
2. Confirm local master == <remote>/master (git rev-list --left-right --count
<remote>/master...master → 0 0) and the tree is clean.
3. Run the FULL test suite; it must pass. STOP on any failure.
4. Inspect merged issues/PRs since the last tag:
git log --oneline <last-tag>..<remote>/master
5. Choose the bump (PATCH/MINOR/MAJOR) per the rules above; set <vX.Y.Z>.
6. Create an ANNOTATED tag on <remote>/master with release notes that reference
the merged PRs/issues:
git tag -a <vX.Y.Z> <remote>/master -m "<vX.Y.Z>: <summary>
- #<n> <title> (PR #<pr>)
- ..."
7. Push the tag: git push <remote> <vX.Y.Z>
8. Create/update the release notes / changelog entry if the forge supports it.
Fail-closed: STOP if tests fail, the tree/worktree is dirty, master != remote,
the target commit is not on remote master, or the work was self-authored/
unreviewed. Never tag to "fix" a failing state.
Handoff: version, bump rationale, commit tagged, tests result, tag pushed,
release notes link.
```
-60
View File
@@ -1,60 +0,0 @@
"""Tests for scripts/clear-provenance (#3).
Exercises argument handling and the inert --dry-run path only — no real xattr
mutation, no network. (Actually removing com.apple.provenance is macOS-only and
has real side effects, so it is not exercised here.)
"""
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SCRIPT = REPO / "scripts" / "clear-provenance"
def run(*args):
proc = subprocess.run(["bash", str(SCRIPT), *args],
capture_output=True, text=True, cwd=str(REPO))
return proc.returncode, proc.stdout, proc.stderr
class TestClearProvenance(unittest.TestCase):
def test_dry_run_defaults_to_repo_root(self):
rc, out, _ = run("--dry-run")
self.assertEqual(rc, 0)
self.assertIn("would run: xattr -r -d com.apple.provenance", out)
self.assertIn(str(REPO), out)
def test_dry_run_explicit_path(self):
with tempfile.TemporaryDirectory() as d:
f = Path(d) / "x.py"
f.write_text("print('hi')\n")
rc, out, _ = run("--dry-run", str(f))
self.assertEqual(rc, 0)
self.assertIn(str(f), out)
def test_missing_path_errors(self):
rc, _, err = run("--dry-run", "/no/such/path-xyz")
self.assertEqual(rc, 1)
self.assertIn("no such path", err)
def test_bad_flag_exit_2(self):
rc, _, _ = run("--bogus")
self.assertEqual(rc, 2)
def test_too_many_args_exit_2(self):
rc, _, _ = run("a", "b")
self.assertEqual(rc, 2)
def test_only_targets_provenance_attribute(self):
# The command removes only com.apple.provenance, not all xattrs.
rc, out, _ = run("--dry-run")
self.assertIn("com.apple.provenance", out)
self.assertNotIn("xattr -rc", out) # not a blanket "clear all"
self.assertNotIn("-c ", out)
if __name__ == "__main__":
unittest.main()
-84
View File
@@ -137,89 +137,5 @@ class TestConstants(unittest.TestCase):
f"Label '{label['name']}' has invalid color") f"Label '{label['name']}' has invalid color")
# ---------------------------------------------------------------------------
# Modes: --create-labels / --apply-mapping / --add-label (#6)
# ---------------------------------------------------------------------------
class TestModes(unittest.TestCase):
def _methods(self, mock_api):
return [(c[0][0], c[0][1]) for c in mock_api.call_args_list]
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
def test_create_labels_only_no_mapping(self, mock_api, _auth):
def se(method, path, auth, payload=None):
if method == "GET":
return [] # no existing labels
if method == "POST" and path == "/labels":
return {"id": 1, "name": payload["name"]}
return None
mock_api.side_effect = se
manage_labels.main(["--create-labels"])
methods = self._methods(mock_api)
self.assertTrue(any(m == ("POST", "/labels") for m in methods))
self.assertFalse(any(m[0] == "PUT" for m in methods)) # no mapping applied
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
def test_apply_mapping_only_no_label_creation(self, mock_api, _auth):
existing = [_make_label(l["name"], i + 1)
for i, l in enumerate(manage_labels.LABELS)]
def se(method, path, auth, payload=None):
if method == "GET":
return existing
if method == "PUT":
return [{"name": "applied"}]
return None
mock_api.side_effect = se
manage_labels.main(["--apply-mapping"])
methods = self._methods(mock_api)
self.assertFalse(any(m == ("POST", "/labels") for m in methods))
put_calls = [m for m in methods if m[0] == "PUT"]
self.assertEqual(len(put_calls), len(manage_labels.MAPPING))
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
def test_add_label_appends_to_issue(self, mock_api, _auth):
existing = [_make_label("chore", 5)]
def se(method, path, auth, payload=None):
if method == "GET":
return existing
if method == "POST":
return [{"name": "chore"}]
return None
mock_api.side_effect = se
manage_labels.main(["--add-label", "42", "chore"])
posts = [c for c in mock_api.call_args_list
if c[0][0] == "POST" and c[0][1] == "/issues/42/labels"]
self.assertEqual(len(posts), 1)
self.assertEqual(posts[0][0][3], {"labels": [5]}) # append, id 5
# POST appends; no PUT (which would replace the whole set).
self.assertFalse(any(c[0][0] == "PUT" for c in mock_api.call_args_list))
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
def test_add_label_unknown_makes_no_write(self, mock_api, _auth):
mock_api.side_effect = lambda *a, **k: [] if a[0] == "GET" else None
manage_labels.main(["--add-label", "42", "ghost"])
# Only the GET label lookup; no POST/PUT for an undefined label.
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list))
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
def test_add_label_dry_makes_no_write(self, mock_api, _auth):
mock_api.side_effect = lambda *a, **k: [_make_label("chore", 5)] if a[0] == "GET" else None
manage_labels.main(["--dry", "--add-label", "42", "chore"])
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list))
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
@patch("manage_labels.api")
def test_add_label_non_numeric_issue_exits(self, mock_api, _auth):
with self.assertRaises(SystemExit):
manage_labels.main(["--add-label", "notanum", "chore"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+1 -249
View File
@@ -93,8 +93,7 @@ class TestCloseIssue(unittest.TestCase):
result = gitea_close_issue(issue_number=42) result = gitea_close_issue(issue_number=42)
self.assertTrue(result["success"]) self.assertTrue(result["success"])
self.assertIn("42", result["message"]) self.assertIn("42", result["message"])
patch_call = next(call for call in mock_api.call_args_list if call[0][0] == "PATCH") payload = mock_api.call_args[0][3]
payload = patch_call[0][3]
self.assertEqual(payload["state"], "closed") self.assertEqual(payload["state"], "closed")
@@ -862,34 +861,6 @@ class TestWhoami(unittest.TestCase):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Runtime profile (env-configured profile metadata) — issue #19 # Runtime profile (env-configured profile metadata) — issue #19
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@patch("mcp_server.api_request")
@patch("mcp_server._auth")
def test_gitea_get_authenticated_user_alias(self, _auth, mock_api):
mock_api.return_value = {
"login": "alias_user",
"full_name": "Alias User",
"id": 999,
"email": "alias@example.com"
}
from mcp_server import gitea_get_authenticated_user
result = gitea_get_authenticated_user(remote="prgs")
self.assertEqual(result["username"], "alias_user")
@patch("mcp_server.api_request")
@patch("mcp_server._auth")
def test_gitea_get_current_user_alias(self, _auth, mock_api):
mock_api.return_value = {
"login": "alias_user",
"full_name": "Alias User",
"id": 999,
"email": "alias@example.com"
}
from mcp_server import gitea_get_current_user
result = gitea_get_current_user(remote="prgs")
self.assertEqual(result["username"], "alias_user")
class TestRuntimeProfile(unittest.TestCase): class TestRuntimeProfile(unittest.TestCase):
def test_defaults_when_unset(self): def test_defaults_when_unset(self):
@@ -1381,222 +1352,3 @@ class TestSubmitPrReview(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
# ---------------------------------------------------------------------------
# Tracker Hygiene Cleanup Tests
# ---------------------------------------------------------------------------
class TestTrackerHygieneCleanup(unittest.TestCase):
def setUp(self):
self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
patch("gitea_audit.audit_enabled", return_value=True).start()
self.mock_audit = patch("gitea_audit.write_event").start()
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["merge", "edit", "close"], "audit_label": "test", "forbidden_operations": []}).start()
def tearDown(self):
patch.stopall()
def test_close_issue_removes_in_progress(self):
def api_side_effect(method, url, auth, payload=None):
if method == "PATCH" and "issues/1" in url:
return {"state": "closed"}
if method == "GET" and "labels" in url and "issues" not in url:
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
if method == "GET" and "issues/1" in url:
return {"labels": [{"name": "status:in-progress"}, {"name": "bug"}]}
if method == "DELETE" and url.endswith("/issues/1/labels/1"):
return {}
if method == "PUT" and "labels" in url:
self.fail("Should not replace the issue label set")
return {}
self.mock_api.side_effect = api_side_effect
res = gitea_close_issue(issue_number=1)
self.assertTrue(res["success"])
self.assertEqual(res["cleanup_status"].get(1), "released")
self.mock_audit.assert_called()
def test_close_issue_no_label_is_noop(self):
def api_side_effect(method, url, auth, payload=None):
if method == "PATCH" and "issues/1" in url:
return {"state": "closed"}
if method == "GET" and "labels" in url and "issues" not in url:
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
if method == "GET" and "issues/1" in url:
return {"labels": [{"name": "bug"}]}
if method == "DELETE" and "labels" in url:
self.fail("Should not DELETE labels")
if method == "PUT" and "labels" in url:
self.fail("Should not replace the issue label set")
return {}
self.mock_api.side_effect = api_side_effect
res = gitea_close_issue(issue_number=1)
self.assertTrue(res["success"])
self.assertEqual(res["cleanup_status"].get(1), "not present")
def test_merge_pr_with_closes_removes_label(self):
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/user" in url:
return {"login": "merger"}
if method == "GET" and "pulls/1" in url and "/files" not in url:
return {
"user": {"login": "author"},
"state": "open",
"head": {"sha": "sha123", "ref": "feat/my-branch"},
"base": {"ref": "main"},
"mergeable": True,
"merged_commit_sha": "merge123",
"title": "My PR",
"body": "Closes #123"
}
if method == "POST" and "merge" in url:
return {}
if method == "GET" and "labels" in url and "issues" not in url:
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
if method == "GET" and "issues/123" in url:
return {"labels": [{"name": "status:in-progress"}, {"name": "bug"}]}
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
return {}
if method == "PUT" and "labels" in url:
self.fail("Should not replace the issue label set")
return {}
self.mock_api.side_effect = api_side_effect
res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge")
self.assertTrue(res["performed"])
self.assertEqual(res["cleanup_status"].get(123), "released")
def test_merge_pr_with_branch_name_removes_label(self):
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/user" in url:
return {"login": "merger"}
if method == "GET" and "pulls/1" in url and "/files" not in url:
return {
"user": {"login": "author"},
"state": "open",
"head": {"sha": "sha123", "ref": "fix/issue-123-slug"},
"base": {"ref": "main"},
"mergeable": True,
"merged_commit_sha": "merge123",
"title": "My PR",
"body": "Fixing things"
}
if method == "POST" and "merge" in url:
return {}
if method == "GET" and "labels" in url and "issues" not in url:
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
if method == "GET" and "issues/123" in url:
return {"labels": [{"name": "status:in-progress"}, {"name": "bug"}]}
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
return {}
if method == "PUT" and "labels" in url:
self.fail("Should not replace the issue label set")
return {}
self.mock_api.side_effect = api_side_effect
res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge")
self.assertTrue(res["performed"])
self.assertEqual(res["cleanup_status"].get(123), "released")
def test_close_pr_removes_label_but_does_not_close_issue(self):
def api_side_effect(method, url, auth, payload=None):
if method == "PATCH" and "pulls/1" in url:
return {
"number": 1,
"title": "My PR",
"state": "closed",
"html_url": "url",
"body": "Closes #123",
"head": {"ref": "feat/my-branch"}
}
if method == "GET" and "labels" in url and "issues" not in url:
return [{"name": "status:in-progress", "id": 1}]
if method == "GET" and "issues/123" in url:
return {"labels": [{"name": "status:in-progress"}]}
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
return {}
if method == "PUT" and "labels" in url:
self.fail("Should not replace the issue label set")
if method == "POST" and "comments" in url:
return {}
return {}
self.mock_api.side_effect = api_side_effect
res = gitea_edit_pr(pr_number=1, state="closed")
self.assertTrue(res["success"])
self.assertEqual(res["cleanup_status"].get(123), "released")
def test_multiple_linked_issues(self):
def api_side_effect(method, url, auth, payload=None):
if method == "PATCH" and "pulls/1" in url:
return {
"number": 1,
"title": "My PR",
"state": "closed",
"html_url": "url",
"body": "Closes #123\nFixes #124",
"head": {"ref": "issue-125"}
}
if method == "GET" and "labels" in url and "issues" not in url:
return [{"name": "status:in-progress", "id": 1}]
if method == "GET" and "issues/123" in url:
return {"labels": [{"name": "status:in-progress"}]}
if method == "GET" and "issues/124" in url:
return {"labels": [{"name": "status:in-progress"}]}
if method == "GET" and "issues/125" in url:
return {"labels": []}
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
return {}
if method == "DELETE" and url.endswith("/issues/124/labels/1"):
return {}
if method == "PUT" and "labels" in url:
self.fail("Should not replace the issue label set")
if method == "POST" and "comments" in url:
return {}
return {}
self.mock_api.side_effect = api_side_effect
res = gitea_edit_pr(pr_number=1, state="closed")
self.assertTrue(res["success"])
self.assertEqual(res["cleanup_status"].get(123), "released")
self.assertEqual(res["cleanup_status"].get(124), "released")
self.assertEqual(res["cleanup_status"].get(125), "not present")
def test_no_linked_issue_found(self):
def api_side_effect(method, url, auth, payload=None):
if method == "PATCH" and "pulls/1" in url:
return {
"number": 1,
"title": "My PR",
"state": "closed",
"html_url": "url",
"body": "No issue link",
"head": {"ref": "main"}
}
return {}
self.mock_api.side_effect = api_side_effect
res = gitea_edit_pr(pr_number=1, state="closed")
self.assertTrue(res["success"])
self.assertEqual(res["cleanup_status"], "no linked issue found")
def test_label_removal_failure_reported(self):
def api_side_effect(method, url, auth, payload=None):
if method == "PATCH" and "issues/1" in url:
return {"state": "closed"}
if method == "GET" and "labels" in url and "issues" not in url:
return [{"name": "status:in-progress", "id": 1}]
if method == "GET" and "issues/1" in url:
return {"labels": [{"name": "status:in-progress"}]}
if method == "DELETE" and url.endswith("/issues/1/labels/1"):
raise RuntimeError("API failure")
if method == "PUT" and "labels" in url:
self.fail("Should not replace the issue label set")
return {}
self.mock_api.side_effect = api_side_effect
res = gitea_close_issue(issue_number=1)
self.assertTrue(res["success"])
self.assertIn("error:", res["cleanup_status"].get(1))
-169
View File
@@ -1,169 +0,0 @@
"""Tests for scripts/release-tag (#50).
Each test builds a throwaway git repo with a LOCAL bare remote named per
RELEASE_TAG_REMOTE — no network, no pushing from the project repo, no real tags
created here. The test suite gate is stubbed via RELEASE_TAG_TEST_CMD (true =
pass, false = fail) so no real pytest/venv is needed inside the temp repo.
"""
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SCRIPT_SRC = REPO / "scripts" / "release-tag"
REMOTE = "prgs"
def _git(cwd, *args):
return subprocess.run(["git", *args], cwd=str(cwd),
capture_output=True, text=True)
class _ReleaseTagCase(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.work = self.tmp / "work"
self.bare = self.tmp / "remote.git"
self.work.mkdir()
_git(self.work, "init", "-b", "master")
_git(self.work, "config", "user.email", "t@example.invalid")
_git(self.work, "config", "user.name", "Test")
(self.work / "README").write_text("hello\n")
# Install the script under test and commit it so the worktree is clean.
(self.work / "scripts").mkdir()
dst = self.work / "scripts" / "release-tag"
shutil.copy(SCRIPT_SRC, dst)
dst.chmod(0o755)
_git(self.work, "add", "README", "scripts/release-tag")
_git(self.work, "commit", "-m", "initial")
# Seed the bare remote by cloning the work repo (already has master +
# the script). Avoids `git push <remote> master`, which the harness blocks.
_git(self.tmp, "clone", "--bare", str(self.work), str(self.bare))
_git(self.work, "remote", "add", REMOTE, str(self.bare))
_git(self.work, "fetch", REMOTE, "--prune")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def rt(self, *args, test_cmd="true"):
env = dict(os.environ, RELEASE_TAG_REMOTE=REMOTE,
RELEASE_TAG_TEST_CMD=test_cmd)
proc = subprocess.run(
["bash", str(self.work / "scripts" / "release-tag"), *args],
cwd=str(self.work), capture_output=True, text=True, env=env)
return proc.returncode, proc.stdout, proc.stderr
def tag_type(self, name):
r = _git(self.work, "cat-file", "-t", name)
return r.stdout.strip()
def local_tags(self):
return _git(self.work, "tag").stdout.split()
def remote_has_tag(self, name):
r = _git(self.work, "ls-remote", "--tags", REMOTE, f"refs/tags/{name}")
return bool(r.stdout.strip())
class TestValidation(_ReleaseTagCase):
def test_accepts_valid_semver_dry_run(self):
rc, out, err = self.rt("--dry-run", "v0.4.0")
self.assertEqual(rc, 0, err)
self.assertIn("would create annotated tag v0.4.0", out)
self.assertEqual(self.local_tags(), []) # dry-run creates nothing
def test_rejects_invalid_version(self):
for bad in ("v1.2", "1.0.0", "v1.0", "release-1", "vx.y.z"):
rc, _, err = self.rt(bad)
self.assertEqual(rc, 2, bad)
self.assertIn("invalid version", err)
def test_rejects_dirty_worktree(self):
(self.work / "README").write_text("dirty\n")
rc, _, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 1)
self.assertIn("dirty", err)
def test_rejects_non_master_branch(self):
_git(self.work, "checkout", "-b", "feat/issue-1-x")
rc, _, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 1)
self.assertIn("not on master", err)
def test_rejects_master_remote_mismatch(self):
(self.work / "extra").write_text("x\n")
_git(self.work, "add", "extra")
_git(self.work, "commit", "-m", "local-only") # not pushed
rc, _, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 1)
self.assertIn("master", err)
def test_rejects_existing_local_tag(self):
_git(self.work, "tag", "-a", "v0.4.0", "-m", "pre-existing")
rc, _, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 1)
self.assertIn("already exists", err)
def test_missing_notes_file_rejected(self):
rc, _, err = self.rt("v0.4.0", "--skip-tests", "--notes-file",
str(self.tmp / "nope.md"))
self.assertEqual(rc, 2)
self.assertIn("notes file not found", err)
class TestTagging(_ReleaseTagCase):
def test_creates_annotated_tag_not_lightweight(self):
rc, out, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 0, err)
self.assertEqual(self.tag_type("v0.4.0"), "tag") # annotated, not "commit"
self.assertIn("tag_created: yes", out)
def test_no_push_without_flag(self):
rc, out, _ = self.rt("v0.5.0", "--skip-tests")
self.assertEqual(rc, 0)
self.assertIn("tag_pushed: no", out)
self.assertFalse(self.remote_has_tag("v0.5.0"))
def test_push_only_with_flag(self):
rc, out, err = self.rt("v0.6.0", "--skip-tests", "--push")
self.assertEqual(rc, 0, err)
self.assertIn("tag_pushed: yes", out)
self.assertTrue(self.remote_has_tag("v0.6.0"))
def test_notes_file_used_as_message(self):
notes = self.tmp / "notes.md"
notes.write_text("Release v0.4.0\n\n- #50 release-tag helper\n")
rc, _, err = self.rt("v0.4.0", "--skip-tests", "--notes-file", str(notes))
self.assertEqual(rc, 0, err)
msg = _git(self.work, "tag", "-n99", "-l", "v0.4.0").stdout
self.assertIn("release-tag helper", msg)
class TestTestsGate(_ReleaseTagCase):
def test_skip_tests_warns_and_skips(self):
rc, out, err = self.rt("v0.4.0", "--skip-tests")
self.assertEqual(rc, 0)
self.assertIn("WARNING", err)
self.assertIn("tests_run: no", out)
def test_default_runs_tests_and_failure_blocks_tag(self):
rc, out, err = self.rt("v0.4.0", test_cmd="false") # tests "fail"
self.assertEqual(rc, 1)
self.assertIn("tests failed", err)
self.assertEqual(self.local_tags(), []) # no tag on failure
def test_default_runs_tests_and_passes(self):
rc, out, err = self.rt("v0.4.0", test_cmd="true")
self.assertEqual(rc, 0, err)
self.assertIn("tests_run: yes", out)
self.assertEqual(self.tag_type("v0.4.0"), "tag")
if __name__ == "__main__":
unittest.main()
+2 -29
View File
@@ -35,43 +35,16 @@ class TestWorktreeStart(unittest.TestCase):
self.assertEqual(rc, 2) self.assertEqual(rc, 2)
def test_refuses_existing_worktree(self): def test_refuses_existing_worktree(self):
branch = f"fix/issue-999-refuse-{os.getpid()}" slug = f"zz-refuse-start-{os.getpid()}"
slug = branch.replace("/", "-")
target = BRANCHES / slug target = BRANCHES / slug
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
try: try:
rc, _, err = run("worktree-start", "--dry-run", branch) rc, _, err = run("worktree-start", "--dry-run", slug)
self.assertEqual(rc, 1) self.assertEqual(rc, 1)
self.assertIn("Refusing to reuse", err) self.assertIn("Refusing to reuse", err)
finally: finally:
target.rmdir() target.rmdir()
# -- issue-linked branch validation (#48) --------------------------------
def test_accepts_issue_linked_impl_branches(self):
for branch in ("fix/issue-123-example", "feat/issue-123-example",
"docs/issue-123-example", "chore/issue-123-example"):
rc, out, err = run("worktree-start", "--dry-run", branch)
self.assertEqual(rc, 0, f"{branch}: {err}")
self.assertIn(f"branches/{branch.replace('/', '-')}", out)
def test_accepts_review_branch(self):
rc, out, _ = run("worktree-start", "--dry-run", "review/pr-456-example")
self.assertEqual(rc, 0)
self.assertIn("branches/review-pr-456-example", out)
def test_rejects_untraceable_branches(self):
for branch in ("fix/random-name", "my-branch", "feat/no-issue-here",
"fix/issue-abc-x"):
rc, _, err = run("worktree-start", "--dry-run", branch)
self.assertEqual(rc, 2, branch)
self.assertIn("Untraceable branch name", err)
def test_allow_unlinked_override(self):
rc, out, _ = run("worktree-start", "--dry-run", "--allow-unlinked", "my-branch")
self.assertEqual(rc, 0)
self.assertIn("branches/my-branch", out)
class TestWorktreeReview(unittest.TestCase): class TestWorktreeReview(unittest.TestCase):