feat: add MCP server + shared auth module (#7, #1)

- 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:
2026-06-21 20:08:07 -04:00
parent dd6f1308c1
commit b7e195e426
11 changed files with 978 additions and 214 deletions
Executable
+117
View File
@@ -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