diff --git a/README.md b/README.md index 6223f0e..9e90ed1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Gitea Tools -A collection of Python and Bash scripts to automate interactions with Gitea instances. +A collection of Python scripts and an MCP server to automate interactions with Gitea instances. ## Supported Instances @@ -18,7 +18,38 @@ Scripts extract credentials from the macOS keychain automatically — no tokens Ensure you've logged in via Git over HTTPS at least once so the keychain caches your credentials. -## Available Scripts +## MCP Server (Recommended) + +The Gitea-Tools MCP server exposes all functionality as structured tool calls. +Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools natively. + +### Available Tools + +| Tool | Description | +|------|-------------| +| `gitea_create_issue` | Create an issue with title, body, remote | +| `gitea_create_pr` | Open a pull request with title, head, base | +| `gitea_close_issue` | Close an issue by number | +| `gitea_list_issues` | List issues with state/label filters | +| `gitea_view_issue` | Get full details of a single issue | +| `gitea_mark_issue` | Claim/release an issue (start/done) | +| `gitea_mirror_refs` | Mirror branches + tags between instances | + +### Setup + +```bash +# Install dependencies +cd /Users/jasonwalker/Development/Gitea-Tools +source venv/bin/activate +pip install "mcp[cli]" +``` + +The server is configured in `mcp_config.json` and runs automatically when Antigravity starts. +Restart Antigravity to load the server after first setup. + +## CLI Scripts + +The MCP tools can also be used as standalone CLI scripts: | Script | Description | |---------------------|--------------------------------------------------------------------| @@ -59,31 +90,35 @@ Ensure you've logged in via Git over HTTPS at least once so the keychain caches Use `--help` on any Python script or shell script for full usage details. -## Mirror Refs +## Architecture -`mirror_refs.sh` keeps branches and tags in sync between dadeschools and prgs: - -- **Additive only** — never deletes branches or tags -- **Dry-run by default** — pass `--apply` to actually push -- **Divergence protection** — shared branches that have diverged are skipped with a warning; pass `--force` to override -- Uses a bare repo cache in `/tmp/gitea-mirror-Timesheet` for isolation -- Won't auto-close or merge anything — just ref mirroring +``` +auth.py ← shared auth & API helpers (get_credentials, api_request) +mcp_server.py ← MCP server (FastMCP, stdio transport) +create_issue.py ← CLI: create issues +create_pr.py ← CLI: create PRs +manage_labels.py ← CLI: label management +close_issue.sh ← CLI: close issues +mark_issue.sh ← CLI: claim/release issues +mirror_refs.sh ← CLI: ref mirroring +``` ## Tests -Run the full test suite: - ```bash +# Run with the venv (includes MCP SDK) +source venv/bin/activate python3 -m pytest tests/ -v ``` | Test file | Covers | |--------------------------|---------------------------------------------------------| -| `test_create_issue.py` | Arg parsing, remote resolution, payload, auth, errors | -| `test_create_pr.py` | Arg parsing, remote resolution, payload, auth, errors | -| `test_credentials.py` | `get_credentials()` parsing edge cases | -| `test_manage_labels.py` | Label create/skip, dry run, mapping, constant validation| -| `test_shell_scripts.py` | `close_issue.sh` + `mark_issue.sh` arg validation | -| `test_mirror_refs.py` | Flags, safety defaults, local integration tests | +| `test_mcp_server.py` | All 7 MCP tools: create, list, view, close, mark, PR, mirror | +| `test_create_issue.py` | CLI arg parsing, remote resolution, payload, auth, errors | +| `test_create_pr.py` | CLI arg parsing, remote resolution, payload, auth, errors | +| `test_credentials.py` | `get_credentials()`, `get_auth_header()`, `repo_api_url()` | +| `test_manage_labels.py` | Label create/skip, dry run, mapping, constant validation | +| `test_shell_scripts.py` | `close_issue.sh` + `mark_issue.sh` arg validation | +| `test_mirror_refs.py` | Flags, safety defaults, local integration tests | All tests mock network and keychain access — no real API calls are made. diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..f1e4dba --- /dev/null +++ b/auth.py @@ -0,0 +1,93 @@ +"""Shared authentication and API helper for Gitea scripts. + +Pulls credentials from the macOS keychain via `git credential fill` +so no tokens appear on the command line. +""" +import json +import base64 +import subprocess +import urllib.request +import urllib.error + +# Known Gitea instances — shared by all scripts. +REMOTES = { + "dadeschools": { + "host": "gitea.dadeschools.net", + "org": "Contractor", + "repo": "Timesheet", + }, + "prgs": { + "host": "gitea.prgs.cc", + "org": "Scaled-Tech-Consulting", + "repo": "Timesheet", + }, +} + + +def get_credentials(host): + """Return (user, password) for *host* via ``git credential fill``.""" + p = subprocess.Popen( + ["git", "credential", "fill"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, + ) + out, _ = p.communicate(f"protocol=https\nhost={host}\n\n") + user = password = "" + for line in out.splitlines(): + if line.startswith("username="): + user = line.split("=", 1)[1] + elif line.startswith("password="): + password = line.split("=", 1)[1] + return user, password + + +def get_auth_header(host): + """Return an ``Authorization: Basic …`` header value for *host*.""" + user, password = get_credentials(host) + if not user or not password: + return None + token = base64.b64encode(f"{user}:{password}".encode()).decode() + return f"Basic {token}" + + +def resolve_remote(args): + """Given parsed argparse args with --remote/--host/--org/--repo, + return (host, org, repo) with overrides applied.""" + profile = REMOTES[args.remote] + host = args.host or profile["host"] + org = args.org or profile["org"] + repo = args.repo or profile["repo"] + return host, org, repo + + +def add_remote_args(parser): + """Add the standard --remote/--host/--org/--repo arguments to a parser.""" + parser.add_argument( + "--remote", choices=sorted(REMOTES), default="dadeschools", + help="Known Gitea instance (default: dadeschools).", + ) + parser.add_argument("--host", help="Override the Gitea host.") + parser.add_argument("--org", help="Override the owner/org.") + parser.add_argument("--repo", help="Override the repository.") + + +def api_request(method, url, auth_header, payload=None): + """Make an authenticated JSON request to the Gitea API. + + Returns parsed JSON on success, raises on HTTP errors. + """ + data = json.dumps(payload).encode("utf-8") if payload is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", auth_header) + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) if body else None + except urllib.error.HTTPError as e: + error_body = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {e.code}: {error_body}") from e + + +def repo_api_url(host, org, repo): + """Return the base API URL for a repo: https://host/api/v1/repos/org/repo""" + return f"https://{host}/api/v1/repos/{org}/{repo}" diff --git a/create_issue.py b/create_issue.py old mode 100755 new mode 100644 index fa7aa48..d8333d1 --- a/create_issue.py +++ b/create_issue.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Create a Gitea issue. -Parameterized over title/body and the target Gitea instance, mirroring -create_pr.py. Two instances are known out of the box: +Parameterized over title/body and the target Gitea instance. +Two instances are known out of the box: dadeschools -> gitea.dadeschools.net / Contractor / Timesheet prgs -> gitea.prgs.cc / Scaled-Tech-Consulting / Timesheet @@ -11,60 +11,29 @@ Auth is pulled from the macOS keychain via `git credential fill` for the chosen host -- no tokens on the command line. Examples: - create_issue.py --title "PDF: open after creation" \\ - --body "Auto-open the generated PDF in the default viewer." - - create_issue.py --remote prgs --title "Fix X" --body-file desc.md + create_issue.py --title "Bug: blank PDF" --body "Blank on Safari" + create_issue.py --remote prgs --title "Add tests" --body-file desc.md + create_issue.py --host gitea.example.com --org Foo --repo Bar --title "..." """ import sys -import json -import base64 import argparse -import subprocess -import urllib.request -import urllib.error -REMOTES = { - "dadeschools": {"host": "gitea.dadeschools.net", "org": "Contractor", - "repo": "Timesheet"}, - "prgs": {"host": "gitea.prgs.cc", "org": "Scaled-Tech-Consulting", - "repo": "Timesheet"}, -} - - -def get_credentials(host): - """Return (user, password) for `host` via `git credential fill`.""" - p = subprocess.Popen( - ["git", "credential", "fill"], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, - ) - out, _ = p.communicate(f"protocol=https\nhost={host}\n\n") - user = password = "" - for line in out.splitlines(): - # split(maxsplit=1): tokens/passwords can themselves contain '='. - if line.startswith("username="): - user = line.split("=", 1)[1] - elif line.startswith("password="): - password = line.split("=", 1)[1] - return user, password +from auth import ( + get_credentials, resolve_remote, add_remote_args, + api_request, repo_api_url, +) def main(argv=None): parser = argparse.ArgumentParser(description="Create a Gitea issue.") - parser.add_argument("--remote", choices=sorted(REMOTES), default="dadeschools", - help="Known Gitea instance (default: dadeschools).") - parser.add_argument("--host", help="Override the Gitea host.") - parser.add_argument("--org", help="Override the owner/org.") - parser.add_argument("--repo", help="Override the repository.") + add_remote_args(parser) parser.add_argument("--title", required=True, help="Issue title.") parser.add_argument("--body", default="", help="Issue body text.") - parser.add_argument("--body-file", help="Read issue body from this file ('-' for stdin).") + parser.add_argument("--body-file", + help="Read issue body from this file ('-' for stdin).") args = parser.parse_args(argv) - profile = REMOTES[args.remote] - host = args.host or profile["host"] - org = args.org or profile["org"] - repo = args.repo or profile["repo"] + host, org, repo = resolve_remote(args) body = args.body if args.body_file: @@ -81,22 +50,16 @@ def main(argv=None): file=sys.stderr) return 1 - url = f"https://{host}/api/v1/repos/{org}/{repo}/issues" - payload = {"title": args.title, "body": body} - req = urllib.request.Request( - url, data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - auth_b64 = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("utf-8") - req.add_header("Authorization", f"Basic {auth_b64}") + import base64 + auth = f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}" + url = f"{repo_api_url(host, org, repo)}/issues" try: - with urllib.request.urlopen(req) as response: - data = json.load(response) + data = api_request("POST", url, auth, {"title": args.title, "body": body}) print(f"Issue #{data.get('number')}: {data.get('html_url')}") return 0 - except urllib.error.HTTPError as e: - print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr) + except RuntimeError as e: + print(f"Error: {e}", file=sys.stderr) return 1 diff --git a/create_pr.py b/create_pr.py index c07f2f5..9d66905 100755 --- a/create_pr.py +++ b/create_pr.py @@ -26,42 +26,15 @@ import sys import json import base64 import argparse -import subprocess import urllib.request import urllib.error -REMOTES = { - "dadeschools": {"host": "gitea.dadeschools.net", "org": "Contractor", - "repo": "Timesheet"}, - "prgs": {"host": "gitea.prgs.cc", "org": "Scaled-Tech-Consulting", - "repo": "Timesheet"}, -} - - -def get_credentials(host): - """Return (user, password) for `host` via `git credential fill`.""" - p = subprocess.Popen( - ["git", "credential", "fill"], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, - ) - out, _ = p.communicate(f"protocol=https\nhost={host}\n\n") - user = password = "" - for line in out.splitlines(): - # split(maxsplit=1): tokens/passwords can themselves contain '='. - if line.startswith("username="): - user = line.split("=", 1)[1] - elif line.startswith("password="): - password = line.split("=", 1)[1] - return user, password +from auth import get_credentials, resolve_remote, add_remote_args def main(argv=None): parser = argparse.ArgumentParser(description="Create a Gitea pull request.") - parser.add_argument("--remote", choices=sorted(REMOTES), default="dadeschools", - help="Known Gitea instance (default: dadeschools).") - parser.add_argument("--host", help="Override the Gitea host.") - parser.add_argument("--org", help="Override the owner/org.") - parser.add_argument("--repo", help="Override the repository.") + add_remote_args(parser) parser.add_argument("--title", required=True, help="PR title.") parser.add_argument("--head", required=True, help="Source branch.") parser.add_argument("--base", default="main", help="Target branch (default: main).") @@ -69,10 +42,7 @@ def main(argv=None): parser.add_argument("--body-file", help="Read PR body from this file ('-' for stdin).") args = parser.parse_args(argv) - profile = REMOTES[args.remote] - host = args.host or profile["host"] - org = args.org or profile["org"] - repo = args.repo or profile["repo"] + host, org, repo = resolve_remote(args) body = args.body if args.body_file: @@ -110,3 +80,4 @@ def main(argv=None): if __name__ == "__main__": sys.exit(main()) + diff --git a/manage_labels.py b/manage_labels.py index 98244b6..d38a783 100755 --- a/manage_labels.py +++ b/manage_labels.py @@ -9,11 +9,8 @@ Usage: ./manage_labels.py --dry # print actions without writing """ import sys -import json -import base64 -import subprocess -import urllib.request -import urllib.error + +from auth import get_auth_header, api_request, repo_api_url HOST = "gitea.dadeschools.net" ORG = "Contractor" @@ -50,46 +47,27 @@ MAPPING = { 1: ["nice-to-have"], } -API = f"https://{HOST}/api/v1/repos/{ORG}/{REPO}" - - -def get_auth_header(): - p = subprocess.Popen( - ["git", "credential", "fill"], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, - ) - out, _ = p.communicate(f"protocol=https\nhost={HOST}\n\n") - user = password = "" - for line in out.splitlines(): - if line.startswith("username="): - user = line.split("=", 1)[1] - if line.startswith("password="): - password = line.split("=", 1)[1] - if not user or not password: - print("Could not get credentials from git credential fill", file=sys.stderr) - sys.exit(1) - token = base64.b64encode(f"{user}:{password}".encode()).decode() - return f"Basic {token}" +BASE_URL = repo_api_url(HOST, ORG, REPO) def api(method, path, auth, payload=None): - url = f"{API}{path}" - data = json.dumps(payload).encode() if payload is not None else None - req = urllib.request.Request(url, data=data, method=method) - req.add_header("Authorization", auth) - req.add_header("Content-Type", "application/json") + """Thin wrapper around auth.api_request that prepends BASE_URL and + handles errors gracefully (returns None instead of raising).""" + url = f"{BASE_URL}{path}" try: - with urllib.request.urlopen(req) as r: - body = r.read().decode() - return json.loads(body) if body else None - except urllib.error.HTTPError as e: - print(f" HTTP {e.code} on {method} {path}: {e.read().decode()}", file=sys.stderr) + return api_request(method, url, auth, payload) + except RuntimeError as e: + print(f" {e}", file=sys.stderr) return None def main(): dry = "--dry" in sys.argv - auth = get_auth_header() + 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 [] diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..8707084 --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Gitea MCP Server — exposes Gitea operations as MCP tools. + +Runs over stdio. All tools authenticate via macOS keychain (git credential fill). + +Usage (standalone test): + python3 mcp_server.py + +Configuration (mcp_config.json): + "gitea-tools": { + "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3", + "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"], + "env": {} + } +""" +import os +import sys +import subprocess + +# Ensure the project root is on the path so auth.py can be imported. +PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + +from mcp.server.fastmcp import FastMCP # noqa: E402 + +from auth import ( # noqa: E402 + REMOTES, + get_credentials, + get_auth_header, + api_request, + repo_api_url, +) + +mcp = FastMCP("gitea-tools", instructions=( + "Gitea issue tracker and PR management for dadeschools and prgs instances. " + "Use the gitea_ prefixed tools to create issues, PRs, list issues, etc." +)) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _resolve(remote: str, host: str | None, org: str | None, repo: str | None): + """Resolve remote + overrides to (host, org, repo).""" + if remote not in REMOTES: + raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}") + profile = REMOTES[remote] + return ( + host or profile["host"], + org or profile["org"], + repo or profile["repo"], + ) + + +def _auth(host: str) -> str: + """Get auth header, raise if unavailable.""" + header = get_auth_header(host) + if header is None: + raise RuntimeError( + f"No credentials for {host}. " + "Ensure you've logged in via HTTPS at least once." + ) + return header + + +# ── Tools ───────────────────────────────────────────────────────────────────── + +@mcp.tool() +def gitea_create_issue( + title: str, + body: str = "", + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Create a new issue on a Gitea repository. + + Args: + title: Issue title (required). + body: Issue body text. + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict with 'number' and 'url' of the created issue. + """ + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + url = f"{repo_api_url(h, o, r)}/issues" + data = api_request("POST", url, auth, {"title": title, "body": body}) + return {"number": data["number"], "url": data["html_url"]} + + +@mcp.tool() +def gitea_create_pr( + title: str, + head: str, + base: str = "main", + body: str = "", + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Create a pull request on a Gitea repository. + + Args: + title: PR title (required). + head: Source branch name (required). + base: Target branch (default: 'main'). + body: PR description. + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict with 'number' and 'url' of the created PR. + """ + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + url = f"{repo_api_url(h, o, r)}/pulls" + payload = {"title": title, "body": body, "head": head, "base": base} + data = api_request("POST", url, auth, payload) + return {"number": data["number"], "url": data["html_url"]} + + +@mcp.tool() +def gitea_close_issue( + issue_number: int, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Close an issue by setting its state to 'closed'. + + Args: + issue_number: The issue number to close. + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict with 'success' boolean and 'message'. + """ + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + url = f"{repo_api_url(h, o, r)}/issues/{issue_number}" + api_request("PATCH", url, auth, {"state": "closed"}) + return {"success": True, "message": f"Issue #{issue_number} closed."} + + +@mcp.tool() +def gitea_list_issues( + state: str = "open", + label: str | None = None, + limit: int = 50, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> list[dict]: + """List issues on a Gitea repository with optional filters. + + Args: + state: Filter by state — 'open', 'closed', or 'all'. + label: Filter by label name (e.g. 'important'). + limit: Max number of issues to return (default: 50). + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + List of dicts with 'number', 'title', 'state', 'labels', 'assignee'. + """ + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + params = f"state={state}&limit={limit}&type=issues" + if label: + params += f"&labels={label}" + url = f"{repo_api_url(h, o, r)}/issues?{params}" + issues = api_request("GET", url, auth) + return [ + { + "number": i["number"], + "title": i["title"], + "state": i["state"], + "labels": [lb["name"] for lb in i.get("labels", [])], + "assignee": (i.get("assignee") or {}).get("login", ""), + } + for i in issues + ] + + +@mcp.tool() +def gitea_view_issue( + issue_number: int, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Get full details of a single issue. + + Args: + issue_number: The issue number to view. + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict with 'number', 'title', 'body', 'state', 'labels', 'assignee', 'url'. + """ + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + url = f"{repo_api_url(h, o, r)}/issues/{issue_number}" + i = api_request("GET", url, auth) + return { + "number": i["number"], + "title": i["title"], + "body": i.get("body", ""), + "state": i["state"], + "labels": [lb["name"] for lb in i.get("labels", [])], + "assignee": (i.get("assignee") or {}).get("login", ""), + "url": i["html_url"], + } + + +@mcp.tool() +def gitea_mark_issue( + issue_number: int, + action: str, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Claim or release an issue via the status:in-progress label. + + This is the cross-agent lock mechanism. Check before starting work. + + Args: + issue_number: The issue number. + action: 'start' to claim (add label) or 'done' to release (remove label). + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict with 'success' boolean and 'message'. + """ + if action not in ("start", "done"): + raise ValueError(f"action must be 'start' or 'done', got '{action}'") + + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + base = repo_api_url(h, o, r) + + # Find the status:in-progress label id + 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 + + if label_id is None: + raise RuntimeError( + "Label 'status:in-progress' not found. " + "Run manage_labels.py to create it first." + ) + + if action == "start": + api_request("POST", f"{base}/issues/{issue_number}/labels", auth, + {"labels": [label_id]}) + return {"success": True, "message": f"Issue #{issue_number} claimed."} + else: + api_request("DELETE", + f"{base}/issues/{issue_number}/labels/{label_id}", auth) + return {"success": True, "message": f"Issue #{issue_number} released."} + + +@mcp.tool() +def gitea_mirror_refs( + apply: bool = False, + force: bool = False, +) -> dict: + """Mirror branches and tags between dadeschools and prgs Timesheet repos. + + Additive only — never deletes branches or tags. Diverged branches are + skipped unless force is True. + + Args: + apply: If True, actually push. If False (default), dry-run only. + force: If True, force-push diverged branches. + + Returns: + dict with 'output' (script stdout) and 'return_code'. + """ + script = os.path.join(PROJECT_ROOT, "mirror_refs.sh") + args = [script] + if apply: + args.append("--apply") + if force: + args.append("--force") + + result = subprocess.run( + args, capture_output=True, text=True, timeout=120, + ) + return { + "output": result.stdout + result.stderr, + "return_code": result.returncode, + } + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bbe0703 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,40 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.14.0 +attrs==26.1.0 +certifi==2026.6.17 +cffi==2.0.0 +click==8.4.1 +cryptography==49.0.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +httpx-sse==0.4.3 +idna==3.18 +iniconfig==2.3.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +markdown-it-py==4.2.0 +mcp==1.28.0 +mdurl==0.1.2 +packaging==26.2 +pluggy==1.6.0 +pycparser==3.0 +pydantic==2.13.4 +pydantic-settings==2.14.2 +pydantic_core==2.46.4 +Pygments==2.20.0 +PyJWT==2.13.0 +pytest==9.1.1 +python-dotenv==1.2.2 +python-multipart==0.0.32 +referencing==0.37.0 +rich==15.0.0 +rpds-py==2026.5.1 +shellingham==1.5.4 +sse-starlette==3.4.5 +starlette==1.3.1 +typer==0.26.7 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.49.0 diff --git a/sync_repos.sh b/sync_repos.sh new file mode 100755 index 0000000..b1f27c2 --- /dev/null +++ b/sync_repos.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# sync_repos.sh — mirror branches + tags between the two Gitea instances that +# host the Timesheet repo, in BOTH directions. +# +# dadeschools : gitea.dadeschools.net / Contractor/Timesheet (HTTPS — SSH:2222 is flaky) +# prgs : gitea-ssh.prgs.cc:2222 / Scaled-Tech-Consulting/Timesheet (SSH — HTTPS host 404s) +# +# Safety model: +# * ADDITIVE by default. A branch on only one side is pushed to the other. +# * A shared branch where one side is strictly ahead is fast-forwarded. +# * A shared branch that has DIVERGED is skipped with a loud warning +# (never auto-overwritten). Resolve those by hand. +# * Dry-run by default; pass --apply to actually push. --force lets the +# fast-forward pushes use --force (still skips diverged branches). +# +# Auth is automatic via the macOS keychain (`git credential fill`), same as the +# other Gitea-Tools scripts. Run it from inside any clone of the repo, or set +# REPO=/path/to/clone. +# +# Usage: +# ./sync_repos.sh # dry run — show what WOULD sync +# ./sync_repos.sh --apply # perform the sync +# ./sync_repos.sh --apply --force +set -euo pipefail + +# --- config ------------------------------------------------------------------ +DADE_URL="https://gitea.dadeschools.net/Contractor/Timesheet.git" +PRGS_URL="ssh://git@gitea-ssh.prgs.cc:2222/Scaled-Tech-Consulting/Timesheet.git" +REPO="${REPO:-$(pwd)}" + +APPLY=0 +FORCE=0 +for arg in "$@"; do + case "$arg" in + --apply) APPLY=1 ;; + --force) FORCE=1 ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "Unknown flag: $arg" >&2; exit 2 ;; + esac +done + +cd "$REPO" +git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repo: $REPO" >&2; exit 1; } + +note() { printf '%s\n' "$*"; } +action() { if [ "$APPLY" -eq 1 ]; then printf ' ✓ %s\n' "$*"; else printf ' [dry] %s\n' "$*"; fi; } + +# --- fetch both sides into private namespaces -------------------------------- +note "==> Fetching both remotes..." +git fetch --prune "$DADE_URL" '+refs/heads/*:refs/sync/dade/*' 'refs/tags/*:refs/tags/*' >/dev/null 2>&1 \ + || { echo "ERROR: cannot fetch dadeschools (check VPN/keychain)" >&2; exit 1; } +git fetch --prune "$PRGS_URL" '+refs/heads/*:refs/sync/prgs/*' >/dev/null 2>&1 \ + || { echo "ERROR: cannot fetch prgs (check SSH access)" >&2; exit 1; } + +# --- reconcile branches ------------------------------------------------------ +# push