package service
import (
"context"
"fmt"
"strconv"
"strings"
"gitcode-mcp/internal/buildinfo"
"gitcode-mcp/internal/cache"
"gitcode-mcp/internal/feedback"
"gitcode-mcp/internal/gitcode"
)
type SubmitFeedbackRequest struct {
Draft feedback.Draft `json:"draft"`
Mode WriteMode `json:"write_mode"`
IdempotencyKey string `json:"idempotency_key"`
}
type FeedbackSink interface {
Name() string
Submit(context.Context, feedback.PreparedReport, string) (feedback.SubmissionResult, error)
}
type gitCodeIssueFeedbackSink struct {
service *Service
config feedback.Config
}
func (s gitCodeIssueFeedbackSink) Name() string { return feedback.SinkGitCodeIssues }
func (s gitCodeIssueFeedbackSink) Submit(ctx context.Context, prepared feedback.PreparedReport, idempotencyKey string) (feedback.SubmissionResult, error) {
write, err := s.service.CreateIssue(ctx, WriteCommandRequest{
RepoID: s.config.RepoID,
Repo: s.config.RepoID,
Mode: WriteModeLive,
Title: prepared.Title,
Body: prepared.Body,
Labels: append([]string(nil), s.config.Labels...),
IdempotencyKey: idempotencyKey,
idempotencyFingerprint: prepared.Fingerprint,
})
if err != nil {
return feedback.SubmissionResult{}, err
}
number := write.RemoteNumber
if number == 0 {
number = feedback.ParseIssueNumber(write.ID)
}
url := strings.TrimSpace(write.BrowserURL)
if url == "" && number > 0 {
url = fmt.Sprintf("https://gitcode.com/%s/issues/%d", s.config.RepoID, number)
}
return feedback.SubmissionResult{
Status: "submitted",
Sink: s.Name(),
RepoID: s.config.RepoID,
TicketID: write.ID,
TicketNumber: number,
TicketURL: url,
Fingerprint: prepared.Fingerprint,
DedupeDecision: prepared.DedupeDecision,
IdempotencyKey: write.IdempotencyKey,
Replayed: write.Replayed,
Evidence: firstNonEmptyString(write.Evidence, "adapter-confirmed issue write with sanitized readback and audit evidence"),
GeneratedAt: write.GeneratedAt,
Readiness: prepared.Readiness,
}, nil
}
func normalizeFeedbackConfig(cfg feedback.Config) feedback.Config {
if normalized, err := feedback.NormalizeConfig(cfg); err == nil {
return normalized
}
return feedback.DefaultConfig()
}
func (s *Service) ConfigureFeedback(cfg feedback.Config) {
s.feedbackConfig = normalizeFeedbackConfig(cfg)
}
func (s *Service) ConfigureFeedbackReadiness(credentialPresent, providerAvailable bool) {
s.writeCredentialPresent = credentialPresent
s.feedbackProviderAvailable = providerAvailable
}
func (s *Service) PrepareFeedback(ctx context.Context, draft feedback.Draft) (feedback.PreparedReport, error) {
if err := ctx.Err(); err != nil {
return feedback.PreparedReport{}, err
}
context := s.feedbackRuntimeContext(ctx)
existing, err := s.feedbackExistingIssues(ctx)
if err != nil {
return feedback.PreparedReport{}, err
}
prepared, err := feedback.Prepare(draft, context, s.feedbackConfig, existing)
if err != nil {
return feedback.PreparedReport{}, err
}
readiness, err := s.FeedbackReadiness(ctx)
if err != nil {
return feedback.PreparedReport{}, err
}
prepared.Readiness = readiness
return prepared, nil
}
func (s *Service) FeedbackReadiness(ctx context.Context) (feedback.Readiness, error) {
if err := ctx.Err(); err != nil {
return feedback.Readiness{}, err
}
repositoryBound := false
repoID := strings.TrimSpace(s.feedbackConfig.RepoID)
if s.store != nil && repoID != "" {
if _, err := s.store.GetRepository(ctx, repoID); err == nil {
repositoryBound = true
} else if !isCacheNotFound(err) {
return feedback.Readiness{}, normalizeError(err, "feedback readiness", repoID)
}
}
return feedback.EvaluateReadiness(feedback.ReadinessInput{
Config: s.feedbackConfig,
RepositoryBound: repositoryBound,
CredentialPresent: s.writeCredentialPresent,
ProviderAvailable: s.feedbackProviderAvailable || s.ProviderMode() == gitcode.ProviderModeLive || s.ProviderMode() == gitcode.ProviderMode("custom"),
}), nil
}
func (s *Service) SubmitFeedback(ctx context.Context, req SubmitFeedbackRequest) (feedback.SubmissionResult, error) {
if req.Mode != WriteModeLive {
return feedback.SubmissionResult{}, ErrInvalidQuery{Field: "write_mode", Message: "submit_feedback requires write_mode=live"}
}
key := strings.TrimSpace(req.IdempotencyKey)
if key == "" {
return feedback.SubmissionResult{}, ErrInvalidQuery{Field: "idempotency_key", Message: "is required for feedback submission"}
}
prepared, err := s.PrepareFeedback(ctx, req.Draft)
if err != nil {
return feedback.SubmissionResult{}, err
}
base := feedback.SubmissionResult{Status: prepared.Status, Sink: prepared.Sink, RepoID: prepared.RepoID, Fingerprint: prepared.Fingerprint, DedupeDecision: prepared.DedupeDecision, Candidates: prepared.Candidates, IdempotencyKey: key, Remediation: prepared.Remediation, GeneratedAt: s.now().UTC(), Readiness: prepared.Readiness}
if prepared.Status == "duplicate" && len(prepared.Candidates) > 0 {
candidate := prepared.Candidates[0]
base.Status = "duplicate"
base.TicketID = candidate.ID
base.TicketNumber = candidate.Number
base.TicketURL = candidate.URL
if prepared.DedupeDecision == "exact_match" {
base.Evidence = "existing open feedback issue matched the deterministic fingerprint; no write performed"
} else {
base.Evidence = "duplicate_policy=return_existing selected the strongest cached likely match; no write performed"
}
return base, nil
}
if prepared.Status == "duplicate_candidates" {
base.Evidence = "likely duplicates found in the configured feedback cache; no write performed"
return base, nil
}
if !prepared.Readiness.SubmitAvailable {
base.Status = "submission_unavailable"
base.Remediation = prepared.Readiness.Remediation
return base, nil
}
sink, err := s.feedbackSink()
if err != nil {
return feedback.SubmissionResult{}, err
}
return sink.Submit(ctx, prepared, key)
}
func (s *Service) feedbackSink() (FeedbackSink, error) {
if !s.feedbackConfig.Enabled || strings.TrimSpace(s.feedbackConfig.RepoID) == "" {
return nil, ErrInvalidQuery{Field: "feedback", Message: "feedback sink is not configured"}
}
switch s.feedbackConfig.Sink {
case feedback.SinkGitCodeIssues:
return gitCodeIssueFeedbackSink{service: s, config: s.feedbackConfig}, nil
default:
return nil, ErrInvalidQuery{Field: "feedback.sink", Message: "unsupported feedback sink"}
}
}
func (s *Service) feedbackRuntimeContext(ctx context.Context) feedback.RuntimeContext {
build := buildinfo.Current()
result := feedback.DefaultRuntimeContext(s.now().UTC())
result.Version = build.Version
result.Commit = build.Commit
result.ProviderMode = string(s.ProviderMode())
result.ExpectedSchema = cache.CurrentSchemaVersion()
if s.store == nil {
result.SinkBindingState = "cache_unavailable"
return result
}
if version, err := s.store.SchemaVersion(ctx); err == nil {
result.CacheSchemaVersion = version
result.SchemaCompatible = version == result.ExpectedSchema
}
if strings.TrimSpace(s.feedbackConfig.RepoID) == "" {
result.SinkBindingState = "not_configured"
} else if _, err := s.store.GetRepository(ctx, s.feedbackConfig.RepoID); err == nil {
result.SinkBindingState = "configured"
} else if isCacheNotFound(err) {
result.SinkBindingState = "not_bound"
} else {
result.SinkBindingState = "unavailable"
}
return result
}
func (s *Service) feedbackExistingIssues(ctx context.Context) ([]feedback.ExistingIssue, error) {
if s.store == nil || strings.TrimSpace(s.feedbackConfig.RepoID) == "" {
return nil, nil
}
sources, err := s.store.ListSources(ctx, cache.SourceFilter{RepoID: s.feedbackConfig.RepoID, Kind: "issue", Status: "open"})
if err != nil {
if isCacheNotFound(err) {
return nil, nil
}
return nil, normalizeError(err, "feedback candidates", s.feedbackConfig.RepoID)
}
out := make([]feedback.ExistingIssue, 0, len(sources))
for _, source := range sources {
number := issueNumberFromFeedbackSource(source)
url := ""
if number > 0 {
url = fmt.Sprintf("https://gitcode.com/%s/issues/%d", s.feedbackConfig.RepoID, number)
}
out = append(out, feedback.ExistingIssue{ID: source.ID, Number: number, Title: source.Title, Body: source.Body, URL: url, Status: source.Status})
}
return out, nil
}
func issueNumberFromFeedbackSource(source cache.Source) int {
for _, alias := range source.Aliases {
if alias.Remote.Type == "issue" || alias.AliasType == "issue" {
value := firstNonEmptyString(alias.Remote.ID, alias.Alias)
if number, err := strconv.Atoi(value); err == nil && number > 0 {
return number
}
}
}
return feedback.ParseIssueNumber(source.ID)
}