-
Notifications
You must be signed in to change notification settings - Fork 83
add self service agent to workbench #4381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
a935a7a
653afb2
93be15e
c9b76a6
4a3adbe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -90,6 +90,7 @@ fragment Workbench on Workbench { | |
| id | ||
| } | ||
| configuration { | ||
| selfService | ||
| infrastructure { | ||
| services | ||
| stacks | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| defmodule Console.AI.Tools.Workbench.SelfService.CatalogSearch do | ||
| use Console.AI.Tools.Workbench.Base | ||
| import Ecto.Query | ||
| alias Console.Repo | ||
| alias Console.AI.Tool | ||
| alias Console.Deployments.{Git, Policies} | ||
| alias Console.Schema.{Catalog, PrAutomation} | ||
|
|
||
| embedded_schema do | ||
| field :query, :string | ||
| end | ||
|
|
||
| @valid ~w(query)a | ||
| @json_schema Console.priv_file!("tools/workbench/self_service/catalog_search.json") |> Jason.decode!() | ||
|
|
||
| def json_schema(), do: @json_schema | ||
| def name(), do: "workbench_catalog_search" | ||
| def description(), do: """ | ||
| Search Plural catalogs and PR automations that are available to the current user. | ||
| Prefer this when you need a relevant golden path but do not yet know which catalog or automation fits. | ||
| Falls back to name search when semantic search is unavailable. | ||
| """ | ||
|
|
||
| def changeset(model, attrs) do | ||
| model | ||
| |> cast(attrs, @valid) | ||
| |> validate_required([:query]) | ||
| end | ||
|
|
||
| def implement(%__MODULE__{query: query}) do | ||
| with {:actor, %{} = user} <- {:actor, Tool.actor()}, | ||
| {:search, ^user, {:ok, results}} <- {:search, user, Git.catalog_search(query, user: user, count: 100)} do | ||
| format_results(results) | ||
| else | ||
| {:actor, _} -> | ||
| {:ok, "not logged in"} | ||
| {:search, user, {:error, _}} -> | ||
| fallback_search(query, user) | ||
| end | ||
| end | ||
|
|
||
| defp fallback_search(query, user) do | ||
| catalog_hits(query, user) | ||
| |> Enum.concat(automation_hits(query, user)) | ||
| |> Jason.encode() | ||
| end | ||
|
|
||
| defp catalog_hits(query, user) do | ||
| Catalog.search(query) | ||
| |> Catalog.for_user(user) | ||
| |> limit(100) | ||
| |> Repo.all() | ||
| |> Enum.map(&%{catalog: Map.take(&1, [:id, :name, :description, :category])}) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ultimately not a huge deal here, but Enum.map materializes a new list (alongside really all enum mod functions). For something that could be large enumerations, Stream equivalents is lazy and more memory efficient
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Questions (still learning Elixir):
I'm happy to switch to the Stream whether for best practice or if it's actually better in this case. Just using this opportunity to make sure I understand what's happening. |
||
| end | ||
|
|
||
| defp automation_hits(query, user) do | ||
| PrAutomation.search(query) | ||
| |> limit(100) | ||
| |> Repo.all() | ||
| |> Repo.preload([:catalog]) | ||
| |> Enum.filter(&readable?(&1, user)) | ||
| |> Enum.map(fn pra -> | ||
| %{ | ||
| pr_automation: Map.take(pra, [:id, :name, :documentation, :title, :branch]) | ||
| |> Map.put(:description, pra.documentation) | ||
| |> Map.put(:catalog, pra.catalog && Map.take(pra.catalog, [:id, :name])) | ||
| } | ||
| end) | ||
| end | ||
|
|
||
| defp readable?(%PrAutomation{catalog: %Catalog{} = catalog}, user), | ||
| do: match?({:ok, _}, Policies.allow(catalog, user, :read)) | ||
| defp readable?(%PrAutomation{} = pra, user), | ||
| do: match?({:ok, _}, Policies.allow(pra, user, :create)) | ||
|
|
||
| defp format_results(results) do | ||
| Enum.map(results, fn | ||
| %{catalog: %Catalog{} = catalog} -> | ||
| %{catalog: Map.take(catalog, [:id, :name, :description, :category])} | ||
| %{pr_automation: %PrAutomation{} = pra} -> | ||
| %{ | ||
| pr_automation: | ||
| Map.take(pra, [:id, :name, :documentation, :title, :branch]) | ||
| |> Map.put(:description, pra.documentation) | ||
| } | ||
| other -> | ||
| other | ||
| end) | ||
| |> Jason.encode() | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| defmodule Console.AI.Tools.Workbench.SelfService.GetPrAutomation do | ||
| use Console.AI.Tools.Workbench.Base | ||
| alias Console.Repo | ||
| alias Console.AI.Tool | ||
| alias Console.Deployments.{Git, Policies} | ||
| alias Console.Schema.PrAutomation | ||
|
|
||
| embedded_schema do | ||
| field :pr_automation_id, :string | ||
| field :name, :string | ||
| end | ||
|
|
||
| @valid ~w(pr_automation_id name)a | ||
| @json_schema Console.priv_file!("tools/workbench/self_service/get_pr_automation.json") |> Jason.decode!() | ||
| @fields ~w(id name documentation title message branch branch_prefix identifier configuration icon dark_icon)a | ||
|
|
||
| def json_schema(), do: @json_schema | ||
| def name(), do: "workbench_get_pr_automation" | ||
| def description(), do: """ | ||
| Fetch a single PR automation by id or name, including documentation, branch metadata, | ||
| configuration fields, and confirmation requirements. Use this before invoking an automation | ||
| so you can fill a valid context. | ||
| """ | ||
|
|
||
| def changeset(model, attrs) do | ||
| model | ||
| |> cast(attrs, @valid) | ||
| |> validate_one_of() | ||
| end | ||
|
|
||
| defp validate_one_of(cs) do | ||
| case {get_field(cs, :pr_automation_id), get_field(cs, :name)} do | ||
| {id, _} when is_binary(id) and byte_size(id) > 0 -> cs | ||
| {_, name} when is_binary(name) and byte_size(name) > 0 -> cs | ||
| _ -> add_error(cs, :pr_automation_id, "either pr_automation_id or name is required") | ||
| end | ||
| end | ||
|
|
||
| def implement(%__MODULE__{} = model) do | ||
| with %{} = user <- Tool.actor(), | ||
| %PrAutomation{} = pra <- fetch(model), | ||
| {:ok, _} <- Policies.allow(pra, user, :read) do | ||
| pra | ||
| |> Repo.preload([:catalog]) | ||
| |> format() | ||
| |> Jason.encode() | ||
| else | ||
| nil -> {:ok, "PR automation not found"} | ||
| {:error, _} -> {:ok, "You do not have access to this PR automation"} | ||
| _ -> {:ok, "not logged in"} | ||
| end | ||
| end | ||
|
|
||
| defp fetch(%__MODULE__{pr_automation_id: id}) when is_binary(id) and byte_size(id) > 0, | ||
| do: Git.get_pr_automation(id) | ||
| defp fetch(%__MODULE__{name: name}) when is_binary(name) and byte_size(name) > 0, | ||
| do: Git.get_pr_automation_by_name(name) | ||
| defp fetch(_), do: nil | ||
|
|
||
| defp format(%PrAutomation{} = pra) do | ||
| Map.take(pra, @fields) | ||
| |> Map.put(:description, pra.documentation) | ||
| |> Map.put(:catalog, format_catalog(pra.catalog)) | ||
| |> Map.put(:confirmation, format_confirmation(pra.confirmation)) | ||
| |> Console.mapify() | ||
| end | ||
|
|
||
| defp format_catalog(%{id: id, name: name, description: description, category: category}), | ||
| do: %{id: id, name: name, description: description, category: category} | ||
| defp format_catalog(_), do: nil | ||
|
|
||
| defp format_confirmation(%{text: text, checklist: checklist}), | ||
| do: %{text: text, checklist: Enum.map(checklist || [], &Map.take(&1, [:label]))} | ||
| defp format_confirmation(_), do: nil | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| defmodule Console.AI.Tools.Workbench.SelfService.InvokePrAutomation do | ||
| use Console.AI.Tools.Workbench.Base | ||
| alias Console.AI.Tool | ||
| alias Console.Deployments.Git | ||
| alias Console.Schema.{WorkbenchJob, PullRequest} | ||
|
|
||
| embedded_schema do | ||
| field :pr_automation_id, :string | ||
| field :context, :string | ||
| field :branch, :string | ||
| field :identifier, :string | ||
| field :job, :map, virtual: true | ||
| end | ||
|
|
||
| @valid ~w(pr_automation_id context branch identifier)a | ||
| @json_schema Console.priv_file!("tools/workbench/self_service/invoke_pr_automation.json") |> Jason.decode!() | ||
|
|
||
| def json_schema(_), do: @json_schema | ||
| def name(_), do: "workbench_invoke_pr_automation" | ||
| def description(_), do: """ | ||
| Invoke a PR automation to create a pull request for a clear GitOps provisioning pathway. | ||
| The generated pull request is automatically associated with the current workbench job. | ||
| Call this only after confirming a relevant automation and filling a valid context. Prefer a single invocation. | ||
| """ | ||
|
|
||
| def changeset(model, attrs) do | ||
| model | ||
| |> cast(attrs, @valid) | ||
| |> validate_required([:pr_automation_id, :branch]) | ||
| end | ||
|
|
||
| def implement(%__MODULE__{pr_automation_id: pra_id, branch: branch} = model) do | ||
| with %{} = user <- Tool.actor(), | ||
| %WorkbenchJob{id: job_id, workbench_id: workbench_id} <- job(model), | ||
| {:ok, %PullRequest{} = pr} <- | ||
| Git.create_pull_request( | ||
| %{workbench_job_id: job_id, workbench_id: workbench_id}, | ||
| get_context(model), | ||
| pra_id, | ||
| branch, | ||
| model.identifier, | ||
| user | ||
| ) do | ||
| Jason.encode(%{ | ||
| id: pr.id, | ||
| url: pr.url, | ||
| title: pr.title, | ||
| status: pr.status, | ||
|
michaeljguarino marked this conversation as resolved.
|
||
| workbench_job_id: pr.workbench_job_id, | ||
| workbench_id: pr.workbench_id | ||
| }) | ||
| else | ||
| nil -> {:ok, "no workbench job or user available for this invocation"} | ||
| {:error, %Ecto.Changeset{} = cs} -> | ||
| {:ok, "failed to create pull request: #{inspect(Console.GraphQl.Helpers.resolve_changeset(cs))}"} | ||
| {:error, err} when is_binary(err) -> {:ok, "failed to create pull request: #{err}"} | ||
| {:error, err} -> {:ok, "failed to create pull request: #{inspect(err)}"} | ||
| err -> {:ok, "failed to create pull request: #{inspect(err)}"} | ||
| end | ||
| end | ||
|
|
||
| defp job(%__MODULE__{job: %WorkbenchJob{} = job}), do: job | ||
| defp job(_) do | ||
| case Tool.context() do | ||
| %{job: %WorkbenchJob{} = job} -> job | ||
| _ -> nil | ||
| end | ||
| end | ||
|
|
||
| defp get_context(%__MODULE__{context: ctx}) when is_binary(ctx) do | ||
| case Jason.decode(ctx) do | ||
| {:ok, %{} = map} -> map | ||
| _ -> %{} | ||
| end | ||
| end | ||
| defp get_context(_), do: %{} | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
all queries in this function should have a limit (maybe 100)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I put it at 100, which means
fallback_searchcould query up to 200 results