- New: mcp_server.py — FastMCP stdio server exposing 7 tools: gitea_create_issue, gitea_create_pr, gitea_close_issue, gitea_list_issues, gitea_view_issue, gitea_mark_issue, gitea_mirror_refs - New: auth.py — shared authentication and API helpers (get_credentials, get_auth_header, api_request, repo_api_url) - Refactored: create_pr.py, create_issue.py, manage_labels.py to use shared auth module (eliminates credential duplication) - New: tests/test_mcp_server.py — 17 tests for all MCP tools - Updated: tests/test_credentials.py — now tests auth.py directly - Updated: tests/test_create_issue.py — adapted for refactored imports - New: requirements.txt — frozen venv deps (mcp[cli], pytest) - Updated: README.md — MCP server as primary interface - Config: added gitea-tools to mcp_config.json Closes #1. Resolves #2, #5. Relates to #7.
This commit is contained in:
@@ -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,29 +90,33 @@ 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_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 |
|
||||
|
||||
@@ -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}"
|
||||
Executable → Regular
+18
-55
@@ -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,
|
||||
from auth import (
|
||||
get_credentials, resolve_remote, add_remote_args,
|
||||
api_request, repo_api_url,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
+4
-33
@@ -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())
|
||||
|
||||
|
||||
+14
-36
@@ -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 []
|
||||
|
||||
+327
@@ -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")
|
||||
@@ -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
|
||||
Executable
+117
@@ -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 <dest_url> <sha> <branch> <label>
|
||||
push_branch() {
|
||||
local url="$1" sha="$2" b="$3" label="$4" ff_flag=""
|
||||
[ "$FORCE" -eq 1 ] && ff_flag="--force"
|
||||
action "push '${b}' -> ${label}"
|
||||
if [ "$APPLY" -eq 1 ]; then
|
||||
git push $ff_flag "$url" "${sha}:refs/heads/${b}" >/dev/null 2>&1 \
|
||||
&& note " done." \
|
||||
|| note " FAILED (see: git push $ff_flag $url ${sha}:refs/heads/${b})"
|
||||
fi
|
||||
}
|
||||
|
||||
branches=$(git for-each-ref --format='%(refname:lstrip=3)' refs/sync/dade refs/sync/prgs | sort -u)
|
||||
|
||||
note "==> Reconciling branches..."
|
||||
synced=0; pushed=0; diverged=0
|
||||
for b in $branches; do
|
||||
d=$(git rev-parse -q --verify "refs/sync/dade/${b}" || true)
|
||||
p=$(git rev-parse -q --verify "refs/sync/prgs/${b}" || true)
|
||||
|
||||
if [ -n "$d" ] && [ -z "$p" ]; then
|
||||
note "branch '${b}': only on dadeschools"; push_branch "$PRGS_URL" "$d" "$b" "prgs"; pushed=$((pushed+1)); continue
|
||||
fi
|
||||
if [ -z "$d" ] && [ -n "$p" ]; then
|
||||
note "branch '${b}': only on prgs"; push_branch "$DADE_URL" "$p" "$b" "dadeschools"; pushed=$((pushed+1)); continue
|
||||
fi
|
||||
# on both
|
||||
if [ "$d" = "$p" ]; then synced=$((synced+1)); continue; fi
|
||||
if git merge-base --is-ancestor "$p" "$d"; then
|
||||
note "branch '${b}': dadeschools is ahead (fast-forward)"; push_branch "$PRGS_URL" "$d" "$b" "prgs"; pushed=$((pushed+1))
|
||||
elif git merge-base --is-ancestor "$d" "$p"; then
|
||||
note "branch '${b}': prgs is ahead (fast-forward)"; push_branch "$DADE_URL" "$p" "$b" "dadeschools"; pushed=$((pushed+1))
|
||||
else
|
||||
note "branch '${b}': ⚠ DIVERGED — skipped (resolve manually; --force will not auto-pick a winner)"; diverged=$((diverged+1))
|
||||
fi
|
||||
done
|
||||
|
||||
# --- tags (additive both ways) ----------------------------------------------
|
||||
note "==> Syncing tags..."
|
||||
sync_tags() {
|
||||
local url="$1" label="$2"
|
||||
local remote_tags local_tags missing
|
||||
remote_tags=$(git ls-remote --tags "$url" 2>/dev/null | awk '{print $2}' | grep -v '\^{}' | sed 's#refs/tags/##' | sort || true)
|
||||
local_tags=$(git tag -l | sort)
|
||||
missing=$(comm -23 <(printf '%s\n' "$local_tags") <(printf '%s\n' "$remote_tags"))
|
||||
if [ -z "$missing" ]; then note " ${label}: tags up to date"; return; fi
|
||||
for t in $missing; do
|
||||
action "push tag '${t}' -> ${label}"
|
||||
[ "$APPLY" -eq 1 ] && git push "$url" "refs/tags/${t}" >/dev/null 2>&1 && note " done."
|
||||
done
|
||||
}
|
||||
sync_tags "$DADE_URL" "dadeschools"
|
||||
sync_tags "$PRGS_URL" "prgs"
|
||||
|
||||
# --- cleanup private namespace ----------------------------------------------
|
||||
git for-each-ref --format='%(refname)' refs/sync | while read -r r; do git update-ref -d "$r"; done
|
||||
|
||||
note ""
|
||||
note "==> Summary: ${synced} already in sync, ${pushed} branch push(es), ${diverged} diverged/skipped."
|
||||
[ "$APPLY" -eq 0 ] && note " (dry run — re-run with --apply to perform the sync)"
|
||||
[ "$diverged" -gt 0 ] && note " ⚠ ${diverged} diverged branch(es) need manual reconciliation."
|
||||
exit 0
|
||||
+30
-56
@@ -1,7 +1,7 @@
|
||||
"""Tests for create_issue.py.
|
||||
|
||||
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
|
||||
network calls or keychain access are performed.
|
||||
Every test mocks auth functions so no real network calls or keychain
|
||||
access are performed.
|
||||
|
||||
Note: create_issue.py may be inaccessible due to macOS sandbox restrictions.
|
||||
If so, these tests are automatically skipped.
|
||||
@@ -28,17 +28,6 @@ except (ImportError, PermissionError, OSError) as exc:
|
||||
FAKE_CREDS = ("testuser", "testpass")
|
||||
|
||||
|
||||
def _mock_urlopen(status=200, body=None):
|
||||
"""Return a mock context-manager for urllib.request.urlopen."""
|
||||
if body is None:
|
||||
body = {"number": 42, "html_url": "https://gitea.example.com/issues/42"}
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps(body).encode("utf-8")
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -46,9 +35,9 @@ def _mock_urlopen(status=200, body=None):
|
||||
class TestArgParsing(unittest.TestCase):
|
||||
"""Ensure argparse accepts the expected flags and rejects bad input."""
|
||||
|
||||
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
|
||||
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_minimal_args(self, _cred, _url):
|
||||
def test_minimal_args(self, _cred, _api):
|
||||
rc = create_issue.main(["--title", "Hello"])
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
@@ -57,9 +46,9 @@ class TestArgParsing(unittest.TestCase):
|
||||
create_issue.main(["--body", "no title given"])
|
||||
self.assertNotEqual(ctx.exception.code, 0)
|
||||
|
||||
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
|
||||
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_remote_choices(self, _cred, _url):
|
||||
def test_remote_choices(self, _cred, _api):
|
||||
for remote in ("dadeschools", "prgs"):
|
||||
rc = create_issue.main(["--remote", remote, "--title", "X"])
|
||||
self.assertEqual(rc, 0, f"--remote {remote} should be accepted")
|
||||
@@ -76,21 +65,21 @@ class TestArgParsing(unittest.TestCase):
|
||||
class TestRemoteResolution(unittest.TestCase):
|
||||
"""Verify the correct host/org/repo are selected per remote."""
|
||||
|
||||
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
|
||||
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_dadeschools_default(self, mock_cred, mock_url):
|
||||
def test_dadeschools_default(self, mock_cred, _api):
|
||||
create_issue.main(["--title", "T"])
|
||||
mock_cred.assert_called_with("gitea.dadeschools.net")
|
||||
|
||||
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
|
||||
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_prgs_remote(self, mock_cred, mock_url):
|
||||
def test_prgs_remote(self, mock_cred, _api):
|
||||
create_issue.main(["--remote", "prgs", "--title", "T"])
|
||||
mock_cred.assert_called_with("gitea.prgs.cc")
|
||||
|
||||
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
|
||||
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_host_override(self, mock_cred, mock_url):
|
||||
def test_host_override(self, mock_cred, _api):
|
||||
create_issue.main(["--host", "custom.example.com", "--title", "T"])
|
||||
mock_cred.assert_called_with("custom.example.com")
|
||||
|
||||
@@ -104,27 +93,19 @@ class TestAPIPayload(unittest.TestCase):
|
||||
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_payload_title_and_body(self, _cred):
|
||||
with patch("create_issue.urllib.request.Request") as MockReq:
|
||||
MockReq.return_value = MagicMock()
|
||||
with patch("create_issue.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen()):
|
||||
with patch("create_issue.api_request",
|
||||
return_value={"number": 1, "html_url": "http://x/1"}) as mock_api:
|
||||
create_issue.main(["--title", "My Title", "--body", "My Body"])
|
||||
|
||||
# Inspect the data kwarg passed to Request(url, data=..., ...)
|
||||
call_kwargs = MockReq.call_args
|
||||
data = json.loads(call_kwargs[1]["data"].decode("utf-8"))
|
||||
self.assertEqual(data["title"], "My Title")
|
||||
self.assertEqual(data["body"], "My Body")
|
||||
payload = mock_api.call_args[0][3]
|
||||
self.assertEqual(payload["title"], "My Title")
|
||||
self.assertEqual(payload["body"], "My Body")
|
||||
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_url_construction(self, _cred):
|
||||
with patch("create_issue.urllib.request.Request") as MockReq:
|
||||
MockReq.return_value = MagicMock()
|
||||
with patch("create_issue.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen()):
|
||||
with patch("create_issue.api_request",
|
||||
return_value={"number": 1, "html_url": "http://x/1"}) as mock_api:
|
||||
create_issue.main(["--remote", "prgs", "--title", "X"])
|
||||
|
||||
url = MockReq.call_args[0][0]
|
||||
url = mock_api.call_args[0][1]
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues",
|
||||
@@ -137,19 +118,16 @@ class TestAPIPayload(unittest.TestCase):
|
||||
@unittest.skipIf(_SKIP, _REASON)
|
||||
class TestBodyFile(unittest.TestCase):
|
||||
|
||||
@patch("create_issue.urllib.request.urlopen", return_value=_mock_urlopen())
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_body_from_file(self, _cred, _url):
|
||||
def test_body_from_file(self, _cred):
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
|
||||
f.write("File body content")
|
||||
f.flush()
|
||||
with patch("create_issue.urllib.request.Request") as MockReq:
|
||||
MockReq.return_value = MagicMock()
|
||||
with patch("create_issue.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen()):
|
||||
with patch("create_issue.api_request",
|
||||
return_value={"number": 1, "html_url": "http://x/1"}) as mock_api:
|
||||
create_issue.main(["--title", "T", "--body-file", f.name])
|
||||
data = json.loads(MockReq.call_args[1]["data"].decode("utf-8"))
|
||||
self.assertEqual(data["body"], "File body content")
|
||||
payload = mock_api.call_args[0][3]
|
||||
self.assertEqual(payload["body"], "File body content")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -165,19 +143,15 @@ class TestAuthFailure(unittest.TestCase):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP error handling
|
||||
# API error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
@unittest.skipIf(_SKIP, _REASON)
|
||||
class TestHTTPError(unittest.TestCase):
|
||||
class TestAPIError(unittest.TestCase):
|
||||
|
||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||
def test_http_error_returns_1(self, _cred):
|
||||
import urllib.error
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://example.com", code=422, msg="Unprocessable",
|
||||
hdrs=None, fp=io.BytesIO(b'{"message":"duplicate"}'),
|
||||
)
|
||||
with patch("create_issue.urllib.request.urlopen", side_effect=err):
|
||||
def test_api_error_returns_1(self, _cred):
|
||||
with patch("create_issue.api_request",
|
||||
side_effect=RuntimeError("HTTP 422: duplicate")):
|
||||
rc = create_issue.main(["--title", "Dup"])
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
|
||||
+34
-12
@@ -1,4 +1,4 @@
|
||||
"""Tests for the shared get_credentials() function used by create_issue.py and create_pr.py.
|
||||
"""Tests for the shared get_credentials() function in auth.py.
|
||||
|
||||
These test the credential parsing logic in isolation by mocking subprocess.Popen.
|
||||
"""
|
||||
@@ -7,7 +7,7 @@ import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
import create_pr # noqa: E402 (get_credentials is identical in create_issue.py and create_pr.py)
|
||||
import auth # noqa: E402
|
||||
|
||||
|
||||
class TestGetCredentials(unittest.TestCase):
|
||||
@@ -19,45 +19,45 @@ class TestGetCredentials(unittest.TestCase):
|
||||
mock_proc.communicate.return_value = (output_text, "")
|
||||
return mock_proc
|
||||
|
||||
@patch("create_pr.subprocess.Popen")
|
||||
@patch("auth.subprocess.Popen")
|
||||
def test_parses_standard_output(self, mock_popen_cls):
|
||||
mock_popen_cls.return_value = self._mock_popen(
|
||||
"protocol=https\nhost=gitea.example.com\nusername=admin\npassword=s3cret\n"
|
||||
)
|
||||
user, password = create_pr.get_credentials("gitea.example.com")
|
||||
user, password = auth.get_credentials("gitea.example.com")
|
||||
self.assertEqual(user, "admin")
|
||||
self.assertEqual(password, "s3cret")
|
||||
|
||||
@patch("create_pr.subprocess.Popen")
|
||||
@patch("auth.subprocess.Popen")
|
||||
def test_handles_password_with_equals(self, mock_popen_cls):
|
||||
# Tokens often contain '=' characters
|
||||
mock_popen_cls.return_value = self._mock_popen(
|
||||
"username=bot\npassword=abc=def=ghi\n"
|
||||
)
|
||||
user, password = create_pr.get_credentials("example.com")
|
||||
user, password = auth.get_credentials("example.com")
|
||||
self.assertEqual(user, "bot")
|
||||
self.assertEqual(password, "abc=def=ghi")
|
||||
|
||||
@patch("create_pr.subprocess.Popen")
|
||||
@patch("auth.subprocess.Popen")
|
||||
def test_empty_output_returns_empty(self, mock_popen_cls):
|
||||
mock_popen_cls.return_value = self._mock_popen("")
|
||||
user, password = create_pr.get_credentials("example.com")
|
||||
user, password = auth.get_credentials("example.com")
|
||||
self.assertEqual(user, "")
|
||||
self.assertEqual(password, "")
|
||||
|
||||
@patch("create_pr.subprocess.Popen")
|
||||
@patch("auth.subprocess.Popen")
|
||||
def test_missing_password_returns_empty(self, mock_popen_cls):
|
||||
mock_popen_cls.return_value = self._mock_popen("username=admin\n")
|
||||
user, password = create_pr.get_credentials("example.com")
|
||||
user, password = auth.get_credentials("example.com")
|
||||
self.assertEqual(user, "admin")
|
||||
self.assertEqual(password, "")
|
||||
|
||||
@patch("create_pr.subprocess.Popen")
|
||||
@patch("auth.subprocess.Popen")
|
||||
def test_sends_correct_stdin(self, mock_popen_cls):
|
||||
mock_proc = self._mock_popen("username=u\npassword=p\n")
|
||||
mock_popen_cls.return_value = mock_proc
|
||||
|
||||
create_pr.get_credentials("gitea.prgs.cc")
|
||||
auth.get_credentials("gitea.prgs.cc")
|
||||
|
||||
# Verify the correct input was sent to git credential fill
|
||||
mock_proc.communicate.assert_called_once_with(
|
||||
@@ -65,5 +65,27 @@ class TestGetCredentials(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestGetAuthHeader(unittest.TestCase):
|
||||
"""Test the get_auth_header function."""
|
||||
|
||||
@patch("auth.get_credentials", return_value=("user", "pass"))
|
||||
def test_returns_basic_header(self, _cred):
|
||||
header = auth.get_auth_header("example.com")
|
||||
self.assertIsNotNone(header)
|
||||
self.assertTrue(header.startswith("Basic "))
|
||||
|
||||
@patch("auth.get_credentials", return_value=("", ""))
|
||||
def test_returns_none_for_missing_creds(self, _cred):
|
||||
header = auth.get_auth_header("example.com")
|
||||
self.assertIsNone(header)
|
||||
|
||||
|
||||
class TestRepoApiUrl(unittest.TestCase):
|
||||
|
||||
def test_url_format(self):
|
||||
url = auth.repo_api_url("gitea.prgs.cc", "Org", "Repo")
|
||||
self.assertEqual(url, "https://gitea.prgs.cc/api/v1/repos/Org/Repo")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests for the MCP server tool functions.
|
||||
|
||||
Each tool is tested by calling the underlying function directly (not through
|
||||
the MCP protocol) with mocked API responses.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
from mcp_server import ( # noqa: E402
|
||||
gitea_create_issue,
|
||||
gitea_create_pr,
|
||||
gitea_close_issue,
|
||||
gitea_list_issues,
|
||||
gitea_view_issue,
|
||||
gitea_mark_issue,
|
||||
gitea_mirror_refs,
|
||||
)
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create Issue
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCreateIssue(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_creates_issue(self, _auth, mock_api):
|
||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||
result = gitea_create_issue(title="Test issue", body="body text")
|
||||
self.assertEqual(result["number"], 1)
|
||||
self.assertIn("issues/1", result["url"])
|
||||
mock_api.assert_called_once()
|
||||
# Verify payload
|
||||
call_args = mock_api.call_args
|
||||
self.assertEqual(call_args[0][0], "POST")
|
||||
self.assertEqual(call_args[0][3]["title"], "Test issue")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_creates_on_prgs(self, _auth, mock_api):
|
||||
mock_api.return_value = {"number": 5, "html_url": "https://gitea.prgs.cc/issues/5"}
|
||||
result = gitea_create_issue(title="Test", remote="prgs")
|
||||
self.assertEqual(result["number"], 5)
|
||||
url = mock_api.call_args[0][1]
|
||||
self.assertIn("gitea.prgs.cc", url)
|
||||
self.assertIn("Scaled-Tech-Consulting", url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create PR
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCreatePR(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_creates_pr(self, _auth, mock_api):
|
||||
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
|
||||
result = gitea_create_pr(title="feat: X", head="feat/x", base="main")
|
||||
self.assertEqual(result["number"], 3)
|
||||
payload = mock_api.call_args[0][3]
|
||||
self.assertEqual(payload["head"], "feat/x")
|
||||
self.assertEqual(payload["base"], "main")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Close Issue
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCloseIssue(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_closes_issue(self, _auth, mock_api):
|
||||
mock_api.return_value = {"state": "closed"}
|
||||
result = gitea_close_issue(issue_number=42)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("42", result["message"])
|
||||
payload = mock_api.call_args[0][3]
|
||||
self.assertEqual(payload["state"], "closed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List Issues
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestListIssues(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_returns_formatted_list(self, _auth, mock_api):
|
||||
mock_api.return_value = [
|
||||
{
|
||||
"number": 1, "title": "Bug", "state": "open",
|
||||
"labels": [{"name": "bug"}],
|
||||
"assignee": {"login": "alice"},
|
||||
},
|
||||
{
|
||||
"number": 2, "title": "Feature", "state": "open",
|
||||
"labels": [], "assignee": None,
|
||||
},
|
||||
]
|
||||
result = gitea_list_issues()
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual(result[0]["number"], 1)
|
||||
self.assertEqual(result[0]["labels"], ["bug"])
|
||||
self.assertEqual(result[0]["assignee"], "alice")
|
||||
self.assertEqual(result[1]["assignee"], "")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_passes_label_filter(self, _auth, mock_api):
|
||||
mock_api.return_value = []
|
||||
gitea_list_issues(label="important")
|
||||
url = mock_api.call_args[0][1]
|
||||
self.assertIn("labels=important", url)
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_passes_state_filter(self, _auth, mock_api):
|
||||
mock_api.return_value = []
|
||||
gitea_list_issues(state="closed")
|
||||
url = mock_api.call_args[0][1]
|
||||
self.assertIn("state=closed", url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# View Issue
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestViewIssue(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_returns_full_details(self, _auth, mock_api):
|
||||
mock_api.return_value = {
|
||||
"number": 7, "title": "MCP server", "body": "Build it",
|
||||
"state": "open", "labels": [{"name": "important"}],
|
||||
"assignee": {"login": "jason"},
|
||||
"html_url": "https://gitea.prgs.cc/issues/7",
|
||||
}
|
||||
result = gitea_view_issue(issue_number=7, remote="prgs")
|
||||
self.assertEqual(result["number"], 7)
|
||||
self.assertEqual(result["body"], "Build it")
|
||||
self.assertEqual(result["labels"], ["important"])
|
||||
self.assertEqual(result["assignee"], "jason")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mark Issue
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMarkIssue(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_start_adds_label(self, _auth, mock_api):
|
||||
# First call: get labels; second call: add label
|
||||
mock_api.side_effect = [
|
||||
[{"id": 10, "name": "status:in-progress"}],
|
||||
[{"name": "status:in-progress"}],
|
||||
]
|
||||
result = gitea_mark_issue(issue_number=5, action="start")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("claimed", result["message"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_done_removes_label(self, _auth, mock_api):
|
||||
mock_api.side_effect = [
|
||||
[{"id": 10, "name": "status:in-progress"}],
|
||||
None,
|
||||
]
|
||||
result = gitea_mark_issue(issue_number=5, action="done")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("released", result["message"])
|
||||
|
||||
def test_invalid_action_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
gitea_mark_issue(issue_number=5, action="pause")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_missing_label_raises(self, _auth, mock_api):
|
||||
mock_api.return_value = [] # no labels exist
|
||||
with self.assertRaises(RuntimeError):
|
||||
gitea_mark_issue(issue_number=5, action="start")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth errors
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAuthErrors(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.get_auth_header", return_value=None)
|
||||
def test_no_credentials_raises(self, _auth):
|
||||
with self.assertRaises(RuntimeError):
|
||||
gitea_create_issue(title="test")
|
||||
|
||||
def test_unknown_remote_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
gitea_create_issue(title="test", remote="nonexistent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mirror Refs
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMirrorRefs(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.subprocess.run")
|
||||
def test_dry_run_default(self, mock_run):
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="DRY RUN\n", stderr="", returncode=0
|
||||
)
|
||||
result = gitea_mirror_refs()
|
||||
self.assertEqual(result["return_code"], 0)
|
||||
# Should NOT have --apply
|
||||
args = mock_run.call_args[0][0]
|
||||
self.assertNotIn("--apply", args)
|
||||
self.assertNotIn("--force", args)
|
||||
|
||||
@patch("mcp_server.subprocess.run")
|
||||
def test_apply_flag(self, mock_run):
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="done\n", stderr="", returncode=0
|
||||
)
|
||||
result = gitea_mirror_refs(apply=True)
|
||||
args = mock_run.call_args[0][0]
|
||||
self.assertIn("--apply", args)
|
||||
|
||||
@patch("mcp_server.subprocess.run")
|
||||
def test_force_flag(self, mock_run):
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="", stderr="", returncode=0
|
||||
)
|
||||
gitea_mirror_refs(apply=True, force=True)
|
||||
args = mock_run.call_args[0][0]
|
||||
self.assertIn("--apply", args)
|
||||
self.assertIn("--force", args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user