initial commit [141.14.129.234,mike]
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
package main
|
||||
|
||||
// remote.go — the `pushremote` command: mirror the active project to a public
|
||||
// git hosting server (Gitea, GitHub or GitLab), creating the repository via the
|
||||
// server's REST API when it does not exist yet.
|
||||
//
|
||||
// Configuration (in ~/.mgshrc or MGSH_* env):
|
||||
//
|
||||
// remoteurl = https://git.example.com base URL of the server
|
||||
// remotekey = <api-token> personal access token
|
||||
// remotetype = gitea|github|gitlab optional; auto-detected from the URL
|
||||
//
|
||||
// The token is used for the API calls and, via an HTTP Basic auth header, for
|
||||
// the git push. It is never written into the repository's git config.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type remoteKind int
|
||||
|
||||
const (
|
||||
kindGitea remoteKind = iota
|
||||
kindGitHub
|
||||
kindGitLab
|
||||
)
|
||||
|
||||
// detectRemoteKind picks the provider from an explicit override or the URL.
|
||||
func detectRemoteKind(rawurl, override string) remoteKind {
|
||||
switch strings.ToLower(strings.TrimSpace(override)) {
|
||||
case "github":
|
||||
return kindGitHub
|
||||
case "gitlab":
|
||||
return kindGitLab
|
||||
case "gitea":
|
||||
return kindGitea
|
||||
}
|
||||
u := strings.ToLower(rawurl)
|
||||
switch {
|
||||
case strings.Contains(u, "github.com") || strings.Contains(u, "api.github.com"):
|
||||
return kindGitHub
|
||||
case strings.Contains(u, "gitlab"):
|
||||
return kindGitLab
|
||||
default:
|
||||
return kindGitea
|
||||
}
|
||||
}
|
||||
|
||||
// remoteAPI talks to one provider's REST API.
|
||||
type remoteAPI struct {
|
||||
kind remoteKind
|
||||
url string // base URL, trailing slash trimmed
|
||||
key string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newRemoteAPI(cfgURL, key, typ string) *remoteAPI {
|
||||
return &remoteAPI{
|
||||
kind: detectRemoteKind(cfgURL, typ),
|
||||
url: strings.TrimRight(strings.TrimSpace(cfgURL), "/"),
|
||||
key: strings.TrimSpace(key),
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// apiRoot returns the REST API root for the provider.
|
||||
func (r *remoteAPI) apiRoot() string {
|
||||
switch r.kind {
|
||||
case kindGitHub:
|
||||
if r.url == "" || strings.Contains(r.url, "github.com") {
|
||||
return "https://api.github.com"
|
||||
}
|
||||
return r.url + "/api/v3" // GitHub Enterprise
|
||||
case kindGitLab:
|
||||
return r.url + "/api/v4"
|
||||
default: // Gitea
|
||||
return r.url + "/api/v1"
|
||||
}
|
||||
}
|
||||
|
||||
// authHeader returns the header name/value used to authenticate API calls.
|
||||
func (r *remoteAPI) authHeader() (string, string) {
|
||||
switch r.kind {
|
||||
case kindGitLab:
|
||||
return "PRIVATE-TOKEN", r.key
|
||||
case kindGitHub:
|
||||
return "Authorization", "Bearer " + r.key
|
||||
default: // Gitea
|
||||
return "Authorization", "token " + r.key
|
||||
}
|
||||
}
|
||||
|
||||
func (r *remoteAPI) do(method, endpoint string, body any) (int, []byte, error) {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, endpoint, rdr)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
hk, hv := r.authHeader()
|
||||
req.Header.Set(hk, hv)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, data, nil
|
||||
}
|
||||
|
||||
// authUser returns the login/username of the token owner.
|
||||
func (r *remoteAPI) authUser() (string, error) {
|
||||
code, data, err := r.do("GET", r.apiRoot()+"/user", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if code != 200 {
|
||||
return "", fmt.Errorf("authentication failed (HTTP %d): %s", code, firstLine(data))
|
||||
}
|
||||
var u struct {
|
||||
Login string `json:"login"` // Gitea, GitHub
|
||||
Username string `json:"username"` // GitLab
|
||||
}
|
||||
json.Unmarshal(data, &u)
|
||||
if u.Login != "" {
|
||||
return u.Login, nil
|
||||
}
|
||||
if u.Username != "" {
|
||||
return u.Username, nil
|
||||
}
|
||||
return "", fmt.Errorf("could not determine remote user")
|
||||
}
|
||||
|
||||
// repoExists reports whether owner/repo already exists on the server.
|
||||
func (r *remoteAPI) repoExists(owner, repo string) (bool, error) {
|
||||
var ep string
|
||||
if r.kind == kindGitLab {
|
||||
ep = r.apiRoot() + "/projects/" + url.PathEscape(owner+"/"+repo)
|
||||
} else {
|
||||
ep = r.apiRoot() + "/repos/" + owner + "/" + repo
|
||||
}
|
||||
code, data, err := r.do("GET", ep, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch code {
|
||||
case 200:
|
||||
return true, nil
|
||||
case 404:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("checking repository failed (HTTP %d): %s", code, firstLine(data))
|
||||
}
|
||||
}
|
||||
|
||||
// createRepo creates owner/repo on the server with the given visibility and
|
||||
// (optional) description.
|
||||
func (r *remoteAPI) createRepo(repo, description string, private bool) error {
|
||||
var ep string
|
||||
var body map[string]any
|
||||
if r.kind == kindGitLab {
|
||||
vis := "public"
|
||||
if private {
|
||||
vis = "private"
|
||||
}
|
||||
ep = r.apiRoot() + "/projects"
|
||||
body = map[string]any{"name": repo, "visibility": vis}
|
||||
} else { // Gitea + GitHub
|
||||
ep = r.apiRoot() + "/user/repos"
|
||||
body = map[string]any{"name": repo, "private": private}
|
||||
}
|
||||
if description != "" {
|
||||
body["description"] = description
|
||||
}
|
||||
code, data, err := r.do("POST", ep, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != 200 && code != 201 {
|
||||
return fmt.Errorf("creating repository failed (HTTP %d): %s", code, firstLine(data))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// repoWebURL returns the https clone/push URL (without credentials).
|
||||
func (r *remoteAPI) repoWebURL(owner, repo string) string {
|
||||
base := r.url
|
||||
if r.kind == kindGitHub && (base == "" || strings.Contains(base, "api.github.com")) {
|
||||
base = "https://github.com"
|
||||
}
|
||||
return base + "/" + owner + "/" + repo + ".git"
|
||||
}
|
||||
|
||||
// handlePushRemote implements the `pushremote [description]` command. The
|
||||
// description, if given, is set on the repository when it is created.
|
||||
func handlePushRemote(description string) {
|
||||
if !requireRepo() {
|
||||
return
|
||||
}
|
||||
if cfg.RemoteURL == "" || cfg.RemoteKey == "" {
|
||||
errorln("pushremote needs 'remoteurl' and 'remotekey' in ~/.mgshrc")
|
||||
return
|
||||
}
|
||||
repo := PRJ
|
||||
if repo == "" {
|
||||
repo = REPO
|
||||
}
|
||||
if repo == "" {
|
||||
errorln("cannot determine repository name")
|
||||
return
|
||||
}
|
||||
|
||||
private := !strings.EqualFold(strings.TrimSpace(cfg.RemoteVis), "public")
|
||||
|
||||
api := newRemoteAPI(cfg.RemoteURL, cfg.RemoteKey, cfg.RemoteType)
|
||||
|
||||
owner, err := api.authUser()
|
||||
if err != nil {
|
||||
errorln(err.Error())
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s %s (as %s)\n", col(cGray, "remote"), col(cCyan, api.url), col(cGreen, owner))
|
||||
|
||||
exists, err := api.repoExists(owner, repo)
|
||||
if err != nil {
|
||||
errorln(err.Error())
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
fmt.Printf("repository %s exists\n", col(cGreen, owner+"/"+repo))
|
||||
} else {
|
||||
vis := "private"
|
||||
if !private {
|
||||
vis = "public"
|
||||
}
|
||||
fmt.Printf("creating %s repository %s ...\n", vis, col(cGreen, owner+"/"+repo))
|
||||
if err := api.createRepo(repo, description, private); err != nil {
|
||||
errorln(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// keep a credential-free remote named "public" for convenience
|
||||
web := api.repoWebURL(owner, repo)
|
||||
if _, err := gitCapture(DIR, "remote", "get-url", "public"); err == nil {
|
||||
gitOK(DIR, "remote", "set-url", "public", web)
|
||||
} else {
|
||||
gitOK(DIR, "remote", "add", "public", web)
|
||||
}
|
||||
|
||||
// authenticate the push with a one-shot Basic auth header so the token is
|
||||
// never persisted in the repository's git config
|
||||
header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(owner+":"+api.key))
|
||||
if !gitPushHeader(DIR, "public", header, "--all") {
|
||||
return
|
||||
}
|
||||
gitPushHeader(DIR, "public", header, "--tags")
|
||||
fmt.Println(col(cGreen, "pushed to ") + col(cCyan, web))
|
||||
}
|
||||
|
||||
// gitPushHeader runs `git push <remote> <args...>` with an extra HTTP auth
|
||||
// header, disabling interactive credential prompts.
|
||||
func gitPushHeader(dir, remote, header string, args ...string) bool {
|
||||
full := append([]string{"-c", "http.extraHeader=" + header, "push", remote}, args...)
|
||||
c := exec.Command("git", full...)
|
||||
c.Dir = dir
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||
if err := c.Run(); err != nil {
|
||||
errorln("git push " + remote + " " + strings.Join(args, " ") + " failed")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// firstLine trims an API response body to a short single-line summary.
|
||||
func firstLine(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if len(s) > 200 {
|
||||
s = s[:200]
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user