342 lines
9.0 KiB
Go
342 lines
9.0 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"gitocean/internal/backup"
|
|
appconfig "gitocean/internal/config"
|
|
"gitocean/internal/dbutil"
|
|
"gitocean/internal/gitutil"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// ---------------- CLI helpers ----------------
|
|
|
|
func apiRequest(ctx context.Context, server, method, path, token string, body any, out any) error {
|
|
server = strings.TrimRight(server, "/")
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, server+path, rdr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
var e struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if json.Unmarshal(b, &e) == nil && e.Error != "" {
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
return fmt.Errorf("%s\nYour session may have expired. Run:\n gitocean login", e.Error)
|
|
}
|
|
return fmt.Errorf("%s", e.Error)
|
|
}
|
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
if out != nil && len(b) > 0 {
|
|
if err := json.Unmarshal(b, out); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func envServer() string {
|
|
return appconfig.EnvServer()
|
|
}
|
|
|
|
func configPath() string {
|
|
return appconfig.ClientConfigPath()
|
|
}
|
|
|
|
func loadConfig() (Config, error) {
|
|
return appconfig.LoadClient()
|
|
}
|
|
|
|
func saveConfig(cfg Config) error {
|
|
return appconfig.SaveClient(cfg)
|
|
}
|
|
|
|
func approveGitCredential(cfg Config) {
|
|
u, err := url.Parse(cfg.Server)
|
|
if err != nil || u.Host == "" || u.Scheme == "" {
|
|
return
|
|
}
|
|
cmd := exec.Command("git", "credential", "approve")
|
|
cmd.Stdin = strings.NewReader(fmt.Sprintf("protocol=%s\nhost=%s\nusername=%s\npassword=%s\n\n", u.Scheme, u.Host, cfg.Username, cfg.Token))
|
|
_ = cmd.Run()
|
|
}
|
|
|
|
func prompt(label string) string {
|
|
fmt.Print(label)
|
|
var s string
|
|
fmt.Scanln(&s)
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
func printJSON(v any) {
|
|
b, _ := json.MarshalIndent(v, "", " ")
|
|
fmt.Println(string(b))
|
|
}
|
|
|
|
func splitOwnerRepo(s string) (string, string, error) {
|
|
parts := strings.Split(s, "/")
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return "", "", fmt.Errorf("expected OWNER/REPO, got %q", s)
|
|
}
|
|
return strings.ToLower(parts[0]), strings.ToLower(parts[1]), nil
|
|
}
|
|
|
|
func splitRepoBranch(s string) (owner, repo, branch string, err error) {
|
|
left, branch, ok := strings.Cut(s, ":")
|
|
if !ok || branch == "" {
|
|
return "", "", "", fmt.Errorf("expected OWNER/REPO:BRANCH, got %q", s)
|
|
}
|
|
owner, repo, err = splitOwnerRepo(left)
|
|
return owner, repo, branch, err
|
|
}
|
|
|
|
func parseRepoCreateArgs(args []string) (name, visibility, description string, jsonOut bool, err error) {
|
|
pub, priv := false, false
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch {
|
|
case arg == "--public" || arg == "-public":
|
|
pub = true
|
|
case arg == "--private" || arg == "-private":
|
|
priv = true
|
|
case arg == "--json":
|
|
jsonOut = true
|
|
case arg == "--description":
|
|
if i+1 >= len(args) {
|
|
return "", "", "", false, errors.New("--description requires a value")
|
|
}
|
|
i++
|
|
description = args[i]
|
|
case strings.HasPrefix(arg, "--description="):
|
|
description = strings.TrimPrefix(arg, "--description=")
|
|
default:
|
|
if strings.HasPrefix(arg, "-") {
|
|
return "", "", "", false, fmt.Errorf("unknown flag %s", arg)
|
|
}
|
|
if name != "" {
|
|
return "", "", "", false, errors.New("usage: gitocean repo create NAME (--public|--private)")
|
|
}
|
|
name = arg
|
|
}
|
|
}
|
|
if name == "" || pub == priv {
|
|
return "", "", "", false, errors.New("usage: gitocean repo create NAME (--public|--private)")
|
|
}
|
|
visibility = "private"
|
|
if pub {
|
|
visibility = "public"
|
|
}
|
|
return name, visibility, description, jsonOut, nil
|
|
}
|
|
|
|
func parseRepoPublishArgs(args []string) (name, visibility, description, remoteName, branch string, err error) {
|
|
remoteName = "origin"
|
|
branch = "main"
|
|
filtered := []string{}
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--remote":
|
|
if i+1 >= len(args) {
|
|
return "", "", "", "", "", errors.New("--remote requires a value")
|
|
}
|
|
i++
|
|
remoteName = args[i]
|
|
case "--branch":
|
|
if i+1 >= len(args) {
|
|
return "", "", "", "", "", errors.New("--branch requires a value")
|
|
}
|
|
i++
|
|
branch = args[i]
|
|
default:
|
|
filtered = append(filtered, args[i])
|
|
}
|
|
}
|
|
name, visibility, description, _, err = parseRepoCreateArgs(filtered)
|
|
if err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
return name, visibility, description, remoteName, branch, nil
|
|
}
|
|
|
|
func removeBoolFlag(args *[]string, flagName string) bool {
|
|
in := *args
|
|
out := in[:0]
|
|
found := false
|
|
for _, arg := range in {
|
|
if arg == flagName {
|
|
found = true
|
|
continue
|
|
}
|
|
out = append(out, arg)
|
|
}
|
|
*args = out
|
|
return found
|
|
}
|
|
|
|
func repoCloneURL(server, owner, repo string) string {
|
|
return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(server, "/"), owner, repo)
|
|
}
|
|
|
|
func printRepoCreated(server string, repo Repository) {
|
|
remote := repoCloneURL(server, repo.Owner, repo.Name)
|
|
fmt.Printf("Created %s repository %s/%s\n\n", repo.Visibility, repo.Owner, repo.Name)
|
|
fmt.Println("Clone:")
|
|
fmt.Printf(" git clone %s\n\n", remote)
|
|
fmt.Println("Add existing repo:")
|
|
fmt.Printf(" git remote add origin %s\n", remote)
|
|
fmt.Printf(" git branch -M %s\n", repo.DefaultBranch)
|
|
fmt.Printf(" git push -u origin %s\n", repo.DefaultBranch)
|
|
}
|
|
|
|
func printRepoView(server string, repo Repository) {
|
|
fmt.Printf("%s/%s\n", repo.Owner, repo.Name)
|
|
fmt.Printf("Visibility: %s\n", repo.Visibility)
|
|
fmt.Printf("Default branch: %s\n", repo.DefaultBranch)
|
|
fmt.Printf("Archived: %v\n", repo.Archived)
|
|
if repo.Description != "" {
|
|
fmt.Printf("Description: %s\n", repo.Description)
|
|
}
|
|
fmt.Println("\nClone:")
|
|
fmt.Printf(" git clone %s\n", repoCloneURL(server, repo.Owner, repo.Name))
|
|
}
|
|
|
|
func printRepoTable(repos []Repository) {
|
|
fmt.Printf("%-28s %-10s %-8s %s\n", "REPOSITORY", "VISIBILITY", "ARCHIVED", "DESCRIPTION")
|
|
for _, r := range repos {
|
|
fmt.Printf("%-28s %-10s %-8v %s\n", r.Owner+"/"+r.Name, r.Visibility, r.Archived, r.Description)
|
|
}
|
|
}
|
|
|
|
func printRefs(kind string, refs []RefInfo) {
|
|
fmt.Printf("%-28s %-10s %-20s %s\n", strings.ToUpper(strings.TrimSuffix(kind, "es")), "COMMIT", "DATE", "MESSAGE")
|
|
for _, r := range refs {
|
|
commit := r.Commit
|
|
if len(commit) > 10 {
|
|
commit = commit[:10]
|
|
}
|
|
fmt.Printf("%-28s %-10s %-20s %s\n", r.Name, commit, r.Date.Format("2006-01-02 15:04"), r.Message)
|
|
}
|
|
}
|
|
|
|
func printPR(pr PullRequest) {
|
|
fmt.Printf("#%d %s: %s\n", pr.Number, pr.Status, pr.Title)
|
|
fmt.Printf("Author: %s\n", pr.Author)
|
|
fmt.Printf("From: %s/%s:%s\n", pr.SourceOwner, pr.SourceRepo, pr.SourceBranch)
|
|
fmt.Printf("To: %s/%s:%s\n", pr.TargetOwner, pr.TargetRepo, pr.TargetBranch)
|
|
if pr.Description != "" {
|
|
fmt.Printf("\n%s\n", pr.Description)
|
|
}
|
|
}
|
|
|
|
func apiTextRequest(ctx context.Context, server, path, token string) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(server, "/")+path, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func defaultServerConfigPath() string {
|
|
return appconfig.DefaultServerConfigPath()
|
|
}
|
|
|
|
func loadServerConfig(path string) (ServerConfig, error) {
|
|
return appconfig.LoadServer(path)
|
|
}
|
|
|
|
func saveServerConfig(path string, cfg ServerConfig) error {
|
|
return appconfig.SaveServer(path, cfg)
|
|
}
|
|
|
|
func promptDefault(label, def string) string {
|
|
fmt.Printf("%s [%s]: ", label, def)
|
|
var s string
|
|
fmt.Scanln(&s)
|
|
if strings.TrimSpace(s) == "" {
|
|
return def
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
func createUserDirect(db *sql.DB, email, username, password string, isAdmin bool) error {
|
|
if !strings.Contains(email, "@") || !usernameRE.MatchString(username) || len(password) < 8 {
|
|
return errors.New("invalid email, username, or password")
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = db.Exec(`INSERT INTO users (email, username, password_hash, is_admin) VALUES (?, ?, ?, ?)`, email, username, string(hash), isAdmin)
|
|
return err
|
|
}
|
|
|
|
func gitRefs(repoPath, prefix string) ([]RefInfo, error) {
|
|
return gitutil.Refs(repoPath, prefix)
|
|
}
|
|
|
|
func createBackup(cfg ServerConfig, file string) error {
|
|
return backup.Create(cfg, file)
|
|
}
|
|
|
|
func restoreBackup(cfg ServerConfig, file string) error {
|
|
return backup.Restore(cfg, file)
|
|
}
|
|
|
|
func mysqlCLIArgs(dsn string) ([]string, string, error) {
|
|
return backup.MySQLCLIArgs(dsn)
|
|
}
|
|
|
|
func copyDir(src, dst string) error {
|
|
return backup.CopyDir(src, dst)
|
|
}
|
|
|
|
func isDuplicateColumnError(err error) bool {
|
|
return dbutil.IsDuplicateColumnError(err)
|
|
}
|