package tools import ( "context" "encoding/json" "fmt" "os" "git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.d-ma.be/mathias/gitea-mcp/internal/registry" ) type RepoMirrorPush struct { c *gitea.Client a *allowlist.Allowlist } func NewRepoMirrorPush(c *gitea.Client, a *allowlist.Allowlist) *RepoMirrorPush { return &RepoMirrorPush{c: c, a: a} } func (t *RepoMirrorPush) Descriptor() registry.ToolDescriptor { return registry.ToolDescriptor{ Name: "repo_mirror_push", Description: "Manage push mirrors for a repository: add, list, or delete. For the mirror credential, PREFER remote_password_env (the name of an env var the server reads) so the secret never rides the tool-call payload/transcript; remote_password (raw) is discouraged and will be persisted in logs.", InputSchema: json.RawMessage(`{ "type":"object", "properties":{ "owner":{"type":"string"}, "repo":{"type":"string"}, "action":{"type":"string","enum":["add","list","delete"]}, "remote_address":{"type":"string","description":"Mirror target URL (required for add)."}, "remote_username":{"type":"string"}, "remote_password_env":{"type":"string","description":"PREFERRED: name of a server-side env var holding the mirror credential; the server resolves it, so the secret is never in this call. Errors if the var is unset."}, "remote_password":{"type":"string","description":"DISCOURAGED: raw credential — lands in the tool-call transcript/logs. Use remote_password_env instead."}, "interval":{"type":"string","description":"Sync interval, e.g. '8h0m0s'."}, "sync_on_commit":{"type":"boolean"}, "mirror_name":{"type":"string","description":"Remote name to delete (required for delete)."} }, "required":["owner","repo","action"] }`), } } type repoMirrorPushArgs struct { Owner string `json:"owner"` Repo string `json:"repo"` Action string `json:"action"` RemoteAddress string `json:"remote_address"` RemoteUsername string `json:"remote_username"` RemotePassword string `json:"remote_password"` RemotePasswordEnv string `json:"remote_password_env"` Interval string `json:"interval"` SyncOnCommit bool `json:"sync_on_commit"` MirrorName string `json:"mirror_name"` } // safeMirror omits remote_password so it is never returned to the caller. type safeMirror struct { ID int `json:"id"` RemoteName string `json:"remote_name"` RemoteAddress string `json:"remote_address"` Interval string `json:"interval"` SyncOnCommit bool `json:"sync_on_commit"` } func toSafeMirror(m *gitea.PushMirror) safeMirror { return safeMirror{ ID: m.ID, RemoteName: m.RemoteName, RemoteAddress: m.RemoteAddress, Interval: m.Interval, SyncOnCommit: m.SyncOnCommit, } } // resolveMirrorPassword prefers remote_password_env — the name of a server-side // env var — so the credential never appears in the tool-call payload (#49). It // falls back to the raw (discouraged) remote_password. An env name that resolves // to empty is a loud error, not a silent empty password. func resolveMirrorPassword(args repoMirrorPushArgs) (string, error) { if args.RemotePasswordEnv != "" { pw := os.Getenv(args.RemotePasswordEnv) if pw == "" { return "", fmt.Errorf("remote_password_env %q is unset or empty in the server environment: %w", args.RemotePasswordEnv, gitea.ErrValidation) } return pw, nil } return args.RemotePassword, nil } func (t *RepoMirrorPush) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { var args repoMirrorPushArgs if err := parseArgs(raw, &args); err != nil { return nil, err } if err := t.a.Check(ctx, args.Owner); err != nil { return nil, err } switch args.Action { case "add": password, err := resolveMirrorPassword(args) if err != nil { return nil, err } m, err := t.c.AddPushMirror(ctx, args.Owner, args.Repo, gitea.AddPushMirrorArgs{ RemoteAddress: args.RemoteAddress, RemoteUsername: args.RemoteUsername, RemotePassword: password, Interval: args.Interval, SyncOnCommit: args.SyncOnCommit, }) if err != nil { return nil, err } return textOK(toSafeMirror(m)) case "list": mirrors, err := t.c.ListPushMirrors(ctx, args.Owner, args.Repo) if err != nil { return nil, err } safe := make([]safeMirror, len(mirrors)) for i := range mirrors { safe[i] = toSafeMirror(&mirrors[i]) } return textOK(safe) case "delete": if args.MirrorName == "" { return nil, fmt.Errorf("mirror_name is required for action=delete") } if err := t.c.DeletePushMirror(ctx, args.Owner, args.Repo, args.MirrorName); err != nil { return nil, err } return textOK(map[string]string{"status": "deleted", "mirror_name": args.MirrorName}) default: return nil, fmt.Errorf("unknown action %q: must be add, list, or delete", args.Action) } }