lib/openagents/forge/commit_references.ex

main at 58e6347eeb72 · 6 KB

defmodule OpenAgents.Forge.CommitReferences do
  @moduledoc """
  Read issue references out of a commit message.

  Two forms carry a closing reference, and both are in use here:

    * the GitHub-style inline line, `Closes #126`, which is prose rather
      than a `Key: value` trailer, and
    * the trailer form, `Closes: #126`, which sits with the other trailers
      `OpenAgents.Forge.Browse.trailers/1` collects.

  A verb accepts a comma-separated list (`Closes #12, #13`) and the
  cross-repository form (`Closes OpenAgentsInc/openagents.com#12`).

  Every function here is pure and total. It reads a commit message, which is
  attacker-controlled text arriving on the most load-bearing path in the
  application, so it never raises, never runs a query, and never decides
  anything: it returns what the message says and leaves acting on it to
  `OpenAgents.Forge.Pushes`. Anything it cannot parse is simply not a
  reference.
  """

  @typedoc """
  One issue reference read from a commit message. `owner` and `repository`
  are `nil` for the bare `#N` form, which means the repository that received
  the push.
  """
  @type reference_entry :: %{
          verb: String.t() | nil,
          closing?: boolean(),
          owner: String.t() | nil,
          repository: String.t() | nil,
          number: pos_integer()
        }

  # GitHub's closing keywords, all tenses. Matching the full set keeps a
  # commit message that closes an issue on GitHub closing it here too.
  @closing_verbs ~w(close closes closed fix fixes fixed resolve resolves resolved)

  # A commit message is unbounded input. Past this many bytes the message is
  # not a commit message anyone wrote, and scanning it is not worth the time.
  @message_limit 64_000

  # No single commit closes more references than this. The cap bounds the work
  # one push can ask of the issue tracker.
  @reference_limit 32

  # Issue numbers are `integer` primary keys; anything wider is not a number
  # this tracker ever issued.
  @maximum_number 2_147_483_647

  @verb_pattern @closing_verbs |> Enum.join("|")

  # `Closes #12, #13 and OpenAgentsInc/openagents.com#14` — the verb, then the
  # run of references that follows it. The run stops at the first token that
  # is not a reference, a separator, or whitespace.
  @closing_regex Regex.compile!(
                   "(?:^|[^\\w/#-])(#{@verb_pattern}):?\\s+((?:[A-Za-z0-9][\\w.-]*/[A-Za-z0-9][\\w.-]*)?#\\d{1,10}(?:\\s*(?:,|and)\\s*(?:[A-Za-z0-9][\\w.-]*/[A-Za-z0-9][\\w.-]*)?#\\d{1,10})*)",
                   "i"
                 )

  # Every `#N` or `owner/repo#N` occurrence, whatever precedes it. This is the
  # one place a reference is defined; `OpenAgents.Issues.TaskList` reads
  # task-list checkboxes through it rather than carrying a second regex.
  @mention_regex ~r/(?:^|[^\w\/#-])(?:([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*))?#(\d{1,10})\b/

  @doc "The closing keywords this reader accepts, lowercase."
  @spec closing_verbs() :: [String.t()]
  def closing_verbs, do: @closing_verbs

  @doc """
  Every closing reference in `message`, in the order it appears, deduplicated.

  Returns `[]` for anything that is not a binary, for a message with no
  closing verb, and for a reference whose number is out of range. It never
  raises.

      iex> OpenAgents.Forge.CommitReferences.closing("Ship it\\n\\nCloses #126")
      [%{verb: "closes", closing?: true, owner: nil, repository: nil, number: 126}]
  """
  @spec closing(term()) :: [reference_entry()]
  def closing(message) when is_binary(message) do
    message
    |> clamp()
    |> scan_closing()
    |> Enum.uniq()
    |> Enum.take(@reference_limit)
  rescue
    _unparseable -> []
  end

  def closing(_message), do: []

  @doc """
  Every issue reference in `message`, closing or not.

  `OpenAgents.Issues.TaskList` reads issue and comment bodies for the `#N`
  mentions inside task-list checkboxes, which carry no verb. `closing?` says
  whether a closing keyword introduced the reference, so one scan serves both
  readers.
  """
  @spec all(term()) :: [reference_entry()]
  def all(message) when is_binary(message) do
    clamped = clamp(message)
    closing = scan_closing(clamped)

    mentions =
      Regex.scan(@mention_regex, clamped, capture: :all_but_first)
      |> Enum.flat_map(fn
        [owner, repository, number] -> entry(nil, owner, repository, number)
        _unmatched -> []
      end)

    (closing ++ mentions)
    |> Enum.uniq_by(fn entry -> {entry.owner, entry.repository, entry.number} end)
    |> Enum.take(@reference_limit)
  rescue
    _unparseable -> []
  end

  def all(_message), do: []

  @doc """
  The issue numbers `message` closes in the repository that received it.

  A cross-repository reference is dropped here rather than resolved: closing
  an issue in another repository is a separate decision with its own
  authority question, and guessing wrong closes the wrong issue.
  """
  @spec closing_numbers(term()) :: [pos_integer()]
  def closing_numbers(message) do
    message
    |> closing()
    |> Enum.filter(&same_repository?/1)
    |> Enum.map(& &1.number)
    |> Enum.uniq()
  end

  @doc "Whether a reference names the repository that received the push."
  @spec same_repository?(reference_entry()) :: boolean()
  def same_repository?(%{owner: nil, repository: nil}), do: true
  def same_repository?(_entry), do: false

  # ── internals ────────────────────────────────────────────────────────────

  defp clamp(message) when byte_size(message) > @message_limit,
    do: binary_part(message, 0, @message_limit)

  defp clamp(message), do: message

  defp scan_closing(message) do
    @closing_regex
    |> Regex.scan(message, capture: :all_but_first)
    |> Enum.flat_map(fn
      [verb, run] -> expand_run(verb, run)
      _unmatched -> []
    end)
  end

  defp expand_run(verb, run) do
    normalized = String.downcase(verb)

    Regex.scan(@mention_regex, " " <> run, capture: :all_but_first)
    |> Enum.flat_map(fn
      [owner, repository, number] -> entry(normalized, owner, repository, number)
      _unmatched -> []
    end)
  end

  defp entry(verb, owner, repository, number) do
    case Integer.parse(number) do
      {parsed, ""} when parsed > 0 and parsed <= @maximum_number ->
        [
          %{
            verb: verb,
            closing?: not is_nil(verb),
            owner: blank_to_nil(owner),
            repository: blank_to_nil(repository),
            number: parsed
          }
        ]

      _out_of_range ->
        []
    end
  end

  defp blank_to_nil(""), do: nil
  defp blank_to_nil(value) when is_binary(value), do: value
  defp blank_to_nil(_value), do: nil
end