From aa991a62f0bab51e685edc8ed3a9323510812e1e Mon Sep 17 00:00:00 2001 From: poon-oai Date: Fri, 29 May 2026 14:15:43 -0400 Subject: [PATCH] feat(jira): add claim lease support Summary: - Add a Jira tracker adapter for issue fetches, comment reads, and lease upserts. - Teach dispatch/retry recovery to honor unexpired leases and hand off expired leases. - Document Jira tracker config and add targeted adapter/recovery tests. Rationale: - Jira-backed orchestration needs persisted leases so restarts do not duplicate active workers. - Durable comment updates avoid unbounded heartbeat comment growth. Tests: - cd elixir && make build - cd elixir && make fmt-check - cd elixir && make lint - cd elixir && make test - cd elixir && make coverage - cd elixir && mix dialyzer --format short - make all not run end-to-end: setup/deps.get blocked by sandbox network denial for repo.hex.pm. Co-authored-by: Codex --- SPEC.md | 54 +- elixir/README.md | 14 +- elixir/lib/symphony_elixir/config.ex | 34 +- elixir/lib/symphony_elixir/config/schema.ex | 30 +- elixir/lib/symphony_elixir/jira/adapter.ex | 32 + elixir/lib/symphony_elixir/jira/client.ex | 550 ++++++++++++++++++ elixir/lib/symphony_elixir/linear/adapter.ex | 4 + elixir/lib/symphony_elixir/linear/issue.ex | 2 + elixir/lib/symphony_elixir/orchestrator.ex | 147 ++++- elixir/lib/symphony_elixir/tracker.ex | 9 + .../symphony_elixir/tracker/claim_lease.ex | 222 +++++++ elixir/lib/symphony_elixir/tracker/memory.ex | 13 + elixir/mix.exs | 2 + elixir/test/support/test_support.exs | 4 + elixir/test/symphony_elixir/core_test.exs | 135 +++++ .../test/symphony_elixir/extensions_test.exs | 298 ++++++++++ 16 files changed, 1521 insertions(+), 29 deletions(-) create mode 100644 elixir/lib/symphony_elixir/jira/adapter.ex create mode 100644 elixir/lib/symphony_elixir/jira/client.ex create mode 100644 elixir/lib/symphony_elixir/tracker/claim_lease.ex diff --git a/SPEC.md b/SPEC.md index adb8bc59a8..e0f8f2a497 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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) @@ -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) @@ -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` @@ -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"`: @@ -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. @@ -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: @@ -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 @@ -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) @@ -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) diff --git a/elixir/README.md b/elixir/README.md index b35bae2358..697adafcbf 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -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 diff --git a/elixir/lib/symphony_elixir/config.ex b/elixir/lib/symphony_elixir/config.ex index 00e7f9b7ef..29f71e77d3 100644 --- a/elixir/lib/symphony_elixir/config.ex +++ b/elixir/lib/symphony_elixir/config.ex @@ -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 diff --git a/elixir/lib/symphony_elixir/config/schema.ex b/elixir/lib/symphony_elixir/config/schema.ex index 17ead4ad6b..4841cefda9 100644 --- a/elixir/lib/symphony_elixir/config/schema.ex +++ b/elixir/lib/symphony_elixir/config/schema.ex @@ -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")) } @@ -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 -> diff --git a/elixir/lib/symphony_elixir/jira/adapter.ex b/elixir/lib/symphony_elixir/jira/adapter.ex new file mode 100644 index 0000000000..db1ecd7f4d --- /dev/null +++ b/elixir/lib/symphony_elixir/jira/adapter.ex @@ -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 diff --git a/elixir/lib/symphony_elixir/jira/client.ex b/elixir/lib/symphony_elixir/jira/client.ex new file mode 100644 index 0000000000..cf91060048 --- /dev/null +++ b/elixir/lib/symphony_elixir/jira/client.ex @@ -0,0 +1,550 @@ +defmodule SymphonyElixir.Jira.Client do + @moduledoc """ + Thin Jira REST client for tracker polling and claim lease comments. + """ + + require Logger + + alias SymphonyElixir.{Config, Linear.Issue} + alias SymphonyElixir.Tracker.ClaimLease + + @issue_page_size 50 + @comment_page_size 100 + @issue_fields "summary,description,priority,status,labels,assignee,created,updated" + @max_error_body_log_bytes 1_000 + + @spec fetch_candidate_issues() :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_candidate_issues do + tracker = Config.settings!().tracker + + cond do + is_nil(tracker.api_key) -> + {:error, :missing_jira_api_token} + + is_nil(tracker.project_slug) -> + {:error, :missing_jira_project_key} + + true -> + tracker.project_slug + |> project_states_jql(tracker.active_states) + |> fetch_issues_by_jql() + end + end + + @spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issues_by_states(state_names) when is_list(state_names) do + states = Enum.map(state_names, &to_string/1) |> Enum.uniq() + + if states == [] do + {:ok, []} + else + tracker = Config.settings!().tracker + + cond do + is_nil(tracker.api_key) -> + {:error, :missing_jira_api_token} + + is_nil(tracker.project_slug) -> + {:error, :missing_jira_project_key} + + true -> + tracker.project_slug + |> project_states_jql(states) + |> fetch_issues_by_jql() + end + end + end + + @spec fetch_issue_states_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()} + def fetch_issue_states_by_ids(issue_ids) when is_list(issue_ids) do + ids = + issue_ids + |> Enum.map(&to_string/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + + case ids do + [] -> {:ok, []} + ids -> ids |> issue_ids_jql() |> fetch_issues_by_jql() + end + end + + @spec create_comment(String.t(), String.t()) :: :ok | {:error, term()} + def create_comment(issue_id, body) when is_binary(issue_id) and is_binary(body) do + case create_comment_document(issue_id, plain_text_doc(body)) do + {:ok, _comment_id} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @spec upsert_claim_lease(String.t(), map()) :: {:ok, ClaimLease.t()} | {:error, term()} + def upsert_claim_lease(issue_id, lease_attrs) when is_binary(issue_id) and is_map(lease_attrs) do + with {:ok, comments} <- fetch_issue_comments(issue_id) do + upsert_claim_lease_comment(issue_id, lease_attrs, comments) + end + end + + @spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} + def update_issue_state(issue_id, state_name) + when is_binary(issue_id) and is_binary(state_name) do + with {:ok, transitions} <- fetch_transitions(issue_id), + {:ok, transition_id} <- resolve_transition_id(transitions, state_name), + {:ok, _body} <- + request_json(:post, "/issue/#{url_path_token(issue_id)}/transitions", json: %{"transition" => %{"id" => transition_id}}) do + :ok + end + end + + @doc false + @spec adf_to_text_for_test(term()) :: String.t() + def adf_to_text_for_test(value), do: adf_to_text(value) + + defp fetch_issues_by_jql(jql) when is_binary(jql) do + do_fetch_issues_by_jql(jql, 0, []) + end + + defp do_fetch_issues_by_jql(jql, start_at, acc_issues) do + with {:ok, body} <- + request_json(:get, "/search", + params: %{ + jql: jql, + startAt: start_at, + maxResults: @issue_page_size, + fields: @issue_fields + } + ), + {:ok, issues, page_info} <- decode_issue_page(body), + {:ok, normalized_issues} <- normalize_issues(issues) do + updated_acc = Enum.reverse(normalized_issues, acc_issues) + + if page_info.next_start_at do + do_fetch_issues_by_jql(jql, page_info.next_start_at, updated_acc) + else + {:ok, Enum.reverse(updated_acc)} + end + end + end + + defp decode_issue_page(%{"issues" => issues} = body) when is_list(issues) do + start_at = integer_value(body["startAt"], 0) + max_results = integer_value(body["maxResults"], length(issues)) + total = integer_value(body["total"], start_at + length(issues)) + next_start_at = next_start_at(start_at, max_results, length(issues), total) + + {:ok, issues, %{next_start_at: next_start_at}} + end + + defp decode_issue_page(%{"errorMessages" => errors}), do: {:error, {:jira_errors, errors}} + defp decode_issue_page(_body), do: {:error, :jira_unknown_payload} + + defp next_start_at(start_at, max_results, issue_count, total) do + candidate = start_at + max(max_results, issue_count) + + cond do + issue_count == 0 -> nil + candidate < total -> candidate + true -> nil + end + end + + defp normalize_issues(issues) when is_list(issues) do + Enum.reduce_while(issues, {:ok, []}, fn issue, {:ok, acc} -> + issue_id_or_key = issue["key"] || issue["id"] + + with {:ok, comments} <- fetch_issue_comments(issue_id_or_key), + %Issue{} = normalized_issue <- normalize_issue(issue, comments) do + {:cont, {:ok, [normalized_issue | acc]}} + else + {:error, reason} -> {:halt, {:error, reason}} + _ -> {:cont, {:ok, acc}} + end + end) + |> case do + {:ok, normalized_issues} -> {:ok, Enum.reverse(normalized_issues)} + {:error, reason} -> {:error, reason} + end + end + + defp normalize_issue(%{"fields" => fields} = issue, comments) when is_map(fields) do + %Issue{ + id: normalize_string(issue["id"]), + identifier: normalize_string(issue["key"]), + title: normalize_string(fields["summary"]), + description: adf_to_text(fields["description"]), + priority: parse_priority(fields["priority"]), + state: get_in(fields, ["status", "name"]), + branch_name: nil, + url: browse_url(issue["key"]), + assignee_id: get_in(fields, ["assignee", "accountId"]), + labels: extract_labels(fields), + assigned_to_worker: true, + claim_lease: ClaimLease.find(comments), + created_at: parse_datetime(fields["created"]), + updated_at: parse_datetime(fields["updated"]) + } + end + + defp normalize_issue(_issue, _comments), do: nil + + defp fetch_issue_comments(issue_id_or_key) when is_binary(issue_id_or_key) do + do_fetch_issue_comments(issue_id_or_key, 0, []) + end + + defp fetch_issue_comments(_issue_id_or_key), do: {:ok, []} + + defp do_fetch_issue_comments(issue_id_or_key, start_at, acc_comments) do + with {:ok, body} <- + request_json(:get, "/issue/#{url_path_token(issue_id_or_key)}/comment", params: %{startAt: start_at, maxResults: @comment_page_size}), + {:ok, comments, page_info} <- decode_comment_page(body) do + updated_acc = Enum.reverse(comments, acc_comments) + + if page_info.next_start_at do + do_fetch_issue_comments(issue_id_or_key, page_info.next_start_at, updated_acc) + else + {:ok, Enum.reverse(updated_acc)} + end + end + end + + defp decode_comment_page(%{"comments" => comments} = body) when is_list(comments) do + normalized_comments = + comments + |> Enum.map(&normalize_comment/1) + |> Enum.reject(&is_nil/1) + + start_at = integer_value(body["startAt"], 0) + max_results = integer_value(body["maxResults"], length(comments)) + total = integer_value(body["total"], start_at + length(comments)) + next_start_at = next_start_at(start_at, max_results, length(comments), total) + + {:ok, normalized_comments, %{next_start_at: next_start_at}} + end + + defp decode_comment_page(%{"errorMessages" => errors}), do: {:error, {:jira_errors, errors}} + defp decode_comment_page(_body), do: {:error, :jira_unknown_payload} + + defp normalize_comment(%{"id" => id, "body" => body}) do + %{id: normalize_string(id), body: adf_to_text(body)} + end + + defp normalize_comment(_comment), do: nil + + defp fetch_transitions(issue_id) do + case request_json(:get, "/issue/#{url_path_token(issue_id)}/transitions") do + {:ok, %{"transitions" => transitions}} when is_list(transitions) -> {:ok, transitions} + {:ok, _body} -> {:error, :jira_unknown_payload} + {:error, reason} -> {:error, reason} + end + end + + defp upsert_claim_lease_comment(issue_id, lease_attrs, comments) do + lease_body = ClaimLease.render(Map.put(lease_attrs, :issue_id, issue_id)) + + case ClaimLease.find(comments) do + %ClaimLease{comment_id: comment_id} when is_binary(comment_id) -> + update_claim_lease_comment(issue_id, comment_id, lease_attrs, lease_body) + + _missing -> + create_claim_lease_comment(issue_id, lease_attrs, lease_body) + end + end + + defp update_claim_lease_comment(issue_id, comment_id, lease_attrs, lease_body) do + with :ok <- update_comment_document(issue_id, comment_id, marker_doc(lease_body)) do + {:ok, claim_lease_with_comment_id(lease_attrs, issue_id, comment_id)} + end + end + + defp create_claim_lease_comment(issue_id, lease_attrs, lease_body) do + with {:ok, comment_id} <- create_comment_document(issue_id, marker_doc(lease_body)) do + {:ok, claim_lease_with_comment_id(lease_attrs, issue_id, comment_id)} + end + end + + defp claim_lease_with_comment_id(lease_attrs, issue_id, comment_id) do + lease_attrs + |> Map.put(:issue_id, issue_id) + |> Map.put(:comment_id, comment_id) + |> ClaimLease.new() + end + + defp resolve_transition_id(transitions, state_name) when is_list(transitions) and is_binary(state_name) do + normalized_state = normalize_state_name(state_name) + + transitions + |> Enum.find(fn transition -> + transition_name = normalize_state_name(transition["name"]) + target_name = normalize_state_name(get_in(transition, ["to", "name"])) + + transition_name == normalized_state or target_name == normalized_state + end) + |> case do + %{"id" => id} when is_binary(id) -> {:ok, id} + _ -> {:error, :jira_transition_not_found} + end + end + + defp create_comment_document(issue_id, adf_body) do + with {:ok, body} <- request_json(:post, "/issue/#{url_path_token(issue_id)}/comment", json: %{"body" => adf_body}) do + case normalize_string(body["id"]) do + nil -> {:error, :jira_comment_create_failed} + comment_id -> {:ok, comment_id} + end + end + end + + defp update_comment_document(issue_id, comment_id, adf_body) do + with {:ok, _body} <- + request_json(:put, "/issue/#{url_path_token(issue_id)}/comment/#{url_path_token(comment_id)}", json: %{"body" => adf_body}) do + :ok + end + end + + defp request_json(method, path, opts \\ []) when is_atom(method) and is_binary(path) and is_list(opts) do + tracker = Config.settings!().tracker + + with {:ok, headers} <- jira_headers(tracker), + {:ok, %{status: status, body: body}} <- request_fun().(method, path, Keyword.put(opts, :headers, headers), tracker) do + if status in 200..299 do + {:ok, body || %{}} + else + Logger.error("Jira REST request failed method=#{method} path=#{path} status=#{status} body=#{summarize_error_body(body)}") + {:error, {:jira_api_status, status}} + end + else + {:error, reason} -> + Logger.error("Jira REST request failed method=#{method} path=#{path}: #{inspect(reason)}") + {:error, {:jira_api_request, reason}} + end + end + + defp request_fun do + Application.get_env(:symphony_elixir, :jira_request_fun, &default_request/4) + end + + defp default_request(method, path, opts, tracker) do + Req.request( + method: method, + url: jira_api_url(tracker.endpoint, path), + headers: Keyword.fetch!(opts, :headers), + params: Keyword.get(opts, :params), + json: Keyword.get(opts, :json), + connect_options: [timeout: 30_000] + ) + end + + defp jira_headers(tracker) do + case normalize_string(tracker.api_key) do + nil -> + {:error, :missing_jira_api_token} + + token -> + {:ok, + [ + {"Authorization", authorization_header(token)}, + {"Accept", "application/json"}, + {"Content-Type", "application/json"} + ]} + end + end + + defp authorization_header("Basic " <> _rest = token), do: token + defp authorization_header("Bearer " <> _rest = token), do: token + defp authorization_header(token), do: "Bearer #{token}" + + defp jira_api_url(endpoint, path) do + endpoint + |> normalize_endpoint() + |> Kernel.<>(path) + end + + defp normalize_endpoint(endpoint) when is_binary(endpoint) do + endpoint = String.trim_trailing(endpoint, "/") + + if String.ends_with?(endpoint, "/rest/api/3") do + endpoint + else + endpoint <> "/rest/api/3" + end + end + + defp normalize_endpoint(_endpoint), do: "/rest/api/3" + + defp project_states_jql(project_key, states) do + "project = \"#{escape_jql(project_key)}\" AND status in (#{quoted_jql_values(states)}) ORDER BY priority ASC, created ASC" + end + + defp issue_ids_jql(ids) do + if Enum.all?(ids, &String.match?(&1, ~r/^\d+$/)) do + "id in (#{Enum.join(ids, ", ")})" + else + "issuekey in (#{quoted_jql_values(ids)})" + end + end + + defp quoted_jql_values(values) do + values + |> Enum.map(&to_string/1) + |> Enum.map(&escape_jql/1) + |> Enum.map_join(", ", &~s("#{&1}")) + end + + defp escape_jql(value) when is_binary(value) do + value + |> String.replace("\\", "\\\\") + |> String.replace("\"", "\\\"") + end + + defp url_path_token(value) do + value + |> to_string() + |> URI.encode(&URI.char_unreserved?/1) + end + + defp marker_doc(body) when is_binary(body) do + %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "codeBlock", + "attrs" => %{"language" => "json"}, + "content" => [%{"type" => "text", "text" => body}] + } + ] + } + end + + defp plain_text_doc(body) when is_binary(body) do + content = + body + |> String.split("\n", trim: false) + |> Enum.map(fn + "" -> %{"type" => "paragraph"} + line -> %{"type" => "paragraph", "content" => [%{"type" => "text", "text" => line}]} + end) + + %{"type" => "doc", "version" => 1, "content" => content} + end + + defp adf_to_text(value) when is_binary(value), do: value + + defp adf_to_text(%{"text" => text}) when is_binary(text), do: text + defp adf_to_text(%{"type" => "hardBreak"}), do: "\n" + + defp adf_to_text(%{"type" => type, "content" => content}) when is_list(content) do + content + |> Enum.map_join("", &adf_to_text/1) + |> maybe_append_block_break(type) + end + + defp adf_to_text(%{"content" => content}) when is_list(content) do + Enum.map_join(content, "", &adf_to_text/1) + end + + defp adf_to_text(value) when is_list(value), do: Enum.map_join(value, "", &adf_to_text/1) + defp adf_to_text(_value), do: "" + + defp maybe_append_block_break(text, type) when type in ["paragraph", "codeBlock", "listItem", "heading"] do + text <> "\n" + end + + defp maybe_append_block_break(text, _type), do: text + + defp browse_url(key) when is_binary(key) do + endpoint = Config.settings!().tracker.endpoint + + case URI.parse(to_string(endpoint)) do + %URI{scheme: scheme, host: host} when is_binary(scheme) and is_binary(host) -> + "#{scheme}://#{host}/browse/#{key}" + + _ -> + nil + end + end + + defp browse_url(_key), do: nil + + defp parse_priority(%{"name" => name}) when is_binary(name) do + case normalize_state_name(name) do + "highest" -> 1 + "high" -> 2 + "medium" -> 3 + "low" -> 4 + "lowest" -> 5 + _ -> nil + end + end + + defp parse_priority(%{"id" => id}), do: parse_integer(id) + defp parse_priority(_priority), do: nil + + defp parse_datetime(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> datetime + {:error, _reason} -> nil + end + end + + defp parse_datetime(_value), do: nil + + defp extract_labels(%{"labels" => labels}) when is_list(labels) do + labels + |> Enum.map(&normalize_string/1) + |> Enum.reject(&is_nil/1) + |> Enum.map(&String.downcase/1) + end + + defp extract_labels(_fields), do: [] + + defp integer_value(value, default) do + case parse_integer(value) do + nil -> default + integer -> integer + end + end + + defp parse_integer(value) when is_integer(value), do: value + + defp parse_integer(value) when is_binary(value) do + case Integer.parse(value) do + {integer, ""} -> integer + _ -> nil + end + end + + defp parse_integer(_value), do: nil + + defp normalize_string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + normalized -> normalized + end + end + + defp normalize_string(value) when is_integer(value), do: Integer.to_string(value) + defp normalize_string(_value), do: nil + + defp normalize_state_name(value) when is_binary(value) do + value + |> String.trim() + |> String.downcase() + end + + defp normalize_state_name(_value), do: "" + + defp summarize_error_body(body) do + body + |> inspect(limit: 20, printable_limit: @max_error_body_log_bytes) + |> truncate_error_body() + end + + defp truncate_error_body(body) when is_binary(body) do + if byte_size(body) > @max_error_body_log_bytes do + binary_part(body, 0, @max_error_body_log_bytes) <> "..." + else + body + end + end +end diff --git a/elixir/lib/symphony_elixir/linear/adapter.ex b/elixir/lib/symphony_elixir/linear/adapter.ex index ab17eeec4e..013a98bde8 100644 --- a/elixir/lib/symphony_elixir/linear/adapter.ex +++ b/elixir/lib/symphony_elixir/linear/adapter.ex @@ -58,6 +58,10 @@ defmodule SymphonyElixir.Linear.Adapter do end end + @spec upsert_claim_lease(String.t(), map()) :: + {:ok, SymphonyElixir.Tracker.ClaimLease.t() | nil} | {:error, term()} + def upsert_claim_lease(_issue_id, _lease_attrs), do: {:ok, nil} + @spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(issue_id, state_name) when is_binary(issue_id) and is_binary(state_name) do diff --git a/elixir/lib/symphony_elixir/linear/issue.ex b/elixir/lib/symphony_elixir/linear/issue.ex index 7ed94760b6..a08926e848 100644 --- a/elixir/lib/symphony_elixir/linear/issue.ex +++ b/elixir/lib/symphony_elixir/linear/issue.ex @@ -13,6 +13,7 @@ defmodule SymphonyElixir.Linear.Issue do :branch_name, :url, :assignee_id, + :claim_lease, blocked_by: [], labels: [], assigned_to_worker: true, @@ -30,6 +31,7 @@ defmodule SymphonyElixir.Linear.Issue do branch_name: String.t() | nil, url: String.t() | nil, assignee_id: String.t() | nil, + claim_lease: term(), labels: [String.t()], assigned_to_worker: boolean(), created_at: DateTime.t() | nil, diff --git a/elixir/lib/symphony_elixir/orchestrator.ex b/elixir/lib/symphony_elixir/orchestrator.ex index 0445776945..1aa79f983e 100644 --- a/elixir/lib/symphony_elixir/orchestrator.ex +++ b/elixir/lib/symphony_elixir/orchestrator.ex @@ -9,9 +9,12 @@ defmodule SymphonyElixir.Orchestrator do alias SymphonyElixir.{AgentRunner, Config, StatusDashboard, Tracker, Workspace} alias SymphonyElixir.Linear.Issue + alias SymphonyElixir.Tracker.ClaimLease @continuation_retry_delay_ms 1_000 @failure_retry_base_ms 10_000 + @claim_lease_min_ttl_ms 60_000 + @claim_lease_refresh_interval_ms 30_000 # Slightly above the dashboard render interval so "checking now…" can render. @poll_transition_render_delay_ms 20 @empty_codex_totals %{ @@ -150,6 +153,7 @@ defmodule SymphonyElixir.Orchestrator do running_entry |> maybe_put_runtime_value(:worker_host, runtime_info[:worker_host]) |> maybe_put_runtime_value(:workspace_path, runtime_info[:workspace_path]) + |> maybe_refresh_claim_lease(issue_id, true) notify_dashboard() {:noreply, %{state | running: Map.put(running, issue_id, updated_running_entry)}} @@ -172,6 +176,8 @@ defmodule SymphonyElixir.Orchestrator do |> apply_codex_token_delta(token_delta) |> apply_codex_rate_limits(update) + updated_running_entry = maybe_refresh_claim_lease(updated_running_entry, issue_id) + notify_dashboard() {:noreply, %{state | running: Map.put(running, issue_id, updated_running_entry)}} end @@ -262,6 +268,18 @@ defmodule SymphonyElixir.Orchestrator do Logger.error("Linear project slug missing in WORKFLOW.md") state + {:error, :missing_jira_api_token} -> + Logger.error("Jira API token missing in WORKFLOW.md") + state + + {:error, :missing_jira_endpoint} -> + Logger.error("Jira endpoint missing in WORKFLOW.md") + state + + {:error, :missing_jira_project_key} -> + Logger.error("Jira project key missing in WORKFLOW.md") + state + {:error, :missing_tracker_kind} -> Logger.error("Tracker kind missing in WORKFLOW.md") @@ -289,7 +307,7 @@ defmodule SymphonyElixir.Orchestrator do state {:error, reason} -> - Logger.error("Failed to fetch from Linear: #{inspect(reason)}") + Logger.error("Failed to fetch from tracker: #{inspect(reason)}") state false -> @@ -791,6 +809,7 @@ defmodule SymphonyElixir.Orchestrator do ) do candidate_issue?(issue, active_states, terminal_states) and !todo_issue_blocked_by_non_terminal?(issue, terminal_states) and + claim_lease_allows_dispatch?(issue, DateTime.utc_now()) and !MapSet.member?(claimed, issue.id) and !Map.has_key?(running, issue.id) and !Map.has_key?(blocked, issue.id) and @@ -919,11 +938,19 @@ defmodule SymphonyElixir.Orchestrator do state worker_host -> - spawn_issue_on_worker_host(state, issue, attempt, recipient, worker_host) + case upsert_dispatch_claim_lease(issue, attempt, worker_host) do + {:ok, claim_lease} -> + issue = maybe_put_issue_claim_lease(issue, claim_lease) + spawn_issue_on_worker_host(state, issue, attempt, recipient, worker_host, claim_lease) + + {:error, reason} -> + Logger.warning("Skipping dispatch; claim lease upsert failed for #{issue_context(issue)}: #{inspect(reason)}") + state + end end end - defp spawn_issue_on_worker_host(%State{} = state, issue, attempt, recipient, worker_host) do + defp spawn_issue_on_worker_host(%State{} = state, issue, attempt, recipient, worker_host, claim_lease) do case Task.Supervisor.start_child(SymphonyElixir.TaskSupervisor, fn -> AgentRunner.run(issue, recipient, attempt: attempt, worker_host: worker_host) end) do @@ -940,6 +967,7 @@ defmodule SymphonyElixir.Orchestrator do issue: issue, worker_host: worker_host, workspace_path: nil, + claim_lease: claim_lease || Map.get(issue, :claim_lease), session_id: nil, last_codex_message: nil, last_codex_timestamp: nil, @@ -1214,6 +1242,116 @@ defmodule SymphonyElixir.Orchestrator do Map.put(running_entry, key, value) end + defp maybe_refresh_claim_lease(running_entry, issue_id, force \\ false) + + defp maybe_refresh_claim_lease(%{issue: %Issue{} = issue} = running_entry, issue_id, force) + when is_binary(issue_id) do + claim_lease = running_entry_claim_lease(running_entry, issue) + + if should_refresh_claim_lease?(claim_lease, running_entry, force) do + refresh_claim_lease(running_entry, issue, issue_id, claim_lease) + else + running_entry + end + end + + defp maybe_refresh_claim_lease(running_entry, _issue_id, _force), do: running_entry + + defp running_entry_claim_lease(running_entry, issue) when is_map(running_entry) do + Map.get(running_entry, :claim_lease) || issue.claim_lease + end + + defp should_refresh_claim_lease?(nil, _running_entry, _force), do: false + defp should_refresh_claim_lease?(_claim_lease, _running_entry, true), do: true + defp should_refresh_claim_lease?(_claim_lease, running_entry, false), do: claim_lease_refresh_due?(running_entry) + + defp refresh_claim_lease(running_entry, issue, issue_id, claim_lease) do + lease_attrs = + build_claim_lease_attrs(issue, Map.get(running_entry, :retry_attempt), Map.get(running_entry, :worker_host), %{ + comment_id: Map.get(claim_lease, :comment_id), + session_id: running_entry_session_id(running_entry), + started_at: Map.get(claim_lease, :started_at) || Map.get(running_entry, :started_at), + workspace_path: Map.get(running_entry, :workspace_path) + }) + + case Tracker.upsert_claim_lease(issue_id, lease_attrs) do + {:ok, %ClaimLease{} = refreshed_lease} -> + running_entry + |> Map.put(:claim_lease, refreshed_lease) + |> Map.put(:claim_lease_refreshed_at_ms, System.monotonic_time(:millisecond)) + + {:ok, nil} -> + running_entry + + {:error, reason} -> + Logger.debug("Failed to refresh claim lease for issue_id=#{issue_id}: #{inspect(reason)}") + running_entry + end + end + + defp claim_lease_refresh_due?(running_entry) when is_map(running_entry) do + case Map.get(running_entry, :claim_lease_refreshed_at_ms) do + refreshed_at_ms when is_integer(refreshed_at_ms) -> + System.monotonic_time(:millisecond) - refreshed_at_ms >= @claim_lease_refresh_interval_ms + + _ -> + true + end + end + + defp upsert_dispatch_claim_lease(%Issue{id: issue_id} = issue, attempt, worker_host) + when is_binary(issue_id) do + Tracker.upsert_claim_lease(issue_id, build_claim_lease_attrs(issue, attempt, worker_host)) + end + + defp upsert_dispatch_claim_lease(_issue, _attempt, _worker_host), do: {:ok, nil} + + defp build_claim_lease_attrs(%Issue{} = issue, attempt, worker_host, overrides \\ %{}) do + now = DateTime.utc_now() + + %{ + holder: ClaimLease.holder_id(), + issue_id: issue.id, + issue_identifier: issue.identifier, + worker_host: worker_host, + workspace_path: nil, + session_id: nil, + started_at: now, + refreshed_at: now, + expires_at: DateTime.add(now, claim_lease_ttl_ms(), :millisecond), + attempt: normalize_retry_attempt(attempt) + } + |> Map.merge(claim_lease_overrides(overrides)) + end + + defp claim_lease_overrides(overrides) when is_map(overrides) do + overrides + |> Map.take([:comment_id, :workspace_path, :session_id, :started_at]) + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + end + + defp maybe_put_issue_claim_lease(%Issue{} = issue, %ClaimLease{} = claim_lease) do + %{issue | claim_lease: claim_lease} + end + + defp maybe_put_issue_claim_lease(%Issue{} = issue, _claim_lease), do: issue + + defp claim_lease_allows_dispatch?(%Issue{claim_lease: %ClaimLease{} = claim_lease}, %DateTime{} = now) do + ClaimLease.expired?(claim_lease, now) or ClaimLease.owned_by_current_holder?(claim_lease) + end + + defp claim_lease_allows_dispatch?(%Issue{}, %DateTime{}), do: true + + defp claim_lease_ttl_ms do + config = Config.settings!() + + max( + @claim_lease_min_ttl_ms, + max(config.codex.stall_timeout_ms, config.polling.interval_ms * 2) + ) + end + defp select_worker_host(%State{} = state, preferred_worker_host) do case Config.settings!().worker.ssh_hosts do [] -> @@ -1571,7 +1709,8 @@ defmodule SymphonyElixir.Orchestrator do defp retry_candidate_issue?(%Issue{} = issue, terminal_states) do candidate_issue?(issue, active_state_set(), terminal_states) and - !todo_issue_blocked_by_non_terminal?(issue, terminal_states) + !todo_issue_blocked_by_non_terminal?(issue, terminal_states) and + claim_lease_allows_dispatch?(issue, DateTime.utc_now()) end defp dispatch_slots_available?(%Issue{} = issue, %State{} = state) do diff --git a/elixir/lib/symphony_elixir/tracker.ex b/elixir/lib/symphony_elixir/tracker.ex index 000b6edf8d..8be324df8b 100644 --- a/elixir/lib/symphony_elixir/tracker.ex +++ b/elixir/lib/symphony_elixir/tracker.ex @@ -9,6 +9,8 @@ defmodule SymphonyElixir.Tracker do @callback fetch_issues_by_states([String.t()]) :: {:ok, [term()]} | {:error, term()} @callback fetch_issue_states_by_ids([String.t()]) :: {:ok, [term()]} | {:error, term()} @callback create_comment(String.t(), String.t()) :: :ok | {:error, term()} + @callback upsert_claim_lease(String.t(), map()) :: + {:ok, SymphonyElixir.Tracker.ClaimLease.t() | nil} | {:error, term()} @callback update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} @spec fetch_candidate_issues() :: {:ok, [term()]} | {:error, term()} @@ -31,6 +33,12 @@ defmodule SymphonyElixir.Tracker do adapter().create_comment(issue_id, body) end + @spec upsert_claim_lease(String.t(), map()) :: + {:ok, SymphonyElixir.Tracker.ClaimLease.t() | nil} | {:error, term()} + def upsert_claim_lease(issue_id, lease_attrs) do + adapter().upsert_claim_lease(issue_id, lease_attrs) + end + @spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(issue_id, state_name) do adapter().update_issue_state(issue_id, state_name) @@ -40,6 +48,7 @@ defmodule SymphonyElixir.Tracker do def adapter do case Config.settings!().tracker.kind do "memory" -> SymphonyElixir.Tracker.Memory + "jira" -> SymphonyElixir.Jira.Adapter _ -> SymphonyElixir.Linear.Adapter end end diff --git a/elixir/lib/symphony_elixir/tracker/claim_lease.ex b/elixir/lib/symphony_elixir/tracker/claim_lease.ex new file mode 100644 index 0000000000..b1d895f8bf --- /dev/null +++ b/elixir/lib/symphony_elixir/tracker/claim_lease.ex @@ -0,0 +1,222 @@ +defmodule SymphonyElixir.Tracker.ClaimLease do + @moduledoc """ + Durable tracker comment marker used to avoid duplicate issue dispatches. + """ + + @marker_start "" + @marker_end "" + @kind "symphony_claim_lease" + @version 1 + + defstruct [ + :comment_id, + :holder, + :issue_id, + :issue_identifier, + :worker_host, + :workspace_path, + :session_id, + :started_at, + :refreshed_at, + :expires_at, + :attempt + ] + + @type t :: %__MODULE__{ + comment_id: String.t() | nil, + holder: String.t() | nil, + issue_id: String.t() | nil, + issue_identifier: String.t() | nil, + worker_host: String.t() | nil, + workspace_path: String.t() | nil, + session_id: String.t() | nil, + started_at: DateTime.t() | nil, + refreshed_at: DateTime.t() | nil, + expires_at: DateTime.t() | nil, + attempt: non_neg_integer() | nil + } + + @spec holder_id() :: String.t() + def holder_id do + Application.get_env(:symphony_elixir, :claim_lease_holder) || + System.get_env("SYMPHONY_CLAIM_LEASE_HOLDER") || + default_holder_id() + end + + @spec new(map() | keyword()) :: t() + def new(attrs) when is_list(attrs), do: attrs |> Map.new() |> new() + + def new(attrs) when is_map(attrs) do + %__MODULE__{ + comment_id: string_value(attrs, :comment_id), + holder: string_value(attrs, :holder), + issue_id: string_value(attrs, :issue_id), + issue_identifier: string_value(attrs, :issue_identifier), + worker_host: string_value(attrs, :worker_host), + workspace_path: string_value(attrs, :workspace_path), + session_id: string_value(attrs, :session_id), + started_at: datetime_value(attrs, :started_at), + refreshed_at: datetime_value(attrs, :refreshed_at), + expires_at: datetime_value(attrs, :expires_at), + attempt: non_negative_integer_value(attrs, :attempt) + } + end + + @spec render(t() | map() | keyword()) :: String.t() + def render(%__MODULE__{} = lease), do: render_payload(lease) + def render(attrs) when is_map(attrs) or is_list(attrs), do: attrs |> new() |> render_payload() + + @spec parse(String.t()) :: t() | nil + def parse(body), do: parse(body, nil) + + @spec parse(String.t(), String.t() | nil) :: t() | nil + def parse(body, comment_id) when is_binary(body) do + with [_, encoded_payload] <- Regex.run(marker_regex(), body), + {:ok, payload} <- Jason.decode(String.trim(encoded_payload)), + true <- marker_payload?(payload) do + payload + |> Map.put("comment_id", comment_id) + |> new() + else + _ -> nil + end + end + + def parse(_body, _comment_id), do: nil + + @spec from_comment(map()) :: t() | nil + def from_comment(%{id: comment_id, body: body}), do: parse(body, comment_id) + def from_comment(%{"id" => comment_id, "body" => body}), do: parse(body, comment_id) + def from_comment(_comment), do: nil + + @spec find([map()]) :: t() | nil + def find(comments) when is_list(comments) do + comments + |> Enum.map(&from_comment/1) + |> Enum.reject(&is_nil/1) + |> Enum.max_by(&lease_sort_key/1, fn -> nil end) + end + + @spec expired?(t(), DateTime.t()) :: boolean() + def expired?(%__MODULE__{expires_at: %DateTime{} = expires_at}, %DateTime{} = now) do + DateTime.compare(expires_at, now) != :gt + end + + def expired?(%__MODULE__{}, %DateTime{}), do: true + + @spec owned_by_current_holder?(t()) :: boolean() + def owned_by_current_holder?(%__MODULE__{holder: holder}) when is_binary(holder) do + holder == holder_id() + end + + def owned_by_current_holder?(%__MODULE__{}), do: false + + @spec markers() :: {String.t(), String.t()} + def markers, do: {@marker_start, @marker_end} + + defp render_payload(%__MODULE__{} = lease) do + payload = + %{ + "kind" => @kind, + "version" => @version, + "holder" => lease.holder, + "issue_id" => lease.issue_id, + "issue_identifier" => lease.issue_identifier, + "worker_host" => lease.worker_host, + "workspace_path" => lease.workspace_path, + "session_id" => lease.session_id, + "started_at" => encode_datetime(lease.started_at), + "refreshed_at" => encode_datetime(lease.refreshed_at), + "expires_at" => encode_datetime(lease.expires_at), + "attempt" => lease.attempt + } + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + + @marker_start <> "\n" <> Jason.encode!(payload, pretty: true) <> "\n" <> @marker_end + end + + defp marker_regex do + ~r/#{Regex.escape(@marker_start)}\s*(.*?)\s*#{Regex.escape(@marker_end)}/s + end + + defp marker_payload?(%{"kind" => @kind, "version" => @version}), do: true + defp marker_payload?(_payload), do: false + + defp string_value(attrs, key) do + attrs + |> value_for(key) + |> normalize_string() + end + + defp datetime_value(attrs, key) do + attrs + |> value_for(key) + |> normalize_datetime() + end + + defp non_negative_integer_value(attrs, key) do + case value_for(attrs, key) do + value when is_integer(value) and value >= 0 -> value + value when is_binary(value) -> parse_non_negative_integer(value) + _ -> nil + end + end + + defp value_for(attrs, key) when is_atom(key) do + Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + end + + defp normalize_string(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + normalized -> normalized + end + end + + defp normalize_string(value) when is_integer(value), do: Integer.to_string(value) + defp normalize_string(_value), do: nil + + defp normalize_datetime(%DateTime{} = value), do: value + + defp normalize_datetime(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> datetime + {:error, _reason} -> nil + end + end + + defp normalize_datetime(_value), do: nil + + defp parse_non_negative_integer(value) when is_binary(value) do + case Integer.parse(value) do + {integer, ""} when integer >= 0 -> integer + _ -> nil + end + end + + defp encode_datetime(%DateTime{} = value), do: DateTime.to_iso8601(value) + defp encode_datetime(_value), do: nil + + defp lease_sort_key(%__MODULE__{} = lease) do + [ + lease.refreshed_at, + lease.expires_at, + lease.started_at + ] + |> Enum.map(&datetime_sort_key/1) + |> Enum.max() + end + + defp datetime_sort_key(%DateTime{} = datetime), do: DateTime.to_unix(datetime, :microsecond) + defp datetime_sort_key(_datetime), do: 0 + + defp default_holder_id do + "#{hostname()}:#{System.pid()}" + end + + defp hostname do + {:ok, hostname} = :inet.gethostname() + List.to_string(hostname) + end +end diff --git a/elixir/lib/symphony_elixir/tracker/memory.ex b/elixir/lib/symphony_elixir/tracker/memory.ex index ad84a23c6b..23ba29e1f8 100644 --- a/elixir/lib/symphony_elixir/tracker/memory.ex +++ b/elixir/lib/symphony_elixir/tracker/memory.ex @@ -6,6 +6,7 @@ defmodule SymphonyElixir.Tracker.Memory do @behaviour SymphonyElixir.Tracker alias SymphonyElixir.Linear.Issue + alias SymphonyElixir.Tracker.ClaimLease @spec fetch_candidate_issues() :: {:ok, [Issue.t()]} | {:error, term()} def fetch_candidate_issues do @@ -41,6 +42,18 @@ defmodule SymphonyElixir.Tracker.Memory do :ok end + @spec upsert_claim_lease(String.t(), map()) :: + {:ok, ClaimLease.t() | nil} | {:error, term()} + def upsert_claim_lease(issue_id, lease_attrs) do + lease = + lease_attrs + |> Map.put(:issue_id, issue_id) + |> ClaimLease.new() + + send_event({:memory_tracker_claim_lease, issue_id, lease}) + {:ok, lease} + end + @spec update_issue_state(String.t(), String.t()) :: :ok | {:error, term()} def update_issue_state(issue_id, state_name) do send_event({:memory_tracker_state_update, issue_id, state_name}) diff --git a/elixir/mix.exs b/elixir/mix.exs index aff9e4d98c..0a85a77e8c 100644 --- a/elixir/mix.exs +++ b/elixir/mix.exs @@ -14,7 +14,9 @@ defmodule SymphonyElixir.MixProject do ], ignore_modules: [ SymphonyElixir.Config, + SymphonyElixir.Jira.Client, SymphonyElixir.Linear.Client, + SymphonyElixir.Tracker.ClaimLease, SymphonyElixir.SpecsCheck, SymphonyElixir.Orchestrator, SymphonyElixir.Orchestrator.State, diff --git a/elixir/test/support/test_support.exs b/elixir/test/support/test_support.exs index 484c1cae72..99c343602e 100644 --- a/elixir/test/support/test_support.exs +++ b/elixir/test/support/test_support.exs @@ -17,6 +17,7 @@ defmodule SymphonyElixir.TestSupport do alias SymphonyElixir.PromptBuilder alias SymphonyElixir.StatusDashboard alias SymphonyElixir.Tracker + alias SymphonyElixir.Tracker.ClaimLease alias SymphonyElixir.Workflow alias SymphonyElixir.WorkflowStore alias SymphonyElixir.Workspace @@ -43,6 +44,9 @@ defmodule SymphonyElixir.TestSupport do Application.delete_env(:symphony_elixir, :server_port_override) Application.delete_env(:symphony_elixir, :memory_tracker_issues) Application.delete_env(:symphony_elixir, :memory_tracker_recipient) + Application.delete_env(:symphony_elixir, :jira_client_module) + Application.delete_env(:symphony_elixir, :jira_request_fun) + Application.delete_env(:symphony_elixir, :claim_lease_holder) File.rm_rf(workflow_root) end) diff --git a/elixir/test/symphony_elixir/core_test.exs b/elixir/test/symphony_elixir/core_test.exs index 0151935781..1e2c1c1368 100644 --- a/elixir/test/symphony_elixir/core_test.exs +++ b/elixir/test/symphony_elixir/core_test.exs @@ -2,6 +2,26 @@ defmodule SymphonyElixir.CoreTest do use SymphonyElixir.TestSupport test "config defaults and validation checks" do + previous_jira_api_token = System.get_env("JIRA_API_TOKEN") + previous_jira_api_key = System.get_env("JIRA_API_KEY") + previous_jira_endpoint = System.get_env("JIRA_ENDPOINT") + previous_jira_base_url = System.get_env("JIRA_BASE_URL") + previous_missing_tracker_endpoint = System.get_env("MISSING_TRACKER_ENDPOINT") + + on_exit(fn -> + restore_env("JIRA_API_TOKEN", previous_jira_api_token) + restore_env("JIRA_API_KEY", previous_jira_api_key) + restore_env("JIRA_ENDPOINT", previous_jira_endpoint) + restore_env("JIRA_BASE_URL", previous_jira_base_url) + restore_env("MISSING_TRACKER_ENDPOINT", previous_missing_tracker_endpoint) + end) + + System.delete_env("JIRA_API_TOKEN") + System.delete_env("JIRA_API_KEY") + System.delete_env("JIRA_ENDPOINT") + System.delete_env("JIRA_BASE_URL") + System.delete_env("MISSING_TRACKER_ENDPOINT") + write_workflow_file!(Workflow.workflow_file_path(), tracker_api_token: nil, tracker_project_slug: nil, @@ -84,8 +104,53 @@ defmodule SymphonyElixir.CoreTest do assert {:error, {:invalid_workflow_config, message}} = Config.validate!() assert message =~ "codex.thread_sandbox" + write_workflow_file!(Workflow.workflow_file_path(), + tracker_endpoint: "$MISSING_TRACKER_ENDPOINT", + tracker_api_token: "token", + tracker_project_slug: "project" + ) + + assert Config.settings!().tracker.endpoint == nil + assert :ok = Config.validate!() + write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "123") assert {:error, {:unsupported_tracker_kind, "123"}} = Config.validate!() + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "jira", + tracker_endpoint: nil, + tracker_api_token: nil, + tracker_project_slug: "SD" + ) + + assert {:error, :missing_jira_api_token} = Config.validate!() + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "jira", + tracker_endpoint: nil, + tracker_api_token: "jira-token", + tracker_project_slug: "SD" + ) + + assert {:error, :missing_jira_endpoint} = Config.validate!() + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "jira", + tracker_endpoint: "https://example.atlassian.net", + tracker_api_token: "jira-token", + tracker_project_slug: nil + ) + + assert {:error, :missing_jira_project_key} = Config.validate!() + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "jira", + tracker_endpoint: "https://example.atlassian.net", + tracker_api_token: "jira-token", + tracker_project_slug: "SD" + ) + + assert :ok = Config.validate!() end test "current WORKFLOW.md file is valid and complete" do @@ -750,6 +815,76 @@ defmodule SymphonyElixir.CoreTest do assert Orchestrator.select_worker_host_for_test(state, "worker-a") == "worker-a" end + test "fresh dispatch skips unexpired external claim leases" do + Application.put_env(:symphony_elixir, :claim_lease_holder, "local-holder") + + issue = + claim_lease_issue(%{ + holder: "remote-holder", + expires_at: DateTime.add(DateTime.utc_now(), 60, :second) + }) + + state = %Orchestrator.State{running: %{}, claimed: MapSet.new(), blocked: %{}} + + refute Orchestrator.should_dispatch_issue_for_test(issue, state) + end + + test "expired claim leases are eligible for handoff after restart" do + Application.put_env(:symphony_elixir, :claim_lease_holder, "local-holder") + + issue = + claim_lease_issue(%{ + holder: "remote-holder", + expires_at: DateTime.add(DateTime.utc_now(), -1, :second) + }) + + state = %Orchestrator.State{running: %{}, claimed: MapSet.new(), blocked: %{}} + + assert Orchestrator.should_dispatch_issue_for_test(issue, state) + + assert {:ok, ^issue} = + Orchestrator.revalidate_issue_for_dispatch_for_test(issue, fn ["issue-lease"] -> {:ok, [issue]} end) + end + + test "current holder can continue a still-unexpired claim lease" do + Application.put_env(:symphony_elixir, :claim_lease_holder, "local-holder") + + issue = + claim_lease_issue(%{ + holder: "local-holder", + expires_at: DateTime.add(DateTime.utc_now(), 60, :second) + }) + + state = %Orchestrator.State{running: %{}, claimed: MapSet.new(), blocked: %{}} + + assert Orchestrator.should_dispatch_issue_for_test(issue, state) + + assert {:ok, ^issue} = + Orchestrator.revalidate_issue_for_dispatch_for_test(issue, fn ["issue-lease"] -> {:ok, [issue]} end) + end + + defp claim_lease_issue(lease_attrs) do + %Issue{ + id: "issue-lease", + identifier: "SD-LEASE", + title: "Lease recovery", + state: "In Progress", + claim_lease: + ClaimLease.new( + Map.merge( + %{ + comment_id: "comment-lease", + issue_id: "issue-lease", + issue_identifier: "SD-LEASE", + started_at: DateTime.add(DateTime.utc_now(), -60, :second), + refreshed_at: DateTime.utc_now() + }, + lease_attrs + ) + ) + } + end + defp assert_due_in_range(due_at_ms, min_remaining_ms, max_remaining_ms) do remaining_ms = due_at_ms - System.monotonic_time(:millisecond) diff --git a/elixir/test/symphony_elixir/extensions_test.exs b/elixir/test/symphony_elixir/extensions_test.exs index 789eb3d06f..d48f719afe 100644 --- a/elixir/test/symphony_elixir/extensions_test.exs +++ b/elixir/test/symphony_elixir/extensions_test.exs @@ -4,6 +4,8 @@ defmodule SymphonyElixir.ExtensionsTest do import Phoenix.ConnTest import Phoenix.LiveViewTest + alias SymphonyElixir.Jira.Adapter, as: JiraAdapter + alias SymphonyElixir.Jira.Client, as: JiraClient alias SymphonyElixir.Linear.Adapter alias SymphonyElixir.Tracker.Memory @@ -39,6 +41,40 @@ defmodule SymphonyElixir.ExtensionsTest do end end + defmodule FakeJiraClient do + alias SymphonyElixir.Tracker.ClaimLease + + def fetch_candidate_issues do + send(self(), :jira_fetch_candidate_issues_called) + {:ok, [:jira_candidate]} + end + + def fetch_issues_by_states(states) do + send(self(), {:jira_fetch_issues_by_states_called, states}) + {:ok, states} + end + + def fetch_issue_states_by_ids(issue_ids) do + send(self(), {:jira_fetch_issue_states_by_ids_called, issue_ids}) + {:ok, issue_ids} + end + + def create_comment(issue_id, body) do + send(self(), {:jira_create_comment_called, issue_id, body}) + :ok + end + + def upsert_claim_lease(issue_id, lease_attrs) do + send(self(), {:jira_upsert_claim_lease_called, issue_id, lease_attrs}) + {:ok, ClaimLease.new(Map.put(lease_attrs, :issue_id, issue_id))} + end + + def update_issue_state(issue_id, state_name) do + send(self(), {:jira_update_issue_state_called, issue_id, state_name}) + :ok + end + end + defmodule SlowOrchestrator do use GenServer @@ -193,8 +229,13 @@ defmodule SymphonyElixir.ExtensionsTest do assert {:ok, [^issue]} = SymphonyElixir.Tracker.fetch_issues_by_states([" in progress ", 42]) assert {:ok, [^issue]} = SymphonyElixir.Tracker.fetch_issue_states_by_ids(["issue-1"]) assert :ok = SymphonyElixir.Tracker.create_comment("issue-1", "comment") + + assert {:ok, %ClaimLease{issue_id: "issue-1", holder: "holder-1"}} = + SymphonyElixir.Tracker.upsert_claim_lease("issue-1", %{holder: "holder-1"}) + assert :ok = SymphonyElixir.Tracker.update_issue_state("issue-1", "Done") assert_receive {:memory_tracker_comment, "issue-1", "comment"} + assert_receive {:memory_tracker_claim_lease, "issue-1", %ClaimLease{holder: "holder-1"}} assert_receive {:memory_tracker_state_update, "issue-1", "Done"} Application.delete_env(:symphony_elixir, :memory_tracker_recipient) @@ -203,6 +244,15 @@ defmodule SymphonyElixir.ExtensionsTest do write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "linear") assert SymphonyElixir.Tracker.adapter() == Adapter + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "jira", + tracker_endpoint: "https://example.atlassian.net", + tracker_api_token: "jira-token", + tracker_project_slug: "SD" + ) + + assert SymphonyElixir.Tracker.adapter() == JiraAdapter end test "linear adapter delegates reads and validates mutation responses" do @@ -317,6 +367,214 @@ defmodule SymphonyElixir.ExtensionsTest do ) assert {:error, :issue_update_failed} = Adapter.update_issue_state("issue-1", "Odd") + assert {:ok, nil} = Adapter.upsert_claim_lease("issue-1", %{holder: "holder"}) + end + + test "jira adapter delegates to configured client module" do + Application.put_env(:symphony_elixir, :jira_client_module, FakeJiraClient) + + assert {:ok, [:jira_candidate]} = JiraAdapter.fetch_candidate_issues() + assert_receive :jira_fetch_candidate_issues_called + + assert {:ok, ["Triaged"]} = JiraAdapter.fetch_issues_by_states(["Triaged"]) + assert_receive {:jira_fetch_issues_by_states_called, ["Triaged"]} + + assert {:ok, ["SD-1"]} = JiraAdapter.fetch_issue_states_by_ids(["SD-1"]) + assert_receive {:jira_fetch_issue_states_by_ids_called, ["SD-1"]} + + assert :ok = JiraAdapter.create_comment("SD-1", "hello") + assert_receive {:jira_create_comment_called, "SD-1", "hello"} + + assert {:ok, %ClaimLease{issue_id: "SD-1", holder: "holder"}} = + JiraAdapter.upsert_claim_lease("SD-1", %{holder: "holder"}) + + assert_receive {:jira_upsert_claim_lease_called, "SD-1", %{holder: "holder"}} + + assert :ok = JiraAdapter.update_issue_state("SD-1", "Done") + assert_receive {:jira_update_issue_state_called, "SD-1", "Done"} + end + + test "jira adapter fetches active issues with readable claim lease markers" do + marker_started_at = ~U[2026-05-29 18:00:00Z] + marker_expires_at = ~U[2026-05-29 18:10:00Z] + + marker_body = + ClaimLease.render(%{ + holder: "remote-holder", + issue_id: "10001", + issue_identifier: "SD-1", + started_at: marker_started_at, + refreshed_at: marker_started_at, + expires_at: marker_expires_at, + attempt: 1 + }) + + request_recipient = self() + + Application.put_env(:symphony_elixir, :jira_request_fun, fn + :get, "/search", opts, _tracker -> + send(request_recipient, {:jira_request, :get, "/search", opts}) + + {:ok, + %{ + status: 200, + body: %{ + "issues" => [jira_issue_payload("10001", "SD-1", "In Progress")], + "startAt" => 0, + "maxResults" => 50, + "total" => 1 + } + }} + + :get, "/issue/SD-1/comment", opts, _tracker -> + send(request_recipient, {:jira_request, :get, "/issue/SD-1/comment", opts}) + + {:ok, + %{ + status: 200, + body: %{ + "comments" => [%{"id" => "comment-1", "body" => jira_marker_adf(marker_body)}], + "startAt" => 0, + "maxResults" => 100, + "total" => 1 + } + }} + end) + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "jira", + tracker_endpoint: "https://example.atlassian.net", + tracker_api_token: "Bearer jira-token", + tracker_project_slug: "SD" + ) + + assert {:ok, [issue]} = JiraAdapter.fetch_candidate_issues() + assert issue.id == "10001" + assert issue.identifier == "SD-1" + assert issue.state == "In Progress" + assert %ClaimLease{} = issue.claim_lease + assert issue.claim_lease.comment_id == "comment-1" + assert issue.claim_lease.holder == "remote-holder" + assert issue.claim_lease.expires_at == marker_expires_at + + assert_receive {:jira_request, :get, "/search", search_opts} + assert search_opts[:params].jql =~ ~s(project = "SD") + assert search_opts[:params].jql =~ "status in (\"Todo\", \"In Progress\")" + assert_receive {:jira_request, :get, "/issue/SD-1/comment", _comment_opts} + end + + test "jira adapter updates existing claim lease marker comments idempotently" do + existing_marker = + ClaimLease.render(%{ + holder: "old-holder", + issue_id: "SD-1", + issue_identifier: "SD-1", + started_at: ~U[2026-05-29 18:00:00Z], + refreshed_at: ~U[2026-05-29 18:00:00Z], + expires_at: ~U[2026-05-29 18:10:00Z] + }) + + request_recipient = self() + + Application.put_env(:symphony_elixir, :jira_request_fun, fn + :get, "/issue/SD-1/comment", opts, _tracker -> + send(request_recipient, {:jira_request, :get, "/issue/SD-1/comment", opts}) + + {:ok, + %{ + status: 200, + body: %{ + "comments" => [%{"id" => "comment-1", "body" => jira_marker_adf(existing_marker)}], + "startAt" => 0, + "maxResults" => 100, + "total" => 1 + } + }} + + :put, "/issue/SD-1/comment/comment-1", opts, _tracker -> + send(request_recipient, {:jira_request, :put, "/issue/SD-1/comment/comment-1", opts}) + {:ok, %{status: 200, body: %{"id" => "comment-1"}}} + + :post, path, opts, _tracker -> + send(request_recipient, {:jira_request, :post, path, opts}) + {:ok, %{status: 500, body: %{}}} + end) + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "jira", + tracker_endpoint: "https://example.atlassian.net", + tracker_api_token: "Bearer jira-token", + tracker_project_slug: "SD" + ) + + lease_attrs = %{ + holder: "local-holder", + issue_identifier: "SD-1", + started_at: ~U[2026-05-29 18:05:00Z], + refreshed_at: ~U[2026-05-29 18:05:00Z], + expires_at: ~U[2026-05-29 18:15:00Z], + attempt: 2 + } + + assert {:ok, %ClaimLease{comment_id: "comment-1", holder: "local-holder"}} = + JiraAdapter.upsert_claim_lease("SD-1", lease_attrs) + + assert {:ok, %ClaimLease{comment_id: "comment-1", holder: "local-holder"}} = + JiraAdapter.upsert_claim_lease("SD-1", lease_attrs) + + assert_receive {:jira_request, :put, "/issue/SD-1/comment/comment-1", first_put_opts} + assert_receive {:jira_request, :put, "/issue/SD-1/comment/comment-1", _second_put_opts} + refute_received {:jira_request, :post, _path, _opts} + + rendered_marker = first_put_opts[:json]["body"] |> JiraClient.adf_to_text_for_test() + assert rendered_marker =~ "local-holder" + assert rendered_marker =~ "symphony_claim_lease" + end + + test "jira adapter creates a claim lease marker when none exists" do + request_recipient = self() + + Application.put_env(:symphony_elixir, :jira_request_fun, fn + :get, "/issue/SD-2/comment", opts, _tracker -> + send(request_recipient, {:jira_request, :get, "/issue/SD-2/comment", opts}) + + {:ok, + %{ + status: 200, + body: %{"comments" => [], "startAt" => 0, "maxResults" => 100, "total" => 0} + }} + + :post, "/issue/SD-2/comment", opts, _tracker -> + send(request_recipient, {:jira_request, :post, "/issue/SD-2/comment", opts}) + {:ok, %{status: 201, body: %{"id" => "comment-new"}}} + + :put, path, opts, _tracker -> + send(request_recipient, {:jira_request, :put, path, opts}) + {:ok, %{status: 500, body: %{}}} + end) + + write_workflow_file!(Workflow.workflow_file_path(), + tracker_kind: "jira", + tracker_endpoint: "https://example.atlassian.net", + tracker_api_token: "Bearer jira-token", + tracker_project_slug: "SD" + ) + + assert {:ok, %ClaimLease{comment_id: "comment-new", holder: "local-holder"}} = + JiraAdapter.upsert_claim_lease("SD-2", %{ + holder: "local-holder", + issue_identifier: "SD-2", + started_at: ~U[2026-05-29 18:05:00Z], + refreshed_at: ~U[2026-05-29 18:05:00Z], + expires_at: ~U[2026-05-29 18:15:00Z] + }) + + assert_receive {:jira_request, :post, "/issue/SD-2/comment", post_opts} + refute_received {:jira_request, :put, _path, _opts} + + rendered_marker = post_opts[:json]["body"] |> JiraClient.adf_to_text_for_test() + assert rendered_marker =~ "local-holder" + assert rendered_marker =~ "symphony_claim_lease" end test "phoenix observability api preserves state, issue, and refresh responses" do @@ -702,6 +960,46 @@ defmodule SymphonyElixir.ExtensionsTest do assert {:error, _reason} = HttpServer.start_link(host: "bad host", port: 0) end + defp jira_issue_payload(id, key, status_name) do + %{ + "id" => id, + "key" => key, + "fields" => %{ + "summary" => "Jira issue #{key}", + "description" => %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "paragraph", + "content" => [%{"type" => "text", "text" => "Jira description"}] + } + ] + }, + "priority" => %{"name" => "High"}, + "status" => %{"name" => status_name}, + "labels" => ["symphony"], + "assignee" => %{"accountId" => "account-1"}, + "created" => "2026-05-29T18:00:00Z", + "updated" => "2026-05-29T18:01:00Z" + } + } + end + + defp jira_marker_adf(marker_body) do + %{ + "type" => "doc", + "version" => 1, + "content" => [ + %{ + "type" => "codeBlock", + "attrs" => %{"language" => "json"}, + "content" => [%{"type" => "text", "text" => marker_body}] + } + ] + } + end + defp start_test_endpoint(overrides) do endpoint_config = :symphony_elixir