Files
kandev/apps/backend/internal/improvekandev/github.go
T
2026-05-01 15:18:18 +01:00

95 lines
3.3 KiB
Go

package improvekandev
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"time"
)
// GitHubInfo exposes the authenticated user's login, write-access status,
// and existing-fork lookup. It is used during bootstrap to tell the
// contributor whether forking kdlbs/kandev will work for them when they
// lack push access on the upstream repo. The EMU-restriction signal is
// derived from the login itself (see isEMULogin) and does not require an
// extra API call.
type GitHubInfo interface {
GetAuthenticatedLogin(ctx context.Context) (string, error)
HasRepoWriteAccess(ctx context.Context, owner, name string) (bool, error)
UserHasFork(ctx context.Context, login, name string) (bool, error)
}
// defaultGitHubInfo shells out to the gh CLI. The improve-kandev dialog
// gates entry on gh auth being present, so by the time bootstrap runs gh
// is expected to be installed and authenticated.
type defaultGitHubInfo struct{}
func newDefaultGitHubInfo() GitHubInfo { return &defaultGitHubInfo{} }
func (d *defaultGitHubInfo) GetAuthenticatedLogin(ctx context.Context) (string, error) {
out, err := runGH(ctx, "api", "user", "--jq", ".login")
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}
func (d *defaultGitHubInfo) HasRepoWriteAccess(ctx context.Context, owner, name string) (bool, error) {
out, err := runGH(ctx, "api", fmt.Sprintf("repos/%s/%s", owner, name), "--jq", ".permissions.push")
if err != nil {
return false, err
}
return strings.TrimSpace(out) == "true", nil
}
// UserHasFork returns true if a repo named name exists under login and is a
// fork. A 404 from GitHub is treated as a missing fork (false, nil) so callers
// can distinguish "no fork yet" from a real error.
func (d *defaultGitHubInfo) UserHasFork(ctx context.Context, login, name string) (bool, error) {
out, err := runGH(ctx, "api", fmt.Sprintf("repos/%s/%s", login, name), "--jq", ".fork")
if err != nil {
if isGHNotFound(err) {
return false, nil
}
return false, err
}
return strings.TrimSpace(out) == "true", nil
}
// isEMULogin reports whether login looks like a GitHub Enterprise Managed
// User shortname. EMU account usernames are always of the form
// "{username}_{enterprise-shortcode}"; regular GitHub usernames cannot
// contain underscores, so the presence of an underscore is a definitive
// signal. EMU accounts typically cannot fork repositories outside their
// owning enterprise, so the contribution flow will fail at the PR step.
func isEMULogin(login string) bool {
return strings.Contains(login, "_")
}
const ghTimeout = 10 * time.Second
func runGH(ctx context.Context, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, ghTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "gh", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return stdout.String(), fmt.Errorf("gh %s: %w: %s", args[0], err, stderr.String())
}
return stdout.String(), nil
}
// isGHNotFound reports whether err originates from gh CLI receiving an HTTP
// 404 from the GitHub API. The default error format embeds the stderr output,
// where gh prints e.g. "HTTP 404: Not Found (https://api.github.com/...)".
func isGHNotFound(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "HTTP 404")
}