Skip to content
Merged
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
188 changes: 188 additions & 0 deletions docs/ADRs/0093-tracker-routed-status-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
---
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. Both `fullsend run` and
`reconcile-status` read the normalized event from the on-disk dispatch
payload or `GITHUB_EVENT_PATH` and derive the tracker adapter from
`source.system` and the Jira project/number from `entity.key`.

### 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 reads the normalized event from
the same sources as `fullsend run` (on-disk dispatch payload or
`GITHUB_EVENT_PATH`) to determine the tracker destination. For
GitHub/GitLab events it wraps the forge client in
`tracker.NewForgeClient()`; for Jira events it constructs a
`tracker.JiraClient` using the Jira credentials from environment
variables.

## 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 @@ -47,6 +47,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
6 changes: 3 additions & 3 deletions docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,16 +153,16 @@ fullsend
│ ├── --offline # Reject network fetches (local manifest only)
│ └── --out-dir <path> # Output dir (default: telemetry directory)
└── reconcile-status # Finalize orphaned status comments
├── --repo <owner/repo> # Repository in owner/repo format
├── --number <int> # Issue/PR number
├── --repo <owner/repo> # Repository in owner/repo format (required for GitHub/GitLab)
├── --number <int> # Issue/PR number (required for GitHub/GitLab; derived from entity.key for Jira)
├── --run-id <string> # Workflow run ID (marker key)
├── --run-url <url> # Workflow run URL (optional)
├── --sha <string> # Commit SHA (optional)
├── --reason <string> # Termination reason: terminated or cancelled (default: terminated)
├── --mint-url <url> # Mint service URL for on-demand token (default: $FULLSEND_MINT_URL)
├── --role <string> # Agent role for minting (required with --mint-url)
├── --forge <platform> # Forge platform (github, gitlab); auto-detected from CI env
├── --fullsend-dir <path> # Path to fullsend config directory (completion mode detection)
├── --fullsend-dir <path> # Path to fullsend config directory (completion mode detection and tracker routing)
├── --job-status <string> # Job outcome from CI runner (e.g. success, failure, cancelled)
└── --was-skipped # Pre-script decided to skip the run; forces synthesis under on_failure
```
Expand Down
6 changes: 3 additions & 3 deletions docs/guides/user/jira-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ A slash command is only recognized as the **first token of the comment's first l

The `event_type` field in dispatch records (e.g. `"comment_added"`, `"label_changed"`) describes the Jira-side activity that **triggered** the dispatch — it is an **input** event, not a description of what the agent will do. When you see `event_type: "comment_added"` in `dispatches.json`, it means "a comment was added to a Jira issue, and that comment matched a routing rule." It says nothing about the agent's output.

Currently, agents process Jira-sourced dispatches but write all results — comments, labels, status updates — back to **GitHub only** by default. The agent runs against the target GitHub repo exactly as it would for a GitHub-sourced event: triage output becomes a GitHub issue comment, code agent output becomes a pull request, and so on. The Jira issue that triggered the dispatch receives no automatic update.
Currently, agents process Jira-sourced dispatches and write substantive results — comments, labels, pull requests, and so on — back to **GitHub** by default. Run-status notifications (start, completion, and orphan reconciliation) are routed through `tracker.Client` to the tracker that originated the event, so Jira-triggered runs update the Jira issue as well. The Jira issue does not receive the agent's substantive output automatically.

The CLI primitive for posting comments to Jira exists — `fullsend issues post-comment --tracker jira` — along with the underlying `tracker.Client` Jira implementation ([#5989](https://github.com/fullsend-ai/fullsend/issues/5989)). However, the built-in agent pipeline does not use it yet: agent pre/post scripts still expect a GitHub issue number, not a Jira key ([#2264](https://github.com/fullsend-ai/fullsend/issues/2264)).

This means the person who commented `/fs-triage` on a Jira issue will not see the triage result in Jira — they need to check the linked GitHub repo. Until agent-pipeline integration lands ([#2264](https://github.com/fullsend-ai/fullsend/issues/2264)), treat Jira as a **trigger source only** for built-in agents: it can start agent work, but all output appears in GitHub. Custom agents can use `fullsend issues post-comment --tracker jira` directly to post results back to Jira.
This means the person who commented `/fs-triage` on a Jira issue will see the run-status updates in Jira, but needs to check the linked GitHub repo for the triage result. Until agent-pipeline integration lands ([#2264](https://github.com/fullsend-ai/fullsend/issues/2264)), treat Jira as a **trigger source for substantive built-in-agent output**: it can start agent work, while that output appears in GitHub. Custom agents can use `fullsend issues post-comment --tracker jira` directly to post results back to Jira.

## Prerequisites

Expand Down Expand Up @@ -65,7 +65,7 @@ The wildcard host (`*.atlassian.net`) permits egress to any Atlassian Cloud tena

## Repo configuration

No special harness or config changes are needed to *receive* Jira-sourced dispatches: the Jira poller produces the same [NormalizedEvents](../../normative/normalized-event/v1/) that GitHub and GitLab do, so routing and triggers work unchanged. However, built-in agent output is currently written to GitHub only — the CLI primitive for posting to Jira exists (`fullsend issues post-comment --tracker jira`), but the agent pipeline does not use it yet. See [Event semantics — input only](#event-semantics--input-only) for details. Additionally, the built-in agents' pre/post scripts do not yet understand Jira work-item payloads (they expect a GitHub issue number, not a Jira key — [#2264](https://github.com/fullsend-ai/fullsend/issues/2264)), so dispatched agent runs will not complete successfully until that follow-up lands. See the Troubleshooting section below.
No special harness or config changes are needed to *receive* Jira-sourced dispatches: the Jira poller produces the same [NormalizedEvents](../../normative/normalized-event/v1/) that GitHub and GitLab do, so routing and triggers work unchanged. Built-in agent output is currently written to GitHub only, while run-status notifications route to Jira for Jira-triggered runs. See [Event semantics — input only](#event-semantics--input-only) for details. Additionally, the built-in agents' pre/post scripts do not yet understand Jira work-item payloads (they expect a GitHub issue number, not a Jira key — [#2264](https://github.com/fullsend-ai/fullsend/issues/2264)), so dispatched agent runs will not complete successfully until that follow-up lands. See the Troubleshooting section below.

If your repo already has a `.fullsend/config.yaml` from `fullsend github setup`, you are ready to go.

Expand Down
37 changes: 37 additions & 0 deletions internal/cli/jira_tracker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cli

import (
"fmt"
"os"

"github.com/fullsend-ai/fullsend/internal/forge/jira"
"github.com/fullsend-ai/fullsend/internal/tracker"
)

// newJiraTrackerClientFromEnv creates the tracker used for Jira-originated
// status notifications and reconciliation. Jira Cloud requires Basic auth
// with both the account email and API token; accepting a bare token here
// would defer a configuration error until the first API request.
func newJiraTrackerClientFromEnv() (tracker.Client, error) {
baseURL := os.Getenv("JIRA_BASE_URL")
if baseURL == "" {
return nil, fmt.Errorf("JIRA_BASE_URL required for Jira status notifications")
}
token := os.Getenv("JIRA_TOKEN")
if token == "" {
return nil, fmt.Errorf("JIRA_TOKEN required for Jira status notifications")
}
if os.Getenv("GITHUB_ACTIONS") == "true" {
fmt.Fprintf(os.Stderr, "::add-mask::%s\n", token)
}
email := os.Getenv("JIRA_USER_EMAIL")
if email == "" {
return nil, fmt.Errorf("JIRA_USER_EMAIL required for Jira Cloud authentication")
}

jc, err := jira.New(token, jira.WithBaseURL(baseURL), jira.WithEmail(email))
if err != nil {
return nil, fmt.Errorf("creating Jira client: %w", err)
}
return tracker.NewJiraClient(jc, baseURL)
}
Loading
Loading