Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions docs/ADRs/0093-tracker-routed-status-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
---
title: "93. Event-source routing for run-status notifications"
status: Accepted
relates_to:
- agent-architecture
- agent-infrastructure
topics:
- tracker
- notifications
- status-comments
- jira
- portability
---

# 93. Event-source routing for run-status notifications

Date: 2026-08-29

## Status

Accepted

Builds on:

- Forge abstraction: [ADR 0005](0005-forge-abstraction-layer.md)
- Conversation surface / domain split: [ADR 0086](0086-conversation-surface-for-agent-participation.md)

## Context

When an agent run is triggered by an event — a GitHub issue comment, a
GitLab MR update, a Jira issue transition — the resulting status
notifications (start comments, completion comments, emoji reactions,
orphan reconciliation) must be posted back to the tracker that
*originated* the event, not necessarily to the forge that hosts the
code.

Previously, status notifications were hardwired to `forge.Client` with
`owner/repo/number` addressing. This works when the triggering work
item and the code repository live on the same forge, but breaks when
an external issue tracker drives the work.

The concrete failure: when a Jira issue triggers a code-agent run, the
Jira issue number (e.g. 6964193) is passed to `forge.Client` as a GitHub
issue number. No such GitHub issue exists, producing:

```
Failed to post start status: posting start comment:
create issue comment on #6964193: github api: 404 Not Found
```

The key insight is that the notification destination should be
*dynamically determined by event provenance*: a Jira-triggered run
posts status to Jira, a GitHub-triggered run posts to GitHub, a
GitLab-triggered run posts to GitLab. The code repository and the
notification target are independent axes.

The repository already has a tracker-neutral comment interface
(`tracker.Client`) with adapters for GitHub/GitLab (via `forge.Client`)
and Jira. ADR 0086 established the domain split: `forge.Client` for
git-hosting, `tracker.Client` for issue content, `conversation.Client`
for chat. Status notifications are issue content, not git-hosting
operations, so they naturally route through `tracker.Client`.

## Options

### A. Special-case Jira in the notifier

Add Jira-specific branching to `statuscomment.Notifier` alongside the
existing `forge.Client` calls. Fast to implement but duplicates the
tracker abstraction and grows worse with each new tracker backend.

### B. Dynamic event-source routing through `tracker.Client` (chosen)

Let the event source determine the notification destination at
runtime. The notifier accepts a `tracker.Client`, and callers wire in
the appropriate tracker adapter based on which system originated the
event. This replaces `forge.Client` in the status-notification path
as a natural consequence: comments route to whichever tracker
originated the run.

Reactions become an optional capability (`tracker.Reactor` interface)
since tracker reaction support varies. Jira Cloud supports reactions
on comments but not on issues; GitHub and GitLab support both. Making
`Reactor` optional keeps the interface clean for trackers with partial
or no reaction support.

### C. Post status to both the tracker and the forge

Dual-write so status appears on both the Jira issue and a corresponding
GitHub issue. Requires a matching GitHub issue to exist (or be created),
adds complexity, and doubles API calls for every status update.

## Decision

Adopt **Option B**: dynamically route status notifications based on
event provenance.

The central change is architectural: the notification destination is
no longer hardwired to the code-output forge but is determined by the
event source at runtime. The interface refactoring (`forge.Client` →
`tracker.Client`) is a consequence of this routing decision — it makes
the notifier tracker-agnostic so any adapter can be wired in.

### Event-source wiring

Callers determine the tracker adapter based on the event source:

- **GitHub-triggered runs**: callers construct
`tracker.NewForgeClient(ghClient)` — status posts to GitHub.
- **GitLab-triggered runs**: callers construct
`tracker.NewForgeClient(glClient)` — status posts to GitLab.
- **Jira-triggered runs**: callers construct a `tracker.JiraClient` —
status posts to Jira.

The `ClientFactory` in the GitHub path returns
`tracker.NewForgeClient(gh.New(mintedToken))` so each token refresh
produces a tracker-wrapped client. The plumbing for reading the event
source from the normalized event and dynamically selecting the tracker
adapter is a follow-on; the interface is ready for it.

### Interface changes

1. **`tracker.Client`** gains `DeleteComment` for cleaning up transient
start comments when completion is suppressed.

2. **`tracker.Reactor`** is a new optional interface:

