Skip to content

API: Add sourceActions to GitHubReporting for declarative source lifecycle management on task completionΒ #922

@kelos-bot

Description

@kelos-bot

πŸ€– Kelos Strategist Agent @gjkim42

Summary

When a Kelos task completes, the only framework-level feedback to the originating source system is a bare status comment ("Task X has succeeded. βœ…"). All other lifecycle actions β€” closing issues, adding/removing labels, assigning users β€” must be performed by the agent itself via CLI commands embedded in the task prompt.

This creates unreliable, inconsistent, and unobservable behavior. This proposal adds a sourceActions field to GitHubReporting (and its Jira/Linear equivalents) that declaratively specifies lifecycle actions to perform on the originating work item when tasks reach terminal phases.

Motivation

The "prompt-embedded lifecycle" problem

Today, virtually every TaskSpawner prompt includes instructions like:

If you successfully create a PR, close the originating issue.
Add the label "agent/completed" to the issue.
Remove the label "actor/kelos" from the issue.

This pattern has five concrete problems:

  1. Unreliable: If the agent hits activeDeadlineSeconds, OOMs, or fails mid-execution, lifecycle actions never happen. The controller transitions the Task to Failed (task_controller.go:507-512) and moves on β€” the issue remains open with stale labels.

  2. Inconsistent: Each TaskSpawner prompt handles lifecycle differently. One spawner closes issues on success; another doesn't. One adds labels; another uses different label names. There's no central configuration.

  3. Unobservable: The controller has no visibility into whether lifecycle actions were performed. There's no metric, event, or status field tracking label changes or issue state transitions.

  4. Duplicated across spawners: Every TaskSpawner prompt repeats the same lifecycle instructions. Changing the label scheme (e.g., renaming agent/completed to kelos/done) requires editing every prompt.

  5. Requires credential elevation: For the agent to manage labels and close issues, it needs write access to the repository β€” even if the core task only needs read access for analysis.

The reporting gap

The current GitHubReporting mechanism (api/v1alpha1/taskspawner_types.go:57-61) is a single boolean:

type GitHubReporting struct {
    Enabled bool `json:"enabled,omitempty"`
}

When enabled, the spawner's TaskReporter (internal/reporting/watcher.go:45-127) creates/updates a comment with three fixed templates:

  • FormatAcceptedComment: "Task X has been accepted and is being processed."
  • FormatSucceededComment: "Task X has succeeded. βœ…"
  • FormatFailedComment: "Task X has failed. ❌"

These comments contain no task outputs β€” no PR URL, no branch name, no cost metrics, no error details. And beyond posting this comment, no other actions are taken on the source work item.

Differentiation from existing proposals

Current State Analysis

Source metadata is already propagated

The spawner stamps source metadata onto every task via sourceAnnotations() (cmd/kelos-spawner/main.go:453-473):

annotations := map[string]string{
    reporting.AnnotationSourceKind:   kind,        // "issue" or "pull-request"
    reporting.AnnotationSourceNumber: strconv.Itoa(item.Number),
}

The reporting watcher (internal/reporting/watcher.go) already reads these annotations to identify the originating issue/PR when posting comments. This same metadata can drive lifecycle actions.

The spawner already has GitHub API access

The spawner process authenticates to GitHub (via workspace secret or GitHub App token) for source discovery. The same credentials can be reused for lifecycle API calls without requiring additional secrets.

Task outputs are already captured

The controller captures task outputs (internal/controller/output_parser.go) including branch names, PR URLs, and structured results. These values are available in Task.Status.Outputs and Task.Status.Results β€” but the reporting watcher currently ignores them entirely.

Proposed API Design

Extended GitHubReporting

type GitHubReporting struct {
    // Enabled posts standard status comments back to the originating
    // GitHub issue or PR. When SourceActions is set, this also controls
    // whether actions are performed.
    Enabled bool `json:"enabled,omitempty"`

    // SourceActions defines lifecycle actions to perform on the originating
    // GitHub issue or PR when the spawned task reaches a terminal phase.
    // Actions are performed by the spawner process using the workspace's
    // GitHub credentials.
    // +optional
    SourceActions *GitHubSourceActions `json:"sourceActions,omitempty"`

    // CommentTemplate overrides the default status comment body.
    // Supports Go text/template with variables: {{.TaskName}}, {{.Phase}},
    // {{.Outputs}} (map), {{.Results}} (map), {{.Duration}}.
    // +optional
    CommentTemplate *GitHubCommentTemplate `json:"commentTemplate,omitempty"`
}

// GitHubSourceActions defines per-phase lifecycle actions on GitHub items.
type GitHubSourceActions struct {
    // OnSuccess defines actions to perform when the task succeeds.
    // +optional
    OnSuccess *GitHubActions `json:"onSuccess,omitempty"`

    // OnFailure defines actions to perform when the task fails.
    // +optional
    OnFailure *GitHubActions `json:"onFailure,omitempty"`
}

// GitHubActions defines a set of lifecycle actions on a GitHub issue or PR.
type GitHubActions struct {
    // AddLabels adds these labels to the issue/PR.
    // +optional
    AddLabels []string `json:"addLabels,omitempty"`

    // RemoveLabels removes these labels from the issue/PR. Missing labels
    // are silently ignored.
    // +optional
    RemoveLabels []string `json:"removeLabels,omitempty"`

    // Close closes the issue or PR. Only applies to open items.
    // +optional
    Close bool `json:"close,omitempty"`

    // Reopen reopens the issue or PR. Only applies to closed items.
    // +optional
    Reopen bool `json:"reopen,omitempty"`

    // Assignees adds these GitHub usernames as assignees.
    // +optional
    Assignees []string `json:"assignees,omitempty"`

    // RemoveAssignees removes these GitHub usernames from assignees.
    // +optional
    RemoveAssignees []string `json:"removeAssignees,omitempty"`
}

// GitHubCommentTemplate configures templated status comments.
type GitHubCommentTemplate struct {
    // Accepted is the template for the comment posted when the task starts.
    // +optional
    Accepted string `json:"accepted,omitempty"`

    // Succeeded is the template for the comment posted when the task succeeds.
    // +optional
    Succeeded string `json:"succeeded,omitempty"`

    // Failed is the template for the comment posted when the task fails.
    // +optional
    Failed string `json:"failed,omitempty"`
}

Example Configurations

Issue-fixing spawner with full lifecycle management

apiVersion: kelos.dev/v1alpha1
kind: TaskSpawner
metadata:
  name: issue-fixer
spec:
  when:
    githubIssues:
      labels: ["actor/kelos"]
      reporting:
        enabled: true
        sourceActions:
          onSuccess:
            addLabels: ["agent/completed"]
            removeLabels: ["actor/kelos", "needs-agent"]
            close: true
          onFailure:
            addLabels: ["agent/failed", "needs-human"]
            removeLabels: ["actor/kelos"]
            assignees: ["oncall-engineer"]
        commentTemplate:
          succeeded: |
            πŸ€– **Kelos Task Status**

            Task `{{.TaskName}}` has **succeeded**. βœ…

            {{- if index .Outputs "prURL" }}
            **PR**: {{ index .Outputs "prURL" }}
            {{- end }}
            {{- if index .Results "cost-usd" }}
            **Cost**: ${{ index .Results "cost-usd" }}
            {{- end }}
          failed: |
            πŸ€– **Kelos Task Status**

            Task `{{.TaskName}}` has **failed**. ❌
            Duration: {{.Duration}}

            Manual intervention required.
  taskTemplate:
    type: claude-code
    # ... (prompt no longer needs lifecycle instructions)

PR review spawner with label-based signaling

apiVersion: kelos.dev/v1alpha1
kind: TaskSpawner
metadata:
  name: pr-reviewer
spec:
  when:
    githubPullRequests:
      labels: ["needs-review"]
      reporting:
        enabled: true
        sourceActions:
          onSuccess:
            addLabels: ["review/completed"]
            removeLabels: ["needs-review"]
          onFailure:
            addLabels: ["review/error"]
            removeLabels: ["needs-review"]
  taskTemplate:
    type: claude-code
    # ... (agent focuses on review, not label management)

Implementation Notes

Where actions are executed

The reporting watcher (internal/reporting/watcher.go:45) already runs in the spawner process and observes task phase transitions. Source actions would be executed here, immediately after the status comment is posted/updated:

func (tr *TaskReporter) ReportTaskStatus(ctx context.Context, task *Task) error {
    // ... existing comment logic ...

    // NEW: execute source actions for terminal phases
    if desiredPhase == "succeeded" || desiredPhase == "failed" {
        if err := tr.executeSourceActions(ctx, task, desiredPhase); err != nil {
            log.Error(err, "Failed to execute source actions", "task", task.Name)
            // Non-fatal: record event but don't block
        }
    }
}

GitHub API methods required

All required GitHub API methods are standard REST endpoints:

  • POST /repos/{owner}/{repo}/issues/{number}/labels β€” add labels
  • DELETE /repos/{owner}/{repo}/issues/{number}/labels/{name} β€” remove labels
  • PATCH /repos/{owner}/{repo}/issues/{number} β€” close/reopen (state field)
  • POST /repos/{owner}/{repo}/issues/{number}/assignees β€” add assignees
  • DELETE /repos/{owner}/{repo}/issues/{number}/assignees β€” remove assignees

The GitHubReporter (internal/reporting/github.go) already implements HTTP requests with token refresh support. Adding these methods follows the same pattern as CreateComment and UpdateComment.

Idempotency

Actions must be idempotent since the watcher may retry:

  • Adding a label that already exists: GitHub returns 200 (no-op)
  • Removing a label that doesn't exist: GitHub returns 404 (catch and ignore)
  • Closing an already-closed issue: GitHub returns 200 (no-op)
  • The AnnotationGitHubReportPhase annotation (watcher.go:34) already prevents duplicate reporting, and the same mechanism prevents duplicate action execution.

Future extension: Jira and Linear

The same SourceActions pattern can extend to Jira and Linear when those sources gain reporting support (a gap noted in #903):

  • Jira: transitions (move to "Done"), addLabels, assignee
  • Linear: state (move to "Done"), addLabels, assignee

The SourceActions type would be source-specific (not generic) to leverage each platform's native lifecycle capabilities.

Backward Compatibility

  • GitHubReporting.Enabled: true with no sourceActions behaves exactly as today β€” only posts status comments.
  • sourceActions is fully optional; existing TaskSpawners require no changes.
  • The commentTemplate extension is additive β€” when not set, the existing Format*Comment() functions are used.

/kind feature

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions