171 lines
5.2 KiB
Go
171 lines
5.2 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// glabAuthTimeout bounds glab subprocess execution.
|
|
const glabAuthTimeout = 10 * time.Second
|
|
|
|
// GLabClient implements Client by piggy-backing on the user's glab CLI
|
|
// configuration. It discovers the host and token via `glab auth status`,
|
|
// then delegates every API call to an embedded PATClient.
|
|
//
|
|
// Versus a pure shell-out client, this approach is simpler (no per-command
|
|
// JSON parsing) and stays consistent with the REST surface that
|
|
// PATClient exercises in tests.
|
|
type GLabClient struct {
|
|
*PATClient
|
|
version string
|
|
}
|
|
|
|
// GLabAvailable checks if the glab CLI is installed and on PATH.
|
|
func GLabAvailable() bool {
|
|
_, err := exec.LookPath("glab")
|
|
return err == nil
|
|
}
|
|
|
|
// NewGLabClient discovers glab's configured host and token for the given
|
|
// targetHost (or the default if empty), and returns a Client that signs
|
|
// requests with that token. Returns an error when glab is not available
|
|
// or not authenticated for targetHost.
|
|
func NewGLabClient(ctx context.Context, targetHost string) (*GLabClient, error) {
|
|
if !GLabAvailable() {
|
|
return nil, errors.New("glab CLI not installed")
|
|
}
|
|
host := targetHost
|
|
if host == "" {
|
|
host = DefaultHost
|
|
}
|
|
hostname := stripScheme(host)
|
|
|
|
token, err := glabReadToken(ctx, hostname)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if token == "" {
|
|
return nil, fmt.Errorf("glab not authenticated for %s", hostname)
|
|
}
|
|
pat := NewPATClient(host, token)
|
|
return &GLabClient{
|
|
PATClient: pat,
|
|
version: glabVersion(ctx),
|
|
}, nil
|
|
}
|
|
|
|
// Version reports the glab CLI version (best effort, "" if unavailable).
|
|
func (c *GLabClient) Version() string { return c.version }
|
|
|
|
// RunAuthDiagnostics executes `glab auth status` and captures the raw
|
|
// output for troubleshooting. Mirrors GitHub's RunAuthDiagnostics.
|
|
func (c *GLabClient) RunAuthDiagnostics(ctx context.Context) *AuthDiagnostics {
|
|
cctx, cancel := context.WithTimeout(ctx, glabAuthTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(cctx, "glab", "auth", "status", "--hostname", stripScheme(c.Host()))
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
exitCode := 0
|
|
if err := cmd.Run(); err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
exitCode = exitErr.ExitCode()
|
|
} else {
|
|
exitCode = -1
|
|
}
|
|
}
|
|
output := stderr.String()
|
|
if output == "" {
|
|
output = stdout.String()
|
|
}
|
|
return &AuthDiagnostics{
|
|
Command: "glab auth status --hostname " + stripScheme(c.Host()),
|
|
Output: output,
|
|
ExitCode: exitCode,
|
|
}
|
|
}
|
|
|
|
// glabReadToken extracts the token glab uses for the given hostname. glab's
|
|
// `auth status -t` prints the token to stderr; this function captures both
|
|
// streams and parses the line "Token: <value>".
|
|
func glabReadToken(ctx context.Context, hostname string) (string, error) {
|
|
cctx, cancel := context.WithTimeout(ctx, glabAuthTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(cctx, "glab", "auth", "status", "--hostname", hostname, "-t")
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
// Even when glab exits non-zero we may still have a token printed —
|
|
// e.g. a different account on the same host triggers a warning. Try
|
|
// to parse before giving up.
|
|
token := parseGlabToken(stdout.String() + "\n" + stderr.String())
|
|
if token != "" {
|
|
return token, nil
|
|
}
|
|
return "", fmt.Errorf("glab auth status: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return parseGlabToken(stdout.String() + "\n" + stderr.String()), nil
|
|
}
|
|
|
|
// glabTokenLabels are the label variants `glab auth status -t` has used for
|
|
// the token line. glab >= 1.x prints "Token found:"; older builds printed a
|
|
// bare "Token:". Longest first so "Token found:" is not mis-split by "Token:".
|
|
var glabTokenLabels = []string{"token found:", "token:"}
|
|
|
|
// parseGlabToken finds the token line in the combined output of
|
|
// `glab auth status -t` and returns the token (or "" if none).
|
|
func parseGlabToken(output string) string {
|
|
for _, line := range strings.Split(output, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
lower := strings.ToLower(line)
|
|
idx, label := -1, ""
|
|
for _, candidate := range glabTokenLabels {
|
|
if at := strings.Index(lower, candidate); at >= 0 {
|
|
idx, label = at, candidate
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
continue
|
|
}
|
|
token := strings.TrimSpace(line[idx+len(label):])
|
|
// glab sometimes prefixes lines with ANSI / arrows; strip leading
|
|
// non-alphanumerics until we hit token characters.
|
|
token = strings.TrimLeft(token, " \t-→>")
|
|
if token != "" && token != "<no token>" {
|
|
return token
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// glabVersion runs `glab --version` and extracts the semver string.
|
|
// Returns "" on any failure.
|
|
func glabVersion(ctx context.Context) string {
|
|
cctx, cancel := context.WithTimeout(ctx, glabAuthTimeout)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(cctx, "glab", "--version").Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, field := range strings.Fields(string(out)) {
|
|
if strings.Count(field, ".") >= 2 {
|
|
return strings.TrimSpace(field)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func stripScheme(host string) string {
|
|
host = strings.TrimPrefix(host, "https://")
|
|
host = strings.TrimPrefix(host, "http://")
|
|
return strings.TrimRight(host, "/")
|
|
}
|