1b58a64fd8
* feat: add automation system with triggers, backend-driven metadata, and PR association Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(automations): execution_mode + flat /settings/automations Layered on top of #406's automation subsystem: - Add execution_mode to Automation (task | run, default task). Run-mode firings create an ephemeral kanban-hidden task tagged with origin=automation_run. The session pipeline still runs against it, but users surface them through the AutomationRun history instead of the kanban. Pragmatic shim while AutomationRun-as-true-session-owner is deferred to a follow-up. - Thread IsEphemeral + Origin through ReviewTaskRequest + reviewTaskCreatorAdapter so the orchestrator can create the hidden task in one call. - Run-mode always auto-starts (the user never sees the task to drag). - Flat /settings/automations route replaces the per-workspace sidebar entry. Picks the only workspace, shows a picker for many, or shows a CTA for zero workspaces. - Editor exposes execution mode + table shows it as a badge column. Spec at docs/specs/office/automations-settings.md documents the design, deferred items, and scenarios. * feat(automations): per-automation repository picker Adds a repository_id field on Automation so users can pin scheduled and webhook automations to a specific workspace repository instead of always falling through to "first repo". github_pr triggers ignore the field and keep using the PR's own repo — the editor disables the picker with a hint when a PR trigger is selected. Backend: - repository_id column folded into the canonical CREATE TABLE (no migration needed — PR #406 introduces the table). - RepositoryID threaded through model, DTOs, store INSERT/UPDATE, and service.CreateAutomation. - resolveAutomationRepository prefers the explicit ID, falls back to workspace-first on empty. Frontend: - repository_id on the Automation TS type + Create/Update payloads. - Repository SelectField in ConfigSection backed by useRepositories; "Auto" sentinel represents the empty/fallback choice. - Disabled with help-text for github_pr triggers. * feat(automations): repo picker shows discovered repos too Matches the task-creation dialog's UX: the editor's repository picker now lists registered workspace repos AND filesystem-discovered repos under the workspace's roots. Picking a discovered repo registers it with the workspace at save time (via createRepositoryAction), captures the new id, and writes it onto the automation. After the first save the picker selection is promoted from "discovered" to "registered" so re-saving doesn't re-register. Form state holds a RepositorySelection union (auto | registered | discovered) rather than a plain id string so the picker can carry the metadata needed to register. github_pr triggers still disable the picker — PR events always override any configured repo with the PR's own. * fix(automations): add idempotent ALTERs for in-branch columns The canonical CREATE TABLE covers fresh installs, but `CREATE TABLE IF NOT EXISTS` is a no-op for DBs already initialised from an earlier commit on this branch (PR #406's original schema). Without these ALTERs, dev DBs created before execution_mode + repository_id existed on the row hit "no such column" errors. Three idempotent ALTERs: task_title_template, execution_mode, repository_id. SQLite returns a duplicate-column error when the column already exists; we swallow it. * fix(automations): use generateUUID in non-secure contexts crypto.randomUUID is only defined in secure contexts (HTTPS or localhost). PR #406's editor used it directly, which throws "crypto. randomUUID is not a function" when the app is reached via a plain hostname like nova:58204. Switch to lib/utils.generateUUID, which falls back to a Math.random-based implementation when crypto isn't available. * fix(automations): polish run-mode lifecycle, validation, and editor UX Run-mode automations were partly broken end-to-end: - workflow_id was sent for ephemeral tasks (rejected by task service) and was required by the editor + automation service, so "Create Automation" was always disabled and silently failed - a permission_request hanging the agent left the run pinned at task_created forever; nothing reaped the ephemeral worktree - trigger config inputs (cron, branches, authors) did not re-sync with the loaded automation on page reload - runs row never linked back to the underlying task for task-mode automations, and the table header highlighted on hover Backend - skip workflow fields in CreateReviewTask / StartTask for run-mode - relax automation.Service.CreateAutomation: workflow only required for task-mode - auto-reject permission prompts on ephemeral automation tasks and mark the AutomationRun failed with the requested action - on agent completion / failure for ephemeral automation tasks, flip the run to succeeded/failed and remove the worktree + branch immediately (new RunStatusSucceeded + MarkRunSucceededByTaskID) - wire worktree.Manager into the orchestrator service Frontend - show Execution Mode first; hide Workflow / Workflow Step in run-mode - canSave no longer requires workflow in run-mode - surface save errors via toast instead of silent failure - sync trigger-config local input state with loaded prop value - runs table: clickable row → /tasks/<id> for task-mode runs, non-clickable in run-mode; suppress header hover * fix(automations): address PR review feedback Backend - automation/scheduler.go: parseCronFields returns an error for unsupported expressions instead of silently defaulting to hourly - automation/webhook.go: constant-time secret compare, header-only (no more ?secret= query param), reject with 409 when no enabled webhook trigger exists - automation/service.go: enforce max_concurrent_runs in FireTrigger via maybeSkipForConcurrencyCap (records a skipped run when capped) - cmd/kandev/e2e_reset.go: surface automation delete errors as 500 instead of swallowing them - orchestrator/event_handlers_git.go: auto-reject permission with cancelled=true so the message status is "rejected" and the session isn't flipped back to running - orchestrator/event_handlers_agent.go: finalizeAutomationRunIfEphemeral now runs at the top of handleAgentFailed, before the resume/recoverable early returns, so session-backed run-mode failures get cleaned up Frontend - state/slices/automations/automations-slice.ts: declare _get/_api so the call site's arity matches and CodeQL stops flagging ignored trailing arguments - automations/schedule-selector.tsx: clearing the input now propagates ""; cron is trimmed before validation+save - automations/triggers-section.tsx: switching to webhook mode deletes the existing scheduled/condition triggers so they don't keep firing hidden in the background - automations/trigger-configs/github-ci-config.tsx: useEffect re-sync for checkNames, matching the other trigger configs - hooks/domains/settings/use-automations.ts: per-workspace fetch via loadedWorkspaceRef + render-safe loadedWorkspaceId state; inFlightWorkspaceRef discards stale responses after workspace switch --------- Co-authored-by: Carlos Florêncio <carlosmflorencio@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Kandev Agent <agent@kandev.dev>
254 lines
6.3 KiB
Go
254 lines
6.3 KiB
Go
package automation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/github"
|
|
)
|
|
|
|
const defaultGitHubPollInterval = 60 * time.Second
|
|
|
|
// GitHubEvaluator polls GitHub for events matching automation triggers.
|
|
// It handles github_pr, github_push, and github_ci trigger types.
|
|
type GitHubEvaluator struct {
|
|
svc *Service
|
|
ghClient github.Client
|
|
logger *logger.Logger
|
|
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
started bool
|
|
}
|
|
|
|
// NewGitHubEvaluator creates a new GitHub trigger evaluator.
|
|
func NewGitHubEvaluator(svc *Service, ghClient github.Client, log *logger.Logger) *GitHubEvaluator {
|
|
return &GitHubEvaluator{
|
|
svc: svc,
|
|
ghClient: ghClient,
|
|
logger: log,
|
|
}
|
|
}
|
|
|
|
// Start begins the polling loop.
|
|
func (e *GitHubEvaluator) Start(ctx context.Context) {
|
|
if e.started || e.ghClient == nil {
|
|
return
|
|
}
|
|
e.started = true
|
|
ctx, e.cancel = context.WithCancel(ctx)
|
|
|
|
e.wg.Add(1)
|
|
go e.loop(ctx)
|
|
|
|
e.logger.Info("automation GitHub evaluator started")
|
|
}
|
|
|
|
// Stop cancels the polling loop and waits for it to finish.
|
|
func (e *GitHubEvaluator) Stop() {
|
|
if !e.started {
|
|
return
|
|
}
|
|
if e.cancel != nil {
|
|
e.cancel()
|
|
}
|
|
e.wg.Wait()
|
|
e.started = false
|
|
e.logger.Info("automation GitHub evaluator stopped")
|
|
}
|
|
|
|
func (e *GitHubEvaluator) loop(ctx context.Context) {
|
|
defer e.wg.Done()
|
|
|
|
e.evaluate(ctx)
|
|
|
|
ticker := time.NewTicker(defaultGitHubPollInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
e.evaluate(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *GitHubEvaluator) evaluate(ctx context.Context) {
|
|
e.evaluatePRTriggers(ctx)
|
|
e.evaluatePushTriggers(ctx)
|
|
e.evaluateCITriggers(ctx)
|
|
}
|
|
|
|
func (e *GitHubEvaluator) evaluatePRTriggers(ctx context.Context) {
|
|
triggers, err := e.svc.Store().ListEnabledTriggersByType(ctx, TriggerTypeGitHubPR)
|
|
if err != nil {
|
|
e.logger.Error("failed to list github_pr triggers", zap.Error(err))
|
|
return
|
|
}
|
|
for i := range triggers {
|
|
t := &triggers[i]
|
|
e.checkPRTrigger(ctx, t)
|
|
}
|
|
}
|
|
|
|
func (e *GitHubEvaluator) checkPRTrigger(ctx context.Context, t *AutomationTrigger) {
|
|
var cfg GitHubPRTriggerConfig
|
|
if err := json.Unmarshal(t.Config, &cfg); err != nil {
|
|
e.logger.Debug("invalid github_pr trigger config",
|
|
zap.String("trigger_id", t.ID), zap.Error(err))
|
|
return
|
|
}
|
|
|
|
repos := cfg.Repos
|
|
if len(repos) == 0 {
|
|
return // Cannot poll without specific repos
|
|
}
|
|
|
|
for _, repo := range repos {
|
|
// Use ListReviewRequestedPRs with a custom query scoped to this repo.
|
|
query := fmt.Sprintf("is:pr is:open repo:%s/%s", repo.Owner, repo.Name)
|
|
prs, err := e.ghClient.ListReviewRequestedPRs(ctx, "", "", query)
|
|
if err != nil {
|
|
e.logger.Debug("failed to list PRs for automation trigger",
|
|
zap.String("repo", repo.Owner+"/"+repo.Name),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
for _, pr := range prs {
|
|
if pr == nil {
|
|
continue
|
|
}
|
|
if cfg.ExcludeDraft && pr.Draft {
|
|
continue
|
|
}
|
|
if !matchesBranches(pr.BaseBranch, cfg.Branches) {
|
|
continue
|
|
}
|
|
if !matchesAuthors(pr.AuthorLogin, cfg.Authors) {
|
|
continue
|
|
}
|
|
|
|
dedupKey := fmt.Sprintf("pr:%s/%s#%d", repo.Owner, repo.Name, pr.Number)
|
|
e.firePRTrigger(ctx, t, pr, dedupKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *GitHubEvaluator) firePRTrigger(ctx context.Context, t *AutomationTrigger, pr *github.PR, dedupKey string) {
|
|
data, _ := json.Marshal(map[string]interface{}{
|
|
"number": pr.Number,
|
|
"title": pr.Title,
|
|
"html_url": pr.HTMLURL,
|
|
"author_login": pr.AuthorLogin,
|
|
"repo": fmt.Sprintf("%s/%s", pr.RepoOwner, pr.RepoName),
|
|
"head_branch": pr.HeadBranch,
|
|
"base_branch": pr.BaseBranch,
|
|
"body": pr.Body,
|
|
"draft": pr.Draft,
|
|
"state": pr.State,
|
|
})
|
|
|
|
if err := e.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeGitHubPR, data, dedupKey); err != nil {
|
|
e.logger.Error("failed to fire PR trigger",
|
|
zap.String("trigger_id", t.ID), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (e *GitHubEvaluator) evaluatePushTriggers(ctx context.Context) {
|
|
triggers, err := e.svc.Store().ListEnabledTriggersByType(ctx, TriggerTypeGitHubPush)
|
|
if err != nil {
|
|
e.logger.Error("failed to list github_push triggers", zap.Error(err))
|
|
return
|
|
}
|
|
for i := range triggers {
|
|
t := &triggers[i]
|
|
e.checkPushTrigger(ctx, t)
|
|
}
|
|
}
|
|
|
|
func (e *GitHubEvaluator) checkPushTrigger(ctx context.Context, t *AutomationTrigger) {
|
|
var cfg GitHubPushTriggerConfig
|
|
if err := json.Unmarshal(t.Config, &cfg); err != nil {
|
|
return
|
|
}
|
|
// Push trigger evaluation requires comparing commit SHAs.
|
|
// For now, mark as evaluated; full implementation needs commit tracking.
|
|
now := time.Now().UTC()
|
|
_ = e.svc.Store().UpdateTriggerEvaluatedAt(ctx, t.ID, now)
|
|
}
|
|
|
|
func (e *GitHubEvaluator) evaluateCITriggers(ctx context.Context) {
|
|
triggers, err := e.svc.Store().ListEnabledTriggersByType(ctx, TriggerTypeGitHubCI)
|
|
if err != nil {
|
|
e.logger.Error("failed to list github_ci triggers", zap.Error(err))
|
|
return
|
|
}
|
|
for i := range triggers {
|
|
t := &triggers[i]
|
|
e.checkCITrigger(ctx, t)
|
|
}
|
|
}
|
|
|
|
func (e *GitHubEvaluator) checkCITrigger(ctx context.Context, t *AutomationTrigger) {
|
|
var cfg GitHubCITriggerConfig
|
|
if err := json.Unmarshal(t.Config, &cfg); err != nil {
|
|
return
|
|
}
|
|
// CI trigger evaluation requires tracking check run completion.
|
|
// For now, mark as evaluated; full implementation needs check run tracking.
|
|
now := time.Now().UTC()
|
|
_ = e.svc.Store().UpdateTriggerEvaluatedAt(ctx, t.ID, now)
|
|
}
|
|
|
|
// matchesBranches checks if a branch matches any of the filter patterns.
|
|
// Empty filter means match all.
|
|
func matchesBranches(branch string, filters []string) bool {
|
|
if len(filters) == 0 {
|
|
return true
|
|
}
|
|
for _, f := range filters {
|
|
if matchGlob(f, branch) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchesAuthors checks if an author matches the filter list.
|
|
// Empty filter means match all.
|
|
func matchesAuthors(author string, filters []string) bool {
|
|
if len(filters) == 0 {
|
|
return true
|
|
}
|
|
for _, f := range filters {
|
|
if f == author {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchGlob performs simple glob matching (supports * wildcard).
|
|
func matchGlob(pattern, s string) bool {
|
|
if pattern == "*" {
|
|
return true
|
|
}
|
|
if pattern == s {
|
|
return true
|
|
}
|
|
// Handle trailing wildcard: "release/*" matches "release/v1"
|
|
if len(pattern) > 1 && pattern[len(pattern)-1] == '*' {
|
|
prefix := pattern[:len(pattern)-1]
|
|
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
|
}
|
|
return false
|
|
}
|