diff --git a/internal/ai/runner.go b/internal/ai/runner.go index a84052b..4847d97 100644 --- a/internal/ai/runner.go +++ b/internal/ai/runner.go @@ -244,6 +244,24 @@ func (r *Runner) RunSkill(ctx context.Context, user string, scope Scope, skillNa return RunResult{Cards: cards, Commentary: result.Text}, nil } +// RunText performs one tool-free completion using stored configuration +// without exposing the decrypted API key to callers. +func (r *Runner) RunText(ctx context.Context, user, system, prompt string, maxTokens int64) (string, error) { + cfg, err := r.storedConfig(user) + if err != nil { + return "", err + } + client, err := r.rigClient(cfg) + if err != nil { + return "", err + } + result, err := client.Run(ctx, rig.RunRequest{Model: cfg.Model, System: system, Prompt: prompt, MaxTokens: skillBudget(maxTokens), MaxIterations: 1}) + if err != nil { + return "", runnerError(err) + } + return result.Text, nil +} + // skillBudget bounds one run's output budget to the range the endpoint is // known to accept. The ceiling is not advice: a model is only known by the // name the user typed, and asking for more than its own completion cap is diff --git a/internal/ai/runner_test.go b/internal/ai/runner_test.go index 61b0989..6e2ac24 100644 --- a/internal/ai/runner_test.go +++ b/internal/ai/runner_test.go @@ -149,6 +149,41 @@ func TestRunnerReadOnlyScopeOmitsMutatingTools(t *testing.T) { } } +func TestRunnerRunTextKeepsConfigurationPrivate(t *testing.T) { + fake := &scriptedOpenAI{replies: []fakeReply{{content: "plain summary"}}} + runner, closeUpstream := configuredRunner(t, fake, "gpt-4o") + defer closeUpstream() + result, err := runner.RunText(context.Background(), "default", "system instruction", "prompt text", 123) + if err != nil || result != "plain summary" { + t.Fatalf("RunText = %q, %v", result, err) + } + requests := fake.requests() + if len(requests) != 1 { + t.Fatalf("requests = %d", len(requests)) + } + request := string(requests[0]) + for _, want := range []string{"system instruction", "prompt text", `"max_tokens":123`} { + if !strings.Contains(request, want) { + t.Errorf("request missing %q: %s", want, request) + } + } + if strings.Contains(request, `"tools"`) { + t.Fatalf("tool-free request exposed tools: %s", request) + } + + failing := &scriptedOpenAI{replies: []fakeReply{{status: http.StatusInternalServerError}}} + failingRunner, closeFailing := configuredRunner(t, failing, "gpt-4o") + defer closeFailing() + if _, err := failingRunner.RunText(context.Background(), "default", "system", "prompt", 10); err == nil { + t.Fatal("upstream RunText failure succeeded") + } + + unconfigured := NewRunner(newTestStore(t), "", nil, nil) + if _, err := unconfigured.RunText(context.Background(), "missing", "system", "prompt", 10); err == nil { + t.Fatal("unconfigured RunText succeeded") + } +} + func TestRunnerErrorsAndPartialResults(t *testing.T) { t.Run("unknown skill", func(t *testing.T) { fake := &scriptedOpenAI{} @@ -227,6 +262,16 @@ func osWriteFile(path string, data []byte) error { } func TestRunnerHelpers(t *testing.T) { + if err := ValidateBaseURL("https://example.com"); err != nil { + t.Fatal(err) + } + if err := ValidateDraft(Draft{Title: "card", Prio: 3}); err != nil { + t.Fatal(err) + } + coerced := CoerceDraft(map[string]any{"title": " card ", "prio": float64(9)}) + if coerced.Title != "card" || coerced.Prio != 4 || ClampPriority(0) != 1 || NormalizeStoryCount(0) != defaultStoryCount { + t.Fatalf("public draft helpers = %+v", coerced) + } for _, test := range []struct { ask int64 want int64 diff --git a/internal/forge/api.go b/internal/forge/api.go new file mode 100644 index 0000000..0822bad --- /dev/null +++ b/internal/forge/api.go @@ -0,0 +1,509 @@ +package forge + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +// ImportFetchTimeout bounds one forge selection independently of an AI run. +const ImportFetchTimeout = importFetchTimeout + +// ErrUpstreamChanged is returned when a baseline revision no longer names the +// current upstream snapshot. +var ErrUpstreamChanged = errors.New("upstream changed; check again") + +// Ref is a parsed configured forge selection. Its credential remains private. +type Ref struct { + Source store.ForgeSource + Kind string + Project string + Issue int + Milestone int + credential string +} + +func publicRef(value forgeRef) Ref { + return Ref{Source: value.Source, Kind: value.Kind, Project: value.Project, Issue: value.Issue, Milestone: value.Milestone, credential: value.pat} +} + +func privateRef(value Ref) forgeRef { + return forgeRef{Source: value.Source, Kind: value.Kind, Project: value.Project, Issue: value.Issue, Milestone: value.Milestone, pat: value.credential} +} + +func parseRef(sources []store.ForgeSource, source, raw string) (Ref, error) { + value, err := parseForgeRef(sources, source, raw) + return publicRef(value), err +} + +func (r Ref) withCredential(value string) Ref { r.credential = value; return r } + +// Issue is one bounded upstream forge issue. +type Issue = forgeIssue + +func sourceByName(sources []store.ForgeSource, name string) (store.ForgeSource, bool) { + return forgeSourceByName(sources, name) +} + +// ValidSourceName reports whether a source name is safe for persisted lookup. +func ValidSourceName(name string) bool { return validForgeSourceName(name) } + +// BaselineRevision identifies one accepted upstream snapshot for compare-and-swap. +func BaselineRevision(baseline store.ImportBaseline) string { return importDriftRevision(baseline) } + +// ValidRevision reports whether a drift revision is a canonical digest. +func ValidRevision(revision string) bool { return validImportDriftRevision(revision) } + +func issueProvenance(ref Ref, issue Issue) (string, string) { + return importIssueProvenance(privateRef(ref), issue) +} + +// ResolveIssueDocument authorizes and fetches one configured issue for the +// server's existing ADR split endpoint. +func (s *Service) ResolveIssueDocument(ctx context.Context, user, source, raw string) (Issue, string, string, error) { + ref, err := s.authorizeRef(user, source, raw) + if err != nil { + return Issue{}, "", "", err + } + fetchCtx, cancel := context.WithTimeout(ctx, importFetchTimeout) + defer cancel() + issue, err := s.fetchIssue(fetchCtx, privateRef(ref)) + if err != nil { + return Issue{}, "", "", err + } + link, _ := issueProvenance(ref, issue) + return issue, link, issue.URL, nil +} + +// Draft is one import proposal with its forge provenance and duplicate hint. +type Draft struct { + ai.Draft + Link string + ExternalKey string + URL string + Duplicate *Duplicate +} + +type Duplicate struct { + ID string + Title string + Via string +} + +type PreviewRequest struct { + Source string + Ref string + Max int +} + +type Preview struct { + Kind string + TotalHint int + Fetched int + Truncated bool + Note string + Drafts []Draft +} + +func (s *Service) Sources(user string) ([]store.ForgeSource, error) { + sources, err := s.store.ForgeSources(user) + if err != nil { + return nil, storageFailure(err) + } + return sources, nil +} + +// SaveSource validates and persists one configured forge source. +func (s *Service) SaveSource(user, name, kind string, baseURL, token *string) (bool, error) { + if !validForgeSourceName(name) { + return false, badRequest(invalidIntegrationNameMessage, nil) + } + if kind != "gitlab" && kind != "github" { + return false, badRequest("invalid forge kind", nil) + } + if baseURL != nil { + if _, err := forgeAPIBase(kind, *baseURL); err != nil { + return false, badRequest(err.Error(), err) + } + } + cleared, err := s.store.SetForgeSource(user, name, kind, baseURL, token) + if err != nil { + switch err.Error() { + case "store: forge base URL is required", "store: forge base URL must not contain query or fragment": + return false, badRequest(strings.TrimPrefix(err.Error(), "store: "), err) + default: + return false, storageFailure(err) + } + } + return cleared, nil +} + +func (s *Service) DeleteSource(user, name string) error { + if !validForgeSourceName(name) { + return badRequest(invalidIntegrationNameMessage, nil) + } + if err := s.store.DeleteForgeSource(user, name); err != nil { + return storageFailure(err) + } + return nil +} + +func (s *Service) Probe(ctx context.Context, user string, config ForgeProbeConfig) error { + return NewForgeProberWithClient(s.store, s.forgeClient).Probe(ctx, user, config) +} + +// Preview fetches, classifies and transforms one configured selection. It is +// read-only: third-party forge text cannot receive write or fetch tools. +func (s *Service) Preview(ctx context.Context, user string, request PreviewRequest) (Preview, error) { + ref, err := s.authorizeRef(user, request.Source, request.Ref) + if err != nil { + return Preview{}, err + } + fetchCtx, cancel := context.WithTimeout(ctx, importFetchTimeout) + issues, total, truncated, note, err := s.fetchIssues(fetchCtx, privateRef(ref), request.Max) + cancel() + if err != nil { + return Preview{}, err + } + duplicates, err := s.duplicates(user, ref, issues) + if err != nil { + return Preview{}, storageFailure(err) + } + fetched := len(issues) + packed, sourceCount := packImportIssues(issues) + result := Preview{Kind: refKind(ref), TotalHint: total, Fetched: fetched, Truncated: truncated, Note: note, Drafts: []Draft{}} + if sourceCount < len(issues) { + result.Truncated = true + result.Note = appendImportNote(result.Note, importPackTruncationNote) + } + issues, duplicates = issues[:sourceCount], duplicates[:sourceCount] + if sourceCount == 0 { + return result, nil + } + runCtx, cancel := context.WithTimeout(ctx, skillRunDeadline) + defer cancel() + run, err := s.runner.RunSkill(runCtx, user, ai.ScopeReadOnly, importTransformSkillName, + "Transform these numbered forge issues into kanban-card proposals:\n\n"+packed, maxImportIssues, aiImportMaxTokens) + if err != nil { + return Preview{}, err + } + if run.Partial { + result.Note = appendImportNote(result.Note, importPartialTransformNote) + } + result.Drafts = attachDrafts(ref, run.Cards, issues, duplicates) + return result, nil +} + +func (s *Service) authorizeRef(user, sourceName, raw string) (Ref, error) { + sources, err := s.store.ForgeSources(user) + if err != nil { + return Ref{}, storageFailure(err) + } + ref, err := parseRef(sources, sourceName, raw) + if err != nil { + return Ref{}, badRequest(err.Error(), err) + } + selected, found := sourceByName(sources, sourceName) + if !found { + return Ref{}, badRequest(configuredSourceUnavailableMessage, nil) + } + if ref.Source.Name != selected.Name { + return Ref{}, badRequest("reference does not match selected source", nil) + } + kind, baseURL, token, err := s.store.ForgePAT(user, selected.Name) + if err != nil || kind != selected.Kind || baseURL != selected.BaseURL { + return Ref{}, badRequest(configuredSourceUnavailableMessage, err) + } + return ref.withCredential(token), nil +} + +func (s *Service) duplicates(user string, ref Ref, issues []Issue) ([]*Duplicate, error) { + result := make([]*Duplicate, len(issues)) + for index, issue := range issues { + _, externalKey := issueProvenance(ref, issue) + exact, err := s.store.TasksByLink(user, importTagPrefix+externalKey) + if err != nil { + return nil, err + } + if len(exact) > 0 { + result[index] = &Duplicate{ID: exact[0].ID, Title: exact[0].Title, Via: "link"} + continue + } + similar, err := s.store.SearchSimilar(user, issue.Title, "", nil, 1) + if err != nil { + return nil, err + } + if len(similar) > 0 { + result[index] = &Duplicate{ID: similar[0].ID, Title: similar[0].Title, Via: "similar"} + } + } + return result, nil +} + +func attachDrafts(ref Ref, drafts []ai.Draft, issues []Issue, duplicates []*Duplicate) []Draft { + result := make([]Draft, 0, len(drafts)) + claimed := make(map[int]bool, len(drafts)) + for _, proposal := range drafts { + proposal.Tags = stripModelLinkTags(proposal.Tags) + item := Draft{Draft: proposal} + if proposal.Source > 0 && proposal.Source <= len(issues) && !claimed[proposal.Source] { + claimed[proposal.Source] = true + issue := issues[proposal.Source-1] + item.Link, item.ExternalKey = issueProvenance(ref, issue) + item.URL = issue.URL + item.Duplicate = duplicates[proposal.Source-1] + item.Tags = append(item.Tags, linkTagPrefix+item.Link, importTagPrefix+item.ExternalKey) + } + result = append(result, item) + } + return result +} + +func refKind(ref Ref) string { + if ref.Issue > 0 { + return "issue" + } + if ref.Milestone > 0 { + return "milestone" + } + return "project" +} + +type LinkInput struct { + ExternalKey string `json:"external_key"` + Link string `json:"link"` + URL string `json:"url"` + Title string `json:"title"` +} + +// RecordLinks journals provenance for compatibility clients that already +// created their cards. The TUI uses CreateTask for an atomic write. +func (s *Service) RecordLinks(user, sourceName string, items []LinkInput) error { + if len(items) > maxImportLinks { + return badRequest("too many import links (max 100)", nil) + } + if strings.TrimSpace(sourceName) == "" { + return badRequest("source required", nil) + } + sources, err := s.store.ForgeSources(user) + if err != nil { + return storageFailure(err) + } + source, found := sourceByName(sources, sourceName) + if !found { + return badRequest(configuredSourceUnavailableMessage, nil) + } + links := make([]store.ImportLink, len(items)) + for index, item := range items { + link, err := importLink(source, item) + if err != nil { + return err + } + links[index] = link + } + if err := s.store.RecordImportLinks(user, links); err != nil { + if strings.HasPrefix(err.Error(), "store: import ") { + return badRequest("invalid import link", err) + } + return storageFailure(err) + } + return nil +} + +// CreateTask atomically creates one selected card and its provenance. The +// transaction removes the crash window between those two durable writes. +func (s *Service) CreateTask(user, sourceName string, task board.Task, item LinkInput) (board.Task, error) { + sources, err := s.store.ForgeSources(user) + if err != nil { + return board.Task{}, storageFailure(err) + } + source, found := sourceByName(sources, sourceName) + if !found { + return board.Task{}, badRequest(configuredSourceUnavailableMessage, nil) + } + link, err := importLink(source, item) + if err != nil { + return board.Task{}, err + } + created, err := s.store.AddTaskWithImportLink(user, task, link) + if err != nil { + if strings.HasPrefix(err.Error(), "store: import ") { + return board.Task{}, badRequest("invalid import link", err) + } + return board.Task{}, storageFailure(err) + } + return created, nil +} + +func importLink(source store.ForgeSource, item LinkInput) (store.ImportLink, error) { + if strings.TrimSpace(item.ExternalKey) == "" || strings.TrimSpace(item.Link) == "" || strings.TrimSpace(item.URL) == "" || strings.TrimSpace(item.Title) == "" { + return store.ImportLink{}, badRequest("import link fields required", nil) + } + return store.ImportLink{Source: source.Name, Kind: source.Kind, ExternalKey: item.ExternalKey, Link: item.Link, URL: item.URL, Title: item.Title}, nil +} + +func (s *Service) Provenance(user, link string) ([]store.ImportLink, error) { + raw := link + link = strings.TrimSpace(link) + if link == "" || len(link) > 2048 || strings.ContainsAny(raw, "\r\n") { + return nil, badRequest("invalid import link", nil) + } + items, err := s.store.ImportLinksByLink(user, link) + if err != nil { + return nil, storageFailure(err) + } + if len(items) == 0 { + return nil, &Error{Code: http.StatusNotFound, Message: "import link not found"} + } + return items, nil +} + +type Drift struct { + State string + Link string + URL string + TitleChanged *bool + UpstreamTitle string + BaselineTitle string + BaselineAt string + CheckedAt string + Summary string + Revision string +} + +func (s *Service) authorizeDrift(user, source, externalKey string) (store.ImportLink, Ref, error) { + items, err := s.store.ImportedAs(user, []string{externalKey}) + if err != nil { + return store.ImportLink{}, Ref{}, storageFailure(err) + } + provenance, found := items[externalKey] + if !found { + return store.ImportLink{}, Ref{}, &Error{Code: http.StatusNotFound, Message: "import link not found"} + } + if !strings.EqualFold(source, provenance.Source) { + return store.ImportLink{}, Ref{}, badRequest("source does not match imported item", nil) + } + ref, err := s.authorizeRef(user, source, provenance.URL) + if err != nil { + return store.ImportLink{}, Ref{}, err + } + if provenance.Kind != ref.Kind { + return store.ImportLink{}, Ref{}, badRequest("imported item kind does not match selected source", nil) + } + if ref.Issue <= 0 { + return store.ImportLink{}, Ref{}, badRequest("issue reference required", nil) + } + return provenance, ref, nil +} + +func (s *Service) CheckDrift(ctx context.Context, user, source, externalKey string) (Drift, error) { + ctx, cancel := context.WithTimeout(ctx, importFetchTimeout) + defer cancel() + provenance, ref, err := s.authorizeDrift(user, source, externalKey) + if err != nil { + return Drift{}, err + } + issue, err := s.fetchIssue(ctx, privateRef(ref)) + if err != nil { + return Drift{}, err + } + checkedAt := time.Now().UTC().Format(time.RFC3339Nano) + current := store.NewImportBaseline(issue.Title, issue.Body, checkedAt) + baseline, present, err := s.resolveBaseline(user, externalKey, current) + if err != nil { + return Drift{}, storageFailure(err) + } + result := Drift{Link: provenance.Link, URL: provenance.URL, UpstreamTitle: issue.Title, CheckedAt: checkedAt} + if !present { + result.State, result.BaselineTitle, result.BaselineAt = "baseline_recorded", current.Title, current.At + return result, nil + } + changed := current.Title != baseline.Title + result.TitleChanged, result.BaselineTitle, result.BaselineAt = &changed, baseline.Title, baseline.At + if !changed && current.Hash == baseline.Hash { + result.State = "unchanged" + return result, nil + } + result.State = "drifted" + result.Revision = importDriftRevision(current) + result.Summary = s.driftSummary(ctx, user, baseline, current) + return result, nil +} + +func (s *Service) resolveBaseline(user, key string, current store.ImportBaseline) (store.ImportBaseline, bool, error) { + baseline, created, err := s.store.CreateImportBaseline(user, key, current) + return baseline, !created, err +} + +func (s *Service) driftSummary(ctx context.Context, user string, baseline, current store.ImportBaseline) string { + prompt := fmt.Sprintf("Summarize the material change in plain text. Do not invent details.\n\nBaseline title:\n%s\nBaseline excerpt:\n%s\n\nCurrent title:\n%s\nCurrent excerpt:\n%s", + truncateImportText(baseline.Title, maxImportCommentBytes), baseline.Excerpt, + truncateImportText(current.Title, maxImportCommentBytes), current.Excerpt) + result, err := s.runner.RunText(ctx, user, importDriftSummaryPrompt, truncateImportText(prompt, maxImportPackBytes), aiDriftMaxTokens) + if err != nil { + return "" + } + return truncateImportText(strings.TrimSpace(result), maxImportCommentBytes) +} + +func (s *Service) AcceptDrift(ctx context.Context, user, source, externalKey, revision string) (string, error) { + if !validImportDriftRevision(revision) { + return "", badRequest("invalid revision", nil) + } + ctx, cancel := context.WithTimeout(ctx, importFetchTimeout) + defer cancel() + _, ref, err := s.authorizeDrift(user, source, externalKey) + if err != nil { + return "", err + } + lock := s.importDriftLocks.get(user) + lock.Lock() + defer lock.Unlock() + baseline, present, err := s.store.ImportBaseline(user, externalKey) + if err != nil { + return "", storageFailure(err) + } + if !present { + return "", &Error{Code: http.StatusConflict, Message: "check again"} + } + if importDriftRevision(baseline) == revision { + return baseline.At, nil + } + issue, _, _, err := s.fetchIssueSnapshot(ctx, privateRef(ref)) + if err != nil { + return "", err + } + current := store.NewImportBaseline(issue.Title, issue.Body, time.Now().UTC().Format(time.RFC3339Nano)) + if importDriftRevision(current) != revision { + return "", fmt.Errorf("%w", ErrUpstreamChanged) + } + updated, err := s.store.CompareAndSwapImportBaseline(user, externalKey, baseline, current) + if err != nil { + return "", storageFailure(err) + } + if !updated { + latest, present, err := s.store.ImportBaseline(user, externalKey) + if err != nil { + return "", storageFailure(err) + } + if present && importDriftRevision(latest) == revision { + return latest.At, nil + } + return "", fmt.Errorf("%w", ErrUpstreamChanged) + } + return current.At, nil +} + +func badRequest(message string, cause error) error { + return &Error{Code: http.StatusBadRequest, Message: message, Cause: cause} +} +func storageFailure(cause error) error { + return &Error{Code: http.StatusInternalServerError, Message: storageErrorMessage, Cause: cause} +} diff --git a/internal/forge/compat.go b/internal/forge/compat.go new file mode 100644 index 0000000..a11af62 --- /dev/null +++ b/internal/forge/compat.go @@ -0,0 +1,91 @@ +package forge + +import ( + "fmt" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/RandomCodeSpace/kb/internal/ai" +) + +const ( + skillRunDeadline = 4 * time.Minute + storageErrorMessage = "storage error" + configuredSourceUnavailableMessage = "configured source unavailable" + connectionFailedMessage = "connection failed" +) + +func packImportIssues(issues []forgeIssue) (string, int) { + var packed strings.Builder + count := 0 + for index, issue := range issues { + if packed.Len() >= maxImportPackBytes { + break + } + comments := make([]string, 0, min(len(issue.Comments), 10)) + for _, comment := range issue.Comments { + if len(comments) == 10 { + break + } + comments = append(comments, truncateImportText(comment, maxImportCommentBytes)) + } + section := fmt.Sprintf("Source %d\nTitle: %s\nRef: %s\nLabels: %s\nBody:\n%s\nComments:\n%s\n\n", + index+1, issue.Title, issue.Ref, strings.Join(issue.Labels, ", "), + truncateImportText(issue.Body, maxImportIssueBodyBytes), strings.Join(comments, "\n")) + if len(section) > maxImportPackBytes-packed.Len() { + break + } + packed.WriteString(section) + count++ + } + return packed.String(), count +} + +func forgeIssueADR(issue forgeIssue) string { + adr := fmt.Sprintf("# %s\n\n%s", issue.Title, issue.Body) + discussion := "\n\n## Discussion" + if len(adr)+len(discussion) > 64<<10 { + return truncateImportText(adr, 64<<10) + } + adr += discussion + for _, comment := range issue.Comments { + item := "\n- " + comment + remaining := 64<<10 - len(adr) + if len(item) > remaining { + return adr + truncateImportText(item, remaining) + } + adr += item + } + return adr +} + +// IssueADR renders one bounded forge issue for the existing ADR split API. +func IssueADR(issue Issue) string { return forgeIssueADR(issue) } + +// StripLinkTags removes model-proposed forge identity tags before the server +// appends its authorized provenance. +func StripLinkTags(tags []string) []string { return stripModelLinkTags(tags) } + +func truncateImportText(text string, limit int) string { + if limit <= 0 { + return "" + } + for len(text) > limit { + _, size := utf8.DecodeLastRuneInString(text) + text = text[:len(text)-size] + } + return text +} + +func skillBudget(value int64) int64 { return ai.SkillBudget(value) } + +func logSafe(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, value) +} diff --git a/internal/forge/forge.go b/internal/forge/forge.go new file mode 100644 index 0000000..23ce8d0 --- /dev/null +++ b/internal/forge/forge.go @@ -0,0 +1,1024 @@ +// Package forge implements configured, guarded issue imports and upstream +// drift checks without requiring the optional HTTP server. +package forge + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/store" +) + +const ( + forgeTimeout = 20 * time.Second + maxForgeDrainBytes = 64 << 10 + maxForgeBodyBytes = 2 << 20 + maxImportIssues = 20 + maxForgeComments = 20 + maxForgeCommentLen = 1 << 10 + importFetchTimeout = 25 * time.Second + maxImportLinks = 100 + invalidIntegrationNameMessage = "invalid integration name" + linkTagPrefix = "link::" + importTagPrefix = "import::" + maxImportPackBytes = 48 << 10 + maxImportIssueBodyBytes = 16 << 10 + maxImportCommentBytes = 1 << 10 + maxForgePages = 20 + aiImportMaxTokens = 8192 + aiDriftMaxTokens = 1024 + importPackTruncationNote = "assistant input limit reached — some fetched issues produced no draft" +) + +// Error is a caller-safe failure category shared by local frontends and HTTP +// adapters. Cause is retained for errors.Is/As and logs, never display. +type Error struct { + Code int + Message string + Cause error +} + +func (e *Error) Error() string { return e.Message } +func (e *Error) Unwrap() error { return e.Cause } + +// Service owns the single guarded forge path used by the TUI and server. +type Service struct { + store *store.Store + runner *ai.Runner + forgeClient *http.Client + importDriftLocks boardLocks +} + +// New constructs the direct-store forge service. Nil collaborators select +// the guarded production clients. +func New(st *store.Store, runner *ai.Runner, client *http.Client) *Service { + if runner == nil { + runner = ai.NewRunner(st, "", nil, nil) + } + if client == nil { + client = NewHTTPClient() + } + return &Service{store: st, runner: runner, forgeClient: client} +} + +type boardLocks struct { + mu sync.Mutex + m map[string]*sync.Mutex +} + +func (l *boardLocks) get(user string) *sync.Mutex { + l.mu.Lock() + defer l.mu.Unlock() + if l.m == nil { + l.m = make(map[string]*sync.Mutex) + } + if l.m[user] == nil { + l.m[user] = new(sync.Mutex) + } + return l.m[user] +} + +type forgeTestProbe struct { + BaseURL *string `json:"base_url"` + PAT *string `json:"pat"` +} + +type forgeTestTarget struct { + baseURL string + pat string +} + +type forgeRef struct { + Source store.ForgeSource + Kind string + Project string + Issue int + Milestone int + + // pat is populated only after the request owner decrypts its selected source. + // Keeping it private prevents the parser and response paths from exposing it. + pat string +} + +type forgeIssue struct { + Ref string + Title string + Body string + URL string + Labels []string + Comments []string +} + +type forgeHTTPResponse struct { + status int + header http.Header + body []byte +} + +type gitLabIssue struct { + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + WebURL string `json:"web_url"` + Labels []string `json:"labels"` +} + +type gitHubIssue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + PullRequest json.RawMessage `json:"pull_request"` +} + +type gitLabNote struct { + Body string `json:"body"` + System bool `json:"system"` +} + +type gitHubComment struct { + Body string `json:"body"` +} + +// parseForgeRef accepts only configured forge URLs so later fetches can select +// the corresponding stored credential without ever following arbitrary hosts. +func parseForgeRef(sources []store.ForgeSource, sourceName, raw string) (forgeRef, error) { + raw = strings.TrimSpace(raw) + if isBareForgeProject(raw) { + source, ok := forgeSourceByName(sources, sourceName) + if !ok { + if sourceName == "" { + return forgeRef{}, errors.New("no configured source named") + } + return forgeRef{}, fmt.Errorf("no configured source named %s", sourceName) + } + if source.Kind != "github" { + return forgeRef{}, errors.New("bare reference requires GitHub source") + } + return forgeRef{Source: source, Kind: source.Kind, Project: raw}, nil + } + + u, err := url.ParseRequestURI(raw) + if err != nil || u.Scheme == "" || u.Hostname() == "" || u.User != nil || + (u.Scheme != "http" && u.Scheme != "https") { + return forgeRef{}, errors.New("invalid forge reference") + } + + source, path, ok := configuredForgeSource(sources, sourceName, u) + if !ok { + return forgeRef{}, fmt.Errorf("no configured source for host %s", u.Hostname()) + } + switch source.Kind { + case "gitlab": + return parseGitLabRef(source, path) + case "github": + return parseGitHubRef(source, path) + default: + return forgeRef{}, errors.New("invalid forge kind") + } +} + +func isBareForgeProject(raw string) bool { + if raw == "" || strings.ContainsAny(raw, ":?#") { + return false + } + parts := strings.Split(raw, "/") + return len(parts) == 2 && parts[0] != "" && parts[1] != "" +} + +func forgeSourceByName(sources []store.ForgeSource, name string) (store.ForgeSource, bool) { + for _, source := range sources { + if strings.EqualFold(source.Name, name) { + return source, true + } + } + return store.ForgeSource{}, false +} + +// configuredForgeSource compares origins and whole path segments rather than +// raw strings, so a source at /forge cannot authorize /forgeish by accident. +// Equal-length configured bases are disambiguated by the caller's source name; +// a longer base always wins regardless of that selection. +func configuredForgeSource(sources []store.ForgeSource, sourceName string, request *url.URL) (store.ForgeSource, string, bool) { + bestLength := -1 + var best store.ForgeSource + bestPath := "" + requestPath := strings.TrimRight(request.Path, "/") + for _, source := range sources { + base, err := normalizeForgeProbeBase(source.BaseURL) + if err != nil || !sameForgeOrigin(base, request) { + continue + } + basePath := strings.TrimRight(base.Path, "/") + var path string + switch { + case basePath == "": + path = strings.TrimPrefix(requestPath, "/") + case requestPath == basePath: + path = "" + case strings.HasPrefix(requestPath, basePath+"/"): + path = strings.TrimPrefix(requestPath, basePath+"/") + default: + continue + } + length := len(base.Scheme) + len(base.Host) + len(basePath) + if length > bestLength || (length == bestLength && strings.EqualFold(source.Name, sourceName)) { + bestLength = length + best = source + bestPath = path + } + } + return best, bestPath, bestLength >= 0 +} + +func sameForgeOrigin(a, b *url.URL) bool { + return strings.EqualFold(a.Scheme, b.Scheme) && + strings.EqualFold(a.Hostname(), b.Hostname()) && + forgeURLPort(a) == forgeURLPort(b) +} + +func forgeURLPort(u *url.URL) string { + if port := u.Port(); port != "" { + return port + } + if u.Scheme == "https" { + return "443" + } + return "80" +} + +func parseGitLabRef(source store.ForgeSource, path string) (forgeRef, error) { + parts, err := forgePathParts(path) + if err != nil { + return forgeRef{}, err + } + n := len(parts) + if n >= 3 && parts[n-3] == "-" { + return parseGitLabScopedRef(source, parts[:n-3], parts[n-2], parts[n-1]) + } + if n >= 3 && (parts[n-2] == "issues" || parts[n-2] == "milestones") { + ref, err := parseGitLabScopedRef(source, parts[:n-2], parts[n-2], parts[n-1]) + if err == nil { + return ref, nil + } + } + if slices.Contains(parts, "-") { + return forgeRef{}, errors.New("invalid forge reference") + } + return forgeRef{Source: source, Kind: source.Kind, Project: strings.Join(parts, "/")}, nil +} + +func parseGitLabScopedRef(source store.ForgeSource, projectParts []string, resource, rawID string) (forgeRef, error) { + project := strings.Join(projectParts, "/") + if project == "" { + return forgeRef{}, errors.New("invalid forge reference") + } + id, err := forgeRefID(rawID) + if err != nil { + return forgeRef{}, err + } + ref := forgeRef{Source: source, Kind: source.Kind, Project: project} + switch resource { + case "issues": + ref.Issue = id + case "milestones": + ref.Milestone = id + case "boards": + // Phase 1 resolves a board to its project; list and label filters are out of scope. + default: + return forgeRef{}, errors.New("invalid forge reference") + } + return ref, nil +} + +func parseGitHubRef(source store.ForgeSource, path string) (forgeRef, error) { + parts, err := forgePathParts(path) + if err != nil { + return forgeRef{}, err + } + if len(parts) == 2 { + return forgeRef{Source: source, Kind: source.Kind, Project: strings.Join(parts, "/")}, nil + } + if len(parts) != 4 || (parts[2] != "issues" && parts[2] != "milestone") { + return forgeRef{}, errors.New("invalid forge reference") + } + id, err := forgeRefID(parts[3]) + if err != nil { + return forgeRef{}, err + } + ref := forgeRef{Source: source, Kind: source.Kind, Project: strings.Join(parts[:2], "/")} + if parts[2] == "issues" { + ref.Issue = id + } else { + ref.Milestone = id + } + return ref, nil +} + +func forgePathParts(path string) ([]string, error) { + path = strings.Trim(path, "/") + if path == "" { + return nil, errors.New("invalid forge reference") + } + parts := strings.Split(path, "/") + if slices.Contains(parts, "") { + return nil, errors.New("invalid forge reference") + } + return parts, nil +} + +func forgeRefID(raw string) (int, error) { + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + return 0, errors.New("invalid forge reference") + } + return id, nil +} + +// fetchIssue loads one forge issue and its bounded human discussion for the +// import pipeline. The caller already chose the configured source in D1. +func (s *Service) fetchIssue(ctx context.Context, ref forgeRef) (forgeIssue, error) { + issue, apiBase, issuePath, err := s.fetchIssueSnapshot(ctx, ref) + if err != nil { + return forgeIssue{}, err + } + comments, err := s.fetchForgeComments(ctx, ref, apiBase, issuePath) + if err != nil { + return forgeIssue{}, err + } + issue.Comments = comments + return issue, nil +} + +// fetchIssueSnapshot reads only the issue record. Acceptance needs no +// discussion, so keeping this distinct prevents an unnecessary second egress. +func (s *Service) fetchIssueSnapshot(ctx context.Context, ref forgeRef) (forgeIssue, string, string, error) { + if ref.Issue <= 0 { + return forgeIssue{}, "", "", forgeRequestError(ref, "") + } + apiBase, err := forgeAPIBase(ref.Kind, ref.Source.BaseURL) + if err != nil { + return forgeIssue{}, "", "", forgeRequestError(ref, "") + } + issuePath, err := forgeIssuePath(ref) + if err != nil { + return forgeIssue{}, "", "", forgeRequestError(ref, "") + } + response, err := s.forgeGet(ctx, ref, apiBase, issuePath, nil) + if err != nil { + return forgeIssue{}, "", "", err + } + if response.status < http.StatusOK || response.status >= http.StatusMultipleChoices { + return forgeIssue{}, "", "", forgeRequestError(ref, issuePath) + } + + var issue forgeIssue + switch ref.Kind { + case "gitlab": + var raw gitLabIssue + if err := json.Unmarshal(response.body, &raw); err != nil { + return forgeIssue{}, "", "", forgeRequestError(ref, issuePath) + } + issue = forgeIssue{Ref: fmt.Sprintf("gitlab#%d", raw.IID), Title: raw.Title, Body: raw.Description, URL: raw.WebURL, Labels: raw.Labels} + case "github": + var raw gitHubIssue + if err := json.Unmarshal(response.body, &raw); err != nil || len(raw.PullRequest) != 0 { + return forgeIssue{}, "", "", forgeRequestError(ref, issuePath) + } + issue = gitHubForgeIssue(raw) + default: + return forgeIssue{}, "", "", forgeRequestError(ref, issuePath) + } + return issue, apiBase, issuePath, nil +} + +// fetchIssues loads open project, board, or milestone issues. Board filtering +// deliberately remains out of scope: D1 resolves boards to their project. +func (s *Service) fetchIssues(ctx context.Context, ref forgeRef, max int) (issues []forgeIssue, totalHint int, truncated bool, note string, err error) { + if ref.Issue > 0 { + issue, err := s.fetchIssue(ctx, ref) + if err != nil { + return nil, 0, false, "", err + } + return []forgeIssue{issue}, 1, false, "", nil + } + if max <= 0 || max > maxImportIssues { + max = maxImportIssues + } + apiBase, err := forgeAPIBase(ref.Kind, ref.Source.BaseURL) + if err != nil { + return nil, 0, false, "", forgeRequestError(ref, "") + } + listPath, query, err := s.forgeIssuesList(ctx, ref, apiBase) + if err != nil { + return nil, 0, false, "", err + } + return s.fetchIssuePages(ctx, ref, apiBase, listPath, query, max) +} + +type forgeIssuePageState struct { + issues []forgeIssue + totalHint int + fallbackTotal int + hasGitLabTotal bool + truncated bool + note string +} + +func (s *Service) fetchIssuePages(ctx context.Context, ref forgeRef, apiBase, listPath string, query url.Values, max int) ([]forgeIssue, int, bool, string, error) { + state := forgeIssuePageState{} + page := 1 + for { + if page > 1 { + query.Set("page", strconv.Itoa(page)) + } + response, err := s.forgeGet(ctx, ref, apiBase, listPath, query) + if err != nil { + return nil, 0, false, "", err + } + state.observeTotal(ref.Kind, response.header) + if forgeRateLimited(ref.Kind, response) { + state.markRateLimited() + return state.result() + } + if response.status < http.StatusOK || response.status >= http.StatusMultipleChoices { + return nil, 0, false, "", forgeRequestError(ref, listPath) + } + + batch, err := parseForgeIssueList(ref.Kind, response.body) + if err != nil { + return nil, 0, false, "", forgeRequestError(ref, listPath) + } + if state.appendBatch(batch, max, forgeHasNextPage(ref.Kind, response.header)) { + break + } + if page >= maxForgePages { + state.truncated = true + state.note = appendImportNote(state.note, "upstream pagination limit reached — partial results") + break + } + page++ + } + return state.result() +} + +func (state *forgeIssuePageState) observeTotal(kind string, header http.Header) { + if kind != "gitlab" { + return + } + total := forgeTotalHint(header) + if total >= 0 { + state.totalHint = total + state.hasGitLabTotal = true + } +} + +func (state *forgeIssuePageState) markRateLimited() { + if !state.hasGitLabTotal { + state.totalHint = state.fallbackTotal + } + state.truncated = true + state.note = fmt.Sprintf("rate limited — partial results (%d of %d)", len(state.issues), state.totalHint) +} + +func (state *forgeIssuePageState) appendBatch(batch []forgeIssue, max int, hasNext bool) bool { + state.fallbackTotal += len(batch) + remaining := max - len(state.issues) + if len(batch) > remaining { + state.issues = append(state.issues, batch[:remaining]...) + state.truncated = true + return true + } + state.issues = append(state.issues, batch...) + if len(state.issues) >= max { + state.truncated = hasNext + return true + } + return !hasNext +} + +func (state *forgeIssuePageState) result() ([]forgeIssue, int, bool, string, error) { + if !state.hasGitLabTotal { + state.totalHint = state.fallbackTotal + } + if state.totalHint > len(state.issues) { + state.truncated = true + } + return state.issues, state.totalHint, state.truncated, state.note, nil +} + +func (s *Service) forgeIssuesList(ctx context.Context, ref forgeRef, apiBase string) (string, url.Values, error) { + listPath, query, err := forgeIssueListRequest(ref) + if err != nil { + return "", nil, forgeRequestError(ref, listPath) + } + if ref.Milestone == 0 { + return listPath, query, nil + } + milestonePath, err := forgeMilestonePath(ref) + if err != nil { + return "", nil, forgeRequestError(ref, "") + } + response, err := s.forgeGet(ctx, ref, apiBase, milestonePath, nil) + if err != nil { + return "", nil, err + } + if response.status < http.StatusOK || response.status >= http.StatusMultipleChoices { + return "", nil, forgeRequestError(ref, milestonePath) + } + if !setForgeMilestoneQuery(ref.Kind, query, response.body) { + return "", nil, forgeRequestError(ref, milestonePath) + } + return listPath, query, nil +} + +func forgeIssueListRequest(ref forgeRef) (string, url.Values, error) { + listPath, err := forgeProjectIssuesPath(ref) + if err != nil { + return "", nil, err + } + query := url.Values{"per_page": {"50"}} + switch ref.Kind { + case "gitlab": + query.Set("state", "opened") + case "github": + query.Set("state", "open") + default: + return listPath, nil, errors.New("invalid forge kind") + } + return listPath, query, nil +} + +func setForgeMilestoneQuery(kind string, query url.Values, body []byte) bool { + switch kind { + case "gitlab": + var milestone struct { + Title string `json:"title"` + } + if err := json.Unmarshal(body, &milestone); err != nil || milestone.Title == "" { + return false + } + query.Set("milestone", milestone.Title) + case "github": + var milestone struct { + Number int `json:"number"` + } + if err := json.Unmarshal(body, &milestone); err != nil || milestone.Number <= 0 { + return false + } + query.Set("milestone", strconv.Itoa(milestone.Number)) + default: + return false + } + return true +} + +func (s *Service) fetchForgeComments(ctx context.Context, ref forgeRef, apiBase, issuePath string) ([]string, error) { + commentsPath := issuePath + "/comments" + if ref.Kind == "gitlab" { + commentsPath = issuePath + "/notes" + } + response, err := s.forgeGet(ctx, ref, apiBase, commentsPath, url.Values{"per_page": {"50"}}) + if err != nil { + return nil, err + } + if response.status < http.StatusOK || response.status >= http.StatusMultipleChoices { + return nil, forgeRequestError(ref, commentsPath) + } + + comments, err := parseForgeComments(ref.Kind, response.body) + if err != nil { + return nil, forgeRequestError(ref, commentsPath) + } + return comments, nil +} + +func parseForgeComments(kind string, body []byte) ([]string, error) { + comments := make([]string, 0, maxForgeComments) + switch kind { + case "gitlab": + var notes []gitLabNote + if err := json.Unmarshal(body, ¬es); err != nil { + return nil, err + } + for _, note := range notes { + if !note.System { + comments = appendBoundedForgeComment(comments, note.Body) + } + } + case "github": + var raw []gitHubComment + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + for _, comment := range raw { + comments = appendBoundedForgeComment(comments, comment.Body) + } + default: + return nil, errors.New("invalid forge kind") + } + return comments, nil +} + +func appendBoundedForgeComment(comments []string, body string) []string { + if len(comments) >= maxForgeComments { + return comments + } + for len(body) > maxForgeCommentLen { + _, size := utf8.DecodeLastRuneInString(body) + body = body[:len(body)-size] + } + return append(comments, body) +} + +func forgeIssuePath(ref forgeRef) (string, error) { + projectPath, err := forgeProjectPath(ref) + if err != nil { + return "", err + } + return projectPath + "/issues/" + strconv.Itoa(ref.Issue), nil +} + +func forgeMilestonePath(ref forgeRef) (string, error) { + projectPath, err := forgeProjectPath(ref) + if err != nil { + return "", err + } + return projectPath + "/milestones/" + strconv.Itoa(ref.Milestone), nil +} + +func forgeProjectIssuesPath(ref forgeRef) (string, error) { + projectPath, err := forgeProjectPath(ref) + if err != nil { + return "", err + } + return projectPath + "/issues", nil +} + +func forgeProjectPath(ref forgeRef) (string, error) { + if ref.Project == "" { + return "", errors.New("invalid forge project") + } + switch ref.Kind { + case "gitlab": + return "/projects/" + url.PathEscape(ref.Project), nil + case "github": + parts := strings.Split(ref.Project, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", errors.New("invalid forge project") + } + return "/repos/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1]), nil + default: + return "", errors.New("invalid forge kind") + } +} + +func (s *Service) forgeGet(ctx context.Context, ref forgeRef, apiBase, path string, query url.Values) (forgeHTTPResponse, error) { + endpoint := strings.TrimRight(apiBase, "/") + path + if len(query) > 0 { + endpoint += "?" + query.Encode() + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return forgeHTTPResponse{}, forgeRequestError(ref, path) + } + switch ref.Kind { + case "gitlab": + if ref.pat != "" { + request.Header.Set("PRIVATE-TOKEN", ref.pat) + } + case "github": + if ref.pat != "" { + request.Header.Set("Authorization", "Bearer "+ref.pat) + } + request.Header.Set("Accept", "application/vnd.github+json") + default: + return forgeHTTPResponse{}, forgeRequestError(ref, path) + } + if s.forgeClient == nil { + return forgeHTTPResponse{}, forgeRequestError(ref, path) + } + response, err := s.forgeClient.Do(request) + if err != nil { + return forgeHTTPResponse{}, forgeRequestError(ref, path) + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, maxForgeBodyBytes)) + closeErr := response.Body.Close() + if readErr != nil || closeErr != nil { + return forgeHTTPResponse{}, forgeRequestError(ref, path) + } + return forgeHTTPResponse{status: response.StatusCode, header: response.Header.Clone(), body: body}, nil +} + +// The name and path are already constrained (names match ^[a-z0-9._-]{1,64}$ and +// paths are url.PathEscape'd), but both are stripped anyway so no future caller +// can turn this line into a log-forging primitive. +func forgeRequestError(ref forgeRef, path string) error { + log.Printf("forge: request failed source=%s path=%s", logSafe(ref.Source.Name), logSafe(path)) + return &Error{Code: http.StatusBadGateway, Message: "forge request failed"} +} + +func forgeTotalHint(header http.Header) int { + total, err := strconv.Atoi(header.Get("X-Total")) + if err != nil || total < 0 { + return -1 + } + return total +} + +func forgeRateLimited(kind string, response forgeHTTPResponse) bool { + return response.status == http.StatusTooManyRequests || + (kind == "github" && response.status == http.StatusForbidden && response.header.Get("X-RateLimit-Remaining") == "0") +} + +func forgeHasNextPage(kind string, header http.Header) bool { + if kind == "gitlab" { + return header.Get("X-Next-Page") != "" + } + return strings.Contains(header.Get("Link"), "rel=\"next\"") +} + +func parseForgeIssueList(kind string, body []byte) ([]forgeIssue, error) { + switch kind { + case "gitlab": + var raw []gitLabIssue + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + issues := make([]forgeIssue, 0, len(raw)) + for _, issue := range raw { + issues = append(issues, forgeIssue{Ref: fmt.Sprintf("gitlab#%d", issue.IID), Title: issue.Title, Body: issue.Description, URL: issue.WebURL, Labels: issue.Labels}) + } + return issues, nil + case "github": + var raw []gitHubIssue + if err := json.Unmarshal(body, &raw); err != nil { + return nil, err + } + issues := make([]forgeIssue, 0, len(raw)) + for _, issue := range raw { + if len(issue.PullRequest) == 0 { + issues = append(issues, gitHubForgeIssue(issue)) + } + } + return issues, nil + default: + return nil, errors.New("invalid forge kind") + } +} + +func gitHubForgeIssue(issue gitHubIssue) forgeIssue { + labels := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + if label.Name != "" { + labels = append(labels, label.Name) + } + } + return forgeIssue{Ref: fmt.Sprintf("github#%d", issue.Number), Title: issue.Title, Body: issue.Body, URL: issue.HTMLURL, Labels: labels} +} + +func newForgeClient() *http.Client { return NewHTTPClient() } + +func validForgeSourceName(name string) bool { + name = strings.ToLower(name) + if len(name) == 0 || len(name) > 64 { + return false + } + for i := 0; i < len(name); i++ { + c := name[i] + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && + c != '.' && c != '_' && c != '-' { + return false + } + } + return true +} + +func normalizeForgeProbeBase(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("invalid forge base URL") + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + return nil, errors.New("invalid forge base URL") + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, errors.New("forge base URL scheme must be http or https") + } + if u.User != nil { + return nil, errors.New("forge base URL must not contain userinfo") + } + if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return nil, errors.New("forge base URL must not contain query or fragment") + } + return u, nil +} + +// forgeAPIBase derives the stable REST prefix for each supported forge while +// preserving enterprise installations mounted below a path prefix. +func forgeAPIBase(kind, baseURL string) (string, error) { + if kind != "gitlab" && kind != "github" { + return "", errors.New("invalid forge kind") + } + u, err := normalizeForgeProbeBase(baseURL) + if err != nil { + return "", err + } + if kind == "github" && strings.EqualFold(u.Hostname(), "github.com") { + return "https://api.github.com", nil + } + + u.Path = strings.TrimRight(u.Path, "/") + u.RawPath = "" + if kind == "gitlab" { + u.Path += "/api/v4" + } else { + u.Path += "/api/v3" + } + return u.String(), nil +} + +// importTransformSkillName is the skill the import preview runs. The endpoint +// keeps its own request and response shape; only the way the drafts are +// produced is shared with the other skill callers. +const importTransformSkillName = "import-transform" + +// importPartialTransformNote tells the caller the draft list is short because +// the run ran out of room, not because the issues were judged noise. +const importPartialTransformNote = "the assistant stopped early — some issues produced no draft" + +// appendImportNote joins a second note onto whatever fetchIssues already said, +// so a rate-limited fetch and a truncated transform can both be reported. +func appendImportNote(note, extra string) string { + if note == "" { + return extra + } + return note + "; " + extra +} + +// handleImportPreview transforms a bounded, configured forge selection once; +// it never writes cards or provenance, which remain an explicit later commit. +func importDriftRevision(baseline store.ImportBaseline) string { + sum := sha256.Sum256([]byte(baseline.Title + "\x00" + baseline.Hash)) + return fmt.Sprintf("%x", sum) +} + +func validImportDriftRevision(revision string) bool { + if len(revision) != sha256.Size*2 { + return false + } + for i := 0; i < len(revision); i++ { + if (revision[i] < '0' || revision[i] > '9') && (revision[i] < 'a' || revision[i] > 'f') { + return false + } + } + return true +} + +// importDriftSummaryPrompt is the whole instruction the drift summary gets. +// The run carries no tools: it compares two pieces of text the caller already +// holds, so a tool would only be another way for third-party issue text to +// reach the board. +const importDriftSummaryPrompt = "Summarize an imported issue change using only the supplied titles and excerpts." + +// importDriftSummary is best-effort prose about what changed upstream. Every +// failure — no configuration, a bad endpoint, an upstream that never answers — +// degrades to no summary, because a drift comparison the caller asked for is +// valid without one. One run is one round trip: the loop is capped at a single +// iteration, and a toolless request cannot ask for a second. +func stripModelLinkTags(tags []string) []string { + filtered := make([]string, 0, len(tags)) + for _, tag := range tags { + if !strings.HasPrefix(tag, linkTagPrefix) && !strings.HasPrefix(tag, importTagPrefix) { + filtered = append(filtered, tag) + } + } + return filtered +} + +func importIssueProvenance(ref forgeRef, issue forgeIssue) (link, externalKey string) { + link = issue.Ref + issueID := strings.TrimPrefix(link, ref.Kind+"#") + identity := strings.ToLower(strings.TrimSpace(ref.Source.Name)) + if base, err := url.Parse(ref.Source.BaseURL); err == nil { + origin := strings.ToLower(base.Scheme) + "://" + strings.ToLower(base.Host) + identity += "@" + origin + strings.TrimRight(base.EscapedPath(), "/") + } + externalKey = fmt.Sprintf("%s:%s/%s#%s", ref.Kind, identity, ref.Project, issueID) + return link, externalKey +} + +func resolveForgeTestTarget(storedBase, storedPAT string, probe forgeTestProbe) (forgeTestTarget, error) { + target := forgeTestTarget{baseURL: storedBase, pat: storedPAT} + suppliedBase := trimmedForgeProbeValue(probe.BaseURL) + if suppliedBase != "" { + normalized, err := normalizeForgeProbeBase(suppliedBase) + if err != nil { + return forgeTestTarget{}, err + } + target.baseURL = normalized.String() + } + suppliedPAT := trimmedForgeProbeValue(probe.PAT) + if suppliedPAT != "" { + target.pat = suppliedPAT + } + if suppliedPAT == "" && suppliedBase != "" && storedPAT != "" && + !store.SameAIOrigin(storedBase, target.baseURL) { + return forgeTestTarget{}, errors.New("enter the token to test a different endpoint") + } + return target, nil +} + +func trimmedForgeProbeValue(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + +func newForgeTestRequest(ctx context.Context, kind string, target forgeTestTarget, project string) (*http.Request, error) { + apiBase, err := forgeAPIBase(kind, target.baseURL) + if err != nil { + return nil, err + } + project = strings.TrimSpace(project) + endpoint := apiBase + if project != "" { + projectPath, err := forgeProjectPath(forgeRef{Kind: kind, Project: project}) + if err != nil { + return nil, err + } + endpoint += projectPath + } else { + switch kind { + case "gitlab": + endpoint += "/version" + case "github": + endpoint += "/user" + default: + return nil, errors.New("invalid forge kind") + } + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, errors.New("invalid forge base URL") + } + setForgeTestHeaders(request, kind, target.pat) + return request, nil +} + +func setForgeTestHeaders(request *http.Request, kind, pat string) { + if kind == "gitlab" && pat != "" { + request.Header.Set("PRIVATE-TOKEN", pat) + } + if kind == "github" { + if pat != "" { + request.Header.Set("Authorization", "Bearer "+pat) + } + request.Header.Set("Accept", "application/vnd.github+json") + } +} + +func executeForgeTest(client *http.Client, request *http.Request) error { + response, err := client.Do(request) + if err != nil { + return err + } + if err := drainForgeResponse(response); err != nil { + return fmt.Errorf("close response: %w", err) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("upstream status %d", response.StatusCode) + } + return nil +} + +func drainForgeResponse(response *http.Response) error { + _, readErr := io.Copy(io.Discard, io.LimitReader(response.Body, maxForgeDrainBytes)) + closeErr := response.Body.Close() + if readErr != nil || closeErr != nil { + return fmt.Errorf("read: %v; close: %v", readErr, closeErr) + } + return nil +} diff --git a/internal/forge/forge_test.go b/internal/forge/forge_test.go new file mode 100644 index 0000000..62bf941 --- /dev/null +++ b/internal/forge/forge_test.go @@ -0,0 +1,1377 @@ +package forge + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +type failingReadCloser struct { + readErr error + closeErr error +} + +func (f failingReadCloser) Read([]byte) (int, error) { + if f.readErr == nil { + return 0, io.EOF + } + return 0, f.readErr +} +func (f failingReadCloser) Close() error { return f.closeErr } + +func testStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "kb.db"), []byte("test-secret")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func TestReferenceParsingAndPaths(t *testing.T) { + sources := []store.ForgeSource{ + {Name: "github", Kind: "github", BaseURL: "https://github.com"}, + {Name: "gitlab", Kind: "gitlab", BaseURL: "https://gitlab.example/forge"}, + } + tests := []struct { + source, raw, kind, project string + issue, milestone int + }{ + {"github", "owner/repo", "github", "owner/repo", 0, 0}, + {"github", "https://github.com/owner/repo/issues/7", "github", "owner/repo", 7, 0}, + {"github", "https://github.com/owner/repo/milestone/3", "github", "owner/repo", 0, 3}, + {"gitlab", "https://gitlab.example/forge/group/sub/project/-/issues/8", "gitlab", "group/sub/project", 8, 0}, + {"gitlab", "https://gitlab.example/forge/group/sub/project/-/milestones/4", "gitlab", "group/sub/project", 0, 4}, + } + for _, test := range tests { + ref, err := parseForgeRef(sources, test.source, test.raw) + if err != nil || ref.Kind != test.kind || ref.Project != test.project || ref.Issue != test.issue || ref.Milestone != test.milestone { + t.Errorf("parse %q = %+v, %v", test.raw, ref, err) + } + } + invalid := []string{"", "ftp://github.com/owner/repo", "https://evil.example/owner/repo", "https://github.com/owner", "https://github.com/owner/repo/issues/0", "https://github.com/owner/repo/issues/x"} + for _, raw := range invalid { + if _, err := parseForgeRef(sources, "github", raw); err == nil { + t.Errorf("parseForgeRef(%q) succeeded", raw) + } + } + if _, err := parseGitHubRef(sources[0], ""); err == nil { + t.Fatal("empty GitHub path accepted") + } + if _, err := parseGitLabRef(sources[1], "group/-/x"); err == nil { + t.Fatal("unscoped GitLab dash accepted") + } + if _, err := parseForgeRef(sources, "gitlab", "https://gitlab.example/forge"); err == nil { + t.Fatal("source root accepted as a project") + } + github := forgeRef{Kind: "github", Project: "owner/repo", Issue: 7, Milestone: 3} + gitlab := forgeRef{Kind: "gitlab", Project: "group/project", Issue: 8, Milestone: 4} + for name, call := range map[string]func() (string, error){ + "gh project": func() (string, error) { return forgeProjectPath(github) }, + "gh issue": func() (string, error) { return forgeIssuePath(github) }, + "gh milestone": func() (string, error) { return forgeMilestonePath(github) }, + "gl project": func() (string, error) { return forgeProjectPath(gitlab) }, + "gl issue": func() (string, error) { return forgeIssuePath(gitlab) }, + "gl milestone": func() (string, error) { return forgeMilestonePath(gitlab) }, + } { + if value, err := call(); err != nil || value == "" { + t.Errorf("%s = %q, %v", name, value, err) + } + } + for _, ref := range []forgeRef{{Kind: "other", Project: "x"}, {Kind: "github", Project: "owner"}} { + if _, err := forgeProjectPath(ref); err == nil { + t.Errorf("invalid project path %+v", ref) + } + } + if id, err := forgeRefID("42"); err != nil || id != 42 { + t.Fatalf("forgeRefID = %d, %v", id, err) + } + for _, raw := range []string{"", "0", "-1", "x"} { + if _, err := forgeRefID(raw); err == nil { + t.Errorf("forgeRefID(%q) accepted", raw) + } + } +} + +func TestGuardedClientAndRedirectPolicy(t *testing.T) { + var hits atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + t.Setenv("KB_FORGE_ALLOW_PRIVATE", "") + blocked := NewHTTPClient() + if blocked.Timeout != forgeTimeout { + t.Fatalf("timeout = %v", blocked.Timeout) + } + if response, err := blocked.Get(upstream.URL); err == nil { + response.Body.Close() + t.Fatal("loopback reached without allowlist") + } + t.Setenv("KB_FORGE_ALLOW_PRIVATE", "127.0.0.1") + allowed := NewHTTPClient() + response, err := allowed.Get(upstream.URL) + if err != nil { + t.Fatalf("allowlisted get: %v", err) + } + response.Body.Close() + if hits.Load() != 1 { + t.Fatalf("hits = %d", hits.Load()) + } + base, _ := url.Parse("https://forge.example/start") + same, _ := http.NewRequest(http.MethodGet, "https://forge.example/next", nil) + cross, _ := http.NewRequest(http.MethodGet, "https://evil.example/next", nil) + down, _ := http.NewRequest(http.MethodGet, "http://forge.example/next", nil) + credential, _ := http.NewRequest(http.MethodGet, "https://user@forge.example/next", nil) + via := []*http.Request{{URL: base}} + if sameHostRedirect(same, via) != nil || sameHostRedirect(cross, via) == nil || sameHostRedirect(down, via) == nil || sameHostRedirect(credential, via) == nil { + t.Fatal("redirect policy mismatch") + } + ftp, _ := http.NewRequest(http.MethodGet, "ftp://forge.example/next", nil) + ten := make([]*http.Request, 10) + for index := range ten { + ten[index] = &http.Request{URL: base} + } + if sameHostRedirect(ftp, via) == nil || sameHostRedirect(same, ten) == nil { + t.Fatal("redirect bounds mismatch") + } + transport := guardedTransport(nil, false) + if _, err := transport.DialContext(context.Background(), "tcp", "invalid"); err == nil { + t.Fatal("invalid dial address accepted") + } + if got := normalizeGuardHost("[::1]"); got != "::1" { + t.Fatalf("normalized host = %q", got) + } +} + +func TestFetchGitHubAndGitLabIssues(t *testing.T) { + var githubAuth, gitlabAuth string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.EscapedPath() { + case "/api/v3/repos/owner/repo/issues/7": + githubAuth = r.Header.Get("Authorization") + _, _ = io.WriteString(w, `{"number":7,"title":"GitHub title","body":"body","html_url":"https://github.test/7","labels":[{"name":"bug"}]}`) + case "/api/v3/repos/owner/repo/issues/7/comments": + _, _ = io.WriteString(w, `[{"body":"one"},{"body":"two"}]`) + case "/api/v4/projects/group%2Fproject/issues/8": + gitlabAuth = r.Header.Get("PRIVATE-TOKEN") + _, _ = io.WriteString(w, `{"iid":8,"title":"GitLab title","description":"body","web_url":"https://gitlab.test/8","labels":["feature"]}`) + case "/api/v4/projects/group%2Fproject/issues/8/notes": + _, _ = io.WriteString(w, `[{"body":"note","system":false},{"body":"ignored","system":true}]`) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + service := New(testStore(t), nil, upstream.Client()) + github := forgeRef{Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: upstream.URL}, Kind: "github", Project: "owner/repo", Issue: 7, pat: "gh-token"} + gitlab := forgeRef{Source: store.ForgeSource{Name: "gl", Kind: "gitlab", BaseURL: upstream.URL}, Kind: "gitlab", Project: "group/project", Issue: 8, pat: "gl-token"} + for _, ref := range []forgeRef{github, gitlab} { + issue, err := service.fetchIssue(context.Background(), ref) + if err != nil || issue.Title == "" || len(issue.Comments) != 1 && ref.Kind == "gitlab" || len(issue.Comments) != 2 && ref.Kind == "github" { + t.Fatalf("fetch %+v = %+v, %v", ref, issue, err) + } + } + if githubAuth != "Bearer gh-token" || gitlabAuth != "gl-token" { + t.Fatalf("auth github=%q gitlab=%q", githubAuth, gitlabAuth) + } + for _, ref := range []forgeRef{{Kind: "other"}, {Kind: "github", Source: store.ForgeSource{BaseURL: ":"}, Project: "owner/repo", Issue: 1}} { + if _, err := service.fetchIssue(context.Background(), ref); err == nil { + t.Errorf("invalid fetch %+v succeeded", ref) + } + } +} + +func TestFetchPaginationTruncationAndRateLimit(t *testing.T) { + var calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + page := r.URL.Query().Get("page") + if page == "" || page == "1" { + w.Header().Set("X-Total", "3") + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `[{"number":1,"title":"one"},{"number":2,"title":"two","pull_request":{}}]`) + return + } + _, _ = io.WriteString(w, `[{"number":3,"title":"three"}]`) + })) + defer upstream.Close() + service := New(testStore(t), nil, upstream.Client()) + ref := forgeRef{Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: upstream.URL}, Kind: "github", Project: "owner/repo"} + issues, total, truncated, note, err := service.fetchIssues(context.Background(), ref, 2) + if err != nil || len(issues) != 2 || total != 2 || truncated || note != "" || calls.Load() != 2 { + t.Fatalf("pages = %d/%d truncated=%t note=%q calls=%d err=%v", len(issues), total, truncated, note, calls.Load(), err) + } + state := forgeIssuePageState{} + state.observeTotal("github", http.Header{"X-Total": []string{"bad"}}) + state.markRateLimited() + if _, _, truncated, note, err := state.result(); err != nil || !truncated || note == "" { + t.Fatalf("rate state = %t %q %v", truncated, note, err) + } + if forgeTotalHint(http.Header{}) != -1 || !forgeHasNextPage("gitlab", http.Header{"X-Next-Page": []string{"2"}}) { + t.Fatal("pagination helpers") + } +} + +func TestFetchPaginationHasFiniteNoProgressCap(t *testing.T) { + var calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `[{"number":1,"title":"pull request","pull_request":{}}]`) + })) + defer upstream.Close() + service := New(testStore(t), nil, upstream.Client()) + ref := forgeRef{Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: upstream.URL}, Kind: "github", Project: "owner/repo"} + issues, _, truncated, note, err := service.fetchIssues(context.Background(), ref, 1) + if err != nil || len(issues) != 0 || !truncated || !strings.Contains(note, "pagination limit") || calls.Load() != maxForgePages { + t.Fatalf("capped pages = issues=%d truncated=%t note=%q calls=%d err=%v", len(issues), truncated, note, calls.Load(), err) + } +} + +type scriptedAI struct { + calls int + partial bool +} + +func (s *scriptedAI) handler(w http.ResponseWriter, r *http.Request) { + s.calls++ + w.Header().Set("Content-Type", "application/json") + message := map[string]any{"role": "assistant", "content": "done"} + if s.calls == 1 { + message["content"] = "" + message["tool_calls"] = []any{map[string]any{ + "id": "call-1", "type": "function", + "function": map[string]any{"name": "propose_card", "arguments": `{"title":"Imported card","source":1,"prio":2}`}, + }} + } + finish := "stop" + if s.partial && s.calls == 2 { + finish = "length" + } + _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": finish}}}) +} + +func TestServicePreviewProvenanceAndDuplicateDefaults(t *testing.T) { + forgeUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.EscapedPath() { + case "/api/v3/repos/owner/repo/issues": + _, _ = io.WriteString(w, `[{"number":93,"title":"Upstream issue","body":"body","html_url":"https://github.test/owner/repo/issues/93"}]`) + case "/api/v3/repos/owner/repo/issues/93": + _, _ = io.WriteString(w, `{"number":93,"title":"Upstream issue","body":"body","html_url":"https://github.test/owner/repo/issues/93"}`) + case "/api/v3/repos/owner/repo/issues/93/comments": + _, _ = io.WriteString(w, `[]`) + default: + http.NotFound(w, r) + } + })) + defer forgeUpstream.Close() + model := &scriptedAI{} + aiUpstream := httptest.NewServer(http.HandlerFunc(model.handler)) + defer aiUpstream.Close() + t.Setenv("KB_AI_ALLOW_PRIVATE", "1") + st := testStore(t) + base, token := forgeUpstream.URL, "forge-token" + if _, err := st.SetForgeSource("alice", "primary", "github", &base, &token); err != nil { + t.Fatal(err) + } + aiBase, aiModel, aiKey := aiUpstream.URL, "gpt-4o", "ai-key" + if _, err := st.SetAISettings("alice", &aiBase, &aiModel, &aiKey); err != nil { + t.Fatal(err) + } + runner := ai.NewRunner(st, "", aiUpstream.Client(), nil) + service := New(st, runner, forgeUpstream.Client()) + if sources, err := service.Sources("alice"); err != nil || len(sources) != 1 { + t.Fatalf("sources = %+v, %v", sources, err) + } + resolved, link, rawURL, err := service.ResolveIssueDocument(context.Background(), "alice", "primary", forgeUpstream.URL+"/owner/repo/issues/93") + if err != nil || resolved.Title == "" || link != "github#93" || rawURL == "" { + t.Fatalf("resolved = %+v %q %q, %v", resolved, link, rawURL, err) + } + preview, err := service.Preview(context.Background(), "alice", PreviewRequest{Source: "primary", Ref: "owner/repo", Max: 1}) + if err != nil || len(preview.Drafts) != 1 || preview.Drafts[0].ExternalKey == "" || preview.Drafts[0].Link != "github#93" || model.calls != 2 { + t.Fatalf("preview = %+v calls=%d err=%v cause=%v", preview, model.calls, err, errors.Unwrap(err)) + } + model.calls, model.partial = 0, true + partial, err := service.Preview(context.Background(), "alice", PreviewRequest{Source: "primary", Ref: "owner/repo", Max: 1}) + if err != nil || len(partial.Drafts) != 1 || !strings.Contains(partial.Note, "stopped early") { + t.Fatalf("partial preview = %+v, %v", partial, err) + } + model.partial = false + draft := preview.Drafts[0] + if err := service.RecordLinks("alice", "primary", []LinkInput{{ExternalKey: draft.ExternalKey, Link: draft.Link, URL: draft.URL, Title: draft.Title}}); err != nil { + t.Fatal(err) + } + links, err := service.Provenance("alice", draft.Link) + if err != nil || len(links) != 1 || links[0].ExternalKey != draft.ExternalKey { + t.Fatalf("provenance = %+v, %v", links, err) + } + if _, err := st.AddTask("alice", board.Task{Title: draft.Title, Tags: append([]string(nil), draft.Tags...)}); err != nil { + t.Fatal(err) + } + authorized, err := service.authorizeRef("alice", "primary", "owner/repo") + if err != nil { + t.Fatal(err) + } + issues := []forgeIssue{{Ref: draft.Link, Title: draft.Title}} + duplicates, err := service.duplicates("alice", authorized, issues) + if err != nil || duplicates[0] == nil || duplicates[0].Via != "link" { + t.Fatalf("duplicates = %+v, %v", duplicates, err) + } + if _, err := st.AddTask("alice", board.Task{Title: "Fuzzy candidate"}); err != nil { + t.Fatal(err) + } + duplicates, err = service.duplicates("alice", authorized, []forgeIssue{{Ref: "github#94", Title: "Fuzzy candidate"}}) + if err != nil || duplicates[0] == nil || duplicates[0].Via != "similar" { + t.Fatalf("fuzzy duplicates = %+v, %v", duplicates, err) + } + if summary := service.driftSummary(context.Background(), "alice", store.NewImportBaseline("old", "body", "one"), store.NewImportBaseline("new", "body", "two")); summary != "done" { + t.Fatalf("drift summary = %q", summary) + } + tooMany := make([]LinkInput, maxImportLinks+1) + for _, call := range []func() error{ + func() error { return service.RecordLinks("alice", "primary", tooMany) }, + func() error { return service.RecordLinks("alice", "primary", []LinkInput{{ExternalKey: "key"}}) }, + func() error { return service.RecordLinks("alice", "missing", nil) }, + } { + if err := call(); err == nil { + t.Fatal("invalid link journal accepted") + } + } + for _, call := range []func() error{ + func() error { return service.RecordLinks("alice", "", nil) }, + func() error { _, err := service.Provenance("alice", ""); return err }, + func() error { _, err := service.Provenance("alice", "missing"); return err }, + } { + if err := call(); err == nil { + t.Fatal("invalid provenance operation succeeded") + } + } +} + +func TestPreviewDisclosesPromptPackLoss(t *testing.T) { + large := strings.Repeat("x", maxImportIssueBodyBytes) + issues := make([]map[string]any, 5) + for index := range issues { + issues[index] = map[string]any{"number": index + 1, "title": "issue", "body": large, "html_url": "https://github.test/issue"} + } + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/issues") { + _ = json.NewEncoder(w).Encode(issues) + return + } + http.NotFound(w, r) + })) + defer upstream.Close() + fake := &scriptedAI{} + aiUpstream := httptest.NewServer(http.HandlerFunc(fake.handler)) + defer aiUpstream.Close() + t.Setenv("KB_AI_ALLOW_PRIVATE", "1") + st := testStore(t) + base := upstream.URL + if _, err := st.SetForgeSource("alice", "primary", "github", &base, nil); err != nil { + t.Fatal(err) + } + aiBase, model, key := aiUpstream.URL, "gpt-4o", "key" + if _, err := st.SetAISettings("alice", &aiBase, &model, &key); err != nil { + t.Fatal(err) + } + service := New(st, ai.NewRunner(st, "", aiUpstream.Client(), nil), upstream.Client()) + preview, err := service.Preview(context.Background(), "alice", PreviewRequest{Source: "primary", Ref: "owner/repo", Max: 5}) + if err != nil || preview.Fetched != 5 || !preview.Truncated || !strings.Contains(preview.Note, "assistant input limit") || len(preview.Drafts) != 1 { + t.Fatalf("pack preview = %+v, %v", preview, err) + } +} + +func TestPreviewHandlesEmptySelectionAndAIConfigurationFailure(t *testing.T) { + var withIssue atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/issues") { + http.NotFound(w, r) + return + } + if withIssue.Load() { + _, _ = io.WriteString(w, `[{"number":1,"title":"issue","html_url":"https://example.test/1"}]`) + return + } + _, _ = io.WriteString(w, `[]`) + })) + defer upstream.Close() + st := testStore(t) + base := upstream.URL + if _, err := st.SetForgeSource("alice", "primary", "github", &base, nil); err != nil { + t.Fatal(err) + } + service := New(st, nil, upstream.Client()) + if _, err := service.Preview(context.Background(), "alice", PreviewRequest{Source: "missing", Ref: "owner/repo"}); err == nil { + t.Fatal("preview with missing source succeeded") + } + preview, err := service.Preview(context.Background(), "alice", PreviewRequest{Source: "primary", Ref: "owner/repo", Max: 1}) + if err != nil || preview.Fetched != 0 || len(preview.Drafts) != 0 { + t.Fatalf("empty preview = %+v, %v", preview, err) + } + withIssue.Store(true) + if _, err := service.Preview(context.Background(), "alice", PreviewRequest{Source: "primary", Ref: "owner/repo", Max: 1}); err == nil { + t.Fatal("preview without AI configuration succeeded") + } +} + +func TestQualifiedIdentityPreventsCrossForgeExactDuplicates(t *testing.T) { + issue := Issue{Ref: "github#93", Title: "different work"} + refA := Ref{Source: store.ForgeSource{Name: "a", Kind: "github", BaseURL: "https://forge.test/a"}, Kind: "github", Project: "owner/repo", Issue: 93} + refB := Ref{Source: store.ForgeSource{Name: "b", Kind: "github", BaseURL: "https://forge.test/b"}, Kind: "github", Project: "owner/repo", Issue: 93} + _, keyA := issueProvenance(refA, issue) + _, keyB := issueProvenance(refB, issue) + if keyA == keyB || !strings.Contains(keyA, "/a/") || !strings.Contains(keyB, "/b/") { + t.Fatalf("qualified keys = %q %q", keyA, keyB) + } + st := testStore(t) + if _, err := st.AddTask("alice", board.Task{Title: "existing", Tags: []string{importTagPrefix + keyA}}); err != nil { + t.Fatal(err) + } + service := New(st, nil, nil) + exact, err := service.duplicates("alice", refA, []Issue{issue}) + if err != nil || exact[0] == nil || exact[0].Via != "link" { + t.Fatalf("exact = %+v, %v", exact, err) + } + other, err := service.duplicates("alice", refB, []Issue{issue}) + if err != nil || other[0] != nil { + t.Fatalf("cross-forge duplicate = %+v, %v", other, err) + } +} + +func TestLegacyShortLinkAndStaleProvenanceNeverBecomeExact(t *testing.T) { + issue := Issue{Ref: "github#93", Title: "Fix login", URL: "https://forge.test/b/owner/repo/issues/93"} + ref := Ref{Source: store.ForgeSource{Name: "primary", Kind: "github", BaseURL: "https://forge.test/b"}, Kind: "github", Project: "owner/repo"} + st := testStore(t) + legacyTask, err := st.AddTask("alice", board.Task{Title: issue.Title, Tags: []string{linkTagPrefix + issue.Ref}}) + if err != nil { + t.Fatal(err) + } + if err := st.RecordImportLinks("alice", []store.ImportLink{{ + Source: ref.Source.Name, Kind: ref.Kind, ExternalKey: "github:forge.test/owner/repo#93", + Link: issue.Ref, URL: issue.URL, Title: legacyTask.Title, + }}); err != nil { + t.Fatal(err) + } + service := New(st, nil, nil) + duplicate, err := service.duplicates("alice", ref, []Issue{issue}) + if err != nil || duplicate[0] == nil || duplicate[0].ID != legacyTask.ID || duplicate[0].Via != "similar" { + t.Fatalf("legacy review classification = %+v, %v", duplicate, err) + } +} + +func TestServiceSourceValidationAndAtomicCreate(t *testing.T) { + st := testStore(t) + service := New(st, nil, nil) + base, token := "https://github.example", "token" + for _, call := range []func() error{ + func() error { _, err := service.SaveSource("alice", "bad name", "github", &base, nil); return err }, + func() error { _, err := service.SaveSource("alice", "primary", "other", &base, nil); return err }, + func() error { _, err := service.SaveSource("alice", "primary", "github", nil, nil); return err }, + func() error { + _, err := service.SaveSource("alice", "primary", "github", ptr("https://github.example?bad=1"), nil) + return err + }, + func() error { return service.DeleteSource("alice", "bad name") }, + } { + if err := call(); err == nil { + t.Fatal("invalid source operation succeeded") + } + } + if cleared, err := service.SaveSource("alice", "primary", "github", &base, &token); err != nil || cleared { + t.Fatalf("save = %t, %v", cleared, err) + } + gitlabBase := "https://gitlab.example" + if cleared, err := service.SaveSource("alice", "primary", "gitlab", &gitlabBase, nil); err != nil || !cleared { + t.Fatalf("kind change = %t, %v", cleared, err) + } + if _, err := service.CreateTask("alice", "missing", board.Task{Title: "card"}, LinkInput{}); err == nil { + t.Fatal("missing source created task") + } + item := LinkInput{ExternalKey: "gitlab:primary@gitlab.example/acme/kb#1", Link: "gitlab#1", URL: "https://gitlab.example/acme/kb/-/issues/1", Title: "card"} + if _, err := service.CreateTask("alice", "primary", board.Task{Title: "card"}, LinkInput{}); err == nil { + t.Fatal("missing provenance created task") + } + created, err := service.CreateTask("alice", "primary", board.Task{Title: "card", Tags: []string{"import::" + item.ExternalKey}}, item) + if err != nil || created.ID == "" { + t.Fatalf("create = %+v, %v", created, err) + } + if found, err := st.ImportedAs("alice", []string{item.ExternalKey}); err != nil || found[item.ExternalKey].Title != item.Title { + t.Fatalf("atomic provenance = %+v, %v", found, err) + } + oversized := item + oversized.URL = strings.Repeat("x", 2049) + if err := service.RecordLinks("alice", "primary", []LinkInput{oversized}); err == nil { + t.Fatal("oversized provenance recorded") + } + if _, err := service.CreateTask("alice", "primary", board.Task{Title: "card"}, oversized); err == nil { + t.Fatal("oversized provenance created task") + } + if _, err := service.CreateTask("alice", "primary", board.Task{Title: "card", Status: board.Status("invalid")}, item); err == nil { + t.Fatal("invalid task created") + } + if err := service.DeleteSource("alice", "primary"); err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + if _, err := service.Sources("alice"); err == nil { + t.Fatal("closed store sources succeeded") + } + if _, err := service.SaveSource("alice", "primary", "github", &base, nil); err == nil { + t.Fatal("closed store save succeeded") + } + if err := service.DeleteSource("alice", "primary"); err == nil { + t.Fatal("closed store delete succeeded") + } +} + +func ptr(value string) *string { return &value } + +func TestDriftLifecycleAndRevisionConflict(t *testing.T) { + var title atomic.Value + title.Store("Initial") + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/issues/42"): + _ = json.NewEncoder(w).Encode(map[string]any{"number": 42, "title": title.Load().(string), "body": "body", "html_url": upstreamURL(r) + "/owner/repo/issues/42"}) + case strings.HasSuffix(r.URL.Path, "/issues/42/comments"): + _, _ = io.WriteString(w, `[]`) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + st := testStore(t) + base := upstream.URL + if _, err := st.SetForgeSource("alice", "primary", "github", &base, nil); err != nil { + t.Fatal(err) + } + key := "github:" + strings.TrimPrefix(upstream.URL, "http://") + "/owner/repo#42" + if err := st.RecordImportLinks("alice", []store.ImportLink{{Source: "primary", Kind: "github", ExternalKey: key, Link: "github#42", URL: upstream.URL + "/owner/repo/issues/42", Title: "Initial"}}); err != nil { + t.Fatal(err) + } + service := New(st, nil, upstream.Client()) + first, err := service.CheckDrift(context.Background(), "alice", "primary", key) + if err != nil || first.State != "baseline_recorded" { + t.Fatalf("first = %+v, %v", first, err) + } + unchanged, err := service.CheckDrift(context.Background(), "alice", "primary", key) + if err != nil || unchanged.State != "unchanged" { + t.Fatalf("unchanged = %+v, %v", unchanged, err) + } + if _, _, err := service.authorizeDrift("alice", "wrong", key); err == nil { + t.Fatal("wrong drift source accepted") + } + title.Store("Changed") + drifted, err := service.CheckDrift(context.Background(), "alice", "primary", key) + if err != nil || drifted.State != "drifted" || drifted.Revision == "" { + t.Fatalf("drift = %+v, %v", drifted, err) + } + title.Store("Changed again") + if _, err := service.AcceptDrift(context.Background(), "alice", "primary", key, drifted.Revision); !errors.Is(err, ErrUpstreamChanged) { + t.Fatalf("stale accept = %v", err) + } + title.Store("Changed") + at, err := service.AcceptDrift(context.Background(), "alice", "primary", key, drifted.Revision) + if err != nil || at == "" { + t.Fatalf("accept = %q, %v", at, err) + } + retry, err := service.AcceptDrift(context.Background(), "alice", "primary", key, drifted.Revision) + if err != nil || retry != at { + t.Fatalf("retry = %q, %v", retry, err) + } + if _, err := service.AcceptDrift(context.Background(), "alice", "primary", key, "bad"); err == nil { + t.Fatal("bad revision accepted") + } + missingBaselineKey := "github:" + strings.TrimPrefix(upstream.URL, "http://") + "/owner/repo#43" + if err := st.RecordImportLinks("alice", []store.ImportLink{{Source: "primary", Kind: "github", ExternalKey: missingBaselineKey, Link: "github#43", URL: upstream.URL + "/owner/repo/issues/43", Title: "Initial"}}); err != nil { + t.Fatal(err) + } + if _, err := service.AcceptDrift(context.Background(), "alice", "primary", missingBaselineKey, strings.Repeat("a", 64)); err == nil { + t.Fatal("missing baseline accepted") + } + if _, err := New(st, nil, nil).AcceptDrift(context.Background(), "alice", "primary", key, strings.Repeat("a", 64)); err == nil { + t.Fatal("drift fetch without client succeeded") + } +} + +func upstreamURL(r *http.Request) string { return "http://" + r.Host } + +func TestProbeAndSourceLifecycle(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v3/user" || r.URL.Path == "/api/v3/repos/owner/repo" { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + })) + defer upstream.Close() + st := testStore(t) + base, token := upstream.URL, "token" + if _, err := st.SetForgeSource("alice", "primary", "github", &base, &token); err != nil { + t.Fatal(err) + } + prober := NewForgeProberWithClient(st, upstream.Client()) + if err := prober.Probe(context.Background(), "alice", ForgeProbeConfig{Name: "primary", Saved: true}); err != nil { + t.Fatal(err) + } + if err := prober.Probe(context.Background(), "alice", ForgeProbeConfig{Name: "draft", Kind: "github", BaseURL: base, Project: "owner/repo", Token: "token"}); err != nil { + t.Fatal(err) + } + for _, config := range []ForgeProbeConfig{{}, {Name: "missing", Saved: true}, {Name: "draft", Kind: "other", BaseURL: base}, {Name: "draft", Kind: "github", BaseURL: "ftp://forge.test"}} { + if err := prober.Probe(context.Background(), "alice", config); err == nil { + t.Errorf("probe %+v succeeded", config) + } + } + failingProber := NewForgeProberWithClient(st, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("offline") + })}) + if err := failingProber.Probe(context.Background(), "alice", ForgeProbeConfig{Name: "draft", Kind: "github", BaseURL: base}); err == nil { + t.Fatal("failed connection probe succeeded") + } + service := New(st, nil, upstream.Client()) + if sources, err := service.Sources("alice"); err != nil || len(sources) != 1 { + t.Fatalf("sources = %+v, %v", sources, err) + } + if cleared, err := service.SaveSource("alice", "new", "github", &base, nil); err != nil || cleared { + t.Fatalf("save source = %t, %v", cleared, err) + } + if err := service.Probe(context.Background(), "alice", ForgeProbeConfig{Name: "new", Saved: true}); err != nil { + t.Fatal(err) + } + if err := service.DeleteSource("alice", "new"); err != nil { + t.Fatal(err) + } +} + +func TestParsingAndResponseHelpers(t *testing.T) { + if got := appendImportNote("one", "two"); got != "one; two" { + t.Fatalf("note = %q", got) + } + if got := truncateImportText("界界", 4); got != "界" { + t.Fatalf("truncate = %q", got) + } + comments, err := parseForgeComments("gitlab", []byte(`[{"body":"one","system":false},{"body":"two","system":true}]`)) + if err != nil || len(comments) != 1 { + t.Fatalf("comments = %v, %v", comments, err) + } + comments, err = parseForgeComments("github", []byte(`[{"body":"one"}]`)) + if err != nil || len(comments) != 1 { + t.Fatalf("github comments = %v, %v", comments, err) + } + if _, err := parseForgeComments("other", nil); err == nil { + t.Fatal("invalid comments kind") + } + issues, err := parseForgeIssueList("github", []byte(`[{"number":1,"title":"issue"},{"number":2,"title":"pr","pull_request":{}}]`)) + if err != nil || len(issues) != 1 { + t.Fatalf("issues = %+v, %v", issues, err) + } + if _, err := parseForgeIssueList("other", nil); err == nil { + t.Fatal("invalid issue kind") + } + query := url.Values{} + if !setForgeMilestoneQuery("gitlab", query, []byte(`{"title":"release"}`)) || query.Get("milestone") != "release" { + t.Fatal("gitlab milestone query") + } + if !setForgeMilestoneQuery("github", query, []byte(`{"number":7}`)) || query.Get("milestone") != "7" { + t.Fatal("github milestone query") + } + if setForgeMilestoneQuery("other", query, nil) { + t.Fatal("invalid milestone query") + } + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))} + if err := drainForgeResponse(response); err != nil { + t.Fatal(err) + } + if err := executeForgeTest(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("bad"))}, nil + })}, httptest.NewRequest(http.MethodGet, "https://forge.test", nil)); err == nil { + t.Fatal("bad status accepted") + } + + if !ValidSourceName("primary") || ValidSourceName("bad name") || ValidSourceName("") { + t.Fatal("public source-name validation") + } + if adr := IssueADR(Issue{Title: "one", Body: "body", Comments: []string{"note"}}); !strings.Contains(adr, "## Discussion") { + t.Fatalf("adr = %q", adr) + } + if tags := StripLinkTags([]string{"one", "link::github#1"}); len(tags) != 1 || tags[0] != "one" { + t.Fatalf("stripped tags = %v", tags) + } + revision := BaselineRevision(store.NewImportBaseline("title", "body", "now")) + if !ValidRevision(revision) || ValidRevision("bad") { + t.Fatalf("revision validation = %q", revision) + } + publicErr := &Error{Code: 400, Message: "bad", Cause: errors.New("cause")} + if publicErr.Error() != "bad" || publicErr.Unwrap() == nil { + t.Fatal("error helpers") + } + if refKind(Ref{}) != "project" || refKind(Ref{Milestone: 1}) != "milestone" { + t.Fatal("kind helpers") + } + if skillBudget(999999) != ai.SkillBudget(999999) || logSafe("a\nb\u009bb") != "abb" { + t.Fatal("compatibility helpers") + } + if adr := forgeIssueADR(forgeIssue{Title: strings.Repeat("x", 70<<10)}); len(adr) > 64<<10 { + t.Fatalf("bounded adr = %d", len(adr)) + } + if adr := forgeIssueADR(forgeIssue{Title: "title", Body: "body", Comments: []string{strings.Repeat("x", 70<<10)}}); len(adr) > 64<<10 || !strings.Contains(adr, "Discussion") { + t.Fatalf("bounded discussion = %d", len(adr)) + } + _ = newForgeClient() + _ = NewForgeProber(testStore(t)) + _ = NewForgeProberWithClient(testStore(t), nil) +} + +func TestAuthorizeDriftRejectsMismatchedProvenance(t *testing.T) { + st := testStore(t) + base := "https://github.example" + if _, err := st.SetForgeSource("alice", "primary", "github", &base, nil); err != nil { + t.Fatal(err) + } + links := []store.ImportLink{ + {Source: "primary", Kind: "gitlab", ExternalKey: "wrong-kind", Link: "github#1", URL: base + "/owner/repo/issues/1", Title: "wrong"}, + {Source: "primary", Kind: "github", ExternalKey: "project", Link: "github#project", URL: base + "/owner/repo", Title: "project"}, + {Source: "primary", Kind: "github", ExternalKey: "wrong-host", Link: "github#2", URL: "https://evil.example/owner/repo/issues/2", Title: "wrong"}, + } + if err := st.RecordImportLinks("alice", links); err != nil { + t.Fatal(err) + } + service := New(st, nil, nil) + for _, test := range []struct{ source, key string }{ + {"primary", "missing"}, {"other", "wrong-kind"}, {"primary", "wrong-kind"}, {"primary", "project"}, {"primary", "wrong-host"}, + } { + if _, _, err := service.authorizeDrift("alice", test.source, test.key); err == nil { + t.Errorf("authorize drift %+v succeeded", test) + } + } + if _, _, _, err := service.ResolveIssueDocument(context.Background(), "alice", "missing", base+"/owner/repo/issues/1"); err == nil { + t.Fatal("missing source document resolved") + } +} + +func TestMilestonePaginationRateLimitAndFetchErrors(t *testing.T) { + var mode atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch mode.Load() { + case 1: + w.WriteHeader(http.StatusInternalServerError) + return + case 2: + _, _ = io.WriteString(w, `{`) + return + case 3: + w.WriteHeader(http.StatusTooManyRequests) + return + } + switch r.URL.EscapedPath() { + case "/api/v4/projects/group%2Frepo/milestones/4": + _, _ = io.WriteString(w, `{"title":"Release"}`) + case "/api/v4/projects/group%2Frepo/issues": + if r.URL.Query().Get("milestone") != "Release" || r.URL.Query().Get("state") != "opened" { + http.Error(w, "bad query", http.StatusBadRequest) + return + } + w.Header().Set("X-Total", "1") + _, _ = io.WriteString(w, `[{"iid":8,"title":"issue"}]`) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + service := New(testStore(t), nil, upstream.Client()) + ref := forgeRef{Source: store.ForgeSource{Name: "gl", Kind: "gitlab", BaseURL: upstream.URL}, Kind: "gitlab", Project: "group/repo", Milestone: 4} + issues, total, truncated, note, err := service.fetchIssues(context.Background(), ref, 20) + if err != nil || len(issues) != 1 || total != 1 || truncated || note != "" { + t.Fatalf("milestone = %d/%d truncated=%t note=%q err=%v", len(issues), total, truncated, note, err) + } + project := ref + project.Milestone = 0 + for _, current := range []int32{1, 2} { + mode.Store(current) + if _, _, _, _, err := service.fetchIssues(context.Background(), project, 1); err == nil { + t.Errorf("fetch mode %d succeeded", current) + } + } + mode.Store(3) + issues, _, truncated, note, err = service.fetchIssues(context.Background(), project, 1) + if err != nil || len(issues) != 0 || !truncated || !strings.Contains(note, "rate limited") { + t.Fatalf("rate limit = %+v %t %q %v", issues, truncated, note, err) + } +} + +func TestFetchIssueRejectsBadStatusCommentsAndPullRequests(t *testing.T) { + var mode atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/comments") { + if mode.Load() == 1 { + w.WriteHeader(http.StatusInternalServerError) + return + } + _, _ = io.WriteString(w, `{`) + return + } + switch mode.Load() { + case 2: + w.WriteHeader(http.StatusNotFound) + case 3: + _, _ = io.WriteString(w, `{`) + case 4: + _, _ = io.WriteString(w, `{"number":7,"title":"pull","pull_request":{}}`) + default: + _, _ = io.WriteString(w, `{"number":7,"title":"issue"}`) + } + })) + defer upstream.Close() + service := New(testStore(t), nil, upstream.Client()) + ref := forgeRef{Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: upstream.URL}, Kind: "github", Project: "owner/repo", Issue: 7} + for _, current := range []int32{0, 1, 2, 3, 4} { + mode.Store(current) + if _, err := service.fetchIssue(context.Background(), ref); err == nil { + t.Errorf("issue mode %d succeeded", current) + } + } + badKind := ref + badKind.Kind = "other" + if _, err := service.fetchIssue(context.Background(), badKind); err == nil { + t.Fatal("bad kind issue fetched") + } + mode.Store(3) + gitlab := ref + gitlab.Kind = "gitlab" + gitlab.Source.Kind = "gitlab" + gitlab.Project = "group/repo" + if _, _, _, err := service.fetchIssueSnapshot(context.Background(), gitlab); err == nil { + t.Fatal("invalid GitLab issue fetched") + } +} + +func TestPackAndPathBoundaryBranches(t *testing.T) { + comments := make([]string, 12) + for index := range comments { + comments[index] = strings.Repeat("x", maxImportCommentBytes+1) + } + packed, count := packImportIssues([]forgeIssue{{Title: "one", Ref: "github#1", Comments: comments}, {Title: strings.Repeat("x", maxImportPackBytes), Ref: "github#2"}}) + if count != 1 || !strings.Contains(packed, "Source 1") || strings.Contains(packed, "Source 2") { + t.Fatalf("packed count=%d length=%d", count, len(packed)) + } + if truncateImportText("x", 0) != "" || forgeURLPort(&url.URL{Scheme: "https"}) != "443" || forgeURLPort(&url.URL{Scheme: "http"}) != "80" { + t.Fatal("boundary helpers") + } + state := forgeIssuePageState{} + state.observeTotal("gitlab", http.Header{"X-Total": []string{"3"}}) + if state.totalHint != 3 || !state.hasGitLabTotal { + t.Fatalf("observed total = %+v", state) + } + if !state.appendBatch([]forgeIssue{{Title: "one"}, {Title: "two"}}, 1, true) || len(state.issues) != 1 || !state.truncated { + t.Fatalf("batch truncation = %+v", state) + } +} + +func TestGitLabReferenceAndFetchDispatchBranches(t *testing.T) { + source := store.ForgeSource{Name: "gl", Kind: "gitlab", BaseURL: "https://gitlab.example"} + tests := []struct { + raw string + project string + issue int + milestone int + }{ + {"group/project", "group/project", 0, 0}, + {"group/project/issues/8", "group/project", 8, 0}, + {"group/project/milestones/4", "group/project", 0, 4}, + {"group/project/-/boards/2", "group/project", 0, 0}, + } + for _, test := range tests { + ref, err := parseGitLabRef(source, test.raw) + if err != nil || ref.Project != test.project || ref.Issue != test.issue || ref.Milestone != test.milestone { + t.Errorf("parse %q = %+v, %v", test.raw, ref, err) + } + } + for _, raw := range []string{"", "group//project", "group/-/unknown/1", "-/issues/1"} { + if _, err := parseGitLabRef(source, raw); err == nil { + t.Errorf("invalid GitLab ref %q accepted", raw) + } + } + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/notes"): + _, _ = io.WriteString(w, `[]`) + case strings.HasSuffix(r.URL.Path, "/issues/8"): + _, _ = io.WriteString(w, `{"iid":8,"title":"issue"}`) + default: + _, _ = io.WriteString(w, `[]`) + } + })) + defer upstream.Close() + service := New(testStore(t), nil, upstream.Client()) + issueRef := forgeRef{Source: store.ForgeSource{Name: "gl", Kind: "gitlab", BaseURL: upstream.URL}, Kind: "gitlab", Project: "group/project", Issue: 8} + issues, total, truncated, note, err := service.fetchIssues(context.Background(), issueRef, 0) + if err != nil || len(issues) != 1 || total != 1 || truncated || note != "" { + t.Fatalf("single issue = %+v total=%d truncated=%t note=%q err=%v", issues, total, truncated, note, err) + } + project := issueRef + project.Issue = 0 + if issues, _, _, _, err = service.fetchIssues(context.Background(), project, maxImportIssues+1); err != nil || len(issues) != 0 { + t.Fatalf("project fetch = %+v, %v", issues, err) + } + bad := project + bad.Kind = "other" + if _, _, _, _, err := service.fetchIssues(context.Background(), bad, 1); err == nil { + t.Fatal("invalid kind fetched") + } +} + +func TestForgeHTTPAndProbeFailureBranches(t *testing.T) { + ref := forgeRef{Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://forge.test"}, Kind: "github", Project: "owner/repo", Issue: 1} + path := "/repos/owner/repo/issues/1" + if _, err := (&Service{}).forgeGet(context.Background(), ref, "https://forge.test/api/v3", path, nil); err == nil { + t.Fatal("nil client accepted") + } + transportErr := errors.New("transport failed") + service := &Service{forgeClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, transportErr + })}} + if _, err := service.forgeGet(context.Background(), ref, "https://forge.test/api/v3", path, nil); err == nil { + t.Fatal("transport failure ignored") + } + service.forgeClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"X-Test": {"yes"}}, Body: failingReadCloser{readErr: errors.New("read")}}, nil + })} + if _, err := service.forgeGet(context.Background(), ref, "https://forge.test/api/v3", path, nil); err == nil { + t.Fatal("body read failure ignored") + } + badKind := ref + badKind.Kind = "other" + service.forgeClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil + })} + if _, err := service.forgeGet(context.Background(), badKind, "https://forge.test", path, nil); err == nil { + t.Fatal("invalid kind accepted") + } + + request, err := http.NewRequest(http.MethodGet, "https://forge.test", nil) + if err != nil { + t.Fatal(err) + } + if err := executeForgeTest(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, transportErr + })}, request); !errors.Is(err, transportErr) { + t.Fatalf("execute transport error = %v", err) + } + if err := executeForgeTest(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: failingReadCloser{readErr: errors.New("read"), closeErr: errors.New("close")}}, nil + })}, request); err == nil { + t.Fatal("execute drain failure ignored") + } + if err := executeForgeTest(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("bad"))}, nil + })}, request); err == nil { + t.Fatal("execute bad status ignored") + } + if err := drainForgeResponse(&http.Response{Body: failingReadCloser{closeErr: errors.New("close")}}); err == nil { + t.Fatal("close failure ignored") + } + + for _, raw := range []string{"", "ftp://forge.test", "https://user:pass@forge.test", "https://forge.test?x=1", "https://forge.test#fragment"} { + if _, err := normalizeForgeProbeBase(raw); err == nil { + t.Errorf("invalid probe base %q accepted", raw) + } + } + base := "https://other.test" + if _, err := resolveForgeTestTarget("https://forge.test", "stored", forgeTestProbe{BaseURL: &base}); err == nil { + t.Fatal("stored token crossed origins") + } + pat := " supplied " + target, err := resolveForgeTestTarget("https://forge.test", "stored", forgeTestProbe{BaseURL: &base, PAT: &pat}) + if err != nil || target.pat != "supplied" { + t.Fatalf("supplied target = %+v, %v", target, err) + } + for _, test := range []struct{ kind, base, project string }{ + {"gitlab", "https://gitlab.test", ""}, + {"github", "https://github.test", ""}, + {"github", "https://github.test", "bad"}, + {"other", "https://forge.test", ""}, + } { + request, err := newForgeTestRequest(context.Background(), test.kind, forgeTestTarget{baseURL: test.base}, test.project) + if (test.kind == "other" || test.project == "bad") != (err != nil) { + t.Errorf("request %+v = %v, %v", test, request, err) + } + } + + tags := stripModelLinkTags([]string{"normal", linkTagPrefix + "github#1", importTagPrefix + "qualified"}) + if len(tags) != 1 || tags[0] != "normal" { + t.Fatalf("filtered tags = %v", tags) + } +} + +func TestRemainingReferenceMilestoneAndBoundedBranches(t *testing.T) { + gitlab := store.ForgeSource{Name: "gl", Kind: "gitlab", BaseURL: "https://gitlab.example"} + for _, test := range []struct { + sources []store.ForgeSource + name string + raw string + }{ + {nil, "", "owner/repo"}, + {nil, "missing", "owner/repo"}, + {[]store.ForgeSource{gitlab}, "gl", "owner/repo"}, + {[]store.ForgeSource{{Name: "bad", Kind: "other", BaseURL: "https://bad.example"}}, "bad", "https://bad.example/project"}, + } { + if _, err := parseForgeRef(test.sources, test.name, test.raw); err == nil { + t.Errorf("invalid reference %+v accepted", test) + } + } + for name, call := range map[string]func() (string, error){ + "issue": func() (string, error) { return forgeIssuePath(forgeRef{Kind: "github"}) }, + "milestone": func() (string, error) { return forgeMilestonePath(forgeRef{Kind: "github"}) }, + "list": func() (string, error) { return forgeProjectIssuesPath(forgeRef{Kind: "github"}) }, + } { + if _, err := call(); err == nil { + t.Errorf("invalid %s path accepted", name) + } + } + if _, _, err := forgeIssueListRequest(forgeRef{Kind: "other", Project: "project"}); err == nil { + t.Fatal("invalid list kind accepted") + } + for _, test := range []struct { + kind string + body string + }{ + {"gitlab", `{}`}, {"gitlab", `{`}, {"github", `{}`}, {"github", `{`}, + } { + if setForgeMilestoneQuery(test.kind, url.Values{}, []byte(test.body)) { + t.Errorf("bad milestone %s %q accepted", test.kind, test.body) + } + } + if _, err := parseForgeComments("gitlab", []byte(`{`)); err == nil { + t.Fatal("bad GitLab comments accepted") + } + if _, err := parseForgeComments("github", []byte(`{`)); err == nil { + t.Fatal("bad GitHub comments accepted") + } + long := strings.Repeat("界", maxForgeCommentLen) + if got := appendBoundedForgeComment(nil, long); len(got) != 1 || len(got[0]) > maxForgeCommentLen { + t.Fatalf("bounded comment bytes = %d", len(got[0])) + } + state := forgeIssuePageState{issues: []forgeIssue{{Title: "one"}}, totalHint: 2, hasGitLabTotal: true} + if _, _, truncated, _, _ := state.result(); !truncated { + t.Fatal("total hint did not mark truncation") + } + if validImportDriftRevision(strings.Repeat("g", sha256.Size*2)) { + t.Fatal("non-hex revision accepted") + } + _, key := importIssueProvenance(forgeRef{Source: store.ForgeSource{Name: "gh", BaseURL: ":"}, Kind: "github", Project: "owner/repo"}, forgeIssue{Ref: "github#1"}) + if strings.Contains(key, "@") { + t.Fatalf("invalid base included in key: %q", key) + } + + var mode atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if mode.Load() == 1 { + w.WriteHeader(http.StatusBadGateway) + return + } + if r.URL.EscapedPath() == "/api/v3/repos/owner/repo/milestones/3" { + if mode.Load() == 2 { + _, _ = io.WriteString(w, `{`) + return + } + _, _ = io.WriteString(w, `{"number":3}`) + return + } + http.NotFound(w, r) + })) + defer upstream.Close() + service := New(testStore(t), nil, upstream.Client()) + ref := forgeRef{Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: upstream.URL}, Kind: "github", Project: "owner/repo", Milestone: 3} + apiBase, err := forgeAPIBase("github", upstream.URL) + if err != nil { + t.Fatal(err) + } + path, query, err := service.forgeIssuesList(context.Background(), ref, apiBase) + if err != nil || path == "" || query.Get("milestone") != "3" { + t.Fatalf("GitHub milestone list = %q %v, %v", path, query, err) + } + for _, current := range []int32{1, 2} { + mode.Store(current) + if _, _, err := service.forgeIssuesList(context.Background(), ref, apiBase); err == nil { + t.Errorf("milestone mode %d succeeded", current) + } + } + badMilestone := ref + badMilestone.Project = "" + if _, _, err := service.forgeIssuesList(context.Background(), badMilestone, apiBase); err == nil { + t.Fatal("invalid milestone path accepted") + } +} + +func TestServiceStorageFailuresRemainPublicErrors(t *testing.T) { + st := testStore(t) + base := "https://github.example" + if _, err := st.SetForgeSource("alice", "primary", "github", &base, nil); err != nil { + t.Fatal(err) + } + service := New(st, nil, nil) + if err := st.Close(); err != nil { + t.Fatal(err) + } + item := LinkInput{ExternalKey: "qualified", Link: "github#1", URL: base + "/owner/repo/issues/1", Title: "issue"} + if _, err := service.duplicates("alice", Ref{Source: store.ForgeSource{Name: "primary", Kind: "github", BaseURL: base}, Kind: "github", Project: "owner/repo"}, []Issue{{Ref: "github#1", Title: "issue"}}); err == nil { + t.Fatal("closed-store duplicate lookup succeeded") + } + checks := []func() error{ + func() error { return service.RecordLinks("alice", "primary", []LinkInput{item}) }, + func() error { + _, err := service.CreateTask("alice", "primary", board.Task{Title: "issue"}, item) + return err + }, + func() error { _, err := service.Provenance("alice", "github#1"); return err }, + func() error { + _, _, _, err := service.ResolveIssueDocument(context.Background(), "alice", "primary", base+"/owner/repo/issues/1") + return err + }, + func() error { + _, err := service.CheckDrift(context.Background(), "alice", "primary", "qualified") + return err + }, + func() error { + _, err := service.AcceptDrift(context.Background(), "alice", "primary", "qualified", strings.Repeat("a", sha256.Size*2)) + return err + }, + } + for index, check := range checks { + var public *Error + if err := check(); err == nil || !errors.As(err, &public) || public.Code != http.StatusInternalServerError { + t.Errorf("check %d = %v", index, err) + } + } +} + +func TestRemainingServiceAuthorizationAndFetchErrors(t *testing.T) { + st := testStore(t) + root, nested := "https://forge.test", "https://forge.test/nested" + if _, err := st.SetForgeSource("alice", "root", "github", &root, nil); err != nil { + t.Fatal(err) + } + if _, err := st.SetForgeSource("alice", "nested", "github", &nested, nil); err != nil { + t.Fatal(err) + } + transportErr := errors.New("offline") + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, transportErr + })} + service := New(st, nil, client) + if _, err := service.authorizeRef("alice", "root", nested+"/owner/repo/issues/1"); err == nil { + t.Fatal("reference selected a different configured source") + } + if _, _, _, err := service.ResolveIssueDocument(context.Background(), "alice", "root", root+"/owner/repo/issues/1"); err == nil { + t.Fatal("document fetch transport failure succeeded") + } + if _, err := service.Preview(context.Background(), "alice", PreviewRequest{Source: "root", Ref: "owner/repo", Max: 1}); err == nil { + t.Fatal("preview fetch transport failure succeeded") + } + if err := st.RecordImportLinks("alice", []store.ImportLink{{Source: "root", Kind: "github", ExternalKey: "qualified", Link: "github#1", URL: root + "/owner/repo/issues/1", Title: "issue"}}); err != nil { + t.Fatal(err) + } + if _, err := service.CheckDrift(context.Background(), "alice", "root", "qualified"); err == nil { + t.Fatal("drift fetch transport failure succeeded") + } + ref := forgeRef{Source: store.ForgeSource{Name: "root", Kind: "github", BaseURL: root}, Kind: "github", Project: "owner/repo"} + if _, _, _, err := service.fetchIssueSnapshot(context.Background(), ref); err == nil { + t.Fatal("snapshot without issue succeeded") + } + badIssue := ref + badIssue.Issue, badIssue.Project = 1, "owner" + if _, _, _, _, err := service.fetchIssues(context.Background(), badIssue, 1); err == nil { + t.Fatal("issue with invalid project fetched") + } + ref.Milestone = 1 + apiBase, err := forgeAPIBase("github", root) + if err != nil { + t.Fatal(err) + } + if _, _, err := service.forgeIssuesList(context.Background(), ref, apiBase); err == nil { + t.Fatal("milestone transport failure succeeded") + } + if _, err := service.fetchForgeComments(context.Background(), ref, apiBase, "/repos/owner/repo/issues/1"); err == nil { + t.Fatal("comment transport failure succeeded") + } + if _, err := parseForgeIssueList("gitlab", []byte(`{`)); err == nil { + t.Fatal("invalid GitLab issue list accepted") + } + badBase := "ftp://forge.test" + if _, err := resolveForgeTestTarget("", "", forgeTestProbe{BaseURL: &badBase}); err == nil { + t.Fatal("invalid test target accepted") + } +} + +func TestAcceptDriftConcurrentCASIsIdempotent(t *testing.T) { + var calls atomic.Int32 + var releaseOnce sync.Once + release := make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/issues/1") { + http.NotFound(w, r) + return + } + if calls.Add(1) == 2 { + releaseOnce.Do(func() { close(release) }) + } + <-release + _, _ = io.WriteString(w, `{"number":1,"title":"new","body":"body"}`) + })) + defer upstream.Close() + st := testStore(t) + base := upstream.URL + if _, err := st.SetForgeSource("alice", "primary", "github", &base, nil); err != nil { + t.Fatal(err) + } + key := "qualified" + if err := st.RecordImportLinks("alice", []store.ImportLink{{Source: "primary", Kind: "github", ExternalKey: key, Link: "github#1", URL: base + "/owner/repo/issues/1", Title: "old"}}); err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateImportBaseline("alice", key, store.NewImportBaseline("old", "body", "old-at")); err != nil { + t.Fatal(err) + } + revision := importDriftRevision(store.NewImportBaseline("new", "body", "new-at")) + services := []*Service{New(st, nil, upstream.Client()), New(st, nil, upstream.Client())} + results := make(chan error, len(services)) + for _, service := range services { + go func(service *Service) { + _, err := service.AcceptDrift(context.Background(), "alice", "primary", key, revision) + results <- err + }(service) + } + for range services { + if err := <-results; err != nil { + t.Fatalf("concurrent accept = %v", err) + } + } + if calls.Load() != 2 { + t.Fatalf("fetch calls = %d", calls.Load()) + } +} + +func TestAcceptDriftRejectsConcurrentDifferentBaseline(t *testing.T) { + arrived, release := make(chan struct{}), make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/issues/1") { + http.NotFound(w, r) + return + } + close(arrived) + <-release + _, _ = io.WriteString(w, `{"number":1,"title":"new","body":"body"}`) + })) + defer upstream.Close() + st := testStore(t) + base := upstream.URL + if _, err := st.SetForgeSource("alice", "primary", "github", &base, nil); err != nil { + t.Fatal(err) + } + key := "qualified" + if err := st.RecordImportLinks("alice", []store.ImportLink{{Source: "primary", Kind: "github", ExternalKey: key, Link: "github#1", URL: base + "/owner/repo/issues/1", Title: "old"}}); err != nil { + t.Fatal(err) + } + old := store.NewImportBaseline("old", "body", "old-at") + if _, _, err := st.CreateImportBaseline("alice", key, old); err != nil { + t.Fatal(err) + } + revision := importDriftRevision(store.NewImportBaseline("new", "body", "new-at")) + result := make(chan error, 1) + go func() { + _, err := New(st, nil, upstream.Client()).AcceptDrift(context.Background(), "alice", "primary", key, revision) + result <- err + }() + select { + case <-arrived: + case <-time.After(time.Second): + t.Fatal("accept did not reach upstream") + } + if err := st.SetImportBaseline("alice", key, store.NewImportBaseline("other", "body", "other-at")); err != nil { + t.Fatal(err) + } + close(release) + select { + case err := <-result: + if !errors.Is(err, ErrUpstreamChanged) { + t.Fatalf("concurrent different baseline = %v", err) + } + case <-time.After(time.Second): + t.Fatal("accept did not finish") + } +} + +func TestAcceptDriftReportsCASStorageFailure(t *testing.T) { + arrived, release := make(chan struct{}), make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/issues/1") { + http.NotFound(w, r) + return + } + close(arrived) + <-release + _, _ = io.WriteString(w, `{"number":1,"title":"new","body":"body"}`) + })) + defer upstream.Close() + st := testStore(t) + base := upstream.URL + if _, err := st.SetForgeSource("alice", "primary", "github", &base, nil); err != nil { + t.Fatal(err) + } + key := "qualified" + if err := st.RecordImportLinks("alice", []store.ImportLink{{Source: "primary", Kind: "github", ExternalKey: key, Link: "github#1", URL: base + "/owner/repo/issues/1", Title: "old"}}); err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateImportBaseline("alice", key, store.NewImportBaseline("old", "body", "old-at")); err != nil { + t.Fatal(err) + } + revision := importDriftRevision(store.NewImportBaseline("new", "body", "new-at")) + result := make(chan error, 1) + go func() { + _, err := New(st, nil, upstream.Client()).AcceptDrift(context.Background(), "alice", "primary", key, revision) + result <- err + }() + select { + case <-arrived: + case <-time.After(time.Second): + t.Fatal("accept did not reach upstream") + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + close(release) + select { + case err := <-result: + var public *Error + if !errors.As(err, &public) || public.Code != http.StatusInternalServerError { + t.Fatalf("CAS storage failure = %v", err) + } + case <-time.After(time.Second): + t.Fatal("accept did not finish") + } +} diff --git a/internal/forge/network.go b/internal/forge/network.go new file mode 100644 index 0000000..17caab6 --- /dev/null +++ b/internal/forge/network.go @@ -0,0 +1,94 @@ +package forge + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "strings" + "syscall" +) + +// NewHTTPClient returns the guarded forge client shared by every frontend. +func NewHTTPClient() *http.Client { + raw := os.Getenv("KB_FORGE_ALLOW_PRIVATE") + allowAll := raw == "1" || raw == "*" + var allowed map[string]bool + if !allowAll { + allowed = parseAllowedHosts(raw) + } + return &http.Client{Timeout: forgeTimeout, Transport: guardedTransport(allowed, allowAll), CheckRedirect: sameHostRedirect} +} + +func parseAllowedHosts(raw string) map[string]bool { + hosts := make(map[string]bool) + for _, host := range strings.Split(raw, ",") { + if host = normalizeGuardHost(host); host != "" { + hosts[host] = true + } + } + return hosts +} + +func normalizeGuardHost(host string) string { + host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } + if ip := net.ParseIP(host); ip != nil { + return ip.String() + } + return host +} + +func guardedTransport(allowHosts map[string]bool, allowAll bool) *http.Transport { + return &http.Transport{DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + host, _, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("invalid dial address %q", address) + } + allowPrivate := allowAll || allowHosts[normalizeGuardHost(host)] + dialer := &net.Dialer{Control: func(_, address string, _ syscall.RawConn) error { + if allowPrivate { + return nil + } + ipHost, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip := net.ParseIP(ipHost) + if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return errors.New("forge endpoint resolves to a private address") + } + return nil + }} + return dialer.DialContext(ctx, network, address) + }} +} + +func sameHostRedirect(request *http.Request, via []*http.Request) error { + origin := via[0].URL + if origin.User != nil || request.URL.User != nil { + return errors.New("refusing redirect with URL credentials") + } + if !httpURL(origin) || !httpURL(request.URL) { + return errors.New("refusing redirect to non-HTTP(S) URL") + } + if normalizeGuardHost(origin.Hostname()) != normalizeGuardHost(request.URL.Hostname()) || origin.Port() != request.URL.Port() { + return errors.New("refusing cross-host redirect") + } + if strings.EqualFold(origin.Scheme, "https") && strings.EqualFold(request.URL.Scheme, "http") { + return errors.New("refusing HTTPS-to-HTTP redirect") + } + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + return nil +} + +func httpURL(value *url.URL) bool { + return strings.EqualFold(value.Scheme, "http") || strings.EqualFold(value.Scheme, "https") +} diff --git a/internal/server/forge_probe.go b/internal/forge/probe.go similarity index 89% rename from internal/server/forge_probe.go rename to internal/forge/probe.go index 3700048..489a8b4 100644 --- a/internal/server/forge_probe.go +++ b/internal/forge/probe.go @@ -1,4 +1,4 @@ -package server +package forge import ( "context" @@ -33,7 +33,14 @@ type ForgeProbeConfig struct { // NewForgeProber constructs a direct-store forge connection prober. func NewForgeProber(st *store.Store) *ForgeProber { - return &ForgeProber{store: st, client: newForgeClient()} + return &ForgeProber{store: st, client: NewHTTPClient()} +} + +func NewForgeProberWithClient(st *store.Store, client *http.Client) *ForgeProber { + if client == nil { + client = NewHTTPClient() + } + return &ForgeProber{store: st, client: client} } // Probe tests one unsaved candidate. Persisted rows may retain blank stored diff --git a/internal/server/ai.go b/internal/server/ai.go index 63a0ee9..d209720 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -16,9 +16,9 @@ import ( "syscall" "time" "unicode" - "unicode/utf8" kbai "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/forge" ) // AITimeout bounds one upstream chat-completion round trip. It is exported so @@ -463,41 +463,12 @@ func (s *server) resolveAIStoriesInput(w http.ResponseWriter, r *http.Request, u return aiStoriesInput{adr: req.ADR}, true } - sources, err := s.store.ForgeSources(user) + issue, link, issueURL, err := s.sharedForge().ResolveIssueDocument(r.Context(), user, req.Source, req.URL) if err != nil { - log.Printf("forge: list sources for stories for %s failed", user) - http.Error(w, storageErrorMessage, http.StatusInternalServerError) + writeAIError(w, user, "stories", serverForgeError(err)) return aiStoriesInput{}, false } - ref, err := parseForgeRef(sources, req.Source, req.URL) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return aiStoriesInput{}, false - } - selected, found := forgeSourceByName(sources, req.Source) - if !found { - http.Error(w, configuredSourceUnavailableMessage, http.StatusBadRequest) - return aiStoriesInput{}, false - } - if ref.Source.Name != selected.Name { - http.Error(w, "reference does not match selected source", http.StatusBadRequest) - return aiStoriesInput{}, false - } - kind, baseURL, pat, err := s.store.ForgePAT(user, selected.Name) - if err != nil || kind != selected.Kind || baseURL != selected.BaseURL { - http.Error(w, configuredSourceUnavailableMessage, http.StatusBadRequest) - return aiStoriesInput{}, false - } - ref.pat = pat - ctx, cancel := context.WithTimeout(r.Context(), importFetchTimeout) - defer cancel() - issue, err := s.fetchIssue(ctx, ref) - if err != nil { - writeAIError(w, user, "stories", err) - return aiStoriesInput{}, false - } - link, _ := importIssueProvenance(ref, issue) - return aiStoriesInput{adr: forgeIssueADR(issue), link: link, issueURL: issue.URL}, true + return aiStoriesInput{adr: forge.IssueADR(issue), link: link, issueURL: issueURL}, true } // adrSplitSkillName is the skill /api/ai/stories runs. The endpoint predates @@ -532,7 +503,7 @@ func (s *server) handleAIStories(w http.ResponseWriter, r *http.Request, user st stories := run.Cards if input.link != "" { for i := range stories { - stories[i].Tags = append(stripModelLinkTags(stories[i].Tags), linkTagPrefix+input.link) + stories[i].Tags = append(forge.StripLinkTags(stories[i].Tags), linkTagPrefix+input.link) } } writeJSON(w, struct { @@ -542,67 +513,6 @@ func (s *server) handleAIStories(w http.ResponseWriter, r *http.Request, user st }{Stories: stories, Link: input.link, URL: input.issueURL}) } -// forgeIssueADR turns one fetched issue and its bounded human discussion into -// the ADR text sent to the existing splitter. Discussion yields first when the -// input reaches maxADRBytes; if title and body alone exceed it, truncation is -// still rune-safe and leaves the prompt within the same existing limit. -func forgeIssueADR(issue forgeIssue) string { - adr := fmt.Sprintf("# %s\n\n%s", issue.Title, issue.Body) - discussion := "\n\n## Discussion" - if len(adr)+len(discussion) > maxADRBytes { - return truncateImportText(adr, maxADRBytes) - } - adr += discussion - for _, comment := range issue.Comments { - item := "\n- " + comment - remaining := maxADRBytes - len(adr) - if len(item) > remaining { - return adr + truncateImportText(item, remaining) - } - adr += item - } - return adr -} - -// packImportIssues limits raw forge text before one model call while keeping -// source numbers stable for server-owned provenance after coercion. -func packImportIssues(issues []forgeIssue) (string, int) { - var packed strings.Builder - count := 0 - for i, issue := range issues { - if packed.Len() >= maxImportPackBytes { - break - } - comments := make([]string, 0, min(len(issue.Comments), 10)) - for _, comment := range issue.Comments { - if len(comments) == 10 { - break - } - comments = append(comments, truncateImportText(comment, maxImportCommentBytes)) - } - section := fmt.Sprintf("Source %d\nTitle: %s\nRef: %s\nLabels: %s\nBody:\n%s\nComments:\n%s\n\n", - i+1, issue.Title, issue.Ref, strings.Join(issue.Labels, ", "), - truncateImportText(issue.Body, maxImportIssueBodyBytes), strings.Join(comments, "\n")) - if len(section) > maxImportPackBytes-packed.Len() { - break - } - packed.WriteString(section) - count++ - } - return packed.String(), count -} - -func truncateImportText(text string, max int) string { - if max <= 0 { - return "" - } - for len(text) > max { - _, size := utf8.DecodeLastRuneInString(text) - text = text[:len(text)-size] - } - return text -} - // logSafe makes an untrusted value safe to interpolate into a log line. The // CR/LF removal is spelled out as explicit replacements rather than folded into // stripControl's strings.Map because static analysis recognizes the former as a diff --git a/internal/server/ai_test.go b/internal/server/ai_test.go index f8ffa6f..951611a 100644 --- a/internal/server/ai_test.go +++ b/internal/server/ai_test.go @@ -166,25 +166,6 @@ func (r wireChatRequest) budget() int64 { return 0 } -// Import packs cap each issue body, each comment, and the total prompt while -// retaining a source index for every issue that fits into the bounded input. -func TestPackImportIssuesBoundsForgeText(t *testing.T) { - issues := make([]forgeIssue, maxImportIssues) - for i := range issues { - issues[i] = forgeIssue{ - Ref: fmt.Sprintf("gitlab#%d", i+1), - Title: "Import issue", - Body: strings.Repeat("é", maxImportIssueBodyBytes), - Labels: []string{"team::auth"}, - Comments: []string{strings.Repeat("界", maxImportCommentBytes)}, - } - } - packed, count := packImportIssues(issues) - if len(packed) > maxImportPackBytes || count == 0 || count > len(issues) { - t.Fatalf("pack len=%d count=%d, want <=%d and 1..%d", len(packed), count, maxImportPackBytes, len(issues)) - } -} - // configureAI stores AI settings for the open-mode "default" user through // the API so the key round-trips through encryption. func configureAI(t *testing.T, h http.Handler, baseURL, model, key string) { @@ -632,7 +613,7 @@ func TestAIStoriesFromForgeIssue(t *testing.T) { case "/forge/api/v4/projects/group%2Fproject/issues/42": _, _ = fmt.Fprintf(w, `{"iid":42,"title":"Issue title","description":%q,"web_url":"https://forge.example/group/project/-/issues/42"}`, body) case "/forge/api/v4/projects/group%2Fproject/issues/42/notes": - _, _ = fmt.Fprintf(w, `[{"body":%q,"system":false}]`, strings.Repeat("界", maxForgeCommentLen)) + _, _ = fmt.Fprintf(w, `[{"body":%q,"system":false}]`, strings.Repeat("界", 8<<10)) default: http.NotFound(w, r) } diff --git a/internal/server/coverage_errors_test.go b/internal/server/coverage_errors_test.go index 3238ea6..3277d61 100644 --- a/internal/server/coverage_errors_test.go +++ b/internal/server/coverage_errors_test.go @@ -8,11 +8,8 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "strings" "testing" - - "github.com/RandomCodeSpace/kb/internal/store" ) type coverageReadCloser struct { @@ -74,138 +71,6 @@ func TestReadBodyDistinguishesOversizeAndReaderFailure(t *testing.T) { } } -func TestForgeGetMapsTransportBodyAndCloseFailures(t *testing.T) { - ref := forgeRef{Kind: "gitlab", pat: "secret", Source: store.ForgeSource{Name: "primary"}} - tests := []struct { - name string - client *http.Client - ref forgeRef - }{ - {name: "nil client", ref: ref}, - {name: "invalid kind", ref: forgeRef{Kind: "other", Source: ref.Source}, client: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { t.Fatal("unexpected egress"); return nil, nil })}}, - {name: "transport failure", ref: ref, client: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("offline") })}}, - {name: "body read failure", ref: ref, client: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: coverageReadCloser{readErr: errors.New("read failed")}}, nil - })}}, - {name: "body close failure", ref: ref, client: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: coverageReadCloser{readErr: io.EOF, closeErr: errors.New("close failed")}}, nil - })}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := &server{forgeClient: tt.client} - if _, err := s.forgeGet(context.Background(), tt.ref, "https://forge.invalid", "/issues", nil); err == nil { - t.Fatal("forgeGet returned nil error") - } - }) - } -} - -func TestForgeGetBuildsBoundedAuthenticatedRequest(t *testing.T) { - var got *http.Request - s := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - got = r.Clone(r.Context()) - return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"X-Total": []string{"1"}}, Body: io.NopCloser(strings.NewReader(`[]`))}, nil - })}} - ref := forgeRef{Kind: "github", pat: "token", Source: store.ForgeSource{Name: "primary"}} - response, err := s.forgeGet(context.Background(), ref, "https://forge.invalid/api", "/issues", url.Values{"page": []string{"2"}}) - if err != nil { - t.Fatalf("forgeGet: %v", err) - } - if response.status != http.StatusOK || got == nil || got.URL.RawQuery != "page=2" { - t.Fatalf("response/request = %+v / %+v", response, got) - } - if got.Header.Get("Authorization") != "Bearer token" || got.Header.Get("Accept") != "application/vnd.github+json" { - t.Fatalf("forge headers = %v", got.Header) - } -} - -func TestForgeParsersRejectMalformedPayloadsAndKinds(t *testing.T) { - for _, kind := range []string{"gitlab", "github", "other"} { - t.Run(kind, func(t *testing.T) { - if _, err := parseForgeIssueList(kind, []byte(`{`)); err == nil { - t.Fatalf("parseForgeIssueList(%q) accepted malformed payload", kind) - } - }) - } - if _, err := forgeProjectPath(forgeRef{Kind: "github", Project: "missing-repo"}); err == nil { - t.Fatal("forgeProjectPath accepted an incomplete GitHub project") - } - if _, err := forgeProjectPath(forgeRef{Kind: "other", Project: "owner/repo"}); err == nil { - t.Fatal("forgeProjectPath accepted an invalid kind") - } - if got := forgeTotalHint(http.Header{"X-Total": []string{"invalid"}}); got != -1 { - t.Fatalf("forgeTotalHint(invalid) = %d", got) - } -} - -func TestForgeReferenceHelpersRejectMalformedPaths(t *testing.T) { - gitlab := store.ForgeSource{Name: "gl", Kind: "gitlab", BaseURL: "https://gitlab.example"} - github := store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://github.example"} - for _, path := range []string{"", "group//project", "/", "group/project/-/unknown/1", "group/project/-/issues/0", "group/project/-/issues/nope", "-/issues/1"} { - if _, err := parseGitLabRef(gitlab, path); err == nil { - t.Errorf("parseGitLabRef(%q) returned nil error", path) - } - } - for _, path := range []string{"", "owner", "owner/repo/pulls/1", "owner/repo/issues/0", "owner//issues/1"} { - if _, err := parseGitHubRef(github, path); err == nil { - t.Errorf("parseGitHubRef(%q) returned nil error", path) - } - } - for _, ref := range []forgeRef{{}, {Kind: "other", Project: "x"}, {Kind: "github", Project: "owner"}} { - if _, err := forgeIssuePath(ref); err == nil { - t.Errorf("forgeIssuePath(%+v) returned nil error", ref) - } - if _, err := forgeMilestonePath(ref); err == nil { - t.Errorf("forgeMilestonePath(%+v) returned nil error", ref) - } - if _, err := forgeProjectIssuesPath(ref); err == nil { - t.Errorf("forgeProjectIssuesPath(%+v) returned nil error", ref) - } - } - for _, raw := range []string{"", "0", "-1", "x"} { - if _, err := forgeRefID(raw); err == nil { - t.Errorf("forgeRefID(%q) returned nil error", raw) - } - } - for _, raw := range []string{"", " ", "ftp://forge.example", "https://user@forge.example", "https://forge.example?q=1", "https://forge.example#x"} { - if _, err := normalizeForgeProbeBase(raw); err == nil { - t.Errorf("normalizeForgeProbeBase(%q) returned nil error", raw) - } - } - if got := forgeURLPort(&url.URL{Scheme: "https", Host: "x"}); got != "443" { - t.Errorf("HTTPS default port = %q", got) - } - if got := forgeURLPort(&url.URL{Scheme: "http", Host: "x"}); got != "80" { - t.Errorf("HTTP default port = %q", got) - } - if got := forgeURLPort(&url.URL{Scheme: "https", Host: "x:8443"}); got != "8443" { - t.Errorf("explicit port = %q", got) - } -} - -func TestForgeListAndCommentHelpersMapInvalidResponses(t *testing.T) { - deny := roundTripperFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("offline") }) - s := &server{forgeClient: &http.Client{Transport: deny}} - ctx := context.Background() - for _, ref := range []forgeRef{{Kind: "other", Project: "x"}, {Kind: "github", Project: "bad"}} { - if _, _, err := s.forgeIssuesList(ctx, ref, "https://forge.invalid"); err == nil { - t.Errorf("forgeIssuesList(%+v) returned nil error", ref) - } - } - if _, err := s.fetchForgeComments(ctx, forgeRef{Kind: "other", Project: "x", Source: store.ForgeSource{Name: "x"}}, "https://forge.invalid", "/issues/1"); err == nil { - t.Fatal("fetchForgeComments accepted invalid kind") - } - comments := make([]string, maxForgeComments) - if got := appendBoundedForgeComment(comments, "extra"); len(got) != maxForgeComments { - t.Fatalf("bounded comments len = %d", len(got)) - } - long := strings.Repeat("界", maxForgeCommentLen) - if got := appendBoundedForgeComment(nil, long); len(got) != 1 || len(got[0]) > maxForgeCommentLen { - t.Fatalf("bounded unicode comment = %#v", got) - } -} - func TestRSAKeyFromJWKRejectsMalformedComponents(t *testing.T) { for _, tt := range []struct{ n, e string }{ {n: "%", e: "AQAB"}, @@ -397,37 +262,3 @@ func TestClosedStoreHandlersReturnServerErrorsWithoutEgress(t *testing.T) { }) } } - -func TestForgeDrainReportsReadAndCloseFailures(t *testing.T) { - for _, body := range []io.ReadCloser{ - coverageReadCloser{readErr: errors.New("read failed")}, - coverageReadCloser{readErr: io.EOF, closeErr: errors.New("close failed")}, - } { - if err := drainForgeResponse(&http.Response{Body: body}); err == nil { - t.Fatal("drainForgeResponse returned nil error") - } - } -} - -func TestAIValueCoercionAndTextBoundsCoverDefensiveBranches(t *testing.T) { - if got := coerceDraftMap(map[string]any{"title": ""}); got.Title != "" { - t.Fatalf("coerceDraftMap empty title = %+v", got) - } - if got := truncateImportText("hello", 0); got != "" { - t.Fatalf("truncate max zero = %q", got) - } - if got := truncateImportText("hello", 10); got != "hello" { - t.Fatalf("truncate short = %q", got) - } - if got := truncateImportText("界界", 4); len(got) > 4 { - t.Fatalf("truncate unicode = %q", got) - } - if got := stripControlKeepLines("a\x00b\r\nc\td"); got != "ab\ncd" { - t.Fatalf("stripControlKeepLines = %q", got) - } - for in, want := range map[int]int{-1: 1, 0: 1, 3: 3, 9: 4} { - if got := clampPrio(in); got != want { - t.Errorf("clampPrio(%d) = %d, want %d", in, got, want) - } - } -} diff --git a/internal/server/coverage_forge_paths_test.go b/internal/server/coverage_forge_paths_test.go deleted file mode 100644 index 62fbfbc..0000000 --- a/internal/server/coverage_forge_paths_test.go +++ /dev/null @@ -1,517 +0,0 @@ -package server - -import ( - "context" - "crypto/rsa" - "errors" - "io" - "math/big" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/RandomCodeSpace/kb/internal/store" -) - -func forgePathResponse(status int, body string, header http.Header) *http.Response { - if header == nil { - header = make(http.Header) - } - return &http.Response{ - StatusCode: status, - Header: header, - Body: io.NopCloser(strings.NewReader(body)), - } -} - -type forgeFailWriter struct{ header http.Header } - -func (w *forgeFailWriter) Header() http.Header { return w.header } -func (*forgeFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") } -func (*forgeFailWriter) WriteHeader(int) {} - -func TestFetchIssueLoadsGitLabAndGitHubDiscussion(t *testing.T) { - tests := []struct { - name string - ref forgeRef - rt roundTripperFunc - want forgeIssue - }{ - { - name: "gitlab", - ref: forgeRef{ - Kind: "gitlab", Project: "group/project", Issue: 7, pat: "gl-token", - Source: store.ForgeSource{Name: "gitlab", Kind: "gitlab", BaseURL: "https://gitlab.example"}, - }, - rt: func(r *http.Request) (*http.Response, error) { - if r.Header.Get("PRIVATE-TOKEN") != "gl-token" { - t.Fatalf("gitlab token = %q", r.Header.Get("PRIVATE-TOKEN")) - } - if strings.HasSuffix(r.URL.Path, "/notes") { - return forgePathResponse(http.StatusOK, `[ - {"body":"human note","system":false}, - {"body":"system note","system":true} - ]`, nil), nil - } - return forgePathResponse(http.StatusOK, `{ - "iid":7,"title":"GitLab issue","description":"body", - "web_url":"https://gitlab.example/group/project/-/issues/7", - "labels":["bug"] - }`, nil), nil - }, - want: forgeIssue{ - Ref: "gitlab#7", Title: "GitLab issue", Body: "body", - URL: "https://gitlab.example/group/project/-/issues/7", - Labels: []string{"bug"}, Comments: []string{"human note"}, - }, - }, - { - name: "github", - ref: forgeRef{ - Kind: "github", Project: "owner/repo", Issue: 9, pat: "gh-token", - Source: store.ForgeSource{Name: "github", Kind: "github", BaseURL: "https://github.com"}, - }, - rt: func(r *http.Request) (*http.Response, error) { - if r.Header.Get("Authorization") != "Bearer gh-token" { - t.Fatalf("github authorization = %q", r.Header.Get("Authorization")) - } - if strings.HasSuffix(r.URL.Path, "/comments") { - return forgePathResponse(http.StatusOK, `[{"body":"first"},{"body":"second"}]`, nil), nil - } - return forgePathResponse(http.StatusOK, `{ - "number":9,"title":"GitHub issue","body":"body", - "html_url":"https://github.com/owner/repo/issues/9", - "labels":[{"name":"feature"},{"name":""}] - }`, nil), nil - }, - want: forgeIssue{ - Ref: "github#9", Title: "GitHub issue", Body: "body", - URL: "https://github.com/owner/repo/issues/9", - Labels: []string{"feature"}, Comments: []string{"first", "second"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s := &server{forgeClient: &http.Client{Transport: tt.rt}} - got, err := s.fetchIssue(context.Background(), tt.ref) - if err != nil { - t.Fatalf("fetchIssue: %v", err) - } - if got.Ref != tt.want.Ref || got.Title != tt.want.Title || got.Body != tt.want.Body || got.URL != tt.want.URL { - t.Fatalf("issue = %+v, want %+v", got, tt.want) - } - if strings.Join(got.Labels, ",") != strings.Join(tt.want.Labels, ",") || - strings.Join(got.Comments, ",") != strings.Join(tt.want.Comments, ",") { - t.Fatalf("labels/comments = %v/%v, want %v/%v", got.Labels, got.Comments, tt.want.Labels, tt.want.Comments) - } - }) - } -} - -func TestFetchIssuesResolvesGitLabMilestoneAndPaginates(t *testing.T) { - var listCalls int - s := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - switch { - case strings.HasSuffix(r.URL.Path, "/milestones/7"): - return forgePathResponse(http.StatusOK, `{"title":"Release 1"}`, nil), nil - case strings.HasSuffix(r.URL.Path, "/issues"): - listCalls++ - if got := r.URL.Query().Get("milestone"); got != "Release 1" { - t.Fatalf("milestone query = %q", got) - } - if listCalls == 1 { - return forgePathResponse(http.StatusOK, `[ - {"iid":1,"title":"one","web_url":"https://gitlab.example/1"}, - {"iid":2,"title":"two","web_url":"https://gitlab.example/2"} - ]`, http.Header{"X-Total": {"3"}, "X-Next-Page": {"2"}}), nil - } - if got := r.URL.Query().Get("page"); got != "2" { - t.Fatalf("page query = %q", got) - } - return forgePathResponse(http.StatusOK, `[ - {"iid":3,"title":"three","web_url":"https://gitlab.example/3"} - ]`, http.Header{"X-Total": {"3"}}), nil - default: - t.Fatalf("unexpected forge request %s", r.URL.String()) - return nil, nil - } - })}} - ref := forgeRef{ - Kind: "gitlab", Project: "group/project", Milestone: 7, - Source: store.ForgeSource{Name: "gitlab", Kind: "gitlab", BaseURL: "https://gitlab.example"}, - } - issues, total, truncated, note, err := s.fetchIssues(context.Background(), ref, 10) - if err != nil { - t.Fatalf("fetchIssues: %v", err) - } - if len(issues) != 3 || total != 3 || truncated || note != "" || listCalls != 2 { - t.Fatalf("issues=%d total=%d truncated=%v note=%q calls=%d", len(issues), total, truncated, note, listCalls) - } -} - -func TestFetchIssuesGitHubTruncationAndFiltering(t *testing.T) { - ref := forgeRef{ - Kind: "github", Project: "owner/repo", - Source: store.ForgeSource{Name: "github", Kind: "github", BaseURL: "https://github.com"}, - } - t.Run("batch truncation filters pull requests", func(t *testing.T) { - s := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - if !strings.HasSuffix(r.URL.Path, "/issues") { - t.Fatalf("unexpected path %s", r.URL.Path) - } - return forgePathResponse(http.StatusOK, `[ - {"number":1,"title":"issue one","html_url":"https://github.com/owner/repo/issues/1"}, - {"number":2,"title":"pull request","pull_request":{"url":"https://api.github.com/pr/2"}}, - {"number":3,"title":"issue three","html_url":"https://github.com/owner/repo/issues/3"} - ]`, http.Header{"Link": {`; rel="next"`}}), nil - })}} - issues, total, truncated, note, err := s.fetchIssues(context.Background(), ref, 1) - if err != nil { - t.Fatalf("fetchIssues: %v", err) - } - if len(issues) != 1 || total != 2 || !truncated || note != "" || issues[0].Ref != "github#1" { - t.Fatalf("issues=%+v total=%d truncated=%v note=%q", issues, total, truncated, note) - } - }) - -} - -func TestFetchIssueAndCommentsRejectInvalidForgePayloads(t *testing.T) { - refs := []forgeRef{ - {Kind: "gitlab", Project: "group/project", Issue: 1, Source: store.ForgeSource{Name: "gl", Kind: "gitlab", BaseURL: "https://gitlab.example"}}, - {Kind: "github", Project: "owner/repo", Issue: 1, Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://github.com"}}, - } - for _, ref := range refs { - t.Run(ref.Kind, func(t *testing.T) { - responses := []string{"{", func() string { - if ref.Kind == "github" { - return `{"number":1,"pull_request":{"url":"x"}}` - } - return `{"iid":1}` - }()} - for _, body := range responses { - s := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return forgePathResponse(http.StatusOK, body, nil), nil - })}} - _, _, _, err := s.fetchIssueSnapshot(context.Background(), ref) - if body == "{" || ref.Kind == "github" { - if err == nil { - t.Fatalf("fetchIssueSnapshot(%q) returned nil error", body) - } - } - } - - s := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return forgePathResponse(http.StatusOK, `{`, nil), nil - })}} - if _, err := s.fetchForgeComments(context.Background(), ref, "https://forge.example", "/issue/1"); err == nil { - t.Fatal("fetchForgeComments accepted malformed JSON") - } - }) - } - - invalid := forgeRef{Kind: "github", Project: "owner/repo", Issue: 0, Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://github.com"}} - if _, _, _, err := (&server{}).fetchIssueSnapshot(context.Background(), invalid); err == nil { - t.Fatal("fetchIssueSnapshot accepted a non-positive issue") - } -} - -func TestForgeResidualControlFlow(t *testing.T) { - t.Run("single issue list path", func(t *testing.T) { - ref := forgeRef{Kind: "github", Project: "owner/repo", Issue: 4, Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://github.com"}} - s := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - if strings.HasSuffix(r.URL.Path, "/comments") { - return forgePathResponse(http.StatusOK, `[]`, nil), nil - } - return forgePathResponse(http.StatusOK, `{"number":4,"title":"one"}`, nil), nil - })}} - issues, total, truncated, note, err := s.fetchIssues(context.Background(), ref, 0) - if err != nil || len(issues) != 1 || total != 1 || truncated || note != "" { - t.Fatalf("fetchIssues = %#v, %d, %v, %q, %v", issues, total, truncated, note, err) - } - }) - - t.Run("comment status propagates", func(t *testing.T) { - ref := forgeRef{Kind: "github", Project: "owner/repo", Issue: 5, Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://github.com"}} - s := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { - if strings.HasSuffix(r.URL.Path, "/comments") { - return forgePathResponse(http.StatusInternalServerError, `no`, nil), nil - } - return forgePathResponse(http.StatusOK, `{"number":5,"title":"one"}`, nil), nil - })}} - if _, err := s.fetchIssue(context.Background(), ref); err == nil { - t.Fatal("fetchIssue accepted failed comment request") - } - }) - - t.Run("invalid snapshots and lists", func(t *testing.T) { - invalidBase := forgeRef{Kind: "github", Project: "owner/repo", Issue: 1, Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: ":"}} - if _, _, _, err := (&server{}).fetchIssueSnapshot(context.Background(), invalidBase); err == nil { - t.Fatal("snapshot accepted invalid base") - } - invalidProject := forgeRef{Kind: "github", Project: "owner", Issue: 1, Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://github.com"}} - if _, _, _, err := (&server{}).fetchIssueSnapshot(context.Background(), invalidProject); err == nil { - t.Fatal("snapshot accepted invalid project") - } - invalidList := invalidBase - invalidList.Issue = 0 - if _, _, _, _, err := (&server{}).fetchIssues(context.Background(), invalidList, 1); err == nil { - t.Fatal("list accepted invalid base") - } - }) - - t.Run("github milestone resolution and errors", func(t *testing.T) { - ref := forgeRef{Kind: "github", Project: "owner/repo", Milestone: 3, Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://github.com"}} - for _, tt := range []struct { - name string - status int - body string - fail bool - }{ - {name: "success", status: http.StatusOK, body: `{"number":8}`}, - {name: "bad status", status: http.StatusNotFound, body: `{}`, fail: true}, - {name: "bad payload", status: http.StatusOK, body: `{"number":0}`, fail: true}, - } { - t.Run(tt.name, func(t *testing.T) { - s := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return forgePathResponse(tt.status, tt.body, nil), nil - })}} - path, query, err := s.forgeIssuesList(context.Background(), ref, "https://api.github.com") - if tt.fail { - if err == nil { - t.Fatal("forgeIssuesList returned nil error") - } - return - } - if err != nil || path != "/repos/owner/repo/issues" || query.Get("milestone") != "8" { - t.Fatalf("path=%q query=%v err=%v", path, query, err) - } - }) - } - }) - - if validForgeSourceName("") || validForgeSourceName(strings.Repeat("a", 65)) { - t.Fatal("invalid forge source name accepted") - } - if _, err := forgeAPIBase("other", "https://example.com"); err == nil { - t.Fatal("invalid forge kind accepted") - } - if got := importRefKind(forgeRef{Issue: 1}); got != "issue" { - t.Fatalf("issue kind = %q", got) - } - if got := importRefKind(forgeRef{Milestone: 1}); got != "milestone" { - t.Fatalf("milestone kind = %q", got) - } - writeJSON(&forgeFailWriter{header: make(http.Header)}, map[string]bool{"ok": true}) -} - -func TestRejectedHandlerBodiesAndBoardJSONEdges(t *testing.T) { - s := &server{store: newTestStore(t)} - handlers := []struct { - name string - fn func(http.ResponseWriter, *http.Request, string) - }{ - {"tombstone", s.handleTombstone}, - {"import preview", s.handleImportPreview}, - {"import links", s.handleImportLinks}, - {"import provenance", s.handleImportProvenance}, - {"import drift", s.handleImportDrift}, - {"import drift accept", s.handleImportDriftAccept}, - {"put integration", s.handlePutIntegration}, - {"test integration", s.handleTestIntegration}, - {"ai test", s.handleAITest}, - } - for _, tt := range handlers { - t.Run(tt.name+" oversized", func(t *testing.T) { - r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", maxBodyBytes+1))) - r.SetPathValue("name", "primary") - w := httptest.NewRecorder() - tt.fn(w, r, "user") - if w.Code != http.StatusRequestEntityTooLarge { - t.Fatalf("status = %d", w.Code) - } - }) - } - for _, tt := range []struct { - name string - fn func(http.ResponseWriter, *http.Request, string) - }{ - {"tombstone", s.handleTombstone}, - {"import preview", s.handleImportPreview}, - {"import drift", s.handleImportDrift}, - {"import drift accept", s.handleImportDriftAccept}, - } { - t.Run(tt.name+" malformed", func(t *testing.T) { - w := httptest.NewRecorder() - tt.fn(w, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{`)), "user") - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d", w.Code) - } - }) - } - - invalidBoards := []string{ - `[]`, - `{"board":`, - `{"board":"x","board":"y","task_ids":[]}`, - `{"board":null,"task_ids":[]}`, - `{"board":"x","task_ids":`, - `{"board":"x","task_ids":null}`, - `{"board":"x","task_ids":{}}`, - `{"board":"x","other":[]}`, - `{"board":"x"}`, - `{"board":"x","task_ids":[]} {}`, - } - for _, body := range invalidBoards { - if _, _, err := parseBoardJSONPut([]byte(body)); err == nil { - t.Fatalf("parseBoardJSONPut accepted %q", body) - } - } -} - -func TestRemainingPureAndNetworkErrorBranches(t *testing.T) { - if got := normalizeGuardHost(" [::1]. "); got != "::1" { - t.Fatalf("normalized host = %q", got) - } - transport := guardedTransport(nil, false) - if _, err := transport.DialContext(context.Background(), "tcp", "invalid-address"); err == nil { - t.Fatal("guarded transport accepted invalid dial address") - } - draft := coerceDraftMap(map[string]any{"title": "x", "prio": "3", "due": "2026-01-01"}) - if draft.Prio != 3 || draft.Due != "2026-01-01" { - t.Fatalf("draft = %+v", draft) - } - if got := forgeIssueADR(forgeIssue{Title: "x", Comments: []string{"one"}}); !strings.Contains(got, "- one") { - t.Fatalf("ADR = %q", got) - } - comments := make([]string, 11) - for i := range comments { - comments[i] = "comment" - } - if _, count := packImportIssues([]forgeIssue{{Title: "x", Comments: comments}}); count != 1 { - t.Fatalf("packed count = %d", count) - } - - badKind := store.ForgeSource{Name: "bad", Kind: "other", BaseURL: "https://forge.example"} - if _, err := parseForgeRef([]store.ForgeSource{badKind}, "bad", "https://forge.example/group/project"); err == nil { - t.Fatal("parseForgeRef accepted invalid kind") - } - if _, err := parseForgeRef(nil, "", "ftp://forge.example/project"); err == nil { - t.Fatal("parseForgeRef accepted invalid scheme") - } - gl := store.ForgeSource{Name: "gl", Kind: "gitlab", BaseURL: "https://gitlab.example"} - ref, err := parseForgeRef([]store.ForgeSource{gl}, "gl", "https://gitlab.example/group/milestones/7") - if err != nil || ref.Milestone != 7 { - t.Fatalf("milestone ref = %+v, err=%v", ref, err) - } - - requestRef := forgeRef{Kind: "github", Project: "owner/repo", Source: store.ForgeSource{Name: "gh"}} - if _, err := (&server{}).forgeGet(context.Background(), requestRef, "\x7f", "", nil); err == nil { - t.Fatal("forgeGet accepted invalid endpoint") - } - - listRef := forgeRef{Kind: "github", Project: "owner/repo", Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://github.com"}} - for _, tt := range []struct { - name string - status int - body string - link string - fail bool - }{ - {name: "bad status", status: http.StatusInternalServerError, body: `[]`, fail: true}, - {name: "malformed", status: http.StatusOK, body: `{`, fail: true}, - {name: "exact max", status: http.StatusOK, body: `[{"number":1,"title":"one"}]`, link: `; rel="next"`}, - } { - t.Run(tt.name, func(t *testing.T) { - s := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return forgePathResponse(tt.status, tt.body, http.Header{"Link": {tt.link}}), nil - })}} - issues, _, truncated, _, err := s.fetchIssues(context.Background(), listRef, 1) - if tt.fail { - if err == nil { - t.Fatal("fetchIssues returned nil error") - } - return - } - if err != nil || len(issues) != 1 || !truncated { - t.Fatalf("issues=%v truncated=%v err=%v", issues, truncated, err) - } - }) - } - - s := &server{authenticate: func(*http.Request) (string, error) { return "../bad", nil }} - w := httptest.NewRecorder() - s.withAuth(func(http.ResponseWriter, *http.Request, string) { t.Fatal("unexpected authenticated call") })(w, httptest.NewRequest(http.MethodGet, "/", nil)) - if w.Code != http.StatusBadRequest { - t.Fatalf("invalid identity status = %d", w.Code) - } - - cache := &jwksCache{ - url: "https://jwks.invalid", - client: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return nil, errors.New("offline") - })}, - keys: map[string]*rsa.PublicKey{"cached": {N: rsaKeyN(), E: 3}}, - fetched: time.Now().Add(-2 * time.Hour), - } - if key, err := cache.key("cached"); err != nil || key == nil { - t.Fatalf("cached JWKS fallback = %v, %v", key, err) - } - if _, err := rsaKeyFromJWK("AQ", "AAM"); err != nil { - t.Fatalf("leading-zero exponent rejected: %v", err) - } - - t.Run("remaining request failures", func(t *testing.T) { - ref := forgeRef{Kind: "github", Project: "owner/repo", Issue: 2, Source: store.ForgeSource{Name: "gh", Kind: "github", BaseURL: "https://github.com"}} - offline := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return nil, errors.New("offline") - })}} - if _, _, _, _, err := offline.fetchIssues(context.Background(), ref, 1); err == nil { - t.Fatal("single issue fetch accepted transport failure") - } - ref.Issue, ref.Milestone = 0, 3 - if _, _, err := offline.forgeIssuesList(context.Background(), ref, "https://api.github.com"); err == nil { - t.Fatal("milestone fetch accepted transport failure") - } - - gitlab := forgeRef{Kind: "gitlab", Project: "group/project", Milestone: 3, Source: store.ForgeSource{Name: "gl", Kind: "gitlab", BaseURL: "https://gitlab.example"}} - malformed := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return forgePathResponse(http.StatusOK, `{`, nil), nil - })}} - if _, _, err := malformed.forgeIssuesList(context.Background(), gitlab, "https://gitlab.example/api/v4"); err == nil { - t.Fatal("GitLab milestone accepted malformed payload") - } - }) - - badJWKS := &jwksCache{ - url: "https://jwks.invalid", - client: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return forgePathResponse(http.StatusOK, `{"keys":[{"kty":"RSA","kid":"bad","n":"!","e":"AQAB"}]}`, nil), nil - })}, - } - if err := badJWKS.fetchLocked(); err == nil { - t.Fatal("JWKS accepted unusable RSA key") - } - - badType := httptest.NewRequest(http.MethodPost, "/api/board", strings.NewReader("x")) - badType.Header.Set("Content-Type", `application/json; broken`) - if contentTypeAllowed(badType) { - t.Fatal("malformed content type accepted") - } - - put := httptest.NewRequest(http.MethodPut, "/api/board", strings.NewReader("# Board")) - put.Header.Set("Content-Type", "text/markdown") - put.Header.Set("If-Match", "*") - put.Header.Set("Idempotency-Key", "not-a-uuid") - putResult := httptest.NewRecorder() - (&server{store: newTestStore(t)}).handlePutBoard(putResult, put, "user") - if putResult.Code != http.StatusBadRequest { - t.Fatalf("invalid idempotency status = %d", putResult.Code) - } -} - -func rsaKeyN() *big.Int { return big.NewInt(3) } diff --git a/internal/server/forge.go b/internal/server/forge.go index 10e5d68..49eb91d 100644 --- a/internal/server/forge.go +++ b/internal/server/forge.go @@ -3,37 +3,19 @@ package server import ( "bytes" "context" - "crypto/sha256" "encoding/json" "errors" - "fmt" - "io" "log" "net/http" - "net/url" - "os" - "slices" - "strconv" - "strings" - "time" - "unicode/utf8" - - "github.com/RandomCodeSpace/rig" + kbai "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/forge" "github.com/RandomCodeSpace/kb/internal/store" ) -const ( - forgeTimeout = 20 * time.Second - maxForgeDrainBytes = 64 << 10 - maxForgeBodyBytes = 2 << 20 - maxImportIssues = 20 - maxForgeComments = 20 - maxForgeCommentLen = 1 << 10 - importFetchTimeout = 25 * time.Second - maxImportLinks = 100 - invalidIntegrationNameMessage = "invalid integration name" -) +const importFetchTimeout = forge.ImportFetchTimeout +const importPartialTransformNote = "the assistant stopped early — some issues produced no draft" +const invalidIntegrationNameMessage = "invalid integration name" type forgeSourceResponse struct { Name string `json:"name"` @@ -46,46 +28,29 @@ type forgeSourcesResponse struct { Sources []forgeSourceResponse `json:"sources"` } -type forgeTestResponse struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` -} - -type forgeTestProbe struct { - BaseURL *string `json:"base_url"` - PAT *string `json:"pat"` -} - -type forgeTestTarget struct { - baseURL string - pat string -} - -type forgeRef struct { - Source store.ForgeSource - Kind string - Project string - Issue int - Milestone int +type forgeIssue = forge.Issue - // pat is populated only after the request owner decrypts its selected source. - // Keeping it private prevents the parser and response paths from exposing it. - pat string +type importDuplicate struct { + ID string `json:"id"` + Title string `json:"title"` + Via string `json:"via"` } -type forgeIssue struct { - Ref string - Title string - Body string - URL string - Labels []string - Comments []string +type importPreviewDraft struct { + storyDraft + Link string `json:"link,omitempty"` + ExternalKey string `json:"external_key,omitempty"` + URL string `json:"url,omitempty"` + DuplicateOf *importDuplicate `json:"duplicate_of,omitempty"` } -type forgeHTTPResponse struct { - status int - header http.Header - body []byte +type importPreviewResponse struct { + Kind string `json:"kind"` + TotalHint int `json:"total_hint"` + Fetched int `json:"fetched"` + Truncated bool `json:"truncated"` + Note string `json:"note"` + Drafts []importPreviewDraft `json:"drafts"` } type importPreviewRequest struct { @@ -96,39 +61,18 @@ type importPreviewRequest struct { type importLinksRequest struct { Source string `json:"source"` - Items []importLinksItem `json:"items"` + Items []forge.LinkInput `json:"items"` } type importProvenanceRequest struct { Link string `json:"link"` } -type importProvenanceItem struct { - Source string `json:"source"` - ExternalKey string `json:"external_key"` - Title string `json:"title"` - URL string `json:"url"` -} - -type importProvenanceResponse struct { - Items []importProvenanceItem `json:"items"` -} - type importDriftRequest struct { Source string `json:"source"` ExternalKey string `json:"external_key"` } -type importDriftAcceptRequest struct { - Source string `json:"source"` - ExternalKey string `json:"external_key"` - Revision string `json:"revision"` -} - -type importDriftAcceptResponse struct { - BaselineAt string `json:"baseline_at"` -} - type importDriftResponse struct { State string `json:"state"` Link string `json:"link"` @@ -142,1549 +86,254 @@ type importDriftResponse struct { Revision string `json:"revision,omitempty"` } -type importLinksItem struct { - ExternalKey string `json:"external_key"` - Link string `json:"link"` - URL string `json:"url"` - Title string `json:"title"` -} - -type importDuplicate struct { - ID string `json:"id"` - Title string `json:"title"` - Via string `json:"via"` -} - -type importPreviewDraft struct { - storyDraft - Link string `json:"link,omitempty"` - ExternalKey string `json:"external_key,omitempty"` - URL string `json:"url,omitempty"` - DuplicateOf *importDuplicate `json:"duplicate_of,omitempty"` -} - -type importPreviewResponse struct { - Kind string `json:"kind"` - TotalHint int `json:"total_hint"` - Fetched int `json:"fetched"` - Truncated bool `json:"truncated"` - Note string `json:"note"` - Drafts []importPreviewDraft `json:"drafts"` +type importDriftAcceptResponse struct { + BaselineAt string `json:"baseline_at"` } -type gitLabIssue struct { - IID int `json:"iid"` - Title string `json:"title"` - Description string `json:"description"` - WebURL string `json:"web_url"` - Labels []string `json:"labels"` +type importProvenanceItem struct { + Source string `json:"source"` + ExternalKey string `json:"external_key"` + Title string `json:"title"` + URL string `json:"url"` } -type gitHubIssue struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - HTMLURL string `json:"html_url"` - Labels []struct { - Name string `json:"name"` - } `json:"labels"` - PullRequest json.RawMessage `json:"pull_request"` +type importProvenanceResponse struct { + Items []importProvenanceItem `json:"items"` } -type gitLabNote struct { - Body string `json:"body"` - System bool `json:"system"` +type importDriftAcceptRequest struct { + Source string `json:"source"` + ExternalKey string `json:"external_key"` + Revision string `json:"revision"` } -type gitHubComment struct { - Body string `json:"body"` +type forgeTestResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` } -// parseForgeRef accepts only configured forge URLs so later fetches can select -// the corresponding stored credential without ever following arbitrary hosts. -func parseForgeRef(sources []store.ForgeSource, sourceName, raw string) (forgeRef, error) { - raw = strings.TrimSpace(raw) - if isBareForgeProject(raw) { - source, ok := forgeSourceByName(sources, sourceName) - if !ok { - if sourceName == "" { - return forgeRef{}, errors.New("no configured source named") - } - return forgeRef{}, fmt.Errorf("no configured source named %s", sourceName) - } - if source.Kind != "github" { - return forgeRef{}, errors.New("bare reference requires GitHub source") - } - return forgeRef{Source: source, Kind: source.Kind, Project: raw}, nil - } - - u, err := url.ParseRequestURI(raw) - if err != nil || u.Scheme == "" || u.Hostname() == "" || u.User != nil || - (u.Scheme != "http" && u.Scheme != "https") { - return forgeRef{}, errors.New("invalid forge reference") - } - - source, path, ok := configuredForgeSource(sources, sourceName, u) - if !ok { - return forgeRef{}, fmt.Errorf("no configured source for host %s", u.Hostname()) - } - switch source.Kind { - case "gitlab": - return parseGitLabRef(source, path) - case "github": - return parseGitHubRef(source, path) - default: - return forgeRef{}, errors.New("invalid forge kind") - } -} +func newForgeClient() *http.Client { return forge.NewHTTPClient() } -func isBareForgeProject(raw string) bool { - if raw == "" || strings.ContainsAny(raw, ":?#") { - return false - } - parts := strings.Split(raw, "/") - return len(parts) == 2 && parts[0] != "" && parts[1] != "" +func (s *server) sharedForge() *forge.Service { + s.forgeOnce.Do(func() { + s.forgeEngine = forge.New(s.store, s.aiRunner(), s.forgeClient) + }) + return s.forgeEngine } -func forgeSourceByName(sources []store.ForgeSource, name string) (store.ForgeSource, bool) { - for _, source := range sources { - if strings.EqualFold(source.Name, name) { - return source, true - } +func serverForgeError(err error) error { + if err == nil { + return nil } - return store.ForgeSource{}, false -} - -// configuredForgeSource compares origins and whole path segments rather than -// raw strings, so a source at /forge cannot authorize /forgeish by accident. -// Equal-length configured bases are disambiguated by the caller's source name; -// a longer base always wins regardless of that selection. -func configuredForgeSource(sources []store.ForgeSource, sourceName string, request *url.URL) (store.ForgeSource, string, bool) { - bestLength := -1 - var best store.ForgeSource - bestPath := "" - requestPath := strings.TrimRight(request.Path, "/") - for _, source := range sources { - base, err := normalizeForgeProbeBase(source.BaseURL) - if err != nil || !sameForgeOrigin(base, request) { - continue - } - basePath := strings.TrimRight(base.Path, "/") - var path string - switch { - case basePath == "": - path = strings.TrimPrefix(requestPath, "/") - case requestPath == basePath: - path = "" - case strings.HasPrefix(requestPath, basePath+"/"): - path = strings.TrimPrefix(requestPath, basePath+"/") - default: - continue - } - length := len(base.Scheme) + len(base.Host) + len(basePath) - if length > bestLength || (length == bestLength && strings.EqualFold(source.Name, sourceName)) { - bestLength = length - best = source - bestPath = path - } + var categorized *forge.Error + if errors.As(err, &categorized) { + return &aiError{code: categorized.Code, msg: categorized.Message} } - return best, bestPath, bestLength >= 0 -} - -func sameForgeOrigin(a, b *url.URL) bool { - return strings.EqualFold(a.Scheme, b.Scheme) && - strings.EqualFold(a.Hostname(), b.Hostname()) && - forgeURLPort(a) == forgeURLPort(b) + return err } -func forgeURLPort(u *url.URL) string { - if port := u.Port(); port != "" { - return port - } - if u.Scheme == "https" { - return "443" +func (s *server) handleImportPreview(w http.ResponseWriter, r *http.Request, user string) { + var request importPreviewRequest + if !decodeForgeRequest(w, r, &request) { + return } - return "80" -} - -func parseGitLabRef(source store.ForgeSource, path string) (forgeRef, error) { - parts, err := forgePathParts(path) + extendWriteDeadline(w) + preview, err := s.sharedForge().Preview(r.Context(), user, forge.PreviewRequest{Source: request.Source, Ref: request.Ref, Max: request.Max}) if err != nil { - return forgeRef{}, err - } - n := len(parts) - if n >= 3 && parts[n-3] == "-" { - return parseGitLabScopedRef(source, parts[:n-3], parts[n-2], parts[n-1]) + writeSharedForgeError(w, user, "import preview", err) + return } - if n >= 3 && (parts[n-2] == "issues" || parts[n-2] == "milestones") { - ref, err := parseGitLabScopedRef(source, parts[:n-2], parts[n-2], parts[n-1]) - if err == nil { - return ref, nil + response := importPreviewResponse{Kind: preview.Kind, TotalHint: preview.TotalHint, Fetched: preview.Fetched, Truncated: preview.Truncated, Note: preview.Note, Drafts: make([]importPreviewDraft, 0, len(preview.Drafts))} + for _, draft := range preview.Drafts { + item := importPreviewDraft{storyDraft: draft.Draft, Link: draft.Link, ExternalKey: draft.ExternalKey, URL: draft.URL} + if draft.Duplicate != nil { + item.DuplicateOf = &importDuplicate{ID: draft.Duplicate.ID, Title: draft.Duplicate.Title, Via: draft.Duplicate.Via} } + response.Drafts = append(response.Drafts, item) } - if slices.Contains(parts, "-") { - return forgeRef{}, errors.New("invalid forge reference") - } - return forgeRef{Source: source, Kind: source.Kind, Project: strings.Join(parts, "/")}, nil + writeJSON(w, response) } -func parseGitLabScopedRef(source store.ForgeSource, projectParts []string, resource, rawID string) (forgeRef, error) { - project := strings.Join(projectParts, "/") - if project == "" { - return forgeRef{}, errors.New("invalid forge reference") - } - id, err := forgeRefID(rawID) - if err != nil { - return forgeRef{}, err +func (s *server) handleImportLinks(w http.ResponseWriter, r *http.Request, user string) { + var request importLinksRequest + if !decodeForgeRequest(w, r, &request) { + return } - ref := forgeRef{Source: source, Kind: source.Kind, Project: project} - switch resource { - case "issues": - ref.Issue = id - case "milestones": - ref.Milestone = id - case "boards": - // Phase 1 resolves a board to its project; list and label filters are out of scope. - default: - return forgeRef{}, errors.New("invalid forge reference") + if err := s.sharedForge().RecordLinks(user, request.Source, request.Items); err != nil { + writeSharedForgeError(w, user, "import links", err) + return } - return ref, nil + w.WriteHeader(http.StatusNoContent) } -func parseGitHubRef(source store.ForgeSource, path string) (forgeRef, error) { - parts, err := forgePathParts(path) - if err != nil { - return forgeRef{}, err - } - if len(parts) == 2 { - return forgeRef{Source: source, Kind: source.Kind, Project: strings.Join(parts, "/")}, nil - } - if len(parts) != 4 || (parts[2] != "issues" && parts[2] != "milestone") { - return forgeRef{}, errors.New("invalid forge reference") +func (s *server) handleImportProvenance(w http.ResponseWriter, r *http.Request, user string) { + var request importProvenanceRequest + if !decodeForgeRequest(w, r, &request) { + return } - id, err := forgeRefID(parts[3]) + links, err := s.sharedForge().Provenance(user, request.Link) if err != nil { - return forgeRef{}, err - } - ref := forgeRef{Source: source, Kind: source.Kind, Project: strings.Join(parts[:2], "/")} - if parts[2] == "issues" { - ref.Issue = id - } else { - ref.Milestone = id - } - return ref, nil -} - -func forgePathParts(path string) ([]string, error) { - path = strings.Trim(path, "/") - if path == "" { - return nil, errors.New("invalid forge reference") - } - parts := strings.Split(path, "/") - if slices.Contains(parts, "") { - return nil, errors.New("invalid forge reference") + writeSharedForgeError(w, user, "import provenance", err) + return } - return parts, nil -} - -func forgeRefID(raw string) (int, error) { - id, err := strconv.Atoi(raw) - if err != nil || id <= 0 { - return 0, errors.New("invalid forge reference") + response := importProvenanceResponse{Items: make([]importProvenanceItem, 0, len(links))} + for _, link := range links { + response.Items = append(response.Items, importProvenanceItem{Source: link.Source, ExternalKey: link.ExternalKey, Title: link.Title, URL: link.URL}) } - return id, nil + writeJSON(w, response) } -// fetchIssue loads one forge issue and its bounded human discussion for the -// import pipeline. The caller already chose the configured source in D1. -func (s *server) fetchIssue(ctx context.Context, ref forgeRef) (forgeIssue, error) { - issue, apiBase, issuePath, err := s.fetchIssueSnapshot(ctx, ref) - if err != nil { - return forgeIssue{}, err +func (s *server) handleImportDrift(w http.ResponseWriter, r *http.Request, user string) { + var request importDriftRequest + if !decodeForgeRequest(w, r, &request) { + return } - comments, err := s.fetchForgeComments(ctx, ref, apiBase, issuePath) + result, err := s.sharedForge().CheckDrift(r.Context(), user, request.Source, request.ExternalKey) if err != nil { - return forgeIssue{}, err + writeSharedForgeError(w, user, "import drift", err) + return } - issue.Comments = comments - return issue, nil + writeJSON(w, importDriftResponse{State: result.State, Link: result.Link, URL: result.URL, TitleChanged: result.TitleChanged, UpstreamTitle: result.UpstreamTitle, BaselineTitle: result.BaselineTitle, BaselineAt: result.BaselineAt, CheckedAt: result.CheckedAt, Summary: result.Summary, Revision: result.Revision}) } -// fetchIssueSnapshot reads only the issue record. Acceptance needs no -// discussion, so keeping this distinct prevents an unnecessary second egress. -func (s *server) fetchIssueSnapshot(ctx context.Context, ref forgeRef) (forgeIssue, string, string, error) { - if ref.Issue <= 0 { - return forgeIssue{}, "", "", forgeRequestError(ref, "") - } - apiBase, err := forgeAPIBase(ref.Kind, ref.Source.BaseURL) - if err != nil { - return forgeIssue{}, "", "", forgeRequestError(ref, "") - } - issuePath, err := forgeIssuePath(ref) - if err != nil { - return forgeIssue{}, "", "", forgeRequestError(ref, "") +func (s *server) handleImportDriftAccept(w http.ResponseWriter, r *http.Request, user string) { + var request importDriftAcceptRequest + if !decodeForgeRequest(w, r, &request) { + return } - response, err := s.forgeGet(ctx, ref, apiBase, issuePath, nil) + at, err := s.sharedForge().AcceptDrift(r.Context(), user, request.Source, request.ExternalKey, request.Revision) if err != nil { - return forgeIssue{}, "", "", err - } - if response.status < http.StatusOK || response.status >= http.StatusMultipleChoices { - return forgeIssue{}, "", "", forgeRequestError(ref, issuePath) - } - - var issue forgeIssue - switch ref.Kind { - case "gitlab": - var raw gitLabIssue - if err := json.Unmarshal(response.body, &raw); err != nil { - return forgeIssue{}, "", "", forgeRequestError(ref, issuePath) - } - issue = forgeIssue{Ref: fmt.Sprintf("gitlab#%d", raw.IID), Title: raw.Title, Body: raw.Description, URL: raw.WebURL, Labels: raw.Labels} - case "github": - var raw gitHubIssue - if err := json.Unmarshal(response.body, &raw); err != nil || len(raw.PullRequest) != 0 { - return forgeIssue{}, "", "", forgeRequestError(ref, issuePath) + if errors.Is(err, forge.ErrUpstreamChanged) { + http.Error(w, "upstream changed; check again", http.StatusConflict) + return } - issue = gitHubForgeIssue(raw) - default: - return forgeIssue{}, "", "", forgeRequestError(ref, issuePath) + writeSharedForgeError(w, user, "import drift accept", err) + return } - return issue, apiBase, issuePath, nil + writeJSON(w, importDriftAcceptResponse{BaselineAt: at}) } -// fetchIssues loads open project, board, or milestone issues. Board filtering -// deliberately remains out of scope: D1 resolves boards to their project. -func (s *server) fetchIssues(ctx context.Context, ref forgeRef, max int) (issues []forgeIssue, totalHint int, truncated bool, note string, err error) { - if ref.Issue > 0 { - issue, err := s.fetchIssue(ctx, ref) - if err != nil { - return nil, 0, false, "", err - } - return []forgeIssue{issue}, 1, false, "", nil - } - if max <= 0 || max > maxImportIssues { - max = maxImportIssues - } - apiBase, err := forgeAPIBase(ref.Kind, ref.Source.BaseURL) - if err != nil { - return nil, 0, false, "", forgeRequestError(ref, "") +func decodeForgeRequest(w http.ResponseWriter, r *http.Request, target any) bool { + body, ok := readBody(w, r) + if !ok { + return false } - listPath, query, err := s.forgeIssuesList(ctx, ref, apiBase) - if err != nil { - return nil, 0, false, "", err + if err := json.Unmarshal(body, target); err != nil { + http.Error(w, invalidJSONBodyMessage, http.StatusBadRequest) + return false } - return s.fetchIssuePages(ctx, ref, apiBase, listPath, query, max) -} - -type forgeIssuePageState struct { - issues []forgeIssue - totalHint int - fallbackTotal int - hasGitLabTotal bool - truncated bool - note string + return true } -func (s *server) fetchIssuePages(ctx context.Context, ref forgeRef, apiBase, listPath string, query url.Values, max int) ([]forgeIssue, int, bool, string, error) { - state := forgeIssuePageState{} - page := 1 - for { - if page > 1 { - query.Set("page", strconv.Itoa(page)) - } - response, err := s.forgeGet(ctx, ref, apiBase, listPath, query) - if err != nil { - return nil, 0, false, "", err - } - state.observeTotal(ref.Kind, response.header) - if forgeRateLimited(ref.Kind, response) { - state.markRateLimited() - return state.result() - } - if response.status < http.StatusOK || response.status >= http.StatusMultipleChoices { - return nil, 0, false, "", forgeRequestError(ref, listPath) - } - - batch, err := parseForgeIssueList(ref.Kind, response.body) - if err != nil { - return nil, 0, false, "", forgeRequestError(ref, listPath) +func writeSharedForgeError(w http.ResponseWriter, user, operation string, err error) { + code, message := http.StatusBadGateway, connectionFailedMessage + var categorized *forge.Error + if errors.As(err, &categorized) { + code = categorized.Code + if code != http.StatusBadGateway { + message = categorized.Message } - if state.appendBatch(batch, max, forgeHasNextPage(ref.Kind, response.header)) { - break + } else { + var categorizedAI *kbai.Error + if errors.As(err, &categorizedAI) { + code = categorizedAI.Code + if code != http.StatusBadGateway { + message = categorizedAI.Message + } } - page++ } - return state.result() + if code == http.StatusBadGateway { + log.Printf("forge: %s for %s failed: %v", operation, user, err) + } + http.Error(w, message, code) } -func (state *forgeIssuePageState) observeTotal(kind string, header http.Header) { - if kind != "gitlab" { +func (s *server) handleGetIntegrations(w http.ResponseWriter, r *http.Request, user string) { + sources, err := s.sharedForge().Sources(user) + if err != nil { + writeSharedForgeError(w, user, "list integrations", err) return } - total := forgeTotalHint(header) - if total >= 0 { - state.totalHint = total - state.hasGitLabTotal = true - } -} - -func (state *forgeIssuePageState) markRateLimited() { - if !state.hasGitLabTotal { - state.totalHint = state.fallbackTotal + response := forgeSourcesResponse{Sources: make([]forgeSourceResponse, 0, len(sources))} + for _, source := range sources { + response.Sources = append(response.Sources, forgeSourceResponse{Name: source.Name, Kind: source.Kind, BaseURL: source.BaseURL, HasToken: source.HasToken}) } - state.truncated = true - state.note = fmt.Sprintf("rate limited — partial results (%d of %d)", len(state.issues), state.totalHint) + writeJSON(w, response) } -func (state *forgeIssuePageState) appendBatch(batch []forgeIssue, max int, hasNext bool) bool { - state.fallbackTotal += len(batch) - remaining := max - len(state.issues) - if len(batch) > remaining { - state.issues = append(state.issues, batch[:remaining]...) - state.truncated = true - return true +func (s *server) handlePutIntegration(w http.ResponseWriter, r *http.Request, user string) { + var request struct { + Kind string `json:"kind"` + BaseURL *string `json:"base_url"` + PAT *string `json:"pat"` } - state.issues = append(state.issues, batch...) - if len(state.issues) >= max { - state.truncated = hasNext - return true + if !decodeForgeRequest(w, r, &request) { + return } - return !hasNext -} - -func (state *forgeIssuePageState) result() ([]forgeIssue, int, bool, string, error) { - if !state.hasGitLabTotal { - state.totalHint = state.fallbackTotal + cleared, err := s.sharedForge().SaveSource(user, r.PathValue("name"), request.Kind, request.BaseURL, request.PAT) + if err != nil { + writeSharedForgeError(w, user, "save integration", err) + return } - if state.totalHint > len(state.issues) { - state.truncated = true + if cleared { + writeJSON(w, map[string]bool{"token_cleared": true}) + return } - return state.issues, state.totalHint, state.truncated, state.note, nil + w.WriteHeader(http.StatusNoContent) } -func (s *server) forgeIssuesList(ctx context.Context, ref forgeRef, apiBase string) (string, url.Values, error) { - listPath, query, err := forgeIssueListRequest(ref) - if err != nil { - return "", nil, forgeRequestError(ref, listPath) - } - if ref.Milestone == 0 { - return listPath, query, nil - } - milestonePath, err := forgeMilestonePath(ref) - if err != nil { - return "", nil, forgeRequestError(ref, "") - } - response, err := s.forgeGet(ctx, ref, apiBase, milestonePath, nil) - if err != nil { - return "", nil, err - } - if response.status < http.StatusOK || response.status >= http.StatusMultipleChoices { - return "", nil, forgeRequestError(ref, milestonePath) - } - if !setForgeMilestoneQuery(ref.Kind, query, response.body) { - return "", nil, forgeRequestError(ref, milestonePath) +func (s *server) handleDeleteIntegration(w http.ResponseWriter, r *http.Request, user string) { + if err := s.sharedForge().DeleteSource(user, r.PathValue("name")); err != nil { + writeSharedForgeError(w, user, "delete integration", err) + return } - return listPath, query, nil + w.WriteHeader(http.StatusNoContent) } -func forgeIssueListRequest(ref forgeRef) (string, url.Values, error) { - listPath, err := forgeProjectIssuesPath(ref) - if err != nil { - return "", nil, err +func (s *server) handleTestIntegration(w http.ResponseWriter, r *http.Request, user string) { + if !forge.ValidSourceName(r.PathValue("name")) { + http.Error(w, invalidIntegrationNameMessage, http.StatusBadRequest) + return } - query := url.Values{"per_page": {"50"}} - switch ref.Kind { - case "gitlab": - query.Set("state", "opened") - case "github": - query.Set("state", "open") - default: - return listPath, nil, errors.New("invalid forge kind") + body, ok := readBody(w, r) + if !ok { + return } - return listPath, query, nil -} - -func setForgeMilestoneQuery(kind string, query url.Values, body []byte) bool { - switch kind { - case "gitlab": - var milestone struct { - Title string `json:"title"` - } - if err := json.Unmarshal(body, &milestone); err != nil || milestone.Title == "" { - return false - } - query.Set("milestone", milestone.Title) - case "github": - var milestone struct { - Number int `json:"number"` - } - if err := json.Unmarshal(body, &milestone); err != nil || milestone.Number <= 0 { - return false - } - query.Set("milestone", strconv.Itoa(milestone.Number)) - default: - return false + var request struct { + BaseURL *string `json:"base_url"` + PAT *string `json:"pat"` } - return true -} - -func (s *server) fetchForgeComments(ctx context.Context, ref forgeRef, apiBase, issuePath string) ([]string, error) { - commentsPath := issuePath + "/comments" - if ref.Kind == "gitlab" { - commentsPath = issuePath + "/notes" + if len(bytes.TrimSpace(body)) > 0 { + if err := json.Unmarshal(body, &request); err != nil { + http.Error(w, invalidJSONBodyMessage, http.StatusBadRequest) + return + } } - response, err := s.forgeGet(ctx, ref, apiBase, commentsPath, url.Values{"per_page": {"50"}}) - if err != nil { - return nil, err + config := forge.ForgeProbeConfig{Name: r.PathValue("name"), Saved: true} + if request.BaseURL != nil { + config.BaseURL = *request.BaseURL } - if response.status < http.StatusOK || response.status >= http.StatusMultipleChoices { - return nil, forgeRequestError(ref, commentsPath) + if request.PAT != nil { + config.Token = *request.PAT } - - comments, err := parseForgeComments(ref.Kind, response.body) - if err != nil { - return nil, forgeRequestError(ref, commentsPath) + if err := s.sharedForge().Probe(r.Context(), user, config); err != nil { + writeJSON(w, forgeTestResponse{Error: err.Error()}) + return } - return comments, nil + writeJSON(w, forgeTestResponse{OK: true}) } -func parseForgeComments(kind string, body []byte) ([]string, error) { - comments := make([]string, 0, maxForgeComments) - switch kind { - case "gitlab": - var notes []gitLabNote - if err := json.Unmarshal(body, ¬es); err != nil { - return nil, err - } - for _, note := range notes { - if !note.System { - comments = appendBoundedForgeComment(comments, note.Body) - } - } - case "github": - var raw []gitHubComment - if err := json.Unmarshal(body, &raw); err != nil { - return nil, err - } - for _, comment := range raw { - comments = appendBoundedForgeComment(comments, comment.Body) - } - default: - return nil, errors.New("invalid forge kind") - } - return comments, nil -} +type ForgeProbeConfig = forge.ForgeProbeConfig -func appendBoundedForgeComment(comments []string, body string) []string { - if len(comments) >= maxForgeComments { - return comments - } - for len(body) > maxForgeCommentLen { - _, size := utf8.DecodeLastRuneInString(body) - body = body[:len(body)-size] - } - return append(comments, body) +type ForgeProber struct { + store *store.Store + client *http.Client } -func forgeIssuePath(ref forgeRef) (string, error) { - projectPath, err := forgeProjectPath(ref) - if err != nil { - return "", err - } - return projectPath + "/issues/" + strconv.Itoa(ref.Issue), nil +func NewForgeProber(st *store.Store) *ForgeProber { + return &ForgeProber{store: st, client: forge.NewHTTPClient()} } -func forgeMilestonePath(ref forgeRef) (string, error) { - projectPath, err := forgeProjectPath(ref) - if err != nil { - return "", err - } - return projectPath + "/milestones/" + strconv.Itoa(ref.Milestone), nil -} - -func forgeProjectIssuesPath(ref forgeRef) (string, error) { - projectPath, err := forgeProjectPath(ref) - if err != nil { - return "", err - } - return projectPath + "/issues", nil -} - -func forgeProjectPath(ref forgeRef) (string, error) { - if ref.Project == "" { - return "", errors.New("invalid forge project") - } - switch ref.Kind { - case "gitlab": - return "/projects/" + url.PathEscape(ref.Project), nil - case "github": - parts := strings.Split(ref.Project, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", errors.New("invalid forge project") - } - return "/repos/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1]), nil - default: - return "", errors.New("invalid forge kind") - } -} - -func (s *server) forgeGet(ctx context.Context, ref forgeRef, apiBase, path string, query url.Values) (forgeHTTPResponse, error) { - endpoint := strings.TrimRight(apiBase, "/") + path - if len(query) > 0 { - endpoint += "?" + query.Encode() - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return forgeHTTPResponse{}, forgeRequestError(ref, path) - } - switch ref.Kind { - case "gitlab": - if ref.pat != "" { - request.Header.Set("PRIVATE-TOKEN", ref.pat) - } - case "github": - if ref.pat != "" { - request.Header.Set("Authorization", "Bearer "+ref.pat) - } - request.Header.Set("Accept", "application/vnd.github+json") - default: - return forgeHTTPResponse{}, forgeRequestError(ref, path) - } - if s.forgeClient == nil { - return forgeHTTPResponse{}, forgeRequestError(ref, path) - } - response, err := s.forgeClient.Do(request) - if err != nil { - return forgeHTTPResponse{}, forgeRequestError(ref, path) - } - body, readErr := io.ReadAll(io.LimitReader(response.Body, maxForgeBodyBytes)) - closeErr := response.Body.Close() - if readErr != nil || closeErr != nil { - return forgeHTTPResponse{}, forgeRequestError(ref, path) - } - return forgeHTTPResponse{status: response.StatusCode, header: response.Header.Clone(), body: body}, nil -} - -// The name and path are already constrained (names match ^[a-z0-9._-]{1,64}$ and -// paths are url.PathEscape'd), but both are stripped anyway so no future caller -// can turn this line into a log-forging primitive. -func forgeRequestError(ref forgeRef, path string) error { - log.Printf("forge: request failed source=%s path=%s", logSafe(ref.Source.Name), logSafe(path)) - return &aiError{code: http.StatusBadGateway, msg: "forge request failed"} -} - -func forgeTotalHint(header http.Header) int { - total, err := strconv.Atoi(header.Get("X-Total")) - if err != nil || total < 0 { - return -1 - } - return total -} - -func forgeRateLimited(kind string, response forgeHTTPResponse) bool { - return response.status == http.StatusTooManyRequests || - (kind == "github" && response.status == http.StatusForbidden && response.header.Get("X-RateLimit-Remaining") == "0") -} - -func forgeHasNextPage(kind string, header http.Header) bool { - if kind == "gitlab" { - return header.Get("X-Next-Page") != "" - } - return strings.Contains(header.Get("Link"), "rel=\"next\"") -} - -func parseForgeIssueList(kind string, body []byte) ([]forgeIssue, error) { - switch kind { - case "gitlab": - var raw []gitLabIssue - if err := json.Unmarshal(body, &raw); err != nil { - return nil, err - } - issues := make([]forgeIssue, 0, len(raw)) - for _, issue := range raw { - issues = append(issues, forgeIssue{Ref: fmt.Sprintf("gitlab#%d", issue.IID), Title: issue.Title, Body: issue.Description, URL: issue.WebURL, Labels: issue.Labels}) - } - return issues, nil - case "github": - var raw []gitHubIssue - if err := json.Unmarshal(body, &raw); err != nil { - return nil, err - } - issues := make([]forgeIssue, 0, len(raw)) - for _, issue := range raw { - if len(issue.PullRequest) == 0 { - issues = append(issues, gitHubForgeIssue(issue)) - } - } - return issues, nil - default: - return nil, errors.New("invalid forge kind") - } -} - -func gitHubForgeIssue(issue gitHubIssue) forgeIssue { - labels := make([]string, 0, len(issue.Labels)) - for _, label := range issue.Labels { - if label.Name != "" { - labels = append(labels, label.Name) - } - } - return forgeIssue{Ref: fmt.Sprintf("github#%d", issue.Number), Title: issue.Title, Body: issue.Body, URL: issue.HTMLURL, Labels: labels} -} - -func newForgeClient() *http.Client { - raw := os.Getenv("KB_FORGE_ALLOW_PRIVATE") - allowAll := raw == "1" || raw == "*" - var allowHosts map[string]bool - if !allowAll { - allowHosts = parseAllowedHosts(raw) - } - - return &http.Client{ - Timeout: forgeTimeout, - Transport: guardedTransport(allowHosts, allowAll), - CheckRedirect: sameHostRedirect, - } -} - -func validForgeSourceName(name string) bool { - name = strings.ToLower(name) - if len(name) == 0 || len(name) > 64 { - return false - } - for i := 0; i < len(name); i++ { - c := name[i] - if (c < 'a' || c > 'z') && (c < '0' || c > '9') && - c != '.' && c != '_' && c != '-' { - return false - } - } - return true -} - -func normalizeForgeProbeBase(raw string) (*url.URL, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil, errors.New("invalid forge base URL") - } - if !strings.Contains(raw, "://") { - raw = "https://" + raw - } - u, err := url.Parse(raw) - if err != nil || u.Hostname() == "" { - return nil, errors.New("invalid forge base URL") - } - if u.Scheme != "http" && u.Scheme != "https" { - return nil, errors.New("forge base URL scheme must be http or https") - } - if u.User != nil { - return nil, errors.New("forge base URL must not contain userinfo") - } - if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { - return nil, errors.New("forge base URL must not contain query or fragment") - } - return u, nil -} - -// forgeAPIBase derives the stable REST prefix for each supported forge while -// preserving enterprise installations mounted below a path prefix. -func forgeAPIBase(kind, baseURL string) (string, error) { - if kind != "gitlab" && kind != "github" { - return "", errors.New("invalid forge kind") - } - u, err := normalizeForgeProbeBase(baseURL) - if err != nil { - return "", err - } - if kind == "github" && strings.EqualFold(u.Hostname(), "github.com") { - return "https://api.github.com", nil - } - - u.Path = strings.TrimRight(u.Path, "/") - u.RawPath = "" - if kind == "gitlab" { - u.Path += "/api/v4" - } else { - u.Path += "/api/v3" - } - return u.String(), nil -} - -// importTransformSkillName is the skill the import preview runs. The endpoint -// keeps its own request and response shape; only the way the drafts are -// produced is shared with the other skill callers. -const importTransformSkillName = "import-transform" - -// importPartialTransformNote tells the caller the draft list is short because -// the run ran out of room, not because the issues were judged noise. -const importPartialTransformNote = "the assistant stopped early — some issues produced no draft" - -// appendImportNote joins a second note onto whatever fetchIssues already said, -// so a rate-limited fetch and a truncated transform can both be reported. -func appendImportNote(note, extra string) string { - if note == "" { - return extra - } - return note + "; " + extra -} - -// handleImportPreview transforms a bounded, configured forge selection once; -// it never writes cards or provenance, which remain an explicit later commit. -func (s *server) handleImportPreview(w http.ResponseWriter, r *http.Request, user string) { - body, ok := readBody(w, r) - if !ok { - return - } - var req importPreviewRequest - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, invalidJSONBodyMessage, http.StatusBadRequest) - return - } - sources, err := s.store.ForgeSources(user) - if err != nil { - log.Printf("forge: list sources for %s failed", user) - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - ref, err := parseForgeRef(sources, req.Source, req.Ref) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - selected, found := forgeSourceByName(sources, req.Source) - if !found { - http.Error(w, configuredSourceUnavailableMessage, http.StatusBadRequest) - return - } - if ref.Source.Name != selected.Name { - http.Error(w, "reference does not match selected source", http.StatusBadRequest) - return - } - kind, baseURL, pat, err := s.store.ForgePAT(user, selected.Name) - if err != nil || kind != selected.Kind || baseURL != selected.BaseURL { - http.Error(w, configuredSourceUnavailableMessage, http.StatusBadRequest) - return - } - ref.pat = pat - - ctx, cancel := context.WithTimeout(r.Context(), importFetchTimeout) - defer cancel() - issues, totalHint, truncated, note, err := s.fetchIssues(ctx, ref, req.Max) - if err != nil { - writeAIError(w, user, "import preview", err) - return - } - duplicates, err := s.importDuplicates(user, issues) - if err != nil { - log.Printf("forge: duplicate lookup for %s failed", user) - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - fetched := len(issues) - packed, sourceCount := packImportIssues(issues) - issues, duplicates = issues[:sourceCount], duplicates[:sourceCount] - response := importPreviewResponse{ - Kind: importRefKind(ref), - TotalHint: totalHint, - Fetched: fetched, - Truncated: truncated, - Note: note, - Drafts: []importPreviewDraft{}, - } - if sourceCount == 0 { - writeJSON(w, response) - return - } - // Forge issues are third-party text — anyone who can comment on an issue - // writes part of this prompt — so the run is read-only: no board write, no - // outbound fetch. The card cap is stated as maxImportIssues rather than left - // to the default, because a pack may carry that many sources. The closing - // commentary is dropped; this endpoint's response shape is fixed. - run, err := s.runSkillForRequest(w, r, user, skillScopeReadOnly, importTransformSkillName, "Transform these numbered forge issues into kanban-card proposals:\n\n"+packed, maxImportIssues, aiImportMaxTokens) - if err != nil { - writeAIError(w, user, "import preview", err) - return - } - // A run cut short at a budget still returns the cards it did propose, and - // this endpoint drops the commentary that would have said so — so the note - // says it instead. Without it a truncated import is a 200 that reads as a - // complete one, and the issues that never became drafts look like issues - // the model deliberately skipped. - if run.Partial { - response.Note = appendImportNote(response.Note, importPartialTransformNote) - } - response.Drafts = buildImportPreviewDrafts(ref, run.Cards, issues, duplicates) - writeJSON(w, response) -} - -// buildImportPreviewDrafts attaches forge provenance to the drafts the run -// proposed. One issue yields at most one linked draft: the model is told never -// to split an issue in two, and nothing enforces that, so a repeated source -// number would otherwise hand two drafts the same link, external key and -// duplicate pointer — and accepting both would put two board cards on one -// external key. The repeat keeps its card and loses only the provenance it -// cannot own. -func buildImportPreviewDrafts(ref forgeRef, drafts []storyDraft, issues []forgeIssue, duplicates []*importDuplicate) []importPreviewDraft { - previews := make([]importPreviewDraft, 0, len(drafts)) - claimed := make(map[int]bool, len(drafts)) - for _, draft := range drafts { - preview := importPreviewDraft{storyDraft: draft} - preview.Tags = stripModelLinkTags(preview.Tags) - if draft.Source > 0 && draft.Source <= len(issues) && !claimed[draft.Source] { - claimed[draft.Source] = true - issue := issues[draft.Source-1] - link, externalKey := importIssueProvenance(ref, issue) - preview.Tags = append(preview.Tags, linkTagPrefix+link) - preview.Link = link - preview.ExternalKey = externalKey - preview.URL = issue.URL - preview.DuplicateOf = duplicates[draft.Source-1] - } - previews = append(previews, preview) - } - return previews -} - -// handleImportLinks records client-selected import provenance without loading -// credentials or contacting a forge. The named source provides the canonical -// source and kind; the client supplies only the item identity and display data. -func (s *server) handleImportLinks(w http.ResponseWriter, r *http.Request, user string) { - body, ok := readBody(w, r) - if !ok { - return - } - var req importLinksRequest - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, invalidJSONBodyMessage, http.StatusBadRequest) - return - } - if len(req.Items) > maxImportLinks { - http.Error(w, "too many import links (max 100)", http.StatusBadRequest) - return - } - if strings.TrimSpace(req.Source) == "" { - http.Error(w, "source required", http.StatusBadRequest) - return - } - for _, item := range req.Items { - if strings.TrimSpace(item.ExternalKey) == "" || strings.TrimSpace(item.Link) == "" || - strings.TrimSpace(item.URL) == "" || strings.TrimSpace(item.Title) == "" { - http.Error(w, "import link fields required", http.StatusBadRequest) - return - } - } - sources, err := s.store.ForgeSources(user) - if err != nil { - log.Printf("forge: list import sources for %s failed: %v", user, err) - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - source, found := forgeSourceByName(sources, req.Source) - if !found { - http.Error(w, configuredSourceUnavailableMessage, http.StatusBadRequest) - return - } - links := make([]store.ImportLink, len(req.Items)) - for i, item := range req.Items { - links[i] = store.ImportLink{ - Source: source.Name, Kind: source.Kind, ExternalKey: item.ExternalKey, - Link: item.Link, URL: item.URL, Title: item.Title, - } - } - if err := s.store.RecordImportLinks(user, links); err != nil { - if strings.HasPrefix(err.Error(), "store: import ") { - http.Error(w, "invalid import link", http.StatusBadRequest) - return - } - log.Printf("forge: record import links for %s failed: %v", user, err) - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// handleImportProvenance resolves every scoped import row for one exact short -// link. It is a local lookup only: no forge or AI request is needed to expose -// the provenance that the server already recorded. -func (s *server) handleImportProvenance(w http.ResponseWriter, r *http.Request, user string) { - body, ok := readBody(w, r) - if !ok { - return - } - var req importProvenanceRequest - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, invalidJSONBodyMessage, http.StatusBadRequest) - return - } - link := strings.TrimSpace(req.Link) - if link == "" || len(link) > 2048 || strings.ContainsAny(req.Link, "\r\n") { - http.Error(w, "invalid import link", http.StatusBadRequest) - return - } - links, err := s.store.ImportLinksByLink(user, link) - if err != nil { - log.Print("forge: import provenance lookup failed") - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - if len(links) == 0 { - http.Error(w, "import link not found", http.StatusNotFound) - return - } - response := importProvenanceResponse{Items: make([]importProvenanceItem, 0, len(links))} - for _, link := range links { - response.Items = append(response.Items, importProvenanceItem{ - Source: link.Source, ExternalKey: link.ExternalKey, Title: link.Title, URL: link.URL, - }) - } - writeJSON(w, response) -} - -// handleImportDrift compares one scoped imported issue with a fresh forge -// read. It never writes to the forge and advances the baseline only when the -// first check has no prior server-authoritative comparison point. -func (s *server) handleImportDrift(w http.ResponseWriter, r *http.Request, user string) { - body, ok := readBody(w, r) - if !ok { - return - } - var req importDriftRequest - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, invalidJSONBodyMessage, http.StatusBadRequest) - return - } - - provenance, ref, ok := s.authorizeImportDriftTarget(w, user, req.Source, req.ExternalKey) - if !ok { - return - } - - ctx, cancel := context.WithTimeout(r.Context(), importFetchTimeout) - defer cancel() - issue, err := s.fetchIssue(ctx, ref) - if err != nil { - writeAIError(w, user, "import drift", err) - return - } - checkedAt := time.Now().UTC().Format(time.RFC3339Nano) - current := store.NewImportBaseline(issue.Title, issue.Body, checkedAt) - - baseline, present, err := s.importDriftBaseline(user, req.ExternalKey, current) - if err != nil { - log.Print("forge: import drift baseline resolution failed") - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - response := importDriftResponse{ - Link: provenance.Link, - URL: provenance.URL, - UpstreamTitle: issue.Title, - CheckedAt: checkedAt, - Summary: "", - } - if !present { - response.State = "baseline_recorded" - response.BaselineTitle = current.Title - response.BaselineAt = current.At - writeJSON(w, response) - return - } - - titleChanged := current.Title != baseline.Title - response.TitleChanged = &titleChanged - response.BaselineTitle = baseline.Title - response.BaselineAt = baseline.At - if !titleChanged && current.Hash == baseline.Hash { - response.State = "unchanged" - writeJSON(w, response) - return - } - response.State = "drifted" - response.Summary = s.importDriftSummary(user, baseline, current) - response.Revision = importDriftRevision(current) - writeJSON(w, response) -} - -// handleImportDriftAccept advances an existing baseline only after the caller -// confirms the exact snapshot returned by a prior drift response. -func (s *server) handleImportDriftAccept(w http.ResponseWriter, r *http.Request, user string) { - body, ok := readBody(w, r) - if !ok { - return - } - var req importDriftAcceptRequest - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, invalidJSONBodyMessage, http.StatusBadRequest) - return - } - if !validImportDriftRevision(req.Revision) { - http.Error(w, "invalid revision", http.StatusBadRequest) - return - } - - _, ref, ok := s.authorizeImportDriftTarget(w, user, req.Source, req.ExternalKey) - if !ok { - return - } - - lock := s.importDriftLocks.get(user) - lock.Lock() - defer lock.Unlock() - - baseline, present, err := s.store.ImportBaseline(user, req.ExternalKey) - if err != nil { - log.Print("forge: import drift accept baseline lookup failed") - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - if !present { - http.Error(w, "check again", http.StatusConflict) - return - } - if importDriftRevision(baseline) == req.Revision { - writeJSON(w, importDriftAcceptResponse{BaselineAt: baseline.At}) - return - } - - ctx, cancel := context.WithTimeout(r.Context(), importFetchTimeout) - defer cancel() - issue, _, _, err := s.fetchIssueSnapshot(ctx, ref) - if err != nil { - writeAIError(w, user, "import drift accept", err) - return - } - current := store.NewImportBaseline(issue.Title, issue.Body, time.Now().UTC().Format(time.RFC3339Nano)) - if importDriftRevision(current) != req.Revision { - http.Error(w, "upstream changed; check again", http.StatusConflict) - return - } - if err := s.store.SetImportBaseline(user, req.ExternalKey, current); err != nil { - log.Print("forge: import drift accept baseline update failed") - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - writeJSON(w, importDriftAcceptResponse{BaselineAt: current.At}) -} - -// authorizeImportDriftTarget keeps both drift endpoints on the same ordered -// provenance/source/PAT/host/kind/issue authorization path before egress. -func (s *server) authorizeImportDriftTarget(w http.ResponseWriter, user, source, externalKey string) (store.ImportLink, forgeRef, bool) { - imported, err := s.store.ImportedAs(user, []string{externalKey}) - if err != nil { - log.Print("forge: import drift provenance lookup failed") - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return store.ImportLink{}, forgeRef{}, false - } - provenance, found := imported[externalKey] - if !found { - http.Error(w, "import link not found", http.StatusNotFound) - return store.ImportLink{}, forgeRef{}, false - } - if !strings.EqualFold(source, provenance.Source) { - http.Error(w, "source does not match imported item", http.StatusBadRequest) - return store.ImportLink{}, forgeRef{}, false - } - - // Keep this authorization sequence in lockstep with handleImportPreview: - // list, parse, select, match, decrypt, verify, then assign the PAT. - sources, err := s.store.ForgeSources(user) - if err != nil { - log.Print("forge: import drift source lookup failed") - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return store.ImportLink{}, forgeRef{}, false - } - ref, err := parseForgeRef(sources, source, provenance.URL) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return store.ImportLink{}, forgeRef{}, false - } - selected, found := forgeSourceByName(sources, source) - if !found { - http.Error(w, configuredSourceUnavailableMessage, http.StatusBadRequest) - return store.ImportLink{}, forgeRef{}, false - } - if ref.Source.Name != selected.Name { - http.Error(w, "reference does not match selected source", http.StatusBadRequest) - return store.ImportLink{}, forgeRef{}, false - } - if provenance.Kind != selected.Kind { - http.Error(w, "imported item kind does not match selected source", http.StatusBadRequest) - return store.ImportLink{}, forgeRef{}, false - } - if ref.Issue <= 0 { - http.Error(w, "issue reference required", http.StatusBadRequest) - return store.ImportLink{}, forgeRef{}, false - } - kind, baseURL, pat, err := s.store.ForgePAT(user, selected.Name) - if err != nil || kind != selected.Kind || baseURL != selected.BaseURL { - http.Error(w, configuredSourceUnavailableMessage, http.StatusBadRequest) - return store.ImportLink{}, forgeRef{}, false - } - ref.pat = pat - return provenance, ref, true -} - -func importDriftRevision(baseline store.ImportBaseline) string { - sum := sha256.Sum256([]byte(baseline.Title + "\x00" + baseline.Hash)) - return fmt.Sprintf("%x", sum) -} - -func validImportDriftRevision(revision string) bool { - if len(revision) != sha256.Size*2 { - return false - } - for i := 0; i < len(revision); i++ { - if (revision[i] < '0' || revision[i] > '9') && (revision[i] < 'a' || revision[i] > 'f') { - return false - } - } - return true -} - -func (s *server) importDriftBaseline(user, externalKey string, current store.ImportBaseline) (store.ImportBaseline, bool, error) { - lock := s.importDriftLocks.get(user) - lock.Lock() - defer lock.Unlock() - - baseline, present, err := s.store.ImportBaseline(user, externalKey) - if err != nil || present { - return baseline, present, err - } - if err := s.store.SetImportBaseline(user, externalKey, current); err != nil { - return store.ImportBaseline{}, false, err - } - return current, false, nil -} - -// importDriftSummaryPrompt is the whole instruction the drift summary gets. -// The run carries no tools: it compares two pieces of text the caller already -// holds, so a tool would only be another way for third-party issue text to -// reach the board. -const importDriftSummaryPrompt = "Summarize an imported issue change using only the supplied titles and excerpts." - -// importDriftSummary is best-effort prose about what changed upstream. Every -// failure — no configuration, a bad endpoint, an upstream that never answers — -// degrades to no summary, because a drift comparison the caller asked for is -// valid without one. One run is one round trip: the loop is capped at a single -// iteration, and a toolless request cannot ask for a second. -func (s *server) importDriftSummary(user string, baseline, current store.ImportBaseline) string { - cfg, err := s.storedAIConfig(user) - if err != nil || strings.TrimSpace(cfg.baseURL) == "" { - return "" - } - client, err := s.rigClient(cfg) - if err != nil { - return "" - } - prompt := fmt.Sprintf( - "Summarize the material change in plain text. Do not invent details.\n\nBaseline title:\n%s\nBaseline excerpt:\n%s\n\nCurrent title:\n%s\nCurrent excerpt:\n%s", - truncateImportText(baseline.Title, maxImportCommentBytes), - baseline.Excerpt, - truncateImportText(current.Title, maxImportCommentBytes), - current.Excerpt, - ) - prompt = truncateImportText(prompt, maxImportPackBytes) - res, err := client.Run(context.Background(), rig.RunRequest{ - Model: cfg.model, - System: importDriftSummaryPrompt, - Prompt: prompt, - MaxTokens: skillBudget(aiDriftMaxTokens), - MaxIterations: 1, - }) - if err != nil { - return "" - } - return truncateImportText(strings.TrimSpace(res.Text), maxImportCommentBytes) -} - -func (s *server) importDuplicates(scope string, issues []forgeIssue) ([]*importDuplicate, error) { - duplicates := make([]*importDuplicate, len(issues)) - for i, issue := range issues { - links, err := s.store.TasksByLink(scope, linkTagPrefix+issue.Ref) - if err != nil { - return nil, err - } - if len(links) > 0 { - duplicates[i] = &importDuplicate{ID: links[0].ID, Title: links[0].Title, Via: "link"} - continue - } - similar, err := s.store.SearchSimilar(scope, issue.Title, "", nil, 1) - if err != nil { - return nil, err - } - if len(similar) > 0 { - duplicates[i] = &importDuplicate{ID: similar[0].ID, Title: similar[0].Title, Via: "similar"} - } - } - return duplicates, nil -} - -func importRefKind(ref forgeRef) string { - if ref.Issue > 0 { - return "issue" - } - if ref.Milestone > 0 { - return "milestone" - } - return "project" -} - -func stripModelLinkTags(tags []string) []string { - filtered := make([]string, 0, len(tags)) - for _, tag := range tags { - if !strings.HasPrefix(tag, linkTagPrefix) { - filtered = append(filtered, tag) - } - } - return filtered -} - -func importIssueProvenance(ref forgeRef, issue forgeIssue) (link, externalKey string) { - link = issue.Ref - issueID := strings.TrimPrefix(link, ref.Kind+"#") - host := "" - if base, err := url.Parse(ref.Source.BaseURL); err == nil { - host = base.Host - } - externalKey = fmt.Sprintf("%s:%s/%s#%s", ref.Kind, host, ref.Project, issueID) - return link, externalKey -} - -func (s *server) handleGetIntegrations(w http.ResponseWriter, _ *http.Request, user string) { - sources, err := s.store.ForgeSources(user) - if err != nil { - log.Printf("forge: list integrations for %s failed: %v", user, err) - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - response := forgeSourcesResponse{Sources: make([]forgeSourceResponse, 0, len(sources))} - for _, source := range sources { - response.Sources = append(response.Sources, forgeSourceResponse{ - Name: source.Name, - Kind: source.Kind, - BaseURL: source.BaseURL, - HasToken: source.HasToken, - }) - } - writeJSON(w, response) -} - -func (s *server) handlePutIntegration(w http.ResponseWriter, r *http.Request, user string) { - name := r.PathValue("name") - if !validForgeSourceName(name) { - http.Error(w, invalidIntegrationNameMessage, http.StatusBadRequest) - return - } - - body, ok := readBody(w, r) - if !ok { - return - } - var req struct { - Kind string `json:"kind"` - BaseURL *string `json:"base_url"` - PAT *string `json:"pat"` - } - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, invalidJSONBodyMessage, http.StatusBadRequest) - return - } - if req.Kind != "gitlab" && req.Kind != "github" { - http.Error(w, "invalid forge kind", http.StatusBadRequest) - return - } - if req.BaseURL != nil { - if _, err := forgeAPIBase(req.Kind, *req.BaseURL); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - } - - tokenCleared, err := s.store.SetForgeSource(user, name, req.Kind, req.BaseURL, req.PAT) - if err != nil { - switch err.Error() { - case "store: forge base URL is required": - http.Error(w, "forge base URL is required", http.StatusBadRequest) - return - case "store: forge base URL must not contain query or fragment": - http.Error(w, "forge base URL must not contain query or fragment", http.StatusBadRequest) - return - } - log.Printf("forge: save integration for %s failed: %v", user, err) - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - if tokenCleared { - writeJSON(w, map[string]bool{"token_cleared": true}) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *server) handleDeleteIntegration(w http.ResponseWriter, r *http.Request, user string) { - name := r.PathValue("name") - if !validForgeSourceName(name) { - http.Error(w, invalidIntegrationNameMessage, http.StatusBadRequest) - return - } - if err := s.store.DeleteForgeSource(user, name); err != nil { - log.Printf("forge: delete integration for %s failed: %v", user, err) - http.Error(w, storageErrorMessage, http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *server) handleTestIntegration(w http.ResponseWriter, r *http.Request, user string) { - name := r.PathValue("name") - if !validForgeSourceName(name) { - http.Error(w, invalidIntegrationNameMessage, http.StatusBadRequest) - return - } - - body, ok := readBody(w, r) - if !ok { - return - } - probe, err := parseForgeTestProbe(body) - if err != nil { - http.Error(w, invalidJSONBodyMessage, http.StatusBadRequest) - return - } - - kind, storedBase, storedPAT, err := s.store.ForgePAT(user, name) - if err != nil { - log.Printf("forge: load integration for %s failed: %v", user, err) - writeJSON(w, forgeTestResponse{Error: "integration unavailable"}) - return - } - - target, err := resolveForgeTestTarget(storedBase, storedPAT, probe) - if err != nil { - writeJSON(w, forgeTestResponse{Error: err.Error()}) - return - } - - request, err := newForgeTestRequest(r.Context(), kind, target, "") - if err != nil { - writeJSON(w, forgeTestResponse{Error: err.Error()}) - return - } - if !s.forgeConnectionOK(request, user) { - writeJSON(w, forgeTestResponse{Error: connectionFailedMessage}) - return - } - writeJSON(w, forgeTestResponse{OK: true}) -} - -func parseForgeTestProbe(body []byte) (forgeTestProbe, error) { - var probe forgeTestProbe - if len(bytes.TrimSpace(body)) == 0 { - return probe, nil - } - err := json.Unmarshal(body, &probe) - return probe, err -} - -func resolveForgeTestTarget(storedBase, storedPAT string, probe forgeTestProbe) (forgeTestTarget, error) { - target := forgeTestTarget{baseURL: storedBase, pat: storedPAT} - suppliedBase := trimmedForgeProbeValue(probe.BaseURL) - if suppliedBase != "" { - normalized, err := normalizeForgeProbeBase(suppliedBase) - if err != nil { - return forgeTestTarget{}, err - } - target.baseURL = normalized.String() - } - suppliedPAT := trimmedForgeProbeValue(probe.PAT) - if suppliedPAT != "" { - target.pat = suppliedPAT - } - if suppliedPAT == "" && suppliedBase != "" && storedPAT != "" && - !store.SameAIOrigin(storedBase, target.baseURL) { - return forgeTestTarget{}, errors.New("enter the token to test a different endpoint") - } - return target, nil -} - -func trimmedForgeProbeValue(value *string) string { - if value == nil { - return "" - } - return strings.TrimSpace(*value) -} - -func newForgeTestRequest(ctx context.Context, kind string, target forgeTestTarget, project string) (*http.Request, error) { - apiBase, err := forgeAPIBase(kind, target.baseURL) - if err != nil { - return nil, err - } - project = strings.TrimSpace(project) - endpoint := apiBase - if project != "" { - projectPath, err := forgeProjectPath(forgeRef{Kind: kind, Project: project}) - if err != nil { - return nil, err - } - endpoint += projectPath - } else { - switch kind { - case "gitlab": - endpoint += "/version" - case "github": - endpoint += "/user" - default: - return nil, errors.New("invalid forge kind") - } - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, errors.New("invalid forge base URL") - } - setForgeTestHeaders(request, kind, target.pat) - return request, nil -} - -func setForgeTestHeaders(request *http.Request, kind, pat string) { - if kind == "gitlab" && pat != "" { - request.Header.Set("PRIVATE-TOKEN", pat) - } - if kind == "github" { - if pat != "" { - request.Header.Set("Authorization", "Bearer "+pat) - } - request.Header.Set("Accept", "application/vnd.github+json") - } -} - -func (s *server) forgeConnectionOK(request *http.Request, user string) bool { - err := executeForgeTest(s.forgeClient, request) - if err != nil { - log.Printf("forge: connection test for %s failed: %v", user, err) - return false - } - return true -} - -func executeForgeTest(client *http.Client, request *http.Request) error { - response, err := client.Do(request) - if err != nil { - return err - } - if err := drainForgeResponse(response); err != nil { - return fmt.Errorf("close response: %w", err) - } - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return fmt.Errorf("upstream status %d", response.StatusCode) - } - return nil -} - -func drainForgeResponse(response *http.Response) error { - _, readErr := io.Copy(io.Discard, io.LimitReader(response.Body, maxForgeDrainBytes)) - closeErr := response.Body.Close() - if readErr != nil || closeErr != nil { - return fmt.Errorf("read: %v; close: %v", readErr, closeErr) - } - return nil +func (p *ForgeProber) Probe(ctx context.Context, user string, config ForgeProbeConfig) error { + return forge.NewForgeProberWithClient(p.store, p.client).Probe(ctx, user, config) } diff --git a/internal/server/forge_adapter_coverage_test.go b/internal/server/forge_adapter_coverage_test.go new file mode 100644 index 0000000..06679ee --- /dev/null +++ b/internal/server/forge_adapter_coverage_test.go @@ -0,0 +1,107 @@ +package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/RandomCodeSpace/kb/internal/forge" +) + +func TestRejectedForgeHandlerBodiesAndBoardJSONEdges(t *testing.T) { + s := &server{store: newTestStore(t)} + handlers := []struct { + name string + fn func(http.ResponseWriter, *http.Request, string) + }{ + {"tombstone", s.handleTombstone}, + {"import preview", s.handleImportPreview}, + {"import links", s.handleImportLinks}, + {"import provenance", s.handleImportProvenance}, + {"import drift", s.handleImportDrift}, + {"import drift accept", s.handleImportDriftAccept}, + {"put integration", s.handlePutIntegration}, + {"test integration", s.handleTestIntegration}, + {"ai test", s.handleAITest}, + } + for _, test := range handlers { + t.Run(test.name+" oversized", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", maxBodyBytes+1))) + request.SetPathValue("name", "primary") + response := httptest.NewRecorder() + test.fn(response, request, "user") + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d", response.Code) + } + }) + } + for _, test := range []struct { + name string + fn func(http.ResponseWriter, *http.Request, string) + }{ + {"tombstone", s.handleTombstone}, + {"import preview", s.handleImportPreview}, + {"import drift", s.handleImportDrift}, + {"import drift accept", s.handleImportDriftAccept}, + } { + t.Run(test.name+" malformed", func(t *testing.T) { + response := httptest.NewRecorder() + test.fn(response, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{`)), "user") + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d", response.Code) + } + }) + } + + for _, body := range []string{ + `[]`, `{"board":`, `{"board":"x","board":"y","task_ids":[]}`, + `{"board":null,"task_ids":[]}`, `{"board":"x","task_ids":`, + `{"board":"x","task_ids":null}`, `{"board":"x","task_ids":{}}`, + `{"board":"x","other":[]}`, `{"board":"x"}`, + `{"board":"x","task_ids":[]} {}`, + } { + if _, _, err := parseBoardJSONPut([]byte(body)); err == nil { + t.Fatalf("parseBoardJSONPut accepted %q", body) + } + } +} + +func TestSharedForgeAdapterAndAISanitizerBranches(t *testing.T) { + config := aiConfig{baseURL: "stored-base", model: "stored-model", key: "stored-key"}.merge(aiTestRequest{ + BaseURL: " new-base ", Model: " new-model ", Key: " new-key ", + }) + if config.baseURL != "new-base" || config.model != "new-model" || config.key != "new-key" { + t.Fatalf("merged config = %+v", config) + } + if got := stripControlKeepLines("one\r\ntwo\t\u009b"); got != "one\ntwo" { + t.Fatalf("line sanitizer = %q", got) + } + if serverForgeError(nil) != nil { + t.Fatal("nil forge error changed") + } + plain := errors.New("plain") + if serverForgeError(plain) != plain { + t.Fatal("uncategorized forge error changed") + } + var mapped *aiError + if err := serverForgeError(&forge.Error{Code: http.StatusUnprocessableEntity, Message: "invalid"}); !errors.As(err, &mapped) || mapped.code != http.StatusUnprocessableEntity { + t.Fatalf("categorized forge error = %#v", err) + } + if NewForgeProber(newTestStore(t)) == nil { + t.Fatal("forge prober constructor returned nil") + } + if got := normalizeGuardHost(" [::1]. "); got != "::1" { + t.Fatalf("normalized host = %q", got) + } + if _, err := guardedTransport(nil, false).DialContext(context.Background(), "tcp", "invalid-address"); err == nil { + t.Fatal("guarded transport accepted invalid dial address") + } + request := httptest.NewRequest(http.MethodPost, "/api/board", nil) + request.Header.Set("Content-Type", `application/json; broken`) + if contentTypeAllowed(request) { + t.Fatal("malformed content type accepted") + } +} diff --git a/internal/server/forge_race_test.go b/internal/server/forge_race_test.go new file mode 100644 index 0000000..1f29492 --- /dev/null +++ b/internal/server/forge_race_test.go @@ -0,0 +1,36 @@ +package server + +import ( + "sync" + "testing" + + "github.com/RandomCodeSpace/kb/internal/forge" +) + +func TestSharedForgeInitializesOnceUnderConcurrency(t *testing.T) { + s := newServer(Config{}, testStatic, newTestStore(t)) + const callers = 64 + start := make(chan struct{}) + services := make(chan *forge.Service, callers) + var wg sync.WaitGroup + for range callers { + wg.Add(1) + go func() { + defer wg.Done() + <-start + services <- s.sharedForge() + }() + } + close(start) + wg.Wait() + close(services) + var first *forge.Service + for service := range services { + if first == nil { + first = service + } + if service != first { + t.Fatal("sharedForge returned multiple services") + } + } +} diff --git a/internal/server/forge_sonar_coverage_test.go b/internal/server/forge_sonar_coverage_test.go index 5db7f54..3ed75b8 100644 --- a/internal/server/forge_sonar_coverage_test.go +++ b/internal/server/forge_sonar_coverage_test.go @@ -10,7 +10,10 @@ import ( "strings" "sync/atomic" "testing" + "time" + kbai "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/forge" "github.com/RandomCodeSpace/kb/internal/store" ) @@ -21,6 +24,16 @@ const ( forgeCoverageStorageBody = "storage error\n" ) +type forgeDeadlineRecorder struct { + *httptest.ResponseRecorder + deadline time.Time +} + +func (w *forgeDeadlineRecorder) SetWriteDeadline(deadline time.Time) error { + w.deadline = deadline + return nil +} + func newForgeCoverageStore(t *testing.T) (*store.Store, *sql.DB) { t.Helper() path := filepath.Join(t.TempDir(), "kb.db") @@ -90,26 +103,6 @@ func replaceImportLinksWithView(t *testing.T, db *sql.DB, projection string) { execForgeCoverageSQL(t, db, "CREATE VIEW import_links AS SELECT "+projection+" FROM import_links_backing") } -func TestForgeIssueListRequestPropagatesInvalidKind(t *testing.T) { - path, query, err := forgeIssueListRequest(forgeRef{Kind: "invalid", Project: "owner/repo"}) - if path != "" || query != nil || err == nil || err.Error() != "invalid forge kind" { - t.Fatalf("invalid-kind list request = (%q, %v, %v)", path, query, err) - } -} - -func TestSetForgeMilestoneQueryRejectsInvalidKind(t *testing.T) { - if setForgeMilestoneQuery("invalid", nil, nil) { - t.Fatal("invalid forge kind produced a milestone query") - } -} - -func TestParseForgeCommentsRejectsInvalidKind(t *testing.T) { - comments, err := parseForgeComments("invalid", []byte(`[]`)) - if comments != nil || err == nil || err.Error() != "invalid forge kind" { - t.Fatalf("invalid-kind comments = (%v, %v)", comments, err) - } -} - func TestImportPreviewRejectsUnavailableConfiguredCredentialWithoutEgress(t *testing.T) { st, db := newForgeCoverageStore(t) seedForgeCoverageSource(t, st, "https://forge.example", "secret") @@ -129,6 +122,31 @@ func TestImportPreviewRejectsUnavailableConfiguredCredentialWithoutEgress(t *tes } } +func TestImportPreviewExtendsWriteDeadlineAndMapsAIErrors(t *testing.T) { + st, _ := newForgeCoverageStore(t) + s := &server{store: st} + response := &forgeDeadlineRecorder{ResponseRecorder: httptest.NewRecorder()} + request := httptest.NewRequest(http.MethodPost, "/api/import/preview", strings.NewReader(`{"source":"missing","ref":"group/project"}`)) + before := time.Now() + s.handleImportPreview(response, request, forgeCoverageUser) + if response.deadline.Before(before.Add(skillRunDeadline)) { + t.Fatalf("preview write deadline = %v, want at least %v", response.deadline, before.Add(skillRunDeadline)) + } + + for _, test := range []struct { + err error + code int + body string + }{ + {err: &kbai.Error{Code: http.StatusUnprocessableEntity, Message: "model rejected request"}, code: http.StatusUnprocessableEntity, body: "model rejected request\n"}, + {err: &kbai.Error{Code: http.StatusBadGateway, Message: "secret upstream detail"}, code: http.StatusBadGateway, body: connectionFailedMessage + "\n"}, + } { + w := httptest.NewRecorder() + writeSharedForgeError(w, forgeCoverageUser, "preview", test.err) + requireForgeCoverageResponse(t, w, test.code, test.body) + } +} + func TestImportPreviewReturnsStorageErrorWhenDuplicateLookupFails(t *testing.T) { var calls atomic.Int32 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -214,7 +232,7 @@ func TestImportDriftAcceptReturnsStorageErrorWhenBaselineUpdateFails(t *testing. current := store.NewImportBaseline("Current", "body", "") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/import/drift/accept", strings.NewReader( - `{"source":"primary","external_key":"`+forgeCoverageKey+`","revision":"`+importDriftRevision(current)+`"}`)) + `{"source":"primary","external_key":"`+forgeCoverageKey+`","revision":"`+forge.BaselineRevision(current)+`"}`)) s.handleImportDriftAccept(response, request, forgeCoverageUser) requireForgeCoverageResponse(t, response, http.StatusInternalServerError, forgeCoverageStorageBody) if calls.Load() != 1 { @@ -225,80 +243,3 @@ func TestImportDriftAcceptReturnsStorageErrorWhenBaselineUpdateFails(t *testing. t.Fatalf("failed baseline update left title = %q, err=%v", title, err) } } - -func TestAuthorizeImportDriftTargetReturnsStorageErrorWhenSourceListFails(t *testing.T) { - st, db := newForgeCoverageStore(t) - seedForgeCoverageDrift(t, st, "https://forge.example", "") - execForgeCoverageSQL(t, db, "DROP TABLE forge_sources") - response := httptest.NewRecorder() - _, _, ok := (&server{store: st}).authorizeImportDriftTarget(response, forgeCoverageUser, forgeCoverageSource, forgeCoverageKey) - if ok { - t.Fatal("source-list failure authorized drift") - } - requireForgeCoverageResponse(t, response, http.StatusInternalServerError, forgeCoverageStorageBody) -} - -func TestAuthorizeImportDriftTargetRejectsMissingSelectedSource(t *testing.T) { - st, db := newForgeCoverageStore(t) - seedForgeCoverageDrift(t, st, "https://forge.example", "") - execForgeCoverageSQL(t, db, "UPDATE import_links SET source = ''") - response := httptest.NewRecorder() - _, _, ok := (&server{store: st}).authorizeImportDriftTarget(response, forgeCoverageUser, "", forgeCoverageKey) - if ok { - t.Fatal("missing selected source authorized drift") - } - requireForgeCoverageResponse(t, response, http.StatusBadRequest, configuredSourceUnavailableMessage+"\n") -} - -func TestAuthorizeImportDriftTargetRejectsUnavailableCredential(t *testing.T) { - st, db := newForgeCoverageStore(t) - seedForgeCoverageDrift(t, st, "https://forge.example", "secret") - execForgeCoverageSQL(t, db, "UPDATE forge_sources SET pat_enc = X'00'") - response := httptest.NewRecorder() - _, _, ok := (&server{store: st}).authorizeImportDriftTarget(response, forgeCoverageUser, forgeCoverageSource, forgeCoverageKey) - if ok { - t.Fatal("unavailable credential authorized drift") - } - requireForgeCoverageResponse(t, response, http.StatusBadRequest, configuredSourceUnavailableMessage+"\n") -} - -func TestIntegrationProbeReportsRequestBuildErrorWithoutEgress(t *testing.T) { - st, db := newForgeCoverageStore(t) - seedForgeCoverageSource(t, st, "https://forge.example", "") - execForgeCoverageSQL(t, db, "UPDATE forge_sources SET base_url = ''") - var calls atomic.Int32 - s := &server{store: st, forgeClient: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - calls.Add(1) - return nil, errors.New("forbidden egress") - })}} - response := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodPost, "/api/integrations/primary/test", nil) - request.SetPathValue("name", forgeCoverageSource) - s.handleTestIntegration(response, request, forgeCoverageUser) - var result forgeTestResponse - decodeForgeJSON(t, response, &result) - if response.Code != http.StatusOK || result.OK || result.Error != "invalid forge base URL" || calls.Load() != 0 { - t.Fatalf("request-build response = %d %+v, calls=%d", response.Code, result, calls.Load()) - } -} - -func TestNewForgeTestRequestRejectsNilContextBeforeEgress(t *testing.T) { - //lint:ignore SA1012 Deliberately exercise the request constructor's nil-context error. - request, err := newForgeTestRequest(nil, "gitlab", forgeTestTarget{baseURL: "https://gitlab.example.test"}, "") - if request != nil || err == nil || err.Error() != "invalid forge base URL" { - t.Fatalf("nil-context request = (%v, %v), want nil request and construction error", request, err) - } -} - -func TestForgeConnectionOKRejectsResponseDrainError(t *testing.T) { - request, err := http.NewRequest(http.MethodGet, "https://forge.example/api/v4/version", nil) - if err != nil { - t.Fatalf("build request: %v", err) - } - s := &server{forgeClient: &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: coverageReadCloser{readErr: errors.New("read failed")}}, nil - })}} - if s.forgeConnectionOK(request, forgeCoverageUser) { - t.Fatal("response drain error passed the integration probe") - } -} diff --git a/internal/server/forge_test.go b/internal/server/forge_test.go index 23ab9e8..b9e0fed 100644 --- a/internal/server/forge_test.go +++ b/internal/server/forge_test.go @@ -1,7 +1,6 @@ package server import ( - "context" "encoding/json" "errors" "fmt" @@ -15,6 +14,7 @@ import ( "unicode/utf8" "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/forge" "github.com/RandomCodeSpace/kb/internal/store" ) @@ -224,54 +224,6 @@ func newIntegrationsHandler(t *testing.T) (http.Handler, *store.Store) { return s.handler(), st } -func TestForgeAPIBaseDerivation(t *testing.T) { - tests := []struct { - name, kind, baseURL, want string - }{ - { - name: "GitLab root", - kind: "gitlab", - baseURL: "https://gitlab.example.com", - want: "https://gitlab.example.com/api/v4", - }, - { - name: "GitLab subpath and trailing slash", - kind: "gitlab", - baseURL: "https://gitlab.example.com/forge/", - want: "https://gitlab.example.com/forge/api/v4", - }, - { - name: "public GitHub", - kind: "github", - baseURL: "https://github.com/", - want: "https://api.github.com", - }, - { - name: "public GitHub with explicit HTTPS port", - kind: "github", - baseURL: "https://github.com:443/", - want: "https://api.github.com", - }, - { - name: "GitHub Enterprise subpath", - kind: "github", - baseURL: "https://github.example.com/forge/", - want: "https://github.example.com/forge/api/v3", - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got, err := forgeAPIBase(test.kind, test.baseURL) - if err != nil { - t.Fatalf("forgeAPIBase(%q, %q): %v", test.kind, test.baseURL, err) - } - if got != test.want { - t.Fatalf("forgeAPIBase(%q, %q) = %q, want %q", test.kind, test.baseURL, got, test.want) - } - }) - } -} - func TestIntegrationsCRUDAndPATRedaction(t *testing.T) { t.Setenv("KB_FORGE_ALLOW_PRIVATE", "") h, st := newIntegrationsHandler(t) @@ -707,493 +659,7 @@ func TestIntegrationsConnectionTestCollapsesUpstreamFailures(t *testing.T) { // Configured references select the most specific forge endpoint and preserve the // project identity required by the later fetch phase. -func TestParseForgeRefResolvesConfiguredReferences(t *testing.T) { - sources := []store.ForgeSource{ - {Name: "gitlab", Kind: "gitlab", BaseURL: "https://gitlab.example.com"}, - {Name: "gitlab-enterprise", Kind: "gitlab", BaseURL: "https://gitlab.example.com/forge"}, - {Name: "gitlab-nested", Kind: "gitlab", BaseURL: "https://gitlab.example.com/forge/team"}, - {Name: "github", Kind: "github", BaseURL: "https://github.com"}, - {Name: "github-enterprise", Kind: "github", BaseURL: "https://github.example.com/enterprise"}, - } - - tests := []struct { - name, sourceName, raw, wantSource, wantKind, wantProject string - wantIssue, wantMilestone int - }{ - { - name: "GitLab issue", - raw: "https://gitlab.example.com/group/subgroup/project/-/issues/42", - wantSource: "gitlab", wantKind: "gitlab", wantProject: "group/subgroup/project", wantIssue: 42, - }, - { - name: "GitLab legacy issue", - raw: "https://gitlab.example.com/group/project/issues/43", - wantSource: "gitlab", wantKind: "gitlab", wantProject: "group/project", wantIssue: 43, - }, - { - name: "GitLab milestone", - raw: "https://gitlab.example.com/group/project/-/milestones/7", - wantSource: "gitlab", wantKind: "gitlab", wantProject: "group/project", wantMilestone: 7, - }, - { - name: "GitLab project", - raw: "https://gitlab.example.com/group/project", - wantSource: "gitlab", wantKind: "gitlab", wantProject: "group/project", - }, - { - name: "GitLab project may use a route-word namespace", - raw: "https://gitlab.example.com/issues/project", - wantSource: "gitlab", wantKind: "gitlab", wantProject: "issues/project", - }, - { - name: "GitLab nested project may use a route-word namespace", - raw: "https://gitlab.example.com/group/subgroup/issues/project", - wantSource: "gitlab", wantKind: "gitlab", wantProject: "group/subgroup/issues/project", - }, - { - name: "GitLab board resolves to its project", - raw: "https://gitlab.example.com/group/project/-/boards/123", - wantSource: "gitlab", wantKind: "gitlab", wantProject: "group/project", - }, - { - name: "longest GitLab enterprise path prefix", - raw: "https://gitlab.example.com/forge/group/project/-/issues/8", - wantSource: "gitlab-enterprise", wantKind: "gitlab", wantProject: "group/project", wantIssue: 8, - }, - { - name: "nested GitLab enterprise path prefix", - raw: "https://gitlab.example.com/forge/team/group/project/-/issues/8", - wantSource: "gitlab-nested", wantKind: "gitlab", wantProject: "group/project", wantIssue: 8, - }, - { - name: "path prefix remains a whole segment", - raw: "https://gitlab.example.com/forgeish/group/project/-/issues/8", - wantSource: "gitlab", wantKind: "gitlab", wantProject: "forgeish/group/project", wantIssue: 8, - }, - { - name: "GitHub issue", - raw: "https://github.com/owner/repo/issues/9", - wantSource: "github", wantKind: "github", wantProject: "owner/repo", wantIssue: 9, - }, - { - name: "GitHub milestone", - raw: "https://github.com/owner/repo/milestone/3", - wantSource: "github", wantKind: "github", wantProject: "owner/repo", wantMilestone: 3, - }, - { - name: "GitHub project", - raw: "https://github.com/owner/repo", - wantSource: "github", wantKind: "github", wantProject: "owner/repo", - }, - { - name: "GitHub Enterprise path prefix", - raw: "https://github.example.com/enterprise/owner/repo/issues/10", - wantSource: "github-enterprise", wantKind: "github", wantProject: "owner/repo", wantIssue: 10, - }, - { - name: "query fragment and trailing slash do not change identity", - raw: "https://github.com/owner/repo/issues/9/?page=2#notes", - wantSource: "github", wantKind: "github", wantProject: "owner/repo", wantIssue: 9, - }, - { - name: "bare project uses named source", - sourceName: "github", raw: "owner/repo", - wantSource: "github", wantKind: "github", wantProject: "owner/repo", - }, - { - name: "absolute host ignores selected source name", - sourceName: "github", raw: "https://gitlab.example.com/group/project/-/issues/11", - wantSource: "gitlab", wantKind: "gitlab", wantProject: "group/project", wantIssue: 11, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got, err := parseForgeRef(sources, test.sourceName, test.raw) - if err != nil { - t.Fatalf("parseForgeRef(%q, %q): %v", test.sourceName, test.raw, err) - } - if got.Source.Name != test.wantSource || got.Kind != test.wantKind || got.Project != test.wantProject || - got.Issue != test.wantIssue || got.Milestone != test.wantMilestone { - t.Fatalf("parseForgeRef(%q) = %+v, want source=%q kind=%q project=%q issue=%d milestone=%d", test.raw, got, test.wantSource, test.wantKind, test.wantProject, test.wantIssue, test.wantMilestone) - } - }) - } -} - -func TestParseForgeRefBreaksEqualBaseTiesBySelectedSource(t *testing.T) { - t.Run("same base prefers selected source case-insensitively", func(t *testing.T) { - sources := []store.ForgeSource{ - {Name: "alpha", Kind: "gitlab", BaseURL: "https://forge.example"}, - {Name: "zulu", Kind: "gitlab", BaseURL: "https://forge.example"}, - } - got, err := parseForgeRef(sources, "ZuLu", "https://forge.example/group/project/-/issues/8") - if err != nil || got.Source.Name != "zulu" || got.Project != "group/project" || got.Issue != 8 { - t.Fatalf("equal-base selected source = %+v, %v; want zulu issue 8", got, err) - } - }) - - t.Run("longer base still wins over selected source", func(t *testing.T) { - sources := []store.ForgeSource{ - {Name: "alpha", Kind: "gitlab", BaseURL: "https://forge.example"}, - {Name: "zulu", Kind: "gitlab", BaseURL: "https://forge.example/forge"}, - } - got, err := parseForgeRef(sources, "alpha", "https://forge.example/forge/group/project/-/issues/8") - if err != nil || got.Source.Name != "zulu" || got.Project != "group/project" || got.Issue != 8 { - t.Fatalf("longer base source = %+v, %v; want zulu issue 8", got, err) - } - }) -} - -// Bare references and unconfigured hosts must never choose an arbitrary PAT. -func TestParseForgeRefRejectsUnconfiguredReferences(t *testing.T) { - sources := []store.ForgeSource{ - {Name: "github", Kind: "github", BaseURL: "https://github.com"}, - {Name: "gitlab", Kind: "gitlab", BaseURL: "https://gitlab.example.com"}, - } - tests := []struct { - name, sourceName, raw, want string - }{ - {"unknown host", "", "https://unknown.example/owner/repo/issues/1", "no configured source for host unknown.example"}, - {"bare without source", "", "owner/repo", "no configured source named"}, - {"bare with unknown source", "missing", "owner/repo", "no configured source named missing"}, - {"bare with GitLab source", "gitlab", "owner/repo", "bare reference requires GitHub source"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, err := parseForgeRef(sources, test.sourceName, test.raw) - if err == nil || err.Error() != test.want { - t.Fatalf("parseForgeRef(%q, %q) error = %v, want %q", test.sourceName, test.raw, err, test.want) - } - }) - } -} - -// Unambiguous modern and GitHub route errors must not degrade to project imports. -func TestParseForgeRefRejectsMalformedRoutes(t *testing.T) { - sources := []store.ForgeSource{ - {Name: "gitlab", Kind: "gitlab", BaseURL: "https://gitlab.example.com"}, - {Name: "github", Kind: "github", BaseURL: "https://github.com"}, - } - for _, raw := range []string{ - "https://gitlab.example.com/group/project/-/issues/0", - "https://gitlab.example.com/group/project/-/issues/not-a-number", - "https://gitlab.example.com/group/project/-/issues/42/notes", - "https://github.com/owner/repo/issues/-1", - "https://github.com/owner/repo/milestone/999999999999999999999999999999999999", - "https://github.com/owner/repo/issues/9/comments", - } { - t.Run(raw, func(t *testing.T) { - if _, err := parseForgeRef(sources, "", raw); err == nil { - t.Fatalf("parseForgeRef(%q) succeeded, want malformed route error", raw) - } - }) - } -} - -// Legacy GitLab markers are routes only with a final positive decimal ID, so -// every other marker-shaped path remains an otherwise valid nested project. -func TestParseForgeRefTreatsAmbiguousLegacyTailsAsProjects(t *testing.T) { - sources := []store.ForgeSource{{Name: "gitlab", Kind: "gitlab", BaseURL: "https://gitlab.example.com"}} - for _, raw := range []string{ - "https://gitlab.example.com/group/project/issues/42/notes", - "https://gitlab.example.com/group/project/issues/not-a-number/notes", - "https://gitlab.example.com/group/project/milestones/not-a-number/notes", - "https://gitlab.example.com/group/project/issues/0", - "https://gitlab.example.com/group/project/issues/not-a-number", - "https://gitlab.example.com/group/project/milestones/0", - } { - t.Run(raw, func(t *testing.T) { - got, err := parseForgeRef(sources, "", raw) - if err != nil { - t.Fatalf("parseForgeRef(%q): %v", raw, err) - } - project := strings.TrimPrefix(raw, "https://gitlab.example.com/") - if got.Project != project || got.Issue != 0 || got.Milestone != 0 { - t.Fatalf("parseForgeRef(%q) = %+v, want project-only %q", raw, got, project) - } - }) - } -} - -func testForgeRef(kind, name, baseURL, project string, issue, milestone int, pat string) forgeRef { - return forgeRef{ - Source: store.ForgeSource{Name: name, Kind: kind, BaseURL: baseURL}, - Kind: kind, - Project: project, - Issue: issue, - Milestone: milestone, - pat: pat, - } -} -// Issue fetches encode forge-native paths, attach credentials only as headers, -// and keep discussion text bounded before it reaches the AI import pipeline. -func TestFetchIssueUsesEncodedEndpointsAndHeaderOnlyPAT(t *testing.T) { - tests := []struct { - name, kind, sourceName, project, pat, issuePath, commentsPath string - basePath string - ref forgeRef - wantRef string - }{ - { - name: "GitLab enterprise", kind: "gitlab", sourceName: "gitlab", project: "group/sub/project", pat: "glpat-test", - basePath: "/forge", issuePath: "/forge/api/v4/projects/group%2Fsub%2Fproject/issues/42", commentsPath: "/forge/api/v4/projects/group%2Fsub%2Fproject/issues/42/notes", - wantRef: "gitlab#42", - }, - { - name: "GitHub enterprise", kind: "github", sourceName: "github", project: "owner name/repo", pat: "ghp-test", - basePath: "/enterprise", issuePath: "/enterprise/api/v3/repos/owner%20name/repo/issues/9", commentsPath: "/enterprise/api/v3/repos/owner%20name/repo/issues/9/comments", - wantRef: "github#9", - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - var requests int - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - if strings.Contains(r.URL.RawQuery, test.pat) || strings.Contains(r.RequestURI, test.pat) { - t.Fatal("forge request put the PAT in its URL") - } - if r.Method != http.MethodGet { - t.Fatalf("method = %s, want GET", r.Method) - } - switch test.kind { - case "gitlab": - if r.Header.Get("PRIVATE-TOKEN") != test.pat || r.Header.Get("Authorization") != "" { - t.Fatal("GitLab request did not carry only PRIVATE-TOKEN") - } - case "github": - if r.Header.Get("Authorization") != "Bearer "+test.pat || r.Header.Get("PRIVATE-TOKEN") != "" { - t.Fatal("GitHub request did not carry only Bearer authorization") - } - if r.Header.Get("Accept") != "application/vnd.github+json" { - t.Fatalf("GitHub Accept = %q", r.Header.Get("Accept")) - } - } - switch r.URL.EscapedPath() { - case test.issuePath: - if r.URL.RawQuery != "" { - t.Fatalf("issue query = %q, want empty", r.URL.RawQuery) - } - if test.kind == "gitlab" { - _, _ = io.WriteString(w, `{"iid":42,"title":"Fix import","description":"body","web_url":"https://forge.example/issues/42","labels":["bug"]}`) - } else { - _, _ = io.WriteString(w, `{"number":9,"title":"Fix import","body":"body","html_url":"https://forge.example/issues/9","labels":[{"name":"bug"}]}`) - } - case test.commentsPath: - if r.URL.Query().Get("per_page") != "50" { - t.Fatalf("comments per_page = %q, want 50", r.URL.Query().Get("per_page")) - } - comments := make([]map[string]any, 0, 22) - for i := range 22 { - body := "comment" - if i == 1 { - body = strings.Repeat("é", 700) - } - if test.kind == "gitlab" { - comments = append(comments, map[string]any{"body": body, "system": i == 0}) - } else { - comments = append(comments, map[string]any{"body": body}) - } - } - if err := json.NewEncoder(w).Encode(comments); err != nil { - t.Fatalf("encode comments: %v", err) - } - default: - http.NotFound(w, r) - } - })) - defer upstream.Close() - - baseURL := upstream.URL + test.basePath - issue := 42 - if test.kind == "github" { - issue = 9 - } - ref := testForgeRef(test.kind, test.sourceName, baseURL, test.project, issue, 0, test.pat) - s := &server{forgeClient: upstream.Client()} - got, err := s.fetchIssue(context.Background(), ref) - if err != nil { - t.Fatalf("fetchIssue: %v", err) - } - if requests != 2 || got.Ref != test.wantRef || got.Title != "Fix import" || got.Body != "body" || got.URL == "" || len(got.Labels) != 1 || got.Labels[0] != "bug" { - t.Fatalf("fetchIssue result = %+v, requests=%d", got, requests) - } - if len(got.Comments) != 20 { - t.Fatalf("comments = %d, want 20 after system-note filtering and cap", len(got.Comments)) - } - for _, comment := range got.Comments { - if len(comment) > 1<<10 || !utf8.ValidString(comment) { - t.Fatalf("comment has invalid bounded UTF-8 length=%d", len(comment)) - } - } - }) - } -} - -// GitHub pull requests share issue endpoints but must never become imported tasks. -func TestFetchIssueRejectsGitHubPullRequests(t *testing.T) { - for _, body := range []string{ - `{"number":9,"title":"PR","pull_request":{}}`, - `{"number":9,"title":"PR","pull_request":null}`, - } { - t.Run(body, func(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, "/comments") { - _, _ = io.WriteString(w, `[]`) - return - } - _, _ = io.WriteString(w, body) - })) - defer upstream.Close() - - s := &server{forgeClient: upstream.Client()} - ref := testForgeRef("github", "github", upstream.URL+"/enterprise", "owner/repo", 9, 0, "") - _, err := s.fetchIssue(context.Background(), ref) - var ae *aiError - if !errors.As(err, &ae) || ae.code != http.StatusBadGateway || ae.msg != "forge request failed" { - t.Fatalf("pull-request error = %#v, want opaque forge aiError", err) - } - }) - } -} - -// List fetches cap imports, preserve upstream total hints, and stop immediately at rate limits. -func TestFetchIssuesCapsAndReturnsRateLimitedPartials(t *testing.T) { - t.Run("GitLab pagination then 429", func(t *testing.T) { - var calls int - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.EscapedPath() != "/forge/api/v4/projects/group%2Fproject/issues" { - http.NotFound(w, r) - return - } - if r.URL.Query().Get("state") != "opened" || r.URL.Query().Get("per_page") != "50" { - t.Fatalf("list query = %q", r.URL.RawQuery) - } - calls++ - if r.URL.Query().Get("page") == "2" { - w.Header().Set("X-Total", "30") - w.WriteHeader(http.StatusTooManyRequests) - return - } - w.Header().Set("X-Total", "30") - w.Header().Set("X-Next-Page", "2") - _, _ = io.WriteString(w, `[{"iid":1,"title":"One","description":"body","web_url":"https://forge.example/1"},{"iid":2,"title":"Two","description":"body","web_url":"https://forge.example/2"}]`) - })) - defer upstream.Close() - - s := &server{forgeClient: upstream.Client()} - ref := testForgeRef("gitlab", "gitlab", upstream.URL+"/forge", "group/project", 0, 0, "") - issues, total, truncated, note, err := s.fetchIssues(context.Background(), ref, 99) - if err != nil || calls != 2 || len(issues) != 2 || total != 30 || !truncated || note != "rate limited — partial results (2 of 30)" { - t.Fatalf("fetchIssues = issues=%d total=%d truncated=%v note=%q err=%v calls=%d", len(issues), total, truncated, note, err, calls) - } - }) - - t.Run("GitLab maximum import cap", func(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - items := make([]map[string]any, 25) - for i := range items { - items[i] = map[string]any{"iid": i + 1, "title": "Task", "description": "body", "web_url": "https://forge.example"} - } - if err := json.NewEncoder(w).Encode(items); err != nil { - t.Fatalf("encode issues: %v", err) - } - })) - defer upstream.Close() - - s := &server{forgeClient: upstream.Client()} - ref := testForgeRef("gitlab", "gitlab", upstream.URL, "group/project", 0, 0, "") - issues, total, truncated, note, err := s.fetchIssues(context.Background(), ref, 100) - if err != nil || len(issues) != 20 || total != 25 || !truncated || note != "" { - t.Fatalf("cap result = issues=%d total=%d truncated=%v note=%q err=%v", len(issues), total, truncated, note, err) - } - }) -} - -// Milestone lists resolve their forge-specific marker before filtering issues, while GitHub rate exhaustion remains partial. -func TestFetchIssuesResolvesMilestonesAndDropsGitHubPullRequests(t *testing.T) { - var calls int - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls++ - switch r.URL.EscapedPath() { - case "/enterprise/api/v3/repos/owner/repo/milestones/3": - _, _ = io.WriteString(w, `{"number":3,"title":"Release"}`) - case "/enterprise/api/v3/repos/owner/repo/issues": - if r.URL.Query().Get("milestone") != "3" || r.URL.Query().Get("state") != "open" || r.URL.Query().Get("per_page") != "50" { - t.Fatalf("milestone list query = %q", r.URL.RawQuery) - } - _, _ = io.WriteString(w, `[{"number":1,"title":"PR","pull_request":null},{"number":2,"title":"Issue","body":"body","html_url":"https://forge.example/2"}]`) - default: - http.NotFound(w, r) - } - })) - defer upstream.Close() - - s := &server{forgeClient: upstream.Client()} - ref := testForgeRef("github", "github", upstream.URL+"/enterprise", "owner/repo", 0, 3, "") - issues, total, truncated, note, err := s.fetchIssues(context.Background(), ref, 20) - if err != nil || calls != 2 || len(issues) != 1 || issues[0].Ref != "github#2" || total != 1 || truncated || note != "" { - t.Fatalf("milestone result = %+v total=%d truncated=%v note=%q err=%v calls=%d", issues, total, truncated, note, err, calls) - } - - t.Run("GitLab title filter", func(t *testing.T) { - var milestoneCalls int - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - milestoneCalls++ - switch r.URL.EscapedPath() { - case "/forge/api/v4/projects/group%2Fproject/milestones/7": - _, _ = io.WriteString(w, `{"title":"Release 1"}`) - case "/forge/api/v4/projects/group%2Fproject/issues": - if r.URL.Query().Get("milestone") != "Release 1" || r.URL.Query().Get("state") != "opened" { - t.Fatalf("GitLab milestone query = %q", r.URL.RawQuery) - } - _, _ = io.WriteString(w, `[{"iid":7,"title":"Issue","description":"body","web_url":"https://forge.example/7"}]`) - default: - http.NotFound(w, r) - } - })) - defer upstream.Close() - - s := &server{forgeClient: upstream.Client()} - ref := testForgeRef("gitlab", "gitlab", upstream.URL+"/forge", "group/project", 0, 7, "") - issues, total, truncated, note, err := s.fetchIssues(context.Background(), ref, 20) - if err != nil || milestoneCalls != 2 || len(issues) != 1 || issues[0].Ref != "gitlab#7" || total != 1 || truncated || note != "" { - t.Fatalf("GitLab milestone result = %+v total=%d truncated=%v note=%q err=%v calls=%d", issues, total, truncated, note, err, milestoneCalls) - } - }) -} - -// Exhausted GitHub rate limits return already fetched work without retries or sleeps. -func TestFetchIssuesReturnsGitHubRateLimitPartial(t *testing.T) { - var calls int - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.EscapedPath() != "/enterprise/api/v3/repos/owner/repo/issues" { - http.NotFound(w, r) - return - } - calls++ - if r.URL.Query().Get("page") == "2" { - w.Header().Set("X-RateLimit-Remaining", "0") - w.WriteHeader(http.StatusForbidden) - return - } - w.Header().Set("Link", `; rel="next"`) - w.Header().Set("X-Total", "999") - _, _ = io.WriteString(w, `[{"number":1,"title":"One","body":"body","html_url":"https://forge.example/1"},{"number":2,"title":"Two","body":"body","html_url":"https://forge.example/2"}]`) - })) - defer upstream.Close() - - s := &server{forgeClient: upstream.Client()} - ref := testForgeRef("github", "github", upstream.URL+"/enterprise", "owner/repo", 0, 0, "") - issues, total, truncated, note, err := s.fetchIssues(context.Background(), ref, 20) - if err != nil || calls != 2 || len(issues) != 2 || total != 2 || !truncated || note != "rate limited — partial results (2 of 2)" { - t.Fatalf("GitHub partial = issues=%d total=%d truncated=%v note=%q err=%v calls=%d", len(issues), total, truncated, note, err, calls) - } -} - -// Import preview computes duplicate flags before one AI transformation and -// makes provenance server-owned even when the model invents link tags. func TestImportPreviewTransformsConfiguredForgeIssues(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.EscapedPath() != "/forge/api/v4/projects/group%2Fproject/issues" { @@ -1227,7 +693,8 @@ func TestImportPreviewTransformsConfiguredForgeIssues(t *testing.T) { if _, err := st.SetForgeSource("default", "gitlab-main", "gitlab", &baseURL, &pat); err != nil { t.Fatalf("seed forge source: %v", err) } - linked, err := st.AddTask("default", board.Task{Title: "Existing linked", Tags: []string{"link::gitlab#42"}}) + qualified42 := "gitlab:gitlab-main@" + baseURL + "/group/project#42" + linked, err := st.AddTask("default", board.Task{Title: "Existing linked", Tags: []string{"link::gitlab#42", "import::" + qualified42}}) if err != nil { t.Fatalf("seed linked task: %v", err) } @@ -1274,9 +741,8 @@ func TestImportPreviewTransformsConfiguredForgeIssues(t *testing.T) { if _, ok := raw["commentary"]; ok { t.Fatalf("preview response leaked the skill commentary: %v", raw) } - host := strings.TrimPrefix(upstream.URL, "http://") first, second, unlinked := response.Drafts[0], response.Drafts[1], response.Drafts[2] - if first.Link != "gitlab#42" || first.ExternalKey != "gitlab:"+host+"/group/project#42" || first.URL == "" || first.DuplicateOf == nil || first.DuplicateOf.ID != linked.ID || first.DuplicateOf.Title != linked.Title || first.DuplicateOf.Via != "link" || strings.Join(first.Tags, ",") != "team::auth,link::gitlab#42" { + if first.Link != "gitlab#42" || first.ExternalKey != qualified42 || first.URL == "" || first.DuplicateOf == nil || first.DuplicateOf.ID != linked.ID || first.DuplicateOf.Title != linked.Title || first.DuplicateOf.Via != "link" || strings.Join(first.Tags, ",") != "team::auth,link::gitlab#42,import::"+qualified42 { t.Fatalf("linked draft = %+v", first) } if second.Link != "gitlab#43" || second.DuplicateOf == nil || second.DuplicateOf.ID != similar.ID || second.DuplicateOf.Via != "similar" || !strings.Contains(strings.Join(second.Tags, ","), "link::gitlab#43") { @@ -1341,20 +807,7 @@ func TestImportPreviewNotesATransformCutShort(t *testing.T) { // A rate-limited fetch and a transform cut short are independent, so the // second note joins the first rather than replacing it. -func TestAppendImportNoteKeepsWhatTheFetchReported(t *testing.T) { - if got := appendImportNote("", importPartialTransformNote); got != importPartialTransformNote { - t.Errorf("appendImportNote(empty) = %q", got) - } - got := appendImportNote("rate limited", importPartialTransformNote) - if !strings.Contains(got, "rate limited") || !strings.Contains(got, importPartialTransformNote) { - t.Errorf("appendImportNote = %q, want both notes", got) - } -} -// One forge issue yields at most one linked draft. Nothing stops a model from -// proposing two cards for the same source, and handing both the same link and -// external key would let the user accept two board cards that claim to be one -// issue. func TestImportPreviewLinksEachForgeIssueOnce(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.EscapedPath() != "/forge/api/v4/projects/group%2Fproject/issues" { @@ -1385,7 +838,8 @@ func TestImportPreviewLinksEachForgeIssueOnce(t *testing.T) { if _, err := st.SetForgeSource("default", "gitlab-main", "gitlab", &baseURL, &pat); err != nil { t.Fatalf("seed forge source: %v", err) } - if _, err := st.AddTask("default", board.Task{Title: "Existing linked", Tags: []string{"link::gitlab#43"}}); err != nil { + qualified43 := "gitlab:gitlab-main@" + baseURL + "/group/project#43" + if _, err := st.AddTask("default", board.Task{Title: "Existing linked", Tags: []string{"link::gitlab#43", "import::" + qualified43}}); err != nil { t.Fatalf("seed linked task: %v", err) } @@ -2138,12 +1592,12 @@ func TestImportDriftRevisionAndAccept(t *testing.T) { } issueState.Store(importDriftIssueState{Title: "Initial title", Body: "Changed body"}) second, bodyDrift := postImportDrift(t, h, "primary", key, nil) - if second.Code != http.StatusOK || bodyDrift.State != "drifted" || !validImportDriftRevision(bodyDrift.Revision) { + if second.Code != http.StatusOK || bodyDrift.State != "drifted" || !forge.ValidRevision(bodyDrift.Revision) { t.Fatalf("body drift response = %d %+v", second.Code, bodyDrift) } issueState.Store(importDriftIssueState{Title: "Changed title", Body: "Changed body"}) third, titleDrift := postImportDrift(t, h, "primary", key, nil) - if third.Code != http.StatusOK || titleDrift.State != "drifted" || !validImportDriftRevision(titleDrift.Revision) || titleDrift.Revision == bodyDrift.Revision { + if third.Code != http.StatusOK || titleDrift.State != "drifted" || !forge.ValidRevision(titleDrift.Revision) || titleDrift.Revision == bodyDrift.Revision { t.Fatalf("title drift response = %d %+v, body revision=%q", third.Code, titleDrift, bodyDrift.Revision) } @@ -2162,7 +1616,7 @@ func TestImportDriftRevisionAndAccept(t *testing.T) { t.Fatalf("matching accept = %d %+v body=%q", accepted.Code, advanced, accepted.Body.String()) } afterAccept, found, err := st.ImportBaseline("default", key) - if err != nil || !found || afterAccept.At != advanced.BaselineAt || importDriftRevision(afterAccept) != titleDrift.Revision { + if err != nil || !found || afterAccept.At != advanced.BaselineAt || forge.BaselineRevision(afterAccept) != titleDrift.Revision { t.Fatalf("accepted baseline = (%+v, %t, %v), want revision=%q at=%q", afterAccept, found, err, titleDrift.Revision, advanced.BaselineAt) } next, unchanged := postImportDrift(t, h, "primary", key, nil) @@ -2218,7 +1672,7 @@ func TestImportDriftAcceptRejectsInvalidTargetsAndPreservesBaseline(t *testing.T t.Fatalf("seed rejected accept provenance: %v", err) } - validButStale := importDriftRevision(store.NewImportBaseline("Different", "body", "")) + validButStale := forge.BaselineRevision(store.NewImportBaseline("Different", "body", "")) tests := []struct { name, source, externalKey, revision string headers map[string]string @@ -2269,7 +1723,7 @@ func TestImportDriftAcceptFailuresStayOpaque(t *testing.T) { if err := st.SetImportBaseline("default", key, store.NewImportBaseline("Old", "Old", "2026-07-29T10:00:00Z")); err != nil { t.Fatalf("seed baseline: %v", err) } - w, _ := postImportDriftAccept(t, h, "primary", key, importDriftRevision(store.NewImportBaseline("Current", "Current", "")), nil) + w, _ := postImportDriftAccept(t, h, "primary", key, forge.BaselineRevision(store.NewImportBaseline("Current", "Current", "")), nil) if w.Code != http.StatusBadGateway || strings.TrimSpace(w.Body.String()) != "connection failed" { t.Fatalf("forge accept failure = %d %q, want opaque 502", w.Code, w.Body.String()) } diff --git a/internal/server/server.go b/internal/server/server.go index 86deb0c..965593b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,6 +22,7 @@ import ( "time" kbai "github.com/RandomCodeSpace/kb/internal/ai" + kbforge "github.com/RandomCodeSpace/kb/internal/forge" "github.com/RandomCodeSpace/kb/internal/store" ) @@ -59,6 +60,8 @@ type server struct { store *store.Store aiClient *http.Client // injectable for tests forgeClient *http.Client // injectable for tests + forgeEngine *kbforge.Service + forgeOnce sync.Once linkClient *http.Client // injectable for tests issuer string jwks *jwksCache @@ -70,32 +73,6 @@ type server struct { // afterConditionalBoardSnapshot is a deterministic test seam for a writer // between the handler's preliminary read and the store-owned predicate. afterConditionalBoardSnapshot func() - - // importDriftLocks serializes first-baseline creation per user so two - // simultaneous checks cannot both claim a different comparison anchor. - importDriftLocks boardLocks -} - -// boardLocks hands out one mutex per board. Per-user rather than global so a -// slow write for one identity does not serialize every other identity; the -// map only grows with the number of distinct users the deployment serves. -type boardLocks struct { - mu sync.Mutex - m map[string]*sync.Mutex -} - -func (l *boardLocks) get(user string) *sync.Mutex { - l.mu.Lock() - defer l.mu.Unlock() - if l.m == nil { - l.m = make(map[string]*sync.Mutex) - } - mu, ok := l.m[user] - if !ok { - mu = new(sync.Mutex) - l.m[user] = mu - } - return mu } // New builds the full HTTP handler (API + embedded SPA) for cfg backed by st. diff --git a/internal/store/drift_test.go b/internal/store/drift_test.go index 45986f2..9e8a661 100644 --- a/internal/store/drift_test.go +++ b/internal/store/drift_test.go @@ -170,8 +170,8 @@ VALUES ('alice', 'forge.example', 'gitlab', 'gitlab:forge.example/acme/app#12', // recreating provenance that no longer exists. func TestSetImportBaselineLeavesMissingKeysAbsent(t *testing.T) { s := newStore(t) - if err := s.SetImportBaseline("alice", "missing", ImportBaseline{Title: "missing"}); err != nil { - t.Fatalf("SetImportBaseline missing key: %v", err) + if err := s.SetImportBaseline("alice", "missing", ImportBaseline{Title: "missing"}); err == nil { + t.Fatal("SetImportBaseline missing key succeeded") } if _, found, err := s.ImportBaseline("alice", "missing"); err != nil || found { t.Fatalf("ImportBaseline missing key = (found %t, err %v), want (false, nil)", found, err) diff --git a/internal/store/import_atomic_test.go b/internal/store/import_atomic_test.go new file mode 100644 index 0000000..0afe495 --- /dev/null +++ b/internal/store/import_atomic_test.go @@ -0,0 +1,140 @@ +package store + +import ( + "sync" + "testing" + + "github.com/RandomCodeSpace/kb/internal/board" +) + +func TestAddTaskWithImportLinkIsAtomicAndDeletionKeepsProvenance(t *testing.T) { + s := newStore(t) + link := ImportLink{Source: "primary", Kind: "github", ExternalKey: "github:primary@example.test/acme/kb#93", Link: "github#93", URL: "https://example.test/acme/kb/issues/93", Title: "Imported"} + created, err := s.AddTaskWithImportLink("alice", board.Task{Title: "Imported", Tags: []string{"link::github#93", "import::" + link.ExternalKey}}, link) + if err != nil { + t.Fatal(err) + } + if _, err := s.CancelTask("alice", created.ID, nil); err != nil { + t.Fatal(err) + } + if _, err := s.DeleteCancelledTask("alice", created.ID); err != nil { + t.Fatal(err) + } + provenance, err := s.ImportedAs("alice", []string{link.ExternalKey}) + if err != nil || provenance[link.ExternalKey].URL != link.URL { + t.Fatalf("provenance after deletion = %+v, %v", provenance, err) + } + + bad := link + bad.URL = "bad\nurl" + if _, err := s.AddTaskWithImportLink("alice", board.Task{Title: "Must roll back"}, bad); err == nil { + t.Fatal("invalid provenance created a task") + } + tasks, err := s.ListTasks("alice", "") + if err != nil || len(tasks) != 0 { + t.Fatalf("rolled-back tasks = %+v, %v", tasks, err) + } +} + +func TestImportBaselineCreateAndCASAreAtomicAcrossCallers(t *testing.T) { + s := newStore(t) + const key = "github:primary@example.test/acme/kb#93" + recordBaselineLink(t, s, "alice", key) + candidates := []ImportBaseline{ + NewImportBaseline("one", "body one", "one"), + NewImportBaseline("two", "body two", "two"), + } + created := make([]bool, 2) + returned := make([]ImportBaseline, 2) + var wg sync.WaitGroup + for index := range candidates { + wg.Add(1) + go func(index int) { + defer wg.Done() + var err error + returned[index], created[index], err = s.CreateImportBaseline("alice", key, candidates[index]) + if err != nil { + t.Errorf("create %d: %v", index, err) + } + }(index) + } + wg.Wait() + if created[0] == created[1] { + t.Fatalf("created flags = %v, want one winner", created) + } + winner, present, err := s.ImportBaseline("alice", key) + if err != nil || !present || (winner != candidates[0] && winner != candidates[1]) { + t.Fatalf("winner = %+v, %t, %v", winner, present, err) + } + for index := range returned { + if returned[index] != winner { + t.Fatalf("caller %d saw %+v, want winner %+v", index, returned[index], winner) + } + } + + next := []ImportBaseline{ + NewImportBaseline("next one", "body", "next-one"), + NewImportBaseline("next two", "body", "next-two"), + } + swapped := make([]bool, 2) + for index := range next { + wg.Add(1) + go func(index int) { + defer wg.Done() + var err error + swapped[index], err = s.CompareAndSwapImportBaseline("alice", key, winner, next[index]) + if err != nil { + t.Errorf("swap %d: %v", index, err) + } + }(index) + } + wg.Wait() + if swapped[0] == swapped[1] { + t.Fatalf("swapped flags = %v, want one winner", swapped) + } +} + +func TestImportAtomicOperationsRejectMissingAndInvalidState(t *testing.T) { + s := newStore(t) + valid := NewImportBaseline("title", "body", "at") + invalid := valid + invalid.Title = "bad\nname" + if _, _, err := s.CreateImportBaseline("alice", "missing", valid); err == nil { + t.Fatal("missing provenance accepted a baseline") + } + if _, _, err := s.CreateImportBaseline("alice", "missing", invalid); err == nil { + t.Fatal("invalid baseline accepted") + } + if _, err := s.CompareAndSwapImportBaseline("alice", "missing", invalid, valid); err == nil { + t.Fatal("invalid expected baseline accepted") + } + if _, err := s.CompareAndSwapImportBaseline("alice", "missing", valid, invalid); err == nil { + t.Fatal("invalid next baseline accepted") + } + if _, err := s.CompareAndSwapImportBaseline("alice", "missing", valid, valid); err == nil { + t.Fatal("missing provenance accepted a swap") + } + + link := ImportLink{Source: "primary", Kind: "github", ExternalKey: "qualified", Link: "github#1", URL: "https://example.test/owner/repo/issues/1", Title: "issue"} + if _, err := s.AddTaskWithImportLink("alice", board.Task{Title: "issue", Status: board.Status("invalid")}, link); err == nil { + t.Fatal("invalid task status accepted") + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + if _, err := s.AddTaskWithImportLink("alice", board.Task{Title: "issue"}, link); err == nil { + t.Fatal("closed store accepted atomic import") + } + if _, _, err := s.CreateImportBaseline("alice", "qualified", valid); err == nil { + t.Fatal("closed store created baseline") + } + if _, err := s.CompareAndSwapImportBaseline("alice", "qualified", valid, valid); err == nil { + t.Fatal("closed store swapped baseline") + } + if err := s.SetImportBaseline("alice", "qualified", valid); err == nil { + t.Fatal("closed store set baseline") + } + if _, _, err := s.ImportBaseline("alice", "qualified"); err == nil { + t.Fatal("closed store read baseline") + } +} diff --git a/internal/store/search.go b/internal/store/search.go index 7d2ee8a..3dfedaf 100644 --- a/internal/store/search.go +++ b/internal/store/search.go @@ -420,16 +420,92 @@ func (s *Store) SetImportBaseline(scope, externalKey string, baseline ImportBase if err := validateImportBaseline(baseline); err != nil { return err } - if _, err := s.db.Exec(` + result, err := s.db.Exec(` UPDATE import_links SET baseline_title = ?, baseline_hash = ?, baseline_excerpt = ?, baseline_at = ? WHERE scope = ? AND external_key = ?`, - baseline.Title, baseline.Hash, baseline.Excerpt, baseline.At, scope, externalKey); err != nil { + baseline.Title, baseline.Hash, baseline.Excerpt, baseline.At, scope, externalKey) + if err != nil { return fmt.Errorf("store: set import baseline: %w", err) } + updated, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("store: set import baseline rows: %w", err) + } + if updated == 0 { + return errors.New("store: import link not found") + } return nil } +// CreateImportBaseline records the first observed baseline atomically. If a +// competing frontend already recorded one, it returns that winner. +func (s *Store) CreateImportBaseline(scope, externalKey string, baseline ImportBaseline) (ImportBaseline, bool, error) { + if err := validateImportBaseline(baseline); err != nil { + return ImportBaseline{}, false, err + } + result, err := s.db.Exec(` +UPDATE import_links +SET baseline_title = ?, baseline_hash = ?, baseline_excerpt = ?, baseline_at = ? +WHERE scope = ? AND external_key = ? + AND baseline_title = '' AND baseline_hash = '' AND baseline_excerpt = '' AND baseline_at = ''`, + baseline.Title, baseline.Hash, baseline.Excerpt, baseline.At, scope, externalKey) + if err != nil { + return ImportBaseline{}, false, fmt.Errorf("store: create import baseline: %w", err) + } + updated, err := result.RowsAffected() + if err != nil { + return ImportBaseline{}, false, fmt.Errorf("store: create import baseline rows: %w", err) + } + if updated == 1 { + return baseline, true, nil + } + current, present, err := s.ImportBaseline(scope, externalKey) + if err != nil { + return ImportBaseline{}, false, err + } + if !present { + return ImportBaseline{}, false, errors.New("store: import link not found") + } + return current, false, nil +} + +// CompareAndSwapImportBaseline updates a baseline only if it still equals the +// caller's observed value. This is the cross-service drift lock. +func (s *Store) CompareAndSwapImportBaseline(scope, externalKey string, expected, next ImportBaseline) (bool, error) { + if err := validateImportBaseline(expected); err != nil { + return false, err + } + if err := validateImportBaseline(next); err != nil { + return false, err + } + result, err := s.db.Exec(` +UPDATE import_links +SET baseline_title = ?, baseline_hash = ?, baseline_excerpt = ?, baseline_at = ? +WHERE scope = ? AND external_key = ? + AND baseline_title = ? AND baseline_hash = ? AND baseline_excerpt = ? AND baseline_at = ?`, + next.Title, next.Hash, next.Excerpt, next.At, scope, externalKey, + expected.Title, expected.Hash, expected.Excerpt, expected.At) + if err != nil { + return false, fmt.Errorf("store: compare and swap import baseline: %w", err) + } + updated, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: compare and swap import baseline rows: %w", err) + } + if updated == 1 { + return true, nil + } + var exists int + if err := s.db.QueryRow(`SELECT 1 FROM import_links WHERE scope = ? AND external_key = ?`, scope, externalKey).Scan(&exists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, errors.New("store: import link not found") + } + return false, fmt.Errorf("store: compare and swap import baseline existence: %w", err) + } + return false, nil +} + func validateImportBaseline(baseline ImportBaseline) error { if len(baseline.Excerpt) > maxImportBaselineExcerptBytes { return errors.New("store: import baseline excerpt exceeds 8192 bytes") @@ -521,8 +597,13 @@ func (s *Store) RecordImportLinks(scope string, links []ImportLink) error { } importedAt := time.Now().UTC().Format(time.RFC3339Nano) return s.withTx(func(tx *sql.Tx) error { - for _, link := range links { - if _, err := tx.Exec(` + return recordImportLinksTx(tx, scope, links, importedAt) + }) +} + +func recordImportLinksTx(tx *sql.Tx, scope string, links []ImportLink, importedAt string) error { + for _, link := range links { + if _, err := tx.Exec(` INSERT INTO import_links (scope, source, kind, external_key, link, url, title, imported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(scope, external_key) DO UPDATE SET @@ -532,12 +613,11 @@ ON CONFLICT(scope, external_key) DO UPDATE SET url = excluded.url, title = excluded.title, imported_at = excluded.imported_at`, - scope, link.Source, link.Kind, link.ExternalKey, link.Link, link.URL, link.Title, importedAt); err != nil { - return fmt.Errorf("store: record import link %q: %w", link.ExternalKey, err) - } + scope, link.Source, link.Kind, link.ExternalKey, link.Link, link.URL, link.Title, importedAt); err != nil { + return fmt.Errorf("store: record import link %q: %w", link.ExternalKey, err) } - return nil - }) + } + return nil } func validateImportLink(link ImportLink) error { diff --git a/internal/store/store.go b/internal/store/store.go index 8a0e402..bfaff97 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -949,6 +949,43 @@ func loadExistingByID(tx *sql.Tx, user string) (map[string]*exTask, error) { // represent are rejected (see ValidateTaskFields). Labels are upserted from // t.Tags. func (s *Store) AddTask(user string, t board.Task) (board.Task, error) { + var err error + t, err = prepareNewTask(t) + if err != nil { + return board.Task{}, err + } + err = s.withTx(func(tx *sql.Tx) error { return s.addTaskTx(tx, user, &t) }) + if err != nil { + return board.Task{}, err + } + return t, nil +} + +// AddTaskWithImportLink inserts one task and its provenance in the same +// transaction. A crash or failed provenance write therefore cannot leave an +// imported card that the next preview mistakes for safe work to recreate. +func (s *Store) AddTaskWithImportLink(user string, t board.Task, link ImportLink) (board.Task, error) { + if err := validateImportLink(link); err != nil { + return board.Task{}, err + } + var err error + t, err = prepareNewTask(t) + if err != nil { + return board.Task{}, err + } + err = s.withTx(func(tx *sql.Tx) error { + if err := s.addTaskTx(tx, user, &t); err != nil { + return err + } + return recordImportLinksTx(tx, user, []ImportLink{link}, time.Now().UTC().Format(time.RFC3339Nano)) + }) + if err != nil { + return board.Task{}, err + } + return t, nil +} + +func prepareNewTask(t board.Task) (board.Task, error) { if t.Status == "" { t.Status = board.StatusTodo } @@ -964,26 +1001,24 @@ func (s *Store) AddTask(user string, t board.Task) (board.Task, error) { now := time.Now().UTC() t.ID = uuid.NewString() t.CreatedAt, t.MovedAt = now, now - err := s.withTx(func(tx *sql.Tx) error { - pos, err := nextPosition(tx, user, t.Status) - if err != nil { - return err - } - t.Position = pos - seq, err := nextSeq(tx, user) - if err != nil { - return err - } - t.Seq = seq - if err := insertTask(tx, user, t); err != nil { - return err - } - return s.upsertLabels(tx, user, t.Tags) - }) + return t, nil +} + +func (s *Store) addTaskTx(tx *sql.Tx, user string, t *board.Task) error { + pos, err := nextPosition(tx, user, t.Status) if err != nil { - return board.Task{}, err + return err } - return t, nil + t.Position = pos + seq, err := nextSeq(tx, user) + if err != nil { + return err + } + t.Seq = seq + if err := insertTask(tx, user, *t); err != nil { + return err + } + return s.upsertLabels(tx, user, t.Tags) } // UpdateTask applies patch to the task matching idPrefix and returns the diff --git a/internal/tui/board_view.go b/internal/tui/board_view.go index 8307cd6..31c495e 100644 --- a/internal/tui/board_view.go +++ b/internal/tui/board_view.go @@ -306,6 +306,9 @@ func (m Model) renderBoard() (string, []boardHit) { } if m.settingsNew != nil { footer := settingsBoardFooter(state, cancelled, m.editor.Enabled(), m.adr.Enabled(), width) + if m.issueImport.Enabled() && width >= 24 { + footer = fitLine("i import | "+footer, width) + } return strings.Join([]string{header, filterLine, body, footer}, "\n"), hits } footer := fitLine(state+" | "+help, width) diff --git a/internal/tui/carddetail/actions.go b/internal/tui/carddetail/actions.go index 9eb1344..24ca81a 100644 --- a/internal/tui/carddetail/actions.go +++ b/internal/tui/carddetail/actions.go @@ -80,7 +80,9 @@ func newLinkInput() textinput.Model { // OwnsInput is the root-routing seam for text entry, selection, confirmation, // and in-flight writes. Destructive or focus-changing root shortcuts must not // run while it returns true. -func (m Model) OwnsInput() bool { return m.action != actionNone || m.saving } +func (m Model) OwnsInput() bool { + return m.action != actionNone || m.saving || m.driftMode != driftNone +} // ConsumeChanged reports one acknowledged mutation exactly once. func (m *Model) ConsumeChanged() bool { @@ -501,6 +503,9 @@ func selectionWindow(count, selection, limit int) (int, int) { } func (m Model) actionFooter(width int) string { + if m.driftMode != driftNone { + return m.driftFooter() + } if m.saving { return "write in progress | esc stays here" } @@ -526,7 +531,7 @@ func (m Model) actionFooter(width int) string { default: switch { case width >= 40: - return "e edit c add d/u rm b link esc close ↑/↓" + return "e edit v drift c add d/u rm b esc close ↑/↓" case width >= 26: return "e c add d/u rm b esc close" default: diff --git a/internal/tui/carddetail/drift.go b/internal/tui/carddetail/drift.go new file mode 100644 index 0000000..3d47d0b --- /dev/null +++ b/internal/tui/carddetail/drift.go @@ -0,0 +1,299 @@ +package carddetail + +import ( + "context" + "errors" + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/RandomCodeSpace/kb/internal/forge" + "github.com/RandomCodeSpace/kb/internal/store" +) + +const upstreamConflictCopy = "Upstream changed again. Check upstream before updating the card." + +type DriftBackend interface { + Provenance(string, string) ([]store.ImportLink, error) + CheckDrift(context.Context, string, string, string) (forge.Drift, error) + AcceptDrift(context.Context, string, string, string, string) (string, error) +} + +type driftMode uint8 + +const ( + driftNone driftMode = iota + driftSelect + driftReview +) + +type driftChoicesLoadedMsg struct { + taskID string + session uint64 + generation uint64 + choices []store.ImportLink + err error +} + +type driftCheckedMsg struct { + taskID string + session uint64 + generation uint64 + result forge.Drift + err error +} + +type driftAcceptedMsg struct { + taskID string + session uint64 + generation uint64 + baselineAt string + err error +} + +func (m *Model) SetDriftBackend(backend DriftBackend, ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + m.driftBackend, m.driftContext = backend, ctx +} + +func (m *Model) cancelDrift() { + if m.driftCancel != nil { + m.driftCancel() + } + m.driftCancel = nil + m.driftGeneration++ + m.driftMode, m.driftBusy = driftNone, "" + m.driftChoices, m.driftSelection, m.driftResult = nil, 0, forge.Drift{} +} + +func (m *Model) beginDrift() tea.Cmd { + if m.driftBackend == nil || m.driftBusy != "" { + return nil + } + links := rawImportLinks(m.task.Tags) + if len(links) == 0 { + m.setStatus("no imported forge link on this card", false) + m.rebuildBody() + return nil + } + m.driftMode, m.driftBusy = driftSelect, "provenance" + m.driftSession++ + m.driftGeneration++ + session, generation, taskID := m.driftSession, m.driftGeneration, m.task.ID + ctx, cancel := context.WithCancel(m.driftContext) + m.driftCancel = cancel + backend, user := m.driftBackend, m.user + return func() tea.Msg { + var choices []store.ImportLink + for _, link := range links { + found, err := backend.Provenance(user, link) + if err != nil { + return driftChoicesLoadedMsg{taskID: taskID, session: session, generation: generation, err: err} + } + choices = append(choices, found...) + } + select { + case <-ctx.Done(): + return driftChoicesLoadedMsg{taskID: taskID, session: session, generation: generation, err: ctx.Err()} + default: + return driftChoicesLoadedMsg{taskID: taskID, session: session, generation: generation, choices: choices} + } + } +} + +func (m *Model) updateDrift(message tea.Msg) tea.Cmd { + switch msg := message.(type) { + case driftChoicesLoadedMsg: + if !m.currentDrift(msg.taskID, msg.session, msg.generation, "provenance") { + return nil + } + m.finishDriftOperation() + if msg.err != nil { + m.cancelDrift() + m.setStatus(driftError(msg.err), true) + m.rebuildBody() + return nil + } + m.driftChoices = msg.choices + if len(msg.choices) == 0 { + m.cancelDrift() + m.setStatus("import provenance not found", true) + m.rebuildBody() + return nil + } + m.driftSelection = 0 + m.rebuildBody() + case driftCheckedMsg: + if !m.currentDrift(msg.taskID, msg.session, msg.generation, "check") { + return nil + } + m.finishDriftOperation() + if msg.err != nil { + m.setStatus(driftError(msg.err), true) + m.rebuildBody() + return nil + } + m.driftMode, m.driftResult = driftReview, msg.result + m.rebuildBody() + case driftAcceptedMsg: + if !m.currentDrift(msg.taskID, msg.session, msg.generation, "accept") { + return nil + } + m.finishDriftOperation() + if msg.err != nil { + if errors.Is(msg.err, forge.ErrUpstreamChanged) { + m.setStatus(upstreamConflictCopy, true) + } else { + m.setStatus(driftError(msg.err), true) + } + m.rebuildBody() + return nil + } + m.driftResult.State, m.driftResult.BaselineAt, m.driftResult.Revision = "unchanged", msg.baselineAt, "" + m.setStatus("upstream baseline updated", false) + m.rebuildBody() + case tea.KeyPressMsg: + if m.driftMode == driftNone { + return nil + } + if msg.String() == "esc" && m.driftBusy == "" { + m.cancelDrift() + m.rebuildBody() + return nil + } + if m.driftBusy != "" { + return nil + } + if m.driftMode == driftSelect { + switch msg.String() { + case "up", "k": + m.driftSelection = max(0, m.driftSelection-1) + case "down", "j": + m.driftSelection = min(len(m.driftChoices)-1, m.driftSelection+1) + case "enter": + return m.startDriftCheck() + } + m.rebuildBody() + return nil + } + if msg.String() == "u" && m.driftResult.State == "drifted" && m.driftResult.Revision != "" { + return m.startDriftAccept() + } + } + return nil +} + +func (m *Model) currentDrift(taskID string, session, generation uint64, operation string) bool { + return m.open && m.task.ID == taskID && m.driftSession == session && m.driftGeneration == generation && m.driftBusy == operation +} + +func (m *Model) finishDriftOperation() { + if m.driftCancel != nil { + m.driftCancel() + } + m.driftCancel, m.driftBusy = nil, "" +} + +func (m *Model) startDriftCheck() tea.Cmd { + if m.driftSelection < 0 || m.driftSelection >= len(m.driftChoices) { + return nil + } + choice := m.driftChoices[m.driftSelection] + m.driftGeneration++ + ctx, cancel := context.WithCancel(m.driftContext) + m.driftCancel, m.driftBusy = cancel, "check" + backend, user := m.driftBackend, m.user + taskID, session, generation := m.task.ID, m.driftSession, m.driftGeneration + return func() tea.Msg { + result, err := backend.CheckDrift(ctx, user, choice.Source, choice.ExternalKey) + return driftCheckedMsg{taskID: taskID, session: session, generation: generation, result: result, err: err} + } +} + +func (m *Model) startDriftAccept() tea.Cmd { + choice := m.driftChoices[m.driftSelection] + revision := m.driftResult.Revision + m.driftGeneration++ + ctx, cancel := context.WithCancel(m.driftContext) + m.driftCancel, m.driftBusy = cancel, "accept" + backend, user := m.driftBackend, m.user + taskID, session, generation := m.task.ID, m.driftSession, m.driftGeneration + return func() tea.Msg { + at, err := backend.AcceptDrift(ctx, user, choice.Source, choice.ExternalKey, revision) + return driftAcceptedMsg{taskID: taskID, session: session, generation: generation, baselineAt: at, err: err} + } +} + +func (m Model) driftBody(width int) string { + lines := []string{"Upstream drift review", "kb does not sync upstream changes into the card."} + if m.driftBusy != "" { + return strings.Join(append(lines, "", m.driftBusy+" in progress..."), "\n") + } + if m.driftMode == driftSelect { + lines = append(lines, "", "Choose provenance:") + for index, item := range m.driftChoices { + cursor := " " + if index == m.driftSelection { + cursor = "> " + } + lines = append(lines, fmt.Sprintf("%s%s %s %s", cursor, safeText(item.Source, false), safeText(item.Title, false), safeText(item.URL, false))) + } + return strings.Join(lines, "\n") + } + result := m.driftResult + lines = append(lines, "", "state "+safeText(result.State, false), "upstream "+safeText(result.UpstreamTitle, false)) + if result.BaselineTitle != "" { + lines = append(lines, "baseline "+safeText(result.BaselineTitle, false)) + } + if result.Summary != "" { + lines = append(lines, "", "summary "+safeText(result.Summary, true)) + } + return strings.Join(lines, "\n") +} + +func (m Model) driftFooter() string { + if m.driftBusy != "" { + return "check in progress | input locked" + } + if m.statusMessage != "" { + prefix := "status: " + if m.statusIsError { + prefix = "error: " + } + return prefix + m.statusMessage + } + if m.driftMode == driftSelect { + return "up/down choose | enter check | esc back" + } + if m.driftResult.State == "drifted" { + return "u update baseline | esc back" + } + return "esc back" +} + +func rawImportLinks(tags []string) []string { + seen := make(map[string]bool) + var result []string + for _, tag := range tags { + if !strings.HasPrefix(tag, "link::") { + continue + } + link := strings.TrimSpace(strings.TrimPrefix(tag, "link::")) + if link != "" && !seen[link] { + seen[link] = true + result = append(result, link) + } + } + return result +} + +func driftError(err error) string { + var categorized *forge.Error + if errors.As(err, &categorized) { + return categorized.Message + } + return "drift check failed" +} diff --git a/internal/tui/carddetail/drift_test.go b/internal/tui/carddetail/drift_test.go new file mode 100644 index 0000000..a7a1c25 --- /dev/null +++ b/internal/tui/carddetail/drift_test.go @@ -0,0 +1,184 @@ +package carddetail + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/forge" + "github.com/RandomCodeSpace/kb/internal/store" +) + +type fakeDriftBackend struct { + provenance map[string][]store.ImportLink + provenanceErr error + result forge.Drift + checkErr error + acceptAt string + acceptErr error + checked []string + accepted int +} + +func (b *fakeDriftBackend) Provenance(_ string, link string) ([]store.ImportLink, error) { + return b.provenance[link], b.provenanceErr +} + +func (b *fakeDriftBackend) CheckDrift(_ context.Context, _, source, key string) (forge.Drift, error) { + b.checked = append(b.checked, source+":"+key) + return b.result, b.checkErr +} + +func (b *fakeDriftBackend) AcceptDrift(_ context.Context, _, _, _, _ string) (string, error) { + b.accepted++ + return b.acceptAt, b.acceptErr +} + +func driftTask(id string) board.Task { + return board.Task{ID: id, Title: "Imported", Status: board.StatusTodo, Tags: []string{"link::github#93", "link::gitlab#93"}} +} + +func TestDriftSelectCheckAndAcceptConflict(t *testing.T) { + backend := &fakeDriftBackend{ + provenance: map[string][]store.ImportLink{ + "github#93": {{Source: "github", ExternalKey: "gh-key", Title: "GitHub issue", URL: "https://github.test/93"}}, + "gitlab#93": {{Source: "gitlab", ExternalKey: "gl-key", Title: "GitLab issue", URL: "https://gitlab.test/93"}}, + }, + result: forge.Drift{State: "drifted", UpstreamTitle: "Changed", BaselineTitle: "Old", Summary: "material change", Revision: strings.Repeat("a", 64)}, + acceptErr: forge.ErrUpstreamChanged, + } + m := New(nil, "alice") + m.SetDriftBackend(backend, context.Background()) + m.Open(driftTask("task-a")) + command := m.Update(tea.KeyPressMsg{Code: 'v'}) + if command == nil || !m.OwnsInput() || m.driftBusy != "provenance" { + t.Fatal("drift selection did not start") + } + m.Update(command()) + if len(m.driftChoices) != 2 || m.driftMode != driftSelect { + t.Fatalf("choices = %+v", m.driftChoices) + } + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + command = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m.Update(command()) + if m.driftMode != driftReview || m.driftResult.State != "drifted" || backend.checked[0] != "gitlab:gl-key" { + t.Fatalf("drift result = %+v checked=%v", m.driftResult, backend.checked) + } + view := m.View(80, 18) + for _, want := range []string{"kb does not sync", "material change", "u update baseline"} { + if !strings.Contains(view, want) { + t.Fatalf("view omitted %q:\n%s", want, view) + } + } + command = m.Update(tea.KeyPressMsg{Code: 'u'}) + m.Update(command()) + if m.statusMessage != upstreamConflictCopy || !m.statusIsError || backend.accepted != 1 { + t.Fatalf("conflict status = %q accepted=%d", m.statusMessage, backend.accepted) + } + if view := m.View(120, 18); !strings.Contains(view, "error: "+upstreamConflictCopy) { + t.Fatalf("conflict status not visible:\n%s", view) + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.driftMode != driftNone || m.OwnsInput() { + t.Fatal("escape did not close drift") + } +} + +func TestDriftAcceptSuccessAndStaleSessionGuards(t *testing.T) { + backend := &fakeDriftBackend{ + provenance: map[string][]store.ImportLink{"github#93": {{Source: "github", ExternalKey: "key", Title: "Issue"}}}, + result: forge.Drift{State: "drifted", Revision: strings.Repeat("b", 64)}, acceptAt: "2026-08-18T00:00:00Z", + } + m := New(nil, "alice") + m.SetDriftBackend(backend, nil) + task := driftTask("task-a") + task.Tags = []string{"link::github#93"} + m.Open(task) + choicesCommand := m.beginDrift() + staleChoices := choicesCommand() + m.Close() + m.Open(board.Task{ID: "task-b", Title: "Other", Status: board.StatusTodo, Tags: task.Tags}) + m.Update(staleChoices) + if len(m.driftChoices) != 0 { + t.Fatal("stale choices crossed task session") + } + m.Update(m.beginDrift()()) + m.Update(m.startDriftCheck()()) + m.Update(m.startDriftAccept()()) + if m.driftResult.State != "unchanged" || m.driftResult.BaselineAt != backend.acceptAt || m.statusMessage != "upstream baseline updated" { + t.Fatalf("accept result = %+v status=%q", m.driftResult, m.statusMessage) + } + if view := m.View(80, 18); !strings.Contains(view, "status: upstream baseline updated") { + t.Fatalf("accept status not visible:\n%s", view) + } +} + +func TestDriftErrorsMissingLinksAndBusyInput(t *testing.T) { + m := New(nil, "alice") + m.SetDriftBackend(&fakeDriftBackend{}, context.Background()) + m.Open(board.Task{ID: "plain", Title: "Plain", Status: board.StatusTodo}) + if command := m.beginDrift(); command != nil || m.statusMessage != "no imported forge link on this card" { + t.Fatalf("missing link = cmd %v status %q", command, m.statusMessage) + } + backend := &fakeDriftBackend{provenanceErr: &forge.Error{Message: "provenance unavailable"}} + m.SetDriftBackend(backend, context.Background()) + m.Open(driftTask("task")) + m.Update(m.beginDrift()()) + if m.driftMode != driftNone || m.statusMessage != "provenance unavailable" { + t.Fatalf("provenance error = mode %d status %q", m.driftMode, m.statusMessage) + } + backend.provenanceErr = nil + backend.provenance = map[string][]store.ImportLink{} + m.Update(m.beginDrift()()) + if m.statusMessage != "import provenance not found" { + t.Fatalf("empty provenance status = %q", m.statusMessage) + } + backend.provenance = map[string][]store.ImportLink{"github#93": {{Source: "github", ExternalKey: "key"}}} + backend.checkErr = errors.New("secret") + m.Open(board.Task{ID: "one", Title: "One", Status: board.StatusTodo, Tags: []string{"link::github#93"}}) + m.Update(m.beginDrift()()) + command := m.startDriftCheck() + if m.updateDrift(tea.KeyPressMsg{Code: tea.KeyEscape}) != nil || m.driftBusy != "check" { + t.Fatal("busy escape leaked") + } + m.Update(command()) + if m.statusMessage != "drift check failed" || !m.driftModeActive() { + t.Fatalf("check error = %q mode=%d", m.statusMessage, m.driftMode) + } +} + +func (m Model) driftModeActive() bool { return m.driftMode != driftNone } + +func TestRawImportLinksAndDriftRenderingHelpers(t *testing.T) { + got := rawImportLinks([]string{"x", "link::github#1", "link::github#1", "link:: ", "link::gitlab#2"}) + if strings.Join(got, ",") != "github#1,gitlab#2" { + t.Fatalf("links = %v", got) + } + m := New(nil, "u") + m.open = true + m.driftMode, m.driftBusy = driftSelect, "check" + if !strings.Contains(m.driftBody(20), "check in progress") || m.driftFooter() != "check in progress | input locked" { + t.Fatal("busy rendering") + } + m.driftBusy = "" + m.driftChoices = []store.ImportLink{{Source: "forge", Title: "title", URL: "https://example"}} + if !strings.Contains(m.driftBody(20), "Choose provenance") || !strings.Contains(m.driftFooter(), "enter check") { + t.Fatal("selection rendering") + } + if driftError(&forge.Error{Message: "safe"}) != "safe" || driftError(errors.New("secret")) != "drift check failed" { + t.Fatal("drift error mapping") + } + m.driftChoices = []store.ImportLink{{Source: "git\x1b[31mhub", Title: "title\a", URL: "https://example.test/\x9b31m"}} + if body := m.driftBody(80); strings.ContainsAny(body, "\x1b\a\x9b") { + t.Fatalf("selection leaked terminal controls: %q", body) + } + m.driftMode = driftReview + m.driftResult = forge.Drift{State: "drifted\x1b", UpstreamTitle: "up\a", BaselineTitle: "base\x9b", Summary: "summary\x1b[31m\nline"} + if body := m.driftBody(80); strings.ContainsAny(body, "\x1b\a\x9b") || !strings.Contains(body, "summary") { + t.Fatalf("review leaked terminal controls: %q", body) + } +} diff --git a/internal/tui/carddetail/model.go b/internal/tui/carddetail/model.go index 9bcf0db..66dadfd 100644 --- a/internal/tui/carddetail/model.go +++ b/internal/tui/carddetail/model.go @@ -3,6 +3,7 @@ package carddetail import ( + "context" "fmt" "strings" "time" @@ -17,6 +18,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/forge" "github.com/RandomCodeSpace/kb/internal/store" ) @@ -80,6 +82,17 @@ type Model struct { changed bool statusMessage string statusIsError bool + + driftBackend DriftBackend + driftContext context.Context + driftMode driftMode + driftSession uint64 + driftGeneration uint64 + driftBusy string + driftCancel context.CancelFunc + driftChoices []store.ImportLink + driftSelection int + driftResult forge.Drift } // New creates a closed detail pane. A nil reader still shows board-resident @@ -105,6 +118,7 @@ func (m Model) TaskID() string { // Open resets the pane to task and returns the asynchronous enrichment load. func (m *Model) Open(task board.Task) tea.Cmd { + m.cancelDrift() m.actionSession++ m.task = task m.comments = nil @@ -154,6 +168,7 @@ func (m *Model) Refresh(task board.Task) tea.Cmd { // Close dismisses the pane and invalidates any in-flight result by clearing // the current task identity. func (m *Model) Close() { + m.cancelDrift() m.generation++ m.actionSession++ m.open = false @@ -190,6 +205,8 @@ func (m *Model) Update(message tea.Msg) tea.Cmd { return nil } switch msg := message.(type) { + case driftChoicesLoadedMsg, driftCheckedMsg, driftAcceptedMsg: + return m.updateDrift(msg) case mutationCompletedMsg: return m.finishMutation(msg) case detailLoadedMsg: @@ -210,6 +227,9 @@ func (m *Model) Update(message tea.Msg) tea.Cmd { m.reconcileDeleteActionAfterRefresh() m.rebuildBody() case tea.KeyPressMsg: + if m.driftMode != driftNone { + return m.updateDrift(msg) + } if m.action != actionNone { return m.updateActionKey(msg) } @@ -219,6 +239,8 @@ func (m *Model) Update(message tea.Msg) tea.Cmd { m.rebuildBody() } switch msg.String() { + case "v": + return m.beginDrift() case "c": return m.beginAction(actionAddComment) case "d": @@ -439,6 +461,9 @@ func fitTerminal(rendered string, width, height int) string { } func (m Model) renderBody(width int) string { + if m.driftMode != driftNone { + return m.driftBody(width) + } if m.action != actionNone { return m.actionBody(width) } diff --git a/internal/tui/carddetail/testdata/TestCardDetailGolden.golden b/internal/tui/carddetail/testdata/TestCardDetailGolden.golden index 47fbaf5..fe110dc 100644 --- a/internal/tui/carddetail/testdata/TestCardDetailGolden.golden +++ b/internal/tui/carddetail/testdata/TestCardDetailGolden.golden @@ -13,5 +13,5 @@ │ • first │ │ https://example.com │ │ │ -│ e edit c add d/u rm b link esc close ↑/↓ 1/8 │ +│ e edit v drift c add d/u rm b esc close ↑/↓ 1/8 │ ╰─────────────────────────────────────────────────────╯ diff --git a/internal/tui/issueimport/model.go b/internal/tui/issueimport/model.go new file mode 100644 index 0000000..325f537 --- /dev/null +++ b/internal/tui/issueimport/model.go @@ -0,0 +1,392 @@ +// Package issueimport implements the direct-store forge import review overlay. +package issueimport + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/forge" + "github.com/RandomCodeSpace/kb/internal/store" +) + +const ( + defaultMax = 8 + maxIssues = 20 +) + +type Store interface { + AddTask(string, board.Task) (board.Task, error) +} + +type Backend interface { + Sources(string) ([]store.ForgeSource, error) + Preview(context.Context, string, forge.PreviewRequest) (forge.Preview, error) + CreateTask(string, string, board.Task, forge.LinkInput) (board.Task, error) +} + +type stage uint8 + +const ( + stageInput stage = iota + stageReview +) + +type row struct { + draft forge.Draft + include bool + created bool + err string +} + +type sourcesLoadedMsg struct { + session uint64 + sources []store.ForgeSource + err error +} + +type previewCompletedMsg struct { + session uint64 + generation uint64 + preview forge.Preview + err error +} + +type cardCreatedMsg struct { + session uint64 + generation uint64 + row int + err error +} + +type Model struct { + store Store + backend Backend + user string + ctx context.Context + + open bool + stage stage + session uint64 + generation uint64 + cancel context.CancelFunc + operation string + changed bool + + sources []store.ForgeSource + source int + ref textinput.Model + max int + focus int + preview forge.Preview + rows []row + selection int + queue []int + queuePos int + status string + statusError bool + scroll int +} + +func New(st Store, backend Backend, user string, ctx context.Context) Model { + if ctx == nil { + ctx = context.Background() + } + input := textinput.New() + input.Prompt = "" + input.Placeholder = "owner/repo or configured forge URL" + input.SetWidth(56) + return Model{store: st, backend: backend, user: user, ctx: ctx, ref: input, max: defaultMax} +} + +func (m Model) Enabled() bool { return m.store != nil && m.backend != nil } +func (m Model) IsOpen() bool { return m.open } + +func IsMessage(message tea.Msg) bool { + switch message.(type) { + case sourcesLoadedMsg, previewCompletedMsg, cardCreatedMsg: + return true + default: + return false + } +} + +func (m *Model) Open() tea.Cmd { + if !m.Enabled() { + return nil + } + m.closeNow() + m.session++ + m.generation++ + m.open, m.stage, m.max, m.focus = true, stageInput, defaultMax, 0 + m.sources, m.source, m.rows, m.queue = nil, 0, nil, nil + m.preview, m.selection, m.queuePos = forge.Preview{}, 0, 0 + m.status, m.statusError, m.changed, m.scroll = "", false, false, 0 + m.ref.SetValue("") + m.ref.Blur() + session := m.session + return func() tea.Msg { + sources, err := m.backend.Sources(m.user) + return sourcesLoadedMsg{session: session, sources: sources, err: err} + } +} + +func (m *Model) Close() { m.closeNow() } + +func (m *Model) closeNow() { + if m.cancel != nil { + m.cancel() + } + m.cancel = nil + m.generation++ + m.open, m.operation = false, "" + m.ref.Blur() +} + +func (m *Model) ConsumeChanged() bool { + changed := m.changed + m.changed = false + return changed +} + +func (m *Model) Update(message tea.Msg) tea.Cmd { + if !m.open { + return nil + } + switch msg := message.(type) { + case sourcesLoadedMsg: + if msg.session != m.session { + return nil + } + if msg.err != nil { + m.setStatus("sources unavailable", true) + return nil + } + m.sources = msg.sources + if len(m.sources) == 0 { + m.setStatus("no forge integrations configured", true) + } + case previewCompletedMsg: + if msg.session != m.session || msg.generation != m.generation || m.operation != "preview" { + return nil + } + m.cancel, m.operation = nil, "" + if msg.err != nil { + m.setStatus(safeError(msg.err), true) + return nil + } + m.preview, m.stage = msg.preview, stageReview + m.rows = make([]row, len(msg.preview.Drafts)) + for index, draft := range msg.preview.Drafts { + include := draft.Duplicate == nil || draft.Duplicate.Via != "link" + m.rows[index] = row{draft: draft, include: include} + } + m.selection, m.scroll = 0, 0 + m.setStatus("review proposals; exact duplicates start unticked", false) + case cardCreatedMsg: + return m.finishCard(msg) + case tea.KeyPressMsg: + if m.operation != "" { + if msg.String() == "esc" && m.operation == "preview" { + m.cancelOperation("preview cancelled") + } + return nil + } + if m.stage == stageInput { + return m.updateInput(msg) + } + return m.updateReview(msg) + } + return nil +} + +func (m *Model) updateInput(msg tea.KeyPressMsg) tea.Cmd { + key := msg.String() + switch key { + case "esc": + m.Close() + return nil + case "tab", "shift+tab": + delta := 1 + if key == "shift+tab" { + delta = -1 + } + m.focus = (m.focus + delta + 3) % 3 + m.applyFocus() + return nil + case "enter": + return m.startPreview() + case "left", "h": + if m.focus == 0 && len(m.sources) > 0 { + m.source = (m.source - 1 + len(m.sources)) % len(m.sources) + } else if m.focus == 2 { + m.max = max(1, m.max-1) + } + return nil + case "right", "l": + if m.focus == 0 && len(m.sources) > 0 { + m.source = (m.source + 1) % len(m.sources) + } else if m.focus == 2 { + m.max = min(maxIssues, m.max+1) + } + return nil + } + if m.focus == 1 { + var command tea.Cmd + m.ref, command = m.ref.Update(msg) + return command + } + if m.focus == 2 && len(key) == 1 && key[0] >= '0' && key[0] <= '9' { + value, _ := strconv.Atoi(key) + m.max = min(maxIssues, max(1, value)) + } + return nil +} + +func (m *Model) applyFocus() tea.Cmd { + if m.focus == 1 { + return m.ref.Focus() + } + m.ref.Blur() + return nil +} + +func (m *Model) startPreview() tea.Cmd { + if len(m.sources) == 0 { + m.setStatus("configure a forge integration first", true) + return nil + } + raw := strings.TrimSpace(m.ref.Value()) + if raw == "" { + m.setStatus("reference required", true) + return nil + } + m.generation++ + generation, session := m.generation, m.session + ctx, cancel := context.WithCancel(m.ctx) + m.cancel, m.operation = cancel, "preview" + m.setStatus("fetching and drafting...", false) + request := forge.PreviewRequest{Source: m.sources[m.source].Name, Ref: raw, Max: m.max} + return func() tea.Msg { + preview, err := m.backend.Preview(ctx, m.user, request) + return previewCompletedMsg{session: session, generation: generation, preview: preview, err: err} + } +} + +func (m *Model) updateReview(msg tea.KeyPressMsg) tea.Cmd { + switch msg.String() { + case "esc": + m.stage, m.rows, m.status = stageInput, nil, "" + m.applyFocus() + case "up", "k": + m.selection = max(0, m.selection-1) + case "down", "j": + m.selection = min(len(m.rows)-1, m.selection+1) + case "space": + if len(m.rows) > 0 && !m.rows[m.selection].created { + m.rows[m.selection].include = !m.rows[m.selection].include + } + case "enter": + return m.startCreate() + } + return nil +} + +func (m *Model) startCreate() tea.Cmd { + m.queue = m.queue[:0] + for index := range m.rows { + if m.rows[index].include && !m.rows[index].created { + m.queue = append(m.queue, index) + } + } + if len(m.queue) == 0 { + m.setStatus("nothing selected", false) + return nil + } + m.generation++ + m.queuePos, m.operation = 0, "create" + return m.nextWrite() +} + +func (m *Model) nextWrite() tea.Cmd { + if m.queuePos >= len(m.queue) { + m.operation = "" + m.setStatus("import complete", false) + return nil + } + index := m.queue[m.queuePos] + if m.rows[index].created { + m.queuePos++ + return m.nextWrite() + } + draft := m.rows[index].draft + task := board.Task{Title: draft.Title, Emoji: draft.Emoji, Desc: draft.Desc, Status: board.StatusTodo, Prio: draft.Prio, Due: draft.Due, Effort: draft.Effort, Tags: append([]string(nil), draft.Tags...)} + for _, check := range draft.Checks { + task.Checks = append(task.Checks, board.Check{Text: check.Text, Done: check.Done}) + } + session, generation := m.session, m.generation + source := m.sources[m.source].Name + item := forge.LinkInput{ExternalKey: draft.ExternalKey, Link: draft.Link, URL: draft.URL, Title: draft.Title} + return func() tea.Msg { + _, err := m.backend.CreateTask(m.user, source, task, item) + return cardCreatedMsg{session: session, generation: generation, row: index, err: err} + } +} + +func (m *Model) finishCard(msg cardCreatedMsg) tea.Cmd { + if msg.session != m.session || msg.generation != m.generation || m.operation != "create" || m.queuePos >= len(m.queue) || m.queue[m.queuePos] != msg.row { + return nil + } + if msg.err != nil { + m.rows[msg.row].err = safeError(msg.err) + m.queuePos++ + return m.nextWrite() + } + m.rows[msg.row].created = true + m.rows[msg.row].err = "" + m.changed = true + m.queuePos++ + return m.nextWrite() +} + +func (m *Model) cancelOperation(status string) { + if m.cancel != nil { + m.cancel() + } + m.cancel = nil + m.generation++ + m.operation = "" + m.setStatus(status, false) +} + +func (m *Model) setStatus(message string, isError bool) { + m.status, m.statusError = message, isError +} + +func safeError(err error) string { + var categorized *forge.Error + if errors.As(err, &categorized) { + return categorized.Message + } + return "operation failed" +} + +func (m Model) sourceName() string { + if len(m.sources) == 0 { + return "none" + } + return m.sources[m.source].Name +} + +func (m Model) progress() string { + if m.operation != "create" { + return "" + } + return fmt.Sprintf("writing %d/%d", min(m.queuePos+1, len(m.queue)), len(m.queue)) +} diff --git a/internal/tui/issueimport/model_test.go b/internal/tui/issueimport/model_test.go new file mode 100644 index 0000000..8ad68ec --- /dev/null +++ b/internal/tui/issueimport/model_test.go @@ -0,0 +1,362 @@ +package issueimport + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/forge" + "github.com/RandomCodeSpace/kb/internal/store" +) + +type fakeStore struct { + added []board.Task + err error +} + +func (s *fakeStore) AddTask(_ string, task board.Task) (board.Task, error) { + if s.err != nil { + return board.Task{}, s.err + } + s.added = append(s.added, task) + task.ID = "created" + return task, nil +} + +type fakeBackend struct { + sources []store.ForgeSource + sourcesErr error + preview forge.Preview + previewErr error + recordErr error + recordCalls int + previewCalls int + lastRequest forge.PreviewRequest + store *fakeStore +} + +func (b *fakeBackend) Sources(string) ([]store.ForgeSource, error) { return b.sources, b.sourcesErr } +func (b *fakeBackend) Preview(_ context.Context, _ string, request forge.PreviewRequest) (forge.Preview, error) { + b.previewCalls++ + b.lastRequest = request + return b.preview, b.previewErr +} +func (b *fakeBackend) CreateTask(user, _ string, task board.Task, _ forge.LinkInput) (board.Task, error) { + b.recordCalls++ + if b.recordErr != nil { + return board.Task{}, b.recordErr + } + return b.store.AddTask(user, task) +} + +func key(value string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: rune(value[0]), Text: value} } + +func openModel(t *testing.T, backend *fakeBackend, st *fakeStore) Model { + t.Helper() + backend.store = st + m := New(st, backend, "alice", context.Background()) + command := m.Open() + if command == nil { + t.Fatal("Open returned nil") + } + m.Update(command()) + return m +} + +func TestPreviewDefaultsExactDuplicatesOffAndFuzzyOn(t *testing.T) { + backend := &fakeBackend{ + sources: []store.ForgeSource{{Name: "primary", Kind: "github"}}, + preview: forge.Preview{Fetched: 2, Truncated: true, TotalHint: 7, Note: "rate limited", Drafts: []forge.Draft{ + {Draft: ai.Draft{Title: "exact"}, Duplicate: &forge.Duplicate{Via: "link", Title: "existing"}}, + {Draft: ai.Draft{Title: "fuzzy"}, Duplicate: &forge.Duplicate{Via: "similar", Title: "maybe"}}, + }}, + } + m := openModel(t, backend, &fakeStore{}) + m.ref.SetValue("owner/repo") + command := m.startPreview() + if command == nil || m.operation != "preview" { + t.Fatal("preview did not start") + } + m.Update(command()) + if m.stage != stageReview || len(m.rows) != 2 || m.rows[0].include || !m.rows[1].include { + t.Fatalf("review defaults = %+v", m.rows) + } + if backend.lastRequest.Source != "primary" || backend.lastRequest.Ref != "owner/repo" || backend.lastRequest.Max != defaultMax { + t.Fatalf("preview request = %+v", backend.lastRequest) + } + view := m.View(70, 20) + for _, want := range []string{"results truncated", "rate limited", "duplicate via link", "duplicate via similar"} { + if !strings.Contains(view, want) { + t.Fatalf("view omitted %q:\n%s", want, view) + } + } +} + +func TestAtomicCardProvenanceRetryDoesNotDuplicateCard(t *testing.T) { + backend := &fakeBackend{ + sources: []store.ForgeSource{{Name: "primary", Kind: "github"}}, + preview: forge.Preview{Drafts: []forge.Draft{{ + Draft: ai.Draft{Title: "import me", Prio: 2, Checks: []ai.DraftCheck{{Text: "verify"}}}, + Link: "github#93", ExternalKey: "github:github.com/acme/kb#93", URL: "https://github.com/acme/kb/issues/93", + }}}, + recordErr: errors.New("disk unavailable"), + } + st := &fakeStore{} + m := openModel(t, backend, st) + m.ref.SetValue("acme/kb") + m.Update(m.startPreview()()) + command := m.startCreate() + m.Update(command()) + if len(st.added) != 0 || m.rows[0].created || m.ConsumeChanged() { + t.Fatalf("failed provenance state = added %d row %+v", len(st.added), m.rows[0]) + } + backend.recordErr = nil + command = m.startCreate() + m.Update(command()) + if len(st.added) != 1 || !m.rows[0].created || backend.recordCalls != 2 || !m.ConsumeChanged() { + t.Fatalf("retry duplicated or failed: added=%d created=%t records=%d", len(st.added), m.rows[0].created, backend.recordCalls) + } +} + +func TestPreviewCancellationAndReopenRejectStaleResults(t *testing.T) { + backend := &fakeBackend{sources: []store.ForgeSource{{Name: "primary"}}, preview: forge.Preview{Drafts: []forge.Draft{{Draft: ai.Draft{Title: "stale"}}}}} + m := openModel(t, backend, &fakeStore{}) + m.ref.SetValue("acme/kb") + command := m.startPreview() + stale := command() + m.Update(key("e")) + if m.operation != "preview" { + t.Fatal("ordinary input cancelled active preview") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.operation != "" || m.status != "preview cancelled" { + t.Fatalf("cancel state = %q %q", m.operation, m.status) + } + m.Update(stale) + if len(m.rows) != 0 || m.stage != stageInput { + t.Fatal("cancelled preview mutated review") + } + m.Open() + m.Update(stale) + if len(m.rows) != 0 { + t.Fatal("prior session result mutated reopened overlay") + } +} + +func TestInputNavigationErrorsAndTerminalSafety(t *testing.T) { + backend := &fakeBackend{sources: []store.ForgeSource{{Name: "one"}, {Name: "two"}}} + m := openModel(t, backend, &fakeStore{}) + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if m.focus != 1 { + t.Fatalf("tab focus = %d", m.focus) + } + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + if m.max != defaultMax+1 { + t.Fatalf("max = %d", m.max) + } + m.focus = 0 + m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + if m.sourceName() != "two" { + t.Fatalf("source = %q", m.sourceName()) + } + m.ref.SetValue("") + m.startPreview() + if !m.statusError || m.status != "reference required" { + t.Fatalf("empty ref status = %q", m.status) + } + m.status = "bad\x1b[31m\x9b31m\nline" + view := m.View(28, 8) + if strings.Contains(view, "\nline") || strings.Contains(view, "\x1b[31m") || strings.Contains(view, "\x9b") { + t.Fatalf("unsafe view:\n%s", view) + } + m.Close() + if m.View(20, 5) != "" || m.Overlay("board", 20, 5) != "board" { + t.Fatal("closed overlay rendered") + } +} + +func TestUnavailableSourcesAndCardFailureStayReviewable(t *testing.T) { + m := openModel(t, &fakeBackend{sourcesErr: errors.New("closed")}, &fakeStore{}) + if !m.statusError || m.status != "sources unavailable" { + t.Fatalf("source error = %q", m.status) + } + backend := &fakeBackend{sources: []store.ForgeSource{{Name: "primary"}}, preview: forge.Preview{Drafts: []forge.Draft{{Draft: ai.Draft{Title: "keep me"}}}}} + st := &fakeStore{err: errors.New("refused")} + m = openModel(t, backend, st) + m.ref.SetValue("acme/kb") + m.Update(m.startPreview()()) + m.Update(m.startCreate()()) + if m.rows[0].created || m.rows[0].err == "" || !m.open || m.stage != stageReview { + t.Fatalf("failed card state = %+v", m.rows[0]) + } + if !IsMessage(previewCompletedMsg{}) || IsMessage(tea.KeyPressMsg{}) { + t.Fatal("message classifier") + } +} + +func TestKeyboardAndReviewBranches(t *testing.T) { + backend := &fakeBackend{sources: []store.ForgeSource{{Name: "one"}, {Name: "two"}}, preview: forge.Preview{Drafts: []forge.Draft{ + {Draft: ai.Draft{Title: "one"}}, {Draft: ai.Draft{Title: "two"}}, + }}} + m := openModel(t, backend, &fakeStore{}) + if !m.IsOpen() { + t.Fatal("model not open") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) + if m.focus != 2 { + t.Fatalf("reverse tab focus = %d", m.focus) + } + m.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + if m.max != defaultMax-1 { + t.Fatalf("left max = %d", m.max) + } + m.focus = 0 + m.source = 0 + m.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + if m.source != 1 { + t.Fatalf("wrapped source = %d", m.source) + } + m.focus = 1 + m.applyFocus() + m.Update(key("x")) + if m.ref.Value() != "x" { + t.Fatalf("text input = %q", m.ref.Value()) + } + m.ref.SetValue("acme/kb") + command := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if command == nil { + t.Fatal("enter did not preview") + } + m.Update(command()) + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if m.selection != 1 { + t.Fatalf("down selection = %d", m.selection) + } + m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) + if m.rows[0].include { + t.Fatal("space did not untick") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.stage != stageInput { + t.Fatal("escape did not return to input") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.open { + t.Fatal("input escape did not close") + } +} + +func TestEdgeMessagesAndRows(t *testing.T) { + disabled := New(nil, nil, "u", nil) + if disabled.Enabled() || disabled.Open() != nil || disabled.Update(key("x")) != nil { + t.Fatal("disabled model became active") + } + backend := &fakeBackend{sources: []store.ForgeSource{{Name: "primary"}}, preview: forge.Preview{Drafts: []forge.Draft{{Draft: ai.Draft{Title: "plain"}}}}} + st := &fakeStore{} + m := openModel(t, backend, st) + m.ref.SetValue("acme/kb") + m.Update(m.startPreview()()) + m.rows[0].draft.ExternalKey = "" + command := m.startCreate() + m.Update(command()) + if len(st.added) != 1 || m.operation != "" || !m.rows[0].created { + t.Fatalf("plain created row = %+v operation=%q", m.rows[0], m.operation) + } + m.rows[0].include = false + m.rows[0].created = false + if m.startCreate() != nil || m.status != "nothing selected" { + t.Fatal("empty selection did not stop") + } + staleCard := cardCreatedMsg{session: m.session + 1, generation: m.generation, row: 0} + if m.finishCard(staleCard) != nil { + t.Fatal("stale write returned command") + } + if safeError(&forge.Error{Message: "safe"}) != "safe" || safeError(errors.New("secret")) != "operation failed" { + t.Fatal("error mapping") + } + empty := New(st, backend, "u", nil) + if empty.sourceName() != "none" || empty.progress() != "" { + t.Fatal("empty helpers") + } +} + +func TestReviewWindowAndRenderingBranches(t *testing.T) { + drafts := make([]forge.Draft, 16) + for index := range drafts { + drafts[index] = forge.Draft{Draft: ai.Draft{Title: strings.Repeat("long", index+1)}} + } + backend := &fakeBackend{sources: []store.ForgeSource{{Name: "primary"}}, preview: forge.Preview{Fetched: 16, Drafts: drafts}} + m := openModel(t, backend, &fakeStore{}) + inputView := m.View(10, 4) + if inputView == "" || m.Overlay("board", 10, 4) == "board" { + t.Fatal("open input did not render") + } + m.ref.SetValue("acme/kb") + m.Update(m.startPreview()()) + m.selection = 15 + m.operation, m.queue, m.queuePos = "create", []int{0, 1}, 0 + view := m.View(44, 14) + if !strings.Contains(view, "writing 1/2") || strings.Contains(view, "longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglong") { + t.Fatalf("review rendering:\n%s", view) + } + if start, end := rowWindow(2, 0, 5); start != 0 || end != 2 { + t.Fatalf("small window = %d,%d", start, end) + } + if start, end := rowWindow(20, 19, 5); start != 15 || end != 20 { + t.Fatalf("tail window = %d,%d", start, end) + } + if got := fit("abcdef", 1); got == "" { + t.Fatal("tiny fit empty") + } +} + +func TestRemainingStateBranches(t *testing.T) { + backend := &fakeBackend{sources: []store.ForgeSource{{Name: "primary"}}, previewErr: &forge.Error{Message: "preview refused"}} + m := openModel(t, backend, &fakeStore{}) + m.focus = 2 + m.Update(key("9")) + m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + if m.max != 10 { + t.Fatalf("numeric/right max = %d", m.max) + } + m.ref.SetValue("acme/kb") + m.Update(m.startPreview()()) + if m.status != "preview refused" || !m.statusError { + t.Fatalf("preview error = %q", m.status) + } + backend.previewErr = nil + backend.preview = forge.Preview{Fetched: 1, Drafts: []forge.Draft{{Draft: ai.Draft{Title: "one"}}}} + m.Update(m.startPreview()()) + command := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if command == nil { + t.Fatal("review enter did not start create") + } + m.operation = "preview" + _, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + m.Close() + if m.open || m.cancel != nil { + t.Fatal("close did not cancel operation") + } + + empty := openModel(t, &fakeBackend{}, &fakeStore{}) + if empty.sourceName() != "none" || !empty.statusError { + t.Fatal("empty source state") + } + empty.stage = stageReview + empty.rows = []row{{draft: forge.Draft{Draft: ai.Draft{Title: "done"}}, created: true}, {draft: forge.Draft{Draft: ai.Draft{Title: "pending"}}, err: "retry"}} + empty.status, empty.statusError = "failed", true + view := empty.View(60, 16) + for _, want := range []string{"[created]", "retry", "error failed"} { + if !strings.Contains(view, want) { + t.Fatalf("remaining view omitted %q:\n%s", want, view) + } + } +} diff --git a/internal/tui/issueimport/view.go b/internal/tui/issueimport/view.go new file mode 100644 index 0000000..66df113 --- /dev/null +++ b/internal/tui/issueimport/view.go @@ -0,0 +1,117 @@ +package issueimport + +import ( + "fmt" + "strings" + "unicode" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +func (m Model) Overlay(background string, width, height int) string { + if !m.open { + return background + } + return lipgloss.Place(max(width, 1), max(height, 1), lipgloss.Center, lipgloss.Center, m.View(width, height)) +} + +func (m Model) View(width, height int) string { + if !m.open { + return "" + } + paneWidth := min(max(width-4, 24), 88) + inner := paneWidth - 4 + lines := []string{"Forge issue import"} + if m.stage == stageInput { + lines = append(lines, + focusMark(m.focus == 0)+"source "+m.sourceName(), + focusMark(m.focus == 1)+"ref "+m.ref.View(), + focusMark(m.focus == 2)+fmt.Sprintf("max %d", m.max), + ) + if m.operation == "preview" { + lines = append(lines, "", "fetching configured forge data and drafting...") + } + lines = append(lines, "", "Tab fields Left/Right change Enter preview Esc close") + } else { + if m.preview.Truncated { + lines = append(lines, fmt.Sprintf("fetched %d of about %d; results truncated", m.preview.Fetched, m.preview.TotalHint)) + } else { + lines = append(lines, fmt.Sprintf("fetched %d", m.preview.Fetched)) + } + if m.preview.Note != "" { + lines = append(lines, "note "+m.preview.Note) + } + lines = append(lines, "") + start, end := rowWindow(len(m.rows), m.selection, max(1, min(height-10, 12))) + for index := start; index < end; index++ { + item := m.rows[index] + cursor := " " + if index == m.selection { + cursor = "> " + } + check := "[ ]" + if item.include { + check = "[x]" + } + state := "" + switch { + case item.created: + state = " [created]" + case item.draft.Duplicate != nil: + state = fmt.Sprintf(" [duplicate via %s: %s]", item.draft.Duplicate.Via, item.draft.Duplicate.Title) + } + lines = append(lines, cursor+check+" "+item.draft.Title+state) + if item.err != "" { + lines = append(lines, " "+item.err) + } + } + if progress := m.progress(); progress != "" { + lines = append(lines, "", progress) + } + lines = append(lines, "", "Up/Down select Space toggle Enter import/retry Esc back") + } + if m.status != "" { + prefix := "status " + if m.statusError { + prefix = "error " + } + lines = append(lines, prefix+m.status) + } + for index := range lines { + lines[index] = fit(lines[index], inner) + } + return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).Width(paneWidth - 2).Render(strings.Join(lines, "\n")) +} + +func focusMark(active bool) string { + if active { + return "> " + } + return " " +} + +func rowWindow(count, selection, limit int) (int, int) { + if count <= limit { + return 0, count + } + start := max(0, selection-limit/2) + start = min(start, count-limit) + return start, start + limit +} + +func fit(value string, width int) string { + value = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, value) + if ansi.StringWidth(value) <= width { + return value + } + if width <= 1 { + return ansi.Truncate(value, width, "") + } + return ansi.Truncate(value, width-1, "") + "…" +} diff --git a/internal/tui/model.go b/internal/tui/model.go index abf6843..e53f225 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -10,9 +10,12 @@ import ( "github.com/RandomCodeSpace/kb/internal/ai" "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/forge" + "github.com/RandomCodeSpace/kb/internal/store" "github.com/RandomCodeSpace/kb/internal/tui/adrsplit" "github.com/RandomCodeSpace/kb/internal/tui/carddetail" "github.com/RandomCodeSpace/kb/internal/tui/cardeditor" + "github.com/RandomCodeSpace/kb/internal/tui/issueimport" ) const ( @@ -63,6 +66,7 @@ type Model struct { detail carddetail.Model editor cardeditor.Model adr adrsplit.Model + issueImport issueimport.Model selectAfterLoad string width int height int @@ -96,6 +100,11 @@ func (m *Model) configureAI(runner *ai.Runner, ctx context.Context) { m.editor.SetAIRunner(runner, ctx) adrStore, _ := m.store.(adrsplit.Store) m.adr = adrsplit.New(adrStore, runner, m.user, ctx) + if direct, ok := m.store.(*store.Store); ok { + backend := forge.New(direct, runner, nil) + m.issueImport = issueimport.New(direct, backend, m.user, ctx) + m.detail.SetDriftBackend(backend, ctx) + } } // NewModel creates the root model for one local board owner. @@ -167,6 +176,30 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if carddetail.IsMutationMessage(message) { return m, m.updateDetail(message) } + if m.issueImport.IsOpen() && issueimport.IsMessage(message) { + command := m.issueImport.Update(message) + if m.issueImport.ConsumeChanged() { + return m, batchCommands(command, m.requireFreshBoard()) + } + return m, command + } + if m.issueImport.IsOpen() { + switch msg := message.(type) { + case tea.KeyPressMsg: + if msg.String() == "ctrl+c" { + break + } + command := m.issueImport.Update(msg) + if m.issueImport.ConsumeChanged() { + return m, batchCommands(command, m.requireFreshBoard()) + } + return m, command + case boardCardClickedMsg, boardColumnClickedMsg, + filterTextClickedMsg, filterLabelClickedMsg, filterClearClickedMsg, + boardPointerDownMsg, boardPointerMoveMsg, boardPointerUpMsg: + return m, nil + } + } if m.actionNotice && isBoardUserInput(message) { m.actionNotice = false } @@ -226,7 +259,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.detail.IsOpen() { switch msg := message.(type) { case tea.KeyPressMsg: - if m.detail.OwnsInput() { + if m.detail.OwnsInput() && msg.String() != "ctrl+c" { return m, m.updateDetail(message) } switch msg.String() { @@ -266,6 +299,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.editor.CancelAsync() m.adr.Close() + m.issueImport.Close() m.stopped = true m.reloadPending = false return m, tea.Quit @@ -296,7 +330,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.boardView.focusTask(m.filteredBoard(), m.move.lifted.taskID) } return m, nil - case "s", "c", "1", "2", "3", "4", "tab", "shift+tab", "n", "e", "a", "/", "f", "x": + case "s", "c", "1", "2", "3", "4", "tab", "shift+tab", "n", "e", "a", "i", "/", "f", "x": m.cancelCardMove("focus changed") default: return m, nil @@ -322,6 +356,10 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.adr.Enabled() && !m.move.saving { return m, m.adr.Open() } + case "i": + if m.issueImport.Enabled() && !m.writeBusy() { + return m, m.issueImport.Open() + } case "enter": if task, ok := m.selectedTask(); ok { m.detail.Resize(m.width, m.height) @@ -655,10 +693,14 @@ func (m Model) View() tea.View { content = m.taskActionOverlay(content) hits = nil } + if m.issueImport.IsOpen() { + content = m.issueImport.Overlay(content, m.width, m.height) + hits = nil + } view := tea.NewView(content) view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion - if m.settings == nil && !m.editor.IsOpen() && !m.adr.IsOpen() && !m.action.open() { + if m.settings == nil && !m.editor.IsOpen() && !m.adr.IsOpen() && !m.action.open() && !m.issueImport.IsOpen() && !m.detail.OwnsInput() { pointerActive := m.move.lifted != nil && m.move.lifted.fromMouse view.OnMouse = boardMouseHandler(hits, pointerActive) } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index e250969..686b378 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -17,7 +17,9 @@ import ( "github.com/RandomCodeSpace/kb/internal/ai" "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/forge" "github.com/RandomCodeSpace/kb/internal/store" + "github.com/RandomCodeSpace/kb/internal/tui/issueimport" ) type stubBoardReader struct { @@ -103,6 +105,37 @@ func updateTestModel(t *testing.T, model *Model, message tea.Msg) tea.Cmd { return command } +type rootImportStore struct{ added int } + +func (s *rootImportStore) AddTask(string, board.Task) (board.Task, error) { + s.added++ + return board.Task{ID: "created"}, nil +} + +type rootImportBackend struct{} + +func (rootImportBackend) Sources(string) ([]store.ForgeSource, error) { + return []store.ForgeSource{{Name: "primary", Kind: "github"}}, nil +} +func (rootImportBackend) Preview(context.Context, string, forge.PreviewRequest) (forge.Preview, error) { + return forge.Preview{}, nil +} +func (rootImportBackend) CreateTask(string, string, board.Task, forge.LinkInput) (board.Task, error) { + return board.Task{ID: "created"}, nil +} + +type rootDriftBackend struct{} + +func (rootDriftBackend) Provenance(string, string) ([]store.ImportLink, error) { + return []store.ImportLink{{Source: "primary", ExternalKey: "qualified", Link: "github#1", URL: "https://example.test/1", Title: "issue"}}, nil +} +func (rootDriftBackend) CheckDrift(context.Context, string, string, string) (forge.Drift, error) { + return forge.Drift{State: "drifted", Revision: strings.Repeat("a", 64)}, nil +} +func (rootDriftBackend) AcceptDrift(context.Context, string, string, string, string) (string, error) { + return "now", nil +} + func boardLoadFromBatch(t *testing.T, command tea.Cmd) tea.Cmd { t.Helper() if command == nil { @@ -115,6 +148,133 @@ func boardLoadFromBatch(t *testing.T, command tea.Cmd) tea.Cmd { return batch[0] } +func TestIssueImportOwnsRootInputAndCancelsLiftOnOpen(t *testing.T) { + task := board.Task{ID: "one", Title: "One", Status: board.StatusTodo, Prio: 3} + m := newModel(stubBoardReader{board: board.Board{Title: "Board", Tasks: []board.Task{task}}}, nil, "alice", context.Background()) + m.board = board.Board{Title: "Board", Tasks: []board.Task{task}} + m.loading = false + importStore := &rootImportStore{} + m.issueImport = issueimport.New(importStore, rootImportBackend{}, "alice", context.Background()) + m.move.begin(m.board, task, boardStatuses[:], false) + command := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'i'}) + if command == nil || m.move.lifted != nil || !m.issueImport.IsOpen() { + t.Fatalf("import open = cmd %v lifted %v open %t", command, m.move.lifted != nil, m.issueImport.IsOpen()) + } + updateTestModel(t, &m, command()) + before := m.boardView + for _, key := range []tea.KeyPressMsg{ + {Code: 't', Text: "t"}, {Code: 'x', Text: "x"}, {Code: 'r', Text: "r"}, + {Code: 'D', Text: "D"}, {Code: tea.KeyDelete}, {Code: tea.KeyBackspace}, + } { + updateTestModel(t, &m, key) + } + for _, message := range []tea.Msg{ + boardCardClickedMsg{taskID: task.ID}, boardColumnClickedMsg{status: board.StatusDoing}, + boardPointerDownMsg{taskID: task.ID}, boardPointerMoveMsg{status: board.StatusDoing}, boardPointerUpMsg{}, + } { + updateTestModel(t, &m, message) + } + if m.boardView != before || m.detail.IsOpen() || m.action.open() || !m.issueImport.IsOpen() { + t.Fatal("active import leaked board input") + } + view := m.View() + if view.OnMouse != nil || !strings.Contains(ansi.Strip(view.Content), "Forge issue import") { + t.Fatal("active import did not own rendering/mouse") + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.issueImport.IsOpen() { + t.Fatal("escape did not close import") + } +} + +func TestIssueImportCannotOpenDuringMoveWrite(t *testing.T) { + for _, busy := range []func(*Model){ + func(m *Model) { m.move.saving = true }, + func(m *Model) { m.action.busy = true }, + } { + m := newModel(stubBoardReader{}, nil, "alice", context.Background()) + m.issueImport = issueimport.New(&rootImportStore{}, rootImportBackend{}, "alice", context.Background()) + busy(&m) + if command := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'i'}); command != nil || m.issueImport.IsOpen() { + t.Fatal("import opened during active write") + } + } +} + +func TestIssueImportPreservesGlobalInterrupt(t *testing.T) { + m := newModel(stubBoardReader{}, nil, "alice", context.Background()) + m.issueImport = issueimport.New(&rootImportStore{}, rootImportBackend{}, "alice", context.Background()) + if command := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'i'}); command == nil || !m.issueImport.IsOpen() { + t.Fatal("import did not open") + } + quit := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if quit == nil || !m.stopped || m.issueImport.IsOpen() { + t.Fatalf("global interrupt = command:%v stopped:%t open:%t", quit, m.stopped, m.issueImport.IsOpen()) + } +} + +func TestDriftReviewBlocksTaskActionsAndBoardMouse(t *testing.T) { + task := board.Task{ID: "one", Title: "One", Status: board.StatusTodo, Prio: 3, Tags: []string{"link::github#1"}} + m := newModel(stubBoardReader{board: board.Board{Title: "Board", Tasks: []board.Task{task}}}, nil, "alice", context.Background()) + m.board = board.Board{Title: "Board", Tasks: []board.Task{task}} + m.loading = false + m.detail.SetDriftBackend(rootDriftBackend{}, context.Background()) + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})) + provenance := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'v', Text: "v"}) + if provenance == nil { + t.Fatal("drift provenance command is nil") + } + updateTestModel(t, &m, provenance()) + check := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if check == nil { + t.Fatal("drift check command is nil") + } + updateTestModel(t, &m, check()) + if !m.detail.OwnsInput() { + t.Fatal("drift review does not own detail input") + } + before := m.boardView + for _, key := range []tea.KeyPressMsg{ + {Code: 't', Text: "t"}, {Code: 'x', Text: "x"}, {Code: 'r', Text: "r"}, + {Code: 'D', Text: "D"}, {Code: tea.KeyDelete}, {Code: tea.KeyBackspace}, + } { + updateTestModel(t, &m, key) + } + for _, message := range []tea.Msg{ + boardCardClickedMsg{taskID: task.ID}, boardColumnClickedMsg{status: board.StatusDoing}, + boardPointerDownMsg{taskID: task.ID}, boardPointerMoveMsg{status: board.StatusDoing}, boardPointerUpMsg{}, + } { + updateTestModel(t, &m, message) + } + if m.action.open() || m.boardView != before || !m.detail.IsOpen() || !m.detail.OwnsInput() || m.View().OnMouse != nil { + t.Fatalf("active drift review leaked input: action=%#v boardChanged=%t detailOpen=%t owns=%t mouse=%t", + m.action, m.boardView != before, m.detail.IsOpen(), m.detail.OwnsInput(), m.View().OnMouse != nil) + } +} + +func TestDriftReviewPreservesGlobalInterrupt(t *testing.T) { + for _, stage := range []string{"selection", "busy", "review"} { + t.Run(stage, func(t *testing.T) { + task := board.Task{ID: "one", Title: "One", Status: board.StatusTodo, Tags: []string{"link::github#1"}} + m := newModel(stubBoardReader{board: board.Board{Title: "Board", Tasks: []board.Task{task}}}, nil, "alice", context.Background()) + m.board, m.loading = board.Board{Title: "Board", Tasks: []board.Task{task}}, false + m.detail.SetDriftBackend(rootDriftBackend{}, context.Background()) + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})) + provenance := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'v', Text: "v"}) + if stage != "busy" { + updateTestModel(t, &m, provenance()) + } + if stage == "review" { + updateTestModel(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})()) + } + quit := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if quit == nil || !m.stopped { + t.Fatalf("ctrl+c swallowed at %s: command=%v stopped=%t", stage, quit, m.stopped) + } + }) + } +} + func completeBoardLoad(t *testing.T, model *Model, command tea.Cmd) tea.Cmd { t.Helper() if command == nil { @@ -984,6 +1144,10 @@ func TestAutoShipInputOwnershipMatrix(t *testing.T) { m.configureAI(ai.NewRunner(st, "", nil, nil), context.Background()) _ = m.adr.Open() }}, + {name: "issue import", own: func(m *Model) { + m.issueImport = issueimport.New(&rootImportStore{}, rootImportBackend{}, "u", context.Background()) + _ = m.issueImport.Open() + }}, {name: "detail", own: func(m *Model) { _ = m.detail.Open(task) }}, {name: "filter", own: func(m *Model) { _ = m.filter.focusText() }}, {name: "move preview", own: func(m *Model) { diff --git a/internal/tui/settings.go b/internal/tui/settings.go index 99fcdc2..37d10f7 100644 --- a/internal/tui/settings.go +++ b/internal/tui/settings.go @@ -12,7 +12,7 @@ import ( tea "charm.land/bubbletea/v2" kbai "github.com/RandomCodeSpace/kb/internal/ai" - "github.com/RandomCodeSpace/kb/internal/server" + "github.com/RandomCodeSpace/kb/internal/forge" "github.com/RandomCodeSpace/kb/internal/store" ) @@ -31,7 +31,7 @@ type aiConnectionProber interface { } type forgeConnectionProber interface { - Probe(context.Context, string, server.ForgeProbeConfig) error + Probe(context.Context, string, forge.ForgeProbeConfig) error } type settingsLoadedMsg struct { @@ -101,7 +101,7 @@ func newSettingsModel(st *store.Store, user string, ctx context.Context) *settin return newSettingsModelWithBackends( st, kbai.NewRunner(st, "", nil, nil), - server.NewForgeProber(st), + forge.NewForgeProber(st), user, ctx, ) @@ -524,7 +524,7 @@ func (m *settingsModel) startForgeTest(row *integrationSettingsRow) tea.Cmd { m.status = "testing " + row.name.Value() + "..." m.statusIsError = false id := row.id - config := server.ForgeProbeConfig{ + config := forge.ForgeProbeConfig{ Name: row.name.Value(), Kind: row.kind, BaseURL: row.baseURL.Value(), Project: row.project.Value(), Token: row.token.Value(), Saved: row.persisted, } diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go index 1b8853d..a68977f 100644 --- a/internal/tui/settings_test.go +++ b/internal/tui/settings_test.go @@ -12,7 +12,7 @@ import ( "github.com/charmbracelet/x/exp/golden" kbai "github.com/RandomCodeSpace/kb/internal/ai" - "github.com/RandomCodeSpace/kb/internal/server" + "github.com/RandomCodeSpace/kb/internal/forge" "github.com/RandomCodeSpace/kb/internal/store" ) @@ -38,7 +38,7 @@ func (p *recordingAIProber) Probe(ctx context.Context, user string, config kbai. type recordingForgeProber struct { user string - config server.ForgeProbeConfig + config forge.ForgeProbeConfig hadDeadline bool err error } @@ -73,7 +73,7 @@ func (s *faultSettingsStore) DeleteForgeSource(string, string) error { return s.deleteForgeErr } -func (p *recordingForgeProber) Probe(ctx context.Context, user string, config server.ForgeProbeConfig) error { +func (p *recordingForgeProber) Probe(ctx context.Context, user string, config forge.ForgeProbeConfig) error { p.user, p.config = user, config _, p.hadDeadline = ctx.Deadline() return p.err @@ -226,7 +226,7 @@ func TestForgeSettingsTestSaveLockAndArmedRemoval(t *testing.T) { row.project.SetValue("group/project") row.token.SetValue(settingsUnsavedSecret) runSettingsCommand(t, model, model.startForgeTest(row)) - if forgeProbe.user != "alice" || forgeProbe.config != (server.ForgeProbeConfig{ + if forgeProbe.user != "alice" || forgeProbe.config != (forge.ForgeProbeConfig{ Name: "primary", Kind: "gitlab", BaseURL: "https://unsaved.example", Project: "group/project", Token: settingsUnsavedSecret, Saved: true, }) || !forgeProbe.hadDeadline { @@ -308,7 +308,7 @@ func TestForgeDraftTestsUnsavedValuesWithoutStoreMutation(t *testing.T) { t.Fatal("draft test action is not focusable") } runSettingsCommand(t, model, model.startForgeTest(row)) - want := server.ForgeProbeConfig{ + want := forge.ForgeProbeConfig{ Name: "unsaved", Kind: "github", BaseURL: "https://candidate.example", Project: "owner/project", Token: settingsUnsavedSecret, } diff --git a/internal/tui/ship_actions.go b/internal/tui/ship_actions.go index 5c085f0..63b16e5 100644 --- a/internal/tui/ship_actions.go +++ b/internal/tui/ship_actions.go @@ -524,6 +524,7 @@ func (m Model) autoShipInputOwned() bool { detailOwns = false } return m.settings != nil || m.editor.IsOpen() || m.adr.IsOpen() || + m.issueImport.IsOpen() || m.filter.focus != filterUnfocused || m.move.lifted != nil || m.move.saving || detailOwns }