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/github.ex
defmodule Sentinel.Tracker.GitHub do
@moduledoc """
GitHub Issues tracker adapter for Sentinel.
Uses the GitHub REST API via `Req` to create issues, add comments,
and manage labels.
## Configuration
config :sentinel,
tracker: {Sentinel.Tracker.GitHub,
token: System.get_env("GITHUB_TOKEN"),
owner: "my-org",
repo: "my-repo"
}
"""
@behaviour Sentinel.Tracker
alias Sentinel.ErrorBucket
@api_base "https://api.github.com"
@impl true
def create_issue(%ErrorBucket{} = bucket) do
with {:ok, config} <- validate_config() do
body = %{
"title" => "[Sentinel] #{bucket.signature.exception_type}: #{bucket.signature.message_pattern}",
"body" => format_issue_body(bucket),
"labels" => ["sentinel", "bug"]
}
case github_post(config, "/repos/#{config.owner}/#{config.repo}/issues", body) do
{:ok, %{status: 201, body: resp}} ->
{:ok, to_string(resp["number"])}
{:ok, %{status: status}} ->
{:error, {:github_api_status, status}}
{:error, reason} ->
{:error, reason}
end
end
end
@impl true
def update_issue(issue_number, update) do
with {:ok, _config} <- validate_config() do
body = Map.get(update, :comment, "Sentinel: updated (count: #{Map.get(update, :count, "?")})")
add_comment(issue_number, body)
:ok
end
end
@impl true
def find_existing(%ErrorBucket{} = bucket) do
with {:ok, config} <- validate_config() do
title_prefix = "[Sentinel] #{bucket.signature.exception_type}"
query =
URI.encode_query(%{
"q" => "repo:#{config.owner}/#{config.repo} is:issue is:open in:title \"#{title_prefix}\""
})
case github_get(config, "/search/issues?#{query}") do
{:ok, %{status: 200, body: %{"items" => items}}} when is_list(items) ->
case items do
[first | _] -> {:ok, to_string(first["number"])}
[] -> :not_found
end
_ ->
:not_found
end
end
end
@impl true
def fetch_available_issues(opts) do
with {:ok, config} <- validate_config() do
labels = Keyword.get(opts, :labels, ["sentinel"]) |> Enum.join(",")
case github_get(config, "/repos/#{config.owner}/#{config.repo}/issues?labels=#{labels}&state=open&per_page=50") do
{:ok, %{status: 200, body: issues}} when is_list(issues) ->
{:ok, normalize_issues(issues)}
{:ok, %{status: status}} ->
{:error, {:github_api_status, status}}
{:error, reason} ->
{:error, reason}
end
end
end
@impl true
def assign_issue(issue_number, assignee) do
with {:ok, config} <- validate_config() do
body = %{"assignees" => [assignee]}
case github_post(config, "/repos/#{config.owner}/#{config.repo}/issues/#{issue_number}", body, :patch) do
{:ok, %{status: 200}} -> :ok
{:ok, %{status: status}} -> {:error, {:github_api_status, status}}
{:error, reason} -> {:error, reason}
end
end
end
@impl true
def transition_issue(issue_number, state_name) do
with {:ok, config} <- validate_config() do
github_state = if state_name in ["Done", "Closed", "Resolved"], do: "closed", else: "open"
body = %{"state" => github_state}
case github_post(config, "/repos/#{config.owner}/#{config.repo}/issues/#{issue_number}", body, :patch) do
{:ok, %{status: 200}} -> :ok
{:ok, %{status: status}} -> {:error, {:github_api_status, status}}
{:error, reason} -> {:error, reason}
end
end
end
@impl true
def add_comment(issue_number, body) do
with {:ok, config} <- validate_config() do
payload = %{"body" => body}
case github_post(config, "/repos/#{config.owner}/#{config.repo}/issues/#{issue_number}/comments", payload) do
{:ok, %{status: 201}} -> :ok
{:ok, %{status: status}} -> {:error, {:github_api_status, status}}
{:error, reason} -> {:error, reason}
end
end
end
defp github_get(config, path) do
Req.get("#{@api_base}#{path}", headers: auth_headers(config))
end
defp github_post(config, path, body, method \\ :post) do
opts = [headers: auth_headers(config), json: body]
case method do
:post -> Req.post("#{@api_base}#{path}", opts)
:patch -> Req.patch("#{@api_base}#{path}", opts)
end
end
defp auth_headers(config) do
[
{"Authorization", "Bearer #{config.token}"},
{"Accept", "application/vnd.github+json"},
{"X-GitHub-Api-Version", "2022-11-28"}
]
end
defp validate_config do
case tracker_opts() do
opts when is_list(opts) ->
token = Keyword.get(opts, :token)
owner = Keyword.get(opts, :owner)
repo = Keyword.get(opts, :repo)
cond do
is_nil(token) -> {:error, :missing_github_token}
is_nil(owner) -> {:error, :missing_github_owner}
is_nil(repo) -> {:error, :missing_github_repo}
true -> {:ok, %{token: token, owner: owner, repo: repo}}
end
_ ->
{:error, :tracker_not_configured}
end
end
defp tracker_opts do
case Application.get_env(:sentinel, :tracker) do
{__MODULE__, opts} -> opts
_ -> nil
end
end
defp format_issue_body(%ErrorBucket{} = bucket) do
sample = List.first(bucket.samples)
stacktrace_section =
if sample && is_list(sample.stacktrace) && sample.stacktrace != [] do
formatted = Enum.map_join(Enum.take(sample.stacktrace, 10), "\n", &Exception.format_stacktrace_entry/1)
"\n## Stacktrace\n\n```\n#{formatted}\n```\n"
else
""
end
"""
## Error Details
| Field | Value |
|-------|-------|
| **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}
```
#{stacktrace_section}
---
*Created by [Sentinel](https://github.com/fun-fx/sentinel)*
"""
end
defp normalize_issues(issues) do
Enum.map(issues, fn issue ->
%{
id: to_string(issue["number"]),
identifier: "##{issue["number"]}",
title: issue["title"],
description: issue["body"],
state: issue["state"],
labels: Enum.map(issue["labels"] || [], & &1["name"]),
url: issue["html_url"]
}
end)
end
end