Packages
In-process autonomous dev agent for Elixir. Captures errors, creates tickets, picks up board work, and runs Codex to investigate and fix.
Current section
Files
Jump to
Current section
Files
lib/sentinel/tracker/linear.ex
defmodule Sentinel.Tracker.Linear do
@moduledoc """
Linear issue tracker adapter for Sentinel.
Uses the `linear_client` package for all API operations.
## Configuration
config :sentinel,
tracker: {Sentinel.Tracker.Linear,
api_key: System.get_env("LINEAR_API_KEY"),
team_id: "TEAM-UUID",
project_slug: "MY-PROJECT"
}
"""
@behaviour Sentinel.Tracker
alias Sentinel.ErrorBucket
@impl true
def create_issue(%ErrorBucket{} = bucket) do
with {:ok, client} <- client(),
{:ok, team_id} <- team_id() do
attrs = %{
"teamId" => team_id,
"title" => "[Sentinel] #{bucket.signature.exception_type}: #{bucket.signature.message_pattern}",
"description" => format_issue_body(bucket),
"labelIds" => resolve_label_ids(client)
}
case Linear.create_issue(client, attrs) do
{:ok, issue} -> {:ok, issue["id"]}
error -> error
end
end
end
@impl true
def update_issue(issue_id, update) do
with {:ok, client} <- client() do
body = Map.get(update, :comment, "Sentinel: occurrence count updated to #{Map.get(update, :count, "?")}")
Linear.create_comment(client, issue_id, body)
:ok
end
end
@impl true
def find_existing(%ErrorBucket{} = bucket) do
with {:ok, client} <- client(),
{:ok, issues} <-
Linear.list_issues(client,
state_names: open_states(),
first: 10
) do
title_prefix = "[Sentinel] #{bucket.signature.exception_type}"
case Enum.find(issues, fn i -> String.starts_with?(i["title"] || "", title_prefix) end) do
nil -> :not_found
issue -> {:ok, issue["id"]}
end
end
end
@impl true
def fetch_available_issues(opts) do
with {:ok, client} <- client() do
filter_opts =
[]
|> maybe_add(:state_names, Keyword.get(opts, :pickup_states))
|> maybe_add(:team_id, Keyword.get(opts, :team_id, team_id_value()))
case Linear.list_issues(client, filter_opts) do
{:ok, issues} ->
labels_filter = Keyword.get(opts, :labels, [])
filtered = filter_by_labels(issues, labels_filter)
{:ok, normalize_issues(filtered)}
error ->
error
end
end
end
@impl true
def assign_issue(issue_id, assignee_id) do
with {:ok, client} <- client() do
case Linear.update_issue(client, issue_id, %{"assigneeId" => assignee_id}) do
{:ok, _} -> :ok
error -> error
end
end
end
@impl true
def transition_issue(issue_id, state_name) do
with {:ok, client} <- client() do
case Linear.transition_issue(client, issue_id, state_name) do
{:ok, _} -> :ok
error -> error
end
end
end
@impl true
def add_comment(issue_id, body) do
with {:ok, client} <- client() do
case Linear.create_comment(client, issue_id, body) do
{:ok, _} -> :ok
error -> error
end
end
end
defp client do
case tracker_opts() do
opts when is_list(opts) ->
api_key = Keyword.get(opts, :api_key)
if api_key do
{:ok, Linear.client(api_key: api_key)}
else
{:error, :missing_linear_api_key}
end
_ ->
{:error, :tracker_not_configured}
end
end
defp team_id do
case team_id_value() do
nil -> {:error, :missing_team_id}
id -> {:ok, id}
end
end
defp team_id_value, do: Keyword.get(tracker_opts() || [], :team_id)
defp tracker_opts do
case Application.get_env(:sentinel, :tracker) do
{__MODULE__, opts} -> opts
_ -> nil
end
end
defp open_states, do: ["Backlog", "Todo", "In Progress", "Triage"]
defp format_issue_body(%ErrorBucket{} = bucket) do
sample = List.first(bucket.samples)
"""
## Error Details
**Type**: `#{bucket.signature.exception_type}`
**Origin**: `#{bucket.signature.origin_function || bucket.signature.origin_module || "unknown"}`
**Line**: #{bucket.signature.origin_line || "unknown"}
**Occurrences**: #{bucket.count}
**First seen**: #{bucket.first_seen_at}
**Last seen**: #{bucket.last_seen_at}
## Message
```
#{bucket.signature.message_pattern}
```
#{if sample, do: format_sample_stacktrace(sample), else: ""}
---
*Created by [Sentinel](https://github.com/fun-fx/sentinel)*
"""
end
defp format_sample_stacktrace(%{stacktrace: stacktrace}) when is_list(stacktrace) and stacktrace != [] do
formatted =
stacktrace
|> Enum.take(10)
|> Enum.map_join("\n", &Exception.format_stacktrace_entry/1)
"""
## Stacktrace
```
#{formatted}
```
"""
end
defp format_sample_stacktrace(_), do: ""
defp resolve_label_ids(_client), do: []
defp filter_by_labels(issues, []), do: issues
defp filter_by_labels(issues, required_labels) do
required_set = MapSet.new(required_labels |> Enum.map(&String.downcase/1))
Enum.filter(issues, fn issue ->
issue_labels =
(issue["labels"] || get_in(issue, ["labels", "nodes"]) || [])
|> Enum.map(fn
%{"name" => name} -> String.downcase(name)
name when is_binary(name) -> String.downcase(name)
_ -> ""
end)
|> MapSet.new()
MapSet.subset?(required_set, issue_labels)
end)
end
defp normalize_issues(issues) do
Enum.map(issues, fn issue ->
%{
id: issue["id"],
identifier: issue["identifier"],
title: issue["title"],
description: issue["description"],
state: get_in(issue, ["state", "name"]) || issue["state"],
labels: extract_label_names(issue),
url: issue["url"]
}
end)
end
defp extract_label_names(issue) do
case issue do
%{"labels" => %{"nodes" => nodes}} when is_list(nodes) ->
Enum.map(nodes, & &1["name"])
%{"labels" => labels} when is_list(labels) ->
Enum.map(labels, fn
%{"name" => n} -> n
n when is_binary(n) -> n
_ -> nil
end)
|> Enum.reject(&is_nil/1)
_ ->
[]
end
end
defp maybe_add(opts, _key, nil), do: opts
defp maybe_add(opts, key, value), do: Keyword.put(opts, key, value)
end