Files
kandev/apps/backend/internal/backendapp/orchestrator.go
T
2026-06-20 19:40:15 +01:00

752 lines
27 KiB
Go

package backendapp
import (
"context"
"crypto/sha1"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
githubpkg "github.com/kandev/kandev/internal/github"
jirapkg "github.com/kandev/kandev/internal/jira"
linearpkg "github.com/kandev/kandev/internal/linear"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/repoclone"
"github.com/kandev/kandev/internal/secrets"
sentrypkg "github.com/kandev/kandev/internal/sentry"
taskmodels "github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
taskservice "github.com/kandev/kandev/internal/task/service"
userservice "github.com/kandev/kandev/internal/user/service"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
workflowservice "github.com/kandev/kandev/internal/workflow/service"
)
const defaultEventNamespace = "default"
func provideOrchestrator(
cfg *config.Config,
log *logger.Logger,
pool *db.Pool,
eventBus bus.EventBus,
taskRepo *sqliterepo.Repository,
taskSvc *taskservice.Service,
userSvc *userservice.Service,
lifecycleMgr *lifecycle.Manager,
agentRegistry *registry.Registry,
workflowSvc *workflowservice.Service,
secretStore secrets.SecretStore,
repoCloner *repoclone.Cloner,
) (*orchestrator.Service, *messageCreatorAdapter, error) {
if lifecycleMgr == nil {
return nil, nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)")
}
taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc}
agentManagerClient := newLifecycleAdapter(lifecycleMgr, agentRegistry, log)
serviceCfg := orchestrator.DefaultServiceConfig()
namespace := resolveEventNamespace(cfg)
serviceCfg.QueueGroup = "orchestrator." + namespace
busMode := "memory"
if cfg != nil && strings.TrimSpace(cfg.NATS.URL) != "" {
busMode = "nats"
}
log.Debug("orchestrator queue group resolved",
zap.String("event_bus", busMode),
zap.String("event_namespace", namespace),
zap.String("queue_group", serviceCfg.QueueGroup),
zap.Int("agent_standalone_port", cfg.Agent.StandalonePort))
queueRepo, err := messagequeue.NewSQLiteRepository(pool.Writer(), pool.Reader())
if err != nil {
return nil, nil, fmt.Errorf("init message queue repo: %w", err)
}
maxPerSession := resolveQueueMaxPerSession(log)
msgQueue := messagequeue.NewService(queueRepo, maxPerSession, log)
log.Info("Message queue initialized",
zap.Int("max_per_session", maxPerSession))
orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, secretStore, msgQueue, log)
taskSvc.SetExecutionStopper(orchestratorSvc)
taskSvc.SetGitArchiveCapture(orchestratorSvc)
orchestratorSvc.SetWorktreeManager(lifecycleMgr.WorktreeManager())
msgCreator := &messageCreatorAdapter{svc: taskSvc, logger: log}
orchestratorSvc.SetMessageCreator(msgCreator)
orchestratorSvc.SetTurnService(newTurnServiceAdapter(taskSvc))
// Route orchestrator task.updated events through the task service, which
// owns the canonical rich payload. Covers workflow transitions, workflow
// step moves, and the primary-session-set callback below.
orchestratorSvc.SetTaskEventPublisher(taskSvc)
// Publish task.updated when the first session is marked primary so the
// frontend receives primary_session_id for newly created tasks.
orchestratorSvc.SetOnPrimarySessionSet(func(ctx context.Context, taskID, _ string) {
task, err := taskRepo.GetTask(ctx, taskID)
if err != nil {
log.Warn("failed to get task for primary session event",
zap.String("task_id", taskID),
zap.Error(err))
return
}
taskSvc.PublishTaskUpdated(ctx, task)
})
// Wire workflow step getter for prompt building
if workflowSvc != nil {
orchestratorSvc.SetWorkflowStepGetter(&orchestratorWorkflowStepGetterAdapter{svc: workflowSvc})
}
// Wire review task creator for auto-creating tasks from review watch PRs
orchestratorSvc.SetReviewTaskCreator(&reviewTaskCreatorAdapter{svc: taskSvc})
// Wire issue task creator for auto-creating tasks from issue watch events
orchestratorSvc.SetIssueTaskCreator(&issueTaskCreatorAdapter{svc: taskSvc})
// Wire the per-watcher throttle gate. taskRepo exposes the JSON-scoped
// count of open watcher-created tasks; the orchestrator combines it with
// an in-process pending counter so a poll-tick burst can't overshoot.
orchestratorSvc.SetWatcherTaskCounter(taskRepo)
// Wire repository resolver for auto-cloning repos during review task creation
if repoCloner != nil {
orchestratorSvc.SetRepositoryResolver(&repositoryResolverAdapter{
cloner: repoCloner,
protocol: repoclone.DetectGitProtocol(),
taskSvc: taskSvc,
logger: log,
})
// Wire repo cloner into executor for provider-backed repos with no local path
orchestratorSvc.SetRepoCloner(repoCloner, &repoLocalPathUpdater{svc: taskSvc})
}
return orchestratorSvc, msgCreator, nil
}
// resolveQueueMaxPerSession honors the KANDEV_QUEUE_MAX_PER_SESSION env var,
// falling back to messagequeue.DefaultMaxPerSession (10) when unset or invalid.
// Values <= 0 disable the cap entirely (callers can still flood queues — only
// useful in tests / specialized deployments).
func resolveQueueMaxPerSession(log *logger.Logger) int {
raw := strings.TrimSpace(os.Getenv("KANDEV_QUEUE_MAX_PER_SESSION"))
if raw == "" {
return messagequeue.DefaultMaxPerSession
}
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn("KANDEV_QUEUE_MAX_PER_SESSION is not a number, using default",
zap.String("value", raw),
zap.Int("default", messagequeue.DefaultMaxPerSession))
return messagequeue.DefaultMaxPerSession
}
return n
}
func resolveEventNamespace(cfg *config.Config) string {
if cfg == nil {
return defaultEventNamespace
}
if explicit := strings.TrimSpace(cfg.Events.Namespace); explicit != "" {
return sanitizeNamespace(explicit)
}
identity := resolveDatabaseIdentity(cfg)
if identity == "" {
return defaultEventNamespace
}
return hashNamespace(identity)
}
func resolveDatabaseIdentity(cfg *config.Config) string {
if strings.EqualFold(cfg.Database.Driver, "sqlite") {
path := cfg.Database.Path
if path == "" {
path = "./kandev.db"
}
absPath, err := filepath.Abs(path)
if err == nil {
return "sqlite:" + absPath
}
return "sqlite:" + path
}
return fmt.Sprintf("pg:%s:%d:%s:%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.DBName, cfg.Database.User)
}
func hashNamespace(identity string) string {
sum := sha1.Sum([]byte(identity))
return fmt.Sprintf("%x", sum[:6])
}
func sanitizeNamespace(namespace string) string {
lower := strings.ToLower(namespace)
var b strings.Builder
for _, r := range lower {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_' || r == '.':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), "-._")
if out == "" {
return defaultEventNamespace
}
return out
}
// orchestratorWorkflowStepGetterAdapter adapts workflow service to orchestrator's WorkflowStepGetter interface.
// Since orchestrator now uses wfmodels.WorkflowStep directly, the adapter simply delegates to the service.
type orchestratorWorkflowStepGetterAdapter struct {
svc *workflowservice.Service
}
// GetStep implements orchestrator.WorkflowStepGetter.
func (a *orchestratorWorkflowStepGetterAdapter) GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) {
return a.svc.GetStep(ctx, stepID)
}
// GetNextStepByPosition implements orchestrator.WorkflowStepGetter.
func (a *orchestratorWorkflowStepGetterAdapter) GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) {
return a.svc.GetNextStepByPosition(ctx, workflowID, currentPosition)
}
// GetPreviousStepByPosition implements orchestrator.WorkflowStepGetter.
func (a *orchestratorWorkflowStepGetterAdapter) GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) {
return a.svc.GetPreviousStepByPosition(ctx, workflowID, currentPosition)
}
// GetWorkflowAgentProfileID implements orchestrator.WorkflowStepGetter.
func (a *orchestratorWorkflowStepGetterAdapter) GetWorkflowAgentProfileID(ctx context.Context, workflowID string) (string, error) {
return a.svc.GetWorkflowAgentProfileID(ctx, workflowID)
}
// reviewTaskCreatorAdapter adapts the task service to the orchestrator's ReviewTaskCreator interface.
type reviewTaskCreatorAdapter struct {
svc *taskservice.Service
}
// CreateReviewTask implements orchestrator.ReviewTaskCreator.
func (a *reviewTaskCreatorAdapter) CreateReviewTask(ctx context.Context, req *orchestrator.ReviewTaskRequest) (*taskmodels.Task, error) {
var repos []taskservice.TaskRepositoryInput
for _, r := range req.Repositories {
repos = append(repos, taskservice.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
CheckoutBranch: r.CheckoutBranch,
PRNumber: r.PRNumber,
})
}
return a.svc.CreateTask(ctx, &taskservice.CreateTaskRequest{
WorkspaceID: req.WorkspaceID,
WorkflowID: req.WorkflowID,
WorkflowStepID: req.WorkflowStepID,
Title: req.Title,
Description: req.Description,
Metadata: req.Metadata,
Repositories: repos,
IsEphemeral: req.IsEphemeral,
Origin: req.Origin,
})
}
// issueTaskCreatorAdapter adapts the task service to the orchestrator's IssueTaskCreator interface.
type issueTaskCreatorAdapter struct {
svc *taskservice.Service
}
// CreateIssueTask implements orchestrator.IssueTaskCreator.
func (a *issueTaskCreatorAdapter) CreateIssueTask(ctx context.Context, req *orchestrator.IssueTaskRequest) (*taskmodels.Task, error) {
var repos []taskservice.TaskRepositoryInput
for _, r := range req.Repositories {
repos = append(repos, taskservice.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
})
}
return a.svc.CreateTask(ctx, &taskservice.CreateTaskRequest{
WorkspaceID: req.WorkspaceID,
WorkflowID: req.WorkflowID,
WorkflowStepID: req.WorkflowStepID,
Title: req.Title,
Description: req.Description,
Metadata: req.Metadata,
Repositories: repos,
})
}
// jiraServiceAdapter exposes the JIRA service's issue-watch dedup methods to
// the orchestrator without leaking the rest of the package surface area.
type jiraServiceAdapter struct {
svc *jirapkg.Service
}
func (a *jiraServiceAdapter) ReserveIssueWatchTask(ctx context.Context, watchID, issueKey, issueURL string) (bool, error) {
return a.svc.Store().ReserveIssueWatchTask(ctx, watchID, issueKey, issueURL)
}
func (a *jiraServiceAdapter) AssignIssueWatchTaskID(ctx context.Context, watchID, issueKey, taskID string) error {
return a.svc.Store().AssignIssueWatchTaskID(ctx, watchID, issueKey, taskID)
}
func (a *jiraServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, issueKey string) error {
return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, issueKey)
}
func (a *jiraServiceAdapter) DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error {
return a.svc.Store().DisableIssueWatchWithError(ctx, watchID, cause)
}
// linearServiceAdapter exposes the Linear service's issue-watch dedup methods
// to the orchestrator without leaking the rest of the package surface area.
type linearServiceAdapter struct {
svc *linearpkg.Service
}
func (a *linearServiceAdapter) ReserveIssueWatchTask(ctx context.Context, watchID, identifier, issueURL string) (bool, error) {
return a.svc.Store().ReserveIssueWatchTask(ctx, watchID, identifier, issueURL)
}
func (a *linearServiceAdapter) AssignIssueWatchTaskID(ctx context.Context, watchID, identifier, taskID string) error {
return a.svc.Store().AssignIssueWatchTaskID(ctx, watchID, identifier, taskID)
}
func (a *linearServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, identifier string) error {
return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, identifier)
}
// sentryServiceAdapter exposes the Sentry service's issue-watch dedup methods
// to the orchestrator without leaking the rest of the package surface area.
type sentryServiceAdapter struct {
svc *sentrypkg.Service
}
func (a *sentryServiceAdapter) ReserveIssueWatchTask(ctx context.Context, watchID, shortID, issueURL string) (bool, error) {
return a.svc.Store().ReserveIssueWatchTask(ctx, watchID, shortID, issueURL)
}
func (a *sentryServiceAdapter) AssignIssueWatchTaskID(ctx context.Context, watchID, shortID, taskID string) error {
return a.svc.Store().AssignIssueWatchTaskID(ctx, watchID, shortID, taskID)
}
func (a *sentryServiceAdapter) ReleaseIssueWatchTask(ctx context.Context, watchID, shortID string) error {
return a.svc.Store().ReleaseIssueWatchTask(ctx, watchID, shortID)
}
func (a *sentryServiceAdapter) DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error {
return a.svc.Store().DisableIssueWatchWithError(ctx, watchID, cause)
}
func (a *linearServiceAdapter) DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error {
return a.svc.Store().DisableIssueWatchWithError(ctx, watchID, cause)
}
// profileLookupAdapter satisfies orchestrator.ProfileLookup by delegating to
// the agent settings store. Returns (true, name, nil) when the row exists
// but is soft-deleted, which the orchestrator treats as a signal to self-heal
// the bound watcher (set enabled=0 + last_error). All other shapes — live
// row, missing row, driver error — are returned verbatim so the dispatch
// pipeline fails open on transient failures.
type profileLookupAdapter struct {
store settingsstore.Repository
}
func (a *profileLookupAdapter) LookupProfile(ctx context.Context, profileID string) (bool, string, error) {
if _, err := a.store.GetAgentProfile(ctx, profileID); err == nil {
return false, "", nil
} else if !errors.Is(err, sql.ErrNoRows) {
return false, "", err
}
profile, err := a.store.GetAgentProfileIncludingDeleted(ctx, profileID)
if err != nil || profile == nil || profile.DeletedAt == nil {
return false, "", err
}
return true, profile.Name, nil
}
// watcherDepsAdapter enumerates linear / jira / github_issue / github_review
// watcher rows that reference an agent profile. The profile-delete confirm
// dialog uses the list to render "this will also disable N watchers — are
// you sure?".
//
// Each integration's package is optional in dev mode; nil-safe so the
// adapter degrades gracefully when one isn't wired.
type watcherDepsAdapter struct {
linear *linearpkg.Service
jira *jirapkg.Service
github githubWatcherLister
log *logger.Logger
}
// githubWatcherLister is the slice of github.Service the adapter needs.
// Local alias so the adapter file stays free of the wider GitHubService
// orchestrator interface, which carries many unrelated methods. Uses the
// enabled-only listers so already-disabled (self-healed) watchers do not
// inflate the dependency count surfaced in ErrProfileInUseDetail. Disable
// methods are the same store-level helpers the dispatch coordinator's
// self-heal path uses.
type githubWatcherLister interface {
ListEnabledIssueWatches(ctx context.Context) ([]*githubpkg.IssueWatch, error)
ListEnabledReviewWatches(ctx context.Context) ([]*githubpkg.ReviewWatch, error)
DisableIssueWatchWithError(ctx context.Context, watchID, cause string) error
DisableReviewWatchWithError(ctx context.Context, watchID, cause string) error
}
func (a *watcherDepsAdapter) ListWatchersByAgentProfile(ctx context.Context, profileID string) ([]agentsettingscontroller.WatcherReference, error) {
if profileID == "" {
return nil, nil
}
var refs []agentsettingscontroller.WatcherReference
more, err := a.listLinearRefs(ctx, profileID)
if err != nil {
return nil, err
}
refs = append(refs, more...)
more, err = a.listJiraRefs(ctx, profileID)
if err != nil {
return nil, err
}
refs = append(refs, more...)
more, err = a.listGitHubRefs(ctx, profileID)
if err != nil {
return nil, err
}
refs = append(refs, more...)
return refs, nil
}
// DisableWatchersByAgentProfile enumerates the enabled watcher rows that
// reference profileID and flips each to enabled=0 with the supplied cause.
// Returns the list it disabled so the caller can log. Best-effort across
// integrations: a failure for one integration logs and continues to the
// next so a single broken store doesn't strand the rest of the user's
// orphaned watchers.
func (a *watcherDepsAdapter) DisableWatchersByAgentProfile(ctx context.Context, profileID, cause string) ([]agentsettingscontroller.WatcherReference, error) {
refs, err := a.ListWatchersByAgentProfile(ctx, profileID)
if err != nil {
return nil, err
}
disabled := make([]agentsettingscontroller.WatcherReference, 0, len(refs))
for _, ref := range refs {
if err := a.disableByKind(ctx, ref.Kind, ref.ID, cause); err != nil {
a.log.Warn("failed to disable referencing watcher",
zap.String("profile_id", profileID),
zap.String("watcher_kind", ref.Kind),
zap.String("watcher_id", ref.ID),
zap.Error(err))
continue
}
disabled = append(disabled, ref)
}
return disabled, nil
}
func (a *watcherDepsAdapter) disableByKind(ctx context.Context, kind, watchID, cause string) error {
switch kind {
case "linear":
if a.linear == nil {
return nil
}
return a.linear.Store().DisableIssueWatchWithError(ctx, watchID, cause)
case "jira":
if a.jira == nil {
return nil
}
return a.jira.Store().DisableIssueWatchWithError(ctx, watchID, cause)
case "github_issue":
if a.github == nil {
return nil
}
return a.github.DisableIssueWatchWithError(ctx, watchID, cause)
case "github_review":
if a.github == nil {
return nil
}
return a.github.DisableReviewWatchWithError(ctx, watchID, cause)
default:
return fmt.Errorf("unknown watcher kind: %s", kind)
}
}
func (a *watcherDepsAdapter) listLinearRefs(ctx context.Context, profileID string) ([]agentsettingscontroller.WatcherReference, error) {
if a.linear == nil {
return nil, nil
}
watches, err := a.linear.Store().ListEnabledIssueWatches(ctx)
if err != nil {
return nil, fmt.Errorf("linear watchers: %w", err)
}
var out []agentsettingscontroller.WatcherReference
for _, w := range watches {
if w.AgentProfileID == profileID {
out = append(out, agentsettingscontroller.WatcherReference{
ID: w.ID, Kind: "linear", Label: linearWatchLabel(w),
})
}
}
return out, nil
}
func (a *watcherDepsAdapter) listJiraRefs(ctx context.Context, profileID string) ([]agentsettingscontroller.WatcherReference, error) {
if a.jira == nil {
return nil, nil
}
watches, err := a.jira.Store().ListEnabledIssueWatches(ctx)
if err != nil {
return nil, fmt.Errorf("jira watchers: %w", err)
}
var out []agentsettingscontroller.WatcherReference
for _, w := range watches {
if w.AgentProfileID == profileID {
out = append(out, agentsettingscontroller.WatcherReference{
ID: w.ID, Kind: "jira", Label: truncateWatcherLabel(w.JQL),
})
}
}
return out, nil
}
func (a *watcherDepsAdapter) listGitHubRefs(ctx context.Context, profileID string) ([]agentsettingscontroller.WatcherReference, error) {
if a.github == nil {
return nil, nil
}
issues, err := a.github.ListEnabledIssueWatches(ctx)
if err != nil {
return nil, fmt.Errorf("github issue watchers: %w", err)
}
var out []agentsettingscontroller.WatcherReference
for _, w := range issues {
if w.AgentProfileID == profileID {
out = append(out, agentsettingscontroller.WatcherReference{
ID: w.ID, Kind: "github_issue", Label: githubIssueWatchLabel(w),
})
}
}
reviews, err := a.github.ListEnabledReviewWatches(ctx)
if err != nil {
return nil, fmt.Errorf("github review watchers: %w", err)
}
for _, w := range reviews {
if w.AgentProfileID == profileID {
out = append(out, agentsettingscontroller.WatcherReference{
ID: w.ID, Kind: "github_review", Label: githubReviewWatchLabel(w),
})
}
}
return out, nil
}
// linearWatchLabel renders a Linear filter into a short human-readable label
// for the confirmation dialog. The team key is the most-recognisable scoping
// fact; the query string is a fallback. Capped at watcherLabelMaxLen.
func linearWatchLabel(w *linearpkg.IssueWatch) string {
switch {
case w.Filter.TeamKey != "":
return truncateWatcherLabel("team " + w.Filter.TeamKey)
case w.Filter.Query != "":
return truncateWatcherLabel(w.Filter.Query)
default:
return "all teams"
}
}
func githubIssueWatchLabel(w *githubpkg.IssueWatch) string {
if w.CustomQuery != "" {
return truncateWatcherLabel(w.CustomQuery)
}
return truncateWatcherLabel(githubReposJoin(w.Repos))
}
func githubReviewWatchLabel(w *githubpkg.ReviewWatch) string {
if w.CustomQuery != "" {
return truncateWatcherLabel(w.CustomQuery)
}
return truncateWatcherLabel(githubReposJoin(w.Repos))
}
func githubReposJoin(repos []githubpkg.RepoFilter) string {
if len(repos) == 0 {
return "all repos"
}
parts := make([]string, 0, len(repos))
for _, r := range repos {
parts = append(parts, r.Owner+"/"+r.Name)
}
return strings.Join(parts, ", ")
}
const watcherLabelMaxLen = 80
// truncateWatcherLabel caps a watcher's human-readable label at
// watcherLabelMaxLen runes, appending "…" when truncation happens. Counted
// in runes (not bytes) so a JQL or filter ending in multi-byte UTF-8
// (Japanese, emoji) is not split mid-codepoint — slicing a byte string
// would emit invalid UTF-8 and the UI would render the replacement
// character.
func truncateWatcherLabel(s string) string {
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) <= watcherLabelMaxLen {
return s
}
return string(runes[:watcherLabelMaxLen-1]) + "…"
}
// repoLocalPathUpdater adapts the task service's UpdateRepository to the executor.RepoUpdater interface.
type repoLocalPathUpdater struct {
svc *taskservice.Service
}
func (u *repoLocalPathUpdater) UpdateRepositoryLocalPath(ctx context.Context, repositoryID, localPath string) error {
if repositoryID == "" || localPath == "" {
return fmt.Errorf("UpdateRepositoryLocalPath: repositoryID and localPath must be non-empty")
}
_, err := u.svc.UpdateRepository(ctx, repositoryID, &taskservice.UpdateRepositoryRequest{
LocalPath: &localPath,
})
return err
}
func (u *repoLocalPathUpdater) UpdateRepositoryDefaultBranch(ctx context.Context, repositoryID, defaultBranch string) error {
if repositoryID == "" || defaultBranch == "" {
return fmt.Errorf("UpdateRepositoryDefaultBranch: repositoryID and defaultBranch must be non-empty")
}
_, err := u.svc.UpdateRepository(ctx, repositoryID, &taskservice.UpdateRepositoryRequest{
DefaultBranch: &defaultBranch,
})
return err
}
// repositoryResolverAdapter resolves GitHub repos by cloning + finding/creating DB records.
type repositoryResolverAdapter struct {
cloner *repoclone.Cloner
protocol string
taskSvc *taskservice.Service
logger *logger.Logger
}
// ResolveForReview implements orchestrator.RepositoryResolver.
//
// If the workspace already has a Repository configured for the given provider
// info with a non-empty LocalPath, that repo is reused and no clone is
// performed. Otherwise the repo is cloned into the kandev-managed location and
// a Repository entity is created.
func (a *repositoryResolverAdapter) ResolveForReview(
ctx context.Context, workspaceID, provider, owner, name, defaultBranch string,
) (string, string, error) {
existing, err := a.taskSvc.GetRepositoryByProviderInfo(ctx, workspaceID, provider, owner, name)
if err != nil {
return "", "", fmt.Errorf("lookup repository by provider info: %w", err)
}
if existing != nil && existing.LocalPath != "" {
baseBranch := defaultBranch
if baseBranch == "" {
baseBranch = existing.DefaultBranch
}
if baseBranch == "" {
baseBranch = a.detectAndPersistDefaultBranch(ctx, existing, existing.LocalPath)
}
return existing.ID, baseBranch, nil
}
cloneURL, err := repoclone.CloneURL(provider, owner, name, a.protocol)
if err != nil {
return "", "", fmt.Errorf("unsupported provider: %w", err)
}
localPath, err := a.cloner.EnsureCloned(ctx, cloneURL, owner, name)
if err != nil {
return "", "", fmt.Errorf("clone repository: %w", err)
}
repo, _, err := a.taskSvc.FindOrCreateRepository(ctx, &taskservice.FindOrCreateRepositoryRequest{
WorkspaceID: workspaceID,
Provider: provider,
ProviderOwner: owner,
ProviderName: name,
DefaultBranch: defaultBranch,
LocalPath: localPath,
})
if err != nil {
return "", "", fmt.Errorf("find/create repository: %w", err)
}
baseBranch := defaultBranch
if baseBranch == "" {
baseBranch = repo.DefaultBranch
}
// When no default branch is known (e.g. issue watch with no PR context),
// detect it from the cloned repo's HEAD.
if baseBranch == "" && localPath != "" {
baseBranch = a.detectAndPersistDefaultBranch(ctx, repo, localPath)
}
return repo.ID, baseBranch, nil
}
// detectAndPersistDefaultBranch reads the default branch from the local clone
// and persists it to the repository record for future lookups.
func (a *repositoryResolverAdapter) detectAndPersistDefaultBranch(
ctx context.Context, repo *taskmodels.Repository, localPath string,
) string {
detected := detectGitDefaultBranch(localPath)
if detected == "" {
return ""
}
if _, err := a.taskSvc.UpdateRepository(ctx, repo.ID, &taskservice.UpdateRepositoryRequest{
DefaultBranch: &detected,
}); err != nil {
a.logger.Warn("failed to persist detected default branch",
zap.String("repository_id", repo.ID),
zap.String("branch", detected),
zap.Error(err))
}
return detected
}
// detectGitDefaultBranch reads the default branch of a git repository.
// It first checks refs/remotes/origin/HEAD (set by `git clone`), then
// falls back to .git/HEAD. Returns empty string on any failure.
func detectGitDefaultBranch(repoPath string) string {
// Prefer the remote default branch pointer set by `git clone`.
originHead := filepath.Join(repoPath, ".git", "refs", "remotes", "origin", "HEAD")
if content, err := os.ReadFile(originHead); err == nil {
trimmed := strings.TrimSpace(string(content))
if after, ok := strings.CutPrefix(trimmed, "ref: refs/remotes/origin/"); ok {
return after
}
}
// Fall back to the local HEAD (works for fresh clones that lack origin/HEAD).
headPath := filepath.Join(repoPath, ".git", "HEAD")
content, err := os.ReadFile(headPath)
if err != nil {
return ""
}
trimmed := strings.TrimSpace(string(content))
if after, ok := strings.CutPrefix(trimmed, "ref: refs/heads/"); ok {
return after
}
return ""
}