```go
type Reactor interface {
AddIssueReaction(ctx, project, number, content) (id, error)
DeleteIssueReaction(ctx, project, number, reactionID) error
AddCommentReaction(ctx, project, number, commentID, content) (id, error)
DeleteCommentReaction(ctx, project, number, commentID, reactionID) error
}
```

`tracker.ForgeClient` implements `Reactor` (GitHub and GitLab support
emoji reactions on both issues and comments). `tracker.JiraClient`
does not implement `Reactor` currently: Jira Cloud supports reactions
on comments but not on issues, and the partial support doesn't
cleanly fit the `Reactor` interface which includes issue-level
reactions. Adding Jira comment-reaction support is straightforward
once needed. Consumers type-assert to `Reactor` and silently skip
reaction operations when the tracker does not implement it.

### Notifier changes

- `statuscomment.Notifier` accepts `tracker.Client` instead of
`forge.Client`, making it tracker-agnostic.
- Addressing changes from `(owner, repo string, number int)` to
`(project string, number int)` to match `tracker.Client`'s
project-keyed model.
- `ClientFactory` returns `tracker.Client` instead of `forge.Client`.
- `SetTriggerCommentID` accepts `string` (tracker comment IDs are
strings for JSON round-tripping safety and non-numeric ID support).

### ReconcileOrphaned changes

- Accepts `tracker.Client` and `(project, number)` instead of
`forge.Client` and `(owner, repo, number)`.
- The `reconcile-status` CLI command wraps its forge client in
`tracker.NewForgeClient()` before calling `ReconcileOrphaned`.

## Consequences

- **Dynamic routing**: status notifications are directed to the tracker
that originated the event, not the code-output forge. A Jira-triggered
run posts status to Jira instead of producing a 404 on a non-existent
GitHub issue.
- Reactions are silently skipped for trackers that do not implement
`Reactor` (e.g. Jira, which supports comment reactions but not issue
reactions — partial support that doesn't fit the current interface).
Adding Jira comment-reaction support is a future option.
- The `statuscomment` package no longer imports `internal/forge`,
depending only on `internal/tracker` and `internal/config`.
- Adding a new tracker backend (e.g. Linear, Azure DevOps) requires only
implementing `tracker.Client` (and optionally `tracker.Reactor`);
status notifications work automatically via event-source routing.
- The orphan reconciler uses the same event-source routing, so
interrupted runs on Jira issues are also finalized correctly.
- `jira.LiveClient` gains a `DeleteComment` method to satisfy the
extended `tracker.Client` interface.
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ the dedicated org-level `<org>/.fullsend` config repo is deprecated

- Forge abstraction: all forge operations go through the `forge.Client` interface, keeping the rest of the codebase forge-agnostic ([ADR 0005](ADRs/0005-forge-abstraction-layer.md)).
- Conversation surface: agents participate in GitHub Discussions and later other chat systems through a narrow `conversation.Client` (parallel to `tracker.Client` for issue content), not by extending `forge.Client` ([ADR 0086](ADRs/0086-conversation-surface-for-agent-participation.md)). A **conversation** is the container (Discussion / Slack channel) with exactly one category and optional M:M labels; a **thread** is the top-level message plus replies that share its `parent_id` (`parent_id == id` on the root message).
- Event-source routing for status notifications: the notification destination for run-status comments and reactions is dynamically determined by event provenance — a Jira-triggered run posts status to Jira, a GitHub-triggered run posts to GitHub — rather than being hardwired to the code-output forge. Status notifications route through `tracker.Client`; reactions are an optional `tracker.Reactor` capability (Jira Cloud supports comment reactions but not issue reactions, so `Reactor` is not implemented for Jira currently) ([ADR 0093](ADRs/0093-tracker-routed-status-notifications.md)).
- Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations. Current stack: config-repo → workflows → vendor-binary → secrets → inference → dispatch → enrollment ([ADR 0006](ADRs/0006-ordered-layer-model.md)).
- Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; a dispatch workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function or Cloudflare Worker) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager (GCF mint), Worker secrets (CF mint), or the local filesystem (standalone mint), not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md)).
- Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim workflow. No long-lived secrets flow through the shim — OIDC tokens are issued by the GitHub runtime and scoped to the workflow run ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)).
Expand Down
18 changes: 14 additions & 4 deletions internal/cli/reconcilestatus.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
gl "github.com/fullsend-ai/fullsend/internal/forge/gitlab"
"github.com/fullsend-ai/fullsend/internal/mintclient"
"github.com/fullsend-ai/fullsend/internal/statuscomment"
"github.com/fullsend-ai/fullsend/internal/tracker"
)

