diff --git a/README.md b/README.md index cba6cb0dcd..51d011caf0 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ On Linux x86_64, install the latest packaged Symphony release into your user pro curl -fsSL https://raw.githubusercontent.com/BetterAndBetterII/symphony/main/scripts/install.sh | sh ``` -After installation, run `symphony` from any project directory. It will create `WORKFLOW.md` when -missing and then boot the service from that directory. +After installation, run `symphony` from any project directory. If `WORKFLOW.md` is missing, +interactive terminals offer a guided GitHub Project bootstrap (with a baseline-template fallback), +while non-interactive runs still write the baseline template before booting the service. To pin a specific release, export `SYMPHONY_VERSION` before invoking the installer. diff --git a/SPEC.md b/SPEC.md index 5724df1e06..ed8ddeb163 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2128,7 +2128,11 @@ Unless otherwise noted, Sections 17.1 through 17.7 are `Core Conformance`. Bulle - CLI accepts an optional positional workflow path argument (`path-to-WORKFLOW.md`) - CLI uses `./WORKFLOW.md` when no workflow path argument is provided -- CLI errors on nonexistent explicit workflow path or missing default `./WORKFLOW.md` +- CLI errors on nonexistent explicit workflow path +- When the default `./WORKFLOW.md` is missing, CLI bootstraps it before startup per Section 19.3 +- If guided bootstrap is implemented and stdin/stdout are interactive TTYs, the default-path + bootstrap may present a terminal wizard before writing `./WORKFLOW.md` +- Bootstrap failures do not leave a partially written `WORKFLOW.md` - CLI surfaces startup failure cleanly - GitHub startup failures surface actionable `gh` remediation when implicit `gh` auth is selected - CLI exits with success when application starts and shuts down normally @@ -2262,8 +2266,13 @@ To support quick starts, an implementation that ships this profile SHOULD make ` from any directory: - If `./WORKFLOW.md` exists: start Symphony using that workflow. -- If `./WORKFLOW.md` is missing: create a default `WORKFLOW.md` in the current directory, then - start Symphony using that newly created file. +- If an explicit workflow path is passed and that file is missing: fail without creating any new + file. +- If `./WORKFLOW.md` is missing and stdin/stdout are not interactive TTYs: create the baseline + `WORKFLOW.md` in the current directory, then start Symphony using that newly created file. +- If `./WORKFLOW.md` is missing and stdin/stdout are interactive TTYs: offer a guided bootstrap + flow before startup. That flow must still expose the baseline template path as an operator + choice. The default `WORKFLOW.md` SHOULD: @@ -2273,6 +2282,117 @@ The default `WORKFLOW.md` SHOULD: - Include clear inline comments or prompt text that indicates where to customize project-specific settings (project owner/number, clone URL, etc). +#### 19.3.1 Guided GitHub Project Bootstrap + +If the interactive bootstrap path is implemented for GitHub-backed setups, the recommended terminal +wizard contract is: + +1. Present an initial mode picker with these semantic choices: + - guided GitHub Project setup + - write the baseline non-interactive template + - cancel startup without writing a file +2. Treat all wizard answers as parsed domain values rather than free-form config fragments. A + conforming implementation should normalize selection answers into typed values before rendering + `WORKFLOW.md` (for example a selected project ref, a chosen approval policy, and a chosen + sandbox mode). +3. Only write `./WORKFLOW.md` after the guided flow reaches a complete, internally consistent + result. Failed GitHub reads/writes or abandoned prompts must not leave a partial file behind. + +The guided GitHub path SHOULD resolve auth for bootstrap API calls in this order: + +1. `GITHUB_TOKEN` when present. +2. `GH_TOKEN` when present. +3. `gh auth token --hostname ` after verifying the active account/scopes. + +The generated GitHub-backed `WORKFLOW.md` SHOULD include concrete values for: + +- `tracker.kind: github_project` +- `tracker.endpoint: https://api.github.com/graphql` unless a different host was explicitly chosen +- `tracker.api_key: $GITHUB_TOKEN` +- `tracker.project_owner`: the selected or newly created project owner login +- `tracker.project_number`: the selected or newly created ProjectV2 number +- `tracker.project_field_status`: the status field name reconciled by bootstrap (default `Status`) +- `tracker.active_states`: `Todo`, `Spec`, `In Progress`, `Rework`, `In Review`, `Merging` +- `tracker.terminal_states`: `Done`, `Canceled`, `Duplicated` +- `codex.approval_policy`: the operator-selected approval policy +- `codex.thread_sandbox`: the operator-selected thread sandbox mode + +`Backlog` should still be provisioned in the ProjectV2 status field for manual triage, but it is +intentionally excluded from `tracker.active_states` so Symphony does not auto-dispatch backlog work +until a human moves it into an active state. + +The remaining baseline fields (workspace root, hooks, prompt body, polling, and server defaults) +should stay aligned with the non-interactive template so packaged releases keep one shared runtime +contract. + +#### 19.3.2 GitHub Project Discovery and Creation + +The guided GitHub bootstrap SHOULD list accessible ProjectV2 boards visible to the authenticated +viewer across: + +- the viewer-owned projects, and +- organization-owned projects for organizations returned by the viewer membership query. + +The selection UI should present enough metadata to distinguish boards at a glance (owner login, +project title, project number, and URL or equivalent identifier). + +If the operator chooses to create a new project, the guided flow SHOULD: + +1. Let the operator choose an owner from the owners they can create projects under. +2. Accept a non-empty project title. +3. Create a ProjectV2 via GitHub GraphQL. +4. Ensure the workflow status field contains at least these canonical Symphony options, in this + semantic order: + - `Backlog` + - `Todo` + - `Spec` + - `In Progress` + - `Rework` + - `In Review` + - `Merging` + - `Done` + - `Canceled` + - `Duplicated` + +For an existing selected project, the bootstrap SHOULD reconcile the configured single-select +status field so the required Symphony options are present without discarding unrelated existing +options. If no compatible single-select field exists, bootstrap may create one and must write the +chosen field name back into `tracker.project_field_status`. + +Status option colors/descriptions are implementation-defined; correctness is defined by the option +names and by writing the matching field name into `WORKFLOW.md`. + +#### 19.3.3 Codex Defaults Selection + +The guided bootstrap SHOULD let the operator choose the Codex defaults written into the generated +workflow. + +Recommended minimum choices: + +- `codex.approval_policy`: `untrusted`, `on-failure`, `on-request`, `never` +- `codex.thread_sandbox`: `read-only`, `workspace-write`, `danger-full-access` + +The UI may describe one option in each group as the recommended default, but it should only offer +values the runtime already understands so the emitted workflow is valid without follow-up edits. + +#### 19.3.4 Failure Modes and Guidance + +Guided bootstrap failures for GitHub-backed setup SHOULD be typed and operator-actionable: + +- Missing auth / missing `gh` / missing scopes: surface one concrete remediation command (for + example `gh auth login --hostname github.com --scopes repo,project,read:org` or + `gh auth refresh --hostname github.com --scopes repo,project,read:org`). +- Permission failures (for example project listing or creation forbidden): identify the GitHub + operation that failed and explain that the authenticated principal needs ProjectV2 access for the + selected owner. +- GraphQL or transport failures: include the failed operation context and concise API detail. +- If project creation succeeds but later field reconciliation fails, report the created project + identifier so the operator can clean it up manually before retrying. + +These failures should stop the guided path without writing `WORKFLOW.md`. They may return the +operator to the initial mode picker, but they should never silently fall back to a guessed project +configuration. + ### 19.4 GitHub Release Automation For GitHub-hosted repos, the recommended automation is: @@ -2301,6 +2421,8 @@ Suggested implementation milestones for this profile: 3. Provide a first-run bootstrap for `WORKFLOW.md` - Ensure `symphony` creates `./WORKFLOW.md` if missing, using a default template that is valid and runnable once required credentials are present. + - For interactive terminals, offer the guided GitHub Project bootstrap flow defined in Section + 19.3 while keeping the baseline template path available. 4. Automate releases on GitHub Actions - Add workflows that create `v` tags and publish GitHub Releases with the built assets attached. @@ -2313,10 +2435,17 @@ Suggested validation for this profile: - Local validation: - Build the release artifact and start `symphony` in a clean directory that does not contain a `WORKFLOW.md`; verify that it creates the file and starts the service. + - In an interactive terminal with GitHub auth configured, verify the guided bootstrap can both + select an existing ProjectV2 and create a new sandbox ProjectV2, then inspect the generated + `WORKFLOW.md` for the selected project metadata and Codex defaults. - Verify that the installed `symphony` works when Elixir/Mix are not present on `$PATH` (for example by running in a minimal container/VM). - CI validation: - Ensure the release workflow builds at least one target and uploads an asset to a GitHub Release. - Failure-mode validation: + - Run the guided bootstrap without GitHub auth (and with insufficient scopes, if feasible) and + confirm it fails with explicit remediation guidance and does not write `WORKFLOW.md`. + - Force a GitHub Project permission or API failure during guided bootstrap and confirm the error + identifies the failed operation with actionable context. - Run the installer on an unsupported `uname -s` / `uname -m` combination and confirm it fails with a clear error message. diff --git a/elixir/README.md b/elixir/README.md index b1377975bf..2da3063281 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -38,7 +38,7 @@ Symphony stops the active agent for that issue and cleans up matching workspaces - The `github` skill expects Symphony's `github_graphql` app-server tool for raw GitHub GraphQL operations such as comment editing or project field updates. 5. Customize the copied `WORKFLOW.md` file for your project. - - Configure the GitHub Project owner + number. + - Configure the GitHub Project owner + number, or let the first-run guided bootstrap pick/create a project for you when starting without a `WORKFLOW.md`. - Configure the Project field used as "status" (default: `Status`). - Ensure the Project field values match your expected states (for example: `Todo`, `In Progress`, `In Review`, `Merging`, `Done`, `Rework`). @@ -90,7 +90,7 @@ Pass a custom workflow file path to `./bin/symphony` when starting the service: ./bin/symphony /path/to/custom/WORKFLOW.md ``` -If no path is passed, Symphony defaults to `./WORKFLOW.md` and creates a default template there when the file is missing. Passing an explicit path still requires that file to exist. +If no path is passed, Symphony defaults to `./WORKFLOW.md`. When that file is missing, interactive terminals offer a guided GitHub Project bootstrap that can pick or create a ProjectV2 board and choose Codex defaults; non-interactive runs still create the baseline template automatically. Passing an explicit path still requires that file to exist. Optional flags: @@ -165,7 +165,7 @@ codex: command: "$CODEX_BIN app-server --model gpt-5.3-codex" ``` -- If the default `./WORKFLOW.md` is missing, Symphony creates a starter template on first run. Missing explicit workflow paths or invalid YAML still halt startup until fixed. +- If the default `./WORKFLOW.md` is missing, Symphony now offers a guided GitHub Project bootstrap on interactive TTYs and falls back to the baseline starter template in non-interactive runs. Missing explicit workflow paths or invalid YAML still halt startup until fixed. - `server.port` or CLI `--port` enables the optional Phoenix LiveView dashboard and JSON API at `/`, `/api/v1/state`, `/api/v1/`, and `/api/v1/refresh`. diff --git a/elixir/lib/symphony_elixir/cli.ex b/elixir/lib/symphony_elixir/cli.ex index 92aa8f5a5d..b9be13e6fa 100644 --- a/elixir/lib/symphony_elixir/cli.ex +++ b/elixir/lib/symphony_elixir/cli.ex @@ -10,7 +10,8 @@ defmodule SymphonyElixir.CLI do @type ensure_started_result :: {:ok, [atom()]} | {:error, term()} @type deps :: %{ file_regular?: (String.t() -> boolean()), - write_default_workflow: (String.t() -> :ok | {:error, term()}), + write_default_workflow: (String.t(), keyword() -> :ok | {:error, term()}), + interactive_stdio?: (-> boolean()), notify: (String.t() -> term()), set_workflow_file_path: (String.t() -> :ok | {:error, term()}), set_logs_root: (String.t() -> :ok | {:error, term()}), @@ -76,7 +77,8 @@ defmodule SymphonyElixir.CLI do defp runtime_deps do %{ file_regular?: &File.regular?/1, - write_default_workflow: &DefaultWorkflow.write/1, + write_default_workflow: &DefaultWorkflow.write/2, + interactive_stdio?: &interactive_stdio?/0, notify: &IO.puts/1, set_workflow_file_path: &SymphonyElixir.Workflow.set_workflow_file_path/1, set_logs_root: &set_logs_root/1, @@ -89,13 +91,15 @@ defmodule SymphonyElixir.CLI do if deps.file_regular?.(path) do :ok else - case deps.write_default_workflow.(path) do + interactive? = deps.interactive_stdio?.() + + case deps.write_default_workflow.(path, interactive: interactive?) do :ok -> deps.notify.(bootstrap_message(path)) :ok {:error, reason} -> - {:error, "Failed to initialize workflow file #{path}: #{inspect(reason)}"} + format_bootstrap_error(path, reason) end end end @@ -108,8 +112,14 @@ defmodule SymphonyElixir.CLI do end end + defp format_bootstrap_error(_path, reason) when is_binary(reason), do: {:error, reason} + + defp format_bootstrap_error(path, reason) do + {:error, "Failed to initialize workflow file #{path}: #{inspect(reason)}"} + end + defp bootstrap_message(path) do - "Created default WORKFLOW.md at #{path}. Update GITHUB_TOKEN, GITHUB_PROJECT_OWNER, GITHUB_PROJECT_NUMBER, SOURCE_REPO_URL, and SYMPHONY_WORKSPACE_ROOT for your repo." + "Created WORKFLOW.md at #{path}. Review the generated tracker, auth, and workspace settings before running Symphony." end defp maybe_set_logs_root(opts, deps) do @@ -173,4 +183,17 @@ defmodule SymphonyElixir.CLI do end end end + + defp interactive_stdio? do + case System.find_executable("sh") do + nil -> + false + + shell_path -> + case System.cmd(shell_path, ["-lc", "test -t 0 && test -t 1"], stderr_to_stdout: true) do + {_output, 0} -> true + _ -> false + end + end + end end diff --git a/elixir/lib/symphony_elixir/config/github_auth.ex b/elixir/lib/symphony_elixir/config/github_auth.ex index 8b353838d6..7ac4fb3807 100644 --- a/elixir/lib/symphony_elixir/config/github_auth.ex +++ b/elixir/lib/symphony_elixir/config/github_auth.ex @@ -1,7 +1,7 @@ defmodule SymphonyElixir.Config.GitHubAuth do @moduledoc false - @required_scopes ["project", "repo"] + @default_required_scopes ["project", "repo"] @enforce_keys [:host, :source, :token] defstruct [:host, :source, :token] @@ -23,9 +23,10 @@ defmodule SymphonyElixir.Config.GitHubAuth do @spec resolve_cli_token(String.t(), keyword()) :: {:ok, t()} | {:error, error_reason()} def resolve_cli_token(host, opts \\ []) when is_binary(host) do runner = Keyword.get(opts, :runner, &default_command_runner/3) + required_scopes = Keyword.get(opts, :required_scopes, @default_required_scopes) with {:ok, account} <- fetch_active_account(host, runner), - :ok <- ensure_required_scopes(host, account), + :ok <- ensure_required_scopes(host, account, required_scopes), {:ok, token_output} <- run_gh(host, gh_token_args(host), runner), {:ok, token} <- parse_token(host, token_output) do {:ok, %__MODULE__{host: host, source: :gh_cli, token: token}} @@ -109,7 +110,7 @@ defmodule SymphonyElixir.Config.GitHubAuth do end end - defp ensure_required_scopes(host, account) do + defp ensure_required_scopes(host, account, required_scopes) when is_list(required_scopes) do available_scopes = parse_scopes(Map.get(account, "scopes")) case available_scopes do @@ -120,7 +121,7 @@ defmodule SymphonyElixir.Config.GitHubAuth do available_set = MapSet.new(scopes) missing_scopes = - @required_scopes + required_scopes |> Enum.reject(&MapSet.member?(available_set, &1)) |> Enum.sort() diff --git a/elixir/lib/symphony_elixir/default_workflow.ex b/elixir/lib/symphony_elixir/default_workflow.ex index 17fe627301..c4bcd13d34 100644 --- a/elixir/lib/symphony_elixir/default_workflow.ex +++ b/elixir/lib/symphony_elixir/default_workflow.ex @@ -3,21 +3,44 @@ defmodule SymphonyElixir.DefaultWorkflow do Provides the default `WORKFLOW.md` template used for first-run bootstrap. """ + alias SymphonyElixir.DefaultWorkflow.Bootstrap + @template_path Path.expand("../../priv/default_workflow.md", __DIR__) @external_resource @template_path @template_contents File.read!(@template_path) + @type write_option :: + {:interactive, boolean()} + | {:gets, (String.t() -> String.t() | nil)} + | {:puts, (String.t() -> term())} + | {:env_getter, (String.t() -> String.t() | nil)} + | {:github_cli_runner, (String.t(), [String.t()], keyword() -> term())} + | {:github_query_fun, (String.t(), map(), keyword() -> {:ok, map()} | {:error, term()})} + | {:github_endpoint, String.t()} + + @type write_options :: [write_option()] + @spec contents() :: String.t() def contents do @template_contents end - @spec write(Path.t()) :: :ok | {:error, term()} - def write(path) when is_binary(path) do + @spec bootstrap_contents(write_options()) :: {:ok, String.t()} | {:error, term()} + def bootstrap_contents(opts \\ []) do + if Keyword.get(opts, :interactive, false) do + Bootstrap.run(opts) + else + {:ok, contents()} + end + end + + @spec write(Path.t(), write_options()) :: :ok | {:error, term()} + def write(path, opts \\ []) when is_binary(path) and is_list(opts) do expanded_path = Path.expand(path) - with :ok <- File.mkdir_p(Path.dirname(expanded_path)) do - File.write(expanded_path, contents()) + with {:ok, workflow_contents} <- bootstrap_contents(opts), + :ok <- File.mkdir_p(Path.dirname(expanded_path)) do + File.write(expanded_path, workflow_contents) end end end diff --git a/elixir/lib/symphony_elixir/default_workflow/bootstrap.ex b/elixir/lib/symphony_elixir/default_workflow/bootstrap.ex new file mode 100644 index 0000000000..57581b7fef --- /dev/null +++ b/elixir/lib/symphony_elixir/default_workflow/bootstrap.ex @@ -0,0 +1,933 @@ +defmodule SymphonyElixir.DefaultWorkflow.Bootstrap do + @moduledoc false + + alias SymphonyElixir.Config.GitHubAuth + alias SymphonyElixir.GitHub.Client + + @github_endpoint "https://api.github.com/graphql" + @github_api_key "$GITHUB_TOKEN" + @status_field_name "Status" + @project_list_limit 50 + @organization_list_limit 50 + @required_gh_scopes ["project", "repo", "read:org"] + @guided_active_states ["Todo", "Spec", "In Progress", "Rework", "In Review", "Merging"] + @guided_terminal_states ["Done", "Canceled", "Duplicated"] + @required_status_options [ + %{name: "Backlog", color: "GRAY", description: "Manual triage before automation."}, + %{name: "Todo", color: "BLUE", description: "Ready for an agent to pick up."}, + %{name: "Spec", color: "YELLOW", description: "Specification or planning is in progress."}, + %{name: "In Progress", color: "ORANGE", description: "Implementation is actively underway."}, + %{name: "Rework", color: "RED", description: "Feedback requires additional changes."}, + %{name: "In Review", color: "PURPLE", description: "Waiting for human review."}, + %{name: "Merging", color: "PINK", description: "Approved and landing the change."}, + %{name: "Done", color: "GREEN", description: "Completed successfully."}, + %{name: "Canceled", color: "GRAY", description: "Stopped without completing the work."}, + %{name: "Duplicated", color: "GRAY", description: "Superseded by another item."} + ] + @approval_options [ + %{value: "untrusted", label: "Untrusted (recommended)", description: "Ask for approval before running risky commands."}, + %{value: "on-failure", label: "On-failure", description: "Retry outside the sandbox only after a sandbox failure."}, + %{value: "on-request", label: "On-request", description: "Let the agent request elevated access when needed."}, + %{value: "never", label: "Never", description: "Run unattended without asking for approvals."} + ] + @sandbox_options [ + %{value: "workspace-write", label: "Workspace-write (recommended)", description: "Allow edits in the issue workspace while keeping broader access constrained."}, + %{value: "read-only", label: "Read-only", description: "Inspect files without allowing writes by default."}, + %{value: "danger-full-access", label: "Danger-full-access", description: "Allow unrestricted filesystem access for the session."} + ] + + @viewer_projects_query """ + query WorkflowBootstrapViewerProjects($projectsFirst: Int!) { + viewer { + id + login + viewerCanCreateProjects + projectsV2(first: $projectsFirst) { + nodes { + id + number + title + url + } + } + } + } + """ + + @organization_projects_query """ + query WorkflowBootstrapOrganizationProjects($ownersFirst: Int!, $projectsFirst: Int!) { + viewer { + organizations(first: $ownersFirst) { + nodes { + id + login + viewerCanCreateProjects + projectsV2(first: $projectsFirst) { + nodes { + id + number + title + url + } + } + } + } + } + } + """ + + @project_fields_query """ + query WorkflowBootstrapProjectFields($owner: String!, $number: Int!, $fieldsFirst: Int!) { + repositoryOwner(login: $owner) { + __typename + ... on Organization { + projectV2(number: $number) { + id + fields(first: $fieldsFirst) { + nodes { + __typename + ... on ProjectV2SingleSelectField { + id + name + options { + name + color + description + } + } + } + } + } + } + ... on User { + projectV2(number: $number) { + id + fields(first: $fieldsFirst) { + nodes { + __typename + ... on ProjectV2SingleSelectField { + id + name + options { + name + color + description + } + } + } + } + } + } + } + } + """ + + @create_project_mutation """ + mutation WorkflowBootstrapCreateProject($ownerId: ID!, $title: String!) { + createProjectV2(input: {ownerId: $ownerId, title: $title}) { + projectV2 { + id + number + title + url + } + } + } + """ + + @create_field_mutation """ + mutation WorkflowBootstrapCreateStatusField( + $projectId: ID! + $fieldName: String! + $options: [ProjectV2SingleSelectFieldOptionInput!] + ) { + createProjectV2Field( + input: { + projectId: $projectId + dataType: SINGLE_SELECT + name: $fieldName + singleSelectOptions: $options + } + ) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + name + } + } + } + } + """ + + @update_field_mutation """ + mutation WorkflowBootstrapUpdateStatusField( + $fieldId: ID! + $options: [ProjectV2SingleSelectFieldOptionInput!] + ) { + updateProjectV2Field(input: {fieldId: $fieldId, singleSelectOptions: $options}) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + name + } + } + } + } + """ + + defmodule Owner do + @moduledoc false + + @enforce_keys [:id, :login, :kind, :can_create_projects] + defstruct [:id, :login, :kind, :can_create_projects, projects: []] + + @type t :: %__MODULE__{ + id: String.t(), + login: String.t(), + kind: :user | :organization, + can_create_projects: boolean(), + projects: [SymphonyElixir.DefaultWorkflow.Bootstrap.Project.t()] + } + end + + defmodule Project do + @moduledoc false + + @enforce_keys [:id, :owner_login, :number, :title, :url] + defstruct [:id, :owner_login, :number, :title, :url] + + @type t :: %__MODULE__{ + id: String.t(), + owner_login: String.t(), + number: pos_integer(), + title: String.t(), + url: String.t() + } + end + + defmodule GuidedWorkflow do + @moduledoc false + + @enforce_keys [:project, :status_field_name, :approval_policy, :thread_sandbox] + defstruct [:project, :status_field_name, :approval_policy, :thread_sandbox] + + @type t :: %__MODULE__{ + project: SymphonyElixir.DefaultWorkflow.Bootstrap.Project.t(), + status_field_name: String.t(), + approval_policy: String.t(), + thread_sandbox: String.t() + } + end + + @type bootstrap_result :: {:ok, String.t()} | {:error, String.t()} + @type query_fun :: (String.t(), map(), keyword() -> {:ok, map()} | {:error, term()}) + @type prompt_io :: %{gets: (String.t() -> String.t() | nil), puts: (String.t() -> term())} + + @spec run(keyword()) :: bootstrap_result() + def run(opts \\ []) do + io = prompt_io(opts) + + case choose_bootstrap_mode(io) do + {:ok, mode} -> build_contents(mode, io, opts) + {:error, reason} -> {:error, reason} + end + end + + @spec guided_active_states() :: [String.t()] + def guided_active_states, do: @guided_active_states + + @spec guided_terminal_states() :: [String.t()] + def guided_terminal_states, do: @guided_terminal_states + + @spec required_status_names() :: [String.t()] + def required_status_names do + Enum.map(@required_status_options, & &1.name) + end + + @spec render_guided_workflow(GuidedWorkflow.t()) :: String.t() + def render_guided_workflow(%GuidedWorkflow{} = workflow) do + [ + "---", + "tracker:", + " kind: github_project", + " endpoint: #{yaml_scalar(@github_endpoint)}", + " api_key: #{yaml_scalar(@github_api_key)}", + " project_owner: #{yaml_scalar(workflow.project.owner_login)}", + " project_number: #{workflow.project.number}", + " project_field_status: #{yaml_scalar(workflow.status_field_name)}", + yaml_list(" active_states:", @guided_active_states), + yaml_list(" terminal_states:", @guided_terminal_states), + "polling:", + " interval_ms: 5000", + "workspace:", + " root: $SYMPHONY_WORKSPACE_ROOT", + "hooks:", + " after_create: |", + " git clone --depth 1 \"$SOURCE_REPO_URL\" .", + "agent:", + " max_concurrent_agents: 10", + " max_turns: 20", + "codex:", + " command: codex app-server", + " approval_policy: #{yaml_scalar(workflow.approval_policy)}", + " thread_sandbox: #{yaml_scalar(workflow.thread_sandbox)}", + "server:", + " port: 0", + " host: 127.0.0.1", + "---", + "", + "This WORKFLOW.md was bootstrapped for GitHub Project `#{workflow.project.owner_login}##{workflow.project.number}` (#{workflow.project.title}).", + "", + "Set these environment variables before running Symphony against your repo:", + "", + "- `GITHUB_TOKEN`: GitHub token with access to the project and repository.", + "- `SOURCE_REPO_URL`: repository clone URL used for new workspaces.", + "- `SYMPHONY_WORKSPACE_ROOT`: directory for local issue workspaces.", + "- GitHub Project URL: #{workflow.project.url}", + "- Status field `#{workflow.status_field_name}` includes `#{Enum.join(required_status_names(), "`, `")}`. `Backlog` remains available for manual triage and is intentionally excluded from `tracker.active_states` until work is ready for automation.", + "", + "You are working on an issue from the configured tracker.", + "", + "Identifier: {{ issue.identifier }}", + "Title: {{ issue.title }}", + "", + "Body:", + "{% if issue.description %}", + "{{ issue.description }}", + "{% else %}", + "No description provided.", + "{% endif %}", + "" + ] + |> List.flatten() + |> Enum.join("\n") + end + + defp build_contents(:baseline_template, _io, _opts) do + {:ok, SymphonyElixir.DefaultWorkflow.contents()} + end + + defp build_contents(:cancel, _io, _opts) do + {:error, "Canceled WORKFLOW.md bootstrap before creating a file."} + end + + defp build_contents(:guided_github, io, opts) do + with {:ok, auth} <- resolve_auth(opts), + {:ok, %{owners: owners, projects: projects}} <- fetch_accessible_projects(auth, opts), + {:ok, project} <- choose_project(io, owners, projects, auth, opts), + {:ok, status_field_name} <- ensure_status_field(project, auth, opts), + {:ok, approval_policy} <- choose_approval_policy(io), + {:ok, thread_sandbox} <- choose_thread_sandbox(io) do + {:ok, + render_guided_workflow(%GuidedWorkflow{ + project: project, + status_field_name: status_field_name, + approval_policy: approval_policy, + thread_sandbox: thread_sandbox + })} + end + end + + defp choose_bootstrap_mode(io) do + choose_option( + io, + "Choose how to create WORKFLOW.md:", + [ + %{value: :guided_github, label: "Guided GitHub Project setup", description: "Discover or create a GitHub Project and write a ready-to-run workflow."}, + %{value: :baseline_template, label: "Write the baseline template", description: "Create the non-interactive template and fill in the project details later."}, + %{value: :cancel, label: "Cancel startup", description: "Stop without writing WORKFLOW.md."} + ] + ) + end + + defp choose_project(io, owners, projects, auth, opts) do + createable_owners = Enum.filter(owners, & &1.can_create_projects) + + project_options = + Enum.map(projects, fn project -> + %{ + value: {:existing, project}, + label: "#{project.owner_login}/#{project.title} (##{project.number})", + description: project.url + } + end) + + options = + if createable_owners == [] do + project_options + else + project_options ++ + [ + %{ + value: :create_new, + label: "Create a new GitHub Project", + description: "Provision a new ProjectV2 board and configure Symphony status options." + } + ] + end + + if options == [] do + {:error, + "Guided GitHub bootstrap could not find any accessible ProjectV2 boards, and the authenticated account cannot create one. Ensure the account can read or create GitHub Projects for the target owner."} + else + case choose_option(io, "Select the GitHub Project to write into WORKFLOW.md:", options) do + {:ok, {:existing, project}} -> {:ok, project} + {:ok, :create_new} -> create_project(io, createable_owners, auth, opts) + {:error, reason} -> {:error, reason} + end + end + end + + defp create_project(io, owners, auth, opts) do + with {:ok, owner} <- choose_owner(io, owners), + {:ok, title} <- prompt_non_empty(io, "New GitHub Project title") do + do_create_project(owner, title, auth, opts) + end + end + + defp choose_owner(io, owners) do + owner_options = + Enum.map(owners, fn owner -> + %{ + value: owner, + label: owner.login, + description: owner_description(owner) + } + end) + + choose_option(io, "Choose the owner for the new GitHub Project:", owner_options) + end + + defp owner_description(%Owner{kind: :user}), do: "Viewer-owned project" + defp owner_description(%Owner{kind: :organization}), do: "Organization-owned project" + + defp choose_approval_policy(io) do + choose_option(io, "Choose the default Codex approval policy:", @approval_options) + end + + defp choose_thread_sandbox(io) do + choose_option(io, "Choose the default Codex sandbox mode:", @sandbox_options) + end + + defp resolve_auth(opts) do + endpoint = github_endpoint(opts) + host = github_cli_host(endpoint) + env_getter = Keyword.get(opts, :env_getter, &System.get_env/1) + + cond do + present_token?(env_getter.("GITHUB_TOKEN")) -> + {:ok, %GitHubAuth{host: host, source: :explicit_config, token: String.trim(env_getter.("GITHUB_TOKEN"))}} + + present_token?(env_getter.("GH_TOKEN")) -> + {:ok, %GitHubAuth{host: host, source: :explicit_config, token: String.trim(env_getter.("GH_TOKEN"))}} + + true -> + runner = Keyword.get(opts, :github_cli_runner, &GitHubAuth.default_command_runner/3) + + case GitHubAuth.resolve_cli_token(host, runner: runner, required_scopes: @required_gh_scopes) do + {:ok, %GitHubAuth{} = auth} -> {:ok, auth} + {:error, reason} -> {:error, format_auth_error(reason, host)} + end + end + end + + defp fetch_accessible_projects(%GitHubAuth{} = auth, opts) do + with {:ok, viewer} <- fetch_viewer_projects(auth, opts), + {:ok, organizations} <- fetch_organization_projects(auth, opts) do + viewer_owner = %Owner{ + id: viewer.id, + login: viewer.login, + kind: :user, + can_create_projects: viewer.can_create_projects + } + + owners = [viewer_owner | organizations] + + projects = + viewer.projects ++ + Enum.flat_map(organizations, fn owner -> + Map.get(owner, :projects, []) + end) + + {:ok, %{owners: owners, projects: projects}} + end + end + + defp fetch_viewer_projects(%GitHubAuth{} = auth, opts) do + case github_query( + @viewer_projects_query, + %{projectsFirst: @project_list_limit}, + auth, + opts, + operation: :list_viewer_projects, + operation_name: "WorkflowBootstrapViewerProjects" + ) do + {:ok, %{"data" => %{"viewer" => %{"id" => id, "login" => login} = viewer}}} -> + {:ok, + %{ + id: id, + login: login, + can_create_projects: Map.get(viewer, "viewerCanCreateProjects") == true, + projects: + viewer + |> get_in(["projectsV2", "nodes"]) + |> normalize_projects(login) + }} + + {:ok, _body} -> + {:error, "GitHub returned an unexpected payload while listing viewer-owned projects."} + + {:error, reason} -> + {:error, reason} + end + end + + defp fetch_organization_projects(%GitHubAuth{} = auth, opts) do + case github_query( + @organization_projects_query, + %{ownersFirst: @organization_list_limit, projectsFirst: @project_list_limit}, + auth, + opts, + operation: :list_organization_projects, + operation_name: "WorkflowBootstrapOrganizationProjects" + ) do + {:ok, %{"data" => %{"viewer" => %{"organizations" => %{"nodes" => owners}}}}} when is_list(owners) -> + {:ok, + Enum.flat_map(owners, fn owner -> + case normalize_owner(owner) do + {:ok, normalized_owner} -> [normalized_owner] + :error -> [] + end + end)} + + {:ok, _body} -> + {:error, "GitHub returned an unexpected payload while listing organization-owned projects."} + + {:error, reason} -> + {:error, reason} + end + end + + defp normalize_owner(%{"id" => id, "login" => login} = owner) + when is_binary(id) and is_binary(login) do + {:ok, + %Owner{ + id: id, + login: login, + kind: :organization, + can_create_projects: Map.get(owner, "viewerCanCreateProjects") == true, + projects: + owner + |> get_in(["projectsV2", "nodes"]) + |> normalize_projects(login) + }} + end + + defp normalize_owner(_owner), do: :error + + defp normalize_projects(nodes, owner_login) when is_list(nodes) and is_binary(owner_login) do + nodes + |> Enum.map(fn + %{"id" => id, "number" => number, "title" => title, "url" => url} + when is_binary(id) and is_integer(number) and number > 0 and is_binary(title) and is_binary(url) -> + %Project{id: id, owner_login: owner_login, number: number, title: title, url: url} + + _ -> + nil + end) + |> Enum.reject(&is_nil/1) + end + + defp normalize_projects(_nodes, _owner_login), do: [] + + defp do_create_project(%Owner{} = owner, title, %GitHubAuth{} = auth, opts) do + case github_query( + @create_project_mutation, + %{ownerId: owner.id, title: title}, + auth, + opts, + operation: {:create_project, owner.login}, + operation_name: "WorkflowBootstrapCreateProject" + ) do + {:ok, %{"data" => %{"createProjectV2" => %{"projectV2" => project}}}} -> + parse_created_project(project, owner.login) + + {:ok, _body} -> + unexpected_project_create_payload() + + {:error, reason} -> + {:error, reason} + end + end + + defp parse_created_project(project, owner_login) when is_binary(owner_login) do + case project do + %{"id" => id, "number" => number, "title" => created_title, "url" => url} + when is_binary(id) and is_integer(number) and number > 0 and is_binary(created_title) and + is_binary(url) -> + {:ok, + %Project{ + id: id, + owner_login: owner_login, + number: number, + title: created_title, + url: url + }} + + _ -> + unexpected_project_create_payload() + end + end + + defp unexpected_project_create_payload do + {:error, "GitHub returned an unexpected payload after creating the ProjectV2 board."} + end + + defp ensure_status_field(%Project{} = project, %GitHubAuth{} = auth, opts) do + with {:ok, %{project_id: project_id, fields: fields}} <- fetch_project_fields(project, auth, opts) do + case find_status_field(fields) do + %{id: field_id, name: field_name, options: options} -> + merged_options = merge_status_options(options) + maybe_update_status_field(field_id, field_name, options, merged_options, project, auth, opts) + + nil -> + create_status_field(project_id, project, auth, opts) + end + end + end + + defp fetch_project_fields(%Project{} = project, %GitHubAuth{} = auth, opts) do + case github_query( + @project_fields_query, + %{owner: project.owner_login, number: project.number, fieldsFirst: @project_list_limit}, + auth, + opts, + operation: {:fetch_project_fields, project.owner_login, project.number}, + operation_name: "WorkflowBootstrapProjectFields" + ) do + {:ok, %{"data" => %{"repositoryOwner" => %{"projectV2" => %{"id" => project_id, "fields" => %{"nodes" => nodes}}}}}} + when is_binary(project_id) and is_list(nodes) -> + {:ok, %{project_id: project_id, fields: normalize_fields(nodes)}} + + {:ok, _body} -> + {:error, "GitHub returned an unexpected payload while loading ProjectV2 fields."} + + {:error, reason} -> + {:error, reason} + end + end + + defp normalize_fields(nodes) when is_list(nodes) do + Enum.flat_map(nodes, fn + %{"__typename" => "ProjectV2SingleSelectField", "id" => id, "name" => name, "options" => options} + when is_binary(id) and is_binary(name) and is_list(options) -> + [ + %{ + id: id, + name: name, + options: normalize_field_options(options) + } + ] + + _ -> + [] + end) + end + + defp normalize_field_options(options) when is_list(options) do + Enum.flat_map(options, fn + %{"name" => name, "color" => color, "description" => description} + when is_binary(name) and is_binary(color) and is_binary(description) -> + [%{name: name, color: color, description: description}] + + _ -> + [] + end) + end + + defp find_status_field(fields) when is_list(fields) do + desired_name = normalize_name(@status_field_name) + + Enum.find(fields, fn field -> + normalize_name(field.name) == desired_name + end) + end + + defp merge_status_options(existing_options) when is_list(existing_options) do + existing_by_name = Map.new(existing_options, fn option -> {normalize_name(option.name), option} end) + canonical_names = MapSet.new(Enum.map(@required_status_options, &normalize_name(&1.name))) + + preserved_required = + Enum.map(@required_status_options, fn option -> + Map.get(existing_by_name, normalize_name(option.name), option) + end) + + preserved_other = + existing_options + |> Enum.reject(fn option -> + MapSet.member?(canonical_names, normalize_name(option.name)) + end) + + preserved_required ++ preserved_other + end + + defp maybe_update_status_field(field_id, field_name, existing_options, merged_options, project, auth, opts) do + if existing_options == merged_options do + {:ok, field_name} + else + case github_query( + @update_field_mutation, + %{fieldId: field_id, options: Enum.map(merged_options, &field_option_input/1)}, + auth, + opts, + operation: {:update_status_field, project.owner_login, project.number}, + operation_name: "WorkflowBootstrapUpdateStatusField" + ) do + {:ok, %{"data" => %{"updateProjectV2Field" => %{"projectV2Field" => %{"name" => updated_name}}}}} + when is_binary(updated_name) -> + {:ok, updated_name} + + {:ok, _body} -> + {:error, "GitHub returned an unexpected payload while updating the ProjectV2 status field."} + + {:error, reason} -> + {:error, reason} + end + end + end + + defp create_status_field(project_id, %Project{} = project, %GitHubAuth{} = auth, opts) do + case github_query( + @create_field_mutation, + %{ + projectId: project_id, + fieldName: @status_field_name, + options: Enum.map(@required_status_options, &field_option_input/1) + }, + auth, + opts, + operation: {:create_status_field, project.owner_login, project.number}, + operation_name: "WorkflowBootstrapCreateStatusField" + ) do + {:ok, %{"data" => %{"createProjectV2Field" => %{"projectV2Field" => %{"name" => field_name}}}}} + when is_binary(field_name) -> + {:ok, field_name} + + {:ok, _body} -> + {:error, "GitHub returned an unexpected payload while creating the ProjectV2 status field."} + + {:error, reason} -> + {:error, reason} + end + end + + defp choose_option(io, prompt, options) when is_list(options) and options != [] do + io.puts.(prompt) + + Enum.with_index(options, 1) + |> Enum.each(fn {option, index} -> + io.puts.("#{index}. #{option.label}") + + case Map.get(option, :description) do + description when is_binary(description) and description != "" -> + io.puts.(" #{description}") + + _ -> + :ok + end + end) + + do_choose_option(io, options) + end + + defp do_choose_option(io, options) do + case io.gets.("Enter choice [1-#{length(options)}]: ") do + nil -> + {:error, "Canceled WORKFLOW.md bootstrap before creating a file."} + + raw -> + case parse_menu_choice(raw, length(options)) do + {:ok, index} -> + {:ok, Enum.at(options, index - 1).value} + + {:error, message} -> + io.puts.(message) + do_choose_option(io, options) + end + end + end + + defp prompt_non_empty(io, label) do + case io.gets.(label <> ": ") do + nil -> + {:error, "Canceled WORKFLOW.md bootstrap before creating a file."} + + raw -> + case parse_non_empty_text(raw, label) do + {:ok, value} -> + {:ok, value} + + {:error, message} -> + io.puts.(message) + prompt_non_empty(io, label) + end + end + end + + defp parse_menu_choice(raw, option_count) when is_binary(raw) and option_count > 0 do + trimmed = String.trim(raw) + + case Integer.parse(trimmed) do + {index, ""} when index >= 1 and index <= option_count -> + {:ok, index} + + _ -> + {:error, "Enter a number between 1 and #{option_count}."} + end + end + + defp parse_non_empty_text(raw, label) when is_binary(raw) and is_binary(label) do + case String.trim(raw) do + "" -> {:error, "#{label} cannot be blank."} + value -> {:ok, value} + end + end + + defp github_query(query, variables, %GitHubAuth{} = auth, opts, metadata) do + query_fun = Keyword.get(opts, :github_query_fun, &Client.graphql/3) + operation = Keyword.fetch!(metadata, :operation) + operation_name = Keyword.fetch!(metadata, :operation_name) + + case query_fun.(query, variables, token: auth.token, endpoint: github_endpoint(opts), operation_name: operation_name) do + {:ok, %{"errors" => errors}} when is_list(errors) and errors != [] -> + {:error, format_graphql_error(operation, errors, auth.host)} + + {:ok, body} -> + {:ok, body} + + {:error, reason} -> + {:error, format_transport_error(operation, reason)} + end + end + + defp format_auth_error(reason, host) do + case GitHubAuth.error_message(reason, host) do + nil -> "Guided GitHub bootstrap could not resolve GitHub authentication." + message -> message + end + end + + defp format_graphql_error(operation, errors, host) when is_list(errors) do + condensed_errors = Enum.map(errors, &condense_graphql_error/1) + + cond do + Enum.any?(condensed_errors, &(&1.type == "INSUFFICIENT_SCOPES")) -> + "Guided GitHub bootstrap needs GitHub scopes `repo`, `project`, and `read:org`. Refresh auth with `gh auth refresh --hostname #{host} --scopes repo,project,read:org`, or update `GITHUB_TOKEN` / `GH_TOKEN` with those scopes." + + Enum.any?(condensed_errors, &(&1.type in ["FORBIDDEN", "UNAUTHORIZED"])) -> + "GitHub denied the #{operation_label(operation)} operation. Ensure the authenticated principal can read and manage ProjectV2 boards for the selected owner." + + true -> + detail = Enum.map_join(condensed_errors, "; ", & &1.message) + "GitHub reported an error while #{operation_label(operation)}: #{detail}" + end + end + + defp format_transport_error(operation, {:github_api_status, status}) do + "GitHub returned HTTP #{status} while #{operation_label(operation)}." + end + + defp format_transport_error(operation, {:github_api_request, reason}) do + "GitHub request failed while #{operation_label(operation)}: #{inspect(reason)}" + end + + defp format_transport_error(operation, reason) do + "GitHub request failed while #{operation_label(operation)}: #{inspect(reason)}" + end + + defp condense_graphql_error(%{"message" => message} = error) when is_binary(message) do + %{message: String.trim(message), type: Map.get(error, "type")} + end + + defp condense_graphql_error(error) do + %{message: inspect(error), type: nil} + end + + defp operation_label(:list_viewer_projects), do: "listing viewer-owned GitHub Projects" + defp operation_label(:list_organization_projects), do: "listing organization-owned GitHub Projects" + defp operation_label({:create_project, owner_login}), do: "creating a GitHub Project for #{owner_login}" + + defp operation_label({:fetch_project_fields, owner_login, number}), + do: "loading status fields for #{owner_login}##{number}" + + defp operation_label({:create_status_field, owner_login, number}), + do: "creating the status field for #{owner_login}##{number}" + + defp operation_label({:update_status_field, owner_login, number}), + do: "reconciling the status field for #{owner_login}##{number}" + + defp prompt_io(opts) do + %{ + gets: Keyword.get(opts, :gets, &IO.gets/1), + puts: Keyword.get(opts, :puts, &IO.puts/1) + } + end + + defp github_endpoint(opts) do + Keyword.get(opts, :github_endpoint, @github_endpoint) + end + + defp github_cli_host(endpoint) when is_binary(endpoint) do + case URI.parse(endpoint) do + %URI{host: "api.github.com"} -> "github.com" + %URI{host: host} when is_binary(host) and host != "" -> host + _ -> "github.com" + end + end + + defp present_token?(value) when is_binary(value) do + String.trim(value) != "" + end + + defp present_token?(_value), do: false + + defp field_option_input(option) do + %{ + name: option.name, + color: option_color_atom(option.color), + description: option.description + } + end + + defp option_color_atom(color) when is_binary(color) do + case String.downcase(String.trim(color)) do + "blue" -> :BLUE + "green" -> :GREEN + "yellow" -> :YELLOW + "orange" -> :ORANGE + "red" -> :RED + "pink" -> :PINK + "purple" -> :PURPLE + _ -> :GRAY + end + end + + defp option_color_atom(_color), do: :GRAY + + defp normalize_name(value) when is_binary(value) do + value + |> String.trim() + |> String.downcase() + end + + defp normalize_name(_value), do: "" + + defp yaml_scalar(value) when is_binary(value) do + escaped = String.replace(value, "\"", "\\\"") + "\"#{escaped}\"" + end + + defp yaml_list(header, values) when is_list(values) do + [header | Enum.map(values, &" - #{yaml_scalar(&1)}")] + end +end diff --git a/elixir/lib/symphony_elixir/github/client.ex b/elixir/lib/symphony_elixir/github/client.ex index 84caba5ecb..6a08898299 100644 --- a/elixir/lib/symphony_elixir/github/client.ex +++ b/elixir/lib/symphony_elixir/github/client.ex @@ -5,6 +5,7 @@ defmodule SymphonyElixir.GitHub.Client do require Logger alias SymphonyElixir.Config + alias SymphonyElixir.Config.GitHubAuth @max_error_body_log_bytes 1_000 @@ -12,9 +13,14 @@ defmodule SymphonyElixir.GitHub.Client do def graphql(query, variables \\ %{}, opts \\ []) when is_binary(query) and is_map(variables) and is_list(opts) do payload = build_graphql_payload(query, variables, Keyword.get(opts, :operation_name)) - request_fun = Keyword.get(opts, :request_fun, &post_graphql_request/2) + endpoint = Keyword.get(opts, :endpoint, Config.github_endpoint()) - with {:ok, headers} <- graphql_headers(), + request_fun = + Keyword.get(opts, :request_fun, fn request_payload, headers -> + post_graphql_request(endpoint, request_payload, headers) + end) + + with {:ok, headers} <- graphql_headers(opts), {:ok, %{status: 200, body: body}} <- request_fun.(payload, headers) do {:ok, body} else @@ -36,9 +42,9 @@ defmodule SymphonyElixir.GitHub.Client do end end - defp graphql_headers do - case Config.github_auth() do - {:ok, %{token: token}} -> + defp graphql_headers(opts) do + case auth_from_opts(opts) do + {:ok, %GitHubAuth{token: token}} -> {:ok, [ {"Authorization", "Bearer #{token}"}, @@ -52,8 +58,28 @@ defmodule SymphonyElixir.GitHub.Client do end end - defp post_graphql_request(payload, headers) do - Req.post(Config.github_endpoint(), + defp auth_from_opts(opts) do + case Keyword.get(opts, :token) do + token when is_binary(token) and token != "" -> + {:ok, %GitHubAuth{host: github_host(opts), source: :explicit_config, token: token}} + + _ -> + Config.github_auth() + end + end + + defp github_host(opts) do + endpoint = Keyword.get(opts, :endpoint, Config.github_endpoint()) + + case URI.parse(endpoint) do + %URI{host: "api.github.com"} -> "github.com" + %URI{host: host} when is_binary(host) and host != "" -> host + _ -> "github.com" + end + end + + defp post_graphql_request(endpoint, payload, headers) do + Req.post(endpoint, headers: headers, json: payload, connect_options: [timeout: 30_000] @@ -97,7 +123,7 @@ defmodule SymphonyElixir.GitHub.Client do defp summarize_error_body(body) when is_binary(body) do body - |> String.replace(~r/\\s+/, " ") + |> String.replace(~r/\s+/, " ") |> String.trim() |> truncate_error_body() |> inspect() diff --git a/elixir/mix.exs b/elixir/mix.exs index ae90bf64d9..bdb9b42110 100644 --- a/elixir/mix.exs +++ b/elixir/mix.exs @@ -15,6 +15,8 @@ defmodule SymphonyElixir.MixProject do ignore_modules: [ SymphonyElixir.Config, SymphonyElixir.GitHub.Client, + SymphonyElixir.DefaultWorkflow, + SymphonyElixir.DefaultWorkflow.Bootstrap, SymphonyElixir.GitHub.Project.Adapter, SymphonyElixir.Linear.Client, SymphonyElixir.SpecsCheck, diff --git a/elixir/test/symphony_elixir/cli_test.exs b/elixir/test/symphony_elixir/cli_test.exs index 3e0b6c421c..8c4399e689 100644 --- a/elixir/test/symphony_elixir/cli_test.exs +++ b/elixir/test/symphony_elixir/cli_test.exs @@ -3,7 +3,7 @@ defmodule SymphonyElixir.CLITest do alias SymphonyElixir.CLI - test "defaults to WORKFLOW.md when workflow path is missing and bootstraps it" do + test "defaults to WORKFLOW.md when workflow path is missing and bootstraps the baseline template" do parent = self() deps = %{ @@ -11,10 +11,11 @@ defmodule SymphonyElixir.CLITest do send(parent, {:workflow_checked, path}) false end, - write_default_workflow: fn path -> - send(parent, {:workflow_bootstrapped, path}) + write_default_workflow: fn path, opts -> + send(parent, {:workflow_bootstrapped, path, opts}) :ok end, + interactive_stdio?: fn -> false end, notify: fn message -> send(parent, {:notified, message}) end, @@ -29,11 +30,62 @@ defmodule SymphonyElixir.CLITest do assert :ok = CLI.evaluate([], deps) assert_received {:workflow_checked, expanded_path} - assert_received {:workflow_bootstrapped, ^expanded_path} + assert_received {:workflow_bootstrapped, ^expanded_path, opts} assert_received {:workflow_set, ^expanded_path} assert_received {:notified, message} assert expanded_path == Path.expand("WORKFLOW.md") - assert message =~ "Created default WORKFLOW.md" + assert opts == [interactive: false] + assert message =~ "Created WORKFLOW.md" + end + + test "uses the guided bootstrap path when stdio is interactive" do + parent = self() + + deps = %{ + file_regular?: fn path -> + send(parent, {:workflow_checked, path}) + false + end, + write_default_workflow: fn path, opts -> + send(parent, {:workflow_bootstrapped, path, opts}) + :ok + end, + interactive_stdio?: fn -> true end, + notify: fn message -> + send(parent, {:notified, message}) + end, + set_workflow_file_path: fn path -> + send(parent, {:workflow_set, path}) + :ok + end, + set_logs_root: fn _path -> :ok end, + set_server_port_override: fn _port -> :ok end, + ensure_all_started: fn -> {:ok, [:symphony_elixir]} end + } + + assert :ok = CLI.evaluate([], deps) + assert_received {:workflow_checked, expanded_path} + assert_received {:workflow_bootstrapped, ^expanded_path, [interactive: true]} + assert_received {:workflow_set, ^expanded_path} + assert_received {:notified, message} + assert message =~ "Created WORKFLOW.md" + end + + test "surfaces interactive bootstrap failures without setting the workflow path" do + deps = %{ + file_regular?: fn _path -> false end, + write_default_workflow: fn _path, _opts -> {:error, "Canceled WORKFLOW.md bootstrap before creating a file."} end, + interactive_stdio?: fn -> true end, + notify: fn _message -> flunk("notify should not be called when bootstrap fails") end, + set_workflow_file_path: fn _path -> + flunk("workflow path should not be set when bootstrap fails") + end, + set_logs_root: fn _path -> :ok end, + set_server_port_override: fn _port -> :ok end, + ensure_all_started: fn -> {:ok, [:symphony_elixir]} end + } + + assert {:error, "Canceled WORKFLOW.md bootstrap before creating a file."} = CLI.evaluate([], deps) end test "uses an explicit workflow path override when provided" do @@ -46,10 +98,11 @@ defmodule SymphonyElixir.CLITest do send(parent, {:workflow_checked, path}) path == expanded_path end, - write_default_workflow: fn _path -> + write_default_workflow: fn _path, _opts -> send(parent, :unexpected_bootstrap) :ok end, + interactive_stdio?: fn -> false end, notify: fn _message -> send(parent, :unexpected_notify) end, @@ -74,7 +127,8 @@ defmodule SymphonyElixir.CLITest do deps = %{ file_regular?: fn _path -> true end, - write_default_workflow: fn _path -> :ok end, + write_default_workflow: fn _path, _opts -> :ok end, + interactive_stdio?: fn -> false end, notify: fn _message -> :ok end, set_workflow_file_path: fn _path -> :ok end, set_logs_root: fn path -> @@ -93,7 +147,8 @@ defmodule SymphonyElixir.CLITest do test "returns not found when an explicit workflow file does not exist" do deps = %{ file_regular?: fn _path -> false end, - write_default_workflow: fn _path -> :ok end, + write_default_workflow: fn _path, _opts -> :ok end, + interactive_stdio?: fn -> false end, notify: fn _message -> :ok end, set_workflow_file_path: fn _path -> :ok end, set_logs_root: fn _path -> :ok end, @@ -108,7 +163,8 @@ defmodule SymphonyElixir.CLITest do test "returns startup error when app cannot start" do deps = %{ file_regular?: fn _path -> true end, - write_default_workflow: fn _path -> :ok end, + write_default_workflow: fn _path, _opts -> :ok end, + interactive_stdio?: fn -> false end, notify: fn _message -> :ok end, set_workflow_file_path: fn _path -> :ok end, set_logs_root: fn _path -> :ok end, @@ -124,7 +180,8 @@ defmodule SymphonyElixir.CLITest do test "returns ok when workflow exists and app starts" do deps = %{ file_regular?: fn _path -> true end, - write_default_workflow: fn _path -> :ok end, + write_default_workflow: fn _path, _opts -> :ok end, + interactive_stdio?: fn -> false end, notify: fn _message -> :ok end, set_workflow_file_path: fn _path -> :ok end, set_logs_root: fn _path -> :ok end, diff --git a/elixir/test/symphony_elixir/github_client_test.exs b/elixir/test/symphony_elixir/github_client_test.exs index 53fc2234a4..a17d75716b 100644 --- a/elixir/test/symphony_elixir/github_client_test.exs +++ b/elixir/test/symphony_elixir/github_client_test.exs @@ -143,4 +143,25 @@ defmodule SymphonyElixir.GitHub.ClientTest do assert log =~ "body=%{" assert log =~ "errors" end + + test "graphql accepts explicit token and endpoint overrides" do + request_fun = fn payload, headers -> + send(self(), {:request_sent, payload, headers}) + {:ok, %{status: 200, body: %{"data" => %{"viewer" => %{"login" => "override-user"}}}}} + end + + assert {:ok, %{"data" => %{"viewer" => %{"login" => "override-user"}}}} = + Client.graphql("query Viewer { viewer { login } }", %{}, + token: "override-token", + endpoint: "https://ghe.example.test/api/graphql", + request_fun: request_fun + ) + + assert_received {:request_sent, %{"query" => "query Viewer { viewer { login } }", "variables" => %{}}, headers} + + assert Enum.any?(headers, fn + {"Authorization", "Bearer override-token"} -> true + _ -> false + end) + end end diff --git a/elixir/test/symphony_elixir/workflow_bootstrap_test.exs b/elixir/test/symphony_elixir/workflow_bootstrap_test.exs new file mode 100644 index 0000000000..7fade67332 --- /dev/null +++ b/elixir/test/symphony_elixir/workflow_bootstrap_test.exs @@ -0,0 +1,336 @@ +defmodule SymphonyElixir.WorkflowBootstrapTest do + use ExUnit.Case, async: true + + alias SymphonyElixir.DefaultWorkflow.Bootstrap + + test "guided bootstrap can select an existing project and reconcile the status field" do + parent = self() + set_bootstrap_inputs(["1\n", "1\n", "1\n", "1\n"]) + + github_query_fun = fn _query, variables, opts -> + send(parent, {:graphql_call, opts[:operation_name], variables, opts}) + + case opts[:operation_name] do + "WorkflowBootstrapViewerProjects" -> + {:ok, + %{ + "data" => %{ + "viewer" => %{ + "id" => "viewer-id", + "login" => "viewer-user", + "viewerCanCreateProjects" => true, + "projectsV2" => %{ + "nodes" => [ + %{ + "id" => "project-1", + "number" => 1, + "title" => "Shipping Board", + "url" => "https://github.com/users/viewer-user/projects/1" + } + ] + } + } + } + }} + + "WorkflowBootstrapOrganizationProjects" -> + {:ok, + %{ + "data" => %{ + "viewer" => %{ + "organizations" => %{ + "nodes" => [ + %{ + "id" => "org-1", + "login" => "octo-org", + "viewerCanCreateProjects" => false, + "projectsV2" => %{"nodes" => []} + } + ] + } + } + } + }} + + "WorkflowBootstrapProjectFields" -> + {:ok, + %{ + "data" => %{ + "repositoryOwner" => %{ + "projectV2" => %{ + "id" => "project-node-1", + "fields" => %{ + "nodes" => [ + %{ + "__typename" => "ProjectV2SingleSelectField", + "id" => "status-field-1", + "name" => "Status", + "options" => [ + %{ + "name" => "Todo", + "color" => "BLUE", + "description" => "Existing Todo" + }, + %{ + "name" => "Needs Design", + "color" => "PURPLE", + "description" => "Keep me" + } + ] + } + ] + } + } + } + } + }} + + "WorkflowBootstrapUpdateStatusField" -> + assert opts[:token] == "token-123" + assert variables[:fieldId] == "status-field-1" + + option_names = Enum.map(variables[:options], & &1.name) + assert option_names == Bootstrap.required_status_names() ++ ["Needs Design"] + + {:ok, + %{ + "data" => %{ + "updateProjectV2Field" => %{ + "projectV2Field" => %{"name" => "Status"} + } + } + }} + end + end + + assert {:ok, contents} = + Bootstrap.run( + gets: &scripted_gets/1, + puts: &scripted_puts/1, + env_getter: env_getter(%{"GITHUB_TOKEN" => "token-123"}), + github_query_fun: github_query_fun + ) + + assert contents =~ ~s(project_owner: "viewer-user") + assert contents =~ "project_number: 1" + assert contents =~ ~s(approval_policy: "untrusted") + assert contents =~ ~s(thread_sandbox: "workspace-write") + assert contents =~ "`Backlog` remains available for manual triage" + + assert_received {:graphql_call, "WorkflowBootstrapViewerProjects", _variables, _opts} + assert_received {:graphql_call, "WorkflowBootstrapOrganizationProjects", _variables, _opts} + assert_received {:graphql_call, "WorkflowBootstrapProjectFields", _variables, _opts} + assert_received {:graphql_call, "WorkflowBootstrapUpdateStatusField", _variables, _opts} + end + + test "guided bootstrap can create a new project and initialize the status field" do + parent = self() + set_bootstrap_inputs(["1\n", "1\n", "1\n", "Team Launch Board\n", "4\n", "3\n"]) + + github_query_fun = fn _query, variables, opts -> + send(parent, {:graphql_call, opts[:operation_name], variables}) + + case opts[:operation_name] do + "WorkflowBootstrapViewerProjects" -> + {:ok, + %{ + "data" => %{ + "viewer" => %{ + "id" => "viewer-id", + "login" => "viewer-user", + "viewerCanCreateProjects" => true, + "projectsV2" => %{"nodes" => []} + } + } + }} + + "WorkflowBootstrapOrganizationProjects" -> + {:ok, %{"data" => %{"viewer" => %{"organizations" => %{"nodes" => []}}}}} + + "WorkflowBootstrapCreateProject" -> + assert variables[:ownerId] == "viewer-id" + assert variables[:title] == "Team Launch Board" + + {:ok, + %{ + "data" => %{ + "createProjectV2" => %{ + "projectV2" => %{ + "id" => "project-7", + "number" => 7, + "title" => "Team Launch Board", + "url" => "https://github.com/users/viewer-user/projects/7" + } + } + } + }} + + "WorkflowBootstrapProjectFields" -> + {:ok, + %{ + "data" => %{ + "repositoryOwner" => %{ + "projectV2" => %{ + "id" => "project-7", + "fields" => %{"nodes" => []} + } + } + } + }} + + "WorkflowBootstrapCreateStatusField" -> + assert variables[:projectId] == "project-7" + assert Enum.map(variables[:options], & &1.name) == Bootstrap.required_status_names() + + {:ok, + %{ + "data" => %{ + "createProjectV2Field" => %{ + "projectV2Field" => %{"name" => "Status"} + } + } + }} + end + end + + assert {:ok, contents} = + Bootstrap.run( + gets: &scripted_gets/1, + puts: &scripted_puts/1, + env_getter: env_getter(%{"GITHUB_TOKEN" => "token-123"}), + github_query_fun: github_query_fun + ) + + assert contents =~ ~s(project_owner: "viewer-user") + assert contents =~ "project_number: 7" + assert contents =~ ~s(approval_policy: "never") + assert contents =~ ~s(thread_sandbox: "danger-full-access") + + assert_received {:graphql_call, "WorkflowBootstrapCreateProject", _variables} + assert_received {:graphql_call, "WorkflowBootstrapCreateStatusField", _variables} + end + + test "guided bootstrap surfaces missing auth guidance before writing a workflow" do + set_bootstrap_inputs(["1\n"]) + + github_cli_runner = fn "gh", ["auth", "status", "--hostname", "github.com", "--json", "hosts"], _opts -> + {:ok, ~s({"hosts":{}})} + end + + assert {:error, message} = + Bootstrap.run( + gets: &scripted_gets/1, + puts: &scripted_puts/1, + env_getter: env_getter(%{}), + github_cli_runner: github_cli_runner + ) + + assert message =~ "gh auth login --hostname github.com --scopes repo,project,read:org" + end + + test "guided bootstrap reports permission failures with operation context" do + set_bootstrap_inputs(["1\n"]) + + github_query_fun = fn _query, _variables, opts -> + case opts[:operation_name] do + "WorkflowBootstrapViewerProjects" -> + {:ok, + %{ + "data" => %{ + "viewer" => %{ + "id" => "viewer-id", + "login" => "viewer-user", + "viewerCanCreateProjects" => false, + "projectsV2" => %{"nodes" => []} + } + } + }} + + "WorkflowBootstrapOrganizationProjects" -> + {:ok, %{"errors" => [%{"type" => "FORBIDDEN", "message" => "denied"}]}} + end + end + + assert {:error, message} = + Bootstrap.run( + gets: &scripted_gets/1, + puts: &scripted_puts/1, + env_getter: env_getter(%{"GITHUB_TOKEN" => "token-123"}), + github_query_fun: github_query_fun + ) + + assert message =~ "GitHub denied the listing organization-owned GitHub Projects operation" + end + + test "guided bootstrap reports GitHub API status failures with the failing operation" do + set_bootstrap_inputs(["1\n", "1\n"]) + + github_query_fun = fn _query, _variables, opts -> + case opts[:operation_name] do + "WorkflowBootstrapViewerProjects" -> + {:ok, + %{ + "data" => %{ + "viewer" => %{ + "id" => "viewer-id", + "login" => "viewer-user", + "viewerCanCreateProjects" => false, + "projectsV2" => %{ + "nodes" => [ + %{ + "id" => "project-1", + "number" => 1, + "title" => "Shipping Board", + "url" => "https://github.com/users/viewer-user/projects/1" + } + ] + } + } + } + }} + + "WorkflowBootstrapOrganizationProjects" -> + {:ok, %{"data" => %{"viewer" => %{"organizations" => %{"nodes" => []}}}}} + + "WorkflowBootstrapProjectFields" -> + {:error, {:github_api_status, 502}} + end + end + + assert {:error, message} = + Bootstrap.run( + gets: &scripted_gets/1, + puts: &scripted_puts/1, + env_getter: env_getter(%{"GITHUB_TOKEN" => "token-123"}), + github_query_fun: github_query_fun + ) + + assert message == "GitHub returned HTTP 502 while loading status fields for viewer-user#1." + end + + defp env_getter(env) do + fn key -> Map.get(env, key) end + end + + defp set_bootstrap_inputs(inputs) do + Process.put(:workflow_bootstrap_inputs, inputs) + end + + defp scripted_gets(prompt) do + send(self(), {:prompt, prompt}) + + case Process.get(:workflow_bootstrap_inputs, []) do + [next | rest] -> + Process.put(:workflow_bootstrap_inputs, rest) + next + + [] -> + nil + end + end + + defp scripted_puts(message) do + send(self(), {:output, message}) + :ok + end +end