Compare commits

16 Commits

Author SHA1 Message Date
sysadmin afa57fa65c chore: version bump and release notes for v1.1.0 (fixes #63) 2026-07-02 06:29:49 -04:00
sysadmin 2d5cb4bb29 Merge pull request 'refactor: split manage_labels.py into reusable modes (#6)' (#62) from feat/issue-6-manage-labels-modes into master 2026-07-02 05:24:33 -05:00
sysadmin 1441591e74 Merge pull request 'feat: add aliases for gitea_whoami identity lookup (fixes #9)' (#61) from feature/issue-9-identity-lookup into master 2026-07-02 05:23:57 -05:00
sysadmin 848a4294ba Merge pull request 'fix: document + tool macOS com.apple.provenance workaround (#3)' (#60) from fix/issue-3-provenance-python-exec into master 2026-07-02 05:23:23 -05:00
sysadmin 496e796cdd refactor: split manage_labels.py into reusable modes (#6)
Split the one-shot label backfill into reusable, mode-selected operations while
preserving the original default behavior:

- --create-labels : idempotent label creation only (create_labels()).
- --apply-mapping : one-off MAPPING labeling only (apply_mapping(); PUT replaces
  each issue's set).
- --add-label <issue> <label> : ad-hoc single-issue labeling (add_label(); POST
  appends the label, does not replace; refuses an undefined label).
- default (no mode) : create labels then apply MAPPING — identical to the prior
  behavior. --dry (and --dry-run) still print without writing.

Extracted create_labels / apply_mapping / add_label / _labels_by_name helpers;
LABELS, MAPPING, and the api() wrapper are unchanged. No auth/network behavior
change; MAPPING remains the same one-off backfill data.

Tests: extend tests/test_manage_labels.py with a TestModes suite — create-only
(no PUT), apply-only (no label creation), add-label appends (POST, not PUT),
unknown-label no-op, dry no-op, non-numeric issue exits. Existing default/dry/
mapping/constant tests unchanged and still pass.

py_compile clean; full suite 319 passed / 0 failures; git diff --check clean;
no secrets.

Closes #6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 06:21:23 -04:00
sysadmin 642adf4705 feat: add aliases for gitea_whoami identity lookup (fixes #9)
Issue #9 requested getAuthenticatedUser and getCurrentUser in addition to whoami.
This adds the two aliased MCP tools and their corresponding unit tests.
2026-07-02 06:15:36 -04:00
sysadmin e842b60ad8 fix: document + tool the macOS com.apple.provenance workaround (#3)
Root cause: macOS Sequoia+ blocks Python.app from executing files carrying the
com.apple.provenance extended attribute. Files written by an agent/IDE terminal
get it (shell scripts and pre-session files do not). This is a macOS security
feature, not a bug in our code — so the fix is an operator workaround, not a
code change to the tools.

- scripts/clear-provenance: recursively removes ONLY com.apple.provenance under
  a path (default: repo root); tolerates files without it; leaves other xattrs
  intact; supports --dry-run. Advises running from a Full-Disk-Access terminal.
- README Troubleshooting section documenting the symptom, the helper, manual
  xattr equivalents, and the Full Disk Access alternative.

Narrow + macOS-specific; no auth/release/worktree/tracker/MCP behavior changed.

Tests: tests/test_clear_provenance.py (6 cases) — dry-run default/explicit path,
missing-path error, bad-flag/too-many-args exit 2, and that only
com.apple.provenance is targeted (not a blanket xattr clear). Dry-run only; no
real xattr mutation.

bash -n clean; py_compile mcp_server.py clean; full suite 319 passed / 0
failures; git diff --check clean; no secrets.

Closes #3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 06:13:26 -04:00
sysadmin 3a246ab553 Merge pull request 'feat: automatically release status:in-progress on close and merge (#56)' (#57) from fix/issue-56-release-in-progress-on-close into master 2026-07-02 05:03:01 -05:00
sysadmin dbfa0fe188 fix: finalize PR 57 tracker cleanup safety 2026-07-02 06:00:06 -04:00
sysadmin b3728c54ce fix: target label delete and move helpers before entry point 2026-07-02 05:55:29 -04:00
sysadmin 4afada098c feat: automatically release status:in-progress on close and merge (#56) 2026-07-02 05:50:10 -04:00
sysadmin 6089ec724a Merge PR #53: Add release-tag automation helper
Merge reviewed PR #53 for issue #50. Validation passed and no release tags were created or pushed during review.
2026-07-02 04:40:07 -05:00
sysadmin c6c6e75af6 chore: remove deprecated recover-dirty-worktree.md (#46) 2026-07-02 05:38:29 -04:00
sysadmin 4e43347b2d feat: add scripts/release-tag automation helper (#50)
Automate the documented release-tag checklist (#48) without bypassing safety
gates.

scripts/release-tag:
- Requires a SemVer tag (vMAJOR.MINOR.PATCH); validates before any git/network.
- Fetch/prune first, then refuses: dirty worktree, non-master branch, local
  master != remote master, HEAD not on remote master, and an existing local or
  remote tag of the same name.
- Runs the full suite by default; --skip-tests is an explicit opt-out that warns.
- Creates an ANNOTATED tag (git tag -a), never lightweight.
- Safe by default: no push unless --push; --dry-run prints planned actions and
  changes nothing. Supports --notes-file <path> for the annotation message.
- Prints: commit, tag, tests_run, tag_created, tag_pushed.
- Env injection points for testing/CI: RELEASE_TAG_REMOTE, RELEASE_TAG_TEST_CMD.

tests/test_release_tag.py (14 cases): valid SemVer dry-run; invalid version;
dirty worktree; non-master; master/remote mismatch; existing tag; missing
notes-file; annotated-not-lightweight; no-push-without-flag; push-only-with-flag;
notes-file message; --skip-tests warns; default runs tests (fail blocks tag,
pass tags). Each test builds a throwaway repo with a LOCAL bare remote (cloned,
not pushed) and stubs the test command — no network, no real tags, no pushing
from the project repo.

Docs: reference scripts/release-tag from the runbook, SKILL, and the release-tag
template (script preferred; manual steps are the fallback).

Full suite 305 passed / 0 failures; bash -n clean; git diff --check clean; no
secrets.

Closes #50. Refs #48.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 05:37:41 -04:00
sysadmin ec9ddb09a7 docs: closed-not-merged PR reconciliation rules (#51)
Documents and enforces rules for closed-not-merged PR reconciliation, direct-master-push prevention, and issue label cleanup.

Rules added:
- Explicit definitions for Merged, Landed, Closed-not-merged, and Reconciled.
- A PR is done only when Gitea reports it merged or reconciliation proves content is present on master.
- Direct push to master is forbidden except as a documented recovery exception.
- PRs closed but not merged trigger the reconciliation process.
- Branch and worktree cleanup is forbidden until merge or reconciliation is confirmed.
- Final reports require PR metadata and Git content verification.

Closes #51.
2026-07-02 04:16:07 -04:00
sysadmin f18cecc998 feat: enforce issue-linked branches + document versioning/tagging policy (#48)
Formalize the branch↔issue relationship and add a release/version-tagging policy.

Branch/issue linkage:
- scripts/worktree-start now validates branch names: implementation branches
  must match (fix|feat|docs|chore)/issue-<number>-<slug>; review branches
  review/pr-<number>-<slug>. Untraceable names are rejected with a clear error
  (exit 2). New --allow-unlinked override for genuine exceptions. --dry-run
  preserved.
- Documented issue → branch → worktree → PR → cleanup traceability in the
  runbook and the portable SKILL, including the claim-comment convention and
  Closes #n / Refs #n PR-body usage.
- Noted that Gitea exposes no native issue→branch API field (only a PR head
  branch), so linkage is enforced via branch name + claim comment + PR body +
  cleanup.

Versioning / tagging policy (docs only; no release automation yet):
- SemVer vMAJOR.MINOR.PATCH (v0.x.y while unstable) with PATCH/MINOR/MAJOR bump
  rules.
- Annotated tags only, from the exact commit on remote master, only after the
  full suite passes, with release notes referencing merged PRs/issues. Never tag
  feature branches, dirty worktrees, unreviewed/self-authored work, or commits
  not on remote master.
- Release runbook in the runbook + SKILL, plus a new
  skills/llm-project-workflow/templates/release-tag.md prompt template.

Tests: worktree-start branch validation — accepts fix/feat/docs/chore/issue-*
and review/pr-*, rejects fix/random-name / my-branch / non-numeric issue,
honors --allow-unlinked, preserves --dry-run. Full suite 291 passed / 0 failures;
bash -n clean; git diff --check clean; no secrets.

Release-tag automation (a scripts/release-tag helper) intentionally deferred to a
later issue to keep this diff narrow and testable.

Closes #48. Refs #38, #39, #46.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 04:08:42 -04:00
19 changed files with 1320 additions and 80 deletions
+20
View File
@@ -0,0 +1,20 @@
# Changelog
All notable changes to this project will be documented in this file.
## [v1.1.0] - 2026-07-02
### Added
- Identity lookup aliases (`gitea_get_authenticated_user` and `gitea_get_current_user`) for common MCP/LLM tool discovery.
- macOS `com.apple.provenance` cleanup helper tool and documentation.
- `manage_labels.py` refactored into reusable modes (`--create-labels`, `--apply-mapping`, `--add-label`).
### Changed
- Automatic `status:in-progress` cleanup on issue/PR close and merge.
- Label cleanup now utilizes safe targeted label deletion behavior rather than replacing the entire label set.
## [v1.0.1]
- Fix Recent Timesheets Remove button text clipping and copy theme/whats_new in build.
## [v1.0.0]
- Initial versioned release.
+32
View File
@@ -382,3 +382,35 @@ python3 -m pytest tests/ -v
| `test_mirror_refs.py` | Flags, safety defaults, local integration tests |
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`.
+66 -5
View File
@@ -180,6 +180,24 @@ under `branches/`. The main repository checkout is an orchestration checkout:
use it for status checks, issue creation/claiming, and creating worktrees, but
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
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.
@@ -281,14 +299,19 @@ touching anything.
- **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.`
### Close the issue after merge
### Close the issue after merge / Reconciliation
- **Profile:** issue-manager or merger.
- **Steps:** verify remote `master` actually contains the merge; close the
issue (or rely on a `Closes #N` keyword); release `status:in-progress`;
clean up merged branches.
- **Prompt:** `After confirming master contains the merge of PR #N, close issue
#M and delete the merged branch.`
issue; release `status:in-progress` (if it cannot be removed, report why).
- **If closed but not merged (`merged=false`):** Stop normal flow. Do not delete worktrees. Compare PR content to remote `master`.
- **fully landed:** comment it landed, remove `status:in-progress`, clean up.
- **partially landed:** reopen issue, create corrective PR for missing pieces.
- **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
@@ -319,6 +342,44 @@ All mutating attempts — allowed, blocked, failed, or succeeded — are audit-l
with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
[`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
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
+78 -17
View File
@@ -4,9 +4,14 @@
Auth follows the project convention: credentials are pulled from the macOS
keychain via `git credential fill` (HTTPS), then sent as Basic auth.
Usage:
./manage_labels.py # create labels, then apply the mapping below
./manage_labels.py --dry # print actions without writing
Modes (default = create labels then apply the one-off MAPPING, preserving the
original behavior):
./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 sys
@@ -34,7 +39,7 @@ LABELS = [
"description": "Issue is being worked on"},
]
# issue number -> label names to apply
# issue number -> label names to apply (one-off backfill)
MAPPING = {
23: ["chore"],
22: ["chore"],
@@ -56,6 +61,11 @@ MAPPING = {
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):
"""Thin wrapper around auth.api_request that prepends BASE_URL and
@@ -68,19 +78,15 @@ def api(method, path, auth, payload=None):
return None
def main():
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
def _labels_by_name(auth):
"""Return {label name: id} for the repo's existing labels."""
existing = api("GET", "/labels?limit=100", auth) or []
by_name = {l["name"]: l["id"] for l in existing}
return {lb["name"]: lb["id"] for lb 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:
if spec["name"] in by_name:
print(f"label exists: {spec['name']}")
@@ -92,8 +98,13 @@ def main():
if created:
by_name[created["name"]] = 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):
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]
@@ -105,9 +116,59 @@ def main():
# PUT replaces the issue's labels with exactly this set (idempotent).
res = api("PUT", f"/issues/{issue}/labels", auth, {"labels": ids})
if res is not None:
applied = [l["name"] for l in res]
applied = [lb["name"] for lb in res]
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__":
main()
+110 -1
View File
@@ -14,6 +14,7 @@ Configuration (mcp_config.json):
}
"""
import os
import re
import sys
import functools
import contextlib
@@ -48,6 +49,71 @@ 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 ───────────────────────────────────────────────────────────────────
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
@@ -743,6 +809,20 @@ def gitea_edit_pr(
with _audited("edit_pr", host=h, remote=remote, org=o, repo=r,
pr_number=pr_number, request_metadata={"fields": sorted(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 {
"success": True,
"number": data["number"],
@@ -750,6 +830,7 @@ def gitea_edit_pr(
"body": data.get("body", ""),
"state": data["state"],
"url": data["html_url"],
"cleanup_status": cleanup_status,
}
@@ -1021,6 +1102,9 @@ def gitea_merge_pr(
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
)
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:
result["merge_commit"] = None
except Exception as exc: # noqa: BLE001 — redact before surfacing
@@ -1157,7 +1241,14 @@ def gitea_close_issue(
with _audited("close_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number, request_metadata={"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()
@@ -1293,6 +1384,24 @@ 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()
def gitea_get_profile(
remote: str = "dadeschools",
+61
View File
@@ -0,0 +1,61 @@
#!/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
@@ -0,0 +1,139 @@
#!/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"
+37 -6
View File
@@ -3,21 +3,32 @@ set -euo pipefail
usage() {
cat <<'EOF'
usage: scripts/worktree-start [--dry-run] <branch-name> [start-ref]
usage: scripts/worktree-start [--dry-run] [--allow-unlinked] <branch-name> [start-ref]
Create an issue-specific git worktree under branches/<branch-name-with-slashes-replaced>.
Create an issue-linked 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:
scripts/worktree-start fix/issue-123-example
scripts/worktree-start --dry-run review/pr-123-scope-check prgs/master
scripts/worktree-start --dry-run review/pr-456-scope-check prgs/master
EOF
}
dry_run=0
if [[ "${1:-}" == "--dry-run" ]]; then
dry_run=1
allow_unlinked=0
while [[ "${1:-}" == --* ]]; do
case "$1" in
--dry-run) dry_run=1 ;;
--allow-unlinked) allow_unlinked=1 ;;
--help) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
shift
fi
done
if [[ $# -lt 1 || $# -gt 2 ]]; then
usage >&2
@@ -27,6 +38,26 @@ fi
branch="$1"
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)"
repo_root="$(cd "$script_dir/.." && pwd)"
worktree_name="${branch//\//-}"
+104 -12
View File
@@ -19,6 +19,14 @@ 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
**No repository change without a tracking issue.** This includes creating,
@@ -45,7 +53,37 @@ orchestration and status only (issue creation, `git status`, creating worktrees)
- Branch folders are removed only after the PR is merged/closed **and** cleanup
is explicitly part of the task.
Preferred helpers (if present in the project):
Every implementation branch **must include its issue number** so it is
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
scripts/worktree-start fix/issue-123-example # → branches/fix-issue-123-example
@@ -103,6 +141,14 @@ Worktree folder = branch with `/` replaced by `-`
10. Push the branch.
11. Open a PR to `master`.
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
@@ -118,13 +164,15 @@ Worktree folder = branch with `/` replaced by `-`
Only an eligible (non-author) reviewer merges. After a real merge:
1. Confirm remote `master` actually contains the merge commit.
2. Close/release the issue; remove `status:in-progress` if used.
3. Delete the remote branch.
4. Remove the local branch.
5. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`).
6. Fetch/prune.
7. Confirm the main checkout is clean and current (`0 0` vs remote).
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).
2. Close/release the issue.
3. Whenever an issue is closed, check for `status:in-progress`: remove it, or report why it could not be removed.
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.
5. Remove the local branch.
6. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`). Branches/worktrees are cleaned only after the above is verified.
7. Fetch/prune.
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`.
@@ -135,7 +183,11 @@ Never run cleanup before the merge is confirmed on remote `master`.
- No issue exists and one cannot be created.
- Worktree state is unclear or unexpected.
- Branch/PR state conflicts with the prompt (e.g. prompt says "merged" but it is not).
- A PR is closed but not merged.
- A PR is closed but not merged (closed with `merged=false`). In this case:
- stop normal review/merge
- do not delete branches/worktrees
- do not start dependent work
- run reconciliation
- Local `master` is ahead of remote unexpectedly.
- The authenticated user is the PR author (for review/merge).
- Secrets/tokens appear in the diff.
@@ -152,9 +204,10 @@ 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
`git reset --hard <remote>/master` to realign. Never discard commits that are
not safely pushed elsewhere.
- **PR closed but not merged:** the work is not in mainline. Re-push the branch,
reopen (or open a replacement) PR, and let an eligible reviewer merge. Do not
assume "closed" means "merged" — verify remote `master` contains the commits.
- **PR closed but not merged (`merged=false`):** do not merge. Run reconciliation: compare PR content to remote `master` and decide:
- **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.
- **partially landed:** do not clean up, reopen issue if needed, create corrective issue/PR for missing pieces.
- **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
reflog), re-push them and reopen the PR; otherwise recover via
`git fsck --lost-found`. Preserve first, then proceed.
@@ -173,7 +226,9 @@ Ready-to-copy templates live in [`templates/`](templates/):
- [`review-pr.md`](templates/review-pr.md) — review a PR.
- [`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.
- [`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.
- [`release-tag.md`](templates/release-tag.md) — create a release tag.
## Adapting to a project
@@ -188,3 +243,40 @@ Replace these project-specific names when copying the skill elsewhere:
| helper scripts | Worktree helpers | `scripts/worktree-start` / `-review` / `-clean` |
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,6 +10,7 @@ Rules (llm-project-workflow):
author → STOP.
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
- No force-merge, no bypassing branch protections.
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
Steps:
1. Verify authenticated identity + active profile.
@@ -20,9 +21,9 @@ Steps:
5. Confirm remote master now contains the merge commit.
Then run the cleanup template (worktree-cleanup.md):
- close/release issue #<n>, remove status:in-progress
- close/release issue #<n>, remove status:in-progress (if it cannot be removed, report why)
- delete remote branch, remove local branch + worktree folder
- fetch/prune; confirm main checkout is clean and current (0 0).
Handoff: reviewer identity, merge result + commit, cleanup done, issue closed.
Handoff: reviewer identity, merge result + commit, cleanup done, issue closed, PR metadata state/merged flag/hash, remote master hash & Git content check.
```
@@ -0,0 +1,24 @@
# 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,8 +22,7 @@ Act per case:
- Local master ahead of remote: confirm the extra commits live on a branch
pushed to <remote>, THEN git reset --hard <remote>/master. Verify with
`git branch --contains <sha>` first.
- PR closed but not merged: re-push the branch, reopen/replace the PR, let an
eligible reviewer merge. Do not merge your own.
- PR closed but not merged (`merged=false`): stop normal flow and use reconcile-closed-not-merged-pr.md instead.
- Branch deleted before merge: recover commits from a local branch/reflog (or
git fsck --lost-found), re-push, reopen the PR.
- Unauthorized untracked file: do not commit it; leave pre-existing artifacts.
@@ -1,31 +0,0 @@
# Recover Dirty Worktree Prompt
You are recovering repository state in `<repo-name>`.
Rules:
- Do not reset, delete, clean, or overwrite work unless explicitly instructed.
- Do not edit another issue's worktree unless assigned to that issue.
- Preserve ambiguous work before any destructive operation.
Workflow:
1. Run `git status --short --branch`.
2. Identify whether dirty files belong to the current issue, another issue, or
unknown work.
3. If dirty work belongs to another issue, leave it alone and use a separate
worktree for the current task.
4. If an unauthorized untracked file was created, stop and report its exact path.
5. Remove unauthorized files only when explicitly instructed.
6. If local `<default-branch>` is ahead of `<remote>/<default-branch>`, stop and
report both commit hashes.
7. If cleanup is requested, verify the branch is merged or explicitly abandoned
before deleting any branch or worktree.
Report:
- current branch
- dirty files
- ownership assessment
- actions taken
- remaining blockers
@@ -0,0 +1,53 @@
# 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
@@ -0,0 +1,60 @@
"""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,5 +137,89 @@ class TestConstants(unittest.TestCase):
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__":
unittest.main()
+249 -1
View File
@@ -93,7 +93,8 @@ class TestCloseIssue(unittest.TestCase):
result = gitea_close_issue(issue_number=42)
self.assertTrue(result["success"])
self.assertIn("42", result["message"])
payload = mock_api.call_args[0][3]
patch_call = next(call for call in mock_api.call_args_list if call[0][0] == "PATCH")
payload = patch_call[0][3]
self.assertEqual(payload["state"], "closed")
@@ -861,6 +862,34 @@ class TestWhoami(unittest.TestCase):
# ---------------------------------------------------------------------------
# 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):
def test_defaults_when_unset(self):
@@ -1352,3 +1381,222 @@ class TestSubmitPrReview(unittest.TestCase):
if __name__ == "__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
@@ -0,0 +1,169 @@
"""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()
+29 -2
View File
@@ -35,16 +35,43 @@ class TestWorktreeStart(unittest.TestCase):
self.assertEqual(rc, 2)
def test_refuses_existing_worktree(self):
slug = f"zz-refuse-start-{os.getpid()}"
branch = f"fix/issue-999-refuse-{os.getpid()}"
slug = branch.replace("/", "-")
target = BRANCHES / slug
target.mkdir(parents=True, exist_ok=True)
try:
rc, _, err = run("worktree-start", "--dry-run", slug)
rc, _, err = run("worktree-start", "--dry-run", branch)
self.assertEqual(rc, 1)
self.assertIn("Refusing to reuse", err)
finally:
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):