defmodule TarakanWeb.RepositoryLive.Show do use TarakanWeb, :live_view alias Tarakan.Accounts alias Tarakan.Repositories alias Tarakan.FindingMemory alias Tarakan.Repositories.Repository alias Tarakan.RepositoryCode alias Tarakan.Policy alias Tarakan.Reputation alias Tarakan.Scans alias Tarakan.SecurityPosture alias Tarakan.Work alias Tarakan.Work.ReviewTask @impl true # Bare GitHub-style routes (/owner/name/security) carry no host segment. # A host-like first segment means the literal "security" swallowed a # remote path - a repository literally named "security" - whose record # entry lives at the /code form, which routes unambiguously. def mount(%{"owner" => owner, "name" => name} = params, session, socket) when not is_map_key(params, "host") do if Tarakan.Hosts.host_segment?(owner) do {:ok, push_navigate(socket, to: "/#{owner}/#{name}/security/code")} else mount(Map.put(params, "host", Repository.hosted_host()), session, socket) end end def mount(%{"host" => slug, "owner" => owner, "name" => name}, _session, socket) do repository = with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug), %Repository{} = repository <- Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do repository else _not_visible -> raise Ecto.NoResultsError, queryable: Repository end if connected?(socket) do Repositories.subscribe() Scans.subscribe(repository.id) Work.subscribe(repository.id) Reputation.subscribe() end {:ok, socket |> assign(:page_title, "#{repository.owner}/#{repository.name} security") |> assign( :meta_description, repository_meta_description(repository) ) |> assign( :canonical_path, TarakanWeb.RepositoryPaths.repository_security_path(repository) ) |> assign(:repository, repository) |> assign( :open_bounties, Tarakan.Market.open_bounties_for_target(:repository, repository.id) ) |> assign(:task_form, empty_task_form(repository)) |> assign(:show_task_form, false) |> assign(:task_kind_options, task_kind_options()) |> assign(:capability_options, capability_options()) |> assign(:branch_options, []) |> assign(:selected_branch, repository.default_branch) |> assign(:can_auto_open_job, can_auto_open_job?(socket, repository)) |> assign(:moderation_form, moderation_form()) |> assign(:can_vote, can_vote?(socket)) |> assign(:can_manage_disclosure, can_manage_disclosure?(socket, repository)) |> assign(:canonical_findings, []) |> assign(:checkable_findings_count, 0) |> assign(:posture, SecurityPosture.cached_repository_posture!(repository)) |> assign(:regressions, FindingMemory.list_repository_regressions(repository, limit: 10)) |> stream(:tasks, Work.list_tasks(repository, scope: socket.assigns.current_scope)) |> load_scans()} end @impl true def handle_info({:scan_submitted, scan}, socket) do {:noreply, sync_visible_scan(socket, scan, at: 0)} end def handle_info({:scan_updated, scan}, socket) do {:noreply, sync_visible_scan(socket, scan)} end def handle_info( {:repository_record_updated, %Repository{id: repository_id}}, %{assigns: %{repository: %Repository{id: repository_id}}} = socket ) do {:noreply, refresh_visible_repository(socket)} end def handle_info({event, %Repository{}}, socket) when event in [:repository_registered, :repository_record_updated] do {:noreply, socket} end def handle_info({event, _task_id}, socket) when event in [ :review_task_created, :review_task_updated, :review_task_published, :review_task_submitted, :review_task_accepted, :review_task_disclosed, :review_task_changes_requested, :review_task_rejected, :review_task_cancelled, :review_task_quarantined ] do {:noreply, reload_tasks(socket)} end def handle_info({:vote_changed, "canonical_finding", _id}, socket) do {:noreply, load_scans(socket)} end def handle_info({:vote_changed, _type, _id}, socket), do: {:noreply, socket} @impl true def handle_event("toggle_task_form", _params, socket) do show = !socket.assigns.show_task_form socket = if show do socket |> assign(:show_task_form, true) |> load_branch_options() |> assign(:task_form, draft_task_form(socket.assigns.repository, %{})) else assign(socket, :show_task_form, false) end {:noreply, socket} end def handle_event("select_branch", %{"branch" => branch}, socket) do repository = socket.assigns.repository branch = String.trim(to_string(branch || "")) case RepositoryCode.resolve_branch_commit(repository, branch) do {:ok, commit_sha} -> params = (socket.assigns.task_form.params || %{}) |> Map.put("commit_sha", commit_sha) # Clear title so it re-fills with the new short SHA. params = Map.put(params, "title", "") {:noreply, socket |> assign(:selected_branch, branch) |> assign(:task_form, draft_task_form(repository, params))} {:error, _reason} -> {:noreply, put_flash(socket, :error, "Could not resolve branch #{inspect(branch)} to a commit.")} end end # One-click mass path: default-branch HEAD, code_review, agent, auto title/description. # Stewards/moderators get an immediately open job; others get a proposal. def handle_event( "quick_open_job", _params, %{assigns: %{current_scope: %{account: account}}} = socket ) when not is_nil(account) do repository = socket.assigns.repository params = case RepositoryCode.resolve_default_commit(repository) do {:ok, commit_sha} -> %{"commit_sha" => commit_sha} {:error, _} -> %{} end create_task_from_params(socket, params) end def handle_event("quick_open_job", _params, socket) do {:noreply, redirect(socket, to: ~p"/accounts/log-in")} end def handle_event( "vote", %{"type" => subject_type, "id" => subject_id, "vote" => value}, %{assigns: %{current_scope: %{account: account}}} = socket ) when not is_nil(account) do with {:ok, subject_id} <- normalize_id(subject_id), {:ok, value} <- normalize_vote(value) do case Reputation.cast_vote( socket.assigns.current_scope, subject_type, subject_id, value ) do {:ok, _summary} -> {:noreply, load_scans(socket)} {:error, :own_content} -> {:noreply, put_flash(socket, :error, "You cannot vote on your own contribution.")} {:error, _reason} -> {:noreply, put_flash(socket, :error, "Your vote could not be recorded.")} end else _invalid -> {:noreply, put_flash(socket, :error, "Your vote could not be recorded.")} end end def handle_event("vote", _params, socket) do {:noreply, redirect(socket, to: ~p"/accounts/log-in")} end def handle_event( "toggle_managed_disclosure", _params, %{assigns: %{current_scope: %{account: account}}} = socket ) when not is_nil(account) do repository = socket.assigns.repository case Repositories.set_managed_disclosure( socket.assigns.current_scope, repository, not repository.managed_disclosure ) do {:ok, _updated} -> {:noreply, socket |> reload_repository() |> put_flash(:info, "Managed disclosure updated.")} {:error, reason} when reason in [:unauthorized, :not_found] -> {:noreply, put_flash(socket, :error, "You cannot change managed disclosure.")} {:error, _reason} -> {:noreply, put_flash(socket, :error, "Managed disclosure could not be updated.")} end end def handle_event("toggle_managed_disclosure", _params, socket) do {:noreply, redirect(socket, to: ~p"/accounts/log-in")} end # Fills the proposal form with the selected (or default) branch tip. def handle_event("use_default_commit", _params, socket) do repository = socket.assigns.repository branch = socket.assigns.selected_branch || repository.default_branch result = if is_binary(branch) and branch != "" do RepositoryCode.resolve_branch_commit(repository, branch) else RepositoryCode.resolve_default_commit(repository) end case result do {:ok, commit_sha} -> params = (socket.assigns.task_form.params || %{}) |> Map.put("commit_sha", commit_sha) |> Map.put("title", "") {:noreply, assign(socket, :task_form, draft_task_form(repository, params))} {:error, _reason} -> {:noreply, put_flash(socket, :error, "The branch tip could not be resolved to a commit.")} end end def handle_event("validate_task", %{"review_task" => params}, socket) do params = normalize_task_params(params) form = draft_task_form(socket.assigns.repository, params, action: :validate) {:noreply, assign(socket, :task_form, form)} end def handle_event( "create_task", %{"review_task" => params}, %{assigns: %{current_scope: %{account: account}}} = socket ) when not is_nil(account) do create_task_from_params(socket, normalize_task_params(params)) end def handle_event("create_task", _params, socket) do {:noreply, redirect(socket, to: ~p"/accounts/log-in")} end def handle_event( "cancel_task", %{"id" => id}, %{assigns: %{current_scope: %{account: account}}} = socket ) when not is_nil(account) do task = Work.list_tasks(socket.assigns.repository, scope: socket.assigns.current_scope) |> Enum.find(&(to_string(&1.id) == to_string(id))) case task do nil -> {:noreply, put_flash(socket, :error, "Job not found.")} task -> case Work.cancel_task(task, socket.assigns.current_scope, %{ "reason" => "Cancelled from the repository security page by a steward or creator." }) do {:ok, _cancelled} -> {:noreply, socket |> reload_tasks() |> put_flash(:info, "Job cancelled.")} {:error, :unauthorized} -> {:noreply, put_flash(socket, :error, "You cannot cancel this job.")} {:error, _} -> {:noreply, put_flash(socket, :error, "Could not cancel this job.")} end end end def handle_event("cancel_task", _params, socket) do {:noreply, redirect(socket, to: ~p"/accounts/log-in")} end def handle_event( "record_finding_verdict", %{ "finding_id" => public_id, "commit_sha" => commit_sha, "verdict" => verdict, "notes" => notes }, %{assigns: %{current_scope: %{account: account}}} = socket ) when not is_nil(account) do case apply_finding_check(socket, public_id, commit_sha, verdict, notes) do :ok -> {:noreply, socket |> reload_repository() |> load_scans() |> put_flash(:info, "Finding check recorded.")} {:error, message} -> {:noreply, put_flash(socket, :error, message)} end end def handle_event("record_finding_verdict", _params, socket) do {:noreply, redirect(socket, to: ~p"/accounts/log-in")} end def handle_event( "bulk_check_findings", %{"verdict" => verdict, "notes" => notes}, %{assigns: %{current_scope: %{account: account}}} = socket ) when not is_nil(account) and verdict in ["confirmed", "disputed", "fixed"] do notes = notes |> to_string() |> String.trim() if String.length(notes) < 20 do {:noreply, put_flash(socket, :error, "Notes need at least 20 characters.")} else targets = socket.assigns.canonical_findings |> Enum.filter(& &1.can_check) |> Enum.map(fn finding -> {finding.public_id, finding.last_seen_commit_sha} end) {ok, fail} = Enum.reduce(targets, {0, 0}, fn {public_id, commit_sha}, {ok, fail} -> case apply_finding_check(socket, public_id, commit_sha, verdict, notes) do :ok -> {ok + 1, fail} {:error, _} -> {ok, fail + 1} end end) socket = socket |> reload_repository() |> load_scans() msg = cond do ok == 0 and fail == 0 -> "No findings available to check." fail == 0 -> "Recorded #{ok} finding check#{if ok == 1, do: "", else: "s"}." ok == 0 -> "Could not record any finding checks." true -> "Recorded #{ok} check#{if ok == 1, do: "", else: "s"}; #{fail} failed." end flash = if ok > 0, do: :info, else: :error {:noreply, put_flash(socket, flash, msg)} end end def handle_event("bulk_check_findings", _params, socket) do {:noreply, redirect(socket, to: ~p"/accounts/log-in")} end def handle_event( "moderate_scan", %{"scan_id" => scan_id, "decision" => decision, "moderation" => attrs}, %{assigns: %{current_scope: %{account: account}}} = socket ) when not is_nil(account) do with_recent_auth(socket, fn -> with {:ok, scan} <- Scans.get_scan(socket.assigns.current_scope, scan_id) do # Reason codes are machine labels for the audit log. The decision button # picks them - moderators only write evidence notes in the UI. attrs = attrs |> stringify_moderation_attrs() |> Map.put("moderation_reason", default_moderation_reason(decision)) result = case decision do "accept" -> Scans.accept_scan(socket.assigns.current_scope, scan, attrs) "reject" -> Scans.reject_scan(socket.assigns.current_scope, scan, attrs) "contest" -> Scans.contest_scan(socket.assigns.current_scope, scan, attrs) "publish_summary" -> Scans.update_visibility(socket.assigns.current_scope, scan, "public_summary", attrs) "publish_full" -> Scans.update_visibility(socket.assigns.current_scope, scan, "public", attrs) "restrict" -> Scans.update_visibility(socket.assigns.current_scope, scan, "restricted", attrs) _other -> {:error, :invalid_transition} end case result do {:ok, updated_scan} -> {:noreply, socket |> assign(:moderation_form, moderation_form()) |> sync_visible_scan(updated_scan) |> put_flash(:info, "Review decision recorded.")} {:error, :verification_required} -> {:noreply, put_flash(socket, :error, "Acceptance requires verification quorum.")} {:error, reason} when reason in [:unauthorized, :conflict_of_interest] -> {:noreply, put_flash(socket, :error, "You cannot moderate this review.")} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, socket |> assign(:moderation_form, to_form(changeset, as: :moderation)) |> put_flash(:error, "Add evidence notes (20+ characters).")} {:error, _reason} -> {:noreply, put_flash(socket, :error, "Review decision could not be recorded.")} end else {:error, :not_found} -> {:noreply, put_flash(socket, :error, "Review is unavailable.")} end end) end def handle_event("moderate_scan", _params, socket) do {:noreply, redirect(socket, to: ~p"/accounts/log-in")} end defp apply_finding_check(socket, public_id, commit_sha, verdict, notes) do attrs = %{ "commit_sha" => commit_sha, "verdict" => verdict, "provenance" => "human", "notes" => notes } case FindingMemory.record_check( socket.assigns.current_scope, socket.assigns.repository, public_id, attrs ) do {:ok, _check, _canonical} -> :ok {:error, :conflict_of_interest} -> {:error, "You cannot verify a finding you submitted."} {:error, :unauthorized} -> {:error, "You are not authorized to verify this finding."} {:error, %Ecto.Changeset{errors: [{_field, {message, _meta}} | _]}} -> {:error, "Finding check not recorded: #{message}."} {:error, _reason} -> {:error, "Finding check could not be recorded."} end end defp reload_repository(socket) do %{host: host, owner: owner, name: name} = socket.assigns.repository assign(socket, :repository, Repositories.get_repository(host, owner, name)) end defp refresh_visible_repository(socket) do %{host: host, owner: owner, name: name} = socket.assigns.repository case Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do nil -> push_navigate(socket, to: ~p"/") repository -> findings = canonical_findings( repository, socket.assigns.current_scope, current_account_id(socket) ) scans = Scans.list_scans(socket.assigns.current_scope, repository) socket |> assign(:repository, repository) |> assign(:canonical_findings, findings) |> assign(:checkable_findings_count, Enum.count(findings, & &1.can_check)) |> stream(:tasks, Work.list_tasks(repository, scope: socket.assigns.current_scope), reset: true ) |> assign(:task_target_options, task_target_options(scans)) |> stream(:scans, scans, reset: true) end end defp reload_tasks(socket) do tasks = Work.list_tasks(socket.assigns.repository, scope: socket.assigns.current_scope) stream(socket, :tasks, tasks, reset: true) end # Raw reports are immutable provenance. Shared votes and checks are loaded # once for each canonical issue instead of being repeated on occurrences. defp load_scans(socket) do scans = Scans.list_scans(socket.assigns.current_scope, socket.assigns.repository) findings = canonical_findings( socket.assigns.repository, socket.assigns.current_scope, current_account_id(socket) ) socket |> assign(:canonical_findings, findings) |> assign(:checkable_findings_count, Enum.count(findings, & &1.can_check)) |> assign(:task_target_options, task_target_options(scans)) |> stream(:scans, scans, reset: true) end defp canonical_findings(repository, scope, account_id) do findings = FindingMemory.list_repository_memory(repository, limit: 200) votes = Reputation.vote_summaries("canonical_finding", Enum.map(findings, & &1.id), account_id) checkable_ids = FindingMemory.checkable_public_ids(scope, repository, findings) findings |> Enum.map(fn finding -> Map.merge(finding, %{ can_check: MapSet.member?(checkable_ids, finding.public_id), vote_summary: Map.get(votes, finding.id, %{score: 0, my_vote: 0}) }) end) end defp current_account_id(%{assigns: %{current_scope: %{account: %{id: id}}}}), do: id defp current_account_id(_socket), do: nil # phx-value params are client-controlled; never feed them to String.to_integer/1. defp normalize_id(id) when is_integer(id) and id > 0, do: {:ok, id} defp normalize_id(id) when is_binary(id) do case Integer.parse(id) do {parsed, ""} when parsed > 0 -> {:ok, parsed} _other -> {:error, :invalid_id} end end defp normalize_id(_id), do: {:error, :invalid_id} defp normalize_vote(value) when is_integer(value), do: {:ok, value} defp normalize_vote(value) when is_binary(value) do case Integer.parse(value) do {parsed, ""} -> {:ok, parsed} _other -> {:error, :invalid_vote} end end defp normalize_vote(_value), do: {:error, :invalid_vote} defp can_vote?(socket), do: not is_nil(current_account_id(socket)) defp can_manage_disclosure?(socket, repository) do Policy.allowed?(socket.assigns.current_scope, :moderate, repository) end defp create_task_from_params(socket, params) do params = normalize_task_params(params) case Work.create_task(socket.assigns.repository, socket.assigns.current_scope, params) do {:ok, task} -> message = if task.status == "open" do "Job opened on the public queue." else "Job proposed for independent approval." end {:noreply, socket |> assign(:task_form, empty_task_form(socket.assigns.repository)) |> assign(:show_task_form, false) |> stream_insert(:tasks, task, at: 0) |> put_flash(:info, message)} {:error, :commit_not_found} -> {:noreply, assign_task_error(socket, params, :commit_sha, "commit was not found")} {:error, :commit_mismatch} -> {:noreply, assign_task_error(socket, params, :commit_sha, "commit verification returned a mismatch")} {:error, reason} when reason in [:rate_limited, :unavailable] -> {:noreply, assign_task_error(socket, params, :commit_sha, "commit could not be verified right now")} {:error, reason} when reason in [:identity_changed, :not_public] -> {:noreply, assign_task_error( socket, params, :commit_sha, "repository identity is no longer confirmed public (GitHub-backed repos only - Tarakan-hosted repos use the local git object database)" )} {:error, :proposal_limit} -> {:noreply, put_flash(socket, :error, "Daily review-task proposal limit reached.")} {:error, :proposal_rate_limited} -> {:noreply, put_flash(socket, :error, "Too many task proposals. Try again shortly.")} {:error, :duplicate_job} -> {:noreply, put_flash( socket, :error, "An open job already covers this commit and job type. Claim or complete that one first." )} {:error, :unauthorized} -> {:noreply, put_flash(socket, :error, "This account cannot propose jobs.")} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, socket |> assign(:show_task_form, true) |> assign(:task_form, to_form(changeset, as: :review_task))} end end defp assign_task_error(socket, params, field, message) do filled = Work.fill_task_defaults(socket.assigns.repository, params) form = %ReviewTask{} |> Work.change_task(filled) |> Ecto.Changeset.add_error(field, message) |> Map.put(:action, :validate) |> to_form(as: :review_task) socket |> assign(:show_task_form, true) |> assign(:task_form, form) end defp empty_task_form(repository) do draft_task_form(repository, %{}) end defp draft_task_form(repository, params, opts \\ []) do params = params |> maybe_default_commit(repository) |> then(&Work.fill_task_defaults(repository, &1)) changeset = %ReviewTask{} |> Work.change_task(params) changeset = case Keyword.get(opts, :action) do nil -> changeset action -> Map.put(changeset, :action, action) end to_form(changeset, as: :review_task) end defp load_branch_options(socket) do repository = socket.assigns.repository case RepositoryCode.list_branches(repository) do {:ok, branches} -> default = repository.default_branch selected = socket.assigns[:selected_branch] || default || List.first(branches) socket |> assign(:branch_options, branches) |> assign(:selected_branch, selected) {:error, _} -> default = repository.default_branch branches = if is_binary(default) and default != "", do: [default], else: [] socket |> assign(:branch_options, branches) |> assign(:selected_branch, default) end end defp maybe_default_commit(params, repository) do params = for {k, v} <- params, into: %{}, do: {to_string(k), v} if present_param?(params["commit_sha"]) do params else case RepositoryCode.resolve_default_commit(repository) do {:ok, commit_sha} -> Map.put(params, "commit_sha", commit_sha) {:error, _} -> params end end end defp present_param?(value) when value in [nil, ""], do: false defp present_param?(value) when is_binary(value), do: String.trim(value) != "" defp present_param?(_), do: true defp can_auto_open_job?(%{assigns: %{current_scope: scope}}, repository) when not is_nil(scope) do # publish_task is checked against the repository for stewards; moderators always can. Policy.allowed?(scope, :publish_task, repository) or Policy.allowed?(scope, :manage_repository, repository) end defp can_auto_open_job?(_socket, _repository), do: false defp can_cancel_job?(task, %{account: %{id: account_id}} = scope) when not is_nil(account_id) do task.status in ["proposed", "open", "claimed", "changes_requested"] and Policy.allowed?(scope, :cancel_task, task) end defp can_cancel_job?(_task, _scope), do: false defp can_moderate?(scan, %{account: account} = scope) when not is_nil(account) do Policy.allowed?(scope, :moderate_review, scan) and scan.submitted_by_id != account.id end defp can_moderate?(_scan, _scope), do: false defp moderation_form do to_form( %{ "visibility" => "restricted", "moderation_reason" => "", "moderation_notes" => "" }, as: :moderation ) end defp stringify_moderation_attrs(attrs) when is_map(attrs) do Map.new(attrs, fn {k, v} -> {to_string(k), v} end) end defp stringify_moderation_attrs(_), do: %{} # Stored on the scan for audit/activity feeds. Not a user-facing concept. defp default_moderation_reason("accept"), do: "evidence_reviewed" defp default_moderation_reason("reject"), do: "evidence_insufficient" defp default_moderation_reason("contest"), do: "independently_contested" defp default_moderation_reason("publish_full"), do: "disclosure_reviewed" defp default_moderation_reason("publish_summary"), do: "safe_summary" defp default_moderation_reason("restrict"), do: "takedown_review" defp default_moderation_reason(_), do: "moderator_decision" defp with_recent_auth(socket, fun) do if Accounts.sudo_mode?(socket.assigns.current_scope.account) do fun.() else repo = socket.assigns.repository return_to = TarakanWeb.RepositoryPaths.repository_path(repo) {:noreply, socket |> put_flash( :error, "Confirm it's you with a magic link before changing review disclosure (sign-in older than 8 hours)." ) |> push_navigate(to: TarakanWeb.AccountAuth.reauth_path(return_to))} end end defp sync_visible_scan(socket, scan, opts \\ []) do socket = reload_repository(socket) socket = case Scans.get_scan(socket.assigns.current_scope, scan.id) do {:ok, visible_scan} -> stream_insert(socket, :scans, visible_scan, opts) {:error, :not_found} -> stream_delete(socket, :scans, scan) end scans = Scans.list_scans(socket.assigns.current_scope, socket.assigns.repository) assign(socket, :task_target_options, task_target_options(scans)) end defp short_sha(sha), do: String.slice(sha, 0, 7) defp scan_time(%DateTime{} = datetime) do Calendar.strftime(datetime, "%Y-%m-%d %H:%M") end # Signal is reserved for findings; quiet states stay quiet. defp finding_lines(%{line_start: nil}), do: "" defp finding_lines(%{line_start: line, line_end: line}), do: ":#{line}" defp finding_lines(%{line_start: line_start, line_end: line_end}), do: ":#{line_start}-#{line_end}" defp repository_meta_description(repository) do label = TarakanWeb.RepositoryComponents.repository_status_label(repository) host = repository.host || "github.com" base = "Public security record for #{repository.owner}/#{repository.name} on #{host}. " <> "#{label}." detail = cond do (repository.open_findings_count || 0) > 0 -> " See open findings and open jobs." true -> " Contribute a review or claim an open job." end String.slice(base <> detail, 0, 160) end # Mass UI: Report job + Check job first; advanced kinds still available. defp task_kind_options do primary = [ {"Security report (findings)", "code_review"}, {"Check an existing report", "verify_findings"} ] advanced = ~w(diff_review threat_model privacy_review business_logic write_fix) |> Enum.map(&{review_kind_label(&1) <> " (advanced)", &1}) primary ++ advanced end defp task_target_options(scans) do scans |> Enum.filter(&(&1.findings_count > 0)) |> Enum.map(fn scan -> count_label = if scan.findings_count == 1, do: "1 finding", else: "#{scan.findings_count} findings" { "Report ##{scan.id} · #{review_kind_label(scan.review_kind)} · #{short_sha(scan.commit_sha)} · #{count_label}", scan.id } end) end # Each kind carries exactly one extra field. Leaving a stale value from a # different kind in the params makes the changeset reject it, which reads to # the user as an unrelated validation error. defp normalize_task_params(%{"kind" => "verify_findings"} = params) do Map.delete(params, "base_commit_sha") end defp normalize_task_params(%{"kind" => "diff_review"} = params) do Map.delete(params, "target_review_id") end defp normalize_task_params(params) do params |> Map.delete("target_review_id") |> Map.delete("base_commit_sha") end # Job form: only who should pick it up. Hybrid stays a report provenance # (agent draft a person edited), not a separate "human-guided" work mode. defp capability_options do [ {"AI helper", "agent"}, {"Human only", "human"} ] end defp short_task_status(%{status: "claimed"} = task) do if Tarakan.Work.ReviewTask.claim_active?(task), do: "Claimed", else: "Open" end defp short_task_status(%{status: "changes_requested"}), do: "Changes requested" defp short_task_status(%{status: status}), do: String.capitalize(status) defp empty_review_label(%{review_kind: "code_review"}), do: "No findings reported" defp empty_review_label(_scan), do: "Reviewed" end