From 2add74c14d9f601dbcc5a0e9bad17b0f9dee49c0 Mon Sep 17 00:00:00 2001 From: Christina Chu Date: Thu, 29 Oct 2026 15:35:03 -0700 Subject: [PATCH 1/2] Prototype: connect GitHub and prepare Kiro in aws-devops-agent setup Add the --github-repo and --repo-dir flags to setup. With --github-repo, setup finds the GitHub registration of the AWS account, or opens the console and waits for it. Then it connects the repository to the agent space. With the gh CLI, it stores the Kiro API key as a secret, lets GitHub Actions open pull requests, writes the three Kiro files, and asks before it protects the default branch. This is a prototype. It has no tests. Co-Authored-By: Claude Opus 5.5 --- cmd/aws_devops_agent/kiro.go | 354 ++++++++++++++++++ .../kirofiles/flag-implementer.json | 18 + .../kirofiles/kiro-implement.yml | 111 ++++++ cmd/aws_devops_agent/kirofiles/merge-pr.yml | 38 ++ cmd/aws_devops_agent/setup.go | 19 +- internal/awsdevops/github.go | 121 ++++++ 6 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 cmd/aws_devops_agent/kiro.go create mode 100644 cmd/aws_devops_agent/kirofiles/flag-implementer.json create mode 100644 cmd/aws_devops_agent/kirofiles/kiro-implement.yml create mode 100644 cmd/aws_devops_agent/kirofiles/merge-pr.yml create mode 100644 internal/awsdevops/github.go diff --git a/cmd/aws_devops_agent/kiro.go b/cmd/aws_devops_agent/kiro.go new file mode 100644 index 00000000..78150b0a --- /dev/null +++ b/cmd/aws_devops_agent/kiro.go @@ -0,0 +1,354 @@ +package awsdevopsagent + +import ( + "bytes" + "embed" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/pkg/browser" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/launchdarkly/ldcli/internal/awsdevops" +) + +const ( + githubRepoFlag = "github-repo" + repoDirFlag = "repo-dir" + + kiroSecretName = "KIRO_API_KEY" + defaultBranchMarker = "__DEFAULT_BRANCH__" + kiroAPIKeyPortalURL = "https://app.kiro.dev" + githubConsoleSteps = "Capabilities > Pipeline > Add > GitHub. Pick Read & Write, and install the app only on " + kiroFileDescriptions = "the Kiro agent and the implement and merge workflows" +) + +//go:embed kirofiles/* +var kiroFiles embed.FS + +// kiroFile maps an embedded template to its path in the repository. +type kiroFile struct { + template string + path string +} + +var kiroFileList = []kiroFile{ + {"kirofiles/flag-implementer.json", ".kiro/agents/flag-implementer.json"}, + {"kirofiles/kiro-implement.yml", ".github/workflows/kiro-implement.yml"}, + {"kirofiles/merge-pr.yml", ".github/workflows/merge-pr.yml"}, +} + +// addKiroFlags adds the flags for the GitHub and Kiro step of setup. +func addKiroFlags(cmd *cobra.Command) { + cmd.Flags().String(githubRepoFlag, "", "GitHub repository (owner/name) the agent works in. If set, setup also connects GitHub and prepares the repository for Kiro. Needs the gh CLI") + cmd.Flags().String(repoDirFlag, ".", "Local checkout of --"+githubRepoFlag+", where setup writes the Kiro files") +} + +// setupGitHubAndKiro connects the repository to the agent space and prepares +// it for headless Kiro runs. It is a prototype: every step after the +// association uses the gh CLI as the person who runs setup. +func setupGitHubAndKiro(cmd *cobra.Command, clients awsdevops.Clients, agentSpaceID string, ask bool) error { + fullName := mustString(cmd, githubRepoFlag) + if fullName == "" { + return nil + } + out := cmd.ErrOrStderr() + logf := func(format string, args ...any) { + _, _ = fmt.Fprintf(out, format+"\n", args...) + } + if _, err := exec.LookPath("gh"); err != nil { + return fmt.Errorf("--%s needs the GitHub CLI. Install gh and run gh auth login, then run setup again", githubRepoFlag) + } + + _, _ = fmt.Fprintf(out, "\nGitHub and Kiro for %s\n", fullName) + repo, defaultBranch, err := lookUpRepo(fullName) + if err != nil { + return err + } + + // 1. The account-level GitHub registration. Only the console can make it. + serviceID, err := awsdevops.GitHubRegistration(cmd.Context(), clients, repo.Owner) + if err != nil { + return err + } + if serviceID == "" { + console := awsdevops.ConsoleURL(clients.Region) + logf("This AWS account has no GitHub registration for %s.", repo.Owner) + logf("In the AWS console, open %s%s.", githubConsoleSteps, repo.FullName()) + logf(" %s", console) + _ = openURL(console) + serviceID, err = awsdevops.WaitForGitHubRegistration(cmd.Context(), clients, repo.Owner, logf) + if err != nil { + return err + } + } + logf("Found the GitHub registration for %s (%s)", repo.Owner, serviceID) + + // 2. The repository in the agent space. + associationID, existed, err := awsdevops.ConnectGitHubRepo(cmd.Context(), clients, agentSpaceID, serviceID, repo) + if err != nil { + return err + } + if existed { + logf("%s is already connected to the agent space (%s)", repo.FullName(), associationID) + } else { + logf("Connected %s to the agent space (%s)", repo.FullName(), associationID) + } + + // 3. The Kiro API key as a repository secret. + if err := storeKiroKey(cmd, repo, ask, logf); err != nil { + return err + } + + // 4. Let the GITHUB_TOKEN of a workflow open pull requests. + if err := allowActionsPullRequests(repo, logf); err != nil { + return err + } + + // 5. The Kiro agent and the two workflows. + dir := mustString(cmd, repoDirFlag) + written, err := writeKiroFiles(dir, defaultBranch, logf) + if err != nil { + return err + } + + // 6. The agent dispatches the deploy workflow, so it needs workflow_dispatch. + warnNoDispatchableDeploy(dir, logf) + + // 7. Branch protection keeps the agent configuration under review. + if err := offerBranchProtection(cmd, repo, defaultBranch, ask, logf); err != nil { + return err + } + + if len(written) > 0 { + _, _ = fmt.Fprintf(out, "\nSetup wrote %s. Review and commit them:\n", kiroFileDescriptions) + _, _ = fmt.Fprintf(out, " git add %s\n", strings.Join(written, " ")) + _, _ = fmt.Fprintln(out, " git commit -m \"Add the Kiro agent and workflows for AWS DevOps Agent\"") + } + + return nil +} + +var openURL = browser.OpenURL + +// lookUpRepo reads the repository ID, owner type and default branch. +func lookUpRepo(fullName string) (awsdevops.GitHubRepo, string, error) { + owner, name, ok := strings.Cut(fullName, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return awsdevops.GitHubRepo{}, "", fmt.Errorf("--%s must be owner/name, for example acme/shop", githubRepoFlag) + } + raw, err := gh(nil, "api", "repos/"+fullName) + if err != nil { + return awsdevops.GitHubRepo{}, "", fmt.Errorf("unable to read %s with gh: %w", fullName, err) + } + var data struct { + ID int64 `json:"id"` + Name string `json:"name"` + DefaultBranch string `json:"default_branch"` + Owner struct { + Login string `json:"login"` + Type string `json:"type"` + } `json:"owner"` + Permissions struct { + Admin bool `json:"admin"` + } `json:"permissions"` + } + if err := json.Unmarshal(raw, &data); err != nil { + return awsdevops.GitHubRepo{}, "", fmt.Errorf("unable to read the gh output for %s: %w", fullName, err) + } + if !data.Permissions.Admin { + return awsdevops.GitHubRepo{}, "", fmt.Errorf("you need admin access to %s to store a secret and change its Actions settings", fullName) + } + + return awsdevops.GitHubRepo{ + Owner: data.Owner.Login, + Name: data.Name, + ID: strconv.FormatInt(data.ID, 10), + Organization: data.Owner.Type == "Organization", + }, data.DefaultBranch, nil +} + +// storeKiroKey asks for the Kiro API key and stores it as a repository secret. +// If the secret exists, setup keeps it. +func storeKiroKey(cmd *cobra.Command, repo awsdevops.GitHubRepo, ask bool, logf func(string, ...any)) error { + raw, err := gh(nil, "secret", "list", "--repo", repo.FullName(), "--json", "name") + if err != nil { + return fmt.Errorf("unable to list the secrets of %s: %w", repo.FullName(), err) + } + var secrets []struct { + Name string `json:"name"` + } + _ = json.Unmarshal(raw, &secrets) + for _, secret := range secrets { + if secret.Name == kiroSecretName { + logf("%s already has the %s secret", repo.FullName(), kiroSecretName) + return nil + } + } + + key := strings.TrimSpace(os.Getenv(kiroSecretName)) + if key == "" { + if !ask { + return fmt.Errorf("set %s in the environment, or run setup in a terminal to type it", kiroSecretName) + } + logf("Kiro needs an API key to run with no person present. Make one in your account settings at %s.", kiroAPIKeyPortalURL) + logf("The key needs a Kiro Pro, Pro+, Pro Max or Power plan.") + _, _ = fmt.Fprint(cmd.ErrOrStderr(), "Kiro API key (input is hidden): ") + bytesKey, err := term.ReadPassword(int(os.Stdin.Fd())) + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) + if err != nil { + return fmt.Errorf("unable to read the Kiro API key: %w", err) + } + key = strings.TrimSpace(string(bytesKey)) + } + if key == "" { + return errors.New("no Kiro API key was given") + } + + // gh reads the value from stdin, so the key is not in the process list. + if _, err := gh(strings.NewReader(key), "secret", "set", kiroSecretName, "--repo", repo.FullName()); err != nil { + return fmt.Errorf("unable to store the %s secret: %w", kiroSecretName, err) + } + logf("Stored the %s secret in %s", kiroSecretName, repo.FullName()) + + return nil +} + +// allowActionsPullRequests turns on "Allow GitHub Actions to create and +// approve pull requests". It keeps the default token permissions as they are. +func allowActionsPullRequests(repo awsdevops.GitHubRepo, logf func(string, ...any)) error { + path := "repos/" + repo.FullName() + "/actions/permissions/workflow" + raw, err := gh(nil, "api", path) + if err != nil { + return fmt.Errorf("unable to read the Actions settings of %s: %w", repo.FullName(), err) + } + var current struct { + DefaultWorkflowPermissions string `json:"default_workflow_permissions"` + CanApprovePullRequestReviews bool `json:"can_approve_pull_request_reviews"` + } + _ = json.Unmarshal(raw, ¤t) + if current.CanApprovePullRequestReviews { + logf("GitHub Actions can already open pull requests in %s", repo.FullName()) + return nil + } + + _, err = gh(nil, "api", "-X", "PUT", path, + "-f", "default_workflow_permissions="+current.DefaultWorkflowPermissions, + "-F", "can_approve_pull_request_reviews=true") + if err != nil { + return fmt.Errorf( + "unable to let GitHub Actions open pull requests in %s. If an organization policy blocks it, "+ + "turn it on in Organization Settings > Actions > General: %w", + repo.FullName(), err) + } + logf("Let GitHub Actions open pull requests in %s", repo.FullName()) + + return nil +} + +// writeKiroFiles writes each Kiro file that the checkout does not have yet. +// It returns the paths it wrote. +func writeKiroFiles(dir, defaultBranch string, logf func(string, ...any)) ([]string, error) { + if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { + return nil, fmt.Errorf("%s is not the root of a git checkout. Pass --%s with the checkout of the repository", dir, repoDirFlag) + } + var written []string + for _, file := range kiroFileList { + target := filepath.Join(dir, file.path) + if _, err := os.Stat(target); err == nil { + logf("Kept %s, which already exists", file.path) + continue + } + body, err := kiroFiles.ReadFile(file.template) + if err != nil { + return written, err + } + body = bytes.ReplaceAll(body, []byte(defaultBranchMarker), []byte(defaultBranch)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return written, err + } + if err := os.WriteFile(target, body, 0o644); err != nil { + return written, err + } + logf("Wrote %s", file.path) + written = append(written, file.path) + } + + return written, nil +} + +// warnNoDispatchableDeploy warns when no other workflow has a +// workflow_dispatch trigger. Setup cannot know which workflow deploys, so it +// does not change one. +func warnNoDispatchableDeploy(dir string, logf func(string, ...any)) { + own := map[string]bool{} + for _, file := range kiroFileList { + own[filepath.Base(file.path)] = true + } + matches, _ := filepath.Glob(filepath.Join(dir, ".github", "workflows", "*.y*ml")) + for _, match := range matches { + if own[filepath.Base(match)] { + continue + } + body, err := os.ReadFile(match) + if err == nil && bytes.Contains(body, []byte("workflow_dispatch")) { + return + } + } + logf("Warning: no deploy workflow has a workflow_dispatch trigger. " + + "The agent starts deployment with it. Add \"workflow_dispatch:\" under \"on:\" in your deploy workflow") +} + +// offerBranchProtection asks before it protects the default branch, because +// the rule changes how everyone merges to it. +func offerBranchProtection(cmd *cobra.Command, repo awsdevops.GitHubRepo, branch string, ask bool, logf func(string, ...any)) error { + path := fmt.Sprintf("repos/%s/branches/%s/protection", repo.FullName(), branch) + if _, err := gh(nil, "api", path); err == nil { + logf("%s already has branch protection on %s", repo.FullName(), branch) + return nil + } + logf("Anyone who can push to %s can change what the agent is allowed to do.", branch) + if !ask { + logf("Warning: %s has no branch protection on %s. Require pull requests and passing status checks", repo.FullName(), branch) + return nil + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Require pull requests and passing status checks on %s? [Y/n]: ", branch) + if !readYes(sharedStdin(cmd)) { + logf("Skipped branch protection") + return nil + } + + rule := `{"required_status_checks":{"strict":true,"contexts":[]},` + + `"enforce_admins":false,` + + `"required_pull_request_reviews":{"required_approving_review_count":1},` + + `"restrictions":null}` + if _, err := gh(strings.NewReader(rule), "api", "-X", "PUT", path, "--input", "-"); err != nil { + return fmt.Errorf("unable to protect %s in %s: %w", branch, repo.FullName(), err) + } + logf("Protected %s in %s", branch, repo.FullName()) + + return nil +} + +// gh runs the GitHub CLI and returns its stdout. +func gh(stdin *strings.Reader, args ...string) ([]byte, error) { + command := exec.Command("gh", args...) + if stdin != nil { + command.Stdin = stdin + } + var stderr bytes.Buffer + command.Stderr = &stderr + out, err := command.Output() + if err != nil { + return out, fmt.Errorf("gh %s: %s", strings.Join(args[:min(2, len(args))], " "), strings.TrimSpace(stderr.String())) + } + + return out, nil +} diff --git a/cmd/aws_devops_agent/kirofiles/flag-implementer.json b/cmd/aws_devops_agent/kirofiles/flag-implementer.json new file mode 100644 index 00000000..61351cf2 --- /dev/null +++ b/cmd/aws_devops_agent/kirofiles/flag-implementer.json @@ -0,0 +1,18 @@ +{ + "name": "flag-implementer", + "description": "Implements a single change gated behind a LaunchDarkly feature flag.", + "prompt": "You implement one narrowly scoped change at a time, always gated behind the LaunchDarkly feature flag key supplied in the task. Requirements: (1) Evaluate the flag using the LaunchDarkly SDK already present in the repository; do not add a new SDK or a new client instance. (2) The control path must remain the default behavior when the flag is off or when flag evaluation fails. (3) Do not change flag configuration in LaunchDarkly — the flag already exists and is managed elsewhere. (4) Touch only the files required for this change. (5) Do not create commits, branches, or pull requests; the surrounding workflow handles version control. (6) If the task is ambiguous or would require a change outside the application code, stop and explain rather than guessing.", + "tools": ["fs_read", "fs_write", "execute_bash"], + "allowedTools": ["fs_read", "fs_write"], + "permissions": { + "rules": [ + { "capability": "fs_read", "match": ["./**"], "effect": "allow" }, + { "capability": "fs_write", "match": ["./**"], "effect": "allow" }, + { + "capability": "fs_write", + "match": [".git", ".git/**", ".github/**", ".kiro/**", "**/.gitattributes"], + "effect": "deny" + } + ] + } +} diff --git a/cmd/aws_devops_agent/kirofiles/kiro-implement.yml b/cmd/aws_devops_agent/kirofiles/kiro-implement.yml new file mode 100644 index 00000000..6f645b01 --- /dev/null +++ b/cmd/aws_devops_agent/kirofiles/kiro-implement.yml @@ -0,0 +1,111 @@ +name: Kiro Implement Behind Flag + +on: + workflow_dispatch: + inputs: + task_description: + description: "What to implement" + required: true + flag_key: + description: "LaunchDarkly flag key gating the change" + required: true + variation_details: + description: "Behavior of the treatment variation" + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + implement: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install Kiro CLI + run: curl -fsSL https://cli.kiro.dev/install | bash + + - name: Create working branch + env: + FLAG_KEY: ${{ inputs.flag_key }} + run: | + if [[ ! "$FLAG_KEY" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::error::Invalid flag key" + exit 1 + fi + BRANCH="experiment/${FLAG_KEY}-${GITHUB_RUN_ID}" + echo "BRANCH=$BRANCH" >> "$GITHUB_ENV" + git checkout -b "$BRANCH" + git config user.email "kiro-agent@users.noreply.github.com" + git config user.name "Kiro Agent" + + - name: Snapshot .git state + run: | + find .git -type f -print0 | sort -z \ + | xargs -0 sha256sum > "$RUNNER_TEMP/git-snapshot.sha256" + + - name: Implement the change + env: + KIRO_API_KEY: ${{ secrets.KIRO_API_KEY }} + TASK_DESCRIPTION: ${{ inputs.task_description }} + FLAG_KEY: ${{ inputs.flag_key }} + VARIATION_DETAILS: ${{ inputs.variation_details }} + run: | + set -o pipefail + kiro-cli chat \ + --agent flag-implementer \ + --no-interactive \ + "Task: ${TASK_DESCRIPTION} + LaunchDarkly flag key: ${FLAG_KEY} + Treatment variation: ${VARIATION_DETAILS}" \ + | tee "$RUNNER_TEMP/kiro-run.jsonl" + + - name: Commit and push + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TASK_DESCRIPTION: ${{ inputs.task_description }} + FLAG_KEY: ${{ inputs.flag_key }} + run: | + # Refuse to proceed if Kiro wrote anywhere under .git/ — a tampered + # .git/config (core.fsmonitor, core.hooksPath, remote URL, etc.) + # could make the git commands below execute attacker-controlled code + # with GH_TOKEN in the environment. + find .git -type f -print0 | sort -z \ + | xargs -0 sha256sum > "$RUNNER_TEMP/git-snapshot-after.sha256" + if ! diff -q "$RUNNER_TEMP/git-snapshot.sha256" "$RUNNER_TEMP/git-snapshot-after.sha256" >/dev/null; then + echo "::error::.git contents changed during Kiro run — refusing to commit" + diff "$RUNNER_TEMP/git-snapshot.sha256" "$RUNNER_TEMP/git-snapshot-after.sha256" || true + exit 1 + fi + GIT="git -c core.fsmonitor=false -c core.hooksPath=/dev/null" + $GIT add -A + if $GIT diff --staged --quiet; then + echo "::error::Kiro produced no changes" + exit 1 + fi + $GIT commit -m "feat: ${TASK_DESCRIPTION} (flag: ${FLAG_KEY})" + $GIT push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + + - name: Open pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TASK_DESCRIPTION: ${{ inputs.task_description }} + FLAG_KEY: ${{ inputs.flag_key }} + run: | + gh pr create \ + --base __DEFAULT_BRANCH__ \ + --head "$BRANCH" \ + --title "feat: ${TASK_DESCRIPTION}" \ + --body "Implemented by Kiro CLI in headless mode. + Gated behind LaunchDarkly flag ${FLAG_KEY}." + + - name: Upload run log + if: always() + uses: actions/upload-artifact@v4 + with: + name: kiro-run-log + path: ${{ runner.temp }}/kiro-run.jsonl diff --git a/cmd/aws_devops_agent/kirofiles/merge-pr.yml b/cmd/aws_devops_agent/kirofiles/merge-pr.yml new file mode 100644 index 00000000..b2a3b730 --- /dev/null +++ b/cmd/aws_devops_agent/kirofiles/merge-pr.yml @@ -0,0 +1,38 @@ +name: Merge Experiment Pull Request + +on: + workflow_dispatch: + inputs: + pr_number: + description: "Pull request number to merge" + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + merge: + runs-on: ubuntu-24.04 + steps: + - name: Merge the pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid pull request number" + exit 1 + fi + # Only merge PRs opened by the Kiro experiment workflow, which pushes + # from branches named experiment/-. + HEAD_REF=$(gh pr view "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --json headRefName --jq .headRefName) + if [[ ! "$HEAD_REF" =~ ^experiment/ ]]; then + echo "::error::PR #$PR_NUMBER head branch '$HEAD_REF' is not an experiment/* branch" + exit 1 + fi + gh pr merge "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --squash --delete-branch diff --git a/cmd/aws_devops_agent/setup.go b/cmd/aws_devops_agent/setup.go index 26e9e3e2..0ff306a5 100644 --- a/cmd/aws_devops_agent/setup.go +++ b/cmd/aws_devops_agent/setup.go @@ -75,7 +75,18 @@ steps. The experiment-orchestrator custom agent runs the skill with the LaunchDarkly MCP tools that the space allows. It needs the MCP server and the skill. Setting up the custom agent is optional: if its step fails, setup warns and -does the other steps.`, +does the other steps. + +With --github-repo, setup also lets the agent change code in that repository. +This step is a prototype, and it needs the gh CLI and admin access to the +repository. Setup finds the GitHub registration of the AWS account. If there +is none, setup opens the AWS console, where you install the GitHub App, and +waits. Then setup connects the repository to the agent space and stores the +Kiro API key as the KIRO_API_KEY secret. It reads the key from KIRO_API_KEY or +asks for it. Setup lets GitHub Actions open pull requests, and writes the Kiro +agent and the implement and merge workflows into --repo-dir. Setup does not +commit them. It warns when no workflow has a workflow_dispatch trigger, and it +asks before it protects the default branch.`, Args: cobra.NoArgs, PreRun: trackRun(analyticsTrackerFn), RunE: runSetup, @@ -100,6 +111,7 @@ does the other steps.`, _ = cmd.Flags().MarkDeprecated(addMCPToolsFlag, "setup now adds the missing MCP tools without it") cmd.Flags().Bool(yesFlag, false, "Ask no questions and accept every step") + addKiroFlags(cmd) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) @@ -179,6 +191,11 @@ func setupWith( return err } + // The GitHub and Kiro step runs only with --github-repo. + if err := setupGitHubAndKiro(cmd, clients, result.AgentSpaceID, ask); err != nil { + return err + } + if !plaintext { // stdout holds only the JSON, so a CI log shows the warning on stderr. if result.MCPToolsError != "" { diff --git a/internal/awsdevops/github.go b/internal/awsdevops/github.go new file mode 100644 index 00000000..0ec626c8 --- /dev/null +++ b/internal/awsdevops/github.go @@ -0,0 +1,121 @@ +package awsdevops + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/devopsagent" + agenttypes "github.com/aws/aws-sdk-go-v2/service/devopsagent/types" +) + +// GitHubRepo names the repository the agent dispatches workflows against. +type GitHubRepo struct { + Owner string + Name string + // ID is the numeric GitHub repository ID, as a string. + ID string + // Organization is true when an organization owns the repository. + Organization bool +} + +// FullName is owner/name. +func (r GitHubRepo) FullName() string { + return r.Owner + "/" + r.Name +} + +// GitHubRegistration finds the account-level GitHub registration for owner. +// It returns an empty ID when the account has none. AWS registers GitHub only +// through the console, where the AWS DevOps Agent GitHub App is installed, so +// setup can find a registration but cannot make one. +func GitHubRegistration(ctx context.Context, clients Clients, owner string) (string, error) { + services, err := listServices(ctx, clients) + if err != nil { + return "", err + } + for _, service := range services { + if service.ServiceType != agenttypes.ServiceGithub { + continue + } + details, ok := service.AdditionalServiceDetails.(*agenttypes.AdditionalServiceDetailsMemberGithub) + if !ok { + continue + } + if strings.EqualFold(aws.ToString(details.Value.Owner), owner) { + return aws.ToString(service.ServiceId), nil + } + } + + return "", nil +} + +// WaitForGitHubRegistration polls until the GitHub registration for owner +// appears, while the person installs the GitHub App in the console. +func WaitForGitHubRegistration(ctx context.Context, clients Clients, owner string, logf func(string, ...any)) (string, error) { + logf("Waiting for the GitHub registration for %s to appear in AWS", owner) + deadline := time.Now().Add(oauthTimeout) + for { + id, err := GitHubRegistration(ctx, clients, owner) + if err != nil { + return "", err + } + if id != "" { + return id, nil + } + if time.Now().After(deadline) { + return "", fmt.Errorf( + "AWS has no GitHub registration for %s after %s. Finish installing the GitHub App in the console, then run setup again", + owner, oauthTimeout, + ) + } + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(oauthPollInterval): + } + } +} + +// ConnectGitHubRepo associates the repository with the agent space. It +// returns the association ID and whether the space already had the repo. +func ConnectGitHubRepo(ctx context.Context, clients Clients, agentSpaceID, serviceID string, repo GitHubRepo) (string, bool, error) { + associations, err := listAssociations(ctx, clients, agentSpaceID) + if err != nil { + return "", false, err + } + for _, association := range associations { + if aws.ToString(association.ServiceId) != serviceID { + continue + } + config, ok := association.Configuration.(*agenttypes.ServiceConfigurationMemberGithub) + if ok && strings.EqualFold(aws.ToString(config.Value.RepoName), repo.Name) && + strings.EqualFold(aws.ToString(config.Value.Owner), repo.Owner) { + return aws.ToString(association.AssociationId), true, nil + } + } + + ownerType := agenttypes.GithubRepoOwnerTypeUser + if repo.Organization { + ownerType = agenttypes.GithubRepoOwnerTypeOrganization + } + out, err := clients.Agent.AssociateService(ctx, &devopsagent.AssociateServiceInput{ + AgentSpaceId: aws.String(agentSpaceID), + ServiceId: aws.String(serviceID), + Configuration: &agenttypes.ServiceConfigurationMemberGithub{ + Value: agenttypes.GitHubConfiguration{ + Owner: aws.String(repo.Owner), + OwnerType: ownerType, + RepoId: aws.String(repo.ID), + RepoName: aws.String(repo.Name), + }, + }, + }) + if err != nil { + return "", false, fmt.Errorf("unable to connect %s to agent space %s: %w", repo.FullName(), agentSpaceID, err) + } + + return aws.ToString(out.Association.AssociationId), false, nil +} From dfc2484ba18811e677e6403c32582f18625a6a87 Mon Sep 17 00:00:00 2001 From: Christina Chu Date: Thu, 29 Oct 2026 16:06:27 -0700 Subject: [PATCH 2/2] Open the GitHub registration form and give short steps When the AWS account has no GitHub registration, setup now opens the console form that registers GitHub, not the console home page. It prints two or three steps: choose Organization for an organization repository, choose Submit, and authorize the app in GitHub. Co-Authored-By: Claude Opus 5.5 --- cmd/aws_devops_agent/kiro.go | 29 +++++++++++++++++++++++------ internal/awsdevops/github.go | 7 ++++++- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/cmd/aws_devops_agent/kiro.go b/cmd/aws_devops_agent/kiro.go index 78150b0a..dfc294f7 100644 --- a/cmd/aws_devops_agent/kiro.go +++ b/cmd/aws_devops_agent/kiro.go @@ -26,7 +26,6 @@ const ( kiroSecretName = "KIRO_API_KEY" defaultBranchMarker = "__DEFAULT_BRANCH__" kiroAPIKeyPortalURL = "https://app.kiro.dev" - githubConsoleSteps = "Capabilities > Pipeline > Add > GitHub. Pick Read & Write, and install the app only on " kiroFileDescriptions = "the Kiro agent and the implement and merge workflows" ) @@ -79,11 +78,19 @@ func setupGitHubAndKiro(cmd *cobra.Command, clients awsdevops.Clients, agentSpac return err } if serviceID == "" { - console := awsdevops.ConsoleURL(clients.Region) - logf("This AWS account has no GitHub registration for %s.", repo.Owner) - logf("In the AWS console, open %s%s.", githubConsoleSteps, repo.FullName()) - logf(" %s", console) - _ = openURL(console) + registerURL := awsdevops.GitHubRegisterURL(clients.Region) + logf("Connect GitHub to the AWS DevOps Agent. Setup opens this page in your browser:") + logf(" %s", registerURL) + logf("") + if repo.Organization { + logf(" 1. Choose Organization and enter %s.", repo.Owner) + logf(" 2. Choose Submit.") + } else { + logf(" 1. Choose Submit.") + } + logf(" %d. In GitHub, authorize the app. If GitHub asks where to install it, pick %s.", stepAfterSubmit(repo), repo.Name) + logf("") + _ = openURL(registerURL) serviceID, err = awsdevops.WaitForGitHubRegistration(cmd.Context(), clients, repo.Owner, logf) if err != nil { return err @@ -138,6 +145,16 @@ func setupGitHubAndKiro(cmd *cobra.Command, clients awsdevops.Clients, agentSpac var openURL = browser.OpenURL +// stepAfterSubmit numbers the GitHub step, which comes after the Organization +// step only for an organization. +func stepAfterSubmit(repo awsdevops.GitHubRepo) int { + if repo.Organization { + return 3 + } + + return 2 +} + // lookUpRepo reads the repository ID, owner type and default branch. func lookUpRepo(fullName string) (awsdevops.GitHubRepo, string, error) { owner, name, ok := strings.Cut(fullName, "/") diff --git a/internal/awsdevops/github.go b/internal/awsdevops/github.go index 0ec626c8..f932effe 100644 --- a/internal/awsdevops/github.go +++ b/internal/awsdevops/github.go @@ -26,6 +26,11 @@ func (r GitHubRepo) FullName() string { return r.Owner + "/" + r.Name } +// GitHubRegisterURL is the console form that registers GitHub for a region. +func GitHubRegisterURL(region string) string { + return ConsoleURL(region) + "#/services/register/github" +} + // GitHubRegistration finds the account-level GitHub registration for owner. // It returns an empty ID when the account has none. AWS registers GitHub only // through the console, where the AWS DevOps Agent GitHub App is installed, so @@ -54,7 +59,7 @@ func GitHubRegistration(ctx context.Context, clients Clients, owner string) (str // WaitForGitHubRegistration polls until the GitHub registration for owner // appears, while the person installs the GitHub App in the console. func WaitForGitHubRegistration(ctx context.Context, clients Clients, owner string, logf func(string, ...any)) (string, error) { - logf("Waiting for the GitHub registration for %s to appear in AWS", owner) + logf("Waiting for you to finish in the browser") deadline := time.Now().Add(oauthTimeout) for { id, err := GitHubRegistration(ctx, clients, owner)