diff --git a/cmd/aws_devops_agent/kiro.go b/cmd/aws_devops_agent/kiro.go new file mode 100644 index 00000000..dfc294f7 --- /dev/null +++ b/cmd/aws_devops_agent/kiro.go @@ -0,0 +1,371 @@ +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" + 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 == "" { + 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 + } + } + 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 + +// 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, "/") + 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..f932effe --- /dev/null +++ b/internal/awsdevops/github.go @@ -0,0 +1,126 @@ +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 +} + +// 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 +// 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 you to finish in the browser") + 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 +}