diff --git a/.adr-dir b/.adr-dir
new file mode 100644
index 0000000000..c73b64aed2
--- /dev/null
+++ b/.adr-dir
@@ -0,0 +1 @@
+docs/adr
diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index b7975adf44..0000000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,121 +0,0 @@
-version: 2
-jobs:
- test:
- docker:
- - image: runatlantis/testing-env:latest
- environment:
- GOFLAGS: "-mod=vendor"
- steps:
- - checkout
- - run: make test-coverage
- - run:
- name: post coverage to codecov.io
- command: bash <(curl -s https://codecov.io/bash)
- - run: make check-fmt
- - run: make check-lint
- e2e:
- docker:
- - image: circleci/golang:1.14 # If you update this, update it in the Makefile too
- environment:
- # This version of TF will be downloaded before Atlantis is started.
- # We do this instead of setting --default-tf-version because setting
- # that flag starts the download asynchronously so we'd have a race
- # condition.
- TERRAFORM_VERSION: 0.13.5
- steps:
- - checkout
- - run: make build-service
- # We don't run e2e tests on fork PRs because they don't have access to the secret env vars.
- - run: if [ -z "${CIRCLE_PR_REPONAME}" ]; then ./scripts/e2e.sh; fi
-
- # Check that there's no missing links for the website.
- # This job builds the website, starts a server to serve it, and then uses
- # muffet (https://github.com/raviqqe/muffet) to perform the link check.
- website_link_check:
- docker:
- # This image's Dockerfile is at runatlantis.io/Dockerfile
- - image: runatlantis/ci-link-checker:0.1
- steps:
- - checkout
- - run: yarn install
- - run: yarn website:build
- - run:
- name: http-server
- command: http-server runatlantis.io/.vuepress/dist
- background: true
- # We use dockerize -wait here to wait until the server is up.
- - run: |
- dockerize -wait tcp://localhost:8080 -- \
- muffet \
- -e 'https://github\.com/runatlantis/atlantis/edit/master/.*' \
- -e 'https://github.com/helm/charts/tree/master/stable/atlantis#customization' \
- http://localhost:8080/
-
- # Build and push 'latest' Docker tag.
- docker_master:
- docker:
- - image: circleci/golang:1.14 # If you update this, update it in the Makefile too
- environment:
- GOFLAGS: "-mod=vendor"
- steps:
- - checkout
- - run: make build-service
- - setup_remote_docker
- - run:
- name: Build image
- command: |
- if [ "${CIRCLE_BRANCH}" == "master" ]; then
- docker build -t runatlantis/atlantis:latest .
- fi
- - run:
- name: Push image
- command: |
- if [ "${CIRCLE_BRANCH}" == "master" ]; then
- docker login -u "$DOCKER_USER" -p "$DOCKER_PASSWORD"
- docker push runatlantis/atlantis:latest
- fi
- # Build and push Docker tag.
- docker_tag:
- docker:
- - image: circleci/golang:1.14 # If you update this, update it in the Makefile too
- environment:
- GOFLAGS: "-mod=vendor"
- steps:
- - checkout
- - run: make build-service
- - setup_remote_docker
- - run:
- name: Build and tag
- command: |
- if [ -n "${CIRCLE_TAG}" ]; then
- docker build -t "runatlantis/atlantis:${CIRCLE_TAG}" .
- docker login -u "$DOCKER_USER" -p "$DOCKER_PASSWORD"
- docker push "runatlantis/atlantis:${CIRCLE_TAG}"
- fi
-workflows:
- version: 2
- branch:
- jobs:
- - test:
- filters:
- branches:
- ignore: /.*-docs/
- - e2e:
- requires: [test]
- filters:
- branches:
- ignore: /.*-docs/
- - docker_master:
- requires: [e2e]
- filters:
- branches:
- only: master
- - website_link_check
- tag:
- jobs:
- - docker_tag:
- filters:
- branches:
- ignore: /.*/
- tags:
- only: /^v.*/
diff --git a/.circleci/website_link_checker/Dockerfile b/.circleci/website_link_checker/Dockerfile
deleted file mode 100644
index 5ba4d1294d..0000000000
--- a/.circleci/website_link_checker/Dockerfile
+++ /dev/null
@@ -1,16 +0,0 @@
-# This Dockerfile builds runatlantis/ci-link-checker.
-# It is used in CircleCI to check if the website has any broken links.
-FROM node:11
-ENV DOCKERIZE_VERSION v0.6.0
-
-# Muffet is used to check for broken links.
-COPY --from=raviqqe/muffet:1.0.3 muffet /usr/local/bin/muffet
-
-# http-server is used to serve the website locally as muffet checks it.
-RUN yarn global add http-server
-
-# Dockerize is used to wait until the server is up and running before running
-# muffet.
-RUN wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
- && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
- && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
diff --git a/.dockerignore b/.dockerignore
index 72ec49f7c8..523596ac26 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,10 @@
*
+!cmd/
+!scripts/download-release.sh
+!server/
+!testdrive/
+!main.go
+!go.mod
+!go.sum
!docker-entrypoint.sh
!atlantis
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000000..3f90f6406f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+indent_style = space
+indent_size = 3
+trim_trailing_whitespace = false
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000..d56abbf304
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Set the default behavior, in case people don't have core.autocrlf set.
+* text=auto eol=lf
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000000..ecce26ca36
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,89 @@
+---
+name: Bug Report
+about: You're experiencing an issue that is different than the documented behavior.
+labels: bug
+---
+
+
+
+### Community Note
+
+* Please vote on this issue by adding a đ [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you!
+* Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request.
+* If you are interested in working on this issue or have submitted a pull request, please leave a comment.
+
+
+
+---
+
+
+
+### Overview of the Issue
+
+
+
+
+### Reproduction Steps
+
+
+
+
+### Logs
+
+
+
+
+### Environment details
+
+
+
+
+### Additional Context
+
+
+
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000000..6e661b9768
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,47 @@
+---
+name: Feature request
+about: Propose a concrete new feature
+title: ''
+labels: 'feature'
+assignees: ''
+
+---
+
+
+
+### Community Note
+
+- Please vote on this issue by adding a đ [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you!
+- Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request.
+- If you are interested in working on this issue or have submitted a pull request, please leave a comment.
+
+
+
+---
+
+- [ ] I'd be willing to implement this feature ([contributing guide](https://github.com/runatlantis/atlantis/blob/main/CONTRIBUTING.md))
+
+**Describe the user story**
+
+
+**Describe the solution you'd like**
+
+
+**Describe the drawbacks of your solution**
+
+
+**Describe alternatives you've considered**
+
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000000..b916ff62b0
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,29 @@
+## what
+
+
+
+
+## why
+
+
+
+## tests
+
+
+
+## references
+
+
+
diff --git a/.github/cherry-pick-bot.yml b/.github/cherry-pick-bot.yml
new file mode 100644
index 0000000000..1f62315d79
--- /dev/null
+++ b/.github/cherry-pick-bot.yml
@@ -0,0 +1,2 @@
+enabled: true
+preservePullRequestTitle: true
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 0000000000..6dd6741d81
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,50 @@
+build:
+- changed-files:
+ - any-glob-to-any-file: 'Dockerfile*'
+
+dependencies:
+- changed-files:
+ - any-glob-to-any-file: 'yarn.lock'
+ - any-glob-to-any-file: 'go.*'
+
+docs:
+- changed-files:
+ - any-glob-to-any-file: 'runatlantis.io/**/*.md'
+ - any-glob-to-any-file: 'README.md'
+
+github-actions:
+- changed-files:
+ - any-glob-to-any-file:
+ - '.github/workflows/*.yml'
+
+go:
+- changed-files:
+ - any-glob-to-any-file: '**/*.go'
+
+provider/azuredevops:
+- changed-files:
+ - any-glob-to-any-file: 'server/**/*azuredevops*.go'
+
+provider/bitbucket:
+- changed-files:
+ - any-glob-to-any-file: 'server/**/*bitbucket*.go'
+ - any-glob-to-any-file: 'server/events/vcs/bitbucketcloud/*.go'
+ - any-glob-to-any-file: 'server/events/vcs/bitbucketserver/*.go'
+
+provider/github:
+- changed-files:
+ - any-glob-to-any-file: 'server/**/*github*.go'
+
+provider/gitlab:
+- changed-files:
+ - any-glob-to-any-file: 'server/**/*gitlab*.go'
+
+website:
+- changed-files:
+ - any-glob-to-any-file: 'runatlantis.io/.vitepress/**/*'
+ - any-glob-to-any-file: 'package.json'
+ - any-glob-to-any-file: 'package-lock.json'
+
+blog:
+- changed-files:
+ - any-glob-to-any-file: 'runatlantis.io/blog/**'
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000000..cf8a202a91
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,45 @@
+changelog:
+ exclude:
+ labels:
+ - ignore-for-release
+ - github-actions
+ authors:
+ - octocat
+ categories:
+ - title: Breaking Changes đ
+ labels:
+ - Semver-Major
+ - breaking-change
+ - title: Exciting New Features đ
+ labels:
+ - Semver-Minor
+ - enhancement
+ - feature
+ - title: Provider AzureDevops
+ labels:
+ - provider/azuredevops
+ - title: Provider Bitbucket
+ labels:
+ - provider/bitbucket
+ - title: Provider GitHub
+ labels:
+ - provider/github
+ - title: Provider GitLab
+ labels:
+ - provider/gitlab
+ - title: Bug fixes đ
+ labels:
+ - bug
+ - title: Security changes
+ labels:
+ - security
+ - title: Documentation
+ labels:
+ - docs
+ - website
+ - title: Dependencies
+ labels:
+ - dependencies
+ - title: Other Changes đ
+ labels:
+ - "*"
diff --git a/.github/renovate.json5 b/.github/renovate.json5
new file mode 100644
index 0000000000..077428aa4a
--- /dev/null
+++ b/.github/renovate.json5
@@ -0,0 +1,143 @@
+{
+ extends: [
+ 'config:best-practices',
+ ':separateMultipleMajorReleases',
+ 'schedule:daily',
+ 'security:openssf-scorecard',
+ ],
+ commitMessageSuffix: ' in {{packageFile}}',
+ dependencyDashboardAutoclose: true,
+ automerge: true,
+ baseBranchPatterns: [
+ 'main',
+ '/^release-.*/',
+ ],
+ platformAutomerge: true,
+ labels: [
+ 'dependencies',
+ ],
+ postUpdateOptions: [
+ 'gomodTidy',
+ 'gomodUpdateImportPaths',
+ 'npmDedupe',
+ ],
+ prHourlyLimit: 1,
+ minimumReleaseAge: '5 days',
+ osvVulnerabilityAlerts: true,
+ vulnerabilityAlerts: {
+ enabled: true,
+ labels: [
+ 'security',
+ ],
+ },
+ packageRules: [
+ // enable release branches for security updates
+ {
+ matchBaseBranches: [
+ '/^release-.*/',
+ ],
+ matchUpdateTypes: [
+ 'security',
+ ],
+ enabled: true,
+ },
+ // disable release branches for anything else
+ {
+ matchBaseBranches: [
+ '/^release-.*/',
+ ],
+ enabled: false,
+ },
+ {
+ matchBaseBranches: [
+ 'main',
+ ],
+ matchFileNames: [
+ 'package.json',
+ 'package-lock.json',
+ ],
+ },
+ {
+ matchFileNames: [
+ 'testing/**',
+ ],
+ additionalBranchPrefix: '{{packageFileDir}}-',
+ groupName: 'conftest-testing',
+ matchPackageNames: [
+ '/conftest/',
+ ],
+ },
+ {
+ ignorePaths: [
+ 'testing/**',
+ ],
+ groupName: 'github-',
+ matchPackageNames: [
+ '/github-actions/',
+ ],
+ },
+ {
+ ignorePaths: [
+ 'server/controllers/events/testdata/**/*.tf',
+ ],
+ matchDatasources: [
+ 'terraform',
+ ],
+ },
+ {
+ matchDatasources: [
+ 'docker',
+ ],
+ matchPackageNames: [
+ 'node',
+ 'cimg/node',
+ ],
+ versioning: 'node',
+ },
+ {
+ matchPackageNames: [
+ 'go',
+ 'golang',
+ ],
+ versioning: 'go',
+ groupName: 'go',
+ },
+ ],
+ customManagers: [
+ {
+ customType: 'regex',
+ managerFilePatterns: [
+ '/(^|/)Dockerfile$/',
+ '/(^|/)Dockerfile\\.[^/]*$/',
+ ],
+ matchStrings: [
+ 'renovate: datasource=(?
-
- Terraform Pull Request Automation
-
- Get Started â
- Bring the benefits of code review to your operations
- workflow. Empower your developers to write Terraform. Safely. Pass audits without compromising your workflow. An example comment coming from the @atlantisbot user
+ 
Terraform Pull Request Automation
-
{{ data.heroText || $title || 'Hello' }}
- The Atlantis Workflow
-
-
- Benefits
- Fewer Mistakes
-
-
- Catch errors in
- the Terraform plan output before it's applied.
-
Ensure that you
- apply changes before merging to master.
-
-
-
Put the Dev back into DevOps
-
-
- Developers can
- submit Terraform pull requests without needing credentials.
-
Operators can
- require approvals prior to allowing an apply.
-
Instant Audit Logs And Compliance
-
-
- Each pull request now holds a detailed log of what infrastructure changes were made and when; along with who made the change and who approved it.
-
Atlantis can be configured to require approvals on every production change.
-
-
Proven at Scale
-
-
-
- Used by one of the world's top companies to manage over 600 Terraform repos with 300 developers.
-
In production use for over 2 years.
-
How It Works
-
-
- Atlantis is
- self-hosted. Your credentials don't leave your infrastructure.
-
Runs as a Golang
- binary or Docker image and can be deployed on VMs, Kubernetes,
- Fargate, etc.
-
Listens for
- webhooks from GitHub/GitLab/Bitbucket/Azure DevOps.
-
Runs terraform
- commands remotely and comments back with their output.
-
- Pull Request Webhooks
+
+The list below links to the supported VCSs and their Pull Request Webhook
+documentation.
+
+- [Azure DevOps Pull Request Created](https://learn.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops#pull-request-created)
+- [BitBucket Pull Request](https://support.atlassian.com/bitbucket-cloud/docs/event-payloads/#Pull-request-events)
+- [GitHub Pull Request](https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request)
+- [GitLab Merge Request](https://docs.gitlab.com/user/project/integrations/webhook_events/#merge-request-events)
+- [Gitea Webhooks](https://docs.gitea.com/next/usage/webhooks)
+
+
-- See [Next Steps](#next-steps)
+* See [Next Steps](#next-steps)
## Bitbucket Server (aka Stash)
-- Go to your repo's home page
-- Click **Settings** in the sidebar
-- Click **Webhooks** under the **WORKFLOW** section
-- Click **Create webhook**
-- Enter "Atlantis" for **Name**
-- set **URL** to `http://$URL/events` (or `https://$URL/events` if you're using SSL) where `$URL` is where Atlantis is hosted. **Be sure to add `/events`**
-- Double-check you added `/events` to the end of your URL.
-- Set **Secret** to the Webhook Secret you generated previously
- - **NOTE** If you're adding a webhook to multiple repositories, each repository will need to use the **same** secret.
-- Under **Repository** select **Push**
-- Under **Pull Request**, select: Opened, Modified, Merged, Declined, Deleted and Comment added
-- Click **Save**
-- See [Next Steps](#next-steps)
+
+* Go to your repo's home page
+* Click **Settings** in the sidebar
+* Click **Webhooks** under the **WORKFLOW** section
+* Click **Create webhook**
+* Enter "Atlantis" for **Name**
+* set **URL** to `http://$URL/events` (or `https://$URL/events` if you're using SSL) where `$URL` is where Atlantis is hosted. **Be sure to add `/events`**
+* Double-check you added `/events` to the end of your URL.
+* Set **Secret** to the Webhook Secret you generated previously
+ * **NOTE** If you're adding a webhook to multiple repositories, each repository will need to use the **same** secret.
+* Under **Pull Request**, select: Opened, Source branch updated, Merged, Declined, Deleted and Comment added
+* Click **Save**
+* See [Next Steps](#next-steps)
## Azure DevOps
+
Webhooks are installed at the [team project](https://docs.microsoft.com/en-us/azure/devops/organizations/projects/about-projects?view=azure-devops) level, but may be restricted to only fire based on events pertaining to [specific repos](https://docs.microsoft.com/en-us/azure/devops/service-hooks/services/webhooks?view=azure-devops) within the team project.
-- Navigate anywhere within a team project, ie: `https://dev.azure.com/orgName/projectName/_git/repoName`
-- Select **Project settings** in the lower-left corner
-- Select **Service hooks**
- - If you see the message "You do not have sufficient permissions to view or configure subscriptions." you need to ensure your user is a member of either the organization's "Project Collection Administrators" group or the project's "Project Administrators" group.
- - To add your user to the Project Collection Build Administrators group, navigate to the organization level, click **Organization Settings** and then click **Permissions**. You should be at `https://dev.azure.com/
*`show` - preserve the full output
* `hide` - hide output from comment (still visible in the real-time streaming output)
* `strip_refreshing` - hide all output up until and including the last line containing "Refreshing...". This matches the behavior of the built-in `plan` command |
+
+#### Native Environment Variables
+
+* `run` steps in the main `workflow` are executed with the following environment variables:
+ note: these variables are not available to `pre` or `post` workflows
* `WORKSPACE` - The Terraform workspace used for this project, ex. `default`.
- * NOTE: if the step is executed before `init` then Atlantis won't have switched to this workspace yet.
+ NOTE: if the step is executed before `init` then Atlantis won't have switched to this workspace yet.
* `ATLANTIS_TERRAFORM_VERSION` - The version of Terraform used for this project, ex. `0.11.0`.
* `DIR` - Absolute path to the current directory.
* `PLANFILE` - Absolute path to the location where Atlantis expects the plan to
- either be generated (by plan) or already exist (if running apply). Can be used to
- override the built-in `plan`/`apply` commands, ex. `run: terraform plan -out $PLANFILE`.
+ either be generated (by plan) or already exist (if running apply). Can be used to
+ override the built-in `plan`/`apply` commands, ex. `run: terraform plan -out $PLANFILE`.
+ * `SHOWFILE` - Absolute path to the location where Atlantis expects the plan in json format to
+ either be generated (by show) or already exist (if running policy checks). Can be used to
+ override the built-in `plan`/`apply` commands, ex. `run: terraform show -json $PLANFILE > $SHOWFILE`.
+ * `POLICYCHECKFILE` - Absolute path to the location of policy check output if Atlantis runs policy checks.
+ See [policy checking](policy-checking.md#data-for-custom-run-steps) for information of data structure.
* `BASE_REPO_NAME` - Name of the repository that the pull request will be merged into, ex. `atlantis`.
* `BASE_REPO_OWNER` - Owner of the repository that the pull request will be merged into, ex. `runatlantis`.
* `HEAD_REPO_NAME` - Name of the repository that is getting merged into the base repository, ex. `atlantis`.
* `HEAD_REPO_OWNER` - Owner of the repository that is getting merged into the base repository, ex. `acme-corp`.
* `HEAD_BRANCH_NAME` - Name of the head branch of the pull request (the branch that is getting merged into the base)
+ * `HEAD_COMMIT` - The sha256 that points to the head of the branch that is being pull requested into the base. If the pull request is from Bitbucket Cloud the string will only be 12 characters long because Bitbucket Cloud truncates its commit IDs.
* `BASE_BRANCH_NAME` - Name of the base branch of the pull request (the branch that the pull request is getting merged into)
* `PROJECT_NAME` - Name of the project configured in `atlantis.yaml`. If no project name is configured this will be an empty string.
* `PULL_NUM` - Pull request number or ID, ex. `2`.
+ * `PULL_URL` - Pull request URL, ex. `https://github.com/runatlantis/atlantis/pull/2`.
* `PULL_AUTHOR` - Username of the pull request author, ex. `acme-user`.
* `REPO_REL_DIR` - The relative path of the project in the repository. For example if your project is in `dir1/dir2/` then this will be set to `"dir1/dir2"`. If your project is at the root this will be `"."`.
* `USER_NAME` - Username of the VCS user running command, ex. `acme-user`. During an autoplan, the user will be the Atlantis API user, ex. `atlantis`.
* `COMMENT_ARGS` - Any additional flags passed in the comment on the pull request. Flags are separated by commas and
- every character is escaped, ex. `atlantis plan -- arg1 arg2` will result in `COMMENT_ARGS=\a\r\g\1,\a\r\g\2`.
+ every character is escaped, ex. `atlantis plan -- arg1 arg2` will result in `COMMENT_ARGS=\a\r\g\1,\a\r\g\2`.
* A custom command will only terminate if all output file descriptors are closed.
Therefore a custom command can only be sent to the background (e.g. for an SSH tunnel during
the terraform run) when its output is redirected to a different location. For example, Atlantis
-will execute a custom script containing the following code to create a SSH tunnel correctly:
+will execute a custom script containing the following code to create a SSH tunnel correctly:
`ssh -f -M -S /tmp/ssh_tunnel -L 3306:database:3306 -N bastion 1>/dev/null 2>&1`. Without
the redirect, the script would block the Atlantis workflow.
-* If a workflow step returns a non-zero exit code, the workflow will stop.
+* If a workflow step returns a non-zero exit code, the workflow will stop.
:::
#### Environment Variable `env` Command
+
The `env` command allows you to set environment variables that will be available
to all steps defined **below** the `env` step.
You can set hard coded values via the `value` key, or set dynamic values via
the `command` key which allows you to run any command and uses the output
as the environment variable value.
+
```yaml
- env:
name: ENV_NAME
@@ -380,12 +674,72 @@ as the environment variable value.
- env:
name: ENV_NAME_2
command: 'echo "dynamic-value-$(date)"'
+- env:
+ name: ENV_NAME_3
+ command: echo ${DIR%$REPO_REL_DIR}
+ shell: bash
+ shellArgs:
+ - "--verbose"
+ - "-c"
```
-| Key | Type | Default | Required | Description |
-|-----------------|------------------------------------|---------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
-| env | map[`name` -> string, `value` -> string, `command` -> string] | none | no | Set environment variables for subsequent steps |
+
+| Key | Type | Default | Required | Description |
+|-----------------|-----------------------|---------|----------|-----------------------------------------------------------------------------------------------------------------|
+| env | map\[string -> string\] | none | no | Set environment variables for subsequent steps |
+| env.name | string | none | yes | Name of the environment variable |
+| env.value | string | none | no | Set the value of the environment variable to a hard-coded string. Cannot be set at the same time as `command` |
+| env.command | string | none | no | Set the value of the environment variable to the output of a command. Cannot be set at the same time as `value` |
+| env.shell | string | "sh" | no | Name of the shell to use for command execution. Cannot be set without `command` |
+| env.shellArgs | string or []string | "-c" | no | Command line arguments to be passed to the shell. Cannot be set without `shell` |
::: tip Notes
+
* `env` `command`'s can use any of the built-in environment variables available
- to `run` commands.
+ to `run` commands.
+:::
+
+#### Multiple Environment Variables `multienv` Command
+
+The `multienv` command allows you to set dynamic number of multiple environment variables that will be available
+to all steps defined **below** the `multienv` step.
+
+Compact:
+
+```yaml
+- multienv: custom-command
+```
+
+| Key | Type | Default | Required | Description |
+|----------|--------|---------|----------|------------------------------------------------------------|
+| multienv | string | none | no | Run a custom command and add printed environment variables |
+
+Full:
+
+```yaml
+- multienv:
+ command: custom-command
+ shell: bash
+ shellArgs:
+ - "--verbose"
+ - "-c"
+ output: show
+```
+
+| Key | Type | Default | Required | Description |
+|--------------------|-----------------------|---------|----------|-------------------------------------------------------------------------------------|
+| multienv | map[string -> string] | none | no | Run a custom command and add printed environment variables |
+| multienv.command | string | none | yes | Name of the custom script to run |
+| multienv.shell | string | "sh" | no | Name of the shell to use for command execution |
+| multienv.shellArgs | string or []string | "-c" | no | Command line arguments to be passed to the shell. Cannot be set without `shell` |
+| multienv.output | string | "show" | no | Setting output to "hide" will suppress the message obout added environment variables |
+
+The output of the command execution must have the following format:
+`EnvVar1Name=value1,EnvVar2Name=value2,EnvVar3Name=value3`
+
+The name-value pairs in the output are added as environment variables if command execution is successful, otherwise the workflow execution is interrupted with an error and the errorMessage is returned.
+
+::: tip Notes
+
+* `multienv` `command`'s can use any of the built-in environment variables available
+ to `run` commands.
:::
diff --git a/runatlantis.io/docs/deployment.md b/runatlantis.io/docs/deployment.md
index 1d74e4d30a..5df948bc64 100644
--- a/runatlantis.io/docs/deployment.md
+++ b/runatlantis.io/docs/deployment.md
@@ -1,29 +1,33 @@
# Deployment
+
This page covers getting Atlantis up and running in your infrastructure.
::: tip Prerequisites
-* You have created [access credentials](access-credentials.html) for your Atlantis user
-* You have created a [webhook secret](webhook-secrets.html)
-:::
-[[toc]]
+* You have created [access credentials](access-credentials.md) for your Atlantis user
+* You have created a [webhook secret](webhook-secrets.md)
+:::
## Architecture Overview
+
### Runtime
+
Atlantis is a simple [Go](https://golang.org/) app. It receives webhooks from
your Git host and executes Terraform commands locally. There is an official
-Atlantis [Docker image](https://hub.docker.com/r/runatlantis/atlantis/).
+Atlantis [Docker image](https://ghcr.io/runatlantis/atlantis).
### Routing
+
Atlantis and your Git host need to be able to route and communicate with one another. Your Git host needs to be able to send webhooks to Atlantis and Atlantis needs to be able to make API calls to your Git host.
If you're using
-a public Git host like github.com, gitlab.com, bitbucket.org, or dev.azure.com then you'll need to
+a public Git host like github.com, gitlab.com, gitea.com, bitbucket.org, or dev.azure.com then you'll need to
expose Atlantis to the internet.
-If you're using a private Git host like GitHub Enterprise, GitLab Enterprise or
+If you're using a private Git host like GitHub Enterprise, GitLab Enterprise, self-hosted Gitea or
Bitbucket Server, then Atlantis needs to be routable from the private host and Atlantis will need to be able to route to the private host.
### Data
+
Atlantis has no external database. Atlantis stores Terraform plan files on disk.
If Atlantis loses that data in between a `plan` and `apply` cycle, then users will have
to re-run `plan`. Because of this, you may want to provision a persistent disk
@@ -32,6 +36,7 @@ for Atlantis.
## Deployment
Pick your deployment type:
+
* [Kubernetes Helm Chart](#kubernetes-helm-chart)
* [Kubernetes Manifests](#kubernetes-manifests)
* [Kubernetes Kustomize](#kubernetes-kustomize)
@@ -41,21 +46,27 @@ Pick your deployment type:
* [Docker](#docker)
* [Roll Your Own](#roll-your-own)
-
### Kubernetes Helm Chart
+
Atlantis has an [official Helm chart](https://github.com/runatlantis/helm-charts/tree/main/charts/atlantis)
To install:
+
1. Add the runatlantis helm chart repository to helm
+
```bash
helm repo add runatlantis https://runatlantis.github.io/helm-charts
```
+
1. `cd` into a directory where you're going to configure your Atlantis Helm chart
1. Create a `values.yaml` file by running
+
```bash
helm inspect values runatlantis/atlantis > values.yaml
```
+
1. Edit `values.yaml` and add your access credentials and webhook secret
+
```yaml
# for example
github:
@@ -63,27 +74,33 @@ To install:
token: bar
secret: baz
```
-1. Edit `values.yaml` and set your `orgWhitelist` (see [Repo Whitelist](server-configuration.html#repo-whitelist) for more information)
+
+1. Edit `values.yaml` and set your `orgAllowlist` (see [Repo Allowlist](server-configuration.md#repo-allowlist) for more information)
+
```yaml
- orgWhitelist: github.com/runatlantis/*
+ orgAllowlist: github.com/runatlantis/*
```
-1. Configure any other variables (see [https://github.com/helm/charts/tree/master/stable/atlantis#customization](https://github.com/helm/charts/tree/master/stable/atlantis#customization)
+
+ **Note**: For helm chart version < `4.0.2`, `orgWhitelist` must be used instead.
+1. Configure any other variables (see [Atlantis Helm Chart: Customization](https://github.com/runatlantis/helm-charts#customization)
for documentation)
1. Run
+
```sh
helm install atlantis runatlantis/atlantis -f values.yaml
```
-
+
If you are using helm v2, run:
+
```sh
helm install -f values.yaml runatlantis/atlantis
```
-
Atlantis should be up and running in minutes! See [Next Steps](#next-steps) for
what to do next.
### Kubernetes Manifests
+
If you'd like to use a raw Kubernetes manifest, we offer either a
[Deployment](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/)
or a [Statefulset](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/) with persistent storage.
@@ -93,35 +110,38 @@ or you upgrade Atlantis, you won't lose plans that haven't been applied. If
you do lose that data, you just need to run `atlantis plan` again so it's not the end of the world.
Regardless of whether you choose a Deployment or StatefulSet, first create a Secret with the webhook secret and access token:
+
```bash
echo -n "yourtoken" > token
echo -n "yoursecret" > webhook-secret
kubectl create secret generic atlantis-vcs --from-file=token --from-file=webhook-secret
```
-::: tip Note
-If you're using Bitbucket Cloud then there is no webhook secret since it's not supported.
-:::
Next, edit the manifests below as follows:
-1. Replace `Show...
@@ -139,17 +159,17 @@ spec:
partition: 0
selector:
matchLabels:
- app: atlantis
+ app.kubernetes.io/name: atlantis
template:
metadata:
labels:
- app: atlantis
+ app.kubernetes.io/name: atlantis
spec:
securityContext:
fsGroup: 1000 # Atlantis group (1000) read/write access to volumes.
containers:
- name: atlantis
- image: runatlantis/atlantis:vShow...
@@ -284,20 +325,20 @@ kind: Deployment
metadata:
name: atlantis
labels:
- app: atlantis
+ app.kubernetes.io/name: atlantis
spec:
replicas: 1
selector:
matchLabels:
- app: atlantis
+ app.kubernetes.io/name: atlantis
template:
metadata:
labels:
- app: atlantis
+ app.kubernetes.io/name: atlantis
spec:
containers:
- name: atlantis
- image: runatlantis/atlantis:v
/events at the end. Ex. https://c5004d84.ngrok.io/events/events to the end of your URL./events at the end. Ex. https://c5004d84.ngrok.io/events/events to the end of your URL.

http://$URL/events (or https://$URL/events if you're using SSL) where $URL is where Atlantis is hosted. Be sure to add /events/events to the end of your URL.atlantis
+ ++ {{ if .Target }} + Create a github app + {{ else }} + Github app created successfully! + {{ end }} +
+Visit {{ .URL }}/installations/new to install the app for your user or organization, then update the following values in your config and restart Atlantis:
+ +{{ .ID }}{{ .Key }}{{ .WebhookSecret }}atlantis
+Plan discarded and unlocked!
+Lock Status: ActiveActive Since: {{ .ApplyLock.TimeFormatted }}Locks
+ {{ $basePath := .CleanedBasePath }} + {{ if .Locks }} +No locks found.
+ {{ end }} +Jobs
+ {{ if .PullToJobMapping }} +{{ .Pull.Path }}{{ end }}
+ {{ if .Pull.Workspace }}{{ .Pull.Workspace }}{{ end }}
+
+ {{ range .JobIDInfos }}
+ No jobs found.
+ {{ end }} +atlantis
+{{.LockKey}} Locked
atlantis
++
\n\n```\n{{.Log}}```\n
\n\n```\nlog```\n
\n\n```\n%s\n```\n
\n\n```\nlog```\n
\n\n```\n%s\n```\n
\n\n```\nlog```\n
\n\n```\n%s\n```\n
\n\n```\n%s\n```\n
\n\n```\nlog```\n
\n\n```\n%s\n```\n
\n\n```\nlog```\n
\n\n```\n%s\n```\n
\n\n```\n%s\n```\n
\n\n```\n%s\n```\n
\n\n```\n%s\n```\n
\n\n```\n%s\n```\n
+ +``` +{{.Log}}``` +
atlantis plan
" + }, + "user": { + "display_name": "Ragne", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B00000000-0000-0000-0000-000000000001%7D" + }, + "avatar": { + "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/EM-3.png" + }, + "html": { + "href": "https://bitbucket.org/%7B00000000-0000-0000-0000-000000000001%7D/" + } + }, + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}", + "account_id": "000000:00000000-0000-0000-0000-000000000001", + "nickname": "Ragne" + }, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5/comments/498931784" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5/_/diff#comment-498931784" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 5, + "title": "for test", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5" + } + } + } + }, + { + "id": 498931802, + "created_on": "2024-05-07T12:21:48.737851+00:00", + "updated_on": "2024-05-07T12:21:48.737927+00:00", + "content": { + "type": "rendered", + "raw": "Ran Plan for 0 projects:", + "markup": "markdown", + "html": "Ran Plan for 0 projects:
" + }, + "user": { + "display_name": "bb bot", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B600000000-0000-0000-0000-000000000000%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/d5c4bac76953df92f47d1dea43fcdba0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FKB-4.png" + }, + "html": { + "href": "https://bitbucket.org/%7B600000000-0000-0000-0000-000000000000%7D/" + } + }, + "type": "user", + "uuid": "{600000000-0000-0000-0000-000000000000}", + "account_id": "00000000-0000-0000-0000-000000000000", + "nickname": "bb bot" + }, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5/comments/498931802" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5/_/diff#comment-498931802" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 5, + "title": "for test", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5" + } + } + } + }, + { + "id": 498931882, + "created_on": "2024-05-07T12:22:01.870344+00:00", + "updated_on": "2024-05-07T12:22:01.870462+00:00", + "content": { + "type": "rendered", + "raw": "atlantis plan", + "markup": "markdown", + "html": "atlantis plan
" + }, + "user": { + "display_name": "Ragne", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B00000000-0000-0000-0000-000000000001%7D" + }, + "avatar": { + "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/EM-3.png" + }, + "html": { + "href": "https://bitbucket.org/%7B00000000-0000-0000-0000-000000000001%7D/" + } + }, + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}", + "account_id": "000000:00000000-0000-0000-0000-000000000001", + "nickname": "Ragne" + }, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5/comments/498931882" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5/_/diff#comment-498931882" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 5, + "title": "for test", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5" + } + } + } + }, + { + "id": 498931901, + "created_on": "2024-05-07T12:22:04.981415+00:00", + "updated_on": "2024-05-07T12:22:04.981490+00:00", + "content": { + "type": "rendered", + "raw": "Ran Plan for 0 projects:", + "markup": "markdown", + "html": "Ran Plan for 0 projects:
" + }, + "user": { + "display_name": "bb bot", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B600000000-0000-0000-0000-000000000000%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/d5c4bac76953df92f47d1dea43fcdba0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FKB-4.png" + }, + "html": { + "href": "https://bitbucket.org/%7B600000000-0000-0000-0000-000000000000%7D/" + } + }, + "type": "user", + "uuid": "{600000000-0000-0000-0000-000000000000}", + "account_id": "00000000-0000-0000-0000-000000000000", + "nickname": "bb bot" + }, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5/comments/498931901" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5/_/diff#comment-498931901" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 5, + "title": "for test", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5" + } + } + } + }, + { + "id": 49893111, + "created_on": "2024-05-07T12:22:05.981415+00:00", + "updated_on": "2024-05-07T12:22:05.981490+00:00", + "content": { + "type": "rendered", + "raw": "Ran Apply for 0 projects:", + "markup": "markdown", + "html": "Ran Apply for 0 projects:
" + }, + "user": { + "display_name": "bb bot", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B600000000-0000-0000-0000-000000000000%7D" + }, + "avatar": { + "href": "https://secure.gravatar.com/avatar/d5c4bac76953df92f47d1dea43fcdba0?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FKB-4.png" + }, + "html": { + "href": "https://bitbucket.org/%7B600000000-0000-0000-0000-000000000000%7D/" + } + }, + "type": "user", + "uuid": "{600000000-0000-0000-0000-000000000000}", + "account_id": "00000000-0000-0000-0000-000000000000", + "nickname": "bb bot" + }, + "deleted": false, + "pending": false, + "type": "pullrequest_comment", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5/comments/498931901" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5/_/diff#comment-498931901" + } + }, + "pullrequest": { + "type": "pullrequest", + "id": 5, + "title": "for test", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/myworkspace/myrepo/pullrequests/5" + }, + "html": { + "href": "https://bitbucket.org/myworkspace/myrepo/pull-requests/5" + } + } + } + } + ], + "pagelen": 10, + "size": 4, + "page": 1 +} diff --git a/server/events/vcs/bitbucketcloud/testdata/pull-approved-by-author.json b/server/events/vcs/bitbucketcloud/testdata/pull-approved-by-author.json index c782d07212..7930cbb0bc 100644 --- a/server/events/vcs/bitbucketcloud/testdata/pull-approved-by-author.json +++ b/server/events/vcs/bitbucketcloud/testdata/pull-approved-by-author.json @@ -82,7 +82,7 @@ "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { - "name": "master" + "name": "main" } }, "created_on": "2019-02-12T16:48:04.251028+00:00", @@ -136,7 +136,6 @@ "type": "participant", "approved": true, "user": { - "username": "lkysow", "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { @@ -161,7 +160,6 @@ "type": "participant", "approved": false, "user": { - "username": "atlantis-bot", "display_name": "Atlantisbot", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b8}", "links": { @@ -184,7 +182,6 @@ "reason": "", "updated_on": "2019-06-03T13:55:54.081581+00:00", "author": { - "username": "lkysow", "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { diff --git a/server/events/vcs/bitbucketcloud/testdata/pull-approved-multiple.json b/server/events/vcs/bitbucketcloud/testdata/pull-approved-multiple.json index 5194b5426d..5adbea01cf 100644 --- a/server/events/vcs/bitbucketcloud/testdata/pull-approved-multiple.json +++ b/server/events/vcs/bitbucketcloud/testdata/pull-approved-multiple.json @@ -82,7 +82,7 @@ "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { - "name": "master" + "name": "main" } }, "created_on": "2019-02-12T16:48:04.251028+00:00", @@ -136,7 +136,6 @@ "type": "participant", "approved": false, "user": { - "username": "lkysow", "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { @@ -161,7 +160,6 @@ "type": "participant", "approved": true, "user": { - "username": "atlantis-bot", "display_name": "Atlantisbot", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b8}", "links": { @@ -186,7 +184,6 @@ "type": "participant", "approved": true, "user": { - "username": "atlantis-bot2", "display_name": "Atlantisbot2", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b2}", "links": { @@ -209,7 +206,6 @@ "reason": "", "updated_on": "2019-06-03T13:55:17.639190+00:00", "author": { - "username": "lkysow", "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { diff --git a/server/events/vcs/bitbucketcloud/testdata/pull-approved.json b/server/events/vcs/bitbucketcloud/testdata/pull-approved.json index b2bcecd040..281103dfd8 100644 --- a/server/events/vcs/bitbucketcloud/testdata/pull-approved.json +++ b/server/events/vcs/bitbucketcloud/testdata/pull-approved.json @@ -82,7 +82,7 @@ "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { - "name": "master" + "name": "main" } }, "created_on": "2019-02-12T16:48:04.251028+00:00", @@ -136,7 +136,6 @@ "type": "participant", "approved": false, "user": { - "username": "lkysow", "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { @@ -161,7 +160,6 @@ "type": "participant", "approved": true, "user": { - "username": "atlantis-bot", "display_name": "Atlantisbot", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b8}", "links": { @@ -184,7 +182,6 @@ "reason": "", "updated_on": "2019-06-03T13:55:17.639190+00:00", "author": { - "username": "lkysow", "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { diff --git a/server/events/vcs/bitbucketcloud/testdata/pull-unapproved.json b/server/events/vcs/bitbucketcloud/testdata/pull-unapproved.json index c3b8c604e7..3439b7cde4 100644 --- a/server/events/vcs/bitbucketcloud/testdata/pull-unapproved.json +++ b/server/events/vcs/bitbucketcloud/testdata/pull-unapproved.json @@ -82,7 +82,7 @@ "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" }, "branch": { - "name": "master" + "name": "main" } }, "created_on": "2019-02-12T16:48:04.251028+00:00", @@ -136,7 +136,6 @@ "type": "participant", "approved": false, "user": { - "username": "lkysow", "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { @@ -161,7 +160,6 @@ "type": "participant", "approved": false, "user": { - "username": "atlantis-bot", "display_name": "Atlantisbot", "uuid": "{73686412-4495-426f-89a7-c69ff1c8d7b8}", "links": { @@ -184,7 +182,6 @@ "reason": "", "updated_on": "2019-06-03T13:54:09.266101+00:00", "author": { - "username": "lkysow", "display_name": "Luke", "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}", "links": { diff --git a/server/events/vcs/bitbucketcloud/testdata/user.json b/server/events/vcs/bitbucketcloud/testdata/user.json new file mode 100644 index 0000000000..336f27832a --- /dev/null +++ b/server/events/vcs/bitbucketcloud/testdata/user.json @@ -0,0 +1,33 @@ +{ + "display_name": "bb bot", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B00000000-0000-0000-0000-000000000001%7D" + }, + "avatar": { + "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/RR-3.png" + }, + "repositories": { + "href": "https://api.bitbucket.org/2.0/repositories/%7B00000000-0000-0000-0000-000000000001%7D" + }, + "snippets": { + "href": "https://api.bitbucket.org/2.0/snippets/%7B00000000-0000-0000-0000-000000000001%7D" + }, + "html": { + "href": "https://bitbucket.org/%7B00000000-0000-0000-0000-000000000001%7D/" + }, + "hooks": { + "href": "https://api.bitbucket.org/2.0/workspaces/%7B00000000-0000-0000-0000-000000000001%7D/hooks" + } + }, + "created_on": "2024-02-01T12:08:46.355300+00:00", + "type": "user", + "uuid": "{00000000-0000-0000-0000-000000000001}", + "has_2fa_enabled": null, + "username": "bb-bot", + "is_staff": false, + "account_id": "000000:00000000-0000-0000-0000-000000000001", + "nickname": "bb bot", + "account_status": "active", + "location": null +} diff --git a/server/events/vcs/bitbucketserver/client.go b/server/events/vcs/bitbucketserver/client.go index adb6eba0d8..61d2db7069 100644 --- a/server/events/vcs/bitbucketserver/client.go +++ b/server/events/vcs/bitbucketserver/client.go @@ -5,17 +5,17 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "net/url" "regexp" "strings" "github.com/runatlantis/atlantis/server/events/vcs/common" + "github.com/runatlantis/atlantis/server/logging" + validator "github.com/go-playground/validator/v10" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events/models" - validator "gopkg.in/go-playground/validator.v9" ) // maxCommentLength is the maximum number of chars allowed by Bitbucket in a @@ -30,6 +30,11 @@ type Client struct { AtlantisURL string } +type DeleteSourceBranch struct { + Name string `json:"name"` + DryRun bool `json:"dryRun"` +} + // NewClient builds a bitbucket cloud client. Returns an error if the baseURL is // malformed. httpClient is the client to use to make the requests, username // and password are used as basic auth in the requests, baseURL is the API's @@ -60,7 +65,7 @@ func NewClient(httpClient *http.Client, username string, password string, baseUR // GetModifiedFiles returns the names of files that were modified in the merge request // relative to the repo root, e.g. parent/child/file.txt. -func (b *Client) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) { +func (b *Client) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { var files []string projectKey, err := b.GetProjectKey(repo.Name, repo.SanitizedCloneURL) @@ -129,10 +134,10 @@ func (b *Client) GetProjectKey(repoName string, cloneURL string) (string, error) // CreateComment creates a comment on the merge request. It will write multiple // comments if a single comment is too long. -func (b *Client) CreateComment(repo models.Repo, pullNum int, comment string, command string) error { +func (b *Client) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, _ string) error { sepEnd := "\n```\n**Warning**: Output length greater than max comment size. Continued in next comment." sepStart := "Continued from previous comment.\n```diff\n" - comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart) + comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart, 0, "") for _, c := range comments { if err := b.postComment(repo, pullNum, c); err != nil { return err @@ -141,7 +146,11 @@ func (b *Client) CreateComment(repo models.Repo, pullNum int, comment string, co return nil } -func (b *Client) HidePrevPlanComments(repo models.Repo, pullNum int) error { +func (b *Client) ReactToComment(_ logging.SimpleLogging, _ models.Repo, _ int, _ int64, _ string) error { + return nil +} + +func (b *Client) HidePrevCommandComments(_ logging.SimpleLogging, _ models.Repo, _ int, _ string, _ string) error { return nil } @@ -161,33 +170,40 @@ func (b *Client) postComment(repo models.Repo, pullNum int, comment string) erro } // PullIsApproved returns true if the merge request was approved. -func (b *Client) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) { +func (b *Client) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (approvalStatus models.ApprovalStatus, err error) { projectKey, err := b.GetProjectKey(repo.Name, repo.SanitizedCloneURL) if err != nil { - return false, err + return approvalStatus, err } path := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d", b.BaseURL, projectKey, repo.Name, pull.Num) resp, err := b.makeRequest("GET", path, nil) if err != nil { - return false, err + return approvalStatus, err } var pullResp PullRequest if err := json.Unmarshal(resp, &pullResp); err != nil { - return false, errors.Wrapf(err, "Could not parse response %q", string(resp)) + return approvalStatus, errors.Wrapf(err, "Could not parse response %q", string(resp)) } if err := validator.New().Struct(pullResp); err != nil { - return false, errors.Wrapf(err, "API response %q was missing fields", string(resp)) + return approvalStatus, errors.Wrapf(err, "API response %q was missing fields", string(resp)) } for _, reviewer := range pullResp.Reviewers { if *reviewer.Approved { - return true, nil + return models.ApprovalStatus{ + IsApproved: true, + }, nil } } - return false, nil + return approvalStatus, nil +} + +func (b *Client) DiscardReviews(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) error { + // TODO implement + return nil } // PullIsMergeable returns true if the merge request has no conflicts and can be merged. -func (b *Client) PullIsMergeable(repo models.Repo, pull models.PullRequest) (bool, error) { +func (b *Client) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, _ string, _ []string) (bool, error) { projectKey, err := b.GetProjectKey(repo.Name, repo.SanitizedCloneURL) if err != nil { return false, err @@ -211,7 +227,7 @@ func (b *Client) PullIsMergeable(repo models.Repo, pull models.PullRequest) (boo } // UpdateStatus updates the status of a commit. -func (b *Client) UpdateStatus(repo models.Repo, pull models.PullRequest, status models.CommitStatus, src string, description string, url string) error { +func (b *Client) UpdateStatus(logger logging.SimpleLogging, _ models.Repo, pull models.PullRequest, status models.CommitStatus, src string, description string, url string) error { bbState := "FAILED" switch status { case models.PendingCommitStatus: @@ -222,6 +238,8 @@ func (b *Client) UpdateStatus(repo models.Repo, pull models.PullRequest, status bbState = "FAILED" } + logger.Info("Updating BitBucket commit status for '%s' to '%s'", src, bbState) + // URL is a required field for bitbucket statuses. We default to the // Atlantis server's URL. if url == "" { @@ -244,7 +262,7 @@ func (b *Client) UpdateStatus(repo models.Repo, pull models.PullRequest, status } // MergePull merges the pull request. -func (b *Client) MergePull(pull models.PullRequest) error { +func (b *Client) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { projectKey, err := b.GetProjectKey(pull.BaseRepo.Name, pull.BaseRepo.SanitizedCloneURL) if err != nil { return err @@ -265,6 +283,21 @@ func (b *Client) MergePull(pull models.PullRequest) error { } path = fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/merge?version=%d", b.BaseURL, projectKey, pull.BaseRepo.Name, pull.Num, *pullResp.Version) _, err = b.makeRequest("POST", path, nil) + if err != nil { + return err + } + if pullOptions.DeleteSourceBranchOnMerge { + bodyBytes, err := json.Marshal(DeleteSourceBranch{Name: "refs/heads/" + pull.HeadBranch, DryRun: false}) + if err != nil { + return errors.Wrap(err, "json encoding") + } + + path = fmt.Sprintf("%s/rest/branch-utils/1.0/projects/%s/repos/%s/branches", b.BaseURL, projectKey, pull.BaseRepo.Name) + _, err = b.makeRequest("DELETE", path, bytes.NewBuffer(bodyBytes)) + if err != nil { + return err + } + } return err } @@ -279,7 +312,11 @@ func (b *Client) prepRequest(method string, path string, body io.Reader) (*http. if err != nil { return nil, err } - req.SetBasicAuth(b.Username, b.Password) + + // Personal access tokens can be sent as basic auth or bearer + bearer := "Bearer " + b.Password + req.Header.Add("Authorization", bearer) + if body != nil { req.Header.Add("Content-Type", "application/json") } @@ -302,23 +339,36 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b requestStr := fmt.Sprintf("%s %s", method, path) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != 204 { - respBody, _ := ioutil.ReadAll(resp.Body) + respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("making request %q unexpected status code: %d, body: %s", requestStr, resp.StatusCode, string(respBody)) } - respBody, err := ioutil.ReadAll(resp.Body) + respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, errors.Wrapf(err, "reading response from request %q", requestStr) } return respBody, nil } -func (b *Client) SupportsSingleFileDownload(repo models.Repo) bool { +// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). +func (b *Client) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { + return nil, nil +} + +func (b *Client) SupportsSingleFileDownload(_ models.Repo) bool { return false } -// DownloadRepoConfigFile return `atlantis.yaml` content from VCS (which support fetch a single file from repository) -// The first return value indicate that repo contain atlantis.yaml or not -// if BaseRepo had one repo config file, its content will placed on the second return value -func (b *Client) DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) { +// GetFileContent a repository file content from VCS (which support fetch a single file from repository) +// The first return value indicates whether the repo contains a file or not +// if BaseRepo had a file, its content will placed on the second return value +func (b *Client) GetFileContent(_ logging.SimpleLogging, _ models.PullRequest, _ string) (bool, []byte, error) { return false, []byte{}, fmt.Errorf("not implemented") } + +func (b *Client) GetCloneURL(_ logging.SimpleLogging, _ models.VCSHostType, _ string) (string, error) { + return "", fmt.Errorf("not yet implemented") +} + +func (b *Client) GetPullLabels(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) ([]string, error) { + return nil, fmt.Errorf("not yet implemented") +} diff --git a/server/events/vcs/bitbucketserver/client_test.go b/server/events/vcs/bitbucketserver/client_test.go index 6ff44f83af..4827b76ed1 100644 --- a/server/events/vcs/bitbucketserver/client_test.go +++ b/server/events/vcs/bitbucketserver/client_test.go @@ -1,16 +1,19 @@ package bitbucketserver_test import ( + "encoding/json" "fmt" - "io/ioutil" + "io" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" + "github.com/runatlantis/atlantis/server/logging" . "github.com/runatlantis/atlantis/testing" ) @@ -70,6 +73,7 @@ func TestClient_BasePath(t *testing.T) { // Should follow pagination properly. func TestClient_GetModifiedFilesPagination(t *testing.T) { + logger := logging.NewNoopLogger(t) respTemplate := ` { "values": [ @@ -118,18 +122,20 @@ func TestClient_GetModifiedFilesPagination(t *testing.T) { client, err := bitbucketserver.NewClient(http.DefaultClient, "user", "pass", serverURL, "runatlantis.io") Ok(t, err) - files, err := client.GetModifiedFiles(models.Repo{ - FullName: "owner/repo", - Owner: "owner", - Name: "repo", - SanitizedCloneURL: fmt.Sprintf("%s/scm/ow/repo.git", serverURL), - VCSHost: models.VCSHost{ - Type: models.BitbucketCloud, - Hostname: "bitbucket.org", - }, - }, models.PullRequest{ - Num: 1, - }) + files, err := client.GetModifiedFiles( + logger, + models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + SanitizedCloneURL: fmt.Sprintf("%s/scm/ow/repo.git", serverURL), + VCSHost: models.VCSHost{ + Type: models.BitbucketCloud, + Hostname: "bitbucket.org", + }, + }, models.PullRequest{ + Num: 1, + }) Ok(t, err) Equals(t, []string{"file1.txt", "file2.txt", "file3.txt"}, files) } @@ -137,7 +143,8 @@ func TestClient_GetModifiedFilesPagination(t *testing.T) { // Test that we use the correct version parameter in our call to merge the pull // request. func TestClient_MergePull(t *testing.T) { - pullRequest, err := ioutil.ReadFile(filepath.Join("testdata", "pull-request.json")) + logger := logging.NewNoopLogger(t) + pullRequest, err := os.ReadFile(filepath.Join("testdata", "pull-request.json")) Ok(t, err) testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.RequestURI { @@ -159,25 +166,93 @@ func TestClient_MergePull(t *testing.T) { client, err := bitbucketserver.NewClient(http.DefaultClient, "user", "pass", testServer.URL, "runatlantis.io") Ok(t, err) - err = client.MergePull(models.PullRequest{ - Num: 1, - HeadCommit: "", - URL: "", - HeadBranch: "", - BaseBranch: "", - Author: "", - State: 0, - BaseRepo: models.Repo{ - FullName: "owner/repo", - Owner: "owner", - Name: "repo", - SanitizedCloneURL: fmt.Sprintf("%s/scm/ow/repo.git", testServer.URL), - VCSHost: models.VCSHost{ - Type: models.BitbucketCloud, - Hostname: "bitbucket.org", + err = client.MergePull( + logger, + models.PullRequest{ + Num: 1, + HeadCommit: "", + URL: "", + HeadBranch: "", + BaseBranch: "", + Author: "", + State: 0, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + SanitizedCloneURL: fmt.Sprintf("%s/scm/ow/repo.git", testServer.URL), + VCSHost: models.VCSHost{ + Type: models.BitbucketCloud, + Hostname: "bitbucket.org", + }, }, + }, models.PullRequestOptions{ + DeleteSourceBranchOnMerge: false, + }) + Ok(t, err) +} + +// Test that we delete the source branch in our call to merge the pull +// request. +func TestClient_MergePullDeleteSourceBranch(t *testing.T) { + logger := logging.NewNoopLogger(t) + pullRequest, err := os.ReadFile(filepath.Join("testdata", "pull-request.json")) + Ok(t, err) + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + // The first request should hit this URL. + case "/rest/api/1.0/projects/ow/repos/repo/pull-requests/1": + w.Write(pullRequest) // nolint: errcheck + return + case "/rest/api/1.0/projects/ow/repos/repo/pull-requests/1/merge?version=3": + Equals(t, "POST", r.Method) + w.Write(pullRequest) // nolint: errcheck + case "/rest/branch-utils/1.0/projects/ow/repos/repo/branches": + Equals(t, "DELETE", r.Method) + defer r.Body.Close() + b, err := io.ReadAll(r.Body) + Ok(t, err) + var payload bitbucketserver.DeleteSourceBranch + err = json.Unmarshal(b, &payload) + Ok(t, err) + Equals(t, "refs/heads/foo", payload.Name) + w.WriteHeader(http.StatusNoContent) // nolint: errcheck + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + defer testServer.Close() + + client, err := bitbucketserver.NewClient(http.DefaultClient, "user", "pass", testServer.URL, "runatlantis.io") + Ok(t, err) + + err = client.MergePull( + logger, + models.PullRequest{ + Num: 1, + HeadCommit: "", + URL: "", + HeadBranch: "foo", + BaseBranch: "", + Author: "", + State: 0, + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + SanitizedCloneURL: fmt.Sprintf("%s/scm/ow/repo.git", testServer.URL), + VCSHost: models.VCSHost{ + Type: models.BitbucketServer, + Hostname: "bitbucket.org", + }, + }, + }, + models.PullRequestOptions{ + DeleteSourceBranchOnMerge: true, }, - }) + ) Ok(t, err) } diff --git a/server/events/vcs/bitbucketserver/models.go b/server/events/vcs/bitbucketserver/models.go index f9c34d4fc9..5646ca4256 100644 --- a/server/events/vcs/bitbucketserver/models.go +++ b/server/events/vcs/bitbucketserver/models.go @@ -3,6 +3,7 @@ package bitbucketserver const ( DiagnosticsPingHeader = "diagnostics:ping" PullCreatedHeader = "pr:opened" + PullFromRefUpdatedHeader = "pr:from_ref_updated" PullMergedHeader = "pr:merged" PullDeclinedHeader = "pr:declined" PullDeletedHeader = "pr:deleted" diff --git a/server/events/vcs/bitbucketserver/request_validation_test.go b/server/events/vcs/bitbucketserver/request_validation_test.go index 9b9d5228a4..4e418c3d0a 100644 --- a/server/events/vcs/bitbucketserver/request_validation_test.go +++ b/server/events/vcs/bitbucketserver/request_validation_test.go @@ -16,7 +16,7 @@ func TestValidateSignature(t *testing.T) { } func TestValidateSignature_Invalid(t *testing.T) { - body := `"eventKey":"pr:comment:added","date":"2018-07-24T15:10:05+0200","actor":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"pullRequest":{"id":5,"version":0,"title":"another.tf edited online with Bitbucket","state":"OPEN","open":true,"closed":false,"createdDate":1532365427513,"updatedDate":1532365427513,"fromRef":{"id":"refs/heads/lkysow/anothertf-1532365422773","displayId":"lkysow/anothertf-1532365422773","latestCommit":"b52b8e254e956654dcdb394d0ccba9199f420427","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"toRef":{"id":"refs/heads/master","displayId":"master","latestCommit":"0a338874369017deba7c22e99e6000932724282f","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"locked":false,"author":{"user":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"role":"AUTHOR","approved":false,"status":"UNAPPROVED"},"reviewers":[],"participants":[]},"comment":{"properties":{"repositoryId":1},"id":65,"version":0,"text":"comment","author":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"createdDate":1532437805137,"updatedDate":1532437805137,"comments":[],"tasks":[]}}` + body := `"eventKey":"pr:comment:added","date":"2018-07-24T15:10:05+0200","actor":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"pullRequest":{"id":5,"version":0,"title":"another.tf edited online with Bitbucket","state":"OPEN","open":true,"closed":false,"createdDate":1532365427513,"updatedDate":1532365427513,"fromRef":{"id":"refs/heads/lkysow/anothertf-1532365422773","displayId":"lkysow/anothertf-1532365422773","latestCommit":"b52b8e254e956654dcdb394d0ccba9199f420427","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"toRef":{"id":"refs/heads/main","displayId":"main","latestCommit":"0a338874369017deba7c22e99e6000932724282f","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"locked":false,"author":{"user":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"role":"AUTHOR","approved":false,"status":"UNAPPROVED"},"reviewers":[],"participants":[]},"comment":{"properties":{"repositoryId":1},"id":65,"version":0,"text":"comment","author":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"createdDate":1532437805137,"updatedDate":1532437805137,"comments":[],"tasks":[]}}` secret := "mysecret" sig := `sha256=ed11f92d1565a4de586727fe8260558277d58009e8957a79eb4749a7009ce083` err := bitbucketserver.ValidateSignature([]byte(body), sig, []byte(secret)) diff --git a/server/events/vcs/bitbucketserver/testdata/pull-request.json b/server/events/vcs/bitbucketserver/testdata/pull-request.json index 2f816b7eec..ee83effcf9 100644 --- a/server/events/vcs/bitbucketserver/testdata/pull-request.json +++ b/server/events/vcs/bitbucketserver/testdata/pull-request.json @@ -55,8 +55,8 @@ } }, "toRef": { - "id": "refs/heads/master", - "displayId": "master", + "id": "refs/heads/main", + "displayId": "main", "latestCommit": "59e03b9cc44e16e20741e328faaac26e377c07bf", "repository": { "slug": "example", diff --git a/server/events/vcs/client.go b/server/events/vcs/client.go index dad6fa1901..58feb26fdb 100644 --- a/server/events/vcs/client.go +++ b/server/events/vcs/client.go @@ -15,19 +15,22 @@ package vcs import ( "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/logging" ) -//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_client.go Client +//go:generate pegomock generate --package mocks -o mocks/mock_client.go github.com/runatlantis/atlantis/server/events/vcs Client // Client is used to make API calls to a VCS host like GitHub or GitLab. type Client interface { // GetModifiedFiles returns the names of files that were modified in the merge request // relative to the repo root, e.g. parent/child/file.txt. - GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) - CreateComment(repo models.Repo, pullNum int, comment string, command string) error - HidePrevPlanComments(repo models.Repo, pullNum int) error - PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) - PullIsMergeable(repo models.Repo, pull models.PullRequest) (bool, error) + GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) + CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error + + ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error + HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error + PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) + PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, vcsstatusname string, ignoreVCSStatusNames []string) (bool, error) // UpdateStatus updates the commit status to state for pull. src is the // source of this status. This should be relatively static across runs, // ex. atlantis/plan or atlantis/apply. @@ -35,13 +38,19 @@ type Client interface { // change across runs. // url is an optional link that users should click on for more information // about this status. - UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error - MergePull(pull models.PullRequest) error + UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error + DiscardReviews(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error + MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error MarkdownPullLink(pull models.PullRequest) (string, error) + GetTeamNamesForUser(logger logging.SimpleLogging, repo models.Repo, user models.User) ([]string, error) - // DownloadRepoConfigFile return `atlantis.yaml` content from VCS (which support fetch a single file from repository) - // The first return value indicate that repo contain atlantis.yaml or not - // if BaseRepo had one repo config file, its content will placed on the second return value - DownloadRepoConfigFile(pull models.PullRequest) (bool, []byte, error) + // GetFileContent a repository file content from VCS (which support fetch a single file from repository) + // The first return value indicates whether the repo contains a file or not + // if BaseRepo had a file, its content will placed on the second return value + GetFileContent(logger logging.SimpleLogging, pull models.PullRequest, fileName string) (bool, []byte, error) SupportsSingleFileDownload(repo models.Repo) bool + GetCloneURL(logger logging.SimpleLogging, VCSHostType models.VCSHostType, repo string) (string, error) + + // GetPullLabels returns the labels of a pull request + GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) } diff --git a/server/events/vcs/common/common.go b/server/events/vcs/common/common.go index 7a41b1d835..b7c1028ac1 100644 --- a/server/events/vcs/common/common.go +++ b/server/events/vcs/common/common.go @@ -3,41 +3,54 @@ package common import ( + "fmt" "math" ) -// AutomergeCommitMsg is the commit message Atlantis will use when automatically -// merging pull requests. -const AutomergeCommitMsg = "[Atlantis] Automatically merging after successful apply" +// AutomergeCommitMsg returns the commit message to use when automerging. +func AutomergeCommitMsg(pullNum int) string { + return fmt.Sprintf("[Atlantis] Automatically merging after successful apply: PR #%d", pullNum) +} -// SplitComment splits comment into a slice of comments that are under maxSize. -// It appends sepEnd to all comments that have a following comment. -// It prepends sepStart to all comments that have a preceding comment. -func SplitComment(comment string, maxSize int, sepEnd string, sepStart string) []string { +/* +SplitComment splits comment into a slice of comments that are under maxSize. +- It appends sepEnd to all comments that have a following comment. +- It prepends sepStart to all comments that have a preceding comment. +- If maxCommentsPerCommand is non-zero, it never returns more than maxCommentsPerCommand +comments, and it truncates the beginning of the comment to preserve the end of the comment string, +which usually contains more important information, such as warnings, errors, and the plan summary. +- SplitComment appends the truncationHeader to the first comment if it would have produced more comments. +*/ +func SplitComment(comment string, maxSize int, sepEnd string, sepStart string, maxCommentsPerCommand int, truncationHeader string) []string { if len(comment) <= maxSize { return []string{comment} } - maxWithSep := maxSize - len(sepEnd) - len(sepStart) + // No comment contains both sepEnd and truncationHeader, so we only have to count their max. + maxWithSep := maxSize - max(len(sepEnd), len(truncationHeader)) - len(sepStart) var comments []string - numComments := int(math.Ceil(float64(len(comment)) / float64(maxWithSep))) - for i := 0; i < numComments; i++ { - upTo := min(len(comment), (i+1)*maxWithSep) - portion := comment[i*maxWithSep : upTo] - if i < numComments-1 { - portion += sepEnd - } - if i > 0 { + numPotentialComments := int(math.Ceil(float64(len(comment)) / float64(maxWithSep))) + var numComments int + if maxCommentsPerCommand == 0 { + numComments = numPotentialComments + } else { + numComments = min(numPotentialComments, maxCommentsPerCommand) + } + isTruncated := numComments < numPotentialComments + upTo := len(comment) + for len(comments) < numComments { + downFrom := max(0, upTo-maxWithSep) + portion := comment[downFrom:upTo] + if len(comments)+1 != numComments { portion = sepStart + portion + } else if len(comments)+1 == numComments && isTruncated { + portion = truncationHeader + portion + } + if len(comments) != 0 { + portion = portion + sepEnd } - comments = append(comments, portion) + comments = append([]string{portion}, comments...) + upTo = downFrom } return comments } - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/server/events/vcs/common/common_test.go b/server/events/vcs/common/common_test.go index 79dea1cb73..1f9d8e9d00 100644 --- a/server/events/vcs/common/common_test.go +++ b/server/events/vcs/common/common_test.go @@ -18,14 +18,13 @@ import ( "testing" "github.com/runatlantis/atlantis/server/events/vcs/common" - . "github.com/runatlantis/atlantis/testing" ) // If under the maximum number of chars, we shouldn't split the comments. func TestSplitComment_UnderMax(t *testing.T) { comment := "comment under max size" - split := common.SplitComment(comment, len(comment)+1, "sepEnd", "sepStart") + split := common.SplitComment(comment, len(comment)+1, "sepEnd", "sepStart", 0, "") Equals(t, []string{comment}, split) } @@ -35,11 +34,11 @@ func TestSplitComment_TwoComments(t *testing.T) { comment := strings.Repeat("a", 1000) sepEnd := "-sepEnd" sepStart := "-sepStart" - split := common.SplitComment(comment, len(comment)-1, sepEnd, sepStart) + split := common.SplitComment(comment, len(comment)-1, sepEnd, sepStart, 0, "") expCommentLen := len(comment) - len(sepEnd) - len(sepStart) - 1 - expFirstComment := comment[:expCommentLen] - expSecondComment := comment[expCommentLen:] + expFirstComment := comment[:len(comment)-expCommentLen] + expSecondComment := comment[len(comment)-expCommentLen:] Equals(t, 2, len(split)) Equals(t, expFirstComment+sepEnd, split[0]) Equals(t, sepStart+expSecondComment, split[1]) @@ -52,12 +51,50 @@ func TestSplitComment_FourComments(t *testing.T) { sepEnd := "-sepEnd" sepStart := "-sepStart" max := (len(comment) / 4) + len(sepEnd) + len(sepStart) - split := common.SplitComment(comment, max, sepEnd, sepStart) + split := common.SplitComment(comment, max, sepEnd, sepStart, 0, "") expMax := len(comment) / 4 Equals(t, []string{ - comment[:expMax] + sepEnd, - sepStart + comment[expMax:expMax*2] + sepEnd, - sepStart + comment[expMax*2:expMax*3] + sepEnd, - sepStart + comment[expMax*3:]}, split) + comment[:len(comment)-expMax*3] + sepEnd, + sepStart + comment[len(comment)-expMax*3:len(comment)-expMax*2] + sepEnd, + sepStart + comment[len(comment)-expMax*2:len(comment)-expMax] + sepEnd, + sepStart + comment[len(comment)-expMax:]}, split) +} + +func TestSplitComment_Limited(t *testing.T) { + comment := strings.Repeat("a", 1000) + sepEnd := "-sepEnd" + sepStart := "-sepStart" + truncationHeader := "truncated-" + max := (len(comment) / 8) + max(len(sepEnd), len(truncationHeader)) + len(sepStart) + split := common.SplitComment(comment, max, sepEnd, sepStart, 5, truncationHeader) + + expMax := len(comment) / 8 + Equals(t, []string{ + truncationHeader + comment[len(comment)-expMax*5:len(comment)-expMax*4] + sepEnd, + sepStart + comment[len(comment)-expMax*4:len(comment)-expMax*3] + sepEnd, + sepStart + comment[len(comment)-expMax*3:len(comment)-expMax*2] + sepEnd, + sepStart + comment[len(comment)-expMax*2:len(comment)-expMax] + sepEnd, + sepStart + comment[len(comment)-expMax:]}, split) +} + +func TestAutomergeCommitMsg(t *testing.T) { + tests := []struct { + name string + pullNum int + want string + }{ + { + name: "Atlantis PR commit message should include PR number", + pullNum: 123, + want: "[Atlantis] Automatically merging after successful apply: PR #123", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := common.AutomergeCommitMsg(tt.pullNum); got != tt.want { + t.Errorf("AutomergeCommitMsg() = %v, want %v", got, tt.want) + } + }) + } } diff --git a/server/events/vcs/fixtures/fixtures.go b/server/events/vcs/fixtures/fixtures.go deleted file mode 100644 index 0f9f2c331f..0000000000 --- a/server/events/vcs/fixtures/fixtures.go +++ /dev/null @@ -1,524 +0,0 @@ -// Copyright 2017 HootSuite Media Inc. -// -// Licensed under the Apache License, Version 2.0 (the License); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an AS IS BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// Modified hereafter by contributors to runatlantis/atlantis. - -package fixtures - -import ( - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/dgrijalva/jwt-go" - "github.com/google/go-github/v31/github" - "github.com/mcdafydd/go-azuredevops/azuredevops" -) - -var PullEvent = github.PullRequestEvent{ - Sender: &github.User{ - Login: github.String("user"), - }, - Repo: &Repo, - PullRequest: &Pull, - Action: github.String("opened"), -} - -var Pull = github.PullRequest{ - Head: &github.PullRequestBranch{ - SHA: github.String("sha256"), - Ref: github.String("ref"), - Repo: &Repo, - }, - Base: &github.PullRequestBranch{ - SHA: github.String("sha256"), - Repo: &Repo, - Ref: github.String("basebranch"), - }, - HTMLURL: github.String("html-url"), - User: &github.User{ - Login: github.String("user"), - }, - Number: github.Int(1), - State: github.String("open"), -} - -var Repo = github.Repository{ - FullName: github.String("owner/repo"), - Owner: &github.User{Login: github.String("owner")}, - Name: github.String("repo"), - CloneURL: github.String("https://github.com/owner/repo.git"), -} - -var ADPullEvent = azuredevops.Event{ - EventType: "git.pullrequest.created", - Resource: &ADPull, -} - -var ADPullUpdatedEvent = azuredevops.Event{ - EventType: "git.pullrequest.updated", - Resource: &ADPull, -} - -var ADPullClosedEvent = azuredevops.Event{ - EventType: "git.pullrequest.merged", - Resource: &ADPullCompleted, -} - -var ADPull = azuredevops.GitPullRequest{ - CreatedBy: &azuredevops.IdentityRef{ - ID: azuredevops.String("d6245f20-2af8-44f4-9451-8107cb2767db"), - DisplayName: azuredevops.String("User"), - UniqueName: azuredevops.String("user@example.com"), - }, - LastMergeSourceCommit: &azuredevops.GitCommitRef{ - CommitID: azuredevops.String("b60280bc6e62e2f880f1b63c1e24987664d3bda3"), - URL: azuredevops.String("https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3"), - }, - PullRequestID: azuredevops.Int(1), - Repository: &ADRepo, - SourceRefName: azuredevops.String("refs/heads/feature/sourceBranch"), - Status: azuredevops.String("active"), - TargetRefName: azuredevops.String("refs/heads/targetBranch"), - URL: azuredevops.String("https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/21"), -} - -var ADPullCompleted = azuredevops.GitPullRequest{ - CreatedBy: &azuredevops.IdentityRef{ - ID: azuredevops.String("d6245f20-2af8-44f4-9451-8107cb2767db"), - DisplayName: azuredevops.String("User"), - UniqueName: azuredevops.String("user@example.com"), - }, - LastMergeSourceCommit: &azuredevops.GitCommitRef{ - CommitID: azuredevops.String("b60280bc6e62e2f880f1b63c1e24987664d3bda3"), - URL: azuredevops.String("https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3"), - }, - PullRequestID: azuredevops.Int(1), - Repository: &ADRepo, - SourceRefName: azuredevops.String("refs/heads/owner/sourceBranch"), - Status: azuredevops.String("completed"), - TargetRefName: azuredevops.String("refs/heads/targetBranch"), - URL: azuredevops.String("https://dev.azure.com/fabrikam/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/21"), -} - -var ADRepo = azuredevops.GitRepository{ - DefaultBranch: azuredevops.String("refs/heads/master"), - Name: azuredevops.String("repo"), - ParentRepository: &azuredevops.GitRepositoryRef{ - Name: azuredevops.String("owner"), - }, - Project: &azuredevops.TeamProjectReference{ - ID: azuredevops.String("a21f5f20-4a12-aaf4-ab12-9a0927cbbb90"), - Name: azuredevops.String("project"), - State: azuredevops.String("unchanged"), - }, - WebURL: azuredevops.String("https://dev.azure.com/owner/project/_git/repo"), -} - -var ADPullJSON = `{ - "repository": { - "id": "3411ebc1-d5aa-464f-9615-0b527bc66719", - "name": "repo", - "url": "https://dev.azure.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719", - "webUrl": "https://dev.azure.com/owner/project/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719", - "project": { - "id": "a7573007-bbb3-4341-b726-0c4148a07853", - "name": "project", - "description": "test project created on Halloween 2016", - "url": "https://dev.azure.com/owner/_apis/projects/a7573007-bbb3-4341-b726-0c4148a07853", - "state": "wellFormed", - "revision": 7 - }, - "remoteUrl": "https://dev.azure.com/owner/project/_git/repo" - }, - "pullRequestId": 22, - "codeReviewId": 22, - "status": "active", - "createdBy": { - "id": "d6245f20-2af8-44f4-9451-8107cb2767db", - "displayName": "Normal Paulk", - "uniqueName": "fabrikamfiber16@hotmail.com", - "url": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db", - "imageUrl": "https://dev.azure.com/owner/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db" - }, - "creationDate": "2016-11-01T16:30:31.6655471Z", - "title": "A new feature", - "description": "Adding a new feature", - "sourceRefName": "refs/heads/npaulk/my_work", - "targetRefName": "refs/heads/new_feature", - "mergeStatus": "succeeded", - "mergeId": "f5fc8381-3fb2-49fe-8a0d-27dcc2d6ef82", - "lastMergeSourceCommit": { - "commitId": "b60280bc6e62e2f880f1b63c1e24987664d3bda3", - "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3" - }, - "lastMergeTargetCommit": { - "commitId": "f47bbc106853afe3c1b07a81754bce5f4b8dbf62", - "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62" - }, - "lastMergeCommit": { - "commitId": "39f52d24533cc712fc845ed9fd1b6c06b3942588", - "author": { - "name": "Normal Paulk", - "email": "fabrikamfiber16@hotmail.com", - "date": "2016-11-01T16:30:32Z" - }, - "committer": { - "name": "Normal Paulk", - "email": "fabrikamfiber16@hotmail.com", - "date": "2016-11-01T16:30:32Z" - }, - "comment": "Merge pull request 22 from npaulk/my_work into new_feature", - "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/39f52d24533cc712fc845ed9fd1b6c06b3942588" - }, - "reviewers": [ - { - "reviewerUrl": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/reviewers/d6245f20-2af8-44f4-9451-8107cb2767db", - "vote": 0, - "id": "d6245f20-2af8-44f4-9451-8107cb2767db", - "displayName": "Normal Paulk", - "uniqueName": "fabrikamfiber16@hotmail.com", - "url": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db", - "imageUrl": "https://dev.azure.com/owner/_api/_common/identityImage?id=d6245f20-2af8-44f4-9451-8107cb2767db" - } - ], - "url": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22", - "_links": { - "self": { - "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22" - }, - "repository": { - "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719" - }, - "workItems": { - "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/workitems" - }, - "sourceBranch": { - "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs" - }, - "targetBranch": { - "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/refs" - }, - "sourceCommit": { - "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/b60280bc6e62e2f880f1b63c1e24987664d3bda3" - }, - "targetCommit": { - "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/commits/f47bbc106853afe3c1b07a81754bce5f4b8dbf62" - }, - "createdBy": { - "href": "https://dev.azure.com/owner/_apis/Identities/d6245f20-2af8-44f4-9451-8107cb2767db" - }, - "iterations": { - "href": "https://dev.azure.com/owner/_apis/git/repositories/3411ebc1-d5aa-464f-9615-0b527bc66719/pullRequests/22/iterations" - } - }, - "supportsIterations": true, - "artifactId": "vstfs:///Git/PullRequestId/a7573007-bbb3-4341-b726-0c4148a07853%2f3411ebc1-d5aa-464f-9615-0b527bc66719%2f22" -}` - -const GithubPrivateKey = `-----BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAuEPzOUE+kiEH1WLiMeBytTEF856j0hOVcSUSUkZxKvqczkWM -9vo1gDyC7ZXhdH9fKh32aapba3RSsp4ke+giSmYTk2mGR538ShSDxh0OgpJmjiKP -X0Bj4j5sFqfXuCtl9SkH4iueivv4R53ktqM+n6hk98l6hRwC39GVIblAh2lEM4L/ -6WvYwuQXPMM5OG2Ryh2tDZ1WS5RKfgq+9ksNJ5Q9UtqtqHkO+E63N5OK9sbzpUUm -oNaOl3udTlZD3A8iqwMPVxH4SxgATBPAc+bmjk6BMJ0qIzDcVGTrqrzUiywCTLma -szdk8GjzXtPDmuBgNn+o6s02qVGpyydgEuqmTQIDAQABAoIBACL6AvkjQVVLn8kJ -dBYznJJ4M8ECo+YEgaFwgAHODT0zRQCCgzd+Vxl4YwHmKV2Lr+y2s0drZt8GvYva -KOK8NYYZyi15IlwFyRXmvvykF1UBpSXluYFDH7KaVroWMgRreHcIys5LqVSIb6Bo -gDmK0yBLPp8qR29s2b7ScZRtLaqGJiX+j55rNzrZwxHkxFHyG9OG+u9IsBElcKCP -kYCVE8ZdYexfnKOZbgn2kZB9qu0T/Mdvki8yk3I2bI6xYO24oQmhnT36qnqWoCBX -NuCNsBQgpYZeZET8mEAUmo9d+ABmIHIvSs005agK8xRaP4+6jYgy6WwoejJRF5yd -NBuF7aECgYEA50nZ4FiZYV0vcJDxFYeY3kYOvVuKn8OyW+2rg7JIQTremIjv8FkE -ZnwuF9ZRxgqLxUIfKKfzp/5l5LrycNoj2YKfHKnRejxRWXqG+ZETfxxlmlRns0QG -J4+BYL0CoanDSeA4fuyn4Bv7cy/03TDhfg/Uq0Aeg+hhcPE/vx3ebPsCgYEAy/Pv -eDLssOSdeyIxf0Brtocg6aPXIVaLdus+bXmLg77rJIFytAZmTTW8SkkSczWtucI3 -FI1I6sei/8FdPzAl62/JDdlf7Wd9K7JIotY4TzT7Tm7QU7xpfLLYIP1bOFjN81rk -77oOD4LsXcosB/U6s1blPJMZ6AlO2EKs10UuR1cCgYBipzuJ2ADEaOz9RLWwi0AH -Pza2Sj+c2epQD9ZivD7Zo/Sid3ZwvGeGF13JyR7kLEdmAkgsHUdu1rI7mAolXMaB -1pdrsHureeLxGbRM6za3tzMXWv1Il7FQWoPC8ZwXvMOR1VQDv4nzq7vbbA8z8c+c -57+8tALQHOTDOgQIzwK61QKBgERGVc0EJy4Uag+VY8J4m1ZQKBluqo7TfP6DQ7O8 -M5MX73maB/7yAX8pVO39RjrhJlYACRZNMbK+v/ckEQYdJSSKmGCVe0JrGYDuPtic -I9+IGfSorf7KHPoMmMN6bPYQ7Gjh7a++tgRFTMEc8956Hnt4xGahy9NcglNtBpVN -6G8jAoGBAMCh028pdzJa/xeBHLLaVB2sc0Fe7993WlsPmnVE779dAz7qMscOtXJK -fgtriltLSSD6rTA9hUAsL/X62rY0wdXuNdijjBb/qvrx7CAV6i37NK1CjABNjsfG -ZM372Ac6zc1EqSrid2IjET1YqyIW2KGLI1R2xbQc98UGlt48OdWu ------END RSA PRIVATE KEY----- -` - -// https://developer.github.com/v3/apps/#response-9 -var githubConversionJSON = `{ - "id": 1, - "node_id": "MDM6QXBwNTk=", - "owner": { - "login": "octocat", - "id": 1, - "node_id": "MDQ6VXNlcjE=", - "avatar_url": "https://github.com/images/error/octocat_happy.gif", - "gravatar_id": "", - "url": "https://api.github.com/users/octocat", - "html_url": "https://github.com/octocat", - "followers_url": "https://api.github.com/users/octocat/followers", - "following_url": "https://api.github.com/users/octocat/following{/other_user}", - "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", - "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", - "organizations_url": "https://api.github.com/users/octocat/orgs", - "repos_url": "https://api.github.com/users/octocat/repos", - "events_url": "https://api.github.com/users/octocat/events{/privacy}", - "received_events_url": "https://api.github.com/users/octocat/received_events", - "type": "User", - "site_admin": false - }, - "name": "Atlantis", - "description": null, - "external_url": "https://atlantis.example.com", - "html_url": "https://github.com/apps/atlantis", - "created_at": "2018-09-13T12:28:37Z", - "updated_at": "2018-09-13T12:28:37Z", - "client_id": "Iv1.8a61f9b3a7aba766", - "client_secret": "1726be1638095a19edd134c77bde3aa2ece1e5d8", - "webhook_secret": "e340154128314309424b7c8e90325147d99fdafa", - "pem": "%s" -}` - -var githubAppInstallationJSON = `[ - { - "id": 1, - "account": { - "login": "github", - "id": 1, - "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=", - "url": "https://api.github.com/orgs/github", - "repos_url": "https://api.github.com/orgs/github/repos", - "events_url": "https://api.github.com/orgs/github/events", - "hooks_url": "https://api.github.com/orgs/github/hooks", - "issues_url": "https://api.github.com/orgs/github/issues", - "members_url": "https://api.github.com/orgs/github/members{/member}", - "public_members_url": "https://api.github.com/orgs/github/public_members{/member}", - "avatar_url": "https://github.com/images/error/octocat_happy.gif", - "description": "A great organization" - }, - "access_tokens_url": "https://api.github.com/installations/1/access_tokens", - "repositories_url": "https://api.github.com/installation/repositories", - "html_url": "https://github.com/organizations/github/settings/installations/1", - "app_id": 1, - "target_id": 1, - "target_type": "Organization", - "permissions": { - "metadata": "read", - "contents": "read", - "issues": "write", - "single_file": "write" - }, - "events": [ - "push", - "pull_request" - ], - "single_file_name": "config.yml", - "repository_selection": "selected" - } -]` - -// nolint: gosec -var githubAppTokenJSON = `{ - "token": "v1.1f699f1069f60xx%d", - "expires_at": "2050-01-01T00:00:00Z", - "permissions": { - "issues": "write", - "contents": "read" - }, - "repositories": [ - { - "id": 1296269, - "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", - "name": "Hello-World", - "full_name": "octocat/Hello-World", - "owner": { - "login": "octocat", - "id": 1, - "node_id": "MDQ6VXNlcjE=", - "avatar_url": "https://github.com/images/error/octocat_happy.gif", - "gravatar_id": "", - "url": "https://api.github.com/users/octocat", - "html_url": "https://github.com/octocat", - "followers_url": "https://api.github.com/users/octocat/followers", - "following_url": "https://api.github.com/users/octocat/following{/other_user}", - "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", - "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", - "organizations_url": "https://api.github.com/users/octocat/orgs", - "repos_url": "https://api.github.com/users/octocat/repos", - "events_url": "https://api.github.com/users/octocat/events{/privacy}", - "received_events_url": "https://api.github.com/users/octocat/received_events", - "type": "User", - "site_admin": false - }, - "private": false, - "html_url": "https://github.com/octocat/Hello-World", - "description": "This your first repo!", - "fork": false, - "url": "https://api.github.com/repos/octocat/Hello-World", - "archive_url": "http://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", - "assignees_url": "http://api.github.com/repos/octocat/Hello-World/assignees{/user}", - "blobs_url": "http://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", - "branches_url": "http://api.github.com/repos/octocat/Hello-World/branches{/branch}", - "collaborators_url": "http://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", - "comments_url": "http://api.github.com/repos/octocat/Hello-World/comments{/number}", - "commits_url": "http://api.github.com/repos/octocat/Hello-World/commits{/sha}", - "compare_url": "http://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", - "contents_url": "http://api.github.com/repos/octocat/Hello-World/contents/{+path}", - "contributors_url": "http://api.github.com/repos/octocat/Hello-World/contributors", - "deployments_url": "http://api.github.com/repos/octocat/Hello-World/deployments", - "downloads_url": "http://api.github.com/repos/octocat/Hello-World/downloads", - "events_url": "http://api.github.com/repos/octocat/Hello-World/events", - "forks_url": "http://api.github.com/repos/octocat/Hello-World/forks", - "git_commits_url": "http://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", - "git_refs_url": "http://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", - "git_tags_url": "http://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", - "git_url": "git:github.com/octocat/Hello-World.git", - "issue_comment_url": "http://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", - "issue_events_url": "http://api.github.com/repos/octocat/Hello-World/issues/events{/number}", - "issues_url": "http://api.github.com/repos/octocat/Hello-World/issues{/number}", - "keys_url": "http://api.github.com/repos/octocat/Hello-World/keys{/key_id}", - "labels_url": "http://api.github.com/repos/octocat/Hello-World/labels{/name}", - "languages_url": "http://api.github.com/repos/octocat/Hello-World/languages", - "merges_url": "http://api.github.com/repos/octocat/Hello-World/merges", - "milestones_url": "http://api.github.com/repos/octocat/Hello-World/milestones{/number}", - "notifications_url": "http://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", - "pulls_url": "http://api.github.com/repos/octocat/Hello-World/pulls{/number}", - "releases_url": "http://api.github.com/repos/octocat/Hello-World/releases{/id}", - "ssh_url": "git@github.com:octocat/Hello-World.git", - "stargazers_url": "http://api.github.com/repos/octocat/Hello-World/stargazers", - "statuses_url": "http://api.github.com/repos/octocat/Hello-World/statuses/{sha}", - "subscribers_url": "http://api.github.com/repos/octocat/Hello-World/subscribers", - "subscription_url": "http://api.github.com/repos/octocat/Hello-World/subscription", - "tags_url": "http://api.github.com/repos/octocat/Hello-World/tags", - "teams_url": "http://api.github.com/repos/octocat/Hello-World/teams", - "trees_url": "http://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", - "clone_url": "https://github.com/octocat/Hello-World.git", - "mirror_url": "git:git.example.com/octocat/Hello-World", - "hooks_url": "http://api.github.com/repos/octocat/Hello-World/hooks", - "svn_url": "https://svn.github.com/octocat/Hello-World", - "homepage": "https://github.com", - "language": null, - "forks_count": 9, - "stargazers_count": 80, - "watchers_count": 80, - "size": 108, - "default_branch": "master", - "open_issues_count": 0, - "is_template": true, - "topics": [ - "octocat", - "atom", - "electron", - "api" - ], - "has_issues": true, - "has_projects": true, - "has_wiki": true, - "has_pages": false, - "has_downloads": true, - "archived": false, - "disabled": false, - "visibility": "public", - "pushed_at": "2011-01-26T19:06:43Z", - "created_at": "2011-01-26T19:01:12Z", - "updated_at": "2011-01-26T19:14:43Z", - "permissions": { - "admin": false, - "push": false, - "pull": true - }, - "allow_rebase_merge": true, - "template_repository": null, - "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O", - "allow_squash_merge": true, - "allow_merge_commit": true, - "subscribers_count": 42, - "network_count": 0 - } - ] -}` - -func validateGithubToken(tokenString string) error { - key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(GithubPrivateKey)) - if err != nil { - return fmt.Errorf("could not parse private key: %s", err) - } - - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - // Don't forget to validate the alg is what you expect: - if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { - err := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) - - return nil, err - } - - return key.Public(), nil - }) - - if err != nil { - return err - } - - if claims, ok := token.Claims.(jwt.MapClaims); !ok || !token.Valid || claims["iss"] != "1" { - return fmt.Errorf("Invalid token") - } - return nil -} - -func GithubAppTestServer(t *testing.T) (string, error) { - counter := 0 - testServer := httptest.NewTLSServer( - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.RequestURI { - case "/api/v3/app-manifests/good-code/conversions": - encodedKey := strings.Join(strings.Split(GithubPrivateKey, "\n"), "\\n") - appInfo := fmt.Sprintf(githubConversionJSON, encodedKey) - w.Write([]byte(appInfo)) // nolint: errcheck - // https://developer.github.com/v3/apps/#list-installations - case "/api/v3/app/installations": - token := strings.Replace(r.Header.Get("Authorization"), "Bearer ", "", 1) - if err := validateGithubToken(token); err != nil { - w.WriteHeader(403) - w.Write([]byte("Invalid token")) // nolint: errcheck - return - } - - w.Write([]byte(githubAppInstallationJSON)) // nolint: errcheck - return - case "/api/v3/app/installations/1/access_tokens": - token := strings.Replace(r.Header.Get("Authorization"), "Bearer ", "", 1) - if err := validateGithubToken(token); err != nil { - w.WriteHeader(403) - w.Write([]byte("Invalid token")) // nolint: errcheck - return - } - - appToken := fmt.Sprintf(githubAppTokenJSON, counter) - counter++ - w.Write([]byte(appToken)) // nolint: errcheck - return - default: - t.Errorf("got unexpected request at %q", r.RequestURI) - http.Error(w, "not found", http.StatusNotFound) - return - } - })) - - testServerURL, err := url.Parse(testServer.URL) - - return testServerURL.Host, err -} diff --git a/server/events/vcs/git_cred_writer.go b/server/events/vcs/git_cred_writer.go new file mode 100644 index 0000000000..f877abcfdf --- /dev/null +++ b/server/events/vcs/git_cred_writer.go @@ -0,0 +1,141 @@ +package vcs + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/logging" +) + +// WriteGitCreds generates a .git-credentials file containing the username and token +// used for authenticating with git over HTTPS +// It will create the file in home/.git-credentials +// If ghAccessToken is true we will look for a line starting with https://x-access-token and ending with gitHostname and replace it. +func WriteGitCreds(gitUser string, gitToken string, gitHostname string, home string, logger logging.SimpleLogging, ghAccessToken bool) error { + const credsFilename = ".git-credentials" + credsFile := filepath.Join(home, credsFilename) + credsFileContentsPattern := `https://%s:%s@%s` // nolint: gosec + config := fmt.Sprintf(credsFileContentsPattern, gitUser, gitToken, gitHostname) + + // If the file doesn't exist, write it. + if _, err := os.Stat(credsFile); err != nil { + if err := os.WriteFile(credsFile, []byte(config), 0600); err != nil { + return errors.Wrapf(err, "writing generated %s file with user, token and hostname to %s", credsFilename, credsFile) + } + logger.Info("wrote git credentials to %s", credsFile) + } else { + hasLine, err := fileHasLine(config, credsFile) + if err != nil { + return err + } + if hasLine { + logger.Debug("git credentials file has expected contents, not modifying") + return nil + } + + if ghAccessToken { + hasGHToken, err := fileHasGHToken(gitUser, gitHostname, credsFile) + if err != nil { + return err + } + if hasGHToken { + // Need to replace the line. + if err := fileLineReplace(config, gitUser, gitHostname, credsFile); err != nil { + return errors.Wrap(err, "replacing git credentials line for github app") + } + logger.Info("updated git credentials in %s", credsFile) + } else { + if err := fileAppend(config, credsFile); err != nil { + return err + } + logger.Info("wrote git credentials to %s", credsFile) + } + + } else { + // Otherwise we need to append the line. + if err := fileAppend(config, credsFile); err != nil { + return err + } + logger.Info("wrote git credentials to %s", credsFile) + } + } + + credentialCmd := exec.Command("git", "config", "--global", "credential.helper", "store") + if out, err := credentialCmd.CombinedOutput(); err != nil { + return errors.Wrapf(err, "There was an error running %s: %s", strings.Join(credentialCmd.Args, " "), string(out)) + } + logger.Info("successfully ran %s", strings.Join(credentialCmd.Args, " ")) + + urlCmd := exec.Command("git", "config", "--global", fmt.Sprintf("url.https://%s@%s.insteadOf", gitUser, gitHostname), fmt.Sprintf("ssh://git@%s", gitHostname)) // nolint: gosec + if out, err := urlCmd.CombinedOutput(); err != nil { + return errors.Wrapf(err, "There was an error running %s: %s", strings.Join(urlCmd.Args, " "), string(out)) + } + logger.Info("successfully ran %s", strings.Join(urlCmd.Args, " ")) + return nil +} + +func fileHasLine(line string, filename string) (bool, error) { + currContents, err := os.ReadFile(filename) // nolint: gosec + if err != nil { + return false, errors.Wrapf(err, "reading %s", filename) + } + for _, l := range strings.Split(string(currContents), "\n") { + if l == line { + return true, nil + } + } + return false, nil +} + +func fileAppend(line string, filename string) error { + currContents, err := os.ReadFile(filename) // nolint: gosec + if err != nil { + return err + } + if len(currContents) > 0 && !strings.HasSuffix(string(currContents), "\n") { + line = "\n" + line + } + return os.WriteFile(filename, []byte(string(currContents)+line), 0600) +} + +func fileLineReplace(line, user, host, filename string) error { + currContents, err := os.ReadFile(filename) // nolint: gosec + if err != nil { + return err + } + prevLines := strings.Split(string(currContents), "\n") + var newLines []string + for _, l := range prevLines { + if strings.HasPrefix(l, "https://"+user) && strings.HasSuffix(l, host) { + newLines = append(newLines, line) + } else { + newLines = append(newLines, l) + } + } + toWrite := strings.Join(newLines, "\n") + + // there was nothing to replace so we need to append the creds + if toWrite == "" { + return fileAppend(line, filename) + } + + return os.WriteFile(filename, []byte(toWrite), 0600) +} + +func fileHasGHToken(user, host, filename string) (bool, error) { + currContents, err := os.ReadFile(filename) // nolint: gosec + if err != nil { + return false, err + } + prevLines := strings.Split(string(currContents), "\n") + for _, l := range prevLines { + if strings.HasPrefix(l, "https://"+user) && strings.HasSuffix(l, host) { + return true, nil + } + } + return false, nil +} diff --git a/server/events/vcs/git_cred_writer_test.go b/server/events/vcs/git_cred_writer_test.go new file mode 100644 index 0000000000..b8692bf25d --- /dev/null +++ b/server/events/vcs/git_cred_writer_test.go @@ -0,0 +1,180 @@ +package vcs_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/runatlantis/atlantis/server/events/vcs" + "github.com/runatlantis/atlantis/server/logging" + . "github.com/runatlantis/atlantis/testing" +) + +// Test that we write the file as expected +func TestWriteGitCreds_WriteFile(t *testing.T) { + logger := logging.NewNoopLogger(t) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + err := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + Ok(t, err) + + expContents := `https://user:token@hostname` + + actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) + Ok(t, err) + Equals(t, expContents, string(actContents)) +} + +// Test that if the file already exists and it doesn't have the line we would +// have written, we write it. +func TestWriteGitCreds_Appends(t *testing.T) { + logger := logging.NewNoopLogger(t) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + credsFile := filepath.Join(tmp, ".git-credentials") + err := os.WriteFile(credsFile, []byte("contents"), 0600) + Ok(t, err) + + err = vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + Ok(t, err) + + expContents := "contents\nhttps://user:token@hostname" + actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) + Ok(t, err) + Equals(t, expContents, string(actContents)) +} + +// Test that if the file already exists and it already has the line expected +// we do nothing. +func TestWriteGitCreds_NoModification(t *testing.T) { + logger := logging.NewNoopLogger(t) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + credsFile := filepath.Join(tmp, ".git-credentials") + contents := "line1\nhttps://user:token@hostname\nline2" + err := os.WriteFile(credsFile, []byte(contents), 0600) + Ok(t, err) + + err = vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + Ok(t, err) + actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) + Ok(t, err) + Equals(t, contents, string(actContents)) +} + +// Test that the github app credentials get replaced. +func TestWriteGitCreds_ReplaceApp(t *testing.T) { + logger := logging.NewNoopLogger(t) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + credsFile := filepath.Join(tmp, ".git-credentials") + contents := "line1\nhttps://x-access-token:v1.87dddddddddddddddd@github.com\nline2" + err := os.WriteFile(credsFile, []byte(contents), 0600) + Ok(t, err) + + err = vcs.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true) + Ok(t, err) + expContents := "line1\nhttps://x-access-token:token@github.com\nline2" + actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) + Ok(t, err) + Equals(t, expContents, string(actContents)) +} + +// Test that the github app credential gets added even if there are other credentials. +func TestWriteGitCreds_AppendAppWhenFileNotEmpty(t *testing.T) { + logger := logging.NewNoopLogger(t) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + credsFile := filepath.Join(tmp, ".git-credentials") + contents := "line1\nhttps://user:token@host.com\nline2" + err := os.WriteFile(credsFile, []byte(contents), 0600) + Ok(t, err) + + err = vcs.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true) + Ok(t, err) + expContents := "line1\nhttps://user:token@host.com\nline2\nhttps://x-access-token:token@github.com" + actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) + Ok(t, err) + Equals(t, expContents, string(actContents)) +} + +// Test that the github app credentials get updated when cred file is empty. +func TestWriteGitCreds_AppendApp(t *testing.T) { + logger := logging.NewNoopLogger(t) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + credsFile := filepath.Join(tmp, ".git-credentials") + contents := "" + err := os.WriteFile(credsFile, []byte(contents), 0600) + Ok(t, err) + + err = vcs.WriteGitCreds("x-access-token", "token", "github.com", tmp, logger, true) + Ok(t, err) + expContents := "https://x-access-token:token@github.com" + actContents, err := os.ReadFile(filepath.Join(tmp, ".git-credentials")) + Ok(t, err) + Equals(t, expContents, string(actContents)) +} + +// Test that if we can't read the existing file to see if the contents will be +// the same that we just error out. +func TestWriteGitCreds_ErrIfCannotRead(t *testing.T) { + logger := logging.NewNoopLogger(t) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + credsFile := filepath.Join(tmp, ".git-credentials") + err := os.WriteFile(credsFile, []byte("can't see me!"), 0000) + Ok(t, err) + + expErr := fmt.Sprintf("open %s: permission denied", credsFile) + actErr := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + ErrContains(t, expErr, actErr) +} + +// Test that if we can't write, we error out. +func TestWriteGitCreds_ErrIfCannotWrite(t *testing.T) { + logger := logging.NewNoopLogger(t) + credsFile := "/this/dir/does/not/exist/.git-credentials" // nolint: gosec + expErr := fmt.Sprintf("writing generated .git-credentials file with user, token and hostname to %s: open %s: no such file or directory", credsFile, credsFile) + actErr := vcs.WriteGitCreds("user", "token", "hostname", "/this/dir/does/not/exist", logger, false) + ErrEquals(t, expErr, actErr) +} + +// Test that git is actually configured to use the credentials +func TestWriteGitCreds_ConfigureGitCredentialHelper(t *testing.T) { + logger := logging.NewNoopLogger(t) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + err := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + Ok(t, err) + + expOutput := `store` + actOutput, err := exec.Command("git", "config", "--global", "credential.helper").Output() + Ok(t, err) + Equals(t, expOutput+"\n", string(actOutput)) +} + +// Test that git is configured to use https instead of ssh +func TestWriteGitCreds_ConfigureGitUrlOverride(t *testing.T) { + logger := logging.NewNoopLogger(t) + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + err := vcs.WriteGitCreds("user", "token", "hostname", tmp, logger, false) + Ok(t, err) + + expOutput := `ssh://git@hostname` + actOutput, err := exec.Command("git", "config", "--global", "url.https://user@hostname.insteadof").Output() + Ok(t, err) + Equals(t, expOutput+"\n", string(actOutput)) +} diff --git a/server/events/vcs/gitea/client.go b/server/events/vcs/gitea/client.go new file mode 100644 index 0000000000..d1fa00dc88 --- /dev/null +++ b/server/events/vcs/gitea/client.go @@ -0,0 +1,517 @@ +// Copyright 2024 Martijn van der Kleijn & Florian Beisel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitea + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "time" + + "code.gitea.io/sdk/gitea" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/logging" +) + +// Emergency break for Gitea pagination (just in case) +// Set to 500 to prevent runaway situations +// Value chosen purposely high, though randomly. +const giteaPaginationEBreak = 500 + +type GiteaClient struct { + giteaClient *gitea.Client + username string + token string + pageSize int + ctx context.Context +} + +type GiteaPRReviewSummary struct { + Reviews []GiteaReview +} + +type GiteaReview struct { + ID int64 + Body string + Reviewer string + State gitea.ReviewStateType // e.g., "APPROVED", "PENDING", "REQUEST_CHANGES" + SubmittedAt time.Time +} + +type GiteaPullGetter interface { + GetPullRequest(repo models.Repo, pullNum int) (*gitea.PullRequest, error) +} + +// NewClient builds a client that makes API calls to Gitea. httpClient is the +// client to use to make the requests, username and password are used as basic +// auth in the requests, baseURL is the API's baseURL, ex. https://corp.com:7990. +// Don't include the API version, ex. '/1.0'. +func NewClient(baseURL string, username string, token string, pagesize int, logger logging.SimpleLogging) (*GiteaClient, error) { + logger.Debug("Creating new Gitea client for: %s", baseURL) + + giteaClient, err := gitea.NewClient(baseURL, + gitea.SetToken(token), + gitea.SetUserAgent("atlantis"), + ) + + if err != nil { + return nil, errors.Wrap(err, "creating gitea client") + } + + return &GiteaClient{ + giteaClient: giteaClient, + username: username, + token: token, + pageSize: pagesize, + ctx: context.Background(), + }, nil +} + +func (c *GiteaClient) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*gitea.PullRequest, error) { + logger.Debug("Getting Gitea pull request %d", pullNum) + + pr, resp, err := c.giteaClient.GetPullRequest(repo.Owner, repo.Name, int64(pullNum)) + + if err != nil { + logger.Debug("GET /repos/%v/%v/pulls/%d returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) + return nil, err + } + + return pr, nil +} + +// GetModifiedFiles returns the names of files that were modified in the merge request +// relative to the repo root, e.g. parent/child/file.txt. +func (c *GiteaClient) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { + logger.Debug("Getting modified files for Gitea pull request %d", pull.Num) + + changedFiles := make([]string, 0) + page := 0 + nextPage := 1 + listOptions := gitea.ListPullRequestFilesOptions{ + ListOptions: gitea.ListOptions{ + Page: 1, + PageSize: c.pageSize, + }, + } + + for page < nextPage { + page = +1 + listOptions.ListOptions.Page = page + files, resp, err := c.giteaClient.ListPullRequestFiles(repo.Owner, repo.Name, int64(pull.Num), listOptions) + if err != nil { + logger.Debug("[page %d] GET /repos/%v/%v/pulls/%d/files returned: %v", page, repo.Owner, repo.Name, pull.Num, resp.StatusCode) + return nil, err + } + + for _, file := range files { + changedFiles = append(changedFiles, file.Filename) + } + + nextPage = resp.NextPage + + // Emergency break after giteaPaginationEBreak pages + if page >= giteaPaginationEBreak { + break + } + } + + return changedFiles, nil +} + +// CreateComment creates a comment on the merge request. As far as we're aware, Gitea has no built in max comment length right now. +func (c *GiteaClient) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { + logger.Debug("Creating comment on Gitea pull request %d", pullNum) + + opt := gitea.CreateIssueCommentOption{ + Body: comment, + } + + _, resp, err := c.giteaClient.CreateIssueComment(repo.Owner, repo.Name, int64(pullNum), opt) + + if err != nil { + logger.Debug("POST /repos/%v/%v/issues/%d/comments returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) + return err + } + + logger.Debug("Added comment to Gitea pull request %d: %s", pullNum, comment) + + return nil +} + +// ReactToComment adds a reaction to a comment. +func (c *GiteaClient) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error { + logger.Debug("Adding reaction to Gitea pull request comment %d", commentID) + + _, resp, err := c.giteaClient.PostIssueCommentReaction(repo.Owner, repo.Name, commentID, reaction) + + if err != nil { + logger.Debug("POST /repos/%v/%v/issues/comments/%d/reactions returned: %v", repo.Owner, repo.Name, commentID, resp.StatusCode) + return err + } + + return nil +} + +// HidePrevCommandComments hides the previous command comments from the pull +// request. +func (c *GiteaClient) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error { + logger.Debug("Hiding previous command comments on Gitea pull request %d", pullNum) + + var allComments []*gitea.Comment + + nextPage := int(1) + for { + // Initialize ListIssueCommentOptions with the current page + opts := gitea.ListIssueCommentOptions{ + ListOptions: gitea.ListOptions{ + Page: nextPage, + PageSize: c.pageSize, + }, + } + + comments, resp, err := c.giteaClient.ListIssueComments(repo.Owner, repo.Name, int64(pullNum), opts) + if err != nil { + logger.Debug("GET /repos/%v/%v/issues/%d/comments returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) + return err + } + + allComments = append(allComments, comments...) + + // Break the loop if there are no more pages to fetch + if resp.NextPage == 0 { + break + } + nextPage = resp.NextPage + } + + currentUser, resp, err := c.giteaClient.GetMyUserInfo() + if err != nil { + logger.Debug("GET /user returned: %v", resp.StatusCode) + return err + } + + summaryHeader := fmt.Sprintf("atlantis
-Plan discarded and unlocked!
-Locks
- {{ if .Locks }} - {{ $basePath := .CleanedBasePath }} - {{ range .Locks }} - - - - {{ end }} - {{ else }} -No locks found.
- {{ end }} -atlantis
-{{.LockKey}} Locked
Repo Owner: {{.RepoOwner}}Repo Name: {{.RepoName}}Pull Request Link: {{.PullRequestLink}}Locked By: {{.LockedBy}}Workspace: {{.Workspace}}atlantis
- -- {{ if .Target }} - Create a github app - {{ else }} - Github app created successfully! - {{ end }} -
-Visit {{ .URL }}/installations/new to install the app for your user or organization, then update the following values in your config and restart Atlantis:
- -{{ .ID }}{{ .Key }}{{ .WebhookSecret }}