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
54 changes: 44 additions & 10 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ Fields:
- `url` (string or null)
- `labels` (list of strings)
- Normalized to lowercase.
- `claim_lease` (object or null)
- Durable tracker comment marker parsed before dispatch when the tracker supports claim leases.
- Contains at minimum holder, comment ID, issue ID/key, refreshed timestamp, and expiration
timestamp.
- `blocked_by` (list of blocker refs)
- Each blocker ref contains:
- `id` (string or null)
Expand Down Expand Up @@ -349,15 +353,19 @@ Fields:

- `kind` (string)
- REQUIRED for dispatch.
- Current supported value: `linear`
- Current supported values: `linear`, `jira`
- `endpoint` (string)
- Default for `tracker.kind == "linear"`: `https://api.linear.app/graphql`
- REQUIRED for `tracker.kind == "jira"`; use the Jira Cloud site URL.
- `api_key` (string)
- MAY be a literal token or `$VAR_NAME`.
- Canonical environment variable for `tracker.kind == "linear"`: `LINEAR_API_KEY`.
- Canonical environment variables for `tracker.kind == "jira"`: `JIRA_API_TOKEN`, then
`JIRA_API_KEY`.
- If `$VAR_NAME` resolves to an empty string, treat the key as missing.
- `project_slug` (string)
- REQUIRED for dispatch when `tracker.kind == "linear"`.
- For `tracker.kind == "jira"`, this is the Jira project key and is REQUIRED for dispatch.
- `active_states` (list of strings)
- Default: `Todo`, `In Progress`
- `terminal_states` (list of strings)
Expand Down Expand Up @@ -570,10 +578,13 @@ This section is intentionally redundant so a coding agent can implement the conf
Extension fields are documented in the extension section that defines them. Core conformance does
not require recognizing or validating extension fields unless that extension is implemented.

- `tracker.kind`: string, REQUIRED, currently `linear`
- `tracker.endpoint`: string, default `https://api.linear.app/graphql` when `tracker.kind=linear`
- `tracker.api_key`: string or `$VAR`, canonical env `LINEAR_API_KEY` when `tracker.kind=linear`
- `tracker.project_slug`: string, REQUIRED when `tracker.kind=linear`
- `tracker.kind`: string, REQUIRED, currently `linear` or `jira`
- `tracker.endpoint`: string, default `https://api.linear.app/graphql` when `tracker.kind=linear`,
REQUIRED Jira Cloud site URL when `tracker.kind=jira`
- `tracker.api_key`: string or `$VAR`, canonical env `LINEAR_API_KEY` when `tracker.kind=linear`,
`JIRA_API_TOKEN`/`JIRA_API_KEY` when `tracker.kind=jira`
- `tracker.project_slug`: string, REQUIRED when `tracker.kind=linear`, Jira project key when
`tracker.kind=jira`
- `tracker.active_states`: list of strings, default `["Todo", "In Progress"]`
- `tracker.terminal_states`: list of strings, default `["Closed", "Cancelled", "Canceled", "Duplicate", "Done"]`
- `polling.interval_ms`: integer, default `30000`
Expand Down Expand Up @@ -1145,6 +1156,11 @@ An implementation MUST support these tracker adapter operations:
3. `fetch_issue_states_by_ids(issue_ids)`
- Used for active-run reconciliation.

4. `upsert_claim_lease(issue_id, lease_attrs)`
- Trackers that support durable comments MUST update the existing claim lease marker comment
when one is present, and create a marker comment only when missing.
- The operation SHOULD return the normalized marker data, including the durable comment ID.

### 11.2 Query Semantics (Linear)

Linear-specific requirements for `tracker.kind == "linear"`:
Expand All @@ -1167,6 +1183,18 @@ Important:
A non-Linear implementation MAY change transport details, but the normalized outputs MUST match the
domain model in Section 4.

### 11.2.1 Query Semantics (Jira)

Jira-specific requirements for `tracker.kind == "jira"`:

- `tracker.endpoint` is the Jira Cloud site URL; the REST API base is `/rest/api/3`.
- `tracker.project_slug` maps to the Jira project key.
- Candidate issue queries use Jira active status names through JQL.
- Issue comments are read before dispatch and claim lease markers are normalized into
`issue.claim_lease`.
- Claim lease upserts prefer `PUT /issue/{issueIdOrKey}/comment/{commentId}` over appending new
comments.

### 11.3 Normalization Rules

