172 lines
6.1 KiB
Go
172 lines
6.1 KiB
Go
package orchestrator
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/sentry"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
// maxSentryTitleRunes bounds the issue-title portion of a generated task
|
|
// title. Sentry titles are derived from error messages / culprits and can be
|
|
// arbitrarily long (unlike Linear/Jira issue titles, which are short upstream),
|
|
// so the producer truncates here rather than deferring to the UI.
|
|
const maxSentryTitleRunes = 80
|
|
|
|
// sentryWatchMetadataKey is the task-metadata key under which a Sentry
|
|
// watcher-created task records its originating watch id. It is the single
|
|
// source of truth shared by BuildTaskRequest (which writes it) and
|
|
// WatchMetadataKey (which the throttle gate uses to count open tasks) — the
|
|
// two MUST stay in lockstep or the per-watch cap silently stops counting.
|
|
const sentryWatchMetadataKey = "sentry_issue_watch_id"
|
|
|
|
func truncateSentryTitle(s string) string {
|
|
if utf8.RuneCountInString(s) <= maxSentryTitleRunes {
|
|
return s
|
|
}
|
|
return string([]rune(s)[:maxSentryTitleRunes-1]) + "…"
|
|
}
|
|
|
|
// SentryWatcherSource adapts the Sentry integration onto the WatcherSource
|
|
// pipeline. Mirrors LinearWatcherSource so a third integration is a clean
|
|
// instance of the abstraction.
|
|
type SentryWatcherSource struct {
|
|
service SentryService
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// NewSentryWatcherSource constructs a source bound to the orchestrator's
|
|
// Sentry service handle. logger may be nil — methods that log will no-op.
|
|
func NewSentryWatcherSource(svc SentryService, log *logger.Logger) *SentryWatcherSource {
|
|
return &SentryWatcherSource{service: svc, logger: log}
|
|
}
|
|
|
|
func (s *SentryWatcherSource) Name() string { return "sentry" }
|
|
|
|
func (s *SentryWatcherSource) AgentProfileID(evt any) string {
|
|
e, ok := evt.(*sentry.NewSentryIssueEvent)
|
|
if !ok || e == nil {
|
|
return ""
|
|
}
|
|
return e.AgentProfileID
|
|
}
|
|
|
|
// SelfHeal disables the sentry_issue_watches row that produced this event.
|
|
// Nil-safe: with no SentryService wired the call is silently dropped — same
|
|
// pattern as Reserve / Release.
|
|
func (s *SentryWatcherSource) SelfHeal(ctx context.Context, evt any, cause string) error {
|
|
e, ok := evt.(*sentry.NewSentryIssueEvent)
|
|
if !ok || e == nil || s.service == nil {
|
|
return nil
|
|
}
|
|
return s.service.DisableIssueWatchWithError(ctx, e.IssueWatchID, cause)
|
|
}
|
|
|
|
func (s *SentryWatcherSource) Reserve(ctx context.Context, evt any) (bool, error) {
|
|
e, ok := evt.(*sentry.NewSentryIssueEvent)
|
|
if !ok || e == nil || e.Issue == nil {
|
|
return false, errors.New("sentry source: event payload missing or wrong type")
|
|
}
|
|
// Matches Linear: nil service is "fail open" so a boot-order corner case
|
|
// doesn't drop the event.
|
|
if s.service == nil {
|
|
return true, nil
|
|
}
|
|
return s.service.ReserveIssueWatchTask(ctx, e.IssueWatchID, e.Issue.ShortID, e.Issue.Permalink)
|
|
}
|
|
|
|
func (s *SentryWatcherSource) Release(ctx context.Context, evt any) {
|
|
e, ok := evt.(*sentry.NewSentryIssueEvent)
|
|
if !ok || e == nil || e.Issue == nil || s.service == nil {
|
|
return
|
|
}
|
|
if err := s.service.ReleaseIssueWatchTask(ctx, e.IssueWatchID, e.Issue.ShortID); err != nil && s.logger != nil {
|
|
s.logger.Warn("sentry source: release failed",
|
|
zap.String("short_id", e.Issue.ShortID), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (s *SentryWatcherSource) BuildTaskRequest(evt any) (*IssueTaskRequest, error) {
|
|
e, ok := evt.(*sentry.NewSentryIssueEvent)
|
|
if !ok || e == nil || e.Issue == nil {
|
|
return nil, errors.New("sentry source: event payload missing or wrong type")
|
|
}
|
|
req := &IssueTaskRequest{
|
|
WorkspaceID: e.WorkspaceID,
|
|
WorkflowID: e.WorkflowID,
|
|
WorkflowStepID: e.WorkflowStepID,
|
|
Title: fmt.Sprintf("[%s] %s — %s", strings.ToUpper(e.Issue.Level), e.Issue.ShortID, truncateSentryTitle(e.Issue.Title)),
|
|
Description: interpolateSentryPrompt(e.Prompt, e.Issue),
|
|
Metadata: map[string]interface{}{
|
|
sentryWatchMetadataKey: e.IssueWatchID,
|
|
"sentry_issue_short_id": e.Issue.ShortID,
|
|
"sentry_issue_url": e.Issue.Permalink,
|
|
"sentry_issue_level": e.Issue.Level,
|
|
"sentry_issue_status": e.Issue.Status,
|
|
"sentry_issue_project": e.Issue.ProjectSlug,
|
|
models.MetaKeyAgentProfileID: e.AgentProfileID,
|
|
models.MetaKeyExecutorProfileID: e.ExecutorProfileID,
|
|
},
|
|
}
|
|
// Only a bound watch carries Repositories. An unbound watch leaves the slice
|
|
// nil so the launch path falls to the historical blank-scratch behaviour.
|
|
if e.RepositoryID != "" {
|
|
req.Repositories = []IssueTaskRepository{{
|
|
RepositoryID: e.RepositoryID,
|
|
BaseBranch: e.BaseBranch,
|
|
}}
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (s *SentryWatcherSource) AttachTaskID(ctx context.Context, evt any, taskID string) error {
|
|
e, ok := evt.(*sentry.NewSentryIssueEvent)
|
|
if !ok || e == nil || e.Issue == nil || s.service == nil {
|
|
return nil
|
|
}
|
|
return s.service.AssignIssueWatchTaskID(ctx, e.IssueWatchID, e.Issue.ShortID, taskID)
|
|
}
|
|
|
|
func (s *SentryWatcherSource) AutoStartParams(evt any) AutoStartParams {
|
|
e, ok := evt.(*sentry.NewSentryIssueEvent)
|
|
if !ok || e == nil {
|
|
return AutoStartParams{}
|
|
}
|
|
return AutoStartParams{
|
|
AgentProfileID: e.AgentProfileID,
|
|
ExecutorProfileID: e.ExecutorProfileID,
|
|
WorkflowStepID: e.WorkflowStepID,
|
|
}
|
|
}
|
|
|
|
func (s *SentryWatcherSource) WatchID(evt any) string {
|
|
e, ok := evt.(*sentry.NewSentryIssueEvent)
|
|
if !ok || e == nil {
|
|
return ""
|
|
}
|
|
return e.IssueWatchID
|
|
}
|
|
|
|
// MaxInflightTasks reports the per-watch cap on open watcher-created tasks,
|
|
// carried on the issue event from the watch row. nil = uncapped, letting the
|
|
// coordinator apply its default (mirrors the Linear source).
|
|
func (s *SentryWatcherSource) MaxInflightTasks(evt any) *int {
|
|
e, ok := evt.(*sentry.NewSentryIssueEvent)
|
|
if !ok || e == nil {
|
|
return nil
|
|
}
|
|
return e.MaxInflightTasks
|
|
}
|
|
|
|
// WatchMetadataKey returns the task-metadata key this source writes in
|
|
// BuildTaskRequest. The throttle gate hands it to the task counter so the
|
|
// repository can tally open Sentry tasks without knowing Sentry exists.
|
|
func (s *SentryWatcherSource) WatchMetadataKey() string { return sentryWatchMetadataKey }
|