defmodule OpenAgents.Forge do
@moduledoc """
Sarah's own git forge — the public API of the bounded context (audit A6).
This app serves the forge at `openagents.com/<owner>/<repo>.git` via stock Git
smart-HTTP (`OpenAgents.Forge.GitHTTP`), with the WAL in object storage as ref
truth (`OpenAgents.Forge.WAL`, Continuity-shaped per audit A7), bare repos on
the stateful partition as per-node cache (`OpenAgents.Forge.Repos` /
`OpenAgents.Forge.Sync`), pushes acked only after WAL persist
(`OpenAgents.Forge.Pushes`), and Postgres holding derived receipts only.
Hard edges: no imports from conversation contexts; emits receipts and
PubSub (`forge:pushes`), never reads turns/memory/voice.
"""
import Ecto.Query
alias OpenAgents.Forge.{Pushes, PushReceipt, ReceiptRepository}
alias OpenAgents.Repo
@doc "Whether the forge endpoint is enabled (default true; env-gated in prod)."
def enabled? do
Application.get_env(:openagents, :forge_enabled, true)
end
@doc "Recent push receipts for one repo, newest first, bounded."
def recent_pushes(repo, limit \\ 20) do
repo_keys = Pushes.receipt_repo_keys(repo)
PushReceipt
|> where([p], p.repo in ^repo_keys)
|> order_by([p], desc: p.wal_seq)
|> limit(^limit)
|> Repo.all()
end
@doc """
Recent deploy receipts for one repo, newest first, bounded.
A receipt that names its repository is matched by that key; `repo` is read
only for a row the #181 backfill could not settle.
"""
def recent_deploys(repo, limit \\ 20) do
OpenAgents.Forge.DeployReceipt
|> ReceiptRepository.scope(ReceiptRepository.resolve(repo), [repo])
|> order_by([d], desc: d.inserted_at)
|> limit(^limit)
|> Repo.all()
end
@doc """
Recent build receipts for one repo, newest first, bounded.
Keyed the same way `recent_deploys/2` is.
"""
def recent_builds(repo, limit \\ 20) do
OpenAgents.Forge.BuildReceipt
|> ReceiptRepository.scope(ReceiptRepository.resolve(repo), [repo])
|> order_by([b], desc: b.inserted_at)
|> limit(^limit)
|> Repo.all()
end
@doc """
Every receipt in the chain that references `sha` (full or abbreviated,
≥7 chars): the commit's deploy story for the public commit view (#137).
Bounded scans; a sha older than the scan window honestly returns empty.
"""
def receipts_for(repo, sha) do
%{
pushes:
repo
|> recent_pushes(200)
|> Enum.filter(fn push ->
push.refs
|> Map.values()
|> Enum.any?(fn
%{"new" => new} -> sha_match?(sha, new)
new when is_binary(new) -> sha_match?(sha, new)
_ -> false
end)
end),
builds: repo |> recent_builds(200) |> Enum.filter(&sha_match?(sha, &1.sha)),
targets:
repo |> OpenAgents.Forge.Targets.recent(100) |> Enum.filter(&sha_match?(sha, &1.sha)),
deploys: repo |> recent_deploys(200) |> Enum.filter(&sha_match?(sha, &1.sha))
}
end
def gate_receipts_for(repo, sha, opts \\ []) when is_binary(repo) and is_binary(sha) do
git_common_dir = Keyword.get(opts, :git_common_dir, OpenAgents.Forge.Repos.bare_path(repo))
case OpenAgents.Forge.GateReceipt.verify(sha, git_common_dir: git_common_dir) do
{:ok, receipt} -> %{gates: [receipt]}
_ -> %{gates: []}
end
end
@doc "Prefix-match two shas in either direction (short vs full), ≥7 chars."
def sha_match?(a, b) when is_binary(a) and is_binary(b) do
min(byte_size(a), byte_size(b)) >= 7 and
(String.starts_with?(a, b) or String.starts_with?(b, a))
end
def sha_match?(_a, _b), do: false
@doc "The clone URL for a repo on this deployment."
def clone_url(repo) do
OpenAgentsWeb.Endpoint.url() <> "/OpenAgentsInc/#{repo}.git"
end
end