gitcrawl/internal/cli/gh_shim.go
2026-05-08 09:50:17 +01:00

452 lines
13 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/openclaw/gitcrawl/internal/store"
)
func (a *App) runGHShim(ctx context.Context, args []string) error {
if len(args) == 0 {
return a.execRealGH(ctx, args)
}
switch args[0] {
case "xcache":
return a.runGHXCache(args[1:])
case "search":
if len(args) >= 2 && isGHSearchKind(args[1]) {
if err := a.runGHSearch(ctx, args[1:]); err != nil {
if isLocalGHUnsupported(err) {
return a.execRealGHMaybeCached(ctx, args)
}
return err
}
_ = a.incrementGHXCacheCounter("local_hits")
return nil
}
case "issue", "pr":
if len(args) >= 2 {
switch args[1] {
case "view":
if err := a.runGHThreadView(ctx, args[0], args[2:]); err != nil {
if isLocalGHUnsupported(err) {
return a.execRealGHMaybeCached(ctx, args)
}
return err
}
_ = a.incrementGHXCacheCounter("local_hits")
return nil
case "checks":
if args[0] == "pr" {
if err := a.runGHPRChecks(ctx, args[2:]); err != nil {
if isLocalGHUnsupported(err) {
return a.execRealGHMaybeCached(ctx, args)
}
return err
}
_ = a.incrementGHXCacheCounter("local_hits")
return nil
}
case "list":
if err := a.runGHThreadList(ctx, args[0], args[2:]); err != nil {
if isLocalGHUnsupported(err) {
return a.execRealGHMaybeCached(ctx, args)
}
return err
}
_ = a.incrementGHXCacheCounter("local_hits")
return nil
}
}
case "run":
if len(args) >= 2 {
switch args[1] {
case "list":
if err := a.runGHRunList(ctx, args[2:]); err != nil {
if isLocalGHUnsupported(err) {
return a.execRealGHMaybeCached(ctx, args)
}
return err
}
_ = a.incrementGHXCacheCounter("local_hits")
return nil
case "view":
if err := a.runGHRunView(ctx, args[2:]); err != nil {
if isLocalGHUnsupported(err) {
return a.execRealGHMaybeCached(ctx, args)
}
return err
}
_ = a.incrementGHXCacheCounter("local_hits")
return nil
}
}
}
return a.execRealGHMaybeCached(ctx, args)
}
func (a *App) runGHThreadView(ctx context.Context, resource string, args []string) error {
fs := flag.NewFlagSet(resource+" view", flag.ContinueOnError)
fs.SetOutput(io.Discard)
repoShort := fs.String("R", "", "repository")
repoLong := fs.String("repo", "", "repository")
jsonFieldsRaw := fs.String("json", "", "comma-separated JSON fields")
jqRaw := fs.String("jq", "", "jq filter")
if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{"R": true, "repo": true, "json": true, "jq": true})); err != nil {
return usageErr(err)
}
if fs.NArg() != 1 {
return usageErr(fmt.Errorf("gh %s view requires a number or GitHub URL", resource))
}
ref, _ := parseThreadReference(fs.Arg(0))
number, err := parseThreadNumber(fs.Arg(0))
if err != nil {
return usageErr(err)
}
repoArg := firstNonEmpty(*repoShort, *repoLong)
if repoArg == "" {
repoArg = ref.FullName()
}
repoValue, err := a.resolveGHRepo(ctx, repoArg)
if err != nil {
return localGHUnsupported(err)
}
thread, err := a.localGHThread(ctx, repoValue, ghResourceKind(resource), number)
if err != nil {
if a.shouldAutoHydrateGHThread(err) {
owner, repoName, parseErr := parseOwnerRepo(repoValue)
if parseErr != nil {
return localGHUnsupported(parseErr)
}
if _, syncErr := a.syncRepository(ctx, owner, repoName, syncOptions{
Numbers: []int{number},
IncludePRDetails: resource == "pr",
}); syncErr != nil {
return localGHUnsupported(syncErr)
}
thread, err = a.localGHThread(ctx, repoValue, ghResourceKind(resource), number)
}
if err != nil {
if errors.Is(err, errLocalGHUnsupported) {
return err
}
return err
}
}
jsonFields := strings.TrimSpace(*jsonFieldsRaw)
if jsonFields != "" || strings.TrimSpace(*jqRaw) != "" || a.format == FormatJSON {
if jsonFields == "" {
jsonFields = "number,title,state,url"
}
row, err := a.ghThreadViewJSONRow(ctx, repoValue, thread, jsonFields)
if err != nil {
return localGHUnsupported(err)
}
return a.writeJSONValue(row, strings.TrimSpace(*jqRaw))
}
_, err = fmt.Fprintf(a.Stdout, "title:\t%s\nstate:\t%s\nurl:\t%s\n\n%s\n", thread.Title, thread.State, thread.HTMLURL, strings.TrimSpace(thread.Body))
return err
}
func (a *App) runGHThreadList(ctx context.Context, resource string, args []string) error {
fs := flag.NewFlagSet(resource+" list", flag.ContinueOnError)
fs.SetOutput(io.Discard)
repoShort := fs.String("R", "", "repository")
repoLong := fs.String("repo", "", "repository")
stateRaw := fs.String("state", "open", "state")
limitRaw := fs.String("limit", "", "maximum rows")
limitShortRaw := fs.String("L", "", "maximum rows")
jsonFieldsRaw := fs.String("json", "", "comma-separated JSON fields")
jqRaw := fs.String("jq", "", "jq filter")
searchRaw := fs.String("search", "", "local search query")
authorRaw := fs.String("author", "", "filter by author")
assigneeRaw := fs.String("assignee", "", "filter by assignee")
var labels stringListFlag
fs.Var(&labels, "label", "filter by label")
if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{
"R": true, "repo": true, "state": true, "limit": true, "L": true, "json": true, "jq": true,
"search": true, "author": true, "assignee": true, "label": true,
})); err != nil {
return usageErr(err)
}
if fs.NArg() != 0 {
return usageErr(fmt.Errorf("unexpected gh %s list arguments: %s", resource, strings.Join(fs.Args(), " ")))
}
if err := validateGHSearchState(strings.TrimSpace(*stateRaw)); err != nil {
return usageErr(err)
}
limit, err := parseGHSearchLimit(*limitRaw, *limitShortRaw)
if err != nil {
return usageErr(err)
}
repoValue, err := a.resolveGHRepo(ctx, firstNonEmpty(*repoShort, *repoLong))
if err != nil {
return localGHUnsupported(err)
}
threads, err := a.localGHThreads(ctx, ghThreadListRequest{
Repo: repoValue,
Kind: ghResourceKind(resource),
State: strings.TrimSpace(*stateRaw),
Query: strings.TrimSpace(*searchRaw),
Author: strings.TrimSpace(*authorRaw),
Assignee: strings.TrimSpace(*assigneeRaw),
Labels: labels.Values(),
Limit: limit,
})
if err != nil {
return err
}
if len(threads) == 0 && ghThreadListNeedsLiveEmptyCheck(ghThreadListRequest{
Kind: ghResourceKind(resource),
State: strings.TrimSpace(*stateRaw),
Query: strings.TrimSpace(*searchRaw),
Author: strings.TrimSpace(*authorRaw),
Assignee: strings.TrimSpace(*assigneeRaw),
Labels: labels.Values(),
}) {
fresh, err := a.localGHThreadListHasBroadSync(ctx, repoValue, strings.TrimSpace(*stateRaw))
if err != nil {
return err
}
if !fresh {
return localGHUnsupported(fmt.Errorf("empty local %s list has no broad %s sync", resource, ghDefaultListState(*stateRaw)))
}
}
jsonFields := strings.TrimSpace(*jsonFieldsRaw)
if jsonFields != "" || strings.TrimSpace(*jqRaw) != "" || a.format == FormatJSON {
if jsonFields == "" {
jsonFields = "number,title,state,url"
}
rows, err := ghSearchJSONRows(threads, jsonFields)
if err != nil {
return localGHUnsupported(err)
}
return a.writeJSONValue(rows, strings.TrimSpace(*jqRaw))
}
for _, thread := range threads {
if _, err := fmt.Fprintf(a.Stdout, "%d\t%s\t%s\n", thread.Number, thread.Title, thread.HTMLURL); err != nil {
return err
}
}
return nil
}
func (a *App) localGHThread(ctx context.Context, repoValue, kind string, number int) (store.Thread, error) {
owner, repoName, err := parseOwnerRepo(repoValue)
if err != nil {
return store.Thread{}, err
}
rt, err := a.openLocalRuntimeReadOnly(ctx)
if err != nil {
return store.Thread{}, localGHUnsupported(err)
}
defer rt.Store.Close()
repo, err := rt.repository(ctx, owner, repoName)
if err != nil {
return store.Thread{}, localGHUnsupported(err)
}
threads, err := rt.Store.ListThreadsFiltered(ctx, store.ThreadListOptions{
RepoID: repo.ID,
IncludeClosed: true,
Numbers: []int{number},
})
if err != nil {
return store.Thread{}, err
}
for _, thread := range threads {
if thread.Number == number && thread.Kind == kind {
return thread, nil
}
}
return store.Thread{}, localGHUnsupported(fmt.Errorf("thread #%d was not found in local cache", number))
}
type ghThreadListRequest struct {
Repo string
Kind string
State string
Query string
Author string
Assignee string
Labels []string
Limit int
}
func (a *App) localGHThreads(ctx context.Context, req ghThreadListRequest) ([]store.Thread, error) {
owner, repoName, err := parseOwnerRepo(req.Repo)
if err != nil {
return nil, err
}
rt, err := a.openLocalRuntimeReadOnly(ctx)
if err != nil {
return nil, localGHUnsupported(err)
}
defer rt.Store.Close()
repo, err := rt.repository(ctx, owner, repoName)
if err != nil {
return nil, localGHUnsupported(err)
}
return rt.Store.SearchThreads(ctx, store.ThreadSearchOptions{
RepoID: repo.ID,
Query: req.Query,
Kind: req.Kind,
State: req.State,
Author: req.Author,
Assignee: req.Assignee,
Labels: req.Labels,
IncludeLocallyClosed: true,
Limit: req.Limit,
})
}
func ghThreadListNeedsLiveEmptyCheck(req ghThreadListRequest) bool {
if req.Kind != "issue" || strings.TrimSpace(req.Query) != "" || strings.TrimSpace(req.Author) != "" || strings.TrimSpace(req.Assignee) != "" || len(req.Labels) > 0 {
return false
}
return ghDefaultListState(req.State) == "open"
}
func (a *App) localGHThreadListHasBroadSync(ctx context.Context, repoValue, state string) (bool, error) {
owner, repoName, err := parseOwnerRepo(repoValue)
if err != nil {
return false, err
}
rt, err := a.openLocalRuntimeReadOnly(ctx)
if err != nil {
return false, localGHUnsupported(err)
}
defer rt.Store.Close()
repo, err := rt.repository(ctx, owner, repoName)
if err != nil {
return false, localGHUnsupported(err)
}
lastSync, err := rt.Store.LastSuccessfulListSyncAt(ctx, repo.ID, state)
if err != nil {
return false, err
}
return !lastSync.IsZero(), nil
}
func (a *App) resolveGHRepo(ctx context.Context, explicit string) (string, error) {
if strings.TrimSpace(explicit) != "" {
return strings.TrimSpace(explicit), nil
}
if envRepo := strings.TrimSpace(os.Getenv("GH_REPO")); envRepo != "" {
return envRepo, nil
}
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("repository is required outside a git checkout; pass -R owner/repo")
}
repo, err := ownerRepoFromGitRemote(strings.TrimSpace(string(out)))
if err != nil {
return "", err
}
return repo, nil
}
func (a *App) execRealGH(ctx context.Context, args []string) error {
ghPath, err := resolveRealGHPath()
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, ghPath, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = a.Stdout
cmd.Stderr = a.Stderr
return cmd.Run()
}
func (a *App) writeJSONValue(value any, jqExpr string) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
if strings.TrimSpace(jqExpr) == "" {
_, err = fmt.Fprintf(a.Stdout, "%s\n", data)
return err
}
jqPath, err := exec.LookPath("jq")
if err != nil {
return localGHUnsupported(fmt.Errorf("--jq requires jq executable"))
}
cmd := exec.Command(jqPath, jqExpr)
cmd.Stdin = bytes.NewReader(data)
cmd.Stdout = a.Stdout
cmd.Stderr = a.Stderr
return cmd.Run()
}
func ghResourceKind(resource string) string {
if resource == "pr" {
return "pull_request"
}
return "issue"
}
func parseThreadNumber(value string) (int, error) {
return parseOptionalThreadNumber(value)
}
func ownerRepoFromGitRemote(value string) (string, error) {
value = strings.TrimSuffix(strings.TrimSpace(value), ".git")
value = strings.TrimPrefix(value, "git@github.com:")
if strings.HasPrefix(value, "https://github.com/") {
value = strings.TrimPrefix(value, "https://github.com/")
}
if strings.HasPrefix(value, "ssh://git@github.com/") {
value = strings.TrimPrefix(value, "ssh://git@github.com/")
}
parts := strings.Split(value, "/")
if len(parts) < 2 {
return "", fmt.Errorf("could not infer owner/repo from origin remote")
}
repo := filepath.Join(parts[len(parts)-2], parts[len(parts)-1])
return strings.ReplaceAll(repo, string(os.PathSeparator), "/"), nil
}
var errLocalGHUnsupported = errors.New("local gh shim unsupported")
func localGHUnsupported(err error) error {
if err == nil {
return errLocalGHUnsupported
}
return fmt.Errorf("%w: %v", errLocalGHUnsupported, err)
}
func isLocalGHUnsupported(err error) bool {
return errors.Is(err, errLocalGHUnsupported) || strings.Contains(err.Error(), "unsupported --json field")
}
type stringListFlag []string
func (f *stringListFlag) String() string {
return strings.Join(*f, ",")
}
func (f *stringListFlag) Set(value string) error {
*f = append(*f, strings.TrimSpace(value))
return nil
}
func (f *stringListFlag) Values() []string {
values := make([]string, 0, len(*f))
for _, value := range *f {
if trimmed := strings.TrimSpace(value); trimmed != "" {
values = append(values, trimmed)
}
}
return values
}