lib/openagents/issues/capture.ex

main at 58e6347eeb72 · 11 KB

defmodule OpenAgents.Issues.Capture do
  @moduledoc """
  Turns one sentence of a conversation into a scoped forge issue.

  This module is the whole of the behavior. The chat tool
  (`OpenAgents.Tools.IssueCapture`) and the authenticated API operation
  (`POST /api/v1/repos/:owner/:repo/issues/capture`) are two transports over
  it, so the two cannot drift: a refusal the tool gives is the refusal the API
  gives, and a draft the API writes is the draft the tool writes.

  Three things happen here, in this order, and the order matters.

  1. **The repository is resolved under the caller's own membership.** Nothing
     here mints authority. `authorize/2` asks
     `OpenAgents.Repositories.writable?/2` about the account that asked, and a
     caller who cannot write gets a typed refusal that names what is missing
     rather than a silent fallback to some other repository. A repository the
     caller cannot even see is reported as absent, because saying
     "you lack write access" about a private repository discloses that it
     exists.
  2. **A near-duplicate is preferred over a new row.** See `dedupe/2`.
  3. **Only then is an issue created**, through `OpenAgents.Issues.create_issue/3`
     with the caller as author. Going through that function rather than
     inserting directly is what subscribes the requester to the issue's own
     notifications: `create_issue/3` calls `Notifications.issue_opened/2`
     inside its transaction, so the requester follows the issue from the
     moment it exists.

  ## What the public issue says

  The body is a fixed template — outcome, current behavior, acceptance
  criteria — filled only from what the caller supplied. Nothing else reaches
  it. No conversation id, no message id, no prompt, no tool trace, no
  repository metadata, and no model output the caller did not see. The
  template placeholders are honest about being unfilled instead of inventing
  a current behavior nobody observed.
  """

  import Ecto.Changeset, only: [traverse_errors: 2]

  alias OpenAgents.Accounts.User
  alias OpenAgents.Issues
  alias OpenAgents.Issues.Issue
  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.Repository

  @maximum_statement_bytes 4_000
  @maximum_section_bytes 4_000
  @maximum_criteria 12
  @maximum_title_characters 72

  @type outcome :: :created | :existing
  @type result :: %{
          issue: Issue.t(),
          repository: Repository.t(),
          outcome: outcome()
        }
  @type error ::
          :blank_problem_statement
          | :problem_statement_too_long
          | :section_too_long
          | :invalid_repository
          | :repository_not_found
          | :repository_write_access_required
          | {:invalid_issue, map()}

  @doc """
  Captures `attrs` as an issue in `repository_path` on behalf of `actor`.

  `repository_path` is `owner/name`. `attrs` accepts string keys:

  - `"problem"` — required, the requester's own words.
  - `"current_behavior"` — optional.
  - `"acceptance_criteria"` — optional, a list of strings or a newline-separated
    string.

  Returns `{:ok, %{issue: issue, repository: repository, outcome: outcome}}`
  where `outcome` is `:created` for a new issue and `:existing` when
  deduplication matched one that was already open. Retrying the same statement
  against the same repository therefore returns the same issue rather than a
  second one.
  """
  @spec capture(User.t(), String.t(), map()) :: {:ok, result()} | {:error, error()}
  def capture(%User{} = actor, repository_path, attrs) when is_binary(repository_path) do
    with {:ok, problem} <- problem(attrs),
         {:ok, current_behavior} <- section(attrs, "current_behavior"),
         {:ok, criteria} <- criteria(attrs),
         {:ok, repository} <- authorize(actor, repository_path) do
      title = draft_title(problem)
      body = draft_body(problem, current_behavior, criteria)

      case dedupe(repository, title) do
        %Issue{} = existing ->
          {:ok, %{issue: existing, repository: repository, outcome: :existing}}

        nil ->
          create(actor, repository, title, body)
      end
    end
  end

  @doc """
  Resolves `repository_path` to a repository `actor` may write to.

  The two refusals are deliberately different facts. `:repository_not_found`
  means the caller cannot see it, and is also what a caller gets for a private
  repository they hold no membership in — the refusal must not become an
  existence oracle. `:repository_write_access_required` means the caller can
  see it and holds no writing role, which is safe to say because they already
  know it exists, and is the refusal that names the missing authority so the
  person knows what to ask for.
  """
  @spec authorize(User.t(), String.t()) :: {:ok, Repository.t()} | {:error, error()}
  def authorize(%User{} = actor, repository_path) when is_binary(repository_path) do
    with {:ok, owner, name} <- parse_path(repository_path),
         %Repository{} = repository <- Repositories.visible_by_path(owner, name, actor) do
      if Repositories.writable?(repository, actor) do
        {:ok, repository}
      else
        {:error, :repository_write_access_required}
      end
    else
      nil -> {:error, :repository_not_found}
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  The open issue this request should be folded into, or `nil`.

  **This is an exact match on the normalized title, not a semantic one, and
  that is a limitation rather than a preference.** The repository has no
  embedding index over issues: `OpenAgents.Tools.Embeddings` covers the tool
  catalog, and the pgvector tables under `OpenAgents.Memory.SemanticIndex`
  cover conversation messages. Neither indexes issue text, and standing one up
  is a larger change than this one.

  So the choice was between an exact normalized-title check and inventing
  keyword or substring heuristics. Heuristics are the worse failure: a
  substring match folds "search is slow" into "search is slow on mobile" and
  the second request disappears without anyone deciding it should. An exact
  match fails in the safe direction — it misses real duplicates, which a person
  can still close by hand, and it never swallows a distinct request.

  Normalization is case, punctuation, and whitespace only, so
  `"Add dark mode"`, `"add dark mode."`, and `"Add  dark   mode"` are one
  issue. When issue embeddings exist, this function is the single place that
  changes.
  """
  @spec dedupe(Repository.t(), String.t()) :: Issue.t() | nil
  def dedupe(%Repository{} = repository, title) when is_binary(title) do
    Issues.open_issue_with_normalized_title(repository, normalize_title(title))
  end

  @doc """
  The comparison form of a title: lowercase, alphanumeric runs, single spaces.

  `OpenAgents.Issues.open_issue_with_normalized_title/2` reproduces this in
  SQL. Change one and you must change the other, or deduplication silently
  stops matching.
  """
  @spec normalize_title(String.t()) :: String.t()
  def normalize_title(title) when is_binary(title) do
    title
    |> String.downcase()
    |> String.replace(~r/[^a-z0-9]+/u, " ")
    |> String.trim()
  end

  @doc "The title this statement drafts to, exposed so a preview can show it."
  @spec draft_title(String.t()) :: String.t()
  def draft_title(problem) when is_binary(problem) do
    problem
    |> collapse()
    |> first_sentence()
    |> truncate(@maximum_title_characters)
    |> capitalize_first()
  end

  @doc "The body this statement drafts to, exposed so a preview can show it."
  @spec draft_body(String.t(), String.t() | nil, [String.t()]) :: String.t()
  def draft_body(problem, current_behavior, criteria)
      when is_binary(problem) and is_list(criteria) do
    """
    ## Outcome

    #{collapse_lines(problem)}

    ## Current behavior

    #{current_behavior_section(current_behavior)}

    ## Acceptance criteria

    #{criteria_section(criteria)}
    """
    |> String.trim()
    |> Kernel.<>("\n")
  end

  defp create(actor, repository, title, body) do
    case Issues.create_issue(repository, %{"title" => title, "body" => body}, actor) do
      {:ok, %Issue{} = issue} ->
        {:ok, %{issue: issue, repository: repository, outcome: :created}}

      {:error, changeset} ->
        {:error, {:invalid_issue, changeset_errors(changeset)}}
    end
  end

  defp changeset_errors(changeset) do
    traverse_errors(changeset, fn {message, options} ->
      Regex.replace(~r/%\{(\w+)\}/, message, fn _whole, key ->
        options |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
      end)
    end)
  end

  defp problem(attrs) do
    case attrs |> Map.get("problem") |> normalize_input() do
      nil ->
        {:error, :blank_problem_statement}

      problem when byte_size(problem) > @maximum_statement_bytes ->
        {:error, :problem_statement_too_long}

      problem ->
        {:ok, problem}
    end
  end

  defp section(attrs, key) do
    case attrs |> Map.get(key) |> normalize_input() do
      nil -> {:ok, nil}
      value when byte_size(value) > @maximum_section_bytes -> {:error, :section_too_long}
      value -> {:ok, value}
    end
  end

  defp criteria(attrs) do
    attrs
    |> Map.get("acceptance_criteria")
    |> List.wrap()
    |> Enum.flat_map(&String.split(to_string(&1), "\n"))
    |> Enum.map(&(&1 |> String.replace_prefix("- [ ]", "") |> String.replace_prefix("-", "")))
    |> Enum.map(&normalize_input/1)
    |> Enum.reject(&is_nil/1)
    |> Enum.take(@maximum_criteria)
    |> then(fn criteria ->
      if Enum.any?(criteria, &(byte_size(&1) > @maximum_section_bytes)) do
        {:error, :section_too_long}
      else
        {:ok, criteria}
      end
    end)
  end

  defp normalize_input(value) when is_binary(value) do
    case String.trim(value) do
      "" -> nil
      trimmed -> trimmed
    end
  end

  defp normalize_input(_value), do: nil

  defp current_behavior_section(nil),
    do: "Not recorded when this was captured. Fill this in before the work starts."

  defp current_behavior_section(current_behavior), do: collapse_lines(current_behavior)

  defp criteria_section([]),
    do: "- [ ] Not recorded when this was captured. Agree these before the work starts."

  defp criteria_section(criteria), do: Enum.map_join(criteria, "\n", &"- [ ] #{collapse(&1)}")

  defp parse_path(repository_path) do
    case repository_path |> String.trim() |> String.split("/", trim: true) do
      [owner, name] when byte_size(owner) in 1..100 and byte_size(name) in 1..100 ->
        {:ok, owner, name}

      _invalid ->
        {:error, :invalid_repository}
    end
  end

  defp collapse(value), do: value |> String.replace(~r/\s+/u, " ") |> String.trim()

  # Paragraphs survive; runs of blank lines and trailing spaces do not. The
  # requester's own prose reaches the issue intact.
  defp collapse_lines(value) do
    value
    |> String.split("\n")
    |> Enum.map(&String.trim_trailing/1)
    |> Enum.join("\n")
    |> String.replace(~r/\n{3,}/, "\n\n")
    |> String.trim()
  end

  defp first_sentence(value) do
    case String.split(value, ~r/(?<=[.!?])\s+/, parts: 2) do
      [sentence, _rest] -> String.trim(sentence)
      [whole] -> whole
    end
    |> String.trim_trailing(".")
  end

  defp truncate(value, limit) do
    if String.length(value) <= limit do
      value
    else
      value
      |> String.slice(0, limit)
      |> String.replace(~r/\s+\S*$/u, "")
      |> String.trim()
      |> then(fn trimmed ->
        if trimmed == "", do: String.slice(value, 0, limit), else: trimmed
      end)
    end
  end

  defp capitalize_first(""), do: ""

  defp capitalize_first(value) do
    {first, rest} = String.split_at(value, 1)
    String.upcase(first) <> rest
  end
end