var reconcileMintToken = mintclient.MintToken
Expand All @@ -21,6 +22,12 @@ var reconcileNewForgeClient = func(token string) forge.Client {
}
var reconcileOrphaned = statuscomment.ReconcileOrphaned

// reconcileNewTrackerClient wraps a forge.Client in a tracker.ForgeClient
// for use by ReconcileOrphaned.
var reconcileNewTrackerClient = func(fc forge.Client) tracker.Client {
return tracker.NewForgeClient(fc)
}

func newReconcileStatusCmd() *cobra.Command {
var (
repo string
Expand Down Expand Up @@ -65,21 +72,23 @@ finalized, this is a no-op.`,
return err
}

var client forge.Client
var forgeClient forge.Client
if forgePlatform == "gitlab" {
var gitlabErr error
client, gitlabErr = newGitLabClientFromEnv("status reconciliation")
forgeClient, gitlabErr = newGitLabClientFromEnv("status reconciliation")
if gitlabErr != nil {
return gitlabErr
}
} else {
var githubErr error
client, githubErr = reconcileGitHubClient(cmd, mintURL, role, repoName)
forgeClient, githubErr = reconcileGitHubClient(cmd, mintURL, role, repoName)
if githubErr != nil {
return githubErr
}
}

tc := reconcileNewTrackerClient(forgeClient)

var termReason statuscomment.TerminationReason
switch reason {
case "cancelled":
Expand Down Expand Up @@ -110,7 +119,8 @@ finalized, this is a no-op.`,

agentDescription := titleCase(strings.ReplaceAll(role, "-", " "))

return reconcileOrphaned(cmd.Context(), client, owner, repoName, number, runID, runURL, sha, termReason, completionMode, jobStatus, wasSkipped, agentDescription)
project := owner + "/" + repoName
return reconcileOrphaned(cmd.Context(), tc, project, number, runID, runURL, sha, termReason, completionMode, jobStatus, wasSkipped, agentDescription)
},
}

Expand Down
8 changes: 7 additions & 1 deletion internal/cli/reconcilestatus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
gh "github.com/fullsend-ai/fullsend/internal/forge/github"
"github.com/fullsend-ai/fullsend/internal/mintclient"
"github.com/fullsend-ai/fullsend/internal/statuscomment"
"github.com/fullsend-ai/fullsend/internal/tracker"
)

