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
1 change: 1 addition & 0 deletions cmd/sortie/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func trackerConfigMap(tc config.TrackerConfig) map[string]any {
"query_filter": tc.QueryFilter,
"handoff_state": tc.HandoffState,
"in_progress_state": tc.InProgressState,
"api_version": tc.APIVersion,
"comments": map[string]any{
"on_dispatch": tc.Comments.OnDispatch,
"on_completion": tc.Comments.OnCompletion,
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,9 @@ This section is intentionally redundant so a coding agent can implement the conf
- `tracker.in_progress_state`: string, optional, default absent; target state for
dispatch-time transition at the start of each worker attempt; must be in `active_states`,
must not collide with `terminal_states` or `handoff_state`; supports `$VAR`
- `tracker.api_version`: string (`"2"` or `"3"`), optional, default `"3"`; selects
Jira REST API v3 (Cloud) or v2 (Server / Data Center); quote the value to avoid
a validation advisory (`api_version: "2"`); supports `$VAR`
- `polling.interval_ms`: integer, default `30000`
- `workspace.root`: path, default `<system-temp>/sortie_workspaces`
- `worker.ssh_hosts` (extension): list of SSH host strings, optional; when omitted, work runs
Expand Down
82 changes: 68 additions & 14 deletions docs/jira-adapter-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,36 @@ PATs act as a secure alternative to Basic Auth passwords, behaving like bearer t
- Header: `Authorization: Bearer <your_pat>`
- Available in Jira Data Center and Server.

Relevant only if Sortie adds Data Center support in the future.
Sortie supports PAT authentication when `tracker.api_version: "2"` is set. The adapter
selects the auth mode from the `tracker.api_key` value using a presence-of-colon rule:

- `api_key` contains a colon (`user:password`): Basic auth, `Authorization: Basic
base64(user:password)`. Both sides of the colon must be non-empty; an empty user
(`":password"`) or empty secret (`"user:"`) is rejected at construction time with
`ErrTrackerAuth`.
- `api_key` has no colon (a colon-free token string): Bearer auth, `Authorization:
Bearer <token>`. This is the PAT path for Server and Data Center.

Under `api_version: "3"` (Cloud), a colon-free `api_key` is always rejected because
Cloud requires `email:token` format.

### Config mapping

| Config field | Value |
| ------------------ | ---------------------------------------------- |
| `tracker.endpoint` | `https://<site>.atlassian.net` (no trailing /) |
| `tracker.api_key` | `email:api_token`; adapter splits on first `:` |
| `tracker.project` | Jira project key, e.g. `SORT` |
| Config field | Cloud (v3) | Server / DC (v2) |
| ----------------------- | ---------------------------------------------------- | -------------------------------------------------- |
| `tracker.endpoint` | `https://<site>.atlassian.net` (no trailing /) | `https://jira.internal.example.com` (no trailing /) |
| `tracker.api_key` | `email:api_token`; splits on first `:` | `user:password` (Basic) or PAT token (Bearer) |
| `tracker.project` | Jira project key, e.g. `SORT` | Same |
| `tracker.api_version` | `"3"` (default, may be omitted) | `"2"` (required for Server / DC) |

Encoding `email:token` in a single field follows curl convention (`-u email:token`) and avoids
adding Jira-specific config keys to the core schema. The value may be provided via environment
variable indirection (e.g. `$JIRA_API_KEY`) if the config layer supports it.

**Construction-time host/version guard:** A `.atlassian.net` endpoint combined with
`api_version: "2"` is rejected at startup (`ErrTrackerPayload`). A non-`.atlassian.net`
endpoint combined with `api_version: "3"` emits a warning and proceeds.

**CAPTCHA caveat:** After several failed logins Jira triggers CAPTCHA and returns
`X-Seraph-LoginReason: AUTHENTICATION_DENIED`. The adapter should detect this header and
return `tracker_auth_error`.
Expand All @@ -58,7 +74,34 @@ return `tracker_auth_error`.

## Endpoints

Each `TrackerAdapter` operation maps to a Jira REST v3 endpoint.
Each `TrackerAdapter` operation maps to a Jira REST endpoint. The base path is
`/rest/api/3` when `api_version` is `"3"` (default) and `/rest/api/2` when
`api_version` is `"2"`. The table below shows the v3 paths; substitute `/rest/api/2`
for v2. The one exception is the search resource: v3 uses `/rest/api/3/search/jql`
whereas v2 uses `/rest/api/2/search` (no `/jql` segment).

### v2 body format

On v2, `description` and comment `body` fields are returned as a **raw string in Jira
wiki markup** (for example `h2. Heading`, `*bold*`, `{code}...{code}`). The adapter
reads this string verbatim; it does not flatten or translate markup tokens. Downstream
consumers (prompt templates, agents) therefore see wiki markup rather than clean prose
when `api_version: "2"` is set. The v3 path returns ADF, which the adapter flattens to
text before storing in `domain.Issue.Description` and `domain.Comment.Body`.

Comment creation on v2 sends a raw-string body:

```json
{"body": "<text>"}
```

Comment creation on v3 sends an ADF document (see ADF flattening section below).

### v2 search pagination

The v2 search endpoint (`GET /rest/api/2/search`) uses offset-based pagination
(`startAt` / `total`). The adapter loops until `startAt + page_count >= total` or the
page is empty. Page size is 50 for both versions.

### 1. `FetchCandidateIssues` → `GET /rest/api/3/search/jql`

Expand Down Expand Up @@ -154,7 +197,7 @@ operations.
| `ID` | `id` (string) | Numeric ID as string |
| `Identifier` | `key` (string) | e.g. `"PROJ-123"` |
| `Title` | `fields.summary` | |
| `Description` | `fields.description` (ADF) | Flatten ADF → plain text |
| `Description` | `fields.description` | v3: ADF flattened to text. v2: raw string (wiki markup). |
| `Priority` | `fields.priority.id` (string) | e.g. `"3"` → int 3; use `id` not `name` |
| `State` | `fields.status.name` | Preserve original casing |
| `BranchName` | — | See dev-status note below |
Expand All @@ -163,7 +206,7 @@ operations.
| `Assignee` | `fields.assignee.displayName` | Nil → empty string |
| `IssueType` | `fields.issuetype.name` | |
| `Parent` | `fields.parent.id`, `.parent.key` | Nil when absent |
| `Comments` | Separate endpoint | ADF → plain text |
| `Comments` | Separate endpoint | v3: ADF flattened to text. v2: raw string (wiki markup). |
| `BlockedBy` | `fields.issuelinks[]` (filtered) | See blocker extraction below |
| `CreatedAt` | `fields.created` (ISO-8601) | |
| `UpdatedAt` | `fields.updated` (ISO-8601) | |
Expand Down Expand Up @@ -223,16 +266,16 @@ Jira v3 returns `description` and comment `body` as ADF JSON:
The adapter must recursively walk the tree and extract all `text` node values, joining
paragraphs with newlines. Without this, `Description` and comment `Body` would be raw JSON.

**v2 API alternative:** The v2 API (`/rest/api/2/...`) returns `description` and comment
`body` as rendered HTML or plain text instead of ADF. If ADF flattening proves too complex,
the adapter could use v2 endpoints for these fields. However, v3 is the current API and
ADF flattening gives the adapter full control over text extraction.
**v2 bodies are not ADF:** When `api_version: "2"`, `description` and comment `body` are
JSON strings containing Jira wiki markup, not ADF objects. The adapter reads them verbatim
with a JSON string unquote, not ADF flattening. Rendered HTML (`expand=renderedBody`) is
not requested and not in scope.

---

## Pagination

### Search endpoint (`GET /rest/api/3/search/jql`), cursor-based
### v3 search endpoint (`GET /rest/api/3/search/jql`), cursor-based

- First request: omit `nextPageToken`, set `maxResults` (recommend `50`).
- Subsequent requests: pass the `nextPageToken` from the previous response.
Expand All @@ -244,6 +287,17 @@ ADF flattening gives the adapter full control over text extraction.
`POST /rest/api/3/search/jql` uses offset-based (`startAt`/`total`) pagination but is
deprecated for new integrations. Prefer `GET` with cursor-based pagination.

### v2 search endpoint (`GET /rest/api/2/search`), offset-based

The v2 endpoint uses offset-based pagination. The adapter maintains a `startAt` counter
and advances it by the number of results returned per page.

- First request: `startAt=0`, `maxResults=50`.
- Subsequent requests: `startAt += len(page.issues)`.
- Stop when `len(page.issues) == 0` or `startAt + len(page.issues) >= total`.

This mirrors the comment pagination loop already used by both v2 and v3.

### Comment endpoint, offset-based

- `startAt` (0-indexed), `maxResults` (default 50)
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ type TrackerConfig struct {
QueryFilter string
HandoffState string
InProgressState string
APIVersion string
Comments TrackerCommentsConfig
}

Expand Down Expand Up @@ -351,6 +352,11 @@ func buildTrackerConfig(m map[string]any, envKeys map[string]bool) TrackerConfig
inProgressState = resolveEnvRef(inProgressState)
}

apiVersion := extractString(m, "api_version")
if !envKeys["tracker.api_version"] {
apiVersion = resolveEnvRef(apiVersion)
}
Comment on lines +355 to +358

return TrackerConfig{
Kind: extractString(m, "kind"),
Endpoint: endpoint,
Expand All @@ -361,6 +367,7 @@ func buildTrackerConfig(m map[string]any, envKeys map[string]bool) TrackerConfig
QueryFilter: queryFilter,
HandoffState: handoffState,
InProgressState: inProgressState,
APIVersion: apiVersion,
Comments: TrackerCommentsConfig{
OnDispatch: coerceBool(commentsMap, "on_dispatch"),
OnCompletion: coerceBool(commentsMap, "on_completion"),
Expand Down
85 changes: 85 additions & 0 deletions internal/config/config_api_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package config

import "testing"

// TestBuildTrackerConfig_APIVersion verifies the tracker config builder
// carries api_version through and resolves $VAR indirection, mirroring
// the handling of the other optional tracker string fields.
func TestBuildTrackerConfig_APIVersion(t *testing.T) {
t.Run("string value", func(t *testing.T) {
t.Parallel()
tc := buildTrackerConfig(map[string]any{"api_version": "2"}, nil)
assertStringEqual(t, "TrackerConfig.APIVersion", "2", tc.APIVersion)
})

t.Run("absent yields empty", func(t *testing.T) {
t.Parallel()
tc := buildTrackerConfig(map[string]any{}, nil)
assertStringEqual(t, "TrackerConfig.APIVersion", "", tc.APIVersion)
})

t.Run("non-string yields empty", func(t *testing.T) {
t.Parallel()
// extractString flattens a non-string to ""; the adapter applies
// the "3" default for an empty value.
tc := buildTrackerConfig(map[string]any{"api_version": 2}, nil)
assertStringEqual(t, "TrackerConfig.APIVersion", "", tc.APIVersion)
})

t.Run("VAR indirection resolved", func(t *testing.T) {
t.Setenv("TEST_API_VERSION", "2")
tc := buildTrackerConfig(map[string]any{"api_version": "$TEST_API_VERSION"}, nil)
assertStringEqual(t, "TrackerConfig.APIVersion", "2", tc.APIVersion)
})

t.Run("VAR indirection skipped when env-override guard set", func(t *testing.T) {
t.Setenv("TEST_API_VERSION", "2")
// When the SORTIE_* override path already populated the value, the
// $VAR guard must leave the literal untouched, exactly as the other
// tracker string fields behave.
tc := buildTrackerConfig(
map[string]any{"api_version": "$TEST_API_VERSION"},
map[string]bool{"tracker.api_version": true},
)
assertStringEqual(t, "TrackerConfig.APIVersion", "$TEST_API_VERSION", tc.APIVersion)
})
}

// TestNewServiceConfig_APIVersion exercises the same resolution through
// the public entry point so the value reaches TrackerConfig end to end.
func TestNewServiceConfig_APIVersion(t *testing.T) {
t.Run("string value loads", func(t *testing.T) {
t.Parallel()
cfg, err := NewServiceConfig(map[string]any{
"tracker": map[string]any{"kind": "jira", "api_version": "2"},
})
if err != nil {
t.Fatalf("NewServiceConfig: %v", err)
}
assertStringEqual(t, "Tracker.APIVersion", "2", cfg.Tracker.APIVersion)
})

t.Run("env var resolved", func(t *testing.T) {
t.Setenv("TEST_SVC_API_VERSION", "2")
cfg, err := NewServiceConfig(map[string]any{
"tracker": map[string]any{"kind": "jira", "api_version": "$TEST_SVC_API_VERSION"},
})
if err != nil {
t.Fatalf("NewServiceConfig: %v", err)
}
assertStringEqual(t, "Tracker.APIVersion", "2", cfg.Tracker.APIVersion)
})

t.Run("bare YAML integer loads without fatal error", func(t *testing.T) {
t.Parallel()
// A bare integer is accepted (flattened to "" here; the adapter
// applies its own coercion). Loading must not fail.
cfg, err := NewServiceConfig(map[string]any{
"tracker": map[string]any{"kind": "jira", "api_version": 2},
})
if err != nil {
t.Fatalf("NewServiceConfig with bare integer api_version: %v", err)
}
assertStringEqual(t, "Tracker.APIVersion", "", cfg.Tracker.APIVersion)
})
}
1 change: 1 addition & 0 deletions internal/config/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ var knownFieldsRegistry = map[string]SectionSchema{
{Name: "query_filter", Type: FieldString},
{Name: "handoff_state", Type: FieldString},
{Name: "in_progress_state", Type: FieldString},
{Name: "api_version", Type: FieldString},
{Name: "comments", Type: FieldMap, Nested: []FieldDef{
{Name: "on_dispatch", Type: FieldBool},
{Name: "on_completion", Type: FieldBool},
Expand Down
73 changes: 73 additions & 0 deletions internal/config/schema_api_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package config

import "testing"

// TestValidateFrontMatter_APIVersion verifies the schema recognizes
// tracker.api_version as a known FieldString: a quoted string draws no
// warning, while a bare YAML integer draws a single non-fatal
// type_mismatch advisory.
func TestValidateFrontMatter_APIVersion(t *testing.T) {
t.Parallel()

tests := []struct {
name string
raw map[string]any
wantCount int
wantChecks []string
wantFields []string
}{
{
name: "quoted string value is recognized, no warning",
raw: map[string]any{
"tracker": map[string]any{"kind": "jira", "api_version": "2"},
},
wantCount: 0,
},
{
name: "bare integer draws a single type_mismatch advisory",
raw: map[string]any{
"tracker": map[string]any{"kind": "jira", "api_version": 2},
},
wantCount: 1,
wantChecks: []string{"type_mismatch"},
wantFields: []string{"tracker.api_version"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := ValidateFrontMatter(tt.raw, ServiceConfig{Tracker: TrackerConfig{Kind: "jira"}})

if len(got) != tt.wantCount {
t.Fatalf("ValidateFrontMatter() returned %d warnings, want %d\nwarnings: %+v", len(got), tt.wantCount, got)
}
for i, wantCheck := range tt.wantChecks {
if got[i].Check != wantCheck {
t.Errorf("warnings[%d].Check = %q, want %q", i, got[i].Check, wantCheck)
}
}
for i, wantField := range tt.wantFields {
if got[i].Field != wantField {
t.Errorf("warnings[%d].Field = %q, want %q", i, got[i].Field, wantField)
}
}
})
}
}

// TestValidateFrontMatter_APIVersionNotUnknownKey is a focused guard:
// api_version must never surface as an unknown_sub_key.
func TestValidateFrontMatter_APIVersionNotUnknownKey(t *testing.T) {
t.Parallel()

raw := map[string]any{
"tracker": map[string]any{"kind": "jira", "api_version": "3"},
}
for _, w := range ValidateFrontMatter(raw, ServiceConfig{Tracker: TrackerConfig{Kind: "jira"}}) {
if w.Check == "unknown_sub_key" && w.Field == "tracker.api_version" {
t.Errorf("api_version reported as unknown_sub_key: %+v", w)
}
}
}
8 changes: 4 additions & 4 deletions internal/tracker/jira/client.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package jira

import (
"encoding/base64"
"fmt"
"io"
"net/http"
Expand All @@ -12,10 +11,11 @@ import (
"github.com/sortie-ai/sortie/internal/httpkit"
)

// newJiraClient constructs the shared Jira transport.
func newJiraClient(baseURL, email, token, userAgent string) *httpkit.Client {
// newJiraClient constructs the shared Jira transport. The authHeader is
// the fully-formed Authorization value (Basic or Bearer) set on every
// request.
func newJiraClient(baseURL, authHeader, userAgent string) *httpkit.Client {
trimmedBaseURL := strings.TrimRight(baseURL, "/")
authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(email+":"+token))

return httpkit.NewClient(httpkit.ClientOptions{
BaseURL: trimmedBaseURL,
Expand Down
4 changes: 3 additions & 1 deletion internal/tracker/jira/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package jira

import (
"context"
"encoding/base64"
"errors"
"io"
"net/http"
Expand All @@ -16,7 +17,8 @@ import (

func newTestJiraClient(t *testing.T, baseURL, email, token string) *httpkit.Client {
t.Helper()
return newJiraClient(baseURL, email, token, "sortie/test")
authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(email+":"+token))
return newJiraClient(baseURL, authHeader, "sortie/test")
}

func assertClientTrackerErrorKind(t *testing.T, err error, want domain.TrackerErrorKind) {
Expand Down
Loading
Loading