Add unified Gitea skill workflows
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared helpers for Gitea skill scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
SSH_RE = re.compile(r"^git@(?P<host>[^:]+):(?P<path>.+?)(?:\.git)?/?$")
|
||||
REPO_PATH_RE = re.compile(r"^(?P<owner>[^/]+)/(?P<repo>[^/]+)$")
|
||||
|
||||
|
||||
class GitCommandError(RuntimeError):
|
||||
"""Raised when a git command fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepoContext:
|
||||
origin: str
|
||||
owner: str
|
||||
repo: str
|
||||
repo_url: str
|
||||
remote_name: str = "origin"
|
||||
remote_url: str | None = None
|
||||
|
||||
|
||||
def normalize_base_url(base_url: str) -> str:
|
||||
return base_url.rstrip("/")
|
||||
|
||||
|
||||
def ensure_git_repo() -> None:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0 or result.stdout.strip() != "true":
|
||||
raise SystemExit("Current directory is not a git repository.")
|
||||
|
||||
|
||||
def run_git(args: list[str], cwd: str | None = None, check: bool = True) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
detail = result.stderr.strip() or result.stdout.strip() or "unknown git error"
|
||||
raise GitCommandError(f"git {' '.join(args)} failed: {detail}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def parse_http_repo_url(repo_url: str) -> tuple[str, str, str, str]:
|
||||
parsed = urllib.parse.urlsplit(repo_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise SystemExit("Invalid repo URL. Expected format: https://host/owner/repo")
|
||||
|
||||
path = parsed.path.rstrip("/")
|
||||
if path.endswith(".git"):
|
||||
path = path[:-4]
|
||||
|
||||
parts = [part for part in path.split("/") if part]
|
||||
if len(parts) < 2:
|
||||
raise SystemExit("Invalid repo URL. Expected format: https://host/owner/repo")
|
||||
|
||||
owner, repo = parts[-2], parts[-1]
|
||||
prefix = "/".join(parts[:-2])
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
if prefix:
|
||||
origin = f"{origin}/{prefix}"
|
||||
normalized_repo_url = f"{origin}/{owner}/{repo}"
|
||||
return origin, owner, repo, normalized_repo_url
|
||||
|
||||
|
||||
def parse_repo_target(
|
||||
repo_target: str,
|
||||
base_url: str | None = None,
|
||||
) -> tuple[str, str, str, str]:
|
||||
value = repo_target.strip()
|
||||
if not value:
|
||||
raise SystemExit("Repo target cannot be empty.")
|
||||
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
return parse_http_repo_url(value)
|
||||
|
||||
if value.startswith("ssh://"):
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
path = parsed.path.rstrip("/")
|
||||
if path.endswith(".git"):
|
||||
path = path[:-4]
|
||||
parts = [part for part in path.split("/") if part]
|
||||
if len(parts) < 2:
|
||||
raise SystemExit(
|
||||
"Invalid SSH repo URL. Expected ssh://git@host/owner/repo.git"
|
||||
)
|
||||
owner, repo = parts[-2], parts[-1]
|
||||
prefix = "/".join(parts[:-2])
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise SystemExit("Invalid SSH repo URL. Missing host.")
|
||||
origin = (
|
||||
normalize_base_url(base_url)
|
||||
if base_url
|
||||
else f"https://{host}{f'/{prefix}' if prefix else ''}"
|
||||
)
|
||||
return origin, owner, repo, f"{origin}/{owner}/{repo}"
|
||||
|
||||
ssh_match = SSH_RE.match(value)
|
||||
if ssh_match:
|
||||
path = ssh_match.group("path").rstrip("/")
|
||||
if path.endswith(".git"):
|
||||
path = path[:-4]
|
||||
parts = [part for part in path.split("/") if part]
|
||||
if len(parts) < 2:
|
||||
raise SystemExit(
|
||||
"Invalid SSH repo target. Expected git@host:owner/repo.git"
|
||||
)
|
||||
owner, repo = parts[-2], parts[-1]
|
||||
prefix = "/".join(parts[:-2])
|
||||
origin = (
|
||||
normalize_base_url(base_url)
|
||||
if base_url
|
||||
else f"https://{ssh_match.group('host')}{f'/{prefix}' if prefix else ''}"
|
||||
)
|
||||
return origin, owner, repo, f"{origin}/{owner}/{repo}"
|
||||
|
||||
path_match = REPO_PATH_RE.match(value)
|
||||
if path_match:
|
||||
if not base_url:
|
||||
raise SystemExit(
|
||||
"Repo shorthand owner/repo requires GITEA_BASE_URL or a full repo URL."
|
||||
)
|
||||
origin = normalize_base_url(base_url)
|
||||
owner = path_match.group("owner")
|
||||
repo = path_match.group("repo")
|
||||
return origin, owner, repo, f"{origin}/{owner}/{repo}"
|
||||
|
||||
raise SystemExit(
|
||||
"Invalid repo target. Use https://host/owner/repo, git@host:owner/repo.git, "
|
||||
"ssh://git@host/owner/repo.git, or owner/repo with GITEA_BASE_URL."
|
||||
)
|
||||
|
||||
|
||||
def get_remote_url(remote: str = "origin") -> str:
|
||||
ensure_git_repo()
|
||||
try:
|
||||
return run_git(["remote", "get-url", remote])
|
||||
except GitCommandError as exc:
|
||||
raise SystemExit(f"Failed to read git remote '{remote}': {exc}") from exc
|
||||
|
||||
|
||||
def resolve_repo(
|
||||
repo_url: str | None = None,
|
||||
repo: str | None = None,
|
||||
remote: str = "origin",
|
||||
) -> RepoContext:
|
||||
base_url = os.getenv("GITEA_BASE_URL")
|
||||
remote_url = None
|
||||
target = repo_url or repo
|
||||
if not target:
|
||||
remote_url = get_remote_url(remote)
|
||||
target = remote_url
|
||||
origin, owner, repo_name, normalized_repo_url = parse_repo_target(
|
||||
target,
|
||||
base_url=base_url,
|
||||
)
|
||||
return RepoContext(
|
||||
origin=origin,
|
||||
owner=owner,
|
||||
repo=repo_name,
|
||||
repo_url=normalized_repo_url,
|
||||
remote_name=remote,
|
||||
remote_url=remote_url,
|
||||
)
|
||||
|
||||
|
||||
def api_base(context: RepoContext) -> str:
|
||||
return f"{context.origin}/api/v1/repos/{context.owner}/{context.repo}"
|
||||
|
||||
|
||||
def load_token(required: bool = True) -> str:
|
||||
token = os.getenv("GITEA_TOKEN", "").strip()
|
||||
if not token and required:
|
||||
raise SystemExit("Missing GITEA_TOKEN. Export it before using Gitea workflows.")
|
||||
return token
|
||||
|
||||
|
||||
def request_json(
|
||||
url: str,
|
||||
token: str,
|
||||
method: str = "GET",
|
||||
payload: dict | None = None,
|
||||
) -> dict | list:
|
||||
data = None
|
||||
headers = {"Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response:
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise SystemExit(
|
||||
f"Gitea API request failed: {exc.code} {exc.reason} | {body}"
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SystemExit(f"Failed to reach Gitea API: {exc.reason}") from exc
|
||||
|
||||
|
||||
def get_current_user_login(origin: str, token: str) -> str:
|
||||
data = request_json(f"{origin}/api/v1/user", token)
|
||||
if not isinstance(data, dict):
|
||||
raise SystemExit("Unexpected user API response.")
|
||||
login = str(data.get("login") or "").strip()
|
||||
if not login:
|
||||
raise SystemExit("Failed to resolve current Gitea user login.")
|
||||
return login
|
||||
|
||||
|
||||
def current_branch() -> str:
|
||||
ensure_git_repo()
|
||||
try:
|
||||
branch = run_git(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
except GitCommandError as exc:
|
||||
raise SystemExit(
|
||||
"Detached HEAD. Checkout a branch before running push or PR actions."
|
||||
) from exc
|
||||
if not branch:
|
||||
raise SystemExit("Failed to determine current git branch.")
|
||||
return branch
|
||||
|
||||
|
||||
def get_upstream(branch: str) -> str | None:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"rev-parse",
|
||||
"--abbrev-ref",
|
||||
"--symbolic-full-name",
|
||||
f"{branch}@{{upstream}}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
upstream = result.stdout.strip()
|
||||
return upstream or None
|
||||
|
||||
|
||||
def remote_branch_sha(remote: str, branch: str) -> str | None:
|
||||
try:
|
||||
output = run_git(["ls-remote", "--heads", remote, branch])
|
||||
except GitCommandError as exc:
|
||||
raise SystemExit(f"Failed to query remote branch '{remote}/{branch}': {exc}") from exc
|
||||
if not output:
|
||||
return None
|
||||
return output.split()[0]
|
||||
|
||||
|
||||
def ahead_behind(compare_ref: str) -> tuple[int, int]:
|
||||
output = run_git(["rev-list", "--left-right", "--count", f"{compare_ref}...HEAD"])
|
||||
parts = output.split()
|
||||
if len(parts) != 2:
|
||||
raise SystemExit(f"Unexpected rev-list output: {output}")
|
||||
behind, ahead = (int(part) for part in parts)
|
||||
return behind, ahead
|
||||
|
||||
|
||||
def worktree_changes() -> list[str]:
|
||||
output = run_git(["status", "--short"])
|
||||
return [line for line in output.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def remote_default_branch(remote: str = "origin") -> str:
|
||||
try:
|
||||
output = run_git(["symbolic-ref", "--quiet", "--short", f"refs/remotes/{remote}/HEAD"])
|
||||
if output.startswith(f"{remote}/"):
|
||||
return output.split("/", 1)[1]
|
||||
except GitCommandError:
|
||||
pass
|
||||
|
||||
for candidate in ("main", "master"):
|
||||
if remote_branch_sha(remote, candidate):
|
||||
return candidate
|
||||
raise SystemExit(
|
||||
f"Failed to infer default branch for remote '{remote}'. Pass --base explicitly."
|
||||
)
|
||||
|
||||
|
||||
def build_authenticated_push_url(repo_url: str, username: str, token: str) -> str:
|
||||
parsed = urllib.parse.urlsplit(repo_url)
|
||||
path = parsed.path.rstrip("/")
|
||||
if not path.endswith(".git"):
|
||||
path = f"{path}.git"
|
||||
netloc = (
|
||||
f"{urllib.parse.quote(username, safe='')}:"
|
||||
f"{urllib.parse.quote(token, safe='')}@{parsed.netloc}"
|
||||
)
|
||||
return urllib.parse.urlunsplit((parsed.scheme or "https", netloc, path, "", ""))
|
||||
|
||||
|
||||
def mask_url(url: str) -> str:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
if "@" not in parsed.netloc:
|
||||
return url
|
||||
_, host = parsed.netloc.rsplit("@", 1)
|
||||
return urllib.parse.urlunsplit((parsed.scheme, f"***@{host}", parsed.path, "", ""))
|
||||
|
||||
|
||||
def is_auth_error(stderr: str) -> bool:
|
||||
message = stderr.lower()
|
||||
indicators = (
|
||||
"authentication failed",
|
||||
"permission denied",
|
||||
"could not read username",
|
||||
"could not read password",
|
||||
"http basic: access denied",
|
||||
"access denied",
|
||||
"unauthorized",
|
||||
)
|
||||
return any(indicator in message for indicator in indicators)
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List, create, and comment on Gitea pull requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from common import (
|
||||
api_base,
|
||||
current_branch,
|
||||
load_token,
|
||||
remote_default_branch,
|
||||
request_json,
|
||||
resolve_repo,
|
||||
)
|
||||
from push_gitea import compute_push_plan, execute_push
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="List, create, and comment on Gitea pull requests."
|
||||
)
|
||||
parser.add_argument("--repo-url", help="Explicit target repo URL.")
|
||||
parser.add_argument("--repo", help="Shorthand owner/repo. Requires GITEA_BASE_URL.")
|
||||
parser.add_argument("--remote", default="origin", help="Git remote name. Default: origin")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
list_parser = subparsers.add_parser("list", help="List pull requests.")
|
||||
list_parser.add_argument(
|
||||
"--state",
|
||||
default="open",
|
||||
choices=("open", "closed", "all"),
|
||||
help="Pull request state filter.",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--limit",
|
||||
default=20,
|
||||
type=int,
|
||||
help="Maximum number of pull requests to return.",
|
||||
)
|
||||
|
||||
create_parser = subparsers.add_parser("create", help="Create a pull request.")
|
||||
create_parser.add_argument("--base", help="Base branch. Default: remote default branch.")
|
||||
create_parser.add_argument("--head", help="Head branch. Default: current branch.")
|
||||
create_parser.add_argument("--title", required=True, help="Pull request title.")
|
||||
create_parser.add_argument("--body", default="", help="Pull request body.")
|
||||
|
||||
comment_parser = subparsers.add_parser("comment", help="Comment on a pull request.")
|
||||
comment_parser.add_argument("pr", type=int, help="Pull request number.")
|
||||
comment_parser.add_argument("--body", required=True, help="Comment body.")
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def summarize_pull_request(item: dict) -> dict:
|
||||
user = item.get("user") or {}
|
||||
head = item.get("head") or {}
|
||||
base = item.get("base") or {}
|
||||
return {
|
||||
"number": item.get("number"),
|
||||
"title": item.get("title"),
|
||||
"state": item.get("state"),
|
||||
"html_url": item.get("html_url"),
|
||||
"author": user.get("full_name") or user.get("login"),
|
||||
"head": head.get("ref"),
|
||||
"base": base.get("ref"),
|
||||
"created_at": item.get("created_at"),
|
||||
"updated_at": item.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def list_pull_requests(args: argparse.Namespace) -> dict:
|
||||
context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote)
|
||||
token = load_token(required=True)
|
||||
url = f"{api_base(context)}/pulls?state={args.state}&page=1&limit={args.limit}"
|
||||
data = request_json(url, token)
|
||||
if not isinstance(data, list):
|
||||
raise SystemExit("Unexpected pull request list response.")
|
||||
return {
|
||||
"action": "list",
|
||||
"repo_url": context.repo_url,
|
||||
"state": args.state,
|
||||
"limit": args.limit,
|
||||
"count": len(data),
|
||||
"pull_requests": [summarize_pull_request(item) for item in data],
|
||||
}
|
||||
|
||||
|
||||
def create_pull_request(args: argparse.Namespace) -> dict:
|
||||
context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote)
|
||||
token = load_token(required=True)
|
||||
head = args.head or current_branch()
|
||||
base = args.base or remote_default_branch(args.remote)
|
||||
|
||||
push_plan = compute_push_plan(
|
||||
repo_url=args.repo_url,
|
||||
repo=args.repo,
|
||||
remote=args.remote,
|
||||
branch=head,
|
||||
force=False,
|
||||
)
|
||||
push_result = None
|
||||
if push_plan["needs_push"]:
|
||||
push_result = execute_push(push_plan, force=False)
|
||||
elif push_plan["status"] == "blocked":
|
||||
raise SystemExit(
|
||||
"Cannot create PR because the current branch has diverged from the remote branch."
|
||||
)
|
||||
|
||||
payload = {"base": base, "head": head, "title": args.title, "body": args.body}
|
||||
created = request_json(f"{api_base(context)}/pulls", token, method="POST", payload=payload)
|
||||
if not isinstance(created, dict):
|
||||
raise SystemExit("Unexpected pull request creation response.")
|
||||
return {
|
||||
"action": "create",
|
||||
"repo_url": context.repo_url,
|
||||
"base": base,
|
||||
"head": head,
|
||||
"push": push_result,
|
||||
"pull_request": summarize_pull_request(created),
|
||||
}
|
||||
|
||||
|
||||
def comment_pull_request(args: argparse.Namespace) -> dict:
|
||||
context = resolve_repo(repo_url=args.repo_url, repo=args.repo, remote=args.remote)
|
||||
token = load_token(required=True)
|
||||
payload = {"body": args.body}
|
||||
created = request_json(
|
||||
f"{api_base(context)}/issues/{args.pr}/comments",
|
||||
token,
|
||||
method="POST",
|
||||
payload=payload,
|
||||
)
|
||||
if not isinstance(created, dict):
|
||||
raise SystemExit("Unexpected PR comment response.")
|
||||
user = created.get("user") or {}
|
||||
return {
|
||||
"action": "comment",
|
||||
"repo_url": context.repo_url,
|
||||
"pull_request": args.pr,
|
||||
"comment": {
|
||||
"id": created.get("id"),
|
||||
"html_url": created.get("html_url"),
|
||||
"author": user.get("full_name") or user.get("login"),
|
||||
"created_at": created.get("created_at"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.command == "list":
|
||||
payload = list_pull_requests(args)
|
||||
elif args.command == "create":
|
||||
payload = create_pull_request(args)
|
||||
else:
|
||||
payload = comment_pull_request(args)
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Preflight and execute git push with optional Gitea token fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from common import (
|
||||
build_authenticated_push_url,
|
||||
current_branch,
|
||||
ensure_git_repo,
|
||||
get_current_user_login,
|
||||
get_upstream,
|
||||
is_auth_error,
|
||||
load_token,
|
||||
mask_url,
|
||||
remote_branch_sha,
|
||||
resolve_repo,
|
||||
worktree_changes,
|
||||
ahead_behind,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Preflight and optionally push the current branch to Gitea."
|
||||
)
|
||||
parser.add_argument("--repo-url", help="Explicit target repo URL.")
|
||||
parser.add_argument("--repo", help="Shorthand owner/repo. Requires GITEA_BASE_URL.")
|
||||
parser.add_argument("--remote", default="origin", help="Git remote name. Default: origin")
|
||||
parser.add_argument("--branch", help="Branch to push. Default: current branch")
|
||||
parser.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="Run git push after preflight instead of only printing the plan.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Allow force push. Uses --force-with-lease under the hood.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def build_push_command(target: str, branch: str, force: bool) -> list[str]:
|
||||
command = ["git", "push"]
|
||||
if force:
|
||||
command.append("--force-with-lease")
|
||||
command.extend([target, f"{branch}:{branch}"])
|
||||
return command
|
||||
|
||||
|
||||
def command_text(command: list[str]) -> str:
|
||||
return " ".join(shlex.quote(part) for part in command)
|
||||
|
||||
|
||||
def compute_push_plan(
|
||||
repo_url: str | None = None,
|
||||
repo: str | None = None,
|
||||
remote: str = "origin",
|
||||
branch: str | None = None,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
ensure_git_repo()
|
||||
context = resolve_repo(repo_url=repo_url, repo=repo, remote=remote)
|
||||
branch_name = branch or current_branch()
|
||||
dirty_lines = worktree_changes()
|
||||
upstream = get_upstream(branch_name)
|
||||
compare_ref = upstream
|
||||
remote_exists = True
|
||||
ahead = 0
|
||||
behind = 0
|
||||
|
||||
if upstream:
|
||||
behind, ahead = ahead_behind(upstream)
|
||||
else:
|
||||
remote_sha = remote_branch_sha(remote, branch_name)
|
||||
if remote_sha:
|
||||
compare_ref = f"{remote}/{branch_name}"
|
||||
behind, ahead = ahead_behind(remote_sha)
|
||||
else:
|
||||
remote_exists = False
|
||||
compare_ref = None
|
||||
ahead = None
|
||||
behind = 0
|
||||
|
||||
diverged = bool(remote_exists and behind > 0 and (ahead or 0) > 0)
|
||||
behind_only = bool(remote_exists and behind > 0 and (ahead or 0) == 0)
|
||||
needs_push = (not remote_exists) or ((ahead or 0) > 0)
|
||||
|
||||
status = "ready"
|
||||
if diverged and not force:
|
||||
status = "blocked"
|
||||
elif not needs_push:
|
||||
status = "noop"
|
||||
|
||||
messages: list[str] = []
|
||||
if dirty_lines:
|
||||
messages.append("Working tree is dirty; push will include only committed changes.")
|
||||
if not remote_exists:
|
||||
messages.append("Remote branch does not exist yet; push will create it.")
|
||||
if behind_only:
|
||||
messages.append("Local branch is behind the remote branch; there are no local commits to push.")
|
||||
if diverged and not force:
|
||||
messages.append("Branch has diverged from remote; rerun with explicit force only if that is intended.")
|
||||
|
||||
push_command = build_push_command(remote, branch_name, force)
|
||||
fallback_command = None
|
||||
token_available = bool(load_token(required=False))
|
||||
if token_available:
|
||||
try:
|
||||
token = load_token(required=False)
|
||||
login = get_current_user_login(context.origin, token)
|
||||
auth_url = build_authenticated_push_url(context.repo_url, login, token)
|
||||
fallback_command = build_push_command(auth_url, branch_name, force)
|
||||
except SystemExit as exc:
|
||||
messages.append(f"Failed to prepare HTTPS token fallback: {exc}")
|
||||
|
||||
return {
|
||||
"repo_url": context.repo_url,
|
||||
"origin": context.origin,
|
||||
"owner": context.owner,
|
||||
"repo": context.repo,
|
||||
"remote": remote,
|
||||
"branch": branch_name,
|
||||
"upstream": upstream,
|
||||
"compare_ref": compare_ref,
|
||||
"remote_branch_exists": remote_exists,
|
||||
"dirty": bool(dirty_lines),
|
||||
"dirty_paths": dirty_lines,
|
||||
"ahead": ahead,
|
||||
"behind": behind,
|
||||
"diverged": diverged,
|
||||
"behind_only": behind_only,
|
||||
"needs_push": needs_push,
|
||||
"status": status,
|
||||
"messages": messages,
|
||||
"push_command": command_text(push_command),
|
||||
"fallback_push_command": command_text(
|
||||
[
|
||||
*(fallback_command[:-2] if fallback_command else []),
|
||||
mask_url(fallback_command[-2]) if fallback_command else "",
|
||||
*(fallback_command[-1:] if fallback_command else []),
|
||||
]
|
||||
)
|
||||
if fallback_command
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(command, capture_output=True, text=True)
|
||||
|
||||
|
||||
def execute_push(plan: dict, force: bool = False) -> dict:
|
||||
if plan["status"] == "noop":
|
||||
return {
|
||||
"status": "noop",
|
||||
"executed": False,
|
||||
"reason": "nothing_to_push",
|
||||
"messages": plan["messages"],
|
||||
}
|
||||
if plan["diverged"] and not force:
|
||||
raise SystemExit(
|
||||
"Branch has diverged from remote. Refusing to push without explicit --force."
|
||||
)
|
||||
|
||||
primary_command = build_push_command(plan["remote"], plan["branch"], force)
|
||||
primary_result = run_command(primary_command)
|
||||
if primary_result.returncode == 0:
|
||||
return {
|
||||
"status": "pushed",
|
||||
"executed": True,
|
||||
"method": "remote",
|
||||
"command": command_text(primary_command),
|
||||
"stdout": primary_result.stdout.strip(),
|
||||
"stderr": primary_result.stderr.strip(),
|
||||
}
|
||||
|
||||
primary_stderr = primary_result.stderr.strip() or primary_result.stdout.strip()
|
||||
if not is_auth_error(primary_stderr):
|
||||
raise SystemExit(f"git push failed: {primary_stderr}")
|
||||
|
||||
token = load_token(required=True)
|
||||
login = get_current_user_login(plan["origin"], token)
|
||||
auth_url = build_authenticated_push_url(plan["repo_url"], login, token)
|
||||
fallback_command = build_push_command(auth_url, plan["branch"], force)
|
||||
fallback_result = run_command(fallback_command)
|
||||
if fallback_result.returncode == 0:
|
||||
return {
|
||||
"status": "pushed",
|
||||
"executed": True,
|
||||
"method": "https-token",
|
||||
"command": command_text(
|
||||
[
|
||||
*(fallback_command[:-2]),
|
||||
mask_url(fallback_command[-2]),
|
||||
fallback_command[-1],
|
||||
]
|
||||
),
|
||||
"stdout": fallback_result.stdout.strip(),
|
||||
"stderr": fallback_result.stderr.strip(),
|
||||
}
|
||||
|
||||
fallback_stderr = fallback_result.stderr.strip() or fallback_result.stdout.strip()
|
||||
raise SystemExit(
|
||||
"git push failed with remote auth and HTTPS token fallback: "
|
||||
f"{fallback_stderr}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
plan = compute_push_plan(
|
||||
repo_url=args.repo_url,
|
||||
repo=args.repo,
|
||||
remote=args.remote,
|
||||
branch=args.branch,
|
||||
force=args.force,
|
||||
)
|
||||
if not args.execute:
|
||||
print(json.dumps(plan, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
result = execute_push(plan, force=args.force)
|
||||
payload = {"plan": plan, "result": result}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user