5407 lines · 174.4 KB · Go
RawDownload
package cli import (	"bytes"	"context"	"encoding/json"	"errors"	"flag"	"fmt"	"io"	"log/slog"	"math"	"net/url"	"os"	"os/exec"	"path/filepath"	"regexp"	"sort"	"strconv"	"strings"	"sync"	"text/tabwriter"	"time" 	"github.com/alecthomas/kong"	crawlconfig "github.com/openclaw/crawlkit/config"	"github.com/openclaw/crawlkit/control"	"github.com/openclaw/crawlkit/mirror"	crawlremote "github.com/openclaw/crawlkit/remote"	clusterer "github.com/openclaw/gitcrawl/internal/cluster"	"github.com/openclaw/gitcrawl/internal/config"	gh "github.com/openclaw/gitcrawl/internal/github"	"github.com/openclaw/gitcrawl/internal/openai"	"github.com/openclaw/gitcrawl/internal/store"	"github.com/openclaw/gitcrawl/internal/syncer"	"github.com/openclaw/gitcrawl/internal/vector") const (	defaultTUIMinSize          = 5	defaultTUIWorkingSetLimit  = 500	defaultClusterMaxSize      = 40	defaultClusterFanout       = 16	defaultClusterThreshold    = 0.80	defaultCrossKindMinScore   = 0.93	highConfidenceEdgeScore    = 0.90	weakEdgeMinTitleOverlap    = 0.18	deterministicRefScore      = 0.94	bodyRefEvidencePrefixChars = 240) var errNoSemanticVectors = errors.New("no semantic vectors") var threadReferencePattern = regexp.MustCompile(`(?i)(?:\b([\w.-]+/[\w.-]+)#(\d+)|(?:\b([\w.-]+/[\w.-]+)/)?(?:issues|pull)/(\d+)|#(\d{2,}))`)var githubThreadURLPattern = regexp.MustCompile(`(?i)^https?://github\.com/([\w.-]+)/([\w.-]+)/(?:issues|pull)/(\d+)(?:[/?#].*)?$`)var ownerRepoThreadPattern = regexp.MustCompile(`(?i)^([\w.-]+)/([\w.-]+)#(\d+)$`)var pathThreadPattern = regexp.MustCompile(`(?i)(?:^|/)(?:issues|pull)/(\d+)(?:[/?#].*)?$`)var titleTokenPattern = regexp.MustCompile(`[A-Za-z0-9]{4,}`) type referenceEvidence struct {	Title     bool	EarlyBody bool} type App struct {	Stdout io.Writer	Stderr io.Writer 	configPath            string	format                OutputFormat	getWorkingDirectory   func() (string, error)	githubAuthTokenLookup func(context.Context) (string, error)	dbTargetNoticeOnce    sync.Once} type initResult struct {	ConfigPath       string `json:"config_path"`	RuntimeDir       string `json:"runtime_dir,omitempty"`	DBPath           string `json:"db_path"`	CacheDir         string `json:"cache_dir"`	VectorDir        string `json:"vector_dir"`	LogDir           string `json:"log_dir"`	PortableStoreURL string `json:"portable_store_url,omitempty"`	PortableStoreDir string `json:"portable_store_dir,omitempty"`	PortableStore    string `json:"portable_store,omitempty"`	RemoteMode       string `json:"remote_mode,omitempty"`	RemoteEndpoint   string `json:"remote_endpoint,omitempty"`	RemoteArchive    string `json:"remote_archive,omitempty"`} type OutputFormat string const (	FormatText OutputFormat = "text"	FormatJSON OutputFormat = "json"	FormatLog  OutputFormat = "log") var version = "dev" func New() *App {	return &App{		Stdout:              os.Stdout,		Stderr:              os.Stderr,		format:              FormatText,		getWorkingDirectory: os.Getwd,	}} func (a *App) Run(ctx context.Context, args []string) error {	if len(args) == 0 || rootHelpRequested(args, "config", "format") {		a.printUsage()		return nil	}	var global gitcrawlRootArgs	if err := parseKongArgs(&global, args, "gitcrawl", a.Stdout, a.Stderr); err != nil {		return usageErr(err)	} 	resolvedFormat, err := resolveOutputFormat(global.Format, global.JSON)	if err != nil {		return usageErr(err)	}	a.configPath = strings.TrimSpace(global.Config)	a.format = resolvedFormat 	rest := global.Args	if global.Version {		return a.writeOutput("version", map[string]string{"version": version}, false)	}	if len(rest) == 0 || rest[0] == "--help" || rest[0] == "-h" {		a.printUsage()		return nil	}	if rest[0] == "help" {		if len(rest) > 1 {			return a.printCommandUsage(rest[1])		}		a.printUsage()		return nil	}	if releaseNotificationAllowed(rest) {		a.maybeNotifyRelease(ctx, rest)	} 	switch rest[0] {	case "version":		return a.writeOutput("version", map[string]string{"version": version}, false)	case "check-update":		return a.runCheckUpdate(ctx, rest[1:])	case "metadata":		return a.runMetadata(rest[1:])	case "whoami":		return a.runRemoteWhoami(ctx, rest[1:])	case "remote":		return a.runRemote(ctx, rest[1:])	case "cloud":		return a.runCloud(ctx, rest[1:])	case "serve":		return usageErr(fmt.Errorf("serve is not supported in gitcrawl"))	case "init":		return a.runInit(ctx, rest[1:])	case "doctor":		return a.runDoctor(ctx, rest[1:])	case "status":		return a.runStatus(ctx, rest[1:])	case "sync":		return a.runSync(ctx, rest[1:])	case "fill-pr-details":		return a.runFillPRDetails(ctx, rest[1:])	case "threads":		return a.runThreads(ctx, rest[1:])	case "capture":		return a.runCapture(ctx, rest[1:])	case "close-thread":		return a.runCloseThread(ctx, rest[1:])	case "reopen-thread":		return a.runReopenThread(ctx, rest[1:])	case "close-cluster":		return a.runCloseCluster(ctx, rest[1:])	case "reopen-cluster":		return a.runReopenCluster(ctx, rest[1:])	case "exclude-cluster-member":		return a.runExcludeClusterMember(ctx, rest[1:])	case "include-cluster-member":		return a.runIncludeClusterMember(ctx, rest[1:])	case "set-cluster-canonical":		return a.runSetClusterCanonical(ctx, rest[1:])	case "runs":		return a.runRuns(ctx, rest[1:])	case "sync-failures":		return a.runSyncFailures(ctx, rest[1:])	case "coverage":		return a.runCoverage(ctx, rest[1:])	case "search":		return a.runSearch(ctx, rest[1:])	case "code":		return a.runCode(ctx, rest[1:])	case "gh":		return a.runGHShim(ctx, rest[1:])	case "configure":		return a.runConfigure(rest[1:])	case "refresh":		return a.runRefresh(ctx, rest[1:])	case "summarize":		return a.runSummarize(ctx, rest[1:])	case "embed":		return a.runEmbed(ctx, rest[1:])	case "clusters":		return a.runClusters(ctx, rest[1:])	case "clusters-report":		return a.runClustersReport(ctx, rest[1:])	case "durable-clusters":		return a.runDurableClusters(ctx, rest[1:])	case "cluster-detail":		return a.runClusterDetail(ctx, rest[1:])	case "cluster-explain":		return a.runClusterDetail(ctx, rest[1:])	case "neighbors":		return a.runNeighbors(ctx, rest[1:])	case "cluster":		return a.runCluster(ctx, rest[1:])	case "portable":		return a.runPortable(ctx, rest[1:])	case "tui":		return a.runTUI(ctx, rest[1:])	case "key-summaries", "cluster-experiment", "merge-clusters", "split-cluster", "export-sync", "import-sync", "validate-sync", "portable-size", "sync-status", "optimize", "completion":		_ = ctx		return notImplemented(rest[0])	default:		return usageErr(fmt.Errorf("unknown command %q", rest[0]))	}} type gitcrawlRootArgs struct {	Config  string   `help:"Config path."`	Format  string   `default:"text" help:"Output format: text, json, or log."`	JSON    bool     `name:"json" help:"Write JSON output."`	Version bool     `help:"Print version."`	NoColor bool     `name:"no-color" help:"Disable color output."`	Args    []string `arg:"" optional:"" passthrough:"partial" name:"command" help:"Command and arguments."`} func rootHelpRequested(args []string, valueFlags ...string) bool {	valueFlagSet := make(map[string]struct{}, len(valueFlags))	for _, flag := range valueFlags {		valueFlagSet[flag] = struct{}{}	}	for i := 0; i < len(args); i++ {		arg := args[i]		if arg == "--help" || arg == "-h" || (arg == "help" && i == len(args)-1) {			return true		}		if !strings.HasPrefix(arg, "-") {			return false		}		if name, ok := strings.CutPrefix(arg, "--"); ok {			if strings.Contains(name, "=") {				continue			}			if _, ok := valueFlagSet[name]; ok {				i++			}		}	}	return false} func parseKongArgs(target any, args []string, name string, stdout, stderr io.Writer, options ...kong.Option) error {	_, err := parseKongContext(target, args, name, stdout, stderr, options...)	return err} func parseKongContext(target any, args []string, name string, stdout, stderr io.Writer, options ...kong.Option) (*kong.Context, error) {	opts := []kong.Option{		kong.Name(name),		kong.NoDefaultHelp(),		kong.Writers(stdout, stderr),		kong.Exit(func(int) {}),	}	opts = append(opts, options...)	parser, err := kong.New(target, opts...)	if err != nil {		return nil, err	}	return parser.Parse(args)} func (a *App) runConfigure(args []string) error {	fs := flag.NewFlagSet("configure", flag.ContinueOnError)	fs.SetOutput(io.Discard)	summaryModel := fs.String("summary-model", "", "summary model")	embedModel := fs.String("embed-model", "", "embedding model")	embedBaseURLFlag := fs.String("embed-base-url", "", "custom endpoint for the embedding model")	embeddingBasis := fs.String("embedding-basis", "", "embedding basis")	jsonOut := fs.Bool("json", false, "write JSON output")	if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{"summary-model": true, "embed-model": true, "embed-base-url": true, "embedding-basis": true})); err != nil {		return usageErr(err)	}	a.applyCommandJSON(*jsonOut) 	cfg, err := config.Load(a.configPath)	configExists := true	if err != nil {		if !errors.Is(err, os.ErrNotExist) {			return err		}		configExists = false		cfg = config.Default()	}	updated := false	if strings.TrimSpace(*summaryModel) != "" {		cfg.OpenAI.SummaryModel = strings.TrimSpace(*summaryModel)		updated = true	}	if strings.TrimSpace(*embedModel) != "" {		cfg.OpenAI.EmbedModel = strings.TrimSpace(*embedModel)		updated = true	}	if flagWasSet(fs, "embed-base-url") {		cfg.OpenAI.EmbedBaseURL = strings.TrimSpace(*embedBaseURLFlag)		updated = true	}	if strings.TrimSpace(*embeddingBasis) != "" {		cfg.EmbeddingBasis = strings.TrimSpace(*embeddingBasis)		updated = true	}	if updated || !configExists {		if err := config.Save(a.configPath, cfg); err != nil {			return err		}	}	return a.writeOutput("configure", map[string]any{		"config_path":     config.ResolvePath(a.configPath),		"updated":         updated || !configExists,		"summary_model":   cfg.OpenAI.SummaryModel,		"embed_model":     cfg.OpenAI.EmbedModel,		"embed_base_url":  embedBaseURL(cfg),		"embedding_basis": cfg.EmbeddingBasis,	}, true)} func flagWasSet(fs *flag.FlagSet, name string) bool {	found := false	fs.Visit(func(value *flag.Flag) {		found = found || value.Name == name	})	return found} type refreshResult struct {	Repository string          `json:"repository"`	Selected   map[string]bool `json:"selected"`	Sync       *syncer.Stats   `json:"sync,omitempty"`	Embed      *embedResult    `json:"embed,omitempty"`	Cluster    map[string]any  `json:"cluster,omitempty"`	dbTargetInfo} func (a *App) runRefresh(ctx context.Context, args []string) error {	fs := flag.NewFlagSet("refresh", flag.ContinueOnError)	fs.SetOutput(io.Discard)	noSync := fs.Bool("no-sync", false, "skip GitHub sync stage")	noEmbed := fs.Bool("no-embed", false, "skip embedding stage")	noCluster := fs.Bool("no-cluster", false, "skip clustering stage")	includeComments := fs.Bool("include-comments", false, "hydrate comments during sync")	includePRDetails := fs.Bool("include-pr-details", false, "hydrate PR files, commits, checks, workflow runs, and review threads")	withRaw := fs.String("with", "", "additional sync hydration: pr-details")	fs.Bool("include-code", false, "accepted for compatibility; code hydration is not implemented yet")	since := fs.String("since", "", "GitHub since timestamp")	state := fs.String("state", "", "GitHub issue state: open|closed|all; default open")	limitRaw := fs.String("limit", "", "maximum sync or embedding rows")	thresholdRaw := fs.String("threshold", fmt.Sprintf("%.2f", defaultClusterThreshold), "minimum cluster cosine score")	minSizeRaw := fs.String("min-size", "1", "minimum cluster member count")	maxClusterSizeRaw := fs.String("max-cluster-size", strconv.Itoa(defaultClusterMaxSize), "maximum members per generated cluster")	fanoutRaw := fs.String("k", strconv.Itoa(defaultClusterFanout), "nearest-neighbor fanout per thread")	crossKindThresholdRaw := fs.String("cross-kind-threshold", fmt.Sprintf("%.2f", defaultCrossKindMinScore), "minimum score for issue/pull request edges")	strictVectors := fs.Bool("strict-vectors", false, "require complete fresh configured vectors before clustering")	jsonOut := fs.Bool("json", false, "write JSON output")	if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{"since": true, "state": true, "limit": true, "threshold": true, "min-size": true, "max-cluster-size": true, "k": true, "cross-kind-threshold": true, "with": true})); err != nil {		return usageErr(err)	}	a.applyCommandJSON(*jsonOut)	if fs.NArg() != 1 {		return usageErr(fmt.Errorf("refresh requires owner/repo"))	}	if *noSync && *noEmbed && *noCluster {		return usageErr(fmt.Errorf("refresh requires at least one selected stage"))	}	with, err := parseSyncWith(*withRaw)	if err != nil {		return usageErr(err)	}	owner, repoName, err := parseOwnerRepo(fs.Arg(0))	if err != nil {		return usageErr(err)	}	limit, err := parseOptionalPositiveInt(*limitRaw)	if err != nil {		return usageErr(err)	}	threshold, err := parseOptionalFloat(*thresholdRaw)	if err != nil {		return usageErr(err)	}	if threshold <= 0 || threshold > 1 {		return usageErr(fmt.Errorf("refresh requires --threshold between 0 and 1"))	}	minSize, err := parseOptionalPositiveInt(*minSizeRaw)	if err != nil {		return usageErr(err)	}	if minSize <= 0 {		minSize = 2	}	maxClusterSize, fanout, crossKindThreshold, err := parseClusterShapeOptions("refresh", *maxClusterSizeRaw, *fanoutRaw, *crossKindThresholdRaw)	if err != nil {		return err	} 	result := refreshResult{		Repository: owner + "/" + repoName,		Selected: map[string]bool{			"sync":    !*noSync,			"embed":   !*noEmbed,			"cluster": !*noCluster,		},	}	if !*noSync {		fmt.Fprintln(a.Stderr, "[refresh] sync")		stats, target, err := a.syncRepository(ctx, owner, repoName, syncOptions{			Since:            strings.TrimSpace(*since),			State:            strings.TrimSpace(*state),			Limit:            limit,			IncludeComments:  *includeComments,			IncludePRDetails: *includePRDetails || with["pr-details"],		})		if err != nil {			return err		}		result.Repository = stats.Repository		result.Sync = &stats		result.dbTargetInfo = target	}	if !*noEmbed {		fmt.Fprintln(a.Stderr, "[refresh] embed")		embed, err := a.embedRepository(ctx, owner, repoName, embedOptions{Limit: limit, IncludeClosed: stateIncludesClosed(*state)})		if err != nil {			return err		}		result.Repository = embed.Repository		result.Embed = &embed		if result.DBTarget == "" {			result.dbTargetInfo = embed.dbTarget		}	}	if !*noCluster {		fmt.Fprintln(a.Stderr, "[refresh] cluster")		rt, err := a.openLocalRuntime(ctx)		if err != nil {			return err		}		if result.DBTarget == "" {			result.dbTargetInfo = rt.dbTarget()		}		repo, err := rt.repository(ctx, owner, repoName)		if err != nil {			_ = rt.Store.Close()			return err		}		query := store.ThreadVectorQuery{RepoID: repo.ID, Model: rt.Config.OpenAI.EmbedModel, Basis: rt.Config.EmbeddingBasis}		query.IncludeClosed = stateIncludesClosed(*state)		vectors, err := rt.Store.ListThreadVectorsFiltered(ctx, query)		if err != nil {			_ = rt.Store.Close()			return err		}		coverage, freshVectors, err := evaluateClusterVectorCoverage(ctx, rt.Store, query, vectors)		if err != nil {			_ = rt.Store.Close()			return err		}		if *strictVectors {			if err := requireCompleteClusterVectorCoverage(owner, repoName, query, coverage, len(freshVectors), false); err != nil {				_ = rt.Store.Close()				return err			}		}		vectors = freshVectors		if len(vectors) == 0 && !*strictVectors {			fallbackQuery := store.ThreadVectorQuery{RepoID: repo.ID, IncludeClosed: stateIncludesClosed(*state)}			fallbackVectors, err := rt.Store.ListThreadVectorsFiltered(ctx, fallbackQuery)			if err != nil {				_ = rt.Store.Close()				return err			}			if len(fallbackVectors) > 0 {				query = fallbackQuery				vectors, err = freshFallbackClusterVectors(ctx, rt.Store, fallbackQuery, fallbackVectors)				if err != nil {

Lines 1–500 of 5407.