// gitlabNoteServer returns an httptest.Server that handles GitLab note API calls
Expand Down Expand Up @@ -322,6 +323,7 @@ func stubReconcileVars(t *testing.T, onReconcile func(completionMode, jobStatus
t.Helper()
origMint := reconcileMintToken
origForge := reconcileNewForgeClient
origTracker := reconcileNewTrackerClient
origReconcile := reconcileOrphaned

reconcileMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) {
Expand All @@ -335,13 +337,17 @@ func stubReconcileVars(t *testing.T, onReconcile func(completionMode, jobStatus
t.Cleanup(srv.Close)
return gh.New(token).WithBaseURL(srv.URL)
}
reconcileOrphaned = func(_ context.Context, _ forge.Client, _, _ string, _ int, _, _, _ string, _ statuscomment.TerminationReason, completionMode, jobStatus string, wasSkipped bool, agentDescription string) error {
reconcileNewTrackerClient = func(fc forge.Client) tracker.Client {
return tracker.NewForgeClient(fc)
}
reconcileOrphaned = func(_ context.Context, _ tracker.Client, _ string, _ int, _, _, _ string, _ statuscomment.TerminationReason, completionMode, jobStatus string, wasSkipped bool, agentDescription string) error {
onReconcile(completionMode, jobStatus, wasSkipped, agentDescription)
return nil
}
t.Cleanup(func() {
reconcileMintToken = origMint
reconcileNewForgeClient = origForge
reconcileNewTrackerClient = origTracker
reconcileOrphaned = origReconcile
})
}
Expand Down
14 changes: 9 additions & 5 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -50,6 +51,7 @@ import (
"github.com/fullsend-ai/fullsend/internal/security"
"github.com/fullsend-ai/fullsend/internal/statuscomment"
"github.com/fullsend-ai/fullsend/internal/telemetry"
"github.com/fullsend-ai/fullsend/internal/tracker"
"github.com/fullsend-ai/fullsend/internal/ui"

"go.opentelemetry.io/otel/attribute"
Expand Down Expand Up @@ -4271,16 +4273,17 @@ func setupStatusNotifierGitHub(notifyCfg config.StatusNotificationConfig, owner,
runID = fmt.Sprintf("%d", time.Now().UnixNano())
}

n := statuscomment.New(nil, notifyCfg, owner, repo, sOpts.statusNum, sOpts.runURL, sha, runID)
project := owner + "/" + repo
n := statuscomment.New(nil, notifyCfg, project, sOpts.statusNum, sOpts.runURL, sha, runID)
n.SetWarnFunc(func(format string, args ...any) {
printer.StepWarn(fmt.Sprintf(format, args...))
})
if sOpts.statusComment != 0 {
n.SetTriggerCommentID(sOpts.statusComment)
n.SetTriggerCommentID(strconv.Itoa(sOpts.statusComment))
}

canonRole := resolveRole(role)
n.SetClientFactory(func(ctx context.Context) (forge.Client, error) {
n.SetClientFactory(func(ctx context.Context) (tracker.Client, error) {
result, err := statusMintToken(ctx, mintclient.MintRequest{
MintURL: mintURL,
Role: canonRole,
Expand All @@ -4295,7 +4298,7 @@ func setupStatusNotifierGitHub(notifyCfg config.StatusNotificationConfig, owner,
if os.Getenv("GITHUB_ACTIONS") == "true" {
fmt.Fprintf(os.Stderr, "::add-mask::%s\n", result.Token)
}
return gh.New(result.Token), nil
return tracker.NewForgeClient(gh.New(result.Token)), nil
})

return n, nil
Expand Down Expand Up @@ -4325,7 +4328,8 @@ func setupStatusNotifierGitLab(notifyCfg config.StatusNotificationConfig, owner,
runID = fmt.Sprintf("%d", time.Now().UnixNano())
}

n := statuscomment.New(client, notifyCfg, owner, repo, sOpts.statusNum, sOpts.runURL, sha, runID)
project := owner + "/" + repo
n := statuscomment.New(tracker.NewForgeClient(client), notifyCfg, project, sOpts.statusNum, sOpts.runURL, sha, runID)
n.SetWarnFunc(func(format string, args ...any) {
printer.StepWarn(fmt.Sprintf(format, args...))
})
Expand Down
9 changes: 9 additions & 0 deletions internal/forge/jira/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,15 @@ func (c *LiveClient) UpdateComment(ctx context.Context, issueIDOrKey, commentID,
return nil
}

// DeleteComment removes a comment by ID from the given issue.
func (c *LiveClient) DeleteComment(ctx context.Context, issueIDOrKey, commentID string) error {
path := "/issue/" + url.PathEscape(issueIDOrKey) + "/comment/" + url.PathEscape(commentID)
if err := c.do(ctx, http.MethodDelete, path, nil, nil); err != nil {
return fmt.Errorf("delete comment %s on %s: %w", commentID, issueIDOrKey, err)
}
return nil
}

// ListChangelog fetches all changelog entries for an issue, exhausting
// pagination up to maxListPages pages.
func (c *LiveClient) ListChangelog(ctx context.Context, issueIDOrKey string) ([]ChangelogEntry, error) {
Expand Down
34 changes: 34 additions & 0 deletions internal/forge/jira/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,40 @@ func TestSetEntityProperty(t *testing.T) {
require.NoError(t, err)
}

// ---------------------------------------------------------------------------
// DeleteComment
// ---------------------------------------------------------------------------

func TestDeleteComment(t *testing.T) {
t.Parallel()
client, mux := setupTest(t)
ctx := context.Background()

mux.HandleFunc("/rest/api/3/issue/PROJ-1/comment/50001", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodDelete, r.Method)
w.WriteHeader(http.StatusNoContent)
})

err := client.DeleteComment(ctx, "PROJ-1", "50001")
require.NoError(t, err)
}

func TestDeleteComment_NotFound(t *testing.T) {
t.Parallel()
client, mux := setupTest(t)
ctx := context.Background()

mux.HandleFunc("/rest/api/3/issue/PROJ-1/comment/99999", func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusNotFound, map[string]any{
"errorMessages": []string{"Comment does not exist."},
})
})

err := client.DeleteComment(ctx, "PROJ-1", "99999")
require.Error(t, err)
assert.Contains(t, err.Error(), "delete comment")
}

// ---------------------------------------------------------------------------
// DeleteEntityProperty
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading