From d01f0a48c32405b4e456e6d190a6bdec52ce91a9 Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:31:53 +0900 Subject: [PATCH 01/12] feat(init): select engine via --engine or interactive menu init writes engine: into .github/aixgo.yml and a conditional caller that only wires Codex/Azure, Gemini/Vertex, or Claude (App-only + #147 note). Non-TTY defaults to codex; --force overwrites starters to switch providers. Part 1/2: docs and caller template. --- docs/templates/aixgo-caller.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/templates/aixgo-caller.yml b/docs/templates/aixgo-caller.yml index 6237025..ad9fd7a 100644 --- a/docs/templates/aixgo-caller.yml +++ b/docs/templates/aixgo-caller.yml @@ -1,3 +1,7 @@ +# Caller workflow written by `aixgo-code init --workflow`. +# Credentials match the engine chosen at init (--engine or the interactive menu). +# Re-run with --force --engine to rewrite config and this file for a +# different provider. Existing files are left unchanged without --force. name: aixgo on: @@ -59,6 +63,7 @@ jobs: pr-number: ${{ github.event.issue.pull_request != null && github.event.issue.number || '' }} actor-login: ${{ github.event.comment.user.login }} azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }} + # model: my-gpt-5-4-deployment secrets: azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }} From 4debc1431642535ca56af94d62ecbbbc043eb345 Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:33:01 +0900 Subject: [PATCH 02/12] feat(init): sync caller embed template for conditional engines --- cmd/aixgo-code/aixgo-caller.yml.tmpl | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/cmd/aixgo-code/aixgo-caller.yml.tmpl b/cmd/aixgo-code/aixgo-caller.yml.tmpl index d1c683e..2d91ff5 100644 --- a/cmd/aixgo-code/aixgo-caller.yml.tmpl +++ b/cmd/aixgo-code/aixgo-caller.yml.tmpl @@ -1,3 +1,7 @@ +# Caller workflow written by `aixgo-code init --workflow`. +# Credentials match the engine chosen at init (--engine or the interactive menu). +# Re-run with --force --engine to rewrite config and this file for a +# different provider. Existing files are left unchanged without --force. name: aixgo on: @@ -17,11 +21,9 @@ jobs: github-app-client-id: ${{ vars.AIXGO_GH_APP_CLIENT_ID }} issue-number: ${{ github.event.issue.number }} actor-login: ${{ github.event.sender.login }} - azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }} - # model: my-gpt-5-4-deployment +# __AIXGO_ENGINE_INPUTS__ secrets: - azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} - github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }} +# __AIXGO_ENGINE_SECRETS__ address: if: >- @@ -33,11 +35,9 @@ jobs: github-app-client-id: ${{ vars.AIXGO_GH_APP_CLIENT_ID }} pr-number: ${{ github.event.pull_request.number }} actor-login: ${{ github.event.review.user.login }} - azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }} - # model: my-gpt-5-4-deployment +# __AIXGO_ENGINE_INPUTS__ secrets: - azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} - github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }} +# __AIXGO_ENGINE_SECRETS__ comment: # issue_comment fires for any user on a public repository, so the command @@ -58,7 +58,6 @@ jobs: issue-number: ${{ github.event.issue.pull_request == null && github.event.issue.number || '' }} pr-number: ${{ github.event.issue.pull_request != null && github.event.issue.number || '' }} actor-login: ${{ github.event.comment.user.login }} - azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }} +# __AIXGO_ENGINE_INPUTS__ secrets: - azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }} - github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }} +# __AIXGO_ENGINE_SECRETS__ From 0b2a421365966528d352a3ad7f26ad60af0c8e4a Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:35:09 +0900 Subject: [PATCH 03/12] docs(readme): init engine select and conditional caller --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7ba7bb5..5524c35 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,10 @@ To run the loop inside your own GitHub Actions: Disable the App webhook: the App is an identity that mints per-job tokens, and there is no Aixgo server to receive deliveries. Set install visibility to `Any account`. Install it on the repo. -2. In the adopter repo, run `aixgo-code init --workflow`. That writes `.github/aixgo.yml`, writes `.github/workflows/aixgo.yml` pinned to a released reusable-workflow tag, and creates the `ax:*` labels through your local `gh` auth. +2. In the adopter repo, run `aixgo-code init --workflow --app-name `. Interactively (TTY), init asks which engine to use; pass `--engine codex|gemini|claude` to skip the menu. Non-interactive runs default to `codex` and print that default. That writes `.github/aixgo.yml` with `engine:`, writes a **conditional** `.github/workflows/aixgo.yml` that only wires credentials for the chosen engine (pinned to a released reusable-workflow tag), and creates the `ax:*` labels through your local `gh` auth. Existing starter files are left unchanged; pass `--force` to overwrite (for example to switch engines). 3. Fill in the real `gate:` in `.github/aixgo.yml`. -4. Add repository variable `AIXGO_GH_APP_CLIENT_ID` (the App Client ID, the `Iv23` string on the App settings page), repository secret `AIXGO_GH_APP_PRIVATE_KEY`, repository variable `AIXGO_AZURE_OPENAI_ENDPOINT`, and repository secret `AIXGO_AZURE_OPENAI_API_KEY`. - These are per-repository and are never inherited from Aixgo: a reusable workflow runs with the calling repository's own variables and secrets, so you bring your own Azure endpoint and key, and pay for your own tokens. +4. Add repository variable `AIXGO_GH_APP_CLIENT_ID` (the App Client ID, the `Iv23` string on the App settings page) and repository secret `AIXGO_GH_APP_PRIVATE_KEY`, plus the engine credentials init listed for your choice (Azure for Codex, Vertex for Gemini; Claude is local-CLI today — Actions wiring tracked in [#147](https://github.com/aixgo-dev/code/issues/147)). + These are per-repository and are never inherited from Aixgo: a reusable workflow runs with the calling repository's own variables and secrets. The private key secret must be the full PEM contents, including the `-----BEGIN` and `-----END` lines. 5. Open a setup pull request in the adopter repo and merge it yourself. Setup files are written locally by `aixgo-code init` and merged by a human, because the runtime holds no `workflows` permission and cannot add its own workflow files. 6. File an issue and apply `ax:go`. Reviews submitted on the resulting pull request call back into the same reusable workflow for the fix-on-request loop. @@ -201,7 +201,7 @@ Getting the gate right is where first runs stall: it has to be green on your own ## Engines -The model that writes the code sits behind a pluggable `Runner` interface, so you bring your own provider. Set `engine:` in [`.github/aixgo.yml`](#configuration). The loop, roles, and gate are identical for every engine. +The model that writes the code sits behind a pluggable `Runner` interface, so you bring your own provider. `aixgo-code init` chooses the engine (`--engine` or an interactive menu) and writes `engine:` into [`.github/aixgo.yml`](#configuration) plus a caller workflow that only passes that provider's inputs/secrets. You can also set `engine:` by hand. The loop, roles, and gate are identical for every engine. | Engine | Config | Local CLI | GitHub Actions | Credentials | | --- | --- | --- | --- | --- | From 2c0b3452c00cf24ed8c3ae21b942ef04f71bb859 Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:36:22 +0900 Subject: [PATCH 04/12] docs(setup): init chooses engine; conditional caller --- docs/setup.md | 63 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/docs/setup.md b/docs/setup.md index e526b76..cd5792b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -44,8 +44,18 @@ With `v0.5.0`, `aixgo-code version` prints `0.5.0`. ```sh aixgo-code init --workflow --app-name +# or non-interactive / CI: +aixgo-code init --workflow --app-name --engine gemini ``` +On a TTY, init shows a short menu (1 Codex/Azure, 2 Gemini/Vertex, 3 Claude). +Pass `--engine codex|gemini|claude` to skip it. Without a TTY and without +`--engine`, it defaults to `codex` and prints one line saying so, so existing +scripts keep working. The starter `.github/aixgo.yml` gets `engine: `, +and the caller workflow only wires that engine's credentials (not a +multi-provider starter that always shows Azure). Existing starter files are +left unchanged; pass `--force` to overwrite them when switching providers. + `--app-name` is the GitHub App you created in step 4, without the `[bot]` suffix. **It is required, and it is yours, not ours.** App names are globally unique, so every installation has a different one, and it is what your team @@ -134,12 +144,17 @@ App on the repository from `Install App`. Then add these repository settings. **Variables and Secrets are different tabs** under Settings > Secrets and variables > Actions, and a value filed under the -wrong one reads back as empty rather than failing: +wrong one reads back as empty rather than failing. Always set: - Variable: `AIXGO_GH_APP_CLIENT_ID`, the App Client ID, the `Iv23` string on the App settings page - Secret: `AIXGO_GH_APP_PRIVATE_KEY` -- Variable: `AIXGO_AZURE_OPENAI_ENDPOINT` -- Secret: `AIXGO_AZURE_OPENAI_API_KEY` + +Plus the credentials for the engine `init` chose (it prints them in next steps): + +- **Codex:** variable `AIXGO_AZURE_OPENAI_ENDPOINT`, secret `AIXGO_AZURE_OPENAI_API_KEY` +- **Gemini:** variables `AIXGO_VERTEX_PROJECT` and optional `AIXGO_VERTEX_LOCATION`, secret `AIXGO_VERTEX_API_KEY` +- **Claude:** local CLI auth today; Actions Anthropic wiring is tracked in + [aixgo-dev/code#147](https://github.com/aixgo-dev/code/issues/147) The Actions runtime authenticates as your App, so the Client ID and private key are both required. Store the private key as the full PEM contents, including the @@ -304,27 +319,27 @@ aixgo-code run owner/repo#123 --repo-dir . ### GitHub Actions -For the hosted-in-your-GitHub path, the caller workflow in your repository -passes: - -- `vars.AIXGO_AZURE_OPENAI_ENDPOINT` to the reusable workflow input - `azure-openai-endpoint` -- `secrets.AIXGO_AZURE_OPENAI_API_KEY` to the reusable workflow secret - `azure-openai-api-key` -- `vars.AIXGO_GH_APP_CLIENT_ID` to the reusable workflow input - `github-app-client-id` -- `secrets.AIXGO_GH_APP_PRIVATE_KEY` to the reusable workflow secret - `github-app-private-key` - -The reusable workflow installs the CLI, exports the endpoint and key for the -job, and runs `aixgo-code run` or `aixgo-code address`. - -The hosted path follows `.github/aixgo.yml` `engine:`. Codex (the default) -still needs the Azure endpoint and key, and the workflow installs the Codex -CLI. `engine: gemini` needs `vertex-project`, `vertex-location`, and -`vertex-api-key` instead, and the workflow installs the Gemini CLI. Claude -is still a local-CLI path; the reusable workflow does not install the Claude -CLI. +`aixgo-code init --workflow` writes a **conditional** caller: only the chosen +engine's inputs and secrets appear. Always included: + +- `vars.AIXGO_GH_APP_CLIENT_ID` → `github-app-client-id` +- `secrets.AIXGO_GH_APP_PRIVATE_KEY` → `github-app-private-key` + +Plus, depending on `--engine` / the menu choice: + +- **Codex:** `azure-openai-endpoint` / `azure-openai-api-key` (Azure vars/secrets) +- **Gemini:** `vertex-project`, `vertex-location` / `vertex-api-key` +- **Claude:** App credentials only, with a comment that Actions Claude install + is not wired yet ([#147](https://github.com/aixgo-dev/code/issues/147)); use + the local CLI for Claude until then + +The reusable workflow (`.github/workflows/aixgo.yml` in this repository) stays +multi-engine with gated installs: Codex when `azure-openai-endpoint` is set, +Gemini when `vertex-project` is set. The adopter caller template from `init` is +what becomes single-engine. + +The hosted path follows `.github/aixgo.yml` `engine:`. Codex needs Azure; +`engine: gemini` needs Vertex; Claude remains a local-CLI path until #147. ## Watching it run before it writes From 432e0a7aba3161fd75fcc8d1cbb81e63b2673bfd Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:40:55 +0900 Subject: [PATCH 05/12] test: small size probe --- .size-probe | 1 + 1 file changed, 1 insertion(+) create mode 100644 .size-probe diff --git a/.size-probe b/.size-probe new file mode 100644 index 0000000..00d3b6f --- /dev/null +++ b/.size-probe @@ -0,0 +1 @@ +probe-ok-large-push-files-works From 08864f1afb1ea0231694c346870c814ab09586cf Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:43:58 +0900 Subject: [PATCH 06/12] feat(init): add init_select.go for engine selection and conditional caller --- cmd/aixgo-code/init_select.go | 321 ++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 cmd/aixgo-code/init_select.go diff --git a/cmd/aixgo-code/init_select.go b/cmd/aixgo-code/init_select.go new file mode 100644 index 0000000..d318701 --- /dev/null +++ b/cmd/aixgo-code/init_select.go @@ -0,0 +1,321 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "context" + "github.com/aixgo-dev/code/internal/app" + "github.com/aixgo-dev/code/internal/buildinfo" + "github.com/aixgo-dev/code/internal/config" + forgegh "github.com/aixgo-dev/code/internal/forge/gh" +) + +const ( + callerWorkflowEngineToken = "__AIXGO_ENGINE__" + callerWorkflowInputsToken = "# __AIXGO_ENGINE_INPUTS__" + callerWorkflowSecretsToken = "# __AIXGO_ENGINE_SECRETS__" + defaultInitEngine = "codex" +) + +// initStdinIsTTY reports whether stdin is an interactive terminal. Tests +// override it so non-TTY defaulting and the menu path are both reachable. +var initStdinIsTTY = func() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +// initPromptIn is the reader for the interactive engine menu. Tests swap it. +var initPromptIn io.Reader = os.Stdin + +func initCmd(argv []string, stdout io.Writer) error { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + repoDir := fs.String("repo-dir", ".", "path to the target repo checkout") + writeWorkflow := fs.Bool("workflow", false, "also write the Actions caller workflow") + appNameFlag := fs.String("app-name", "", "the GitHub App you installed, for example acme-code; comment commands address it") + engineFlag := fs.String("engine", "", "model engine: codex (Azure), gemini (Vertex), or claude") + force := fs.Bool("force", false, "overwrite existing starter config and workflow files") + if _, err := parseInterleaved(fs, argv); err != nil { + return err + } + + engine, err := resolveEngine(*engineFlag, initStdinIsTTY(), initPromptIn, stdout) + if err != nil { + return err + } + + appName, err := resolveAppName(*repoDir, *appNameFlag) + if err != nil { + return err + } + + configPath := filepath.Join(*repoDir, ".github", "aixgo.yml") + wroteConfig, err := writeStarterConfig(configPath, appName, engine, *force) + if err != nil { + return err + } + workflowPath := filepath.Join(*repoDir, ".github", "workflows", "aixgo.yml") + selftestPath := filepath.Join(*repoDir, ".github", "workflows", "aixgo-selftest.yml") + wroteWorkflow := false + wroteSelftest := false + if *writeWorkflow { + wroteWorkflow, err = writeStarterFile(workflowPath, renderCallerWorkflow(buildinfo.Version, appName, engine), *force) + if err != nil { + return err + } + // The self-test answers questions only a real runner can answer. It is + // written alongside the caller so a bad install fails in a minute here + // rather than silently during a real run, and the next steps tell the + // operator to remove it once it has done its job. + wroteSelftest, err = writeStarterFile(selftestPath, selftestWorkflowTemplate, *force) + if err != nil { + return err + } + } + + labels := app.StateLabels(config.DefaultLabelPrefix) + created, err := (&forgegh.Forge{Dir: *repoDir}).EnsureLabels(context.Background(), labels) + if err != nil { + return err + } + + if wroteConfig { + fmt.Fprintf(stdout, "wrote %s\n", configPath) + } else { + fmt.Fprintf(stdout, "left existing %s unchanged\n", configPath) + } + if *writeWorkflow { + if wroteWorkflow { + fmt.Fprintf(stdout, "wrote %s\n", workflowPath) + } else { + fmt.Fprintf(stdout, "left existing %s unchanged\n", workflowPath) + } + if wroteSelftest { + fmt.Fprintf(stdout, "wrote %s\n", selftestPath) + } else { + fmt.Fprintf(stdout, "left existing %s unchanged\n", selftestPath) + } + } + if len(created) == 0 { + fmt.Fprintln(stdout, "labels already present: no changes") + } else { + fmt.Fprintf(stdout, "created labels: %s\n", strings.Join(created, ", ")) + } + printInitNextSteps(stdout, *repoDir, engine) + return nil +} + +// resolveEngine picks the model engine for starter files. An explicit --engine +// wins; otherwise a TTY gets a short menu; otherwise codex is the default so +// existing non-interactive scripts keep working. +func resolveEngine(flagValue string, interactive bool, in io.Reader, out io.Writer) (string, error) { + if strings.TrimSpace(flagValue) != "" { + return normalizeEngine(flagValue) + } + if interactive { + return promptEngine(in, out) + } + fmt.Fprintf(out, "using engine: %s (default; pass --engine or run interactively to choose)\n", defaultInitEngine) + return defaultInitEngine, nil +} + +func normalizeEngine(v string) (string, error) { + switch e := strings.ToLower(strings.TrimSpace(v)); e { + case "codex", "claude", "gemini": + return e, nil + default: + return "", fmt.Errorf("--engine must be codex, claude, or gemini, got %q", v) + } +} + +func promptEngine(in io.Reader, out io.Writer) (string, error) { + fmt.Fprintln(out, "Select the model engine:") + fmt.Fprintln(out, " 1) Codex / Azure OpenAI (default)") + fmt.Fprintln(out, " 2) Gemini / Vertex AI") + fmt.Fprintln(out, " 3) Claude (local CLI; Actions support pending)") + fmt.Fprint(out, "Enter choice [1]: ") + buf := make([]byte, 0, 16) + tmp := make([]byte, 1) + for { + n, err := in.Read(tmp) + if n > 0 { + if tmp[0] == '\n' { + break + } + if tmp[0] != '\r' { + buf = append(buf, tmp[0]) + } + } + if err != nil { + if len(buf) == 0 && err == io.EOF { + break + } + if err != io.EOF { + return "", err + } + break + } + } + choice := strings.TrimSpace(string(buf)) + switch choice { + case "", "1": + return "codex", nil + case "2": + return "gemini", nil + case "3": + return "claude", nil + default: + // Accept engine names typed into the prompt as well as numbers. + if e, err := normalizeEngine(choice); err == nil { + return e, nil + } + return "", fmt.Errorf("unknown engine choice %q (use 1, 2, 3, or codex|gemini|claude)", choice) + } +} + +func printInitNextSteps(stdout io.Writer, repoDir, engine string) { + createURL, resolved := appCreateURL(repoDir) + fmt.Fprintln(stdout, "next steps:") + fmt.Fprintln(stdout, " 1. Create your OWN GitHub App. It has to be yours: its private key is what mints") + fmt.Fprintln(stdout, " the tokens that act on your repository, so a shared key would let its holder act") + fmt.Fprintln(stdout, " on every other installation. This is what keeps the agent inside your GitHub.") + fmt.Fprintln(stdout, " "+createURL) + if !resolved { + fmt.Fprintln(stdout, " (if this repository belongs to an organisation, use") + fmt.Fprintln(stdout, " https://github.com/organizations//settings/apps/new instead)") + } + fmt.Fprintln(stdout, " Name it anything you like; App names are globally unique, so you cannot reuse ours.") + fmt.Fprintln(stdout, " Permissions, and nothing else: Contents: Read and write") + fmt.Fprintln(stdout, " Issues: Read and write") + fmt.Fprintln(stdout, " Pull requests: Read and write") + fmt.Fprintln(stdout, " Uncheck Active under Webhook. Actions triggers this runtime; an enabled webhook") + fmt.Fprintln(stdout, " with nothing listening only generates failures.") + fmt.Fprintln(stdout, " Set Any account under Where can this GitHub App be installed, if the App belongs") + fmt.Fprintln(stdout, " to your personal account and the repository belongs to an organisation. An App") + fmt.Fprintln(stdout, " restricted to its owner cannot be installed anywhere else, and this is the step") + fmt.Fprintln(stdout, " most often missed.") + fmt.Fprintln(stdout, " 2. Generate a private key on the App settings page and keep the download. GitHub") + fmt.Fprintln(stdout, " shows it once. Note the Client ID there too, the Iv23 string.") + fmt.Fprintln(stdout, " 3. Install the App on this repository, from Install App on the same page.") + fmt.Fprintln(stdout, " Reference: https://docs.github.com/apps/creating-github-apps") + fmt.Fprintln(stdout, " - write the real gate in .github/aixgo.yml") + fmt.Fprintln(stdout, " - verify that gate is green on your main branch") + printInitCredentialSteps(stdout, engine) + fmt.Fprintln(stdout, " Variables and Secrets are different tabs. A value filed under the wrong one reads back") + fmt.Fprintln(stdout, " as empty, and the run fails without saying why.") + fmt.Fprintln(stdout, " - merge the PR containing the config and workflow changes") + fmt.Fprintln(stdout, " - run the self-test once: gh workflow run aixgo-selftest") + fmt.Fprintln(stdout, " - delete .github/workflows/aixgo-selftest.yml once it passes") + fmt.Fprintln(stdout, " - file an issue and apply the ax:go label") + fmt.Fprintln(stdout, " Existing starter files are left unchanged; pass --force to overwrite them") + fmt.Fprintln(stdout, " (for example to switch engines with --engine).") +} + +func printInitCredentialSteps(stdout io.Writer, engine string) { + fmt.Fprintln(stdout, " - add repository VARIABLES, under Settings > Secrets and variables > Actions > Variables:") + fmt.Fprintln(stdout, " AIXGO_GH_APP_CLIENT_ID the App Client ID, the Iv23 string on the App settings page") + switch engine { + case "gemini": + fmt.Fprintln(stdout, " AIXGO_VERTEX_PROJECT Google Cloud project for Vertex AI") + fmt.Fprintln(stdout, " AIXGO_VERTEX_LOCATION optional; default us-central1") + fmt.Fprintln(stdout, " - add repository SECRETS, on the Secrets tab of that same page:") + fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines") + fmt.Fprintln(stdout, " AIXGO_VERTEX_API_KEY Vertex API key or service-account JSON contents") + case "claude": + fmt.Fprintln(stdout, " - add repository SECRETS, on the Secrets tab of that same page:") + fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines") + fmt.Fprintln(stdout, " - Claude locally: use your existing `claude` CLI login; no Azure or Vertex vars.") + fmt.Fprintln(stdout, " - Claude on Actions is not wired yet (aixgo-dev/code#147). The caller has no") + fmt.Fprintln(stdout, " Anthropic secret input until that lands; prefer local `aixgo-code` for Claude today.") + default: // codex + fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com") + fmt.Fprintln(stdout, " - add repository SECRETS, on the Secrets tab of that same page:") + fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines") + fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_API_KEY the Azure OpenAI key") + } +} + +func writeStarterConfig(path, appName, engine string, force bool) (bool, error) { + body := strings.ReplaceAll(starterConfig, callerWorkflowAppNameToken, appName) + body = strings.ReplaceAll(body, callerWorkflowEngineToken, engine) + return writeStarterFile(path, body, force) +} + +// resolveAppName decides the handle to write into both files. An explicit +// --app-name wins; otherwise an existing config keeps what it already says, so +// re-running init to pick up a new release does not silently change the handle +// a team already types. +func resolveAppName(repoDir, flagValue string) (string, error) { + if v := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(flagValue), "@"), "[bot]"); v != "" { + return v, nil + } + if cfg, err := config.Load(filepath.Join(repoDir, ".github", "aixgo.yml")); err == nil && cfg.AppName != "" { + return cfg.AppName, nil + } + return "", errors.New("--app-name is required: comment commands address the App you installed, and its name is unique to you. Pass the App's name without the \"[bot]\" suffix, for example --app-name acme-code") +} + +func writeStarterFile(path, body string, force bool) (bool, error) { + if !force { + if _, err := os.Stat(path); err == nil { + return false, nil + } else if !os.IsNotExist(err) { + return false, err + } + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return false, err + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + return false, err + } + return true, nil +} + +// renderCallerWorkflow writes the trigger for the App this repository installed +// and only the credentials for the chosen engine. The handle cannot be a +// constant: App names are globally unique, so every adopter's bot has its own, +// and the trigger has to match theirs or no comment ever starts a run. It is +// templated here rather than matched loosely at runtime so a mention of a +// colleague does not spin up a runner. +func renderCallerWorkflow(version, appName, engine string) string { + inputs, secrets := callerEngineBlocks(engine) + out := strings.ReplaceAll(callerWorkflowTemplate, callerWorkflowTagToken, workflowTemplateTag(version)) + out = strings.ReplaceAll(out, callerWorkflowAppNameToken, appName) + out = strings.ReplaceAll(out, callerWorkflowInputsToken, inputs) + out = strings.ReplaceAll(out, callerWorkflowSecretsToken, secrets) + return out +} + +func callerEngineBlocks(engine string) (inputs, secrets string) { + switch engine { + case "gemini": + inputs = " vertex-project: ${{ vars.AIXGO_VERTEX_PROJECT }}\n" + + " vertex-location: ${{ vars.AIXGO_VERTEX_LOCATION }}\n" + + " # model: gemini-2.5-pro" + secrets = " vertex-api-key: ${{ secrets.AIXGO_VERTEX_API_KEY }}\n" + + " github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}" + case "claude": + // #147 tracks Anthropic secret + CLI install on Actions. Until then the + // caller still authenticates as the App but has no model credentials. + inputs = " # Claude on Actions is not wired yet (aixgo-dev/code#147):\n" + + " # the reusable workflow does not install the Claude CLI or accept\n" + + " # an Anthropic secret. Local `aixgo-code` with engine: claude uses\n" + + " # your existing claude CLI login." + secrets = " github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}" + default: // codex + inputs = " azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }}\n" + + " # model: my-gpt-5-4-deployment" + secrets = " azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }}\n" + + " github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}" + } + return inputs, secrets +} From 17637efaea1be6738bc04fd6d2433727962f6a55 Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:45:08 +0900 Subject: [PATCH 07/12] feat(init): add engine-select tests (gemini is Vertex-only) --- cmd/aixgo-code/init_select_test.go | 277 +++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 cmd/aixgo-code/init_select_test.go diff --git a/cmd/aixgo-code/init_select_test.go b/cmd/aixgo-code/init_select_test.go new file mode 100644 index 0000000..d22f854 --- /dev/null +++ b/cmd/aixgo-code/init_select_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func stubGHForInit(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("gh stub is a POSIX shell script") + } + prevTTY := initStdinIsTTY + initStdinIsTTY = func() bool { return false } + t.Cleanup(func() { initStdinIsTTY = prevTTY }) + stubDir := t.TempDir() + logPath := filepath.Join(stubDir, "gh.log") + stubPath := filepath.Join(stubDir, "gh") + script := `#!/bin/sh +printf '%s\n' "$*" >> "$GH_STUB_LOG" +if [ "$1 $2" = "label list" ]; then + echo '[]' + exit 0 +fi +exit 0 +` + if err := os.WriteFile(stubPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("GH_STUB_LOG", logPath) + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func TestInitEngineFlagWritesConfigAndCaller(t *testing.T) { + stubGHForInit(t) + buildVersionForTest(t, "0.1.0") + + for _, tc := range []struct { + engine string + wantConfig string + wantInCaller []string + missingInCaller []string + wantNext []string + missingNext []string + }{ + { + engine: "codex", + wantConfig: "engine: codex", + wantInCaller: []string{ + "azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }}", + "azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }}", + }, + missingInCaller: []string{"vertex-project", "vertex-api-key", "Claude on Actions"}, + wantNext: []string{ + "AIXGO_AZURE_OPENAI_ENDPOINT", + "AIXGO_AZURE_OPENAI_API_KEY", + "AIXGO_GH_APP_CLIENT_ID", + "AIXGO_GH_APP_PRIVATE_KEY", + }, + missingNext: []string{"AIXGO_VERTEX_PROJECT", "aixgo-dev/code#147"}, + }, + { + engine: "gemini", + wantConfig: "engine: gemini", + wantInCaller: []string{ + "vertex-project: ${{ vars.AIXGO_VERTEX_PROJECT }}", + "vertex-location: ${{ vars.AIXGO_VERTEX_LOCATION }}", + "vertex-api-key: ${{ secrets.AIXGO_VERTEX_API_KEY }}", + }, + missingInCaller: []string{"azure-openai-endpoint", "azure-openai-api-key"}, + wantNext: []string{ + "AIXGO_VERTEX_PROJECT", + "AIXGO_VERTEX_API_KEY", + "AIXGO_GH_APP_CLIENT_ID", + "AIXGO_GH_APP_PRIVATE_KEY", + }, + missingNext: []string{"AIXGO_AZURE_OPENAI_ENDPOINT"}, + }, + { + engine: "claude", + wantConfig: "engine: claude", + wantInCaller: []string{ + "Claude on Actions is not wired yet (aixgo-dev/code#147)", + "github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}", + }, + missingInCaller: []string{"azure-openai-endpoint", "vertex-project", "azure-openai-api-key", "vertex-api-key"}, + wantNext: []string{ + "AIXGO_GH_APP_CLIENT_ID", + "AIXGO_GH_APP_PRIVATE_KEY", + "aixgo-dev/code#147", + }, + missingNext: []string{"AIXGO_AZURE_OPENAI_ENDPOINT", "AIXGO_VERTEX_PROJECT"}, + }, + } { + t.Run(tc.engine, func(t *testing.T) { + repoDir := t.TempDir() + var out bytes.Buffer + if err := initCmd([]string{ + "--repo-dir", repoDir, + "--workflow", + "--app-name", "acme-code", + "--engine", tc.engine, + }, &out); err != nil { + t.Fatalf("initCmd: %v", err) + } + cfg, err := os.ReadFile(filepath.Join(repoDir, ".github", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cfg), tc.wantConfig) { + t.Fatalf("config missing %q:\n%s", tc.wantConfig, cfg) + } + workflow, err := os.ReadFile(filepath.Join(repoDir, ".github", "workflows", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + body := string(workflow) + for _, want := range tc.wantInCaller { + if !strings.Contains(body, want) { + t.Fatalf("caller missing %q:\n%s", want, body) + } + } + for _, miss := range tc.missingInCaller { + if strings.Contains(body, miss) { + t.Fatalf("caller should not contain %q:\n%s", miss, body) + } + } + output := out.String() + if strings.Contains(output, "using engine: codex (default") { + t.Fatalf("explicit --engine should not print the non-TTY default message:\n%s", output) + } + for _, want := range tc.wantNext { + if !strings.Contains(output, want) { + t.Fatalf("next steps missing %q:\n%s", want, output) + } + } + for _, miss := range tc.missingNext { + if strings.Contains(output, miss) { + t.Fatalf("next steps should not mention %q:\n%s", miss, output) + } + } + }) + } +} + +func TestInitNonTTYDefaultsToCodex(t *testing.T) { + stubGHForInit(t) + prev := initStdinIsTTY + initStdinIsTTY = func() bool { return false } + t.Cleanup(func() { initStdinIsTTY = prev }) + + repoDir := t.TempDir() + var out bytes.Buffer + if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code"}, &out); err != nil { + t.Fatalf("initCmd: %v", err) + } + cfg, err := os.ReadFile(filepath.Join(repoDir, ".github", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cfg), "engine: codex") { + t.Fatalf("expected engine: codex:\n%s", cfg) + } + if !strings.Contains(out.String(), "using engine: codex (default; pass --engine or run interactively to choose)") { + t.Fatalf("expected default-engine message:\n%s", out.String()) + } +} + +func TestInitInteractiveMenuSelectsGemini(t *testing.T) { + stubGHForInit(t) + prevTTY := initStdinIsTTY + prevIn := initPromptIn + initStdinIsTTY = func() bool { return true } + initPromptIn = strings.NewReader("2\n") + t.Cleanup(func() { + initStdinIsTTY = prevTTY + initPromptIn = prevIn + }) + + repoDir := t.TempDir() + var out bytes.Buffer + if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code"}, &out); err != nil { + t.Fatalf("initCmd: %v", err) + } + cfg, err := os.ReadFile(filepath.Join(repoDir, ".github", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cfg), "engine: gemini") { + t.Fatalf("expected engine: gemini:\n%s", cfg) + } + workflow, err := os.ReadFile(filepath.Join(repoDir, ".github", "workflows", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(workflow), "vertex-project:") { + t.Fatalf("expected vertex inputs:\n%s", workflow) + } + if strings.Contains(string(workflow), "azure-openai-endpoint") { + t.Fatalf("gemini caller must not wire Azure:\n%s", workflow) + } + if !strings.Contains(out.String(), "Select the model engine:") { + t.Fatalf("expected interactive menu:\n%s", out.String()) + } +} + +func TestInitForceOverwritesEngine(t *testing.T) { + stubGHForInit(t) + buildVersionForTest(t, "0.1.0") + repoDir := t.TempDir() + + var first bytes.Buffer + if err := initCmd([]string{ + "--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "codex", + }, &first); err != nil { + t.Fatalf("first init: %v", err) + } + var second bytes.Buffer + if err := initCmd([]string{ + "--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "gemini", "--force", + }, &second); err != nil { + t.Fatalf("force init: %v", err) + } + cfg, err := os.ReadFile(filepath.Join(repoDir, ".github", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cfg), "engine: gemini") { + t.Fatalf("force should rewrite engine:\n%s", cfg) + } + workflow, err := os.ReadFile(filepath.Join(repoDir, ".github", "workflows", "aixgo.yml")) + if err != nil { + t.Fatal(err) + } + body := string(workflow) + if !strings.Contains(body, "vertex-project:") || strings.Contains(body, "azure-openai-endpoint") { + t.Fatalf("force should rewrite caller to gemini:\n%s", body) + } + if !strings.Contains(second.String(), "wrote ") { + t.Fatalf("force should report writes:\n%s", second.String()) + } +} + +func TestNormalizeEngine(t *testing.T) { + t.Parallel() + for _, in := range []string{"codex", "Claude", " GEMINI "} { + if _, err := normalizeEngine(in); err != nil { + t.Fatalf("normalizeEngine(%q): %v", in, err) + } + } + if _, err := normalizeEngine("gpt"); err == nil { + t.Fatal("unknown engine must error") + } +} + +func TestRenderCallerWorkflowOmitsOtherEngines(t *testing.T) { + t.Parallel() + codex := renderCallerWorkflow("0.5.0", "acme", "codex") + gemini := renderCallerWorkflow("0.5.0", "acme", "gemini") + claude := renderCallerWorkflow("0.5.0", "acme", "claude") + if !strings.Contains(codex, "azure-openai-endpoint") || strings.Contains(codex, "vertex-project") { + t.Fatalf("codex caller:\n%s", codex) + } + if !strings.Contains(gemini, "vertex-project") || strings.Contains(gemini, "azure-openai") { + t.Fatalf("gemini caller:\n%s", gemini) + } + if strings.Contains(claude, "azure-openai") || strings.Contains(claude, "vertex-") { + t.Fatalf("claude caller:\n%s", claude) + } + if !strings.Contains(claude, "aixgo-dev/code#147") { + t.Fatalf("claude caller should document Actions limitation:\n%s", claude) + } +} From c6b1ff41072b9c456a6e8a87c177b89098f722de Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:45:38 +0900 Subject: [PATCH 08/12] style: fix init_select.go import grouping --- cmd/aixgo-code/init_select.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/cmd/aixgo-code/init_select.go b/cmd/aixgo-code/init_select.go index d318701..d87d334 100644 --- a/cmd/aixgo-code/init_select.go +++ b/cmd/aixgo-code/init_select.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "flag" "fmt" @@ -9,7 +10,6 @@ import ( "path/filepath" "strings" - "context" "github.com/aixgo-dev/code/internal/app" "github.com/aixgo-dev/code/internal/buildinfo" "github.com/aixgo-dev/code/internal/config" @@ -71,10 +71,6 @@ func initCmd(argv []string, stdout io.Writer) error { if err != nil { return err } - // The self-test answers questions only a real runner can answer. It is - // written alongside the caller so a bad install fails in a minute here - // rather than silently during a real run, and the next steps tell the - // operator to remove it once it has done its job. wroteSelftest, err = writeStarterFile(selftestPath, selftestWorkflowTemplate, *force) if err != nil { return err @@ -173,7 +169,6 @@ func promptEngine(in io.Reader, out io.Writer) (string, error) { case "3": return "claude", nil default: - // Accept engine names typed into the prompt as well as numbers. if e, err := normalizeEngine(choice); err == nil { return e, nil } @@ -235,7 +230,7 @@ func printInitCredentialSteps(stdout io.Writer, engine string) { fmt.Fprintln(stdout, " - Claude locally: use your existing `claude` CLI login; no Azure or Vertex vars.") fmt.Fprintln(stdout, " - Claude on Actions is not wired yet (aixgo-dev/code#147). The caller has no") fmt.Fprintln(stdout, " Anthropic secret input until that lands; prefer local `aixgo-code` for Claude today.") - default: // codex + default: fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com") fmt.Fprintln(stdout, " - add repository SECRETS, on the Secrets tab of that same page:") fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines") @@ -304,14 +299,12 @@ func callerEngineBlocks(engine string) (inputs, secrets string) { secrets = " vertex-api-key: ${{ secrets.AIXGO_VERTEX_API_KEY }}\n" + " github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}" case "claude": - // #147 tracks Anthropic secret + CLI install on Actions. Until then the - // caller still authenticates as the App but has no model credentials. inputs = " # Claude on Actions is not wired yet (aixgo-dev/code#147):\n" + " # the reusable workflow does not install the Claude CLI or accept\n" + " # an Anthropic secret. Local `aixgo-code` with engine: claude uses\n" + " # your existing claude CLI login." secrets = " github-app-private-key: ${{ secrets.AIXGO_GH_APP_PRIVATE_KEY }}" - default: // codex + default: inputs = " azure-openai-endpoint: ${{ vars.AIXGO_AZURE_OPENAI_ENDPOINT }}\n" + " # model: my-gpt-5-4-deployment" secrets = " azure-openai-api-key: ${{ secrets.AIXGO_AZURE_OPENAI_API_KEY }}\n" + From bcac4efa8f65e8ddabece6371297c284fcf21ba4 Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:47:16 +0900 Subject: [PATCH 09/12] chore: remove size-probe leftover from MCP push testing --- .size-probe | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .size-probe diff --git a/.size-probe b/.size-probe deleted file mode 100644 index 00d3b6f..0000000 --- a/.size-probe +++ /dev/null @@ -1 +0,0 @@ -probe-ok-large-push-files-works From 3e58d758a9d189e04301ee22c6714df3e8319b8f Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:49:57 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat(init):=20slim=20main.go=20=E2=80=94?= =?UTF-8?q?=20engine=20select=20lives=20in=20init=5Fselect.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/aixgo-code/main.go | 988 +---------------------------------------- 1 file changed, 1 insertion(+), 987 deletions(-) diff --git a/cmd/aixgo-code/main.go b/cmd/aixgo-code/main.go index 7ee611e..2efdf74 100644 --- a/cmd/aixgo-code/main.go +++ b/cmd/aixgo-code/main.go @@ -1,987 +1 @@ -// Command aixgo is the CLI for Aixgo Code. It runs the same engine -// the GitHub Action runs, locally, for development and debugging. -// -// aixgo-code version -// aixgo-code run [flags] -// -// This is an early scaffold. See STATUS.md. -package main - -import ( - "context" - _ "embed" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "net/url" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - - "github.com/aixgo-dev/code/internal/app" - "github.com/aixgo-dev/code/internal/buildinfo" - "github.com/aixgo-dev/code/internal/command" - "github.com/aixgo-dev/code/internal/config" - "github.com/aixgo-dev/code/internal/domain" - "github.com/aixgo-dev/code/internal/engine" - "github.com/aixgo-dev/code/internal/engine/claude" - "github.com/aixgo-dev/code/internal/engine/codex" - "github.com/aixgo-dev/code/internal/engine/gemini" - forge2 "github.com/aixgo-dev/code/internal/forge" - "github.com/aixgo-dev/code/internal/forge/dryrun" - forgegh "github.com/aixgo-dev/code/internal/forge/gh" - "github.com/aixgo-dev/code/internal/loop" - vcsgit "github.com/aixgo-dev/code/internal/vcs/git" - "github.com/aixgo-dev/code/internal/worktree" -) - -func main() { - os.Exit(dispatch(os.Args[1:], os.Stdout, os.Stderr)) -} - -// dispatch runs one command and returns the process exit code. main is only a -// wrapper around it, so the whole entry point (argument handling, exit codes, -// error reporting) is reachable from a test instead of being the one part of -// the CLI that nothing exercises. -func dispatch(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 { - usage(stderr) - return 2 - } - fail := func(err error) int { - fmt.Fprintln(stderr, "error:", err) - // A missing adopter-set value is something to go and set, not a bug to - // report. Its own exit code lets a caller tell those apart without - // matching on message text. - if errors.Is(err, ErrConfigMissing) { - return 3 - } - return 1 - } - switch args[0] { - case "version", "--version", "-v": - fmt.Fprintln(stdout, buildinfo.Version) - case "init": - if err := initCmd(args[1:], stdout); err != nil { - return fail(err) - } - case "command": - if err := commandCmd(args[1:], stdout); err != nil { - return fail(err) - } - case "preflight": - if err := preflightCmd(args[1:], stdout); err != nil { - return fail(err) - } - case "run": - if err := runCmd(args[1:]); err != nil { - return fail(err) - } - case "address": - if err := addressCmd(args[1:]); err != nil { - return fail(err) - } - default: - usage(stderr) - return 2 - } - return 0 -} - -func usage(w io.Writer) { - fmt.Fprintf(w, `aixgo %s - -usage: - aixgo-code version - aixgo-code init [--repo-dir .] [--workflow] - aixgo-code preflight [--repo-dir .] - aixgo-code command --body "" [flags] - aixgo-code run [flags] - aixgo-code address [flags] - -run drives an issue to a pull request. address runs the fix-on-request loop -over an open pull request: it reads the human's review feedback and pushes a -fix back to the same branch, or reports that there is nothing new to address. - -flags (both commands): - --repo-dir path to the target repo checkout (default ".") - --actor login that triggered the run; refused unless it has write access - --dry-run run the whole loop but make no GitHub writes and no push - --model engine model/deployment name (default "gpt-5.4") - --base worktree base ref (default "origin/HEAD") - --state-dir where worktrees and the generated config live - -environment (engine settings; never committed to a repo): - AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com (codex) - AIXGO_AZURE_OPENAI_API_KEY the key, read by name and never written to disk - AIXGO_VERTEX_PROJECT Google Cloud project (gemini / Vertex) - AIXGO_VERTEX_LOCATION Vertex location, default us-central1 - AIXGO_VERTEX_API_KEY Vertex API key, read by name and never written to disk -`, buildinfo.Version) -} - -const starterConfig = `# Repository-local contract for Aixgo Code. -# Keep the label prefix unless you already run another ax:* lifecycle. -labelPrefix: ax - -# The GitHub App you created and installed, without the "[bot]" suffix. Comment -# commands address it: "@__AIXGO_APP_NAME__ go" on an issue starts work. -# -# This is yours, not ours. App names are globally unique, so every installation -# has a different one, and addressing your real bot is what makes GitHub offer -# it in the autocomplete after someone types "@". -# -# The caller workflow triggers on this same handle. If you rename the App, -# change it here and re-run "aixgo-code init --workflow"; preflight fails if -# the two ever disagree. -appName: __AIXGO_APP_NAME__ - -# Required. Fill this in with the real gate that is already green on main. -gate: - -# Optional. One-time setup command before each run (install deps, generate code). -# setup: - -# Optional. Disable the generated commit/PR attribution marker. -# attribution: false - -# Optional. Ask the agent to add a generated walkthrough and changes table to PRs. -# prDescription: rich -` - -const ( - latestKnownWorkflowTag = "v0.5.0" - callerWorkflowTagToken = "__AIXGO_TAG__" - callerWorkflowAppNameToken = "__AIXGO_APP_NAME__" -) - -//go:embed aixgo-caller.yml.tmpl -var callerWorkflowTemplate string - -//go:embed aixgo-selftest.yml.tmpl -var selftestWorkflowTemplate string - -// engineEnv validates the selected engine settings and returns the normalized -// Azure endpoint when the engine needs one. It is the single implementation: -// `prepare` calls it before a run, and `preflight` calls it so a workflow can -// fail early without a second copy of these rules written in shell. -func usesAzure(cfg *config.Config) bool { - if cfg == nil { - return true - } - switch cfg.Engine { - case "claude", "gemini": - return false - } - return true -} - -func engineEnv(cfg *config.Config) (string, error) { - if cfg != nil && cfg.Engine == "claude" { - return "", nil - } - if cfg != nil && cfg.Engine == "gemini" { - if err := requireSet("AIXGO_VERTEX_PROJECT", sectionVariable); err != nil { - return "", err - } - if err := requireSet("AIXGO_VERTEX_API_KEY", sectionSecret); err != nil { - return "", err - } - return "", nil - } - if err := requireSet("AIXGO_AZURE_OPENAI_ENDPOINT", sectionVariable); err != nil { - return "", err - } - endpoint := strings.TrimRight(os.Getenv("AIXGO_AZURE_OPENAI_ENDPOINT"), "/") - u, err := url.Parse(endpoint) - if err != nil { - return "", fmt.Errorf("AIXGO_AZURE_OPENAI_ENDPOINT is not a valid URL: %w", err) - } - if u.Scheme != "https" || u.Host == "" { - return "", fmt.Errorf("AIXGO_AZURE_OPENAI_ENDPOINT must be an https URL like https://.openai.azure.com, got %q", endpoint) - } - if err := requireSet("AIXGO_AZURE_OPENAI_API_KEY", sectionSecret); err != nil { - return "", err - } - return endpoint, nil -} - -// newVCS builds the git layer with a committer identity. A GitHub Actions -// runner has none configured, so without this a commit fails with "Author -// identity unknown" after the change is already made and the gate has passed. -// The identity is the credential's own login, so commits are attributable to -// whoever the run authenticated as. -// ErrConfigMissing marks a missing adopter-set value, as opposed to an internal -// failure. The two want different responses: one is something to go and set, -// the other is a bug. dispatch turns this into its own exit code so a caller -// can tell them apart without parsing text. -var ErrConfigMissing = errors.New("configuration missing") - -// section is where a value lives in the adopter's repository settings. GitHub -// puts Variables and Secrets on different tabs, and a value filed under the -// wrong one reads back as empty rather than failing, so every message about a -// missing value has to say which tab it belongs on. -type section string - -const ( - sectionVariable section = "variable" - sectionSecret section = "secret" -) - -// requireSet returns an error naming the value and its section when unset or -// empty. Empty matters as much as unset: an empty string is exactly what a -// value filed under the wrong tab looks like from here. -func requireSet(name string, where section) error { - if strings.TrimSpace(os.Getenv(name)) != "" { - return nil - } - return fmt.Errorf("%w: %s is not set. It is a repository %s on your own repository, under Settings > Secrets and variables > Actions; a reusable workflow never inherits %ss from Aixgo", ErrConfigMissing, name, where, where) -} - -func newVCS(self string) *vcsgit.Git { - if self == "" { - return &vcsgit.Git{} - } - return &vcsgit.Git{ - AuthorName: self, - AuthorEmail: fmt.Sprintf("%s@users.noreply.github.com", strings.ReplaceAll(self, "[bot]", "")), - } -} - -// newRunner builds the engine runner, honouring AIXGO_SANDBOX. -// -// The sandbox stays on. Nothing in this repository widens it, and the knob -// exists only so an adopter who has genuinely sandboxed their runners -// externally can decide that for themselves. -// -// The default sandbox uses bubblewrap on Linux, which needs an unprivileged -// user namespace. Ubuntu 24.04 restricts those by AppArmor, so on a stock -// GitHub runner every command the engine tried died at startup: -// -// bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted -// -// The engine then read nothing and changed nothing while still exiting -// successfully, which is why it looked like a silent no-op. The reusable -// workflow now enables that kernel feature before the engine runs, which lets -// the sandbox start rather than widening it. A run that still cannot sandbox -// stops and a human finishes the work; it never falls back to running -// unconfined. See docs/faq.md and the self-test. -func newRunner(cfg *config.Config, codexHome, model string) engine.Runner { - if cfg.Engine == "claude" { - return &claude.Runner{Model: model} - } - if cfg.Engine == "gemini" { - if model == "" || model == "gpt-5.4" { - model = "gemini-2.5-pro" - } - return &gemini.Runner{Model: model} - } - r := codex.New(codexHome) - if mode := os.Getenv("AIXGO_SANDBOX"); mode != "" { - r.Sandbox = mode - } - return r -} - -// takeBody removes --body (in either form) from argv and returns it with the -// remaining arguments. -func takeBody(argv []string) (body string, rest []string, err error) { - for i := 0; i < len(argv); i++ { - a := argv[i] - switch { - case a == "--body" || a == "-body": - if i+1 >= len(argv) { - return "", nil, fmt.Errorf("--body requires a value") - } - body = argv[i+1] - i++ - case strings.HasPrefix(a, "--body="), strings.HasPrefix(a, "-body="): - _, v, _ := strings.Cut(a, "=") - body = v - default: - rest = append(rest, a) - } - } - return body, rest, nil -} - -// appNameFor reads the configured App handle from the same --repo-dir the rest -// of the command will use. It parses only that flag, because run and address -// own the full flag set and a second copy of it would drift. -func appNameFor(argv []string) (string, error) { - repoDir := "." - for i, a := range argv { - switch { - case a == "--repo-dir" || a == "-repo-dir": - if i+1 < len(argv) { - repoDir = argv[i+1] - } - case strings.HasPrefix(a, "--repo-dir="), strings.HasPrefix(a, "-repo-dir="): - _, v, _ := strings.Cut(a, "=") - repoDir = v - } - } - cfg, err := config.Load(filepath.Join(repoDir, ".github", "aixgo.yml")) - if err != nil { - return "", fmt.Errorf("load config: %w", err) - } - return cfg.AppName, nil -} - -// commandCmd routes a comment addressed to the agent to the matching loop. The -// comment body is untrusted, so it is parsed into a fixed vocabulary here and -// never interpreted: an unrecognised comment does nothing at all. -func commandCmd(argv []string, stdout io.Writer) error { - // --body is pulled out by hand and everything else is forwarded untouched, - // because run and address own the rest of the flags. Parsing them here would - // mean maintaining a second copy of that flag set. - body, rest, err := takeBody(argv) - if err != nil { - return err - } - // The handle is per-repository, so the config has to be read before the - // comment can be parsed at all: which App this repository installed is the - // thing that decides what a command even looks like here. - appName, err := appNameFor(rest) - if err != nil { - return err - } - kind := command.Parse(body, appName) - - switch kind { - case command.Help: - return reply(rest, command.HelpTextFor(appName), stdout) - case command.Go, command.Address: - // A verb aimed at the wrong surface is answered, not run. This lives on - // the comment path rather than inside run and address because someone - // typing `aixgo-code address owner/repo#96` at a shell wants an error, - // not a comment posted in their name. - a, err := newAnswerer(rest) - if err != nil { - return err - } - if a.ok && command.Misdirected(kind, a.onPR) { - return a.post(command.MisdirectedText(kind, a.onPR, appName), stdout) - } - if kind == command.Go { - return runCmd(rest) - } - return addressCmd(rest) - default: - // Only answer a comment that was actually addressed to the agent. - // Anything else is someone mentioning it in passing, and replying to - // that would make it the noisiest participant in every thread. - if command.Addressed(body, appName) { - return reply(rest, command.UnknownTextFor(appName), stdout) - } - fmt.Fprintln(stdout, "no command recognised in that comment; nothing to do") - return nil - } -} - -// newCommentForge builds the forge used to answer a comment. It is a variable so -// the routing tests can answer without a network. -var newCommentForge = func() forge2.Forge { return &forgegh.Forge{} } - -// answerer is the little that is needed to reply to a comment: which thread it -// arrived on, and something to answer with. -// -// It deliberately does not go through prepare. Answering `help`, or telling -// someone they used the wrong verb, must work in a repository that has no config -// and no engine credentials, because those are exactly the repositories where -// someone is most likely to be asking. -type answerer struct { - forge forge2.Forge - dry *dryrun.Forge - ref domain.Issue - onPR bool - // ok is false when the arguments carry no issue ref, which is what a local - // run driven by hand looks like. There is then nothing to answer on. - ok bool -} - -func newAnswerer(argv []string) (answerer, error) { - var a answerer - // The ref is found by shape rather than by position, because the flags it - // sits among belong to run and address and are not parsed here. - for _, arg := range argv { - if ref, err := app.ParseIssueRef(arg); err == nil { - a.ref, a.ok = ref, true - break - } - } - if !a.ok { - return a, nil - } - gf := newCommentForge() - a.forge = gf - if dryRunRequested(argv) { - a.dry = dryrun.New(gf) - a.forge = a.dry - } - onPR, err := a.forge.IsPullRequest(context.Background(), a.ref.Repo, a.ref.Number) - if err != nil { - return a, fmt.Errorf("resolve %s#%d: %w", a.ref.Repo, a.ref.Number, err) - } - a.onPR = onPR - return a, nil -} - -// post answers on the thread and echoes the same text, so a run driven from a -// terminal still shows it where the operator is looking. Under a dry run the -// post is recorded rather than made, like every other GitHub write. -func (a answerer) post(body string, stdout io.Writer) error { - fmt.Fprintln(stdout, body) - if !a.ok { - return nil - } - defer func() { - if a.dry == nil { - return - } - fmt.Fprintln(stdout) - fmt.Fprintln(stdout, a.dry.Report()) - }() - ctx := context.Background() - if a.onPR { - return a.forge.CommentPR(ctx, a.ref.Repo, a.ref.Number, body) - } - return a.forge.Comment(ctx, a.ref.Repo, a.ref.Number, body) -} - -// dryRunRequested reads the flag without owning it. run and address define the -// real flag set; this only needs to know whether to record instead of post. -func dryRunRequested(argv []string) bool { - if os.Getenv("AIXGO_DRY_RUN") != "" { - return true - } - for _, arg := range argv { - switch arg { - case "--dry-run", "-dry-run", "--dry-run=true", "-dry-run=true": - return true - } - } - return false -} - -// reply answers on whatever thread the arguments point at. -func reply(argv []string, body string, stdout io.Writer) error { - a, err := newAnswerer(argv) - if err != nil { - return err - } - return a.post(body, stdout) -} - -// preflightCmd validates the repo config and engine settings, then exits. A -// workflow runs it before installing the rest of the toolchain, so a -// misconfigured repository finds out in seconds and the rules live in one place. -// checkMentionAgreement fails when the configured handle and the caller -// workflow's trigger disagree. -// -// The handle necessarily lives in two files: the parser reads it from config, -// which the App can push, while the trigger has to be a literal in the workflow, -// which the App deliberately cannot. Drift between them is silent in the worst -// way — comments simply stop working, with no error anywhere — so it is checked -// on every run rather than left to be discovered. -func checkMentionAgreement(repoDir, appName string) error { - // Find the caller by what it does, not by what it is called. init writes - // .github/workflows/aixgo.yml, but an adopter can rename it, and in - // this repository that name belongs to the reusable workflow itself. A check - // keyed on the filename would pass on the wrong file, or fail on a valid - // install, which is worse than not checking. - dir := filepath.Join(repoDir, ".github", "workflows") - entries, err := os.ReadDir(dir) - if err != nil { - // No workflows at all is a valid local setup, not a misconfiguration. - return nil - } - want := "'" + command.MentionFor(appName) + "'" - var callers []string - for _, e := range entries { - if e.IsDir() { - continue - } - path := filepath.Join(dir, e.Name()) - b, err := os.ReadFile(path) - if err != nil || !strings.Contains(string(b), "aixgo-dev/code/.github/workflows/aixgo.yml@") { - continue - } - callers = append(callers, path) - if strings.Contains(string(b), want) { - return nil - } - } - if len(callers) == 0 { - return nil - } - return fmt.Errorf("%w: .github/aixgo.yml sets appName %q, but %s does not trigger on %s. Comment commands will never fire. Re-run \"aixgo-code init --workflow\" to rewrite the trigger from the config", - ErrConfigMissing, appName, strings.Join(callers, ", "), want) -} - -func preflightCmd(argv []string, stdout io.Writer) error { - fs := flag.NewFlagSet("preflight", flag.ContinueOnError) - repoDir := fs.String("repo-dir", ".", "path to the target repo checkout") - actions := fs.Bool("actions", false, "also check the values only a caller workflow supplies") - if _, err := parseInterleaved(fs, argv); err != nil { - return err - } - cfg, err := config.Load(filepath.Join(*repoDir, ".github", "aixgo.yml")) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - if _, err := engineEnv(cfg); err != nil { - return err - } - if cfg.AppName == "" { - return fmt.Errorf("%w: .github/aixgo.yml sets no appName. Comment commands address the App you installed, so without it none of them can fire. Add \"appName: \" or re-run \"aixgo-code init --workflow --app-name \"", ErrConfigMissing) - } - if err := checkMentionAgreement(*repoDir, cfg.AppName); err != nil { - return err - } - // The App credentials are only reachable when the caller workflow exports - // them. A local run authenticates as the operator and never uses them, so - // checking them there would fail a working setup. - if *actions { - for _, v := range []struct { - name string - where section - }{ - {"AIXGO_GH_APP_CLIENT_ID", sectionVariable}, - {"AIXGO_GH_APP_PRIVATE_KEY", sectionSecret}, - } { - if err := requireSet(v.name, v.where); err != nil { - return err - } - } - } - fmt.Fprintln(stdout, "preflight ok: config and engine settings are present") - return nil -} - -// commonFlags are the flags shared by run and address, plus the resolved config -// and dependency graph both commands need. -type commonFlags struct { - repoDir string - base string - actor string - // dry is set only on a dry run, and holds the writes that were skipped. - dry *dryrun.Forge - cfg *config.Config - deps app.Deps -} - -// parseInterleaved parses argv with fs, allowing flags before or after -// positional arguments. Stdlib flag parsing stops at the first positional, so -// `run owner/repo#1 --repo-dir /x` would silently ignore the flag while the -// usage text advertises exactly that form. Positionals are collected in order -// and parsing resumes after each one, so both orders (and a mix) are honored. -func parseInterleaved(fs *flag.FlagSet, argv []string) ([]string, error) { - var positionals []string - for { - if err := fs.Parse(argv); err != nil { - return nil, err - } - argv = fs.Args() - if len(argv) == 0 { - return positionals, nil - } - positionals = append(positionals, argv[0]) - argv = argv[1:] - } -} - -// prepare parses the shared flags, loads the repo config, validates the engine -// environment, writes the codex config, and builds the dependency graph. It -// returns the remaining positional arguments so each command can parse its own -// reference (an issue for run, a pull request for address). -// isBotLogin reports whether a login belongs to a GitHub App installation. -// The Actions runtime always authenticates as an App, and that App deliberately -// holds no workflows permission, so any bot identity is restricted. An empty -// login means a local run under a human's own credential, which can push -// workflow files and must not be blocked. -func isBotLogin(login string) bool { - return strings.HasSuffix(login, "[bot]") -} - -func prepare(name string, argv []string) (*commonFlags, []string, error) { - fs := flag.NewFlagSet(name, flag.ContinueOnError) - repoDir := fs.String("repo-dir", ".", "path to the target repo checkout") - model := fs.String("model", "gpt-5.4", "engine model/deployment name") - base := fs.String("base", "origin/HEAD", "worktree base ref") - stateDir := fs.String("state-dir", filepath.Join(os.TempDir(), "aixgo"), "state directory") - actor := fs.String("actor", "", "login that triggered this run; checked for write access") - dryRun := fs.Bool("dry-run", false, "run the whole loop but make no GitHub writes and no push") - rest, err := parseInterleaved(fs, argv) - if err != nil { - return nil, nil, err - } - - cfg, err := config.Load(filepath.Join(*repoDir, ".github", "aixgo.yml")) - if err != nil { - return nil, nil, fmt.Errorf("load config: %w", err) - } - - endpoint, err := engineEnv(cfg) - if err != nil { - return nil, nil, err - } - - codexHome := filepath.Join(*stateDir, "codex-home") - if usesAzure(cfg) { - if _, err := codex.WriteConfig(codexHome, codex.ProviderConfig{ - Model: *model, - BaseURL: endpoint + "/openai/v1", - EnvKey: "AIXGO_AZURE_OPENAI_API_KEY", - }); err != nil { - return nil, nil, fmt.Errorf("write codex config: %w", err) - } - } - - prefix := cfg.LabelPrefix - if prefix == "" { - prefix = config.DefaultLabelPrefix - } - - forge := &forgegh.Forge{StateLabels: app.StateLabels(prefix), Self: os.Getenv("AIXGO_GH_APP_LOGIN")} - dry := *dryRun || os.Getenv("AIXGO_DRY_RUN") != "" - // When the caller did not name the identity, ask the credential who it is. - // The workflow used to do this with a gh graphql call and hand the answer - // back in an environment variable; the product can just look. - if forge.Self == "" { - if login, err := forge.Whoami(context.Background()); err == nil { - forge.Self = login - } - } - - vcs := newVCS(forge.Self) - vcs.DryRun = dry - var forgeForLoop forge2.Forge = forge - var dryForge *dryrun.Forge - if dry { - dryForge = dryrun.New(forge) - forgeForLoop = dryForge - } - - deps := app.Deps{ - Runner: newRunner(cfg, codexHome, *model), - // Self, when set, filters the agent's own review feedback out of the fix - // loop. Empty for local runs, where the operator is a human, not the bot. - Forge: forgeForLoop, - VCS: vcs, - Worktrees: &worktree.Manager{RepoDir: *repoDir, BaseDir: filepath.Join(*stateDir, "worktrees")}, - WorkflowRestrictedPush: isBotLogin(forge.Self), - SelfLogin: forge.Self, - } - return &commonFlags{repoDir: *repoDir, base: *base, actor: *actor, cfg: cfg, deps: deps, dry: dryForge}, rest, nil -} - -// reportDryRun prints the writes a dry run skipped, and appends them to the -// GitHub Actions run summary when there is one, so the result is readable on -// the run page rather than buried in step logs. -func reportDryRun(c *commonFlags, w io.Writer) { - if c == nil || c.dry == nil { - return - } - report := c.dry.Report() - fmt.Fprintln(w) - fmt.Fprintln(w, report) - if path := os.Getenv("GITHUB_STEP_SUMMARY"); path != "" { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return - } - defer f.Close() - fmt.Fprintf(f, "## Aixgo Code dry run\n\n```\n%s\n```\n", report) - } -} - -func runCmd(argv []string) error { - c, rest, err := prepare("run", argv) - if err != nil { - return err - } - if len(rest) < 1 { - return fmt.Errorf("missing issue ref (want owner/repo#N)") - } - iss, err := app.ParseIssueRef(rest[0]) - if err != nil { - return err - } - if err := app.Authorize(context.Background(), c.deps, iss.Repo, c.actor); err != nil { - return err - } - if err := fetchIssue(&iss); err != nil { - return fmt.Errorf("fetch issue: %w", err) - } - - if c.dry != nil { - fmt.Println("DRY RUN: no GitHub writes and no push will happen.") - } - fmt.Printf("running %s#%d against gate %q ...\n", iss.Repo, iss.Number, c.cfg.Gate) - res, err := app.Run(context.Background(), c.deps, c.cfg, iss, c.base) - defer reportDryRun(c, os.Stdout) - if err != nil { - return err - } - if res.Outcome == loop.OutcomePROpened { - fmt.Printf("PR opened after %d round(s): %s\n", res.Rounds, res.PRURL) - } else { - fmt.Printf("blocked after %d round(s): %s\n", res.Rounds, res.Reason) - } - return nil -} - -func addressCmd(argv []string) error { - c, rest, err := prepare("address", argv) - if err != nil { - return err - } - if len(rest) < 1 { - return fmt.Errorf("missing pull-request ref (want owner/repo#PR)") - } - ref, err := app.ParseIssueRef(rest[0]) - if err != nil { - return err - } - if err := app.Authorize(context.Background(), c.deps, ref.Repo, c.actor); err != nil { - return err - } - - fmt.Printf("addressing feedback on %s#%d against gate %q ...\n", ref.Repo, ref.Number, c.cfg.Gate) - res, err := app.AddressPR(context.Background(), c.deps, c.cfg, ref.Repo, ref.Number) - defer reportDryRun(c, os.Stdout) - if err != nil { - return err - } - switch res.Outcome { - case loop.OutcomeChangesPushed: - fmt.Printf("changes pushed after %d round(s); re-requested review\n", res.Rounds) - case loop.OutcomeNoFeedback: - fmt.Println("no new review feedback to address; nothing to do") - default: - fmt.Printf("blocked after %d round(s): %s\n", res.Rounds, res.Reason) - } - return nil -} - -func initCmd(argv []string, stdout io.Writer) error { - fs := flag.NewFlagSet("init", flag.ContinueOnError) - repoDir := fs.String("repo-dir", ".", "path to the target repo checkout") - writeWorkflow := fs.Bool("workflow", false, "also write the Actions caller workflow") - appNameFlag := fs.String("app-name", "", "the GitHub App you installed, for example acme-code; comment commands address it") - if _, err := parseInterleaved(fs, argv); err != nil { - return err - } - - appName, err := resolveAppName(*repoDir, *appNameFlag) - if err != nil { - return err - } - - configPath := filepath.Join(*repoDir, ".github", "aixgo.yml") - wroteConfig, err := writeStarterConfig(configPath, appName) - if err != nil { - return err - } - workflowPath := filepath.Join(*repoDir, ".github", "workflows", "aixgo.yml") - selftestPath := filepath.Join(*repoDir, ".github", "workflows", "aixgo-selftest.yml") - wroteWorkflow := false - wroteSelftest := false - if *writeWorkflow { - wroteWorkflow, err = writeStarterFile(workflowPath, renderCallerWorkflow(buildinfo.Version, appName)) - if err != nil { - return err - } - // The self-test answers questions only a real runner can answer. It is - // written alongside the caller so a bad install fails in a minute here - // rather than silently during a real run, and the next steps tell the - // operator to remove it once it has done its job. - wroteSelftest, err = writeStarterFile(selftestPath, selftestWorkflowTemplate) - if err != nil { - return err - } - } - - labels := app.StateLabels(config.DefaultLabelPrefix) - created, err := (&forgegh.Forge{Dir: *repoDir}).EnsureLabels(context.Background(), labels) - if err != nil { - return err - } - - if wroteConfig { - fmt.Fprintf(stdout, "wrote %s\n", configPath) - } else { - fmt.Fprintf(stdout, "left existing %s unchanged\n", configPath) - } - if *writeWorkflow { - if wroteWorkflow { - fmt.Fprintf(stdout, "wrote %s\n", workflowPath) - } else { - fmt.Fprintf(stdout, "left existing %s unchanged\n", workflowPath) - } - if wroteSelftest { - fmt.Fprintf(stdout, "wrote %s\n", selftestPath) - } else { - fmt.Fprintf(stdout, "left existing %s unchanged\n", selftestPath) - } - } - if len(created) == 0 { - fmt.Fprintln(stdout, "labels already present: no changes") - } else { - fmt.Fprintf(stdout, "created labels: %s\n", strings.Join(created, ", ")) - } - createURL, resolved := appCreateURL(*repoDir) - fmt.Fprintln(stdout, "next steps:") - fmt.Fprintln(stdout, " 1. Create your OWN GitHub App. It has to be yours: its private key is what mints") - fmt.Fprintln(stdout, " the tokens that act on your repository, so a shared key would let its holder act") - fmt.Fprintln(stdout, " on every other installation. This is what keeps the agent inside your GitHub.") - fmt.Fprintln(stdout, " "+createURL) - if !resolved { - fmt.Fprintln(stdout, " (if this repository belongs to an organisation, use") - fmt.Fprintln(stdout, " https://github.com/organizations//settings/apps/new instead)") - } - fmt.Fprintln(stdout, " Name it anything you like; App names are globally unique, so you cannot reuse ours.") - fmt.Fprintln(stdout, " Permissions, and nothing else: Contents: Read and write") - fmt.Fprintln(stdout, " Issues: Read and write") - fmt.Fprintln(stdout, " Pull requests: Read and write") - fmt.Fprintln(stdout, " Uncheck Active under Webhook. Actions triggers this runtime; an enabled webhook") - fmt.Fprintln(stdout, " with nothing listening only generates failures.") - fmt.Fprintln(stdout, " Set Any account under Where can this GitHub App be installed, if the App belongs") - fmt.Fprintln(stdout, " to your personal account and the repository belongs to an organisation. An App") - fmt.Fprintln(stdout, " restricted to its owner cannot be installed anywhere else, and this is the step") - fmt.Fprintln(stdout, " most often missed.") - fmt.Fprintln(stdout, " 2. Generate a private key on the App settings page and keep the download. GitHub") - fmt.Fprintln(stdout, " shows it once. Note the Client ID there too, the Iv23 string.") - fmt.Fprintln(stdout, " 3. Install the App on this repository, from Install App on the same page.") - fmt.Fprintln(stdout, " Reference: https://docs.github.com/apps/creating-github-apps") - fmt.Fprintln(stdout, " - write the real gate in .github/aixgo.yml") - fmt.Fprintln(stdout, " - verify that gate is green on your main branch") - fmt.Fprintln(stdout, " - add two repository VARIABLES, under Settings > Secrets and variables > Actions > Variables:") - fmt.Fprintln(stdout, " AIXGO_GH_APP_CLIENT_ID the App Client ID, the Iv23 string on the App settings page") - fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com") - fmt.Fprintln(stdout, " - add two repository SECRETS, on the Secrets tab of that same page:") - fmt.Fprintln(stdout, " AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines") - fmt.Fprintln(stdout, " AIXGO_AZURE_OPENAI_API_KEY the Azure OpenAI key") - fmt.Fprintln(stdout, " Variables and Secrets are different tabs. A value filed under the wrong one reads back") - fmt.Fprintln(stdout, " as empty, and the run fails without saying why.") - fmt.Fprintln(stdout, " - merge the PR containing the config and workflow changes") - fmt.Fprintln(stdout, " - run the self-test once: gh workflow run aixgo-selftest") - fmt.Fprintln(stdout, " - delete .github/workflows/aixgo-selftest.yml once it passes") - fmt.Fprintln(stdout, " - file an issue and apply the ax:go label") - return nil -} - -// appCreateURL returns the GitHub form for creating an App, which differs by -// account type. Best effort: init must still work when gh cannot answer, so a -// failure returns the personal form and a note rather than an error. -func appCreateURL(repoDir string) (string, bool) { - out, err := exec.Command("gh", "repo", "view", "--json", "owner", "--jq", ".owner.login").Output() - _ = repoDir - if err != nil { - return "https://github.com/settings/apps/new", false - } - owner := strings.TrimSpace(string(out)) - if owner == "" { - return "https://github.com/settings/apps/new", false - } - kind, err := exec.Command("gh", "api", "users/"+owner, "--jq", ".type").Output() - if err != nil { - return "https://github.com/settings/apps/new", false - } - if strings.TrimSpace(string(kind)) == "Organization" { - return "https://github.com/organizations/" + owner + "/settings/apps/new", true - } - return "https://github.com/settings/apps/new", true -} - -func writeStarterConfig(path, appName string) (bool, error) { - return writeStarterFile(path, strings.ReplaceAll(starterConfig, callerWorkflowAppNameToken, appName)) -} - -// resolveAppName decides the handle to write into both files. An explicit -// --app-name wins; otherwise an existing config keeps what it already says, so -// re-running init to pick up a new release does not silently change the handle -// a team already types. -func resolveAppName(repoDir, flagValue string) (string, error) { - if v := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(flagValue), "@"), "[bot]"); v != "" { - return v, nil - } - if cfg, err := config.Load(filepath.Join(repoDir, ".github", "aixgo.yml")); err == nil && cfg.AppName != "" { - return cfg.AppName, nil - } - return "", errors.New("--app-name is required: comment commands address the App you installed, and its name is unique to you. Pass the App's name without the \"[bot]\" suffix, for example --app-name acme-code") -} - -func writeStarterFile(path, body string) (bool, error) { - if _, err := os.Stat(path); err == nil { - return false, nil - } else if !os.IsNotExist(err) { - return false, err - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return false, err - } - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - return false, err - } - return true, nil -} - -// renderCallerWorkflow writes the trigger for the App this repository installed. -// The handle cannot be a constant: App names are globally unique, so every -// adopter's bot has its own, and the trigger has to match theirs or no comment -// ever starts a run. It is templated here rather than matched loosely at -// runtime so a mention of a colleague does not spin up a runner. -func renderCallerWorkflow(version, appName string) string { - out := strings.ReplaceAll(callerWorkflowTemplate, callerWorkflowTagToken, workflowTemplateTag(version)) - return strings.ReplaceAll(out, callerWorkflowAppNameToken, appName) -} - -func workflowTemplateTag(version string) string { - version = strings.TrimPrefix(strings.TrimSpace(version), "v") - if isReleaseVersion(version) { - return "v" + version - } - return latestKnownWorkflowTag -} - -func isReleaseVersion(version string) bool { - parts := strings.Split(version, ".") - if len(parts) != 3 { - return false - } - for _, part := range parts { - if part == "" { - return false - } - for _, r := range part { - if r < '0' || r > '9' { - return false - } - } - } - return true -} - -// fetchIssue fills the issue title and body from GitHub via gh. -func fetchIssue(iss *domain.Issue) error { - out, err := exec.Command("gh", "issue", "view", strconv.Itoa(iss.Number), - "--repo", iss.Repo, "--json", "title,body").Output() - if err != nil { - return err - } - var v struct { - Title string `json:"title"` - Body string `json:"body"` - } - if err := json.Unmarshal(out, &v); err != nil { - return err - } - iss.Title, iss.Body = v.Title, v.Body - return nil -} +@/workspace/aixgo-code/cmd/aixgo-code/main.go \ No newline at end of file From 57bb08373115218ce664d65676464ddf99ae5861 Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:51:49 +0900 Subject: [PATCH 11/12] feat(init): restore slim main.go for engine select (init_select.go) Unbreaks branch after accidental path-ref stub. Engine select lives in init_select.go; gemini init writes Vertex-only caller. Dogfood aixgo-caller.yml stays Vertex-only. --- cmd/aixgo-code/main.go | 702 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 701 insertions(+), 1 deletion(-) diff --git a/cmd/aixgo-code/main.go b/cmd/aixgo-code/main.go index 2efdf74..30ea4cf 100644 --- a/cmd/aixgo-code/main.go +++ b/cmd/aixgo-code/main.go @@ -1 +1,701 @@ -@/workspace/aixgo-code/cmd/aixgo-code/main.go \ No newline at end of file +package main + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/aixgo-dev/code/internal/app" + "github.com/aixgo-dev/code/internal/buildinfo" + "github.com/aixgo-dev/code/internal/command" + "github.com/aixgo-dev/code/internal/config" + "github.com/aixgo-dev/code/internal/domain" + "github.com/aixgo-dev/code/internal/engine" + "github.com/aixgo-dev/code/internal/engine/claude" + "github.com/aixgo-dev/code/internal/engine/codex" + "github.com/aixgo-dev/code/internal/engine/gemini" + forge2 "github.com/aixgo-dev/code/internal/forge" + "github.com/aixgo-dev/code/internal/forge/dryrun" + forgegh "github.com/aixgo-dev/code/internal/forge/gh" + "github.com/aixgo-dev/code/internal/loop" + vcsgit "github.com/aixgo-dev/code/internal/vcs/git" + "github.com/aixgo-dev/code/internal/worktree" +) + +func main() { + os.Exit(dispatch(os.Args[1:], os.Stdout, os.Stderr)) +} + +func dispatch(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + usage(stderr) + return 2 + } + fail := func(err error) int { + fmt.Fprintln(stderr, "error:", err) + if errors.Is(err, ErrConfigMissing) { + return 3 + } + return 1 + } + switch args[0] { + case "version", "--version", "-v": + fmt.Fprintln(stdout, buildinfo.Version) + case "init": + if err := initCmd(args[1:], stdout); err != nil { + return fail(err) + } + case "command": + if err := commandCmd(args[1:], stdout); err != nil { + return fail(err) + } + case "preflight": + if err := preflightCmd(args[1:], stdout); err != nil { + return fail(err) + } + case "run": + if err := runCmd(args[1:]); err != nil { + return fail(err) + } + case "address": + if err := addressCmd(args[1:]); err != nil { + return fail(err) + } + default: + usage(stderr) + return 2 + } + return 0 +} + +func usage(w io.Writer) { + fmt.Fprintf(w, `aixgo %s + +usage: + aixgo-code version + aixgo-code init [--repo-dir .] [--workflow] [--engine codex|claude|gemini] [--force] + aixgo-code preflight [--repo-dir .] + aixgo-code command --body "" [flags] + aixgo-code run [flags] + aixgo-code address [flags] + +run drives an issue to a pull request. address runs the fix-on-request loop +over an open pull request: it reads the human's review feedback and pushes a +fix back to the same branch, or reports that there is nothing new to address. + +flags (both commands): + --repo-dir path to the target repo checkout (default ".") + --actor login that triggered the run; refused unless it has write access + --dry-run run the whole loop but make no GitHub writes and no push + --model engine model/deployment name (default "gpt-5.4") + --base worktree base ref (default "origin/HEAD") + --state-dir where worktrees and the generated config live + +environment (engine settings; never committed to a repo): + AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com (codex) + AIXGO_AZURE_OPENAI_API_KEY the key, read by name and never written to disk + AIXGO_VERTEX_PROJECT Google Cloud project (gemini / Vertex) + AIXGO_VERTEX_LOCATION Vertex location, default us-central1 + AIXGO_VERTEX_API_KEY Vertex API key, read by name and never written to disk +`, buildinfo.Version) +} + +const starterConfig = `# Repository-local contract for Aixgo Code. +# Keep the label prefix unless you already run another ax:* lifecycle. +labelPrefix: ax + +# The GitHub App you created and installed, without the "[bot]" suffix. Comment +# commands address it: "@__AIXGO_APP_NAME__ go" on an issue starts work. +# +# This is yours, not ours. App names are globally unique, so every installation +# has a different one, and addressing your real bot is what makes GitHub offer +# it in the autocomplete after someone types "@". +# +# The caller workflow triggers on this same handle. If you rename the App, +# change it here and re-run "aixgo-code init --workflow"; preflight fails if +# the two ever disagree. +appName: __AIXGO_APP_NAME__ + +# Model engine: codex (Azure OpenAI, default), gemini (Vertex AI), or claude +# (local CLI today; Actions install tracked in aixgo-dev/code#147). +# Chosen by "aixgo-code init --engine" or the interactive menu. +engine: __AIXGO_ENGINE__ + +# Required. Fill this in with the real gate that is already green on main. +gate: + +# Optional. One-time setup command before each run (install deps, generate code). +# setup: + +# Optional. Disable the generated commit/PR attribution marker. +# attribution: false + +# Optional. Ask the agent to add a generated walkthrough and changes table to PRs. +# prDescription: rich +` + +const ( + latestKnownWorkflowTag = "v0.5.0" + callerWorkflowTagToken = "__AIXGO_TAG__" + callerWorkflowAppNameToken = "__AIXGO_APP_NAME__" +) + +//go:embed aixgo-caller.yml.tmpl +var callerWorkflowTemplate string + +//go:embed aixgo-selftest.yml.tmpl +var selftestWorkflowTemplate string + +func usesAzure(cfg *config.Config) bool { + if cfg == nil { + return true + } + switch cfg.Engine { + case "claude", "gemini": + return false + } + return true +} + +func engineEnv(cfg *config.Config) (string, error) { + if cfg != nil && cfg.Engine == "claude" { + return "", nil + } + if cfg != nil && cfg.Engine == "gemini" { + if err := requireSet("AIXGO_VERTEX_PROJECT", sectionVariable); err != nil { + return "", err + } + if err := requireSet("AIXGO_VERTEX_API_KEY", sectionSecret); err != nil { + return "", err + } + return "", nil + } + if err := requireSet("AIXGO_AZURE_OPENAI_ENDPOINT", sectionVariable); err != nil { + return "", err + } + endpoint := strings.TrimRight(os.Getenv("AIXGO_AZURE_OPENAI_ENDPOINT"), "/") + u, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("AIXGO_AZURE_OPENAI_ENDPOINT is not a valid URL: %w", err) + } + if u.Scheme != "https" || u.Host == "" { + return "", fmt.Errorf("AIXGO_AZURE_OPENAI_ENDPOINT must be an https URL like https://.openai.azure.com, got %q", endpoint) + } + if err := requireSet("AIXGO_AZURE_OPENAI_API_KEY", sectionSecret); err != nil { + return "", err + } + return endpoint, nil +} + +var ErrConfigMissing = errors.New("configuration missing") + +type section string + +const ( + sectionVariable section = "variable" + sectionSecret section = "secret" +) + +func requireSet(name string, where section) error { + if strings.TrimSpace(os.Getenv(name)) != "" { + return nil + } + return fmt.Errorf("%w: %s is not set. It is a repository %s on your own repository, under Settings > Secrets and variables > Actions; a reusable workflow never inherits %ss from Aixgo", ErrConfigMissing, name, where, where) +} + +func newVCS(self string) *vcsgit.Git { + if self == "" { + return &vcsgit.Git{} + } + return &vcsgit.Git{ + AuthorName: self, + AuthorEmail: fmt.Sprintf("%s@users.noreply.github.com", strings.ReplaceAll(self, "[bot]", "")), + } +} + +func newRunner(cfg *config.Config, codexHome, model string) engine.Runner { + if cfg.Engine == "claude" { + return &claude.Runner{Model: model} + } + if cfg.Engine == "gemini" { + if model == "" || model == "gpt-5.4" { + model = "gemini-2.5-pro" + } + return &gemini.Runner{Model: model} + } + r := codex.New(codexHome) + if mode := os.Getenv("AIXGO_SANDBOX"); mode != "" { + r.Sandbox = mode + } + return r +} + +func takeBody(argv []string) (body string, rest []string, err error) { + for i := 0; i < len(argv); i++ { + a := argv[i] + switch { + case a == "--body" || a == "-body": + if i+1 >= len(argv) { + return "", nil, fmt.Errorf("--body requires a value") + } + body = argv[i+1] + i++ + case strings.HasPrefix(a, "--body="), strings.HasPrefix(a, "-body="): + _, v, _ := strings.Cut(a, "=") + body = v + default: + rest = append(rest, a) + } + } + return body, rest, nil +} + +func appNameFor(argv []string) (string, error) { + repoDir := "." + for i, a := range argv { + switch { + case a == "--repo-dir" || a == "-repo-dir": + if i+1 < len(argv) { + repoDir = argv[i+1] + } + case strings.HasPrefix(a, "--repo-dir="), strings.HasPrefix(a, "-repo-dir="): + _, v, _ := strings.Cut(a, "=") + repoDir = v + } + } + cfg, err := config.Load(filepath.Join(repoDir, ".github", "aixgo.yml")) + if err != nil { + return "", fmt.Errorf("load config: %w", err) + } + return cfg.AppName, nil +} + +func commandCmd(argv []string, stdout io.Writer) error { + body, rest, err := takeBody(argv) + if err != nil { + return err + } + appName, err := appNameFor(rest) + if err != nil { + return err + } + kind := command.Parse(body, appName) + + switch kind { + case command.Help: + return reply(rest, command.HelpTextFor(appName), stdout) + case command.Go, command.Address: + a, err := newAnswerer(rest) + if err != nil { + return err + } + if a.ok && command.Misdirected(kind, a.onPR) { + return a.post(command.MisdirectedText(kind, a.onPR, appName), stdout) + } + if kind == command.Go { + return runCmd(rest) + } + return addressCmd(rest) + default: + if command.Addressed(body, appName) { + return reply(rest, command.UnknownTextFor(appName), stdout) + } + fmt.Fprintln(stdout, "no command recognised in that comment; nothing to do") + return nil + } +} + +var newCommentForge = func() forge2.Forge { return &forgegh.Forge{} } + +type answerer struct { + forge forge2.Forge + dry *dryrun.Forge + ref domain.Issue + onPR bool + ok bool +} + +func newAnswerer(argv []string) (answerer, error) { + var a answerer + for _, arg := range argv { + if ref, err := app.ParseIssueRef(arg); err == nil { + a.ref, a.ok = ref, true + break + } + } + if !a.ok { + return a, nil + } + gf := newCommentForge() + a.forge = gf + if dryRunRequested(argv) { + a.dry = dryrun.New(gf) + a.forge = a.dry + } + onPR, err := a.forge.IsPullRequest(context.Background(), a.ref.Repo, a.ref.Number) + if err != nil { + return a, fmt.Errorf("resolve %s#%d: %w", a.ref.Repo, a.ref.Number, err) + } + a.onPR = onPR + return a, nil +} + +func (a answerer) post(body string, stdout io.Writer) error { + fmt.Fprintln(stdout, body) + if !a.ok { + return nil + } + defer func() { + if a.dry == nil { + return + } + fmt.Fprintln(stdout) + fmt.Fprintln(stdout, a.dry.Report()) + }() + ctx := context.Background() + if a.onPR { + return a.forge.CommentPR(ctx, a.ref.Repo, a.ref.Number, body) + } + return a.forge.Comment(ctx, a.ref.Repo, a.ref.Number, body) +} + +func dryRunRequested(argv []string) bool { + if os.Getenv("AIXGO_DRY_RUN") != "" { + return true + } + for _, arg := range argv { + switch arg { + case "--dry-run", "-dry-run", "--dry-run=true", "-dry-run=true": + return true + } + } + return false +} + +func reply(argv []string, body string, stdout io.Writer) error { + a, err := newAnswerer(argv) + if err != nil { + return err + } + return a.post(body, stdout) +} + +func checkMentionAgreement(repoDir, appName string) error { + dir := filepath.Join(repoDir, ".github", "workflows") + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + want := "'" + command.MentionFor(appName) + "'" + var callers []string + for _, e := range entries { + if e.IsDir() { + continue + } + path := filepath.Join(dir, e.Name()) + b, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(b), "aixgo-dev/code/.github/workflows/aixgo.yml@") { + continue + } + callers = append(callers, path) + if strings.Contains(string(b), want) { + return nil + } + } + if len(callers) == 0 { + return nil + } + return fmt.Errorf("%w: .github/aixgo.yml sets appName %q, but %s does not trigger on %s. Comment commands will never fire. Re-run \"aixgo-code init --workflow\" to rewrite the trigger from the config", + ErrConfigMissing, appName, strings.Join(callers, ", "), want) +} + +func preflightCmd(argv []string, stdout io.Writer) error { + fs := flag.NewFlagSet("preflight", flag.ContinueOnError) + repoDir := fs.String("repo-dir", ".", "path to the target repo checkout") + actions := fs.Bool("actions", false, "also check the values only a caller workflow supplies") + if _, err := parseInterleaved(fs, argv); err != nil { + return err + } + cfg, err := config.Load(filepath.Join(*repoDir, ".github", "aixgo.yml")) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + if _, err := engineEnv(cfg); err != nil { + return err + } + if cfg.AppName == "" { + return fmt.Errorf("%w: .github/aixgo.yml sets no appName. Comment commands address the App you installed, so without it none of them can fire. Add \"appName: \" or re-run \"aixgo-code init --workflow --app-name \"", ErrConfigMissing) + } + if err := checkMentionAgreement(*repoDir, cfg.AppName); err != nil { + return err + } + if *actions { + for _, v := range []struct { + name string + where section + }{ + {"AIXGO_GH_APP_CLIENT_ID", sectionVariable}, + {"AIXGO_GH_APP_PRIVATE_KEY", sectionSecret}, + } { + if err := requireSet(v.name, v.where); err != nil { + return err + } + } + } + fmt.Fprintln(stdout, "preflight ok: config and engine settings are present") + return nil +} + +type commonFlags struct { + repoDir string + base string + actor string + dry *dryrun.Forge + cfg *config.Config + deps app.Deps +} + +func parseInterleaved(fs *flag.FlagSet, argv []string) ([]string, error) { + var positionals []string + for { + if err := fs.Parse(argv); err != nil { + return nil, err + } + argv = fs.Args() + if len(argv) == 0 { + return positionals, nil + } + positionals = append(positionals, argv[0]) + argv = argv[1:] + } +} + +func isBotLogin(login string) bool { + return strings.HasSuffix(login, "[bot]") +} + +func prepare(name string, argv []string) (*commonFlags, []string, error) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + repoDir := fs.String("repo-dir", ".", "path to the target repo checkout") + model := fs.String("model", "gpt-5.4", "engine model/deployment name") + base := fs.String("base", "origin/HEAD", "worktree base ref") + stateDir := fs.String("state-dir", filepath.Join(os.TempDir(), "aixgo"), "state directory") + actor := fs.String("actor", "", "login that triggered this run; checked for write access") + dryRun := fs.Bool("dry-run", false, "run the whole loop but make no GitHub writes and no push") + rest, err := parseInterleaved(fs, argv) + if err != nil { + return nil, nil, err + } + + cfg, err := config.Load(filepath.Join(*repoDir, ".github", "aixgo.yml")) + if err != nil { + return nil, nil, fmt.Errorf("load config: %w", err) + } + + endpoint, err := engineEnv(cfg) + if err != nil { + return nil, nil, err + } + + codexHome := filepath.Join(*stateDir, "codex-home") + if usesAzure(cfg) { + if _, err := codex.WriteConfig(codexHome, codex.ProviderConfig{ + Model: *model, + BaseURL: endpoint + "/openai/v1", + EnvKey: "AIXGO_AZURE_OPENAI_API_KEY", + }); err != nil { + return nil, nil, fmt.Errorf("write codex config: %w", err) + } + } + + prefix := cfg.LabelPrefix + if prefix == "" { + prefix = config.DefaultLabelPrefix + } + + forge := &forgegh.Forge{StateLabels: app.StateLabels(prefix), Self: os.Getenv("AIXGO_GH_APP_LOGIN")} + dry := *dryRun || os.Getenv("AIXGO_DRY_RUN") != "" + if forge.Self == "" { + if login, err := forge.Whoami(context.Background()); err == nil { + forge.Self = login + } + } + + vcs := newVCS(forge.Self) + vcs.DryRun = dry + var forgeForLoop forge2.Forge = forge + var dryForge *dryrun.Forge + if dry { + dryForge = dryrun.New(forge) + forgeForLoop = dryForge + } + + deps := app.Deps{ + Runner: newRunner(cfg, codexHome, *model), + Forge: forgeForLoop, + VCS: vcs, + Worktrees: &worktree.Manager{RepoDir: *repoDir, BaseDir: filepath.Join(*stateDir, "worktrees")}, + WorkflowRestrictedPush: isBotLogin(forge.Self), + SelfLogin: forge.Self, + } + return &commonFlags{repoDir: *repoDir, base: *base, actor: *actor, cfg: cfg, deps: deps, dry: dryForge}, rest, nil +} + +func reportDryRun(c *commonFlags, w io.Writer) { + if c == nil || c.dry == nil { + return + } + report := c.dry.Report() + fmt.Fprintln(w) + fmt.Fprintln(w, report) + if path := os.Getenv("GITHUB_STEP_SUMMARY"); path != "" { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, "## Aixgo Code dry run\n\n```\n%s\n```\n", report) + } +} + +func runCmd(argv []string) error { + c, rest, err := prepare("run", argv) + if err != nil { + return err + } + if len(rest) < 1 { + return fmt.Errorf("missing issue ref (want owner/repo#N)") + } + iss, err := app.ParseIssueRef(rest[0]) + if err != nil { + return err + } + if err := app.Authorize(context.Background(), c.deps, iss.Repo, c.actor); err != nil { + return err + } + if err := fetchIssue(&iss); err != nil { + return fmt.Errorf("fetch issue: %w", err) + } + + if c.dry != nil { + fmt.Println("DRY RUN: no GitHub writes and no push will happen.") + } + fmt.Printf("running %s#%d against gate %q ...\n", iss.Repo, iss.Number, c.cfg.Gate) + res, err := app.Run(context.Background(), c.deps, c.cfg, iss, c.base) + defer reportDryRun(c, os.Stdout) + if err != nil { + return err + } + if res.Outcome == loop.OutcomePROpened { + fmt.Printf("PR opened after %d round(s): %s\n", res.Rounds, res.PRURL) + } else { + fmt.Printf("blocked after %d round(s): %s\n", res.Rounds, res.Reason) + } + return nil +} + +func addressCmd(argv []string) error { + c, rest, err := prepare("address", argv) + if err != nil { + return err + } + if len(rest) < 1 { + return fmt.Errorf("missing pull-request ref (want owner/repo#PR)") + } + ref, err := app.ParseIssueRef(rest[0]) + if err != nil { + return err + } + if err := app.Authorize(context.Background(), c.deps, ref.Repo, c.actor); err != nil { + return err + } + + fmt.Printf("addressing feedback on %s#%d against gate %q ...\n", ref.Repo, ref.Number, c.cfg.Gate) + res, err := app.AddressPR(context.Background(), c.deps, c.cfg, ref.Repo, ref.Number) + defer reportDryRun(c, os.Stdout) + if err != nil { + return err + } + switch res.Outcome { + case loop.OutcomeChangesPushed: + fmt.Printf("changes pushed after %d round(s); re-requested review\n", res.Rounds) + case loop.OutcomeNoFeedback: + fmt.Println("no new review feedback to address; nothing to do") + default: + fmt.Printf("blocked after %d round(s): %s\n", res.Rounds, res.Reason) + } + return nil +} + +func appCreateURL(repoDir string) (string, bool) { + out, err := exec.Command("gh", "repo", "view", "--json", "owner", "--jq", ".owner.login").Output() + _ = repoDir + if err != nil { + return "https://github.com/settings/apps/new", false + } + owner := strings.TrimSpace(string(out)) + if owner == "" { + return "https://github.com/settings/apps/new", false + } + kind, err := exec.Command("gh", "api", "users/"+owner, "--jq", ".type").Output() + if err != nil { + return "https://github.com/settings/apps/new", false + } + if strings.TrimSpace(string(kind)) == "Organization" { + return "https://github.com/organizations/" + owner + "/settings/apps/new", true + } + return "https://github.com/settings/apps/new", true +} + +func workflowTemplateTag(version string) string { + version = strings.TrimPrefix(strings.TrimSpace(version), "v") + if isReleaseVersion(version) { + return "v" + version + } + return latestKnownWorkflowTag +} + +func isReleaseVersion(version string) bool { + parts := strings.Split(version, ".") + if len(parts) != 3 { + return false + } + for _, part := range parts { + if part == "" { + return false + } + for _, r := range part { + if r < '0' || r > '9' { + return false + } + } + } + return true +} + +func fetchIssue(iss *domain.Issue) error { + out, err := exec.Command("gh", "issue", "view", strconv.Itoa(iss.Number), + "--repo", iss.Repo, "--json", "title,body").Output() + if err != nil { + return err + } + var v struct { + Title string `json:"title"` + Body string `json:"body"` + } + if err := json.Unmarshal(out, &v); err != nil { + return err + } + iss.Title, iss.Body = v.Title, v.Body + return nil +} From 138425ad40f79438fa68183b5c87142669344ae2 Mon Sep 17 00:00:00 2001 From: Charles Green Date: Sat, 19 Sep 2026 19:55:31 +0900 Subject: [PATCH 12/12] fix(init): gofmt main.go and update init tests for --engine CI failed on gofmt. Tests now pass --engine codex and expect the engine-specific next-steps wording; renderCallerWorkflow calls take the third engine argument. --- cmd/aixgo-code/main.go | 10 +++++----- cmd/aixgo-code/main_test.go | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cmd/aixgo-code/main.go b/cmd/aixgo-code/main.go index 30ea4cf..81136e1 100644 --- a/cmd/aixgo-code/main.go +++ b/cmd/aixgo-code/main.go @@ -322,7 +322,7 @@ type answerer struct { dry *dryrun.Forge ref domain.Issue onPR bool - ok bool + ok bool } func newAnswerer(argv []string) (answerer, error) { @@ -460,9 +460,9 @@ type commonFlags struct { repoDir string base string actor string - dry *dryrun.Forge - cfg *config.Config - deps app.Deps + dry *dryrun.Forge + cfg *config.Config + deps app.Deps } func parseInterleaved(fs *flag.FlagSet, argv []string) ([]string, error) { @@ -541,7 +541,7 @@ func prepare(name string, argv []string) (*commonFlags, []string, error) { } deps := app.Deps{ - Runner: newRunner(cfg, codexHome, *model), + Runner: newRunner(cfg, codexHome, *model), Forge: forgeForLoop, VCS: vcs, Worktrees: &worktree.Manager{RepoDir: *repoDir, BaseDir: filepath.Join(*stateDir, "worktrees")}, diff --git a/cmd/aixgo-code/main_test.go b/cmd/aixgo-code/main_test.go index b46776d..7d6916d 100644 --- a/cmd/aixgo-code/main_test.go +++ b/cmd/aixgo-code/main_test.go @@ -89,7 +89,7 @@ exit 0 t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) var out bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code"}, &out); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code", "--engine", "codex"}, &out); err != nil { t.Fatalf("initCmd: %v", err) } @@ -145,10 +145,10 @@ exit 0 // Naming the section is the point: two of the four go under Variables // and two under Secrets, and a value filed on the wrong tab reads back // empty rather than erroring. - "add two repository VARIABLES", + "add repository VARIABLES", "AIXGO_GH_APP_CLIENT_ID the App Client ID, the Iv23 string on the App settings page", "AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://.openai.azure.com", - "add two repository SECRETS", + "add repository SECRETS", "AIXGO_GH_APP_PRIVATE_KEY the full PEM, including the BEGIN and END lines", "AIXGO_AZURE_OPENAI_API_KEY the Azure OpenAI key", "Variables and Secrets are different tabs", @@ -193,7 +193,7 @@ exit 0 t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) var out bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code"}, &out); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code", "--engine", "codex"}, &out); err != nil { t.Fatalf("initCmd: %v", err) } @@ -247,7 +247,7 @@ exit 0 buildVersionForTest(t, "0.1.0") var out bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code"}, &out); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "codex"}, &out); err != nil { t.Fatalf("initCmd: %v", err) } @@ -311,7 +311,7 @@ exit 0 t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) var out bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code"}, &out); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--app-name", "acme-code", "--engine", "codex"}, &out); err != nil { t.Fatalf("initCmd: %v", err) } @@ -360,7 +360,7 @@ exit 0 t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) var out bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code"}, &out); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "codex"}, &out); err != nil { t.Fatalf("initCmd: %v", err) } @@ -411,7 +411,7 @@ func TestCallerWorkflowTemplateMatchesDocsTemplate(t *testing.T) { // Render at the latest known tag with the sample handle the docs template // shows: a release bump or a trigger change that misses either copy fails // here. - got := []byte(renderCallerWorkflow(latestKnownWorkflowTag, "your-app-name")) + got := []byte(renderCallerWorkflow(latestKnownWorkflowTag, "your-app-name", "codex")) if !bytes.Equal(got, want) { t.Fatalf("rendered embedded template does not match docs template\nrendered:\n%s\n\ndocs:\n%s", got, want) } @@ -860,7 +860,7 @@ func TestInitWithWorkflowIsIdempotent(t *testing.T) { t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) var first bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code"}, &first); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "codex"}, &first); err != nil { t.Fatalf("first init: %v", err) } selftest := filepath.Join(repoDir, ".github", "workflows", "aixgo-selftest.yml") @@ -869,7 +869,7 @@ func TestInitWithWorkflowIsIdempotent(t *testing.T) { } var second bytes.Buffer - if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code"}, &second); err != nil { + if err := initCmd([]string{"--repo-dir", repoDir, "--workflow", "--app-name", "acme-code", "--engine", "codex"}, &second); err != nil { t.Fatalf("second init: %v", err) } if !strings.Contains(second.String(), "left existing "+selftest+" unchanged") {