Candidate issue normalization SHOULD produce fields listed in Section 4.1.1.
Expand All @@ -1190,6 +1218,12 @@ RECOMMENDED error categories:
- `linear_graphql_errors`
- `linear_unknown_payload`
- `linear_missing_end_cursor` (pagination integrity error)
- `missing_jira_api_token`
- `missing_jira_endpoint`
- `missing_jira_project_key`
- `jira_api_request` (transport failures)
- `jira_api_status` (non-2xx HTTP)
- `jira_unknown_payload`

Orchestrator behavior on tracker errors:

Expand All @@ -1199,11 +1233,11 @@ Orchestrator behavior on tracker errors:

### 11.5 Tracker Writes (Important Boundary)

Symphony does not require first-class tracker write APIs in the orchestrator.
Symphony requires first-class tracker writes only for orchestration-owned claim lease markers.

- Ticket mutations (state transitions, comments, PR metadata) are typically handled by the coding
agent using tools defined by the workflow prompt.
- The service remains a scheduler/runner and tracker reader.
- The service remains a scheduler/runner and tracker reader outside of claim lease maintenance.
- Workflow-specific success often means "reached the next handoff state" (for example
`Human Review`) rather than tracker terminal state `Done`.
- If the `linear_graphql` client-side tool extension is implemented, it is still part of the agent
Expand Down Expand Up @@ -2083,6 +2117,7 @@ Use the same validation profiles as Section 17:
- Configurable retry backoff cap (`agent.max_retry_backoff_ms`, default 5m)
- Reconciliation that stops runs on terminal/non-active tracker states
- Workspace cleanup for terminal issues (startup sweep + active transition)
- Durable tracker comment claim leases for adapters that support them
- Structured logs with `issue_id`, `issue_identifier`, and `session_id`
- Operator-visible observability (structured logs; OPTIONAL snapshot/status surface)

Expand All @@ -2095,9 +2130,8 @@ Use the same validation profiles as Section 17:
- TODO: Persist retry queue and session metadata across process restarts.
- TODO: Make observability settings configurable in workflow front matter without prescribing UI
implementation details.
- TODO: Add first-class tracker write APIs (comments/state transitions) in the orchestrator instead
of only via agent tools.
- TODO: Add pluggable issue tracker adapters beyond Linear.
- TODO: Generalize first-class tracker write APIs beyond orchestration claim leases.
- TODO: Add more pluggable issue tracker adapters beyond Linear and Jira.

### 18.3 Operational Validation Before Production (RECOMMENDED)

Expand Down
14 changes: 13 additions & 1 deletion elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,19 @@ Notes:
`git clone ... .` there, along with any other setup commands you need.
- If a hook needs `mise exec` inside a freshly cloned workspace, trust the repo config and fetch
the project dependencies in `hooks.after_create` before invoking `mise` later from other hooks.
- `tracker.api_key` reads from `LINEAR_API_KEY` when unset or when value is `$LINEAR_API_KEY`.
- `tracker.kind` supports `linear`, `jira`, and `memory`.
- `tracker.api_key` reads from `LINEAR_API_KEY` for Linear and `JIRA_API_TOKEN`/`JIRA_API_KEY`
for Jira when unset or when configured as an env reference such as `$JIRA_API_TOKEN`.
- For Jira:
- `tracker.endpoint` is the Jira Cloud site URL, for example `https://example.atlassian.net`;
it may also be provided through `JIRA_ENDPOINT` or `JIRA_BASE_URL`.
- `tracker.project_slug` is the Jira project key.
- `tracker.active_states` and `tracker.terminal_states` are Jira status names.
- `tracker.api_key` may be a full `Bearer ...` or `Basic ...` Authorization value. Raw tokens
are sent as bearer tokens.
- Symphony reads and upserts a durable claim lease marker in issue comments before dispatching.
When the marker comment already exists, Jira comment edit APIs are used instead of appending
repeated heartbeat comments.
- For path values, `~` is expanded to the home directory.
- For env-backed path values, use `$VAR`. `workspace.root` resolves `$VAR` before path handling,
while `codex.command` stays a shell command string and any `$VAR` expansion there happens in the
Expand Down
34 changes: 21 additions & 13 deletions elixir/lib/symphony_elixir/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,29 @@ defmodule SymphonyElixir.Config do
end

defp validate_semantics(settings) do
cond do
is_nil(settings.tracker.kind) ->
{:error, :missing_tracker_kind}

settings.tracker.kind not in ["linear", "memory"] ->
{:error, {:unsupported_tracker_kind, settings.tracker.kind}}

settings.tracker.kind == "linear" and not is_binary(settings.tracker.api_key) ->
{:error, :missing_linear_api_token}
case settings.tracker.kind do
nil -> {:error, :missing_tracker_kind}
"linear" -> validate_linear_tracker(settings.tracker)
"memory" -> :ok
"jira" -> validate_jira_tracker(settings.tracker)
kind -> {:error, {:unsupported_tracker_kind, kind}}
end
end

settings.tracker.kind == "linear" and not is_binary(settings.tracker.project_slug) ->
{:error, :missing_linear_project_slug}
defp validate_linear_tracker(tracker) do
cond do
not is_binary(tracker.api_key) -> {:error, :missing_linear_api_token}
not is_binary(tracker.project_slug) -> {:error, :missing_linear_project_slug}
true -> :ok
end
end

true ->
:ok
defp validate_jira_tracker(tracker) do
cond do
not is_binary(tracker.api_key) -> {:error, :missing_jira_api_token}
not is_binary(tracker.endpoint) -> {:error, :missing_jira_endpoint}
not is_binary(tracker.project_slug) -> {:error, :missing_jira_project_key}
true -> :ok
end
end

Expand Down
30 changes: 29 additions & 1 deletion elixir/lib/symphony_elixir/config/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,8 @@ defmodule SymphonyElixir.Config.Schema do
defp finalize_settings(settings) do
tracker = %{
settings.tracker
| api_key: resolve_secret_setting(settings.tracker.api_key, System.get_env("LINEAR_API_KEY")),
| endpoint: resolve_tracker_endpoint(settings.tracker.kind, settings.tracker.endpoint),
api_key: resolve_secret_setting(settings.tracker.api_key, tracker_api_key_fallback(settings.tracker.kind)),
assignee: resolve_secret_setting(settings.tracker.assignee, System.get_env("LINEAR_ASSIGNEE"))
}

Expand Down Expand Up @@ -422,6 +423,33 @@ defmodule SymphonyElixir.Config.Schema do
end
end

defp resolve_tracker_endpoint("jira", value)
when value in [nil, "", "https://api.linear.app/graphql"] do
fallback = System.get_env("JIRA_ENDPOINT") || System.get_env("JIRA_BASE_URL")

normalize_secret_value(fallback)
end

defp resolve_tracker_endpoint("jira", value) when is_binary(value) do
fallback = System.get_env("JIRA_ENDPOINT") || System.get_env("JIRA_BASE_URL")

value
|> resolve_env_value(fallback)
|> normalize_secret_value()
end

defp resolve_tracker_endpoint(_kind, value) when is_binary(value) do
value
|> resolve_env_value(nil)
|> normalize_secret_value()
end

defp tracker_api_key_fallback("jira") do
System.get_env("JIRA_API_TOKEN") || System.get_env("JIRA_API_KEY")
end

defp tracker_api_key_fallback(_kind), do: System.get_env("LINEAR_API_KEY")

defp resolve_path_value(value, default) when is_binary(value) do
case normalize_path_token(value) do
:missing ->
Expand Down
32 changes: 32 additions & 0 deletions elixir/lib/symphony_elixir/jira/adapter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule SymphonyElixir.Jira.Adapter do
@moduledoc """
Jira-backed tracker adapter.
"""

@behaviour SymphonyElixir.Tracker

alias SymphonyElixir.Jira.Client

@spec fetch_candidate_issues() :: {:ok, [term()]} | {:error, term()}
def fetch_candidate_issues, do: client_module().fetch_candidate_issues()

@spec fetch_issues_by_states([String.t()]) :: {:ok, [term()]} | {:error, term()}
def fetch_issues_by_states(states), do: client_module().fetch_issues_by_states(states)

@spec fetch_issue_states_by_ids([String.t()]) :: {:ok, [term()]} | {:error, term()}
def fetch_issue_states_by_ids(issue_ids), do: client_module().fetch_issue_states_by_ids(issue_ids)

@spec create_comment(String.t(), String.t()) :: :ok | {:error, term()}
def create_comment(issue_id, body), do: client_module().create_comment(issue_id, body)

@spec upsert_claim_lease(String.t(), map()) ::
{:ok, SymphonyElixir.Tracker.ClaimLease.t() | nil} | {:error, term()}
def upsert_claim_lease(issue_id, lease_attrs), do: client_module().upsert_claim_lease(issue_id, lease_attrs)

@spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()}
def update_issue_state(issue_id, state_name), do: client_module().update_issue_state(issue_id, state_name)

defp client_module do
Application.get_env(:symphony_elixir, :jira_client_module, Client)
end
end
Loading
Loading