├── .dockerignore ├── .github ├── release.yml ├── renovate.json └── workflows │ ├── delete-outdated-deployments.yaml │ ├── deployment-status.yaml │ ├── docker.yaml │ ├── go.yaml │ └── manifest.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── PROJECT ├── README.md ├── api └── v1 │ ├── applicationhealth_types.go │ ├── groupversion_info.go │ └── zz_generated.deepcopy.go ├── cmd └── main.go ├── config ├── crd │ ├── bases │ │ └── argocdcommenter.int128.github.io_applicationhealths.yaml │ ├── kustomization.yaml │ └── kustomizeconfig.yaml ├── default │ ├── cert_metrics_manager_patch.yaml │ ├── kustomization.yaml │ ├── manager_env_patch.yaml │ ├── manager_metrics_patch.yaml │ └── metrics_service.yaml ├── manager │ ├── kustomization.yaml │ └── manager.yaml ├── network-policy │ ├── allow-metrics-traffic.yaml │ └── kustomization.yaml ├── prometheus │ ├── kustomization.yaml │ ├── monitor.yaml │ └── monitor_tls_patch.yaml ├── rbac │ ├── applicationhealth_editor_role.yaml │ ├── applicationhealth_viewer_role.yaml │ ├── kustomization.yaml │ ├── leader_election_role.yaml │ ├── leader_election_role_binding.yaml │ ├── metrics_auth_role.yaml │ ├── metrics_auth_role_binding.yaml │ ├── metrics_reader_role.yaml │ ├── role.yaml │ ├── role_binding.yaml │ └── service_account.yaml └── samples │ └── kustomization.yaml ├── e2e_test ├── Makefile ├── README.md ├── applications │ ├── e2e-test-project.yaml │ ├── e2e-test-set.yaml │ └── kustomization.yaml ├── argocd │ ├── configmap │ │ └── argocd-cm.yaml │ ├── kustomization.yaml │ └── namespace │ │ └── argocd.yaml ├── controller │ ├── create-controller-manager-secret.sh │ └── kustomization.yaml ├── fixture │ ├── Makefile │ ├── app1 │ │ ├── deployment │ │ │ └── echoserver.yaml │ │ ├── kustomization.yaml │ │ ├── metadata.json │ │ └── namespace │ │ │ └── app1.yaml │ ├── app2 │ │ ├── deployment │ │ │ └── echoserver.yaml │ │ ├── kustomization.yaml │ │ ├── metadata.json │ │ └── namespace │ │ │ └── app2.yaml │ ├── app3 │ │ ├── cronjob │ │ │ └── echoserver.yaml │ │ ├── kustomization.yaml │ │ ├── metadata.json │ │ └── namespace │ │ │ └── app3.yaml │ └── deploy.sh └── waitforapp │ └── main.go ├── go.mod ├── go.sum ├── hack ├── boilerplate.go.txt └── fix_go_mod_replace.sh └── internal ├── argocd ├── application.go ├── application_test.go └── configmap.go ├── controller ├── applicationcomment_test.go ├── applicationdeletiondeployment_controller.go ├── applicationdeploymentstatus_test.go ├── applicationhealthcomment_controller.go ├── applicationhealthdeployment_controller.go ├── applicationphasecomment_controller.go ├── applicationphasedeployment_controller.go ├── eventfilter │ └── application.go ├── githubmock │ ├── endpoint.go │ └── server.go └── suite_test.go ├── github ├── client.go ├── comment.go ├── deployment.go ├── deployment_test.go ├── pull.go ├── types.go └── types_test.go └── notification ├── client.go ├── deletion.go ├── healthcomment.go ├── healthdeployment.go ├── phasecomment.go ├── phasedeployment.go ├── pull.go └── pull_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes 2 | changelog: 3 | categories: 4 | - title: Features 5 | labels: 6 | - '*' 7 | exclude: 8 | labels: 9 | - renovate 10 | - refactoring 11 | - title: Refactoring 12 | labels: 13 | - refactoring 14 | - title: Dependencies 15 | labels: 16 | - renovate 17 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>int128/renovate-base", 5 | "github>int128/go-renovate-config#v1.7.2", 6 | "github>int128/go-renovate-config:go-directive#v1.7.2", 7 | "github>int128/go-renovate-config:github-actions#v1.7.2", 8 | "github>int128/go-renovate-config:kubebuilder#v1.7.2", 9 | "github>int128/go-renovate-config:kustomization-github-releases#v1.7.2", 10 | "helpers:pinGitHubActionDigests" 11 | ], 12 | "packageRules": [ 13 | { 14 | "description": "Ignore replaced Go packages in github.com/argoproj/argo-cd/v2", 15 | "matchPackageNames": [ 16 | "github.com/argoproj/gitops-engine" 17 | ], 18 | "matchPackagePrefixes": [ 19 | "k8s.io/" 20 | ], 21 | "enabled": false 22 | }, 23 | { 24 | "matchPackagePrefixes": [ 25 | "github.com/google/go-github/v" 26 | ], 27 | "matchUpdateTypes": [ 28 | "major" 29 | ], 30 | "automerge": true 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/delete-outdated-deployments.yaml: -------------------------------------------------------------------------------- 1 | name: delete-outdated-deployments 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | pull_request: 7 | paths: 8 | - .github/workflows/delete-outdated-deployments.yaml 9 | 10 | jobs: 11 | delete: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 10 14 | steps: 15 | - uses: int128/delete-deployments-action@b077d8d1f7fc302d92025fa3162d39ea4f1d81d9 # v1.36.0 16 | -------------------------------------------------------------------------------- /.github/workflows/deployment-status.yaml: -------------------------------------------------------------------------------- 1 | name: deployment-status 2 | 3 | on: 4 | deployment_status: 5 | 6 | jobs: 7 | notify: 8 | name: notify / ${{ github.event.deployment.environment }} / ${{ github.event.deployment_status.state }} 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 10 11 | steps: 12 | - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 13 | with: 14 | script: | 15 | core.info(JSON.stringify(context, undefined, 2)) 16 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: docker 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - .github/workflows/docker.yaml 7 | - Dockerfile 8 | - go.* 9 | - api/** 10 | - cmd/** 11 | - internal/** 12 | - e2e_test/** 13 | - '!**/*_test.go' 14 | - '!**/*.md' 15 | push: 16 | branches: 17 | - main 18 | tags: 19 | - v* 20 | paths: 21 | - .github/workflows/docker.yaml 22 | - Dockerfile 23 | - go.* 24 | - cmd/** 25 | - api/** 26 | - internal/** 27 | - e2e_test/** 28 | - '!**/*_test.go' 29 | - '!**/*.md' 30 | 31 | jobs: 32 | build: 33 | runs-on: ubuntu-latest 34 | timeout-minutes: 10 35 | permissions: 36 | contents: read 37 | packages: write 38 | outputs: 39 | image-uri: ${{ steps.build-metadata.outputs.image-uri }} 40 | steps: 41 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 42 | - id: go-version 43 | run: echo "go-version=$(sed -ne '/^go /s/^go //p' go.mod)" >> "$GITHUB_OUTPUT" 44 | - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 45 | with: 46 | registry: ghcr.io 47 | username: ${{ github.actor }} 48 | password: ${{ secrets.GITHUB_TOKEN }} 49 | - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 50 | id: metadata 51 | with: 52 | images: ghcr.io/${{ github.repository }} 53 | - uses: int128/docker-build-cache-config-action@338206c80bf9eeb2b9694b7b4fc8c247c317e2a8 # v1.38.0 54 | id: cache 55 | with: 56 | image: ghcr.io/${{ github.repository }}/cache 57 | - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 58 | - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 59 | - uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0 60 | id: build 61 | with: 62 | push: true 63 | tags: ${{ steps.metadata.outputs.tags }} 64 | labels: ${{ steps.metadata.outputs.labels }} 65 | cache-from: ${{ steps.cache.outputs.cache-from }} 66 | cache-to: ${{ steps.cache.outputs.cache-to }} 67 | build-args: | 68 | go_version=${{ steps.go-version.outputs.go-version }} 69 | platforms: | 70 | linux/amd64 71 | linux/arm64 72 | - uses: int128/docker-build-metadata-action@fac3c879c58b212e339c5e959cabb865cbee0c6e # v1.0.0 73 | id: build-metadata 74 | with: 75 | metadata: ${{ steps.build.outputs.metadata }} 76 | 77 | release: 78 | needs: build 79 | uses: int128/kubebuilder-workflows/.github/workflows/release.yaml@e4ad31a438f188f70bde87827a8dbe466e623dea # v1.5.0 80 | permissions: 81 | contents: write 82 | with: 83 | controller-image: ${{ needs.build.outputs.image-uri }} 84 | 85 | e2e-test: 86 | runs-on: ubuntu-latest 87 | timeout-minutes: 10 88 | steps: 89 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 90 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 91 | with: 92 | go-version-file: go.mod 93 | cache-dependency-path: go.sum 94 | 95 | # Set up the test environment 96 | - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 97 | id: e2e-test-token 98 | with: 99 | app-id: ${{ secrets.E2E_SETUP_GITHUB_APP_ID }} 100 | private-key: ${{ secrets.E2E_SETUP_GITHUB_APP_PRIVATE_KEY }} 101 | repositories: argocd-commenter-e2e-test 102 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 103 | with: 104 | token: ${{ steps.e2e-test-token.outputs.token }} 105 | repository: int128/argocd-commenter-e2e-test 106 | path: e2e_test/argocd-commenter-e2e-test-repository 107 | - run: make -C e2e_test setup-fixture-branch 108 | 109 | - run: make -C e2e_test cluster 110 | - run: make -C e2e_test deploy-argocd 111 | 112 | - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 113 | id: metadata 114 | with: 115 | images: ghcr.io/${{ github.repository }} 116 | # set outputs.tags to single tag 117 | flavor: latest=false 118 | - uses: int128/wait-for-docker-image-action@4975bd239c55b16ec86bbcca9cab53ac056f8e07 # v1.8.0 119 | with: 120 | tags: ${{ steps.metadata.outputs.tags }} 121 | 122 | - run: make -C e2e_test deploy-controller 123 | env: 124 | CONTROLLER_IMAGE: ${{ steps.metadata.outputs.tags }} 125 | GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }} 126 | GITHUB_APP_INSTALLATION_ID: ${{ secrets.E2E_GITHUB_APP_INSTALLATION_ID }} 127 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_APP_PRIVATE_KEY }} 128 | 129 | - run: make -C e2e_test wait-for-apps 130 | 131 | # Run the test 132 | - uses: int128/deployment-action@2a79f0e37aee532b98292dc9cbf3dbc63ce01569 # v1.47.0 133 | id: deployment-app1 134 | with: 135 | environment-suffix: /app1 136 | - uses: int128/deployment-action@2a79f0e37aee532b98292dc9cbf3dbc63ce01569 # v1.47.0 137 | id: deployment-app2 138 | with: 139 | environment-suffix: /app2 140 | - uses: int128/deployment-action@2a79f0e37aee532b98292dc9cbf3dbc63ce01569 # v1.47.0 141 | id: deployment-app3 142 | with: 143 | environment-suffix: /app3 144 | 145 | - run: make -C e2e_test deploy-app1 146 | env: 147 | DEPLOYMENT_URL: ${{ steps.deployment-app1.outputs.url }} 148 | GITHUB_TOKEN: ${{ steps.e2e-test-token.outputs.token }} 149 | 150 | - run: make -C e2e_test restart-app1 151 | 152 | - run: make -C e2e_test deploy-app2 153 | env: 154 | DEPLOYMENT_URL: ${{ steps.deployment-app2.outputs.url }} 155 | GITHUB_TOKEN: ${{ steps.e2e-test-token.outputs.token }} 156 | 157 | - run: make -C e2e_test deploy-app3 158 | env: 159 | DEPLOYMENT_URL: ${{ steps.deployment-app3.outputs.url }} 160 | GITHUB_TOKEN: ${{ steps.e2e-test-token.outputs.token }} 161 | 162 | # Show logs 163 | - run: make -C e2e_test logs-controller 164 | if: always() 165 | - run: make -C e2e_test logs-argocd-application-controller 166 | if: always() 167 | - run: make -C e2e_test logs-argocd-applicationset-controller 168 | if: always() 169 | -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: go 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - .github/workflows/go.yaml 7 | - go.* 8 | - '**/*.go' 9 | - Makefile 10 | push: 11 | branches: 12 | - main 13 | paths: 14 | - .github/workflows/go.yaml 15 | - go.* 16 | - '**/*.go' 17 | - Makefile 18 | 19 | jobs: 20 | go: 21 | uses: int128/kubebuilder-workflows/.github/workflows/go.yaml@e4ad31a438f188f70bde87827a8dbe466e623dea # v1.5.0 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | with: 26 | go-version-file: go.mod 27 | cache-dependency-path: go.sum 28 | 29 | fix-go-mod-replace: 30 | runs-on: ubuntu-latest 31 | timeout-minutes: 10 32 | steps: 33 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 35 | with: 36 | go-version-file: go.mod 37 | cache-dependency-path: go.sum 38 | - run: bash hack/fix_go_mod_replace.sh 39 | - uses: int128/update-generated-files-action@759eb040026a5d97663b33e61243a56949f86bea # v2.59.0 40 | -------------------------------------------------------------------------------- /.github/workflows/manifest.yaml: -------------------------------------------------------------------------------- 1 | name: manifest 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - config/** 7 | - e2e_test/** 8 | - .github/workflows/manifest.yaml 9 | 10 | jobs: 11 | manifest: 12 | uses: int128/kubebuilder-workflows/.github/workflows/manifest.yaml@e6411bdb370db1900260cf86e13c20980c3eaa82 # v1 13 | permissions: 14 | contents: read 15 | pull-requests: write 16 | with: 17 | kustomization: | 18 | config/default/kustomization.yaml 19 | e2e_test/**/kustomization.yaml 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | bin/* 8 | Dockerfile.cross 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Go workspace file 17 | go.work 18 | 19 | # Kubernetes Generated files - skip generated files, except for vendored files 20 | !vendor/**/zz_generated.* 21 | 22 | # editor and IDE paraphernalia 23 | .idea 24 | .vscode 25 | *.swp 26 | *.swo 27 | *~ 28 | 29 | # e2e test 30 | /e2e_test/output/ 31 | /e2e_test/argocd-commenter-e2e-test-repository/ 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | ARG go_version 3 | FROM --platform=$BUILDPLATFORM golang:${go_version} AS builder 4 | 5 | WORKDIR /workspace 6 | # Copy the Go Modules manifests 7 | COPY go.mod go.mod 8 | COPY go.sum go.sum 9 | # cache deps before building and copying source so that we don't need to re-download as much 10 | # and so that source changes don't invalidate our downloaded layer 11 | RUN go mod download 12 | 13 | # Copy the go source 14 | COPY cmd/main.go cmd/main.go 15 | COPY api/ api/ 16 | COPY internal/ internal/ 17 | 18 | ARG TARGETOS 19 | ARG TARGETARCH 20 | 21 | # Build 22 | # the GOARCH has not a default value to allow the binary be built according to the host where the command 23 | # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO 24 | # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, 25 | # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. 26 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go 27 | 28 | # Use distroless as minimal base image to package the manager binary 29 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 30 | FROM gcr.io/distroless/static-debian12:nonroot 31 | WORKDIR / 32 | COPY --from=builder /workspace/manager . 33 | USER 65532:65532 34 | 35 | ENTRYPOINT ["/manager"] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Hidetake Iwata 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Image URL to use all building/pushing image targets 2 | IMG ?= controller:latest 3 | 4 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 5 | ifeq (,$(shell go env GOBIN)) 6 | GOBIN=$(shell go env GOPATH)/bin 7 | else 8 | GOBIN=$(shell go env GOBIN) 9 | endif 10 | 11 | # CONTAINER_TOOL defines the container tool to be used for building images. 12 | # Be aware that the target commands are only tested with Docker which is 13 | # scaffolded by default. However, you might want to replace it to use other 14 | # tools. (i.e. podman) 15 | CONTAINER_TOOL ?= docker 16 | 17 | # Setting SHELL to bash allows bash commands to be executed by recipes. 18 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 19 | SHELL = /usr/bin/env bash -o pipefail 20 | .SHELLFLAGS = -ec 21 | 22 | .PHONY: all 23 | all: build 24 | 25 | ##@ General 26 | 27 | # The help target prints out all targets with their descriptions organized 28 | # beneath their categories. The categories are represented by '##@' and the 29 | # target descriptions by '##'. The awk command is responsible for reading the 30 | # entire set of makefiles included in this invocation, looking for lines of the 31 | # file as xyz: ## something, and then pretty-format the target and help. Then, 32 | # if there's a line with ##@ something, that gets pretty-printed as a category. 33 | # More info on the usage of ANSI control characters for terminal formatting: 34 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 35 | # More info on the awk command: 36 | # http://linuxcommand.org/lc3_adv_awk.php 37 | 38 | .PHONY: help 39 | help: ## Display this help. 40 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 41 | 42 | ##@ Development 43 | 44 | .PHONY: manifests 45 | manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. 46 | $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases 47 | 48 | .PHONY: generate 49 | generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. 50 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 51 | 52 | .PHONY: fmt 53 | fmt: ## Run go fmt against code. 54 | go fmt ./... 55 | 56 | .PHONY: vet 57 | vet: ## Run go vet against code. 58 | go vet ./... 59 | 60 | .PHONY: test 61 | test: manifests generate fmt vet setup-envtest ## Run tests. 62 | KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out 63 | 64 | # TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'. 65 | # The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. 66 | # CertManager is installed by default; skip with: 67 | # - CERT_MANAGER_INSTALL_SKIP=true 68 | .PHONY: test-e2e 69 | test-e2e: manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. 70 | @command -v kind >/dev/null 2>&1 || { \ 71 | echo "Kind is not installed. Please install Kind manually."; \ 72 | exit 1; \ 73 | } 74 | @kind get clusters | grep -q 'kind' || { \ 75 | echo "No Kind cluster is running. Please start a Kind cluster before running the e2e tests."; \ 76 | exit 1; \ 77 | } 78 | go test ./test/e2e/ -v -ginkgo.v 79 | 80 | .PHONY: lint 81 | lint: golangci-lint ## Run golangci-lint linter 82 | $(GOLANGCI_LINT) run 83 | 84 | .PHONY: lint-fix 85 | lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes 86 | $(GOLANGCI_LINT) run --fix 87 | 88 | .PHONY: lint-config 89 | lint-config: golangci-lint ## Verify golangci-lint linter configuration 90 | $(GOLANGCI_LINT) config verify 91 | 92 | ##@ Build 93 | 94 | .PHONY: build 95 | build: manifests generate fmt vet ## Build manager binary. 96 | go build -o bin/manager cmd/main.go 97 | 98 | .PHONY: run 99 | run: manifests generate fmt vet ## Run a controller from your host. 100 | go run ./cmd/main.go 101 | 102 | # If you wish to build the manager image targeting other platforms you can use the --platform flag. 103 | # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. 104 | # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 105 | .PHONY: docker-build 106 | docker-build: ## Build docker image with the manager. 107 | $(CONTAINER_TOOL) build -t ${IMG} . 108 | 109 | .PHONY: docker-push 110 | docker-push: ## Push docker image with the manager. 111 | $(CONTAINER_TOOL) push ${IMG} 112 | 113 | # PLATFORMS defines the target platforms for the manager image be built to provide support to multiple 114 | # architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: 115 | # - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ 116 | # - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 117 | # - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) 118 | # To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. 119 | PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le 120 | .PHONY: docker-buildx 121 | docker-buildx: ## Build and push docker image for the manager for cross-platform support 122 | # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile 123 | sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross 124 | - $(CONTAINER_TOOL) buildx create --name kubebuilder-updates-builder 125 | $(CONTAINER_TOOL) buildx use kubebuilder-updates-builder 126 | - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . 127 | - $(CONTAINER_TOOL) buildx rm kubebuilder-updates-builder 128 | rm Dockerfile.cross 129 | 130 | .PHONY: build-installer 131 | build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. 132 | mkdir -p dist 133 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 134 | $(KUSTOMIZE) build config/default > dist/install.yaml 135 | 136 | ##@ Deployment 137 | 138 | ifndef ignore-not-found 139 | ignore-not-found = false 140 | endif 141 | 142 | .PHONY: install 143 | install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. 144 | $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - 145 | 146 | .PHONY: uninstall 147 | uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 148 | $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - 149 | 150 | .PHONY: deploy 151 | deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. 152 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 153 | $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - 154 | 155 | .PHONY: undeploy 156 | undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 157 | $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - 158 | 159 | ##@ Dependencies 160 | 161 | ## Location to install dependencies to 162 | LOCALBIN ?= $(shell pwd)/bin 163 | $(LOCALBIN): 164 | mkdir -p $(LOCALBIN) 165 | 166 | ## Tool Binaries 167 | KUBECTL ?= kubectl 168 | KUSTOMIZE ?= $(LOCALBIN)/kustomize 169 | CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen 170 | ENVTEST ?= $(LOCALBIN)/setup-envtest 171 | GOLANGCI_LINT = $(LOCALBIN)/golangci-lint 172 | 173 | ## Tool Versions 174 | KUSTOMIZE_VERSION ?= v5.5.0 175 | CONTROLLER_TOOLS_VERSION ?= v0.17.2 176 | #ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20) 177 | ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}') 178 | #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) 179 | ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') 180 | GOLANGCI_LINT_VERSION ?= v1.64.8 181 | 182 | .PHONY: kustomize 183 | kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. 184 | $(KUSTOMIZE): $(LOCALBIN) 185 | $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) 186 | 187 | .PHONY: controller-gen 188 | controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. 189 | $(CONTROLLER_GEN): $(LOCALBIN) 190 | $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) 191 | 192 | .PHONY: setup-envtest 193 | setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory. 194 | @echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..." 195 | @$(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path || { \ 196 | echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \ 197 | exit 1; \ 198 | } 199 | 200 | .PHONY: envtest 201 | envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. 202 | $(ENVTEST): $(LOCALBIN) 203 | $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) 204 | 205 | .PHONY: golangci-lint 206 | golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. 207 | $(GOLANGCI_LINT): $(LOCALBIN) 208 | $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) 209 | 210 | # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist 211 | # $1 - target path with name of binary 212 | # $2 - package url which can be installed 213 | # $3 - specific version of package 214 | define go-install-tool 215 | @[ -f "$(1)-$(3)" ] || { \ 216 | set -e; \ 217 | package=$(2)@$(3) ;\ 218 | echo "Downloading $${package}" ;\ 219 | rm -f $(1) || true ;\ 220 | GOBIN=$(LOCALBIN) go install $${package} ;\ 221 | mv $(1) $(1)-$(3) ;\ 222 | } ;\ 223 | ln -sf $(1)-$(3) $(1) 224 | endef 225 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | # Code generated by tool. DO NOT EDIT. 2 | # This file is used to track the info used to scaffold your project 3 | # and allow the plugins properly work. 4 | # More info: https://book.kubebuilder.io/reference/project-config.html 5 | domain: int128.github.io 6 | layout: 7 | - go.kubebuilder.io/v4 8 | projectName: argocd-commenter 9 | repo: github.com/int128/argocd-commenter 10 | resources: 11 | - api: 12 | crdVersion: v1 13 | namespaced: true 14 | controller: false 15 | domain: int128.github.io 16 | group: argocdcommenter 17 | kind: ApplicationHealth 18 | path: github.com/int128/argocd-commenter/api/v1 19 | version: v1 20 | - controller: true 21 | domain: int128.github.io 22 | group: argocdcommenter 23 | kind: ApplicationPhaseComment 24 | version: v1 25 | - controller: true 26 | domain: int128.github.io 27 | group: argocdcommenter 28 | kind: ApplicationHealthComment 29 | version: v1 30 | - controller: true 31 | domain: int128.github.io 32 | group: argocdcommenter 33 | kind: ApplicationHealthDeployment 34 | version: v1 35 | - controller: true 36 | domain: int128.github.io 37 | group: argocdcommenter 38 | kind: ApplicationPhaseDeployment 39 | version: v1 40 | - controller: true 41 | domain: int128.github.io 42 | group: argocdcommenter 43 | kind: ApplicationDeletionDeployment 44 | version: v1 45 | version: "3" 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # argocd-commenter [![docker](https://github.com/int128/argocd-commenter/actions/workflows/docker.yaml/badge.svg)](https://github.com/int128/argocd-commenter/actions/workflows/docker.yaml) 2 | 3 | This is a Kubernetes Controller to notify a change of Argo CD Application status. 4 | 5 | ## Example: Pull Request notification 6 | 7 | In the [GitOps](https://www.weave.works/technologies/gitops/) way, you merge a pull request to deploy a change to Kubernetes cluster. 8 | argocd-commenter allows you to receive a notification comment after merging. 9 | 10 | When an Application is syncing, synced or healthy, argocd-commenter will create a comment. 11 | 12 | image 13 | 14 | When the sync was failed, argocd-commenter will create a comment. 15 | 16 | image 17 | 18 | See the examples in [e2e-test fixtures](https://github.com/int128/argocd-commenter-e2e-test/pulls?q=is%3Apr+is%3Aclosed). 19 | 20 | ### Sequence diagram 21 | 22 | Here is the sequence diagram of the deployment flow. 23 | 24 | ```mermaid 25 | sequenceDiagram 26 | actor User 27 | 28 | User ->>+ GitHub Repository: git push 29 | GitHub Repository -->>- User: pushed 30 | 31 | loop Argo CD reconciler 32 | Argo CD Application ->>+ GitHub Repository: git checkout 33 | GitHub Repository -->>- Argo CD Application: manifest 34 | note over Argo CD Application: Out of Sync 35 | Argo CD Application ->>+ Kubernetes Deployment: kubectl apply 36 | note over Argo CD Application: Syncing 37 | Kubernetes Deployment -->>- Argo CD Application: applied 38 | note over Argo CD Application: Synced 39 | end 40 | 41 | loop Kubernetes reconciler 42 | Kubernetes Deployment ->>+ Kubernetes Pod: create 43 | note over Argo CD Application: Progressing 44 | Kubernetes Pod -->>- Kubernetes Deployment: ready 45 | note over Argo CD Application: Healthy 46 | end 47 | ``` 48 | 49 | ## Example: GitHub Deployment notification 50 | 51 | In a complex deployment flow, you can receive a notification using GitHub [Deployments](https://docs.github.com/en/rest/reference/deployments) API. 52 | For example, when you deploy a preview environment for a pull request, you can receive the deployment statuses. 53 | 54 | To receive a deployment status, set the following annotation to the Argo CD Application: 55 | 56 | ```yaml 57 | apiVersion: argoproj.io/v1alpha1 58 | kind: Application 59 | metadata: 60 | annotations: 61 | argocd-commenter.int128.github.io/deployment-url: https://api.github.com/repos/OWNER/REPO/deployments/ID 62 | ``` 63 | 64 | Here is an example of workflow to deploy a preview environment: 65 | 66 | ```yaml 67 | jobs: 68 | deploy: 69 | steps: 70 | # Create a deployment 71 | - uses: int128/deployment-action@v1 72 | id: deployment 73 | 74 | # Generate the manifests for a preview environment 75 | - uses: actions/checkout@v2 76 | with: 77 | repository: your/manifests-repository 78 | path: manifests-repository 79 | token: # PAT or GitHub App token is required to write 80 | - run: | 81 | cp -a manifests "manifests-repository/pr-${{ github.event.pull_request.number }}" 82 | cd "manifests-repository/pr-${{ github.event.pull_request.number }}" 83 | sed -e 's|DEPLOYMENT_URL|${{ steps.deployment.outputs.url }}|g' applications/*.yaml 84 | 85 | # Push the manifests for a preview environment 86 | - run: | 87 | git add . 88 | git commit -m 'Deploy pr-${{ github.event.pull_request.number }}' 89 | git push manifests-repository main 90 | ``` 91 | 92 | When the Application status is changed, argocd-commenter will create a deployment status. 93 | 94 | ![image](https://user-images.githubusercontent.com/321266/139166278-e74f6d1b-c722-430f-850c-2f7135e251d6.png) 95 | 96 | See the [e2e test](https://github.com/int128/argocd-commenter/blob/main/.github/workflows/docker.yaml) for details. 97 | 98 | ### Sequence diagram 99 | 100 | Here is the sequence diagram of the deployment flow. 101 | 102 | ```mermaid 103 | sequenceDiagram 104 | actor User 105 | 106 | User ->>+ Application Repository: git push 107 | Application Repository -->>- User: pushed 108 | 109 | Application Repository ->>+ GitHub Actions: start 110 | GitHub Actions ->>+ GitHub Deployment: create 111 | GitHub Deployment -->>- GitHub Actions: created 112 | note over GitHub Deployment: Pending 113 | GitHub Actions ->>+ Manifest Repository: git push 114 | Manifest Repository -->>- GitHub Actions: pushed 115 | GitHub Actions -->>- Application Repository: success 116 | 117 | loop Argo CD reconciler 118 | Argo CD Application ->>+ Manifest Repository: git checkout 119 | Manifest Repository -->>- Argo CD Application: manifest 120 | note over Argo CD Application: Out of Sync 121 | note over GitHub Deployment: Queued 122 | Argo CD Application ->>+ Kubernetes Deployment: kubectl apply 123 | note over Argo CD Application: Syncing 124 | Kubernetes Deployment -->>- Argo CD Application: applied 125 | note over Argo CD Application: Synced 126 | note over GitHub Deployment: In progress 127 | end 128 | 129 | loop Kubernetes reconciler 130 | Kubernetes Deployment ->>+ Kubernetes Pod: create 131 | note over Argo CD Application: Progressing 132 | Kubernetes Pod -->>- Kubernetes Deployment: ready 133 | note over Argo CD Application: Healthy 134 | note over GitHub Deployment: Active 135 | end 136 | ``` 137 | 138 | ## Getting Started 139 | 140 | ### Prerequisite 141 | 142 | Argo CD is running in your Kubernetes cluster. 143 | 144 | ### Setup 145 | 146 | To deploy the manifest: 147 | 148 | ```shell 149 | kubectl apply -f https://github.com/int128/argocd-commenter/releases/download/v1.8.0/argocd-commenter.yaml 150 | ``` 151 | 152 | You need to create either Personal Access Token or GitHub App. 153 | 154 | - Personal Access Token 155 | - Belong to a user 156 | - Share the rate limit in a user 157 | - GitHub App 158 | - Belong to a user or organization 159 | - Have each rate limit for an installation 160 | 161 | #### Option 1: Using a Personal Access Token 162 | 163 | 1. Open https://github.com/settings/tokens 164 | 1. Generate a new token 165 | 1. Create a secret as follows: 166 | ```shell 167 | kubectl -n argocd-commenter-system create secret generic controller-manager \ 168 | --from-literal="GITHUB_TOKEN=$YOUR_PERSONAL_ACCESS_TOKEN" 169 | ``` 170 | 171 | #### Option 2: Using a GitHub App 172 | 173 | 1. Create your GitHub App from either link: 174 | - For a user: https://github.com/settings/apps/new?name=argocd-commenter&url=https://github.com/int128/argocd-commenter&webhook_active=false&contents=read&pull_requests=write 175 | - For an organization: https://github.com/organizations/:org/settings/apps/new?name=argocd-commenter&url=https://github.com/int128/argocd-commenter&webhook_active=false&contents=read&pull_requests=write (replace `:org` with your organization) 176 | 1. Get the **App ID** from the setting page 177 | 1. [Download a private key of the GitHub App](https://docs.github.com/en/developers/apps/authenticating-with-github-apps) 178 | 1. [Set a custom badge for the GitHub App](https://docs.github.com/en/developers/apps/creating-a-custom-badge-for-your-github-app) 179 | - Logo of Argo CD is available in [CNCF Branding](https://cncf-branding.netlify.app/projects/argo/) 180 | 1. [Install your GitHub App on your repository or organization](https://docs.github.com/en/developers/apps/installing-github-apps) 181 | 1. Get the **Installation ID** from the URL, like `https://github.com/settings/installations/ID` 182 | 1. Create a secret as follows: 183 | ```shell 184 | kubectl -n argocd-commenter-system create secret generic controller-manager \ 185 | --from-literal="GITHUB_APP_ID=$YOUR_GITHUB_APP_ID" \ 186 | --from-literal="GITHUB_APP_INSTALLATION_ID=$YOUR_GITHUB_APP_INSTALLATION_ID" \ 187 | --from-file="GITHUB_APP_PRIVATE_KEY=/path/to/private-key.pem" 188 | ``` 189 | 190 | ### Verify setup 191 | 192 | Make sure the controller is running. 193 | 194 | ```shell 195 | kubectl -n argocd-commenter-system rollout status deployment argocd-commenter-controller-manager 196 | ``` 197 | 198 | ## Configuration 199 | 200 | ### GitHub Enterprise Server 201 | 202 | Set the environment variable `GITHUB_ENTERPRISE_URL`. 203 | 204 | ```shell 205 | kubectl -n argocd-commenter-system create secret generic controller-manager \ 206 | --from-literal="GITHUB_TOKEN=$YOUR_PERSONAL_ACCESS_TOKEN" \ 207 | --from-literal="GITHUB_ENTERPRISE_URL=$YOUR_GITHUB_ENTERPRISE_URL" 208 | ``` 209 | 210 | ## Contribution 211 | 212 | This is an open source software. Feel free to contribute to it. 213 | -------------------------------------------------------------------------------- /api/v1/applicationhealth_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1 18 | 19 | import ( 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | ) 22 | 23 | // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 24 | // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. 25 | 26 | // ApplicationHealthSpec defines the desired state of ApplicationHealth 27 | type ApplicationHealthSpec struct { 28 | } 29 | 30 | // ApplicationHealthStatus defines the observed state of ApplicationHealth 31 | type ApplicationHealthStatus struct { 32 | // Last revision when the application is healthy. 33 | // +optional 34 | LastHealthyRevision string `json:"lastHealthyRevision,omitempty"` 35 | } 36 | 37 | // +kubebuilder:object:root=true 38 | // +kubebuilder:subresource:status 39 | 40 | // ApplicationHealth is the Schema for the applicationhealths API 41 | type ApplicationHealth struct { 42 | metav1.TypeMeta `json:",inline"` 43 | metav1.ObjectMeta `json:"metadata,omitempty"` 44 | 45 | Spec ApplicationHealthSpec `json:"spec,omitempty"` 46 | Status ApplicationHealthStatus `json:"status,omitempty"` 47 | } 48 | 49 | // +kubebuilder:object:root=true 50 | 51 | // ApplicationHealthList contains a list of ApplicationHealth 52 | type ApplicationHealthList struct { 53 | metav1.TypeMeta `json:",inline"` 54 | metav1.ListMeta `json:"metadata,omitempty"` 55 | Items []ApplicationHealth `json:"items"` 56 | } 57 | 58 | func init() { 59 | SchemeBuilder.Register(&ApplicationHealth{}, &ApplicationHealthList{}) 60 | } 61 | -------------------------------------------------------------------------------- /api/v1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package v1 contains API Schema definitions for the argocdcommenter v1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=argocdcommenter.int128.github.io 20 | package v1 21 | 22 | import ( 23 | "k8s.io/apimachinery/pkg/runtime/schema" 24 | "sigs.k8s.io/controller-runtime/pkg/scheme" 25 | ) 26 | 27 | var ( 28 | // GroupVersion is group version used to register these objects 29 | GroupVersion = schema.GroupVersion{Group: "argocdcommenter.int128.github.io", Version: "v1"} 30 | 31 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme. 32 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 33 | 34 | // AddToScheme adds the types in this group-version to the given scheme. 35 | AddToScheme = SchemeBuilder.AddToScheme 36 | ) 37 | -------------------------------------------------------------------------------- /api/v1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | /* 4 | Copyright 2025. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | // Code generated by controller-gen. DO NOT EDIT. 20 | 21 | package v1 22 | 23 | import ( 24 | runtime "k8s.io/apimachinery/pkg/runtime" 25 | ) 26 | 27 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 28 | func (in *ApplicationHealth) DeepCopyInto(out *ApplicationHealth) { 29 | *out = *in 30 | out.TypeMeta = in.TypeMeta 31 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 32 | out.Spec = in.Spec 33 | out.Status = in.Status 34 | } 35 | 36 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationHealth. 37 | func (in *ApplicationHealth) DeepCopy() *ApplicationHealth { 38 | if in == nil { 39 | return nil 40 | } 41 | out := new(ApplicationHealth) 42 | in.DeepCopyInto(out) 43 | return out 44 | } 45 | 46 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 47 | func (in *ApplicationHealth) DeepCopyObject() runtime.Object { 48 | if c := in.DeepCopy(); c != nil { 49 | return c 50 | } 51 | return nil 52 | } 53 | 54 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 55 | func (in *ApplicationHealthList) DeepCopyInto(out *ApplicationHealthList) { 56 | *out = *in 57 | out.TypeMeta = in.TypeMeta 58 | in.ListMeta.DeepCopyInto(&out.ListMeta) 59 | if in.Items != nil { 60 | in, out := &in.Items, &out.Items 61 | *out = make([]ApplicationHealth, len(*in)) 62 | for i := range *in { 63 | (*in)[i].DeepCopyInto(&(*out)[i]) 64 | } 65 | } 66 | } 67 | 68 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationHealthList. 69 | func (in *ApplicationHealthList) DeepCopy() *ApplicationHealthList { 70 | if in == nil { 71 | return nil 72 | } 73 | out := new(ApplicationHealthList) 74 | in.DeepCopyInto(out) 75 | return out 76 | } 77 | 78 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 79 | func (in *ApplicationHealthList) DeepCopyObject() runtime.Object { 80 | if c := in.DeepCopy(); c != nil { 81 | return c 82 | } 83 | return nil 84 | } 85 | 86 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 87 | func (in *ApplicationHealthSpec) DeepCopyInto(out *ApplicationHealthSpec) { 88 | *out = *in 89 | } 90 | 91 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationHealthSpec. 92 | func (in *ApplicationHealthSpec) DeepCopy() *ApplicationHealthSpec { 93 | if in == nil { 94 | return nil 95 | } 96 | out := new(ApplicationHealthSpec) 97 | in.DeepCopyInto(out) 98 | return out 99 | } 100 | 101 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 102 | func (in *ApplicationHealthStatus) DeepCopyInto(out *ApplicationHealthStatus) { 103 | *out = *in 104 | } 105 | 106 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationHealthStatus. 107 | func (in *ApplicationHealthStatus) DeepCopy() *ApplicationHealthStatus { 108 | if in == nil { 109 | return nil 110 | } 111 | out := new(ApplicationHealthStatus) 112 | in.DeepCopyInto(out) 113 | return out 114 | } 115 | -------------------------------------------------------------------------------- /config/crd/bases/argocdcommenter.int128.github.io_applicationhealths.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.2 7 | name: applicationhealths.argocdcommenter.int128.github.io 8 | spec: 9 | group: argocdcommenter.int128.github.io 10 | names: 11 | kind: ApplicationHealth 12 | listKind: ApplicationHealthList 13 | plural: applicationhealths 14 | singular: applicationhealth 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: ApplicationHealth is the Schema for the applicationhealths API 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: ApplicationHealthSpec defines the desired state of ApplicationHealth 41 | type: object 42 | status: 43 | description: ApplicationHealthStatus defines the observed state of ApplicationHealth 44 | properties: 45 | lastHealthyRevision: 46 | description: Last revision when the application is healthy. 47 | type: string 48 | type: object 49 | type: object 50 | served: true 51 | storage: true 52 | subresources: 53 | status: {} 54 | -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # This kustomization.yaml is not intended to be run by itself, 2 | # since it depends on service name and namespace that are out of this kustomize package. 3 | # It should be run by config/default 4 | resources: 5 | - bases/argocdcommenter.int128.github.io_applicationhealths.yaml 6 | # +kubebuilder:scaffold:crdkustomizeresource 7 | 8 | patches: 9 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 10 | # patches here are for enabling the conversion webhook for each CRD 11 | #- path: patches/webhook_in_applicationhealths.yaml 12 | # +kubebuilder:scaffold:crdkustomizewebhookpatch 13 | 14 | # [WEBHOOK] To enable webhook, uncomment the following section 15 | # the following config is for teaching kustomize how to do kustomization for CRDs. 16 | #configurations: 17 | #- kustomizeconfig.yaml 18 | -------------------------------------------------------------------------------- /config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This file is for teaching kustomize how to substitute name and namespace reference in CRD 2 | nameReference: 3 | - kind: Service 4 | version: v1 5 | fieldSpecs: 6 | - kind: CustomResourceDefinition 7 | version: v1 8 | group: apiextensions.k8s.io 9 | path: spec/conversion/webhook/clientConfig/service/name 10 | 11 | namespace: 12 | - kind: CustomResourceDefinition 13 | version: v1 14 | group: apiextensions.k8s.io 15 | path: spec/conversion/webhook/clientConfig/service/namespace 16 | create: false 17 | 18 | varReference: 19 | - path: metadata/annotations 20 | -------------------------------------------------------------------------------- /config/default/cert_metrics_manager_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch adds the args, volumes, and ports to allow the manager to use the metrics-server certs. 2 | 3 | # Add the volumeMount for the metrics-server certs 4 | - op: add 5 | path: /spec/template/spec/containers/0/volumeMounts/- 6 | value: 7 | mountPath: /tmp/k8s-metrics-server/metrics-certs 8 | name: metrics-certs 9 | readOnly: true 10 | 11 | # Add the --metrics-cert-path argument for the metrics server 12 | - op: add 13 | path: /spec/template/spec/containers/0/args/- 14 | value: --metrics-cert-path=/tmp/k8s-metrics-server/metrics-certs 15 | 16 | # Add the metrics-server certs volume configuration 17 | - op: add 18 | path: /spec/template/spec/volumes/- 19 | value: 20 | name: metrics-certs 21 | secret: 22 | secretName: metrics-server-cert 23 | optional: false 24 | items: 25 | - key: ca.crt 26 | path: ca.crt 27 | - key: tls.crt 28 | path: tls.crt 29 | - key: tls.key 30 | path: tls.key 31 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: argocd-commenter-system 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: argocd-commenter- 10 | 11 | # Labels to add to all resources and selectors. 12 | #labels: 13 | #- includeSelectors: true 14 | # pairs: 15 | # someName: someValue 16 | 17 | resources: 18 | - ../crd 19 | - ../rbac 20 | - ../manager 21 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 22 | # crd/kustomization.yaml 23 | #- ../webhook 24 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 25 | #- ../certmanager 26 | # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. 27 | #- ../prometheus 28 | # [METRICS] Expose the controller manager metrics service. 29 | - metrics_service.yaml 30 | # [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy. 31 | # Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics. 32 | # Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will 33 | # be able to communicate with the Webhook Server. 34 | #- ../network-policy 35 | 36 | # Uncomment the patches line if you enable Metrics 37 | patches: 38 | - path: manager_env_patch.yaml 39 | 40 | # [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. 41 | # More info: https://book.kubebuilder.io/reference/metrics 42 | - path: manager_metrics_patch.yaml 43 | target: 44 | kind: Deployment 45 | 46 | # Uncomment the patches line if you enable Metrics and CertManager 47 | # [METRICS-WITH-CERTS] To enable metrics protected with certManager, uncomment the following line. 48 | # This patch will protect the metrics with certManager self-signed certs. 49 | #- path: cert_metrics_manager_patch.yaml 50 | # target: 51 | # kind: Deployment 52 | 53 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 54 | # crd/kustomization.yaml 55 | #- path: manager_webhook_patch.yaml 56 | # target: 57 | # kind: Deployment 58 | 59 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 60 | # Uncomment the following replacements to add the cert-manager CA injection annotations 61 | #replacements: 62 | # - source: # Uncomment the following block to enable certificates for metrics 63 | # kind: Service 64 | # version: v1 65 | # name: controller-manager-metrics-service 66 | # fieldPath: metadata.name 67 | # targets: 68 | # - select: 69 | # kind: Certificate 70 | # group: cert-manager.io 71 | # version: v1 72 | # name: metrics-certs 73 | # fieldPaths: 74 | # - spec.dnsNames.0 75 | # - spec.dnsNames.1 76 | # options: 77 | # delimiter: '.' 78 | # index: 0 79 | # create: true 80 | # - select: # Uncomment the following to set the Service name for TLS config in Prometheus ServiceMonitor 81 | # kind: ServiceMonitor 82 | # group: monitoring.coreos.com 83 | # version: v1 84 | # name: controller-manager-metrics-monitor 85 | # fieldPaths: 86 | # - spec.endpoints.0.tlsConfig.serverName 87 | # options: 88 | # delimiter: '.' 89 | # index: 0 90 | # create: true 91 | # 92 | # - source: 93 | # kind: Service 94 | # version: v1 95 | # name: controller-manager-metrics-service 96 | # fieldPath: metadata.namespace 97 | # targets: 98 | # - select: 99 | # kind: Certificate 100 | # group: cert-manager.io 101 | # version: v1 102 | # name: metrics-certs 103 | # fieldPaths: 104 | # - spec.dnsNames.0 105 | # - spec.dnsNames.1 106 | # options: 107 | # delimiter: '.' 108 | # index: 1 109 | # create: true 110 | # - select: # Uncomment the following to set the Service namespace for TLS in Prometheus ServiceMonitor 111 | # kind: ServiceMonitor 112 | # group: monitoring.coreos.com 113 | # version: v1 114 | # name: controller-manager-metrics-monitor 115 | # fieldPaths: 116 | # - spec.endpoints.0.tlsConfig.serverName 117 | # options: 118 | # delimiter: '.' 119 | # index: 1 120 | # create: true 121 | # 122 | # - source: # Uncomment the following block if you have any webhook 123 | # kind: Service 124 | # version: v1 125 | # name: webhook-service 126 | # fieldPath: .metadata.name # Name of the service 127 | # targets: 128 | # - select: 129 | # kind: Certificate 130 | # group: cert-manager.io 131 | # version: v1 132 | # name: serving-cert 133 | # fieldPaths: 134 | # - .spec.dnsNames.0 135 | # - .spec.dnsNames.1 136 | # options: 137 | # delimiter: '.' 138 | # index: 0 139 | # create: true 140 | # - source: 141 | # kind: Service 142 | # version: v1 143 | # name: webhook-service 144 | # fieldPath: .metadata.namespace # Namespace of the service 145 | # targets: 146 | # - select: 147 | # kind: Certificate 148 | # group: cert-manager.io 149 | # version: v1 150 | # name: serving-cert 151 | # fieldPaths: 152 | # - .spec.dnsNames.0 153 | # - .spec.dnsNames.1 154 | # options: 155 | # delimiter: '.' 156 | # index: 1 157 | # create: true 158 | # 159 | # - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation) 160 | # kind: Certificate 161 | # group: cert-manager.io 162 | # version: v1 163 | # name: serving-cert # This name should match the one in certificate.yaml 164 | # fieldPath: .metadata.namespace # Namespace of the certificate CR 165 | # targets: 166 | # - select: 167 | # kind: ValidatingWebhookConfiguration 168 | # fieldPaths: 169 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 170 | # options: 171 | # delimiter: '/' 172 | # index: 0 173 | # create: true 174 | # - source: 175 | # kind: Certificate 176 | # group: cert-manager.io 177 | # version: v1 178 | # name: serving-cert 179 | # fieldPath: .metadata.name 180 | # targets: 181 | # - select: 182 | # kind: ValidatingWebhookConfiguration 183 | # fieldPaths: 184 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 185 | # options: 186 | # delimiter: '/' 187 | # index: 1 188 | # create: true 189 | # 190 | # - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting ) 191 | # kind: Certificate 192 | # group: cert-manager.io 193 | # version: v1 194 | # name: serving-cert 195 | # fieldPath: .metadata.namespace # Namespace of the certificate CR 196 | # targets: 197 | # - select: 198 | # kind: MutatingWebhookConfiguration 199 | # fieldPaths: 200 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 201 | # options: 202 | # delimiter: '/' 203 | # index: 0 204 | # create: true 205 | # - source: 206 | # kind: Certificate 207 | # group: cert-manager.io 208 | # version: v1 209 | # name: serving-cert 210 | # fieldPath: .metadata.name 211 | # targets: 212 | # - select: 213 | # kind: MutatingWebhookConfiguration 214 | # fieldPaths: 215 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 216 | # options: 217 | # delimiter: '/' 218 | # index: 1 219 | # create: true 220 | # 221 | # - source: # Uncomment the following block if you have a ConversionWebhook (--conversion) 222 | # kind: Certificate 223 | # group: cert-manager.io 224 | # version: v1 225 | # name: serving-cert 226 | # fieldPath: .metadata.namespace # Namespace of the certificate CR 227 | # targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD. 228 | # +kubebuilder:scaffold:crdkustomizecainjectionns 229 | # - source: 230 | # kind: Certificate 231 | # group: cert-manager.io 232 | # version: v1 233 | # name: serving-cert 234 | # fieldPath: .metadata.name 235 | # targets: # Do not remove or uncomment the following scaffold marker; required to generate code for target CRD. 236 | # +kubebuilder:scaffold:crdkustomizecainjectionname 237 | -------------------------------------------------------------------------------- /config/default/manager_env_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: manager 11 | env: 12 | - name: GITHUB_TOKEN 13 | valueFrom: 14 | secretKeyRef: 15 | name: controller-manager 16 | key: GITHUB_TOKEN 17 | optional: true 18 | - name: GITHUB_APP_ID 19 | valueFrom: 20 | secretKeyRef: 21 | name: controller-manager 22 | key: GITHUB_APP_ID 23 | optional: true 24 | - name: GITHUB_APP_INSTALLATION_ID 25 | valueFrom: 26 | secretKeyRef: 27 | name: controller-manager 28 | key: GITHUB_APP_INSTALLATION_ID 29 | optional: true 30 | - name: GITHUB_APP_PRIVATE_KEY 31 | valueFrom: 32 | secretKeyRef: 33 | name: controller-manager 34 | key: GITHUB_APP_PRIVATE_KEY 35 | optional: true 36 | - name: GITHUB_ENTERPRISE_URL 37 | valueFrom: 38 | secretKeyRef: 39 | name: controller-manager 40 | key: GITHUB_ENTERPRISE_URL 41 | optional: true 42 | -------------------------------------------------------------------------------- /config/default/manager_metrics_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch adds the args to allow exposing the metrics endpoint using HTTPS 2 | - op: add 3 | path: /spec/template/spec/containers/0/args/0 4 | value: --metrics-bind-address=:8443 5 | -------------------------------------------------------------------------------- /config/default/metrics_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: argocd-commenter 7 | app.kubernetes.io/managed-by: kustomize 8 | name: controller-manager-metrics-service 9 | namespace: system 10 | spec: 11 | ports: 12 | - name: https 13 | port: 8443 14 | protocol: TCP 15 | targetPort: 8443 16 | selector: 17 | control-plane: controller-manager 18 | app.kubernetes.io/name: argocd-commenter 19 | -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: argocd-commenter 7 | app.kubernetes.io/managed-by: kustomize 8 | name: system 9 | --- 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | metadata: 13 | name: controller-manager 14 | namespace: system 15 | labels: 16 | control-plane: controller-manager 17 | app.kubernetes.io/name: argocd-commenter 18 | app.kubernetes.io/managed-by: kustomize 19 | spec: 20 | selector: 21 | matchLabels: 22 | control-plane: controller-manager 23 | app.kubernetes.io/name: argocd-commenter 24 | replicas: 1 25 | template: 26 | metadata: 27 | labels: 28 | control-plane: controller-manager 29 | app.kubernetes.io/name: argocd-commenter 30 | spec: 31 | # TODO(user): Uncomment the following code to configure the nodeAffinity expression 32 | # according to the platforms which are supported by your solution. 33 | # It is considered best practice to support multiple architectures. You can 34 | # build your manager image using the makefile target docker-buildx. 35 | # affinity: 36 | # nodeAffinity: 37 | # requiredDuringSchedulingIgnoredDuringExecution: 38 | # nodeSelectorTerms: 39 | # - matchExpressions: 40 | # - key: kubernetes.io/arch 41 | # operator: In 42 | # values: 43 | # - amd64 44 | # - arm64 45 | # - ppc64le 46 | # - s390x 47 | # - key: kubernetes.io/os 48 | # operator: In 49 | # values: 50 | # - linux 51 | securityContext: 52 | # Projects are configured by default to adhere to the "restricted" Pod Security Standards. 53 | # This ensures that deployments meet the highest security requirements for Kubernetes. 54 | # For more details, see: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted 55 | runAsNonRoot: true 56 | seccompProfile: 57 | type: RuntimeDefault 58 | containers: 59 | - command: 60 | - /manager 61 | args: 62 | - --leader-elect 63 | - --health-probe-bind-address=:8081 64 | image: controller:latest 65 | name: manager 66 | ports: [] 67 | securityContext: 68 | allowPrivilegeEscalation: false 69 | capabilities: 70 | drop: 71 | - "ALL" 72 | livenessProbe: 73 | httpGet: 74 | path: /healthz 75 | port: 8081 76 | initialDelaySeconds: 15 77 | periodSeconds: 20 78 | readinessProbe: 79 | httpGet: 80 | path: /readyz 81 | port: 8081 82 | initialDelaySeconds: 5 83 | periodSeconds: 10 84 | resources: 85 | limits: 86 | # no cpu limit 87 | memory: 128Mi 88 | requests: 89 | cpu: 50m 90 | memory: 128Mi 91 | volumeMounts: [] 92 | volumes: [] 93 | serviceAccountName: controller-manager 94 | terminationGracePeriodSeconds: 10 95 | -------------------------------------------------------------------------------- /config/network-policy/allow-metrics-traffic.yaml: -------------------------------------------------------------------------------- 1 | # This NetworkPolicy allows ingress traffic 2 | # with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those 3 | # namespaces are able to gather data from the metrics endpoint. 4 | apiVersion: networking.k8s.io/v1 5 | kind: NetworkPolicy 6 | metadata: 7 | labels: 8 | app.kubernetes.io/name: argocd-commenter 9 | app.kubernetes.io/managed-by: kustomize 10 | name: allow-metrics-traffic 11 | namespace: system 12 | spec: 13 | podSelector: 14 | matchLabels: 15 | control-plane: controller-manager 16 | app.kubernetes.io/name: argocd-commenter 17 | policyTypes: 18 | - Ingress 19 | ingress: 20 | # This allows ingress traffic from any namespace with the label metrics: enabled 21 | - from: 22 | - namespaceSelector: 23 | matchLabels: 24 | metrics: enabled # Only from namespaces with this label 25 | ports: 26 | - port: 8443 27 | protocol: TCP 28 | -------------------------------------------------------------------------------- /config/network-policy/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - allow-metrics-traffic.yaml 3 | -------------------------------------------------------------------------------- /config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | 4 | # [PROMETHEUS-WITH-CERTS] The following patch configures the ServiceMonitor in ../prometheus 5 | # to securely reference certificates created and managed by cert-manager. 6 | # Additionally, ensure that you uncomment the [METRICS WITH CERTMANAGER] patch under config/default/kustomization.yaml 7 | # to mount the "metrics-server-cert" secret in the Manager Deployment. 8 | #patches: 9 | # - path: monitor_tls_patch.yaml 10 | # target: 11 | # kind: ServiceMonitor 12 | -------------------------------------------------------------------------------- /config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | # Prometheus Monitor Service (Metrics) 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | labels: 6 | control-plane: controller-manager 7 | app.kubernetes.io/name: argocd-commenter 8 | app.kubernetes.io/managed-by: kustomize 9 | name: controller-manager-metrics-monitor 10 | namespace: system 11 | spec: 12 | endpoints: 13 | - path: /metrics 14 | port: https # Ensure this is the name of the port that exposes HTTPS metrics 15 | scheme: https 16 | bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token 17 | tlsConfig: 18 | # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables 19 | # certificate verification, exposing the system to potential man-in-the-middle attacks. 20 | # For production environments, it is recommended to use cert-manager for automatic TLS certificate management. 21 | # To apply this configuration, enable cert-manager and use the patch located at config/prometheus/servicemonitor_tls_patch.yaml, 22 | # which securely references the certificate from the 'metrics-server-cert' secret. 23 | insecureSkipVerify: true 24 | selector: 25 | matchLabels: 26 | control-plane: controller-manager 27 | app.kubernetes.io/name: argocd-commenter 28 | -------------------------------------------------------------------------------- /config/prometheus/monitor_tls_patch.yaml: -------------------------------------------------------------------------------- 1 | # Patch for Prometheus ServiceMonitor to enable secure TLS configuration 2 | # using certificates managed by cert-manager 3 | - op: replace 4 | path: /spec/endpoints/0/tlsConfig 5 | value: 6 | # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize 7 | serverName: SERVICE_NAME.SERVICE_NAMESPACE.svc 8 | insecureSkipVerify: false 9 | ca: 10 | secret: 11 | name: metrics-server-cert 12 | key: ca.crt 13 | cert: 14 | secret: 15 | name: metrics-server-cert 16 | key: tls.crt 17 | keySecret: 18 | name: metrics-server-cert 19 | key: tls.key 20 | -------------------------------------------------------------------------------- /config/rbac/applicationhealth_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # This rule is not used by the project argocd-commenter itself. 2 | # It is provided to allow the cluster admin to help manage permissions for users. 3 | # 4 | # Grants permissions to create, update, and delete resources within the webapp.int128.github.io. 5 | # This role is intended for users who need to manage these resources 6 | # but should not control RBAC or manage permissions for others. 7 | 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | kind: ClusterRole 10 | metadata: 11 | labels: 12 | app.kubernetes.io/name: argocd-commenter 13 | app.kubernetes.io/managed-by: kustomize 14 | name: applicationhealth-editor-role 15 | rules: 16 | - apiGroups: 17 | - argocdcommenter.int128.github.io 18 | resources: 19 | - applicationhealths 20 | verbs: 21 | - create 22 | - delete 23 | - get 24 | - list 25 | - patch 26 | - update 27 | - watch 28 | - apiGroups: 29 | - argocdcommenter.int128.github.io 30 | resources: 31 | - applicationhealths/status 32 | verbs: 33 | - get 34 | -------------------------------------------------------------------------------- /config/rbac/applicationhealth_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view applicationhealths. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: argocd-commenter 7 | app.kubernetes.io/managed-by: kustomize 8 | name: applicationhealth-viewer-role 9 | rules: 10 | - apiGroups: 11 | - argocdcommenter.int128.github.io 12 | resources: 13 | - applicationhealths 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - apiGroups: 19 | - argocdcommenter.int128.github.io 20 | resources: 21 | - applicationhealths/status 22 | verbs: 23 | - get 24 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | # All RBAC will be applied under this service account in 3 | # the deployment namespace. You may comment out this resource 4 | # if your manager will use a service account that exists at 5 | # runtime. Be sure to update RoleBinding and ClusterRoleBinding 6 | # subjects if changing service account names. 7 | - service_account.yaml 8 | - role.yaml 9 | - role_binding.yaml 10 | - leader_election_role.yaml 11 | - leader_election_role_binding.yaml 12 | # The following RBAC configurations are used to protect 13 | # the metrics endpoint with authn/authz. These configurations 14 | # ensure that only authorized users and service accounts 15 | # can access the metrics endpoint. Comment the following 16 | # permissions if you want to disable this protection. 17 | # More info: https://book.kubebuilder.io/reference/metrics.html 18 | - metrics_auth_role.yaml 19 | - metrics_auth_role_binding.yaml 20 | - metrics_reader_role.yaml 21 | # For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by 22 | # default, aiding admins in cluster management. Those roles are 23 | # not used by the {{ .ProjectName }} itself. You can comment the following lines 24 | # if you do not want those helpers be installed with your Project. 25 | - applicationhealth_editor_role.yaml 26 | - applicationhealth_viewer_role.yaml 27 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: argocd-commenter 7 | app.kubernetes.io/managed-by: kustomize 8 | name: leader-election-role 9 | rules: 10 | - apiGroups: 11 | - "" 12 | resources: 13 | - configmaps 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - create 19 | - update 20 | - patch 21 | - delete 22 | - apiGroups: 23 | - coordination.k8s.io 24 | resources: 25 | - leases 26 | verbs: 27 | - get 28 | - list 29 | - watch 30 | - create 31 | - update 32 | - patch 33 | - delete 34 | - apiGroups: 35 | - "" 36 | resources: 37 | - events 38 | verbs: 39 | - create 40 | - patch 41 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: argocd-commenter 6 | app.kubernetes.io/managed-by: kustomize 7 | name: leader-election-rolebinding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: Role 11 | name: leader-election-role 12 | subjects: 13 | - kind: ServiceAccount 14 | name: controller-manager 15 | namespace: system 16 | -------------------------------------------------------------------------------- /config/rbac/metrics_auth_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-auth-role 5 | rules: 6 | - apiGroups: 7 | - authentication.k8s.io 8 | resources: 9 | - tokenreviews 10 | verbs: 11 | - create 12 | - apiGroups: 13 | - authorization.k8s.io 14 | resources: 15 | - subjectaccessreviews 16 | verbs: 17 | - create 18 | -------------------------------------------------------------------------------- /config/rbac/metrics_auth_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: metrics-auth-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: metrics-auth-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/metrics_reader_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-reader 5 | rules: 6 | - nonResourceURLs: 7 | - "/metrics" 8 | verbs: 9 | - get 10 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: manager-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - configmaps 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - "" 17 | resources: 18 | - events 19 | verbs: 20 | - create 21 | - patch 22 | - apiGroups: 23 | - argocdcommenter.int128.github.io 24 | resources: 25 | - applicationhealths 26 | verbs: 27 | - create 28 | - get 29 | - list 30 | - patch 31 | - update 32 | - watch 33 | - apiGroups: 34 | - argocdcommenter.int128.github.io 35 | resources: 36 | - applicationhealths/status 37 | verbs: 38 | - get 39 | - patch 40 | - update 41 | - apiGroups: 42 | - argoproj.io 43 | resources: 44 | - applications 45 | verbs: 46 | - get 47 | - list 48 | - watch 49 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: argocd-commenter 6 | app.kubernetes.io/managed-by: kustomize 7 | name: manager-rolebinding 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: ClusterRole 11 | name: manager-role 12 | subjects: 13 | - kind: ServiceAccount 14 | name: controller-manager 15 | namespace: system 16 | -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: argocd-commenter 6 | app.kubernetes.io/managed-by: kustomize 7 | name: controller-manager 8 | namespace: system 9 | -------------------------------------------------------------------------------- /config/samples/kustomization.yaml: -------------------------------------------------------------------------------- 1 | ## Append samples of your project ## 2 | resources: 3 | - webapp_v1_guestbook.yaml 4 | # +kubebuilder:scaffold:manifestskustomizesamples 5 | -------------------------------------------------------------------------------- /e2e_test/Makefile: -------------------------------------------------------------------------------- 1 | CLUSTER_NAME := argocd-commenter-e2e 2 | KUBECONFIG := output/kubeconfig.yaml 3 | export KUBECONFIG 4 | 5 | GITHUB_RUN_NUMBER ?= 0 6 | FIXTURE_BRANCH := e2e-test/$(GITHUB_RUN_NUMBER)/main 7 | FIXTURE_DIR := argocd-commenter-e2e-test-repository 8 | 9 | all: 10 | 11 | setup-fixture-branch: 12 | test -d $(FIXTURE_DIR) 13 | rsync -av fixture/ $(FIXTURE_DIR)/ 14 | $(MAKE) -C $(FIXTURE_DIR) setup-fixture-branch 15 | 16 | # cluster 17 | cluster: $(KUBECONFIG) 18 | $(KUBECONFIG): 19 | kind create cluster --name $(CLUSTER_NAME) 20 | 21 | delete-cluster: 22 | kind delete cluster --name $(CLUSTER_NAME) 23 | -rm $(KUBECONFIG) 24 | 25 | deploy-argocd: cluster 26 | # To avoid the error "ensure CRDs are installed first" 27 | -kustomize build argocd | kubectl apply -f - 28 | kustomize build argocd | kubectl apply -f - 29 | kustomize build applications | sed -e "s|FIXTURE_BRANCH|$(FIXTURE_BRANCH)|g" | kubectl apply -f - 30 | 31 | deploy-controller: cluster 32 | cd controller && kustomize edit set image controller="$(CONTROLLER_IMAGE)" 33 | kustomize build controller | kubectl apply -f - 34 | bash controller/create-controller-manager-secret.sh 35 | kubectl -n argocd-commenter-system rollout status deployment argocd-commenter-controller-manager 36 | 37 | wait-for-apps: 38 | go run ./waitforapp -revision "`git -C $(FIXTURE_DIR) rev-parse $(FIXTURE_BRANCH)`" app1 app2 app3 39 | 40 | # fixture 41 | deploy-app1: 42 | $(MAKE) -C $(FIXTURE_DIR) deploy-app1 43 | go run ./waitforapp -revision "`git -C $(FIXTURE_DIR) rev-parse $(FIXTURE_BRANCH)`" app1 44 | 45 | restart-app1: 46 | kubectl -n app1 delete pod -l app=echoserver --force 47 | go run ./waitforapp -revision "`git -C $(FIXTURE_DIR) rev-parse $(FIXTURE_BRANCH)`" app1 48 | 49 | deploy-app2: 50 | $(MAKE) -C $(FIXTURE_DIR) deploy-app2 51 | go run ./waitforapp -revision "`git -C $(FIXTURE_DIR) rev-parse $(FIXTURE_BRANCH)`" -sync OutOfSync -operation Failed app2 52 | 53 | deploy-app3: 54 | $(MAKE) -C $(FIXTURE_DIR) deploy-app3 55 | go run ./waitforapp -revision "`git -C $(FIXTURE_DIR) rev-parse $(FIXTURE_BRANCH)`" app3 56 | 57 | logs-controller: 58 | -kubectl -n argocd-commenter-system logs -l control-plane=controller-manager --all-containers --tail=-1 59 | logs-argocd-application-controller: 60 | -kubectl -n argocd logs -l app.kubernetes.io/name=argocd-application-controller --all-containers --tail=-1 61 | logs-argocd-applicationset-controller: 62 | -kubectl -n argocd logs -l app.kubernetes.io/name=argocd-applicationset-controller --all-containers --tail=-1 63 | -------------------------------------------------------------------------------- /e2e_test/README.md: -------------------------------------------------------------------------------- 1 | # E2E test for argocd-commenter 2 | 3 | ## Test environment 4 | 5 | Here is a diagram of the test environment. 6 | 7 | ```mermaid 8 | graph LR 9 | subgraph Local Cluster 10 | argo[Argo CD] 11 | set[ApplicationSet] 12 | set -. owner .-> app1[Application app1] 13 | set -. owner .-> app2[Application app2] 14 | set -. owner .-> app3[Application app3] 15 | end 16 | subgraph GitHub Repository 17 | subgraph Branch 18 | dir1[Directory app1] 19 | dir2[Directory app2] 20 | dir3[Directory app3] 21 | end 22 | end 23 | app1 -. source .-> dir1 24 | app2 -. source .-> dir2 25 | app3 -. source .-> dir3 26 | kubectl -- create --> set 27 | kubectl -- create --> argo 28 | ``` 29 | 30 | ## Local development 31 | 32 | ### Prerequisites 33 | 34 | - docker 35 | - kind 36 | - kustomize 37 | - kubectl 38 | - make 39 | - git 40 | - gh 41 | 42 | ### How to run 43 | 44 | Set up a branch to deploy. 45 | 46 | ```sh 47 | gh repo clone int128/argocd-commenter-e2e-test argocd-commenter-e2e-test-repository 48 | make setup-fixture-branch 49 | ``` 50 | 51 | Set up a cluster and Argo CD. 52 | 53 | ```sh 54 | make cluster 55 | make deploy-argocd 56 | make wait-for-apps 57 | ``` 58 | 59 | You can access the cluster. 60 | 61 | ```console 62 | % export KUBECONFIG=output/kubeconfig.yaml 63 | % k -n argocd get apps 64 | NAME SYNC STATUS HEALTH STATUS 65 | app1 Synced Progressing 66 | app2 Synced Progressing 67 | app3 Synced Healthy 68 | ``` 69 | 70 | You can run the controller locally. 71 | 72 | ```sh 73 | make -C .. run 74 | ``` 75 | 76 | ### Clean up 77 | 78 | ```sh 79 | make delete-cluster 80 | ``` 81 | -------------------------------------------------------------------------------- /e2e_test/applications/e2e-test-project.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: AppProject 3 | metadata: 4 | name: e2e-test-project 5 | namespace: argocd 6 | spec: 7 | sourceRepos: 8 | - https://github.com/int128/argocd-commenter-e2e-test 9 | destinations: 10 | - server: https://kubernetes.default.svc 11 | namespace: "*" 12 | clusterResourceWhitelist: 13 | - group: "*" 14 | kind: "*" 15 | -------------------------------------------------------------------------------- /e2e_test/applications/e2e-test-set.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: ApplicationSet 3 | metadata: 4 | name: e2e-test-set 5 | namespace: argocd 6 | spec: 7 | goTemplate: true 8 | goTemplateOptions: 9 | - missingkey=error 10 | generators: 11 | - git: 12 | repoURL: https://github.com/int128/argocd-commenter-e2e-test 13 | revision: FIXTURE_BRANCH 14 | files: 15 | - path: "*/metadata.json" 16 | requeueAfterSeconds: 5 # To update the deploymentURL within e2e-test 17 | template: 18 | metadata: 19 | name: "{{ .path.basename }}" 20 | annotations: 21 | argocd-commenter.int128.github.io/deployment-url: "{{ .deploymentURL }}" 22 | finalizers: 23 | - resources-finalizer.argocd.argoproj.io 24 | spec: 25 | project: e2e-test-project 26 | source: 27 | repoURL: https://github.com/int128/argocd-commenter-e2e-test 28 | targetRevision: FIXTURE_BRANCH 29 | path: "{{ .path.basename }}" 30 | destination: 31 | server: https://kubernetes.default.svc 32 | namespace: default 33 | syncPolicy: 34 | automated: 35 | prune: true 36 | retry: 37 | limit: 1 # To transition to SyncFailed shortly 38 | -------------------------------------------------------------------------------- /e2e_test/applications/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - e2e-test-set.yaml 3 | - e2e-test-project.yaml 4 | -------------------------------------------------------------------------------- /e2e_test/argocd/configmap/argocd-cm.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: argocd-cm 5 | data: 6 | # https://argoproj.github.io/argo-cd/operator-manual/high_availability/ 7 | # https://argoproj.github.io/argo-cd/operator-manual/upgrading/2.0-2.1/#replacing-app-resync-flag-with-timeoutreconciliation-setting 8 | timeout.reconciliation: 10s 9 | 10 | # add a link to Deployment 11 | url: https://argocd.example.com 12 | -------------------------------------------------------------------------------- /e2e_test/argocd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | namespace: argocd 2 | resources: 3 | # https://github.com/argoproj/argo-cd 4 | - https://raw.githubusercontent.com/argoproj/argo-cd/v2.14.11/manifests/install.yaml 5 | - namespace/argocd.yaml 6 | patches: 7 | - path: configmap/argocd-cm.yaml 8 | -------------------------------------------------------------------------------- /e2e_test/argocd/namespace/argocd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: argocd 5 | -------------------------------------------------------------------------------- /e2e_test/controller/create-controller-manager-secret.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o pipefail 3 | set -e 4 | 5 | kubectl -n argocd-commenter-system delete secret controller-manager || true 6 | 7 | # for personal access token 8 | if [ "$GITHUB_TOKEN" ]; then 9 | echo 'using GITHUB_TOKEN' 10 | kubectl -n argocd-commenter-system create secret generic controller-manager \ 11 | --from-literal="GITHUB_TOKEN=$GITHUB_TOKEN" 12 | exit 0 13 | fi 14 | 15 | # for installation access token 16 | if [ "$GITHUB_APP_ID" ]; then 17 | echo 'using GITHUB_APP_ID' 18 | github_app_private_key_file="$(mktemp)" 19 | echo "$GITHUB_APP_PRIVATE_KEY" > "$github_app_private_key_file" 20 | kubectl -n argocd-commenter-system create secret generic controller-manager \ 21 | --from-literal="GITHUB_APP_ID=$GITHUB_APP_ID" \ 22 | --from-literal="GITHUB_APP_INSTALLATION_ID=$GITHUB_APP_INSTALLATION_ID" \ 23 | --from-file="GITHUB_APP_PRIVATE_KEY=$github_app_private_key_file" 24 | rm -v "$github_app_private_key_file" 25 | exit 0 26 | fi 27 | 28 | echo 'you need to set either GITHUB_TOKEN or GITHUB_APP_ID' >&2 29 | exit 1 30 | -------------------------------------------------------------------------------- /e2e_test/controller/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - ../../config/default 3 | -------------------------------------------------------------------------------- /e2e_test/fixture/Makefile: -------------------------------------------------------------------------------- 1 | GITHUB_RUN_NUMBER ?= 0 2 | FIXTURE_BRANCH := e2e-test/$(GITHUB_RUN_NUMBER)/main 3 | 4 | all: 5 | 6 | setup-fixture-branch: 7 | git config user.name 'github-actions[bot]' 8 | git config user.email '41898282+github-actions[bot]@users.noreply.github.com' 9 | git checkout -B "$(FIXTURE_BRANCH)" 10 | git add . 11 | git commit -m "Initial commit" 12 | git push origin -f "$(FIXTURE_BRANCH)" 13 | 14 | # Test#1 15 | # It updates an image tag of Deployment. 16 | # It will cause the rolling update, that is, Progressing state. 17 | deploy-app1: 18 | git checkout "$(FIXTURE_BRANCH)" 19 | sed -i -e 's/echoserver:1.8/echoserver:1.9/g' app1/deployment/echoserver.yaml 20 | bash deploy.sh app1 21 | 22 | # Test#2 23 | # It updates the label to invalid value. 24 | # It will cause this error: 25 | # one or more objects failed to apply, reason: Deployment.apps "echoserver" is invalid: spec.selector: Invalid value: v1.LabelSelector 26 | deploy-app2: 27 | git checkout "$(FIXTURE_BRANCH)" 28 | sed -i -e 's/app: echoserver/app: echoserver-test2/g' app2/deployment/echoserver.yaml 29 | bash deploy.sh app2 30 | 31 | # Test#3 32 | # It updates an image tag of CronJob template. 33 | # Application will not transit to Progressing state. 34 | deploy-app3: 35 | git checkout "$(FIXTURE_BRANCH)" 36 | sed -i -e 's/busybox:1.28/busybox:1.30/g' app3/cronjob/echoserver.yaml 37 | bash deploy.sh app3 38 | -------------------------------------------------------------------------------- /e2e_test/fixture/app1/deployment/echoserver.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: echoserver 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: echoserver 10 | template: 11 | metadata: 12 | labels: 13 | app: echoserver 14 | spec: 15 | containers: 16 | - image: gcr.io/google_containers/echoserver:1.8 17 | name: echoserver 18 | ports: 19 | - containerPort: 8080 20 | readinessProbe: 21 | initialDelaySeconds: 3 22 | httpGet: 23 | path: / 24 | port: 8080 25 | -------------------------------------------------------------------------------- /e2e_test/fixture/app1/kustomization.yaml: -------------------------------------------------------------------------------- 1 | namespace: app1 2 | resources: 3 | - namespace/app1.yaml 4 | - deployment/echoserver.yaml 5 | -------------------------------------------------------------------------------- /e2e_test/fixture/app1/metadata.json: -------------------------------------------------------------------------------- 1 | {"deploymentURL": ""} 2 | -------------------------------------------------------------------------------- /e2e_test/fixture/app1/namespace/app1.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: app1 5 | -------------------------------------------------------------------------------- /e2e_test/fixture/app2/deployment/echoserver.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: echoserver 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: echoserver 10 | template: 11 | metadata: 12 | labels: 13 | app: echoserver 14 | spec: 15 | containers: 16 | - image: gcr.io/google_containers/echoserver:1.8 17 | name: echoserver 18 | ports: 19 | - containerPort: 8080 20 | -------------------------------------------------------------------------------- /e2e_test/fixture/app2/kustomization.yaml: -------------------------------------------------------------------------------- 1 | namespace: app2 2 | resources: 3 | - namespace/app2.yaml 4 | - deployment/echoserver.yaml 5 | -------------------------------------------------------------------------------- /e2e_test/fixture/app2/metadata.json: -------------------------------------------------------------------------------- 1 | {"deploymentURL": ""} 2 | -------------------------------------------------------------------------------- /e2e_test/fixture/app2/namespace/app2.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: app2 5 | -------------------------------------------------------------------------------- /e2e_test/fixture/app3/cronjob/echoserver.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: CronJob 3 | metadata: 4 | name: echoserver 5 | spec: 6 | schedule: "* * * * *" 7 | jobTemplate: 8 | spec: 9 | template: 10 | metadata: 11 | labels: 12 | app: echoserver 13 | spec: 14 | restartPolicy: Never 15 | containers: 16 | # https://hub.docker.com/_/busybox/ 17 | - image: busybox:1.28 18 | name: echoserver 19 | command: 20 | - /bin/sh 21 | - -c 22 | - date; echo Hello from the Kubernetes cluster 23 | -------------------------------------------------------------------------------- /e2e_test/fixture/app3/kustomization.yaml: -------------------------------------------------------------------------------- 1 | namespace: app3 2 | resources: 3 | - namespace/app3.yaml 4 | - cronjob/echoserver.yaml 5 | -------------------------------------------------------------------------------- /e2e_test/fixture/app3/metadata.json: -------------------------------------------------------------------------------- 1 | {"deploymentURL": ""} 2 | -------------------------------------------------------------------------------- /e2e_test/fixture/app3/namespace/app3.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: app3 5 | -------------------------------------------------------------------------------- /e2e_test/fixture/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eux -o pipefail 3 | 4 | : "$GITHUB_REPOSITORY" 5 | : "$GITHUB_REF_NAME" 6 | : "$GITHUB_RUN_NUMBER" 7 | 8 | app="$1" 9 | head_branch="e2e-test/$GITHUB_RUN_NUMBER/deploy-$app" 10 | base_branch="e2e-test/$GITHUB_RUN_NUMBER/main" 11 | 12 | cat > "$app/metadata.json" < 0 { 119 | s.Revision = app.Status.Sync.Revisions[0] 120 | } 121 | if app.Status.OperationState != nil { 122 | s.Operation = string(app.Status.OperationState.Phase) 123 | } 124 | return s 125 | } 126 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/int128/argocd-commenter 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | github.com/argoproj/argo-cd/v2 v2.14.3 7 | github.com/argoproj/gitops-engine v0.7.1-0.20250207220447-65db274b8d73 8 | github.com/go-logr/logr v1.4.3 9 | github.com/google/go-cmp v0.7.0 10 | github.com/google/go-github/v72 v72.0.0 11 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 12 | github.com/int128/oauth2-github-app v1.2.0 13 | github.com/onsi/ginkgo/v2 v2.23.4 14 | github.com/onsi/gomega v1.37.0 15 | go.uber.org/zap v1.27.0 16 | golang.org/x/oauth2 v0.30.0 17 | k8s.io/api v0.31.0 18 | k8s.io/apimachinery v0.31.0 19 | k8s.io/client-go v0.31.0 20 | sigs.k8s.io/controller-runtime v0.19.4 21 | ) 22 | 23 | require ( 24 | cloud.google.com/go/compute/metadata v0.5.2 // indirect 25 | dario.cat/mergo v1.0.1 // indirect 26 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 27 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 28 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 29 | github.com/Microsoft/go-winio v0.6.1 // indirect 30 | github.com/ProtonMail/go-crypto v1.1.5 // indirect 31 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 32 | github.com/argoproj/pkg v0.13.7-0.20230627120311-a4dd357b057e // indirect 33 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 34 | github.com/beorn7/perks v1.0.1 // indirect 35 | github.com/blang/semver/v4 v4.0.0 // indirect 36 | github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect 37 | github.com/bombsimon/logrusr/v2 v2.0.1 // indirect 38 | github.com/bradleyfalzon/ghinstallation/v2 v2.13.0 // indirect 39 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 40 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 41 | github.com/chai2010/gettext-go v1.0.2 // indirect 42 | github.com/cloudflare/circl v1.3.7 // indirect 43 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 44 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 45 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 46 | github.com/distribution/reference v0.5.0 // indirect 47 | github.com/dlclark/regexp2 v1.11.5 // indirect 48 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 49 | github.com/emirpasic/gods v1.18.1 // indirect 50 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 51 | github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect 52 | github.com/fatih/camelcase v1.0.0 // indirect 53 | github.com/felixge/httpsnoop v1.0.4 // indirect 54 | github.com/fsnotify/fsnotify v1.8.0 // indirect 55 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 56 | github.com/go-errors/errors v1.4.2 // indirect 57 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 58 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 59 | github.com/go-git/go-git/v5 v5.13.2 // indirect 60 | github.com/go-logr/stdr v1.2.2 // indirect 61 | github.com/go-logr/zapr v1.3.0 // indirect 62 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 63 | github.com/go-openapi/jsonreference v0.21.0 // indirect 64 | github.com/go-openapi/swag v0.23.0 // indirect 65 | github.com/go-redis/cache/v9 v9.0.0 // indirect 66 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 67 | github.com/gobwas/glob v0.2.3 // indirect 68 | github.com/gogo/protobuf v1.3.2 // indirect 69 | github.com/golang-jwt/jwt/v4 v4.5.1 // indirect 70 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 71 | github.com/golang/protobuf v1.5.4 // indirect 72 | github.com/google/btree v1.1.3 // indirect 73 | github.com/google/cel-go v0.20.1 // indirect 74 | github.com/google/gnostic-models v0.6.8 // indirect 75 | github.com/google/go-github/v66 v66.0.0 // indirect 76 | github.com/google/go-github/v68 v68.0.0 // indirect 77 | github.com/google/go-querystring v1.1.0 // indirect 78 | github.com/google/gofuzz v1.2.0 // indirect 79 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect 80 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 81 | github.com/google/uuid v1.6.0 // indirect 82 | github.com/gorilla/websocket v1.5.3 // indirect 83 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect 84 | github.com/imdario/mergo v0.3.16 // indirect 85 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 86 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 87 | github.com/jonboulle/clockwork v0.2.2 // indirect 88 | github.com/josharian/intern v1.0.0 // indirect 89 | github.com/json-iterator/go v1.1.12 // indirect 90 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 91 | github.com/kevinburke/ssh_config v1.2.0 // indirect 92 | github.com/klauspost/compress v1.17.9 // indirect 93 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 94 | github.com/mailru/easyjson v0.7.7 // indirect 95 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 96 | github.com/moby/spdystream v0.4.0 // indirect 97 | github.com/moby/term v0.5.0 // indirect 98 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 99 | github.com/modern-go/reflect2 v1.0.2 // indirect 100 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 101 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 102 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 103 | github.com/opencontainers/go-digest v1.0.0 // indirect 104 | github.com/opencontainers/image-spec v1.1.0 // indirect 105 | github.com/patrickmn/go-cache v2.1.0+incompatible // indirect 106 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 107 | github.com/pjbgf/sha1cd v0.3.2 // indirect 108 | github.com/pkg/errors v0.9.1 // indirect 109 | github.com/prometheus/client_golang v1.20.5 // indirect 110 | github.com/prometheus/client_model v0.6.1 // indirect 111 | github.com/prometheus/common v0.55.0 // indirect 112 | github.com/prometheus/procfs v0.15.1 // indirect 113 | github.com/redis/go-redis/v9 v9.7.0 // indirect 114 | github.com/robfig/cron/v3 v3.0.1 // indirect 115 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 116 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 117 | github.com/sirupsen/logrus v1.9.3 // indirect 118 | github.com/skeema/knownhosts v1.3.0 // indirect 119 | github.com/spf13/cobra v1.9.1 // indirect 120 | github.com/spf13/pflag v1.0.6 // indirect 121 | github.com/stoewer/go-strcase v1.2.0 // indirect 122 | github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 123 | github.com/vmihailenco/msgpack/v5 v5.3.4 // indirect 124 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 125 | github.com/x448/float16 v0.8.4 // indirect 126 | github.com/xanzy/ssh-agent v0.3.3 // indirect 127 | github.com/xlab/treeprint v1.2.0 // indirect 128 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 129 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect 130 | go.opentelemetry.io/otel v1.33.0 // indirect 131 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect 132 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect 133 | go.opentelemetry.io/otel/metric v1.33.0 // indirect 134 | go.opentelemetry.io/otel/sdk v1.33.0 // indirect 135 | go.opentelemetry.io/otel/trace v1.33.0 // indirect 136 | go.opentelemetry.io/proto/otlp v1.3.1 // indirect 137 | go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect 138 | go.uber.org/automaxprocs v1.6.0 // indirect 139 | go.uber.org/multierr v1.11.0 // indirect 140 | golang.org/x/crypto v0.36.0 // indirect 141 | golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect 142 | golang.org/x/mod v0.24.0 // indirect 143 | golang.org/x/net v0.37.0 // indirect 144 | golang.org/x/sync v0.12.0 // indirect 145 | golang.org/x/sys v0.32.0 // indirect 146 | golang.org/x/term v0.30.0 // indirect 147 | golang.org/x/text v0.23.0 // indirect 148 | golang.org/x/time v0.10.0 // indirect 149 | golang.org/x/tools v0.31.0 // indirect 150 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 151 | google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a // indirect 152 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect 153 | google.golang.org/grpc v1.70.0 // indirect 154 | google.golang.org/protobuf v1.36.5 // indirect 155 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 156 | gopkg.in/inf.v0 v0.9.1 // indirect 157 | gopkg.in/warnings.v0 v0.1.2 // indirect 158 | gopkg.in/yaml.v2 v2.4.0 // indirect 159 | gopkg.in/yaml.v3 v3.0.1 // indirect 160 | k8s.io/apiextensions-apiserver v0.31.2 // indirect 161 | k8s.io/apiserver v0.31.0 // indirect 162 | k8s.io/cli-runtime v0.31.0 // indirect 163 | k8s.io/component-base v0.31.0 // indirect 164 | k8s.io/component-helpers v0.31.0 // indirect 165 | k8s.io/klog/v2 v2.130.1 // indirect 166 | k8s.io/kube-aggregator v0.31.2 // indirect 167 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 168 | k8s.io/kubectl v0.31.2 // indirect 169 | k8s.io/kubernetes v1.31.0 // indirect 170 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect 171 | oras.land/oras-go/v2 v2.5.0 // indirect 172 | sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 // indirect 173 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 174 | sigs.k8s.io/kustomize/api v0.17.2 // indirect 175 | sigs.k8s.io/kustomize/kyaml v0.17.1 // indirect 176 | sigs.k8s.io/structured-merge-diff/v4 v4.4.4-0.20241211184406-7bf59b3d70ee // indirect 177 | sigs.k8s.io/yaml v1.4.0 // indirect 178 | ) 179 | 180 | replace ( 181 | // https://github.com/argoproj/argo-cd/blob/v2.12.0/go.mod 182 | k8s.io/api => k8s.io/api v0.31.0 183 | k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.31.0 184 | k8s.io/apimachinery => k8s.io/apimachinery v0.31.0 185 | k8s.io/apiserver => k8s.io/apiserver v0.31.0 186 | k8s.io/cli-runtime => k8s.io/cli-runtime v0.31.0 187 | k8s.io/client-go => k8s.io/client-go v0.31.0 188 | k8s.io/cloud-provider => k8s.io/cloud-provider v0.31.0 189 | k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.31.0 190 | k8s.io/code-generator => k8s.io/code-generator v0.31.0 191 | k8s.io/component-base => k8s.io/component-base v0.31.0 192 | k8s.io/component-helpers => k8s.io/component-helpers v0.31.0 193 | k8s.io/controller-manager => k8s.io/controller-manager v0.31.0 194 | k8s.io/cri-api => k8s.io/cri-api v0.31.0 195 | k8s.io/cri-client => k8s.io/cri-client v0.31.0 196 | k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.31.0 197 | k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v0.31.0 198 | k8s.io/endpointslice => k8s.io/endpointslice v0.31.0 199 | k8s.io/kms => k8s.io/kms v0.31.0 200 | k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.31.0 201 | k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.31.0 202 | k8s.io/kube-proxy => k8s.io/kube-proxy v0.31.0 203 | k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.31.0 204 | k8s.io/kubectl => k8s.io/kubectl v0.31.0 205 | k8s.io/kubelet => k8s.io/kubelet v0.31.0 206 | k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.31.0 207 | k8s.io/metrics => k8s.io/metrics v0.31.0 208 | k8s.io/mount-utils => k8s.io/mount-utils v0.31.0 209 | k8s.io/pod-security-admission => k8s.io/pod-security-admission v0.31.0 210 | k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.31.0 211 | k8s.io/sample-cli-plugin => k8s.io/sample-cli-plugin v0.31.0 212 | k8s.io/sample-controller => k8s.io/sample-controller v0.31.0 213 | ) 214 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ -------------------------------------------------------------------------------- /hack/fix_go_mod_replace.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eux -o pipefail 3 | 4 | argocd_version="$(grep -m1 github.com/argoproj/argo-cd/v2 go.mod | awk '{print $2}')" 5 | [ "${argocd_version}" ] 6 | go get "github.com/argoproj/argo-cd/v2@${argocd_version}" 7 | 8 | k8s_version="$(grep -m1 k8s.io/api go.mod | awk '{print $2}')" 9 | [ "${k8s_version}" ] 10 | perl -i -pne "s/(k8s.io\/\S+ => k8s.io\/\S+) .+$/\1 ${k8s_version}/g" go.mod 11 | 12 | go mod tidy 13 | -------------------------------------------------------------------------------- /internal/argocd/application.go: -------------------------------------------------------------------------------- 1 | package argocd 2 | 3 | import ( 4 | "strings" 5 | 6 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 7 | synccommon "github.com/argoproj/gitops-engine/pkg/sync/common" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | ) 10 | 11 | type SourceRevision struct { 12 | Source argocdv1alpha1.ApplicationSource 13 | Revision string 14 | } 15 | 16 | // GetSourceRevisions returns the last synced revisions 17 | func GetSourceRevisions(app argocdv1alpha1.Application) []SourceRevision { 18 | if app.Status.OperationState == nil { 19 | return nil 20 | } 21 | if app.Status.OperationState.Operation.Sync == nil { 22 | return nil 23 | } 24 | sources := app.Spec.GetSources() 25 | revisions := app.Status.OperationState.Operation.Sync.Revisions 26 | if revisions == nil { 27 | revisions = []string{app.Status.OperationState.Operation.Sync.Revision} 28 | } 29 | size := min(len(sources), len(revisions)) 30 | 31 | sourceRevisions := make([]SourceRevision, size) 32 | for i := 0; i < size; i++ { 33 | sourceRevisions[i] = SourceRevision{ 34 | Source: sources[i], 35 | Revision: revisions[i], 36 | } 37 | } 38 | return sourceRevisions 39 | } 40 | 41 | // GetApplicationExternalURL returns the external URL if presents. 42 | func GetApplicationExternalURL(app argocdv1alpha1.Application) string { 43 | if len(app.Status.Summary.ExternalURLs) == 0 { 44 | return "" 45 | } 46 | externalURL := app.Status.Summary.ExternalURLs[0] 47 | parts := strings.SplitN(externalURL, "|", 2) 48 | if len(parts) == 2 { 49 | // Assign the second part to EnvironmentURL as url. 50 | // https://argo-cd.readthedocs.io/en/stable/user-guide/external-url/ 51 | // This is hidden supported functionality: https://github.com/argoproj/argo-cd/blob/f0b03071fc00fd81433d2c16861c193992d5a093/common/common.go#L186 52 | return parts[1] 53 | } 54 | return externalURL 55 | } 56 | 57 | // GetDeploymentURL returns the deployment URL in annotations 58 | func GetDeploymentURL(a argocdv1alpha1.Application) string { 59 | if a.Annotations == nil { 60 | return "" 61 | } 62 | return a.Annotations["argocd-commenter.int128.github.io/deployment-url"] 63 | } 64 | 65 | // GetSyncOperationPhase returns OperationState.Phase or empty string. 66 | func GetSyncOperationPhase(a argocdv1alpha1.Application) synccommon.OperationPhase { 67 | if a.Status.OperationState == nil { 68 | return "" 69 | } 70 | return a.Status.OperationState.Phase 71 | } 72 | 73 | func GetSyncOperationFinishedAt(a argocdv1alpha1.Application) *metav1.Time { 74 | if a.Status.OperationState == nil { 75 | return nil 76 | } 77 | if a.Status.OperationState.FinishedAt == nil { 78 | return nil 79 | } 80 | return a.Status.OperationState.FinishedAt 81 | } 82 | 83 | // GetLastOperationAt returns OperationState.FinishedAt, OperationState.StartedAt or zero Time. 84 | func GetLastOperationAt(a argocdv1alpha1.Application) metav1.Time { 85 | if a.Status.OperationState == nil { 86 | return metav1.Time{} 87 | } 88 | if a.Status.OperationState.FinishedAt != nil { 89 | return *a.Status.OperationState.FinishedAt 90 | } 91 | return a.Status.OperationState.StartedAt 92 | } 93 | -------------------------------------------------------------------------------- /internal/argocd/application_test.go: -------------------------------------------------------------------------------- 1 | package argocd 2 | 3 | import ( 4 | "testing" 5 | 6 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 7 | ) 8 | 9 | func TestGetApplicationExternalURL(t *testing.T) { 10 | t.Run("Empty", func(t *testing.T) { 11 | externalURL := GetApplicationExternalURL(argocdv1alpha1.Application{}) 12 | if externalURL != "" { 13 | t.Errorf("externalURL wants empty but got %s", externalURL) 14 | } 15 | }) 16 | t.Run("Single URL", func(t *testing.T) { 17 | app := argocdv1alpha1.Application{ 18 | Status: argocdv1alpha1.ApplicationStatus{ 19 | Summary: argocdv1alpha1.ApplicationSummary{ 20 | ExternalURLs: []string{"https://example.com"}, 21 | }, 22 | }, 23 | } 24 | externalURL := GetApplicationExternalURL(app) 25 | if want := "https://example.com"; externalURL != want { 26 | t.Errorf("externalURL wants %s but got %s", want, externalURL) 27 | } 28 | }) 29 | t.Run("Multiple URLs", func(t *testing.T) { 30 | app := argocdv1alpha1.Application{ 31 | Status: argocdv1alpha1.ApplicationStatus{ 32 | Summary: argocdv1alpha1.ApplicationSummary{ 33 | ExternalURLs: []string{"https://example.com", "https://example.org"}, 34 | }, 35 | }, 36 | } 37 | externalURL := GetApplicationExternalURL(app) 38 | if want := "https://example.com"; externalURL != want { 39 | t.Errorf("externalURL wants %s but got %s", want, externalURL) 40 | } 41 | }) 42 | t.Run("Single URL with pipe", func(t *testing.T) { 43 | app := argocdv1alpha1.Application{ 44 | Status: argocdv1alpha1.ApplicationStatus{ 45 | Summary: argocdv1alpha1.ApplicationSummary{ 46 | ExternalURLs: []string{"Argo CD|https://example.com/argocd"}, 47 | }, 48 | }, 49 | } 50 | externalURL := GetApplicationExternalURL(app) 51 | if want := "https://example.com/argocd"; externalURL != want { 52 | t.Errorf("externalURL wants %s but got %s", want, externalURL) 53 | } 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /internal/argocd/configmap.go: -------------------------------------------------------------------------------- 1 | package argocd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "k8s.io/api/core/v1" 8 | "k8s.io/apimachinery/pkg/types" 9 | "sigs.k8s.io/controller-runtime/pkg/client" 10 | ) 11 | 12 | // GetExternalURL returns the URL of Argo CD if available. 13 | // See https://github.com/argoproj/argo-cd/blob/master/docs/operator-manual/argocd-cm.yaml 14 | func GetExternalURL(ctx context.Context, c client.Client, namespace string) (string, error) { 15 | var cm v1.ConfigMap 16 | err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "argocd-cm"}, &cm) 17 | if err != nil { 18 | return "", fmt.Errorf("unable to get Argo CD ConfigMap: %w", err) 19 | } 20 | url, ok := cm.Data["url"] 21 | if !ok { 22 | return "", fmt.Errorf("url is not set in ConfigMap %s", cm.Name) 23 | } 24 | return url, nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/controller/applicationcomment_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 8 | "github.com/argoproj/gitops-engine/pkg/health" 9 | synccommon "github.com/argoproj/gitops-engine/pkg/sync/common" 10 | "github.com/int128/argocd-commenter/internal/controller/githubmock" 11 | . "github.com/onsi/ginkgo/v2" 12 | . "github.com/onsi/gomega" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | ) 15 | 16 | var _ = Describe("Comment", func() { 17 | var app argocdv1alpha1.Application 18 | var createComment githubmock.CreateComment 19 | 20 | BeforeEach(func(ctx context.Context) { 21 | By("Setting up a comment endpoint") 22 | createComment = githubmock.CreateComment{} 23 | githubServer.Handle( 24 | "GET /api/v3/repos/owner/repo-comment/commits/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa101/pulls", 25 | githubmock.ListPullRequestsWithCommit(101), 26 | ) 27 | githubServer.Handle( 28 | "GET /api/v3/repos/owner/repo-comment/pulls/101/files", 29 | githubmock.ListPullRequestFiles(), 30 | ) 31 | githubServer.Handle( 32 | "POST /api/v3/repos/owner/repo-comment/issues/101/comments", 33 | &createComment, 34 | ) 35 | 36 | By("Creating an application") 37 | app = argocdv1alpha1.Application{ 38 | TypeMeta: metav1.TypeMeta{ 39 | APIVersion: "argoproj.io/v1alpha1", 40 | Kind: "Application", 41 | }, 42 | ObjectMeta: metav1.ObjectMeta{ 43 | GenerateName: "fixture-phase-comment-", 44 | Namespace: "default", 45 | }, 46 | Spec: argocdv1alpha1.ApplicationSpec{ 47 | Project: "default", 48 | Source: &argocdv1alpha1.ApplicationSource{ 49 | RepoURL: "https://github.com/owner/repo-comment.git", 50 | Path: "test", 51 | TargetRevision: "main", 52 | }, 53 | Destination: argocdv1alpha1.ApplicationDestination{ 54 | Server: "https://kubernetes.default.svc", 55 | Namespace: "default", 56 | }, 57 | }, 58 | } 59 | Expect(k8sClient.Create(ctx, &app)).Should(Succeed()) 60 | }) 61 | 62 | Context("When the application is synced", func() { 63 | BeforeEach(func(ctx context.Context) { 64 | By("Updating the application to running") 65 | startedAt := metav1.Now() 66 | app.Status.OperationState = &argocdv1alpha1.OperationState{ 67 | Phase: synccommon.OperationRunning, 68 | StartedAt: startedAt, 69 | Operation: argocdv1alpha1.Operation{ 70 | Sync: &argocdv1alpha1.SyncOperation{ 71 | Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa101", 72 | }, 73 | }, 74 | } 75 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 76 | Eventually(func() int { return createComment.Count() }).Should(Equal(1)) 77 | 78 | By("Updating the application to succeeded") 79 | finishedAt := metav1.Now() 80 | app.Status.OperationState = &argocdv1alpha1.OperationState{ 81 | Phase: synccommon.OperationSucceeded, 82 | StartedAt: startedAt, 83 | FinishedAt: &finishedAt, 84 | Operation: argocdv1alpha1.Operation{ 85 | Sync: &argocdv1alpha1.SyncOperation{ 86 | Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa101", 87 | }, 88 | }, 89 | } 90 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 91 | Eventually(func() int { return createComment.Count() }).Should(Equal(2)) 92 | }) 93 | 94 | Context("When the application is healthy", func() { 95 | It("Should create comments", func(ctx context.Context) { 96 | By("Updating the application to progressing") 97 | app.Status.Health = argocdv1alpha1.HealthStatus{ 98 | Status: health.HealthStatusProgressing, 99 | } 100 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 101 | 102 | By("Updating the application to healthy") 103 | app.Status.Health = argocdv1alpha1.HealthStatus{ 104 | Status: health.HealthStatusHealthy, 105 | } 106 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 107 | Eventually(func() int { return createComment.Count() }).Should(Equal(3)) 108 | }, SpecTimeout(3*time.Second)) 109 | 110 | It("Should create healthy comment once", func(ctx context.Context) { 111 | By("Updating the application to progressing") 112 | app.Status.Health = argocdv1alpha1.HealthStatus{ 113 | Status: health.HealthStatusProgressing, 114 | } 115 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 116 | 117 | By("Updating the application to healthy") 118 | app.Status.Health = argocdv1alpha1.HealthStatus{ 119 | Status: health.HealthStatusHealthy, 120 | } 121 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 122 | Eventually(func() int { return createComment.Count() }).Should(Equal(3)) 123 | 124 | By("Updating the application to progressing") 125 | app.Status.Health = argocdv1alpha1.HealthStatus{ 126 | Status: health.HealthStatusProgressing, 127 | } 128 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 129 | 130 | By("Updating the application to healthy") 131 | app.Status.Health = argocdv1alpha1.HealthStatus{ 132 | Status: health.HealthStatusHealthy, 133 | } 134 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 135 | Consistently(func() int { return createComment.Count() }, 100*time.Millisecond).Should(Equal(3)) 136 | }, SpecTimeout(3*time.Second)) 137 | }) 138 | 139 | Context("When the application is degraded", func() { 140 | It("Should create comments", func(ctx context.Context) { 141 | By("Updating the application to progressing") 142 | app.Status.Health = argocdv1alpha1.HealthStatus{ 143 | Status: health.HealthStatusProgressing, 144 | } 145 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 146 | 147 | By("Updating the application to degraded") 148 | app.Status.Health = argocdv1alpha1.HealthStatus{ 149 | Status: health.HealthStatusDegraded, 150 | } 151 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 152 | Eventually(func() int { return createComment.Count() }).Should(Equal(3)) 153 | }, SpecTimeout(3*time.Second)) 154 | }) 155 | }) 156 | 157 | Context("When the sync operation is failed", func() { 158 | It("Should create comments", func(ctx context.Context) { 159 | By("Updating the application to running") 160 | app.Status.OperationState = &argocdv1alpha1.OperationState{ 161 | Phase: synccommon.OperationRunning, 162 | StartedAt: metav1.Now(), 163 | Operation: argocdv1alpha1.Operation{ 164 | Sync: &argocdv1alpha1.SyncOperation{ 165 | Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa101", 166 | }, 167 | }, 168 | } 169 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 170 | Eventually(func() int { return createComment.Count() }).Should(Equal(1)) 171 | 172 | By("Updating the application to retrying") 173 | startedAt := metav1.Now() 174 | app.Status.OperationState = &argocdv1alpha1.OperationState{ 175 | Phase: synccommon.OperationRunning, 176 | StartedAt: startedAt, 177 | RetryCount: 1, 178 | Operation: argocdv1alpha1.Operation{ 179 | Sync: &argocdv1alpha1.SyncOperation{ 180 | Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa101", 181 | }, 182 | }, 183 | } 184 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 185 | Consistently(func() int { return createComment.Count() }, 100*time.Millisecond).Should(Equal(1)) 186 | 187 | By("Updating the application to failed") 188 | finishedAt := metav1.Now() 189 | app.Status.OperationState = &argocdv1alpha1.OperationState{ 190 | Phase: synccommon.OperationFailed, 191 | StartedAt: startedAt, 192 | FinishedAt: &finishedAt, 193 | Operation: argocdv1alpha1.Operation{ 194 | Sync: &argocdv1alpha1.SyncOperation{ 195 | Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa101", 196 | }, 197 | }, 198 | } 199 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 200 | Eventually(func() int { return createComment.Count() }).Should(Equal(2)) 201 | }, SpecTimeout(3*time.Second)) 202 | }) 203 | 204 | Context("When an application is synced", func() { 205 | It("Should notify a comment for healthy after 1s", func(ctx context.Context) { 206 | requeueTimeToEvaluateHealthStatusAfterSyncOperation = 1 * time.Second 207 | 208 | By("Updating the application to running") 209 | startedAt := metav1.Now() 210 | app.Status = argocdv1alpha1.ApplicationStatus{ 211 | OperationState: &argocdv1alpha1.OperationState{ 212 | Phase: synccommon.OperationRunning, 213 | StartedAt: startedAt, 214 | Operation: argocdv1alpha1.Operation{ 215 | Sync: &argocdv1alpha1.SyncOperation{ 216 | Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa101", 217 | }, 218 | }, 219 | }, 220 | Health: argocdv1alpha1.HealthStatus{ 221 | Status: health.HealthStatusHealthy, 222 | }, 223 | } 224 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 225 | Eventually(func() int { return createComment.Count() }).Should(Equal(1)) 226 | 227 | By("Updating the application to succeeded") 228 | finishedAt := metav1.Now() 229 | app.Status.OperationState = &argocdv1alpha1.OperationState{ 230 | Phase: synccommon.OperationSucceeded, 231 | StartedAt: startedAt, 232 | FinishedAt: &finishedAt, 233 | Operation: argocdv1alpha1.Operation{ 234 | Sync: &argocdv1alpha1.SyncOperation{ 235 | Revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa101", 236 | }, 237 | }, 238 | } 239 | Expect(k8sClient.Update(ctx, &app)).Should(Succeed()) 240 | Eventually(func() int { return createComment.Count() }).Should(Equal(2)) 241 | 242 | By("It should create a comment for healthy") 243 | Eventually(func() int { return createComment.Count() }).WithTimeout(2 * time.Second).Should(Equal(3)) 244 | }, SpecTimeout(3*time.Second)) 245 | }) 246 | }) 247 | -------------------------------------------------------------------------------- /internal/controller/applicationdeletiondeployment_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | 22 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 23 | "github.com/argoproj/gitops-engine/pkg/health" 24 | "github.com/int128/argocd-commenter/internal/argocd" 25 | "github.com/int128/argocd-commenter/internal/controller/eventfilter" 26 | "github.com/int128/argocd-commenter/internal/notification" 27 | corev1 "k8s.io/api/core/v1" 28 | "k8s.io/apimachinery/pkg/runtime" 29 | "k8s.io/client-go/tools/record" 30 | ctrl "sigs.k8s.io/controller-runtime" 31 | "sigs.k8s.io/controller-runtime/pkg/client" 32 | "sigs.k8s.io/controller-runtime/pkg/log" 33 | ) 34 | 35 | // ApplicationDeletionDeploymentReconciler reconciles an Application object. 36 | // It creates a deployment status when the Application is deleting. 37 | type ApplicationDeletionDeploymentReconciler struct { 38 | client.Client 39 | Scheme *runtime.Scheme 40 | Recorder record.EventRecorder 41 | Notification notification.Client 42 | } 43 | 44 | //+kubebuilder:rbac:groups=argoproj.io,resources=applications,verbs=get;watch;list 45 | //+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;watch;list 46 | //+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch 47 | 48 | func (r *ApplicationDeletionDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 49 | logger := log.FromContext(ctx) 50 | 51 | var app argocdv1alpha1.Application 52 | if err := r.Get(ctx, req.NamespacedName, &app); err != nil { 53 | return ctrl.Result{}, client.IgnoreNotFound(err) 54 | } 55 | deploymentURL := argocd.GetDeploymentURL(app) 56 | if deploymentURL == "" { 57 | return ctrl.Result{}, nil 58 | } 59 | if !isApplicationDeleting(app) { 60 | return ctrl.Result{}, nil 61 | } 62 | 63 | argocdURL, err := argocd.GetExternalURL(ctx, r.Client, req.Namespace) 64 | if err != nil { 65 | logger.Info("unable to determine Argo CD URL", "error", err) 66 | } 67 | 68 | if err := r.Notification.CreateDeploymentStatusOnDeletion(ctx, app, argocdURL); err != nil { 69 | r.Recorder.Eventf(&app, corev1.EventTypeWarning, "CreateDeploymentStatusError", 70 | "unable to create a deployment status on deletion: %s", err) 71 | } else { 72 | r.Recorder.Eventf(&app, corev1.EventTypeNormal, "CreatedDeploymentStatus", 73 | "created a deployment status on deletion") 74 | } 75 | return ctrl.Result{}, nil 76 | } 77 | 78 | func isApplicationDeleting(app argocdv1alpha1.Application) bool { 79 | if !app.DeletionTimestamp.IsZero() { 80 | return true 81 | } 82 | if app.Status.Health.Status == health.HealthStatusMissing { 83 | return true 84 | } 85 | return false 86 | } 87 | 88 | // SetupWithManager sets up the controller with the Manager. 89 | func (r *ApplicationDeletionDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error { 90 | r.Recorder = mgr.GetEventRecorderFor("application-deletion-deployment") 91 | return ctrl.NewControllerManagedBy(mgr). 92 | Named("applicationDeletionDeployment"). 93 | For(&argocdv1alpha1.Application{}). 94 | WithEventFilter(eventfilter.ApplicationChanged(filterApplicationDeletionForDeploymentStatus)). 95 | Complete(r) 96 | } 97 | 98 | func filterApplicationDeletionForDeploymentStatus(appOld, appNew argocdv1alpha1.Application) bool { 99 | if argocd.GetDeploymentURL(appNew) == "" { 100 | return false 101 | } 102 | 103 | // DeletionTimestamp has been set 104 | if appOld.DeletionTimestamp != appNew.DeletionTimestamp && !appNew.DeletionTimestamp.IsZero() { 105 | return true 106 | } 107 | 108 | // The health status has been changed to missing 109 | healthOld, healthNew := appOld.Status.Health.Status, appNew.Status.Health.Status 110 | if healthOld != healthNew && healthNew == health.HealthStatusMissing { 111 | return true 112 | } 113 | 114 | return false 115 | } 116 | -------------------------------------------------------------------------------- /internal/controller/applicationhealthcomment_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "slices" 22 | "time" 23 | 24 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 25 | "github.com/argoproj/gitops-engine/pkg/health" 26 | synccommon "github.com/argoproj/gitops-engine/pkg/sync/common" 27 | argocdcommenterv1 "github.com/int128/argocd-commenter/api/v1" 28 | "github.com/int128/argocd-commenter/internal/argocd" 29 | "github.com/int128/argocd-commenter/internal/controller/eventfilter" 30 | "github.com/int128/argocd-commenter/internal/notification" 31 | corev1 "k8s.io/api/core/v1" 32 | apierrors "k8s.io/apimachinery/pkg/api/errors" 33 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 34 | "k8s.io/apimachinery/pkg/runtime" 35 | "k8s.io/client-go/tools/record" 36 | ctrl "sigs.k8s.io/controller-runtime" 37 | "sigs.k8s.io/controller-runtime/pkg/client" 38 | "sigs.k8s.io/controller-runtime/pkg/log" 39 | ) 40 | 41 | var ( 42 | // When an application is synced but the health status is not changed, 43 | // the controller will evaluate the health status after this time. 44 | requeueTimeToEvaluateHealthStatusAfterSyncOperation = 30 * time.Second 45 | ) 46 | 47 | // ApplicationHealthCommentReconciler reconciles a change of Application object. 48 | // It creates a comment when the health status is changed. 49 | type ApplicationHealthCommentReconciler struct { 50 | client.Client 51 | Scheme *runtime.Scheme 52 | Recorder record.EventRecorder 53 | Notification notification.Client 54 | } 55 | 56 | //+kubebuilder:rbac:groups=argoproj.io,resources=applications,verbs=get;watch;list 57 | //+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;watch;list 58 | //+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch 59 | //+kubebuilder:rbac:groups=argocdcommenter.int128.github.io,resources=applicationhealths,verbs=get;list;watch;create;update;patch 60 | //+kubebuilder:rbac:groups=argocdcommenter.int128.github.io,resources=applicationhealths/status,verbs=get;update;patch 61 | 62 | func (r *ApplicationHealthCommentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 63 | logger := log.FromContext(ctx) 64 | 65 | var app argocdv1alpha1.Application 66 | if err := r.Get(ctx, req.NamespacedName, &app); err != nil { 67 | return ctrl.Result{}, client.IgnoreNotFound(err) 68 | } 69 | if !app.DeletionTimestamp.IsZero() { 70 | return ctrl.Result{}, nil 71 | } 72 | 73 | var appHealth argocdcommenterv1.ApplicationHealth 74 | if err := r.Client.Get(ctx, req.NamespacedName, &appHealth); err != nil { 75 | if !apierrors.IsNotFound(err) { 76 | logger.Error(err, "unable to get the ApplicationHealth") 77 | return ctrl.Result{}, err 78 | } 79 | appHealth.ObjectMeta = metav1.ObjectMeta{ 80 | Namespace: req.Namespace, 81 | Name: req.Name, 82 | } 83 | if err := ctrl.SetControllerReference(&app, &appHealth, r.Scheme); err != nil { 84 | logger.Error(err, "unable to set the controller reference to the ApplicationHealth") 85 | return ctrl.Result{}, err 86 | } 87 | if err := r.Client.Create(ctx, &appHealth); err != nil { 88 | logger.Error(err, "unable to create an ApplicationHealth") 89 | return ctrl.Result{}, err 90 | } 91 | logger.Info("created an ApplicationHealth") 92 | } 93 | 94 | sourceRevisions := argocd.GetSourceRevisions(app) 95 | if len(sourceRevisions) == 0 { 96 | return ctrl.Result{}, nil 97 | } 98 | currentRevision := sourceRevisions[0].Revision 99 | if appHealth.Status.LastHealthyRevision == currentRevision { 100 | logger.Info("current revision is already healthy", "revision", currentRevision) 101 | return ctrl.Result{}, nil 102 | } 103 | 104 | // Evaluate the health status only if the sync operation is succeeded. 105 | phase := argocd.GetSyncOperationPhase(app) 106 | if phase != synccommon.OperationSucceeded { 107 | return ctrl.Result{}, nil 108 | } 109 | syncOperationFinishedAt := argocd.GetSyncOperationFinishedAt(app) 110 | if syncOperationFinishedAt == nil { 111 | return ctrl.Result{}, nil 112 | } 113 | 114 | // If this controller is run just after the sync operation, 115 | // it will evaluate the health status after a few seconds. 116 | // https://github.com/int128/argocd-commenter/issues/1044 117 | if time.Since(syncOperationFinishedAt.Time) < requeueTimeToEvaluateHealthStatusAfterSyncOperation { 118 | logger.Info("Requeue later to evaluate the health status", "after", requeueTimeToEvaluateHealthStatusAfterSyncOperation, 119 | "syncOperationFinishedAt", syncOperationFinishedAt) 120 | return ctrl.Result{RequeueAfter: requeueTimeToEvaluateHealthStatusAfterSyncOperation}, nil 121 | } 122 | 123 | argocdURL, err := argocd.GetExternalURL(ctx, r.Client, req.Namespace) 124 | if err != nil { 125 | logger.Info("unable to determine Argo CD URL", "error", err) 126 | } 127 | 128 | if err := r.Notification.CreateCommentsOnHealthChanged(ctx, app, argocdURL); err != nil { 129 | r.Recorder.Eventf(&app, corev1.EventTypeWarning, "CreateCommentError", 130 | "unable to create a comment on health status %s: %s", app.Status.Health.Status, err) 131 | } else { 132 | r.Recorder.Eventf(&app, corev1.EventTypeNormal, "CreatedComment", 133 | "created a comment on health status %s", app.Status.Health.Status) 134 | } 135 | 136 | if app.Status.Health.Status != health.HealthStatusHealthy { 137 | return ctrl.Result{}, nil 138 | } 139 | patch := client.MergeFrom(appHealth.DeepCopy()) 140 | appHealth.Status.LastHealthyRevision = currentRevision 141 | if err := r.Client.Status().Patch(ctx, &appHealth, patch); err != nil { 142 | logger.Error(err, "unable to patch lastHealthyRevision") 143 | return ctrl.Result{}, client.IgnoreNotFound(err) 144 | } 145 | r.Recorder.Eventf(&appHealth, corev1.EventTypeNormal, "UpdatedLastHealthyRevision", 146 | "patched lastHealthyRevision to %s", currentRevision) 147 | return ctrl.Result{}, nil 148 | } 149 | 150 | // SetupWithManager sets up the controller with the Manager. 151 | func (r *ApplicationHealthCommentReconciler) SetupWithManager(mgr ctrl.Manager) error { 152 | r.Recorder = mgr.GetEventRecorderFor("application-health-comment") 153 | return ctrl.NewControllerManagedBy(mgr). 154 | Named("applicationHealthComment"). 155 | For(&argocdv1alpha1.Application{}). 156 | WithEventFilter(eventfilter.ApplicationChanged(filterApplicationHealthStatusForComment)). 157 | Complete(r) 158 | } 159 | 160 | func filterApplicationHealthStatusForComment(appOld, appNew argocdv1alpha1.Application) bool { 161 | // When the health status is changed 162 | healthOld, healthNew := appOld.Status.Health.Status, appNew.Status.Health.Status 163 | if healthOld != healthNew && slices.Contains(notification.HealthStatusesForComment, healthNew) { 164 | return true 165 | } 166 | 167 | // When an application is synced but the health status is not changed, 168 | // the controller will evaluate the health status after sync. 169 | phaseOld, phaseNew := argocd.GetSyncOperationPhase(appOld), argocd.GetSyncOperationPhase(appNew) 170 | if phaseOld != phaseNew && phaseNew == synccommon.OperationSucceeded { 171 | return true 172 | } 173 | 174 | return false 175 | } 176 | -------------------------------------------------------------------------------- /internal/controller/applicationhealthdeployment_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "slices" 22 | "time" 23 | 24 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 25 | synccommon "github.com/argoproj/gitops-engine/pkg/sync/common" 26 | "github.com/int128/argocd-commenter/internal/argocd" 27 | "github.com/int128/argocd-commenter/internal/controller/eventfilter" 28 | "github.com/int128/argocd-commenter/internal/notification" 29 | corev1 "k8s.io/api/core/v1" 30 | "k8s.io/apimachinery/pkg/runtime" 31 | "k8s.io/client-go/tools/record" 32 | ctrl "sigs.k8s.io/controller-runtime" 33 | "sigs.k8s.io/controller-runtime/pkg/client" 34 | "sigs.k8s.io/controller-runtime/pkg/log" 35 | ) 36 | 37 | var ( 38 | // When the GitHub Deployment is not found, this action will retry by this interval 39 | // until the application is synced with a valid GitHub Deployment. 40 | // This should be reasonable to avoid the rate limit of GitHub API. 41 | requeueIntervalWhenDeploymentNotFound = 30 * time.Second 42 | 43 | // When the GitHub Deployment is not found, this action will retry by this timeout. 44 | // Argo CD refreshes an application every 3 minutes by default. 45 | // This should be reasonable to avoid the rate limit of GitHub API. 46 | requeueTimeoutWhenDeploymentNotFound = 10 * time.Minute 47 | ) 48 | 49 | // ApplicationHealthDeploymentReconciler reconciles an Application object. 50 | // It creates a deployment status when the health status is changed. 51 | type ApplicationHealthDeploymentReconciler struct { 52 | client.Client 53 | Scheme *runtime.Scheme 54 | Recorder record.EventRecorder 55 | Notification notification.Client 56 | } 57 | 58 | //+kubebuilder:rbac:groups=argoproj.io,resources=applications,verbs=get;watch;list 59 | //+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;watch;list 60 | //+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch 61 | //+kubebuilder:rbac:groups=argocdcommenter.int128.github.io,resources=applicationhealths,verbs=get;list;watch;create;update;patch 62 | //+kubebuilder:rbac:groups=argocdcommenter.int128.github.io,resources=applicationhealths/status,verbs=get;update;patch 63 | 64 | func (r *ApplicationHealthDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 65 | logger := log.FromContext(ctx) 66 | 67 | var app argocdv1alpha1.Application 68 | if err := r.Get(ctx, req.NamespacedName, &app); err != nil { 69 | return ctrl.Result{}, client.IgnoreNotFound(err) 70 | } 71 | if !app.DeletionTimestamp.IsZero() { 72 | return ctrl.Result{}, nil 73 | } 74 | deploymentURL := argocd.GetDeploymentURL(app) 75 | if deploymentURL == "" { 76 | return ctrl.Result{}, nil 77 | } 78 | 79 | deploymentIsAlreadyHealthy, err := r.Notification.CheckIfDeploymentIsAlreadyHealthy(ctx, deploymentURL) 80 | if notification.IsNotFoundError(err) { 81 | // Retry until the application is synced with a valid GitHub Deployment. 82 | // https://github.com/int128/argocd-commenter/issues/762 83 | lastOperationAt := argocd.GetLastOperationAt(app).Time 84 | if time.Since(lastOperationAt) < requeueTimeoutWhenDeploymentNotFound { 85 | r.Recorder.Eventf(&app, corev1.EventTypeNormal, "DeploymentNotFound", 86 | "deployment %s not found, retry after %s", deploymentURL, requeueIntervalWhenDeploymentNotFound) 87 | return ctrl.Result{RequeueAfter: requeueIntervalWhenDeploymentNotFound}, nil 88 | } 89 | r.Recorder.Eventf(&app, corev1.EventTypeWarning, "DeploymentNotFoundRetryTimeout", 90 | "deployment %s not found but retry timed out", deploymentURL) 91 | return ctrl.Result{}, nil 92 | } 93 | if deploymentIsAlreadyHealthy { 94 | r.Recorder.Eventf(&app, corev1.EventTypeNormal, "DeploymentAlreadyHealthy", 95 | "skip on status %s because deployment %s is already healthy", app.Status.Health.Status, deploymentURL) 96 | return ctrl.Result{}, nil 97 | } 98 | 99 | // Evaluate the health status only if the sync operation is succeeded. 100 | phase := argocd.GetSyncOperationPhase(app) 101 | if phase != synccommon.OperationSucceeded { 102 | return ctrl.Result{}, nil 103 | } 104 | syncOperationFinishedAt := argocd.GetSyncOperationFinishedAt(app) 105 | if syncOperationFinishedAt == nil { 106 | return ctrl.Result{}, nil 107 | } 108 | 109 | // If this controller is run just after the sync operation, 110 | // it will evaluate the health status after a few seconds. 111 | // https://github.com/int128/argocd-commenter/issues/1044 112 | if time.Since(syncOperationFinishedAt.Time) < requeueTimeToEvaluateHealthStatusAfterSyncOperation { 113 | logger.Info("Requeue later to evaluate the health status", "after", requeueTimeToEvaluateHealthStatusAfterSyncOperation, 114 | "syncOperationFinishedAt", syncOperationFinishedAt) 115 | return ctrl.Result{RequeueAfter: requeueTimeToEvaluateHealthStatusAfterSyncOperation}, nil 116 | } 117 | 118 | argocdURL, err := argocd.GetExternalURL(ctx, r.Client, req.Namespace) 119 | if err != nil { 120 | logger.Info("unable to determine Argo CD URL", "error", err) 121 | } 122 | 123 | if err := r.Notification.CreateDeploymentStatusOnHealthChanged(ctx, app, argocdURL); err != nil { 124 | r.Recorder.Eventf(&app, corev1.EventTypeWarning, "CreateDeploymentStatusError", 125 | "unable to create a deployment status on health status %s: %s", app.Status.Health.Status, err) 126 | } else { 127 | r.Recorder.Eventf(&app, corev1.EventTypeNormal, "CreatedDeploymentStatus", 128 | "created a deployment status on health status %s", app.Status.Health.Status) 129 | } 130 | return ctrl.Result{}, nil 131 | } 132 | 133 | // SetupWithManager sets up the controller with the Manager. 134 | func (r *ApplicationHealthDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error { 135 | r.Recorder = mgr.GetEventRecorderFor("application-health-deployment") 136 | return ctrl.NewControllerManagedBy(mgr). 137 | Named("applicationHealthDeployment"). 138 | For(&argocdv1alpha1.Application{}). 139 | WithEventFilter(eventfilter.ApplicationChanged(filterApplicationHealthStatusForDeploymentStatus)). 140 | Complete(r) 141 | } 142 | 143 | func filterApplicationHealthStatusForDeploymentStatus(appOld, appNew argocdv1alpha1.Application) bool { 144 | if argocd.GetDeploymentURL(appNew) == "" { 145 | return false 146 | } 147 | 148 | // When the health status is changed 149 | healthOld, healthNew := appOld.Status.Health.Status, appNew.Status.Health.Status 150 | if healthOld != healthNew && slices.Contains(notification.HealthStatusesForDeploymentStatus, healthNew) { 151 | return true 152 | } 153 | 154 | // When an application is synced but the health status is not changed, 155 | // the controller will evaluate the health status after sync. 156 | phaseOld, phaseNew := argocd.GetSyncOperationPhase(appOld), argocd.GetSyncOperationPhase(appNew) 157 | if phaseOld != phaseNew && phaseNew == synccommon.OperationSucceeded { 158 | return true 159 | } 160 | 161 | return false 162 | } 163 | -------------------------------------------------------------------------------- /internal/controller/applicationphasecomment_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "slices" 22 | 23 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 24 | "github.com/int128/argocd-commenter/internal/argocd" 25 | "github.com/int128/argocd-commenter/internal/controller/eventfilter" 26 | "github.com/int128/argocd-commenter/internal/notification" 27 | corev1 "k8s.io/api/core/v1" 28 | "k8s.io/apimachinery/pkg/runtime" 29 | "k8s.io/client-go/tools/record" 30 | ctrl "sigs.k8s.io/controller-runtime" 31 | "sigs.k8s.io/controller-runtime/pkg/client" 32 | "sigs.k8s.io/controller-runtime/pkg/log" 33 | ) 34 | 35 | // ApplicationPhaseCommentReconciler reconciles an Application object. 36 | // It creates a comment when the sync operation phase is changed. 37 | type ApplicationPhaseCommentReconciler struct { 38 | client.Client 39 | Scheme *runtime.Scheme 40 | Recorder record.EventRecorder 41 | Notification notification.Client 42 | } 43 | 44 | //+kubebuilder:rbac:groups=argoproj.io,resources=applications,verbs=get;watch;list 45 | //+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;watch;list 46 | //+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch 47 | 48 | func (r *ApplicationPhaseCommentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 49 | logger := log.FromContext(ctx) 50 | 51 | var app argocdv1alpha1.Application 52 | if err := r.Get(ctx, req.NamespacedName, &app); err != nil { 53 | return ctrl.Result{}, client.IgnoreNotFound(err) 54 | } 55 | if !app.DeletionTimestamp.IsZero() { 56 | return ctrl.Result{}, nil 57 | } 58 | phase := argocd.GetSyncOperationPhase(app) 59 | if phase == "" { 60 | return ctrl.Result{}, nil 61 | } 62 | 63 | argocdURL, err := argocd.GetExternalURL(ctx, r.Client, req.Namespace) 64 | if err != nil { 65 | logger.Info("unable to determine Argo CD URL", "error", err) 66 | } 67 | 68 | if err := r.Notification.CreateCommentsOnPhaseChanged(ctx, app, argocdURL); err != nil { 69 | r.Recorder.Eventf(&app, corev1.EventTypeWarning, "CreateCommentError", 70 | "unable to create a comment on sync operation phase %s: %s", phase, err) 71 | } else { 72 | r.Recorder.Eventf(&app, corev1.EventTypeNormal, "CreatedComment", 73 | "created a comment on sync operation phase %s", phase) 74 | } 75 | return ctrl.Result{}, nil 76 | } 77 | 78 | // SetupWithManager sets up the controller with the Manager. 79 | func (r *ApplicationPhaseCommentReconciler) SetupWithManager(mgr ctrl.Manager) error { 80 | r.Recorder = mgr.GetEventRecorderFor("application-phase-comment") 81 | return ctrl.NewControllerManagedBy(mgr). 82 | Named("applicationPhaseComment"). 83 | For(&argocdv1alpha1.Application{}). 84 | WithEventFilter(eventfilter.ApplicationChanged(filterApplicationSyncOperationPhaseForComment)). 85 | Complete(r) 86 | } 87 | 88 | func filterApplicationSyncOperationPhaseForComment(appOld, appNew argocdv1alpha1.Application) bool { 89 | phaseOld, phaseNew := argocd.GetSyncOperationPhase(appOld), argocd.GetSyncOperationPhase(appNew) 90 | if phaseNew == "" { 91 | return false 92 | } 93 | if phaseOld == phaseNew { 94 | return false 95 | } 96 | 97 | return slices.Contains(notification.SyncOperationPhasesForComment, phaseNew) 98 | } 99 | -------------------------------------------------------------------------------- /internal/controller/applicationphasedeployment_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "slices" 22 | "time" 23 | 24 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 25 | "github.com/int128/argocd-commenter/internal/argocd" 26 | "github.com/int128/argocd-commenter/internal/controller/eventfilter" 27 | "github.com/int128/argocd-commenter/internal/notification" 28 | corev1 "k8s.io/api/core/v1" 29 | "k8s.io/apimachinery/pkg/runtime" 30 | "k8s.io/client-go/tools/record" 31 | ctrl "sigs.k8s.io/controller-runtime" 32 | "sigs.k8s.io/controller-runtime/pkg/client" 33 | "sigs.k8s.io/controller-runtime/pkg/log" 34 | ) 35 | 36 | // ApplicationPhaseDeploymentReconciler reconciles an Application object. 37 | // It creates a deployment status when the sync operation phase is changed. 38 | type ApplicationPhaseDeploymentReconciler struct { 39 | client.Client 40 | Scheme *runtime.Scheme 41 | Recorder record.EventRecorder 42 | Notification notification.Client 43 | } 44 | 45 | //+kubebuilder:rbac:groups=argoproj.io,resources=applications,verbs=get;watch;list 46 | //+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;watch;list 47 | //+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch 48 | 49 | func (r *ApplicationPhaseDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 50 | logger := log.FromContext(ctx) 51 | 52 | var app argocdv1alpha1.Application 53 | if err := r.Get(ctx, req.NamespacedName, &app); err != nil { 54 | return ctrl.Result{}, client.IgnoreNotFound(err) 55 | } 56 | if !app.DeletionTimestamp.IsZero() { 57 | return ctrl.Result{}, nil 58 | } 59 | phase := argocd.GetSyncOperationPhase(app) 60 | if phase == "" { 61 | return ctrl.Result{}, nil 62 | } 63 | 64 | deploymentURL := argocd.GetDeploymentURL(app) 65 | if deploymentURL == "" { 66 | return ctrl.Result{}, nil 67 | } 68 | deploymentIsAlreadyHealthy, err := r.Notification.CheckIfDeploymentIsAlreadyHealthy(ctx, deploymentURL) 69 | if notification.IsNotFoundError(err) { 70 | // Retry until the application is synced with a valid GitHub Deployment. 71 | // https://github.com/int128/argocd-commenter/issues/762 72 | lastOperationAt := argocd.GetLastOperationAt(app).Time 73 | if time.Since(lastOperationAt) < requeueTimeoutWhenDeploymentNotFound { 74 | r.Recorder.Eventf(&app, corev1.EventTypeNormal, "DeploymentNotFound", 75 | "deployment %s not found, retry after %s", deploymentURL, requeueIntervalWhenDeploymentNotFound) 76 | return ctrl.Result{RequeueAfter: requeueIntervalWhenDeploymentNotFound}, nil 77 | } 78 | r.Recorder.Eventf(&app, corev1.EventTypeWarning, "DeploymentNotFoundRetryTimeout", 79 | "deployment %s not found but retry timed out", deploymentURL) 80 | return ctrl.Result{}, nil 81 | } 82 | if deploymentIsAlreadyHealthy { 83 | r.Recorder.Eventf(&app, corev1.EventTypeNormal, "DeploymentAlreadyHealthy", 84 | "skip on sync operation phase %s because deployment %s is already healthy", phase, deploymentURL) 85 | return ctrl.Result{}, nil 86 | } 87 | 88 | argocdURL, err := argocd.GetExternalURL(ctx, r.Client, req.Namespace) 89 | if err != nil { 90 | logger.Info("unable to determine Argo CD URL", "error", err) 91 | } 92 | 93 | if err := r.Notification.CreateDeploymentStatusOnPhaseChanged(ctx, app, argocdURL); err != nil { 94 | r.Recorder.Eventf(&app, corev1.EventTypeWarning, "CreateDeploymentStatusError", 95 | "unable to create a deployment status on sync operation phase %s: %s", phase, err) 96 | } else { 97 | r.Recorder.Eventf(&app, corev1.EventTypeNormal, "CreatedDeploymentStatus", 98 | "created a deployment status on sync operation phase %s", phase) 99 | } 100 | return ctrl.Result{}, nil 101 | } 102 | 103 | // SetupWithManager sets up the controller with the Manager. 104 | func (r *ApplicationPhaseDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error { 105 | r.Recorder = mgr.GetEventRecorderFor("application-phase-deployment") 106 | return ctrl.NewControllerManagedBy(mgr). 107 | Named("applicationPhaseDeployment"). 108 | For(&argocdv1alpha1.Application{}). 109 | WithEventFilter(eventfilter.ApplicationChanged(filterApplicationSyncOperationPhaseForDeploymentStatus)). 110 | Complete(r) 111 | } 112 | 113 | func filterApplicationSyncOperationPhaseForDeploymentStatus(appOld, appNew argocdv1alpha1.Application) bool { 114 | if argocd.GetDeploymentURL(appNew) == "" { 115 | return false 116 | } 117 | 118 | phaseOld, phaseNew := argocd.GetSyncOperationPhase(appOld), argocd.GetSyncOperationPhase(appNew) 119 | if phaseNew == "" { 120 | return false 121 | } 122 | if phaseOld == phaseNew { 123 | return false 124 | } 125 | 126 | return slices.Contains(notification.SyncOperationPhasesForDeploymentStatus, phaseNew) 127 | } 128 | -------------------------------------------------------------------------------- /internal/controller/eventfilter/application.go: -------------------------------------------------------------------------------- 1 | package eventfilter 2 | 3 | import ( 4 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 5 | "sigs.k8s.io/controller-runtime/pkg/event" 6 | "sigs.k8s.io/controller-runtime/pkg/predicate" 7 | ) 8 | 9 | // ApplicationChangedFunc is a function to compare the application. 10 | // It must return true if the application is changed. 11 | type ApplicationChangedFunc func(appOld, appNew argocdv1alpha1.Application) bool 12 | 13 | // ApplicationChanged is an event filter triggering when the application is changed. 14 | type ApplicationChanged ApplicationChangedFunc 15 | 16 | var _ predicate.Predicate = ApplicationChanged(func(_, _ argocdv1alpha1.Application) bool { return false }) 17 | 18 | func (f ApplicationChanged) Update(e event.UpdateEvent) bool { 19 | appOld, ok := e.ObjectOld.(*argocdv1alpha1.Application) 20 | if !ok { 21 | return false 22 | } 23 | appNew, ok := e.ObjectNew.(*argocdv1alpha1.Application) 24 | if !ok { 25 | return false 26 | } 27 | return f(*appOld, *appNew) 28 | } 29 | 30 | func (ApplicationChanged) Create(event.CreateEvent) bool { 31 | return false 32 | } 33 | func (ApplicationChanged) Delete(event.DeleteEvent) bool { 34 | return false 35 | } 36 | func (ApplicationChanged) Generic(event.GenericEvent) bool { 37 | return false 38 | } 39 | -------------------------------------------------------------------------------- /internal/controller/githubmock/endpoint.go: -------------------------------------------------------------------------------- 1 | package githubmock 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "sync/atomic" 7 | 8 | "github.com/google/go-github/v72/github" 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func ListPullRequestsWithCommit(number int) http.HandlerFunc { 14 | return func(w http.ResponseWriter, r *http.Request) { 15 | w.Header().Add("content-type", "application/json") 16 | w.WriteHeader(200) 17 | Expect(json.NewEncoder(w).Encode([]*github.PullRequest{{Number: github.Ptr(number)}})).Should(Succeed()) 18 | } 19 | } 20 | 21 | func ListPullRequestFiles() http.HandlerFunc { 22 | return func(w http.ResponseWriter, r *http.Request) { 23 | w.Header().Add("content-type", "application/json") 24 | w.WriteHeader(200) 25 | Expect(json.NewEncoder(w).Encode([]*github.CommitFile{{Filename: github.Ptr("test/deployment.yaml")}})).Should(Succeed()) 26 | } 27 | } 28 | 29 | type recorder struct { 30 | counter atomic.Int32 31 | } 32 | 33 | func (e *recorder) Count() int { 34 | return int(e.counter.Load()) 35 | } 36 | 37 | type CreateComment struct { 38 | recorder 39 | } 40 | 41 | func (e *CreateComment) ServeHTTP(w http.ResponseWriter, r *http.Request) { 42 | e.counter.Add(1) 43 | var req github.IssueComment 44 | Expect(json.NewDecoder(r.Body).Decode(&req)).Should(Succeed()) 45 | GinkgoWriter.Println("GITHUB", "created comment", req) 46 | w.Header().Add("content-type", "application/json") 47 | w.WriteHeader(200) 48 | } 49 | 50 | type ListDeploymentStatus struct { 51 | Response []*github.DeploymentStatus 52 | } 53 | 54 | func (e *ListDeploymentStatus) ServeHTTP(w http.ResponseWriter, _ *http.Request) { 55 | w.Header().Add("content-type", "application/json") 56 | w.WriteHeader(200) 57 | Expect(json.NewEncoder(w).Encode(e.Response)).Should(Succeed()) 58 | } 59 | 60 | type CreateDeploymentStatus struct { 61 | recorder 62 | } 63 | 64 | func (e *CreateDeploymentStatus) ServeHTTP(w http.ResponseWriter, r *http.Request) { 65 | e.counter.Add(1) 66 | var req github.DeploymentStatusRequest 67 | Expect(json.NewDecoder(r.Body).Decode(&req)).Should(Succeed()) 68 | GinkgoWriter.Println("GITHUB", "created deployment status", req) 69 | w.Header().Add("content-type", "application/json") 70 | w.WriteHeader(200) 71 | } 72 | -------------------------------------------------------------------------------- /internal/controller/githubmock/server.go: -------------------------------------------------------------------------------- 1 | package githubmock 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | ) 10 | 11 | type Server struct { 12 | mu sync.Mutex 13 | routes map[string]http.Handler 14 | } 15 | 16 | func (sv *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 17 | defer GinkgoRecover() 18 | methodURI := fmt.Sprintf("%s %s", r.Method, r.RequestURI) 19 | GinkgoWriter.Println("GITHUB", methodURI) 20 | handler := sv.getHandler(methodURI) 21 | if handler == nil { 22 | http.NotFound(w, r) 23 | return 24 | } 25 | handler.ServeHTTP(w, r) 26 | } 27 | 28 | func (sv *Server) getHandler(methodURI string) http.Handler { 29 | sv.mu.Lock() 30 | defer sv.mu.Unlock() 31 | return sv.routes[methodURI] 32 | } 33 | 34 | func (sv *Server) Handle(methodURI string, handler http.Handler) { 35 | sv.mu.Lock() 36 | defer sv.mu.Unlock() 37 | if sv.routes == nil { 38 | sv.routes = make(map[string]http.Handler) 39 | } 40 | sv.routes[methodURI] = handler 41 | } 42 | -------------------------------------------------------------------------------- /internal/controller/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controller 18 | 19 | import ( 20 | "context" 21 | "go/build" 22 | "net/http/httptest" 23 | "os" 24 | "path/filepath" 25 | "testing" 26 | "time" 27 | 28 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 29 | "github.com/int128/argocd-commenter/internal/controller/githubmock" 30 | "github.com/int128/argocd-commenter/internal/github" 31 | "github.com/int128/argocd-commenter/internal/notification" 32 | . "github.com/onsi/ginkgo/v2" 33 | . "github.com/onsi/gomega" 34 | "go.uber.org/zap/zapcore" 35 | corev1 "k8s.io/api/core/v1" 36 | "k8s.io/client-go/kubernetes/scheme" 37 | ctrl "sigs.k8s.io/controller-runtime" 38 | "sigs.k8s.io/controller-runtime/pkg/client" 39 | "sigs.k8s.io/controller-runtime/pkg/envtest" 40 | logf "sigs.k8s.io/controller-runtime/pkg/log" 41 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 42 | 43 | argocdcommenterv1 "github.com/int128/argocd-commenter/api/v1" 44 | // +kubebuilder:scaffold:imports 45 | ) 46 | 47 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 48 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 49 | 50 | var ( 51 | k8sClient client.Client 52 | githubServer githubmock.Server 53 | ) 54 | 55 | var _ = BeforeEach(func() { 56 | requeueIntervalWhenDeploymentNotFound = 1 * time.Second 57 | 58 | requeueTimeToEvaluateHealthStatusAfterSyncOperation = 0 59 | }) 60 | 61 | func TestControllers(t *testing.T) { 62 | RegisterFailHandler(Fail) 63 | 64 | RunSpecs(t, "Controller Suite") 65 | } 66 | 67 | var _ = BeforeSuite(func() { 68 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true), 69 | func(o *zap.Options) { 70 | o.TimeEncoder = zapcore.RFC3339NanoTimeEncoder 71 | })) 72 | 73 | By("Finding the Argo CD Application CRD") 74 | crdPaths, err := filepath.Glob(filepath.Join( 75 | build.Default.GOPATH, "pkg", "mod", 76 | "github.com", "argoproj", "argo-cd", "v2@*", "manifests", "crds", "application-crd.yaml", 77 | )) 78 | Expect(err).NotTo(HaveOccurred()) 79 | Expect(crdPaths).NotTo(BeEmpty()) 80 | 81 | By("Bootstrapping test environment") 82 | crdPaths = append(crdPaths, filepath.Join("..", "..", "config", "crd", "bases")) 83 | testEnv := &envtest.Environment{ 84 | CRDDirectoryPaths: crdPaths, 85 | ErrorIfCRDPathMissing: true, 86 | } 87 | 88 | ctx, cancel := context.WithCancel(context.TODO()) 89 | DeferCleanup(func() { 90 | cancel() 91 | By("Tearing down the test environment") 92 | Expect(testEnv.Stop()).Should(Succeed()) 93 | }) 94 | 95 | // Retrieve the first found binary directory to allow running tests from IDEs 96 | if getFirstFoundEnvTestBinaryDir() != "" { 97 | testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() 98 | } 99 | 100 | // cfg is defined in this file globally. 101 | cfg, err := testEnv.Start() 102 | Expect(err).NotTo(HaveOccurred()) 103 | Expect(cfg).NotTo(BeNil()) 104 | 105 | err = argocdv1alpha1.AddToScheme(scheme.Scheme) 106 | Expect(err).NotTo(HaveOccurred()) 107 | 108 | err = argocdcommenterv1.AddToScheme(scheme.Scheme) 109 | Expect(err).NotTo(HaveOccurred()) 110 | 111 | // +kubebuilder:scaffold:scheme 112 | 113 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 114 | Expect(err).NotTo(HaveOccurred()) 115 | Expect(k8sClient).NotTo(BeNil()) 116 | 117 | By("Creating argocd-cm") 118 | Expect(k8sClient.Create(ctx, &corev1.ConfigMap{ 119 | ObjectMeta: ctrl.ObjectMeta{ 120 | Name: "argocd-cm", 121 | Namespace: "default", 122 | }, 123 | // https://argo-cd.readthedocs.io/en/stable/operator-manual/argocd-cm-yaml/ 124 | Data: map[string]string{ 125 | "url": "https://argocd.example.com", 126 | }, 127 | })).Should(Succeed()) 128 | 129 | By("Setting up the GitHub mock server") 130 | githubMockServer := httptest.NewServer(&githubServer) 131 | DeferCleanup(func() { 132 | By("Shutting down the GitHub mock server") 133 | githubMockServer.Close() 134 | }) 135 | GinkgoT().Setenv("GITHUB_TOKEN", "dummy-github-token") 136 | GinkgoT().Setenv("GITHUB_ENTERPRISE_URL", githubMockServer.URL) 137 | ghc, err := github.NewClient(ctx) 138 | Expect(err).NotTo(HaveOccurred()) 139 | nc := notification.NewClient(ghc) 140 | 141 | By("Setting up the controller manager") 142 | k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ 143 | Scheme: scheme.Scheme, 144 | }) 145 | Expect(err).ToNot(HaveOccurred()) 146 | 147 | err = (&ApplicationPhaseCommentReconciler{ 148 | Client: k8sManager.GetClient(), 149 | Scheme: k8sManager.GetScheme(), 150 | Notification: nc, 151 | }).SetupWithManager(k8sManager) 152 | Expect(err).ToNot(HaveOccurred()) 153 | 154 | err = (&ApplicationHealthCommentReconciler{ 155 | Client: k8sManager.GetClient(), 156 | Scheme: k8sManager.GetScheme(), 157 | Notification: nc, 158 | }).SetupWithManager(k8sManager) 159 | Expect(err).ToNot(HaveOccurred()) 160 | 161 | err = (&ApplicationPhaseDeploymentReconciler{ 162 | Client: k8sManager.GetClient(), 163 | Scheme: k8sManager.GetScheme(), 164 | Notification: nc, 165 | }).SetupWithManager(k8sManager) 166 | Expect(err).ToNot(HaveOccurred()) 167 | 168 | err = (&ApplicationHealthDeploymentReconciler{ 169 | Client: k8sManager.GetClient(), 170 | Scheme: k8sManager.GetScheme(), 171 | Notification: nc, 172 | }).SetupWithManager(k8sManager) 173 | Expect(err).ToNot(HaveOccurred()) 174 | 175 | err = (&ApplicationDeletionDeploymentReconciler{ 176 | Client: k8sManager.GetClient(), 177 | Scheme: k8sManager.GetScheme(), 178 | Notification: nc, 179 | }).SetupWithManager(k8sManager) 180 | Expect(err).ToNot(HaveOccurred()) 181 | 182 | go func() { 183 | defer GinkgoRecover() 184 | By("Starting the controller manager") 185 | err := k8sManager.Start(ctx) 186 | Expect(err).ToNot(HaveOccurred(), "failed to run manager") 187 | }() 188 | }) 189 | 190 | // getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. 191 | // ENVTEST-based tests depend on specific binaries, usually located in paths set by 192 | // controller-runtime. When running tests directly (e.g., via an IDE) without using 193 | // Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. 194 | // 195 | // This function streamlines the process by finding the required binaries, similar to 196 | // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are 197 | // properly set up, run 'make setup-envtest' beforehand. 198 | func getFirstFoundEnvTestBinaryDir() string { 199 | basePath := filepath.Join("..", "..", "bin", "k8s") 200 | entries, err := os.ReadDir(basePath) 201 | if err != nil { 202 | logf.Log.Error(err, "Failed to read directory", "path", basePath) 203 | return "" 204 | } 205 | for _, entry := range entries { 206 | if entry.IsDir() { 207 | return filepath.Join(basePath, entry.Name()) 208 | } 209 | } 210 | return "" 211 | } 212 | -------------------------------------------------------------------------------- /internal/github/client.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/google/go-github/v72/github" 10 | "github.com/gregjones/httpcache" 11 | "github.com/int128/oauth2-github-app" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | type client struct { 16 | rest *github.Client 17 | } 18 | 19 | func NewClient(ctx context.Context) (Client, error) { 20 | transport := httpcache.NewMemoryCacheTransport() 21 | ctx = context.WithValue(ctx, oauth2.HTTPClient, transport) 22 | oauth2Client, err := newOAuth2Client(ctx) 23 | if err != nil { 24 | return nil, fmt.Errorf("could not create an OAuth2 client: %w", err) 25 | } 26 | ghc, err := newGitHubClient(oauth2Client) 27 | if err != nil { 28 | return nil, fmt.Errorf("could not create a GitHub client: %w", err) 29 | } 30 | return &client{rest: ghc}, nil 31 | } 32 | 33 | func newOAuth2Client(ctx context.Context) (*http.Client, error) { 34 | var ( 35 | token = os.Getenv("GITHUB_TOKEN") 36 | appID = os.Getenv("GITHUB_APP_ID") 37 | installationID = os.Getenv("GITHUB_APP_INSTALLATION_ID") 38 | privateKey = os.Getenv("GITHUB_APP_PRIVATE_KEY") 39 | ghesURL = os.Getenv("GITHUB_ENTERPRISE_URL") 40 | ) 41 | if token != "" { 42 | return oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})), nil 43 | } 44 | if appID == "" || installationID == "" || privateKey == "" { 45 | return nil, fmt.Errorf("you need to set either GITHUB_TOKEN or GitHub App configuration") 46 | } 47 | k, err := oauth2githubapp.ParsePrivateKey([]byte(privateKey)) 48 | if err != nil { 49 | return nil, fmt.Errorf("invalid GITHUB_APP_PRIVATE_KEY: %w", err) 50 | } 51 | cfg := oauth2githubapp.Config{ 52 | PrivateKey: k, 53 | AppID: appID, 54 | InstallationID: installationID, 55 | BaseURL: ghesURL, 56 | } 57 | return oauth2.NewClient(ctx, cfg.TokenSource(ctx)), nil 58 | } 59 | 60 | func newGitHubClient(hc *http.Client) (*github.Client, error) { 61 | ghesURL := os.Getenv("GITHUB_ENTERPRISE_URL") 62 | if ghesURL != "" { 63 | ghc, err := github.NewClient(hc).WithEnterpriseURLs(ghesURL, ghesURL) 64 | if err != nil { 65 | return nil, fmt.Errorf("could not create a GitHub Enterprise client: %w", err) 66 | } 67 | return ghc, nil 68 | } 69 | return github.NewClient(hc), nil 70 | } 71 | -------------------------------------------------------------------------------- /internal/github/comment.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/google/go-github/v72/github" 8 | ) 9 | 10 | type Comment struct { 11 | Repository Repository 12 | CommitSHA string 13 | Body string 14 | } 15 | 16 | func (c *client) CreatePullRequestComment(ctx context.Context, r Repository, pullNumber int, body string) error { 17 | _, _, err := c.rest.Issues.CreateComment(ctx, r.Owner, r.Name, pullNumber, 18 | &github.IssueComment{Body: github.Ptr(body)}) 19 | if err != nil { 20 | return fmt.Errorf("could not create a comment to the pull request #%d: %w", pullNumber, err) 21 | } 22 | return nil 23 | } 24 | 25 | func (c *client) CreateCommitComment(ctx context.Context, r Repository, sha, body string) error { 26 | _, _, err := c.rest.Repositories.CreateComment(ctx, r.Owner, r.Name, sha, 27 | &github.RepositoryComment{Body: github.Ptr(body)}) 28 | if err != nil { 29 | return fmt.Errorf("could not create a comment to the commit %s: %w", sha, err) 30 | } 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/github/deployment.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | 9 | "github.com/google/go-github/v72/github" 10 | ) 11 | 12 | type Deployment struct { 13 | Repository Repository 14 | Id int64 15 | } 16 | 17 | var patternDeploymentURL = regexp.MustCompile(`^https://api\.github\.com/repos/(.+?)/(.+?)/deployments/(\d+)$`) 18 | 19 | // ParseDeploymentURL parses the URL. 20 | // For example, https://api.github.com/repos/int128/sandbox/deployments/422988781 21 | func ParseDeploymentURL(s string) *Deployment { 22 | m := patternDeploymentURL.FindStringSubmatch(s) 23 | if len(m) != 4 { 24 | return nil 25 | } 26 | id, err := strconv.ParseInt(m[3], 10, 64) 27 | if err != nil { 28 | return nil 29 | } 30 | return &Deployment{ 31 | Repository: Repository{Owner: m[1], Name: m[2]}, 32 | Id: int64(id), 33 | } 34 | } 35 | 36 | type DeploymentStatus struct { 37 | State string 38 | Description string 39 | LogURL string 40 | EnvironmentURL string 41 | } 42 | 43 | func (c *client) CreateDeploymentStatus(ctx context.Context, d Deployment, ds DeploymentStatus) error { 44 | r := github.DeploymentStatusRequest{ 45 | State: github.Ptr(ds.State), 46 | Description: github.Ptr(ds.Description), 47 | } 48 | if ds.LogURL != "" { 49 | r.LogURL = github.Ptr(ds.LogURL) 50 | } 51 | if ds.EnvironmentURL != "" { 52 | r.EnvironmentURL = github.Ptr(ds.EnvironmentURL) 53 | } 54 | _, _, err := c.rest.Repositories.CreateDeploymentStatus(ctx, d.Repository.Owner, d.Repository.Name, d.Id, &r) 55 | if err != nil { 56 | return fmt.Errorf("GitHub API error: %w", err) 57 | } 58 | return nil 59 | } 60 | 61 | func (c *client) FindLatestDeploymentStatus(ctx context.Context, d Deployment) (*DeploymentStatus, error) { 62 | r, _, err := c.rest.Repositories.ListDeploymentStatuses(ctx, d.Repository.Owner, d.Repository.Name, d.Id, nil) 63 | if err != nil { 64 | return nil, fmt.Errorf("GitHub API error: %w", err) 65 | } 66 | if len(r) == 0 { 67 | return nil, nil 68 | } 69 | ds := r[0] 70 | return &DeploymentStatus{ 71 | State: ds.GetState(), 72 | Description: ds.GetDescription(), 73 | LogURL: ds.GetLogURL(), 74 | EnvironmentURL: ds.GetEnvironmentURL(), 75 | }, nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/github/deployment_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "testing" 4 | 5 | func TestParseDeploymentURL(t *testing.T) { 6 | t.Run("valid", func(t *testing.T) { 7 | d := ParseDeploymentURL("https://api.github.com/repos/int128/sandbox/deployments/422988781") 8 | if d == nil { 9 | t.Fatalf("deployment was nil") 10 | } 11 | if want := (Repository{Owner: "int128", Name: "sandbox"}); d.Repository != want { 12 | t.Errorf("want %+v but was %+v", want, d.Repository) 13 | } 14 | if d.Id != 422988781 { 15 | t.Errorf("want %d but was %d", 422988781, d.Id) 16 | } 17 | }) 18 | 19 | t.Run("not deployment", func(t *testing.T) { 20 | d := ParseDeploymentURL("https://api.github.com/repos/int128/sandbox") 21 | if d != nil { 22 | t.Errorf("want nil but was %+v", d) 23 | } 24 | }) 25 | 26 | t.Run("empty", func(t *testing.T) { 27 | d := ParseDeploymentURL("") 28 | if d != nil { 29 | t.Errorf("want nil but was %+v", d) 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /internal/github/pull.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | func (c *client) ListPullRequests(ctx context.Context, r Repository, revision string) ([]PullRequest, error) { 9 | ghPulls, _, err := c.rest.PullRequests.ListPullRequestsWithCommit(ctx, r.Owner, r.Name, revision, nil) 10 | if err != nil { 11 | return nil, fmt.Errorf("could not list pull requests with commit: %w", err) 12 | } 13 | var pulls []PullRequest 14 | for _, pr := range ghPulls { 15 | prFiles, _, err := c.rest.PullRequests.ListFiles(ctx, r.Owner, r.Name, pr.GetNumber(), nil) 16 | if err != nil { 17 | return nil, fmt.Errorf("could not list files of pull request #%d: %w", pr.GetNumber(), err) 18 | } 19 | var files []string 20 | for _, f := range prFiles { 21 | files = append(files, f.GetFilename()) 22 | } 23 | pulls = append(pulls, PullRequest{Number: pr.GetNumber(), Files: files}) 24 | } 25 | return pulls, nil 26 | } 27 | -------------------------------------------------------------------------------- /internal/github/types.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "regexp" 7 | 8 | "github.com/google/go-github/v72/github" 9 | ) 10 | 11 | type Client interface { 12 | ListPullRequests(ctx context.Context, r Repository, revision string) ([]PullRequest, error) 13 | CreatePullRequestComment(ctx context.Context, r Repository, pullNumber int, body string) error 14 | CreateCommitComment(ctx context.Context, r Repository, sha, body string) error 15 | CreateDeploymentStatus(ctx context.Context, d Deployment, ds DeploymentStatus) error 16 | FindLatestDeploymentStatus(ctx context.Context, d Deployment) (*DeploymentStatus, error) 17 | } 18 | 19 | type Repository struct { 20 | Owner string 21 | Name string 22 | } 23 | 24 | var ( 25 | patternRepositoryHTTPS = regexp.MustCompile(`^https://github\.com/([^/]+?)/([^/]+?)(\.git)?$`) 26 | patternRepositorySSH = regexp.MustCompile(`^git@github\.com:([^/]+?)/([^/]+?)(\.git)?$`) 27 | ) 28 | 29 | func ParseRepositoryURL(s string) *Repository { 30 | if r := parseRepositoryHTTPS(s); r != nil { 31 | return r 32 | } 33 | if r := parseRepositorySSH(s); r != nil { 34 | return r 35 | } 36 | return nil 37 | } 38 | 39 | func parseRepositoryHTTPS(s string) *Repository { 40 | m := patternRepositoryHTTPS.FindStringSubmatch(s) 41 | if len(m) < 3 { 42 | return nil 43 | } 44 | return &Repository{Owner: m[1], Name: m[2]} 45 | } 46 | 47 | func parseRepositorySSH(s string) *Repository { 48 | m := patternRepositorySSH.FindStringSubmatch(s) 49 | if len(m) < 3 { 50 | return nil 51 | } 52 | return &Repository{Owner: m[1], Name: m[2]} 53 | } 54 | 55 | type PullRequest struct { 56 | Number int 57 | Files []string 58 | } 59 | 60 | func IsNotFoundError(err error) bool { 61 | var gherr *github.ErrorResponse 62 | if errors.As(err, &gherr) { 63 | if gherr.Response != nil { 64 | return gherr.Response.StatusCode == 404 65 | } 66 | } 67 | return false 68 | } 69 | -------------------------------------------------------------------------------- /internal/github/types_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "testing" 4 | 5 | func TestParseRepositoryURL(t *testing.T) { 6 | t.Run("valid HTTPS", func(t *testing.T) { 7 | r := ParseRepositoryURL("https://github.com/int128/sandbox") 8 | if r == nil { 9 | t.Fatalf("repository was nil") 10 | } 11 | if want := (Repository{Owner: "int128", Name: "sandbox"}); *r != want { 12 | t.Errorf("want %+v but was %+v", &want, r) 13 | } 14 | }) 15 | 16 | t.Run("HTTPS with .git", func(t *testing.T) { 17 | r := ParseRepositoryURL("https://github.com/int128/sandbox.git") 18 | if r == nil { 19 | t.Fatalf("repository was nil") 20 | } 21 | if want := (Repository{Owner: "int128", Name: "sandbox"}); *r != want { 22 | t.Errorf("want %+v but was %+v", &want, r) 23 | } 24 | }) 25 | 26 | t.Run("HTTPS but not repository", func(t *testing.T) { 27 | r := ParseRepositoryURL("https://github.com/int128/sandbox/commits") 28 | if r != nil { 29 | t.Errorf("want nil but was %+v", r) 30 | } 31 | }) 32 | 33 | // https://github.com/argoproj/argo-cd/blob/master/docs/user-guide/private-repositories.md 34 | t.Run("valid SSH", func(t *testing.T) { 35 | r := ParseRepositoryURL("git@github.com:argoproj/argocd-example-apps.git") 36 | if r == nil { 37 | t.Fatalf("repository was nil") 38 | } 39 | if want := (Repository{Owner: "argoproj", Name: "argocd-example-apps"}); *r != want { 40 | t.Errorf("want %+v but was %+v", &want, r) 41 | } 42 | }) 43 | 44 | t.Run("SSH but not GitHub", func(t *testing.T) { 45 | r := ParseRepositoryURL("git@example.com:argoproj/argocd-example-apps.git") 46 | if r != nil { 47 | t.Errorf("want nil but was %+v", r) 48 | } 49 | }) 50 | 51 | t.Run("empty", func(t *testing.T) { 52 | r := ParseRepositoryURL("") 53 | if r != nil { 54 | t.Errorf("want nil but was %+v", r) 55 | } 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /internal/notification/client.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 10 | "github.com/go-logr/logr" 11 | "github.com/int128/argocd-commenter/internal/argocd" 12 | "github.com/int128/argocd-commenter/internal/github" 13 | ) 14 | 15 | type Client interface { 16 | CreateCommentsOnPhaseChanged(ctx context.Context, app argocdv1alpha1.Application, argocdURL string) error 17 | CreateCommentsOnHealthChanged(ctx context.Context, app argocdv1alpha1.Application, argocdURL string) error 18 | CreateDeploymentStatusOnPhaseChanged(ctx context.Context, app argocdv1alpha1.Application, argocdURL string) error 19 | CreateDeploymentStatusOnHealthChanged(ctx context.Context, app argocdv1alpha1.Application, argocdURL string) error 20 | CreateDeploymentStatusOnDeletion(ctx context.Context, app argocdv1alpha1.Application, argocdURL string) error 21 | 22 | CheckIfDeploymentIsAlreadyHealthy(ctx context.Context, deploymentURL string) (bool, error) 23 | } 24 | 25 | func NewClient(ghc github.Client) Client { 26 | return &client{ghc: ghc} 27 | } 28 | 29 | func IsNotFoundError(err error) bool { 30 | return github.IsNotFoundError(err) 31 | } 32 | 33 | type Comment struct { 34 | GitHubRepository github.Repository 35 | SourceRevision argocd.SourceRevision 36 | Body string 37 | } 38 | 39 | type client struct { 40 | ghc github.Client 41 | } 42 | 43 | func (c client) createComment(ctx context.Context, comment Comment, app argocdv1alpha1.Application) error { 44 | logger := logr.FromContextOrDiscard(ctx).WithValues( 45 | "revision", comment.SourceRevision.Revision, 46 | "repository", comment.GitHubRepository, 47 | ) 48 | pulls, err := c.ghc.ListPullRequests(ctx, comment.GitHubRepository, comment.SourceRevision.Revision) 49 | if err != nil { 50 | return fmt.Errorf("unable to list pull requests of revision %s: %w", comment.SourceRevision.Revision, err) 51 | } 52 | relatedPulls := filterPullRequestsRelatedToEvent(pulls, comment.SourceRevision, app) 53 | if len(relatedPulls) == 0 { 54 | logger.Info("No pull request related to the revision") 55 | // This may cause a secondary rate limit error of GitHub API. 56 | if os.Getenv("FEATURE_CREATE_COMMIT_COMMENT") == "true" { 57 | if err := c.ghc.CreateCommitComment(ctx, comment.GitHubRepository, comment.SourceRevision.Revision, comment.Body); err != nil { 58 | return fmt.Errorf("unable to create a comment on revision %s: %w", comment.SourceRevision.Revision, err) 59 | } 60 | logger.Info("Created a comment to the commit") 61 | } 62 | return nil 63 | } 64 | 65 | var errs []error 66 | for _, pull := range relatedPulls { 67 | if err := c.ghc.CreatePullRequestComment(ctx, comment.GitHubRepository, pull.Number, comment.Body); err != nil { 68 | errs = append(errs, err) 69 | continue 70 | } 71 | logger.Info("Created a comment to the pull request", "pullNumber", pull.Number) 72 | } 73 | if err := errors.Join(errs...); err != nil { 74 | return fmt.Errorf("unable to create comment(s) on revision %s: %w", comment.SourceRevision.Revision, err) 75 | } 76 | return nil 77 | } 78 | 79 | type DeploymentStatus struct { 80 | GitHubDeployment github.Deployment 81 | GitHubDeploymentStatus github.DeploymentStatus 82 | } 83 | 84 | func (c client) createDeploymentStatus(ctx context.Context, ds DeploymentStatus) error { 85 | logger := logr.FromContextOrDiscard(ctx).WithValues( 86 | "deployment", ds.GitHubDeployment, 87 | "state", ds.GitHubDeploymentStatus.State, 88 | ) 89 | if err := c.ghc.CreateDeploymentStatus(ctx, ds.GitHubDeployment, ds.GitHubDeploymentStatus); err != nil { 90 | return fmt.Errorf("unable to create a deployment status of %s: %w", ds.GitHubDeploymentStatus.State, err) 91 | } 92 | logger.Info("created a deployment status") 93 | return nil 94 | } 95 | 96 | func (c client) CheckIfDeploymentIsAlreadyHealthy(ctx context.Context, deploymentURL string) (bool, error) { 97 | deployment := github.ParseDeploymentURL(deploymentURL) 98 | if deployment == nil { 99 | return false, nil 100 | } 101 | latestDeploymentStatus, err := c.ghc.FindLatestDeploymentStatus(ctx, *deployment) 102 | if err != nil { 103 | return false, fmt.Errorf("unable to find the latest deployment status: %w", err) 104 | } 105 | if latestDeploymentStatus == nil { 106 | return false, nil 107 | } 108 | return latestDeploymentStatus.State == "success", nil 109 | } 110 | -------------------------------------------------------------------------------- /internal/notification/deletion.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 8 | "github.com/int128/argocd-commenter/internal/argocd" 9 | "github.com/int128/argocd-commenter/internal/github" 10 | ) 11 | 12 | func (c client) CreateDeploymentStatusOnDeletion(ctx context.Context, app argocdv1alpha1.Application, argocdURL string) error { 13 | deploymentURL := argocd.GetDeploymentURL(app) 14 | deployment := github.ParseDeploymentURL(deploymentURL) 15 | if deployment == nil { 16 | return nil 17 | } 18 | ds := &DeploymentStatus{ 19 | GitHubDeployment: *deployment, 20 | GitHubDeploymentStatus: github.DeploymentStatus{ 21 | LogURL: fmt.Sprintf("%s/applications/%s", argocdURL, app.Name), 22 | State: "inactive", 23 | }, 24 | } 25 | 26 | if err := c.createDeploymentStatus(ctx, *ds); err != nil { 27 | return fmt.Errorf("unable to create a deployment status: %w", err) 28 | } 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /internal/notification/healthcomment.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 10 | "github.com/argoproj/gitops-engine/pkg/health" 11 | "github.com/int128/argocd-commenter/internal/argocd" 12 | "github.com/int128/argocd-commenter/internal/github" 13 | ) 14 | 15 | var HealthStatusesForComment = []health.HealthStatusCode{ 16 | health.HealthStatusHealthy, 17 | health.HealthStatusDegraded, 18 | } 19 | 20 | func (c client) CreateCommentsOnHealthChanged(ctx context.Context, app argocdv1alpha1.Application, argocdURL string) error { 21 | var errs []error 22 | sourceRevisions := argocd.GetSourceRevisions(app) 23 | for _, sourceRevision := range sourceRevisions { 24 | comment := generateCommentOnHealthChanged(app, argocdURL, sourceRevision) 25 | if comment == nil { 26 | continue 27 | } 28 | if err := c.createComment(ctx, *comment, app); err != nil { 29 | errs = append(errs, err) 30 | } 31 | } 32 | return errors.Join(errs...) 33 | } 34 | 35 | func generateCommentOnHealthChanged(app argocdv1alpha1.Application, argocdURL string, sourceRevision argocd.SourceRevision) *Comment { 36 | repository := github.ParseRepositoryURL(sourceRevision.Source.RepoURL) 37 | if repository == nil { 38 | return nil 39 | } 40 | body := generateCommentBodyOnHealthChanged(app, argocdURL, sourceRevision) 41 | if body == "" { 42 | return nil 43 | } 44 | return &Comment{ 45 | GitHubRepository: *repository, 46 | SourceRevision: sourceRevision, 47 | Body: body, 48 | } 49 | } 50 | 51 | func generateCommentBodyOnHealthChanged(app argocdv1alpha1.Application, argocdURL string, sourceRevision argocd.SourceRevision) string { 52 | argocdApplicationURL := fmt.Sprintf("%s/applications/%s", argocdURL, app.Name) 53 | switch app.Status.Health.Status { 54 | case health.HealthStatusHealthy: 55 | return fmt.Sprintf(":white_check_mark: %s [%s](%s) at %s", 56 | app.Status.Health.Status, 57 | app.Name, 58 | argocdApplicationURL, 59 | sourceRevision.Revision, 60 | ) 61 | case health.HealthStatusDegraded: 62 | return fmt.Sprintf("## :x: %s [%s](%s) at %s:\n%s", 63 | app.Status.Health.Status, 64 | app.Name, 65 | argocdApplicationURL, 66 | sourceRevision.Revision, 67 | generateCommentResourcesOnHealthChanged(app), 68 | ) 69 | } 70 | return "" 71 | } 72 | 73 | func generateCommentResourcesOnHealthChanged(app argocdv1alpha1.Application) string { 74 | var b strings.Builder 75 | for _, r := range app.Status.Resources { 76 | if r.Health == nil { 77 | continue 78 | } 79 | namespacedName := r.Namespace + "/" + r.Name 80 | switch r.Health.Status { 81 | case health.HealthStatusDegraded, health.HealthStatusMissing: 82 | b.WriteString(fmt.Sprintf("- %s `%s`: %s\n", r.Health.Status, namespacedName, r.Health.Message)) 83 | } 84 | } 85 | return b.String() 86 | } 87 | -------------------------------------------------------------------------------- /internal/notification/healthdeployment.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 9 | "github.com/argoproj/gitops-engine/pkg/health" 10 | "github.com/int128/argocd-commenter/internal/argocd" 11 | "github.com/int128/argocd-commenter/internal/github" 12 | ) 13 | 14 | var HealthStatusesForDeploymentStatus = []health.HealthStatusCode{ 15 | health.HealthStatusHealthy, 16 | health.HealthStatusDegraded, 17 | } 18 | 19 | func (c client) CreateDeploymentStatusOnHealthChanged(ctx context.Context, app argocdv1alpha1.Application, argocdURL string) error { 20 | ds := generateDeploymentStatusOnHealthChanged(app, argocdURL) 21 | if ds == nil { 22 | return nil 23 | } 24 | if err := c.createDeploymentStatus(ctx, *ds); err != nil { 25 | return fmt.Errorf("unable to create a deployment status: %w", err) 26 | } 27 | return nil 28 | } 29 | 30 | func generateDeploymentStatusOnHealthChanged(app argocdv1alpha1.Application, argocdURL string) *DeploymentStatus { 31 | deploymentURL := argocd.GetDeploymentURL(app) 32 | deployment := github.ParseDeploymentURL(deploymentURL) 33 | if deployment == nil { 34 | return nil 35 | } 36 | 37 | ds := DeploymentStatus{ 38 | GitHubDeployment: *deployment, 39 | GitHubDeploymentStatus: github.DeploymentStatus{ 40 | LogURL: fmt.Sprintf("%s/applications/%s", argocdURL, app.Name), 41 | Description: trimDescription(generateDeploymentStatusDescriptionOnHealthChanged(app)), 42 | EnvironmentURL: argocd.GetApplicationExternalURL(app), 43 | }, 44 | } 45 | switch app.Status.Health.Status { 46 | case health.HealthStatusHealthy: 47 | ds.GitHubDeploymentStatus.State = "success" 48 | return &ds 49 | case health.HealthStatusDegraded: 50 | ds.GitHubDeploymentStatus.State = "failure" 51 | return &ds 52 | } 53 | return nil 54 | } 55 | 56 | func generateDeploymentStatusDescriptionOnHealthChanged(app argocdv1alpha1.Application) string { 57 | var b strings.Builder 58 | b.WriteString(fmt.Sprintf("%s:\n", app.Status.Health.Status)) 59 | if app.Status.Health.Message != "" { 60 | b.WriteString(fmt.Sprintf("%s\n", app.Status.Health.Message)) 61 | } 62 | for _, r := range app.Status.Resources { 63 | if r.Health == nil { 64 | continue 65 | } 66 | namespacedName := r.Namespace + "/" + r.Name 67 | switch r.Health.Status { 68 | case health.HealthStatusDegraded, health.HealthStatusMissing: 69 | b.WriteString(fmt.Sprintf("%s: %s: %s\n", namespacedName, r.Health.Status, r.Health.Message)) 70 | } 71 | } 72 | return b.String() 73 | } 74 | 75 | func trimDescription(s string) string { 76 | // The maximum description length is 140 characters. 77 | // https://docs.github.com/en/rest/deployments/statuses?apiVersion=2022-11-28#create-a-deployment-status 78 | if len(s) < 140 { 79 | return s 80 | } 81 | return s[0:139] 82 | } 83 | -------------------------------------------------------------------------------- /internal/notification/phasecomment.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 10 | synccommon "github.com/argoproj/gitops-engine/pkg/sync/common" 11 | "github.com/int128/argocd-commenter/internal/argocd" 12 | "github.com/int128/argocd-commenter/internal/github" 13 | ) 14 | 15 | var SyncOperationPhasesForComment = []synccommon.OperationPhase{ 16 | synccommon.OperationRunning, 17 | synccommon.OperationSucceeded, 18 | synccommon.OperationFailed, 19 | synccommon.OperationError, 20 | } 21 | 22 | func (c client) CreateCommentsOnPhaseChanged(ctx context.Context, app argocdv1alpha1.Application, argocdURL string) error { 23 | var errs []error 24 | sourceRevisions := argocd.GetSourceRevisions(app) 25 | for _, sourceRevision := range sourceRevisions { 26 | comment := generateCommentOnPhaseChanged(app, argocdURL, sourceRevision) 27 | if comment == nil { 28 | continue 29 | } 30 | if err := c.createComment(ctx, *comment, app); err != nil { 31 | errs = append(errs, err) 32 | } 33 | } 34 | return errors.Join(errs...) 35 | } 36 | 37 | func generateCommentOnPhaseChanged(app argocdv1alpha1.Application, argocdURL string, sourceRevision argocd.SourceRevision) *Comment { 38 | repository := github.ParseRepositoryURL(sourceRevision.Source.RepoURL) 39 | if repository == nil { 40 | return nil 41 | } 42 | body := generateCommentBodyOnPhaseChanged(app, argocdURL, sourceRevision) 43 | if body == "" { 44 | return nil 45 | } 46 | return &Comment{ 47 | GitHubRepository: *repository, 48 | SourceRevision: sourceRevision, 49 | Body: body, 50 | } 51 | } 52 | 53 | func generateCommentBodyOnPhaseChanged(app argocdv1alpha1.Application, argocdURL string, sourceRevision argocd.SourceRevision) string { 54 | if app.Status.OperationState == nil { 55 | return "" 56 | } 57 | argocdApplicationURL := fmt.Sprintf("%s/applications/%s", argocdURL, app.Name) 58 | phase := app.Status.OperationState.Phase 59 | switch phase { 60 | case synccommon.OperationRunning: 61 | return fmt.Sprintf(":warning: Syncing [%s](%s) to %s", app.Name, argocdApplicationURL, sourceRevision.Revision) 62 | case synccommon.OperationSucceeded: 63 | return fmt.Sprintf(":white_check_mark: Synced [%s](%s) to %s", app.Name, argocdApplicationURL, sourceRevision.Revision) 64 | case synccommon.OperationFailed: 65 | return fmt.Sprintf("## :x: Failed to sync [%s](%s) to %s\n%s", 66 | app.Name, 67 | argocdApplicationURL, 68 | sourceRevision.Revision, 69 | generateCommentResourcesOnPhaseChanged(app.Status.OperationState.SyncResult), 70 | ) 71 | case synccommon.OperationError: 72 | return fmt.Sprintf("## :x: Sync error [%s](%s) at %s\n%s", 73 | app.Name, 74 | argocdApplicationURL, 75 | sourceRevision.Revision, 76 | generateCommentResourcesOnPhaseChanged(app.Status.OperationState.SyncResult), 77 | ) 78 | } 79 | return "" 80 | } 81 | 82 | func generateCommentResourcesOnPhaseChanged(syncResult *argocdv1alpha1.SyncOperationResult) string { 83 | if syncResult == nil { 84 | return "" 85 | } 86 | var b strings.Builder 87 | for _, r := range syncResult.Resources { 88 | namespacedName := r.Namespace + "/" + r.Name 89 | switch r.Status { 90 | case synccommon.ResultCodeSyncFailed, synccommon.ResultCodePruneSkipped: 91 | b.WriteString(fmt.Sprintf("- %s `%s`: %s\n", r.Status, namespacedName, r.Message)) 92 | } 93 | } 94 | return b.String() 95 | } 96 | -------------------------------------------------------------------------------- /internal/notification/phasedeployment.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 9 | synccommon "github.com/argoproj/gitops-engine/pkg/sync/common" 10 | "github.com/int128/argocd-commenter/internal/argocd" 11 | "github.com/int128/argocd-commenter/internal/github" 12 | ) 13 | 14 | var SyncOperationPhasesForDeploymentStatus = []synccommon.OperationPhase{ 15 | synccommon.OperationRunning, 16 | synccommon.OperationSucceeded, 17 | synccommon.OperationFailed, 18 | synccommon.OperationError, 19 | } 20 | 21 | func (c client) CreateDeploymentStatusOnPhaseChanged(ctx context.Context, app argocdv1alpha1.Application, argocdURL string) error { 22 | ds := generateDeploymentStatusOnPhaseChanged(app, argocdURL) 23 | if ds == nil { 24 | return nil 25 | } 26 | if err := c.createDeploymentStatus(ctx, *ds); err != nil { 27 | return fmt.Errorf("unable to create a deployment status: %w", err) 28 | } 29 | return nil 30 | } 31 | 32 | func generateDeploymentStatusOnPhaseChanged(app argocdv1alpha1.Application, argocdURL string) *DeploymentStatus { 33 | deploymentURL := argocd.GetDeploymentURL(app) 34 | deployment := github.ParseDeploymentURL(deploymentURL) 35 | if deployment == nil { 36 | return nil 37 | } 38 | 39 | phase := argocd.GetSyncOperationPhase(app) 40 | if phase == "" { 41 | return nil 42 | } 43 | 44 | ds := DeploymentStatus{ 45 | GitHubDeployment: *deployment, 46 | GitHubDeploymentStatus: github.DeploymentStatus{ 47 | LogURL: fmt.Sprintf("%s/applications/%s", argocdURL, app.Name), 48 | Description: trimDescription(generateDeploymentStatusDescriptionOnPhaseChanged(app)), 49 | EnvironmentURL: argocd.GetApplicationExternalURL(app), 50 | }, 51 | } 52 | switch phase { 53 | case synccommon.OperationRunning: 54 | ds.GitHubDeploymentStatus.State = "queued" 55 | return &ds 56 | case synccommon.OperationSucceeded: 57 | ds.GitHubDeploymentStatus.State = "in_progress" 58 | return &ds 59 | case synccommon.OperationFailed: 60 | ds.GitHubDeploymentStatus.State = "failure" 61 | return &ds 62 | case synccommon.OperationError: 63 | ds.GitHubDeploymentStatus.State = "failure" 64 | return &ds 65 | } 66 | return nil 67 | } 68 | 69 | func generateDeploymentStatusDescriptionOnPhaseChanged(app argocdv1alpha1.Application) string { 70 | phase := argocd.GetSyncOperationPhase(app) 71 | if phase == "" { 72 | return "" 73 | } 74 | syncResult := app.Status.OperationState.SyncResult 75 | if syncResult == nil { 76 | return "" 77 | } 78 | 79 | var b strings.Builder 80 | b.WriteString(fmt.Sprintf("%s:\n", phase)) 81 | for _, r := range syncResult.Resources { 82 | namespacedName := r.Namespace + "/" + r.Name 83 | switch r.Status { 84 | case synccommon.ResultCodeSyncFailed: 85 | b.WriteString(fmt.Sprintf("%s: %s\n", namespacedName, r.Message)) 86 | } 87 | } 88 | return b.String() 89 | } 90 | -------------------------------------------------------------------------------- /internal/notification/pull.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "path" 5 | "slices" 6 | "strings" 7 | 8 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 9 | "github.com/int128/argocd-commenter/internal/argocd" 10 | "github.com/int128/argocd-commenter/internal/github" 11 | ) 12 | 13 | func filterPullRequestsRelatedToEvent(pulls []github.PullRequest, sourceRevision argocd.SourceRevision, app argocdv1alpha1.Application) []github.PullRequest { 14 | manifestGeneratePaths := getManifestGeneratePaths(app) 15 | 16 | var relatedPulls []github.PullRequest 17 | for _, pull := range pulls { 18 | if isPullRequestRelatedToEvent(pull, sourceRevision, manifestGeneratePaths) { 19 | relatedPulls = append(relatedPulls, pull) 20 | } 21 | } 22 | return relatedPulls 23 | } 24 | 25 | func isPullRequestRelatedToEvent(pull github.PullRequest, sourceRevision argocd.SourceRevision, manifestGeneratePaths []string) bool { 26 | absSourcePath := path.Join("/", sourceRevision.Source.Path) 27 | for _, file := range pull.Files { 28 | absPullFile := path.Join("/", file) 29 | if strings.HasPrefix(absPullFile, absSourcePath) { 30 | return true 31 | } 32 | for _, manifestGeneratePath := range manifestGeneratePaths { 33 | if strings.HasPrefix(absPullFile, manifestGeneratePath) { 34 | return true 35 | } 36 | } 37 | } 38 | return false 39 | } 40 | 41 | // getManifestGeneratePaths returns canonical paths of "argocd.argoproj.io/manifest-generate-paths" annotation. 42 | // It returns nil if the field is nil or empty. 43 | // https://argo-cd.readthedocs.io/en/stable/operator-manual/high_availability/#webhook-and-manifest-paths-annotation 44 | // https://github.com/int128/argocd-commenter/pull/656 45 | func getManifestGeneratePaths(app argocdv1alpha1.Application) []string { 46 | if app.Annotations == nil { 47 | return nil 48 | } 49 | var absPaths []string 50 | manifestGeneratePaths := strings.Split(app.Annotations["argocd.argoproj.io/manifest-generate-paths"], ";") 51 | for _, manifestGeneratePath := range manifestGeneratePaths { 52 | if manifestGeneratePath == "" { 53 | return nil 54 | } 55 | if path.IsAbs(manifestGeneratePath) { 56 | absPaths = append(absPaths, path.Clean(manifestGeneratePath)) 57 | continue 58 | } 59 | 60 | for _, source := range app.Spec.GetSources() { 61 | absPaths = append(absPaths, path.Join("/", source.Path, manifestGeneratePath)) 62 | } 63 | } 64 | return slices.Compact(absPaths) 65 | } 66 | -------------------------------------------------------------------------------- /internal/notification/pull_test.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "testing" 5 | 6 | argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/int128/argocd-commenter/internal/argocd" 9 | "github.com/int128/argocd-commenter/internal/github" 10 | v1meta "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | func Test_isPullRequestRelatedToEvent(t *testing.T) { 14 | t.Run("source path matches", func(t *testing.T) { 15 | pull := github.PullRequest{ 16 | Files: []string{ 17 | "applications/app1/deployment.yaml", 18 | "applications/app2/deployment.yaml", 19 | }, 20 | } 21 | sourceRevision := argocd.SourceRevision{ 22 | Source: argocdv1alpha1.ApplicationSource{ 23 | Path: "applications/app2", 24 | }, 25 | } 26 | got := isPullRequestRelatedToEvent(pull, sourceRevision, nil) 27 | const want = true 28 | if want != got { 29 | t.Errorf("isPullRequestRelatedToEvent wants %v but was %v", want, got) 30 | } 31 | }) 32 | 33 | t.Run("manifest generate path matches", func(t *testing.T) { 34 | pull := github.PullRequest{ 35 | Files: []string{ 36 | "applications/app1/deployment.yaml", 37 | "applications/app2/deployment.yaml", 38 | }, 39 | } 40 | sourceRevision := argocd.SourceRevision{ 41 | Source: argocdv1alpha1.ApplicationSource{ 42 | Path: "applications/app3", 43 | }, 44 | } 45 | manifestGeneratePaths := []string{"/applications/app1"} 46 | got := isPullRequestRelatedToEvent(pull, sourceRevision, manifestGeneratePaths) 47 | const want = true 48 | if want != got { 49 | t.Errorf("isPullRequestRelatedToEvent wants %v but was %v", want, got) 50 | } 51 | }) 52 | 53 | t.Run("no match", func(t *testing.T) { 54 | pull := github.PullRequest{ 55 | Files: []string{ 56 | "applications/app1/deployment.yaml", 57 | "applications/app2/deployment.yaml", 58 | }, 59 | } 60 | sourceRevision := argocd.SourceRevision{ 61 | Source: argocdv1alpha1.ApplicationSource{ 62 | Path: "applications/app3", 63 | }, 64 | } 65 | manifestGeneratePaths := []string{"/applications/app4"} 66 | got := isPullRequestRelatedToEvent(pull, sourceRevision, manifestGeneratePaths) 67 | const want = false 68 | if want != got { 69 | t.Errorf("isPullRequestRelatedToEvent wants %v but was %v", want, got) 70 | } 71 | }) 72 | } 73 | 74 | func Test_getManifestGeneratePaths(t *testing.T) { 75 | t.Run("nil annotation", func(t *testing.T) { 76 | manifestGeneratePaths := getManifestGeneratePaths(argocdv1alpha1.Application{}) 77 | if manifestGeneratePaths != nil { 78 | t.Errorf("manifestGeneratePaths wants nil but was %+v", manifestGeneratePaths) 79 | } 80 | }) 81 | 82 | t.Run("empty annotation", func(t *testing.T) { 83 | t.Run("single source", func(t *testing.T) { 84 | app := argocdv1alpha1.Application{ 85 | ObjectMeta: v1meta.ObjectMeta{ 86 | Annotations: map[string]string{ 87 | "argocd.argoproj.io/manifest-generate-paths": "", 88 | }, 89 | }, 90 | Spec: argocdv1alpha1.ApplicationSpec{ 91 | Source: &argocdv1alpha1.ApplicationSource{ 92 | Path: "/applications/app1", 93 | }, 94 | }, 95 | } 96 | manifestGeneratePaths := getManifestGeneratePaths(app) 97 | if manifestGeneratePaths != nil { 98 | t.Errorf("manifestGeneratePaths wants nil but was %+v", manifestGeneratePaths) 99 | } 100 | }) 101 | t.Run("multiple sources", func(t *testing.T) { 102 | app := argocdv1alpha1.Application{ 103 | ObjectMeta: v1meta.ObjectMeta{ 104 | Annotations: map[string]string{ 105 | "argocd.argoproj.io/manifest-generate-paths": "", 106 | }, 107 | }, 108 | Spec: argocdv1alpha1.ApplicationSpec{ 109 | Sources: argocdv1alpha1.ApplicationSources{ 110 | {Path: "/applications/app1"}, 111 | {Path: "/applications/app2"}, 112 | }, 113 | }, 114 | } 115 | manifestGeneratePaths := getManifestGeneratePaths(app) 116 | if manifestGeneratePaths != nil { 117 | t.Errorf("manifestGeneratePaths wants nil but was %+v", manifestGeneratePaths) 118 | } 119 | }) 120 | }) 121 | 122 | t.Run("absolute path", func(t *testing.T) { 123 | t.Run("single source", func(t *testing.T) { 124 | app := argocdv1alpha1.Application{ 125 | ObjectMeta: v1meta.ObjectMeta{ 126 | Annotations: map[string]string{ 127 | "argocd.argoproj.io/manifest-generate-paths": "/components/app1", 128 | }, 129 | }, 130 | Spec: argocdv1alpha1.ApplicationSpec{ 131 | Source: &argocdv1alpha1.ApplicationSource{ 132 | Path: "/applications/app1", 133 | }, 134 | }, 135 | } 136 | manifestGeneratePaths := getManifestGeneratePaths(app) 137 | want := []string{"/components/app1"} 138 | if diff := cmp.Diff(want, manifestGeneratePaths); diff != "" { 139 | t.Errorf("want != manifestGeneratePaths:\n%s", diff) 140 | } 141 | }) 142 | t.Run("multiple sources", func(t *testing.T) { 143 | app := argocdv1alpha1.Application{ 144 | ObjectMeta: v1meta.ObjectMeta{ 145 | Annotations: map[string]string{ 146 | "argocd.argoproj.io/manifest-generate-paths": "/components/app1", 147 | }, 148 | }, 149 | Spec: argocdv1alpha1.ApplicationSpec{ 150 | Sources: argocdv1alpha1.ApplicationSources{ 151 | {Path: "/applications/app1"}, 152 | {Path: "/applications/app2"}, 153 | }, 154 | }, 155 | } 156 | manifestGeneratePaths := getManifestGeneratePaths(app) 157 | want := []string{"/components/app1"} 158 | if diff := cmp.Diff(want, manifestGeneratePaths); diff != "" { 159 | t.Errorf("want != manifestGeneratePaths:\n%s", diff) 160 | } 161 | }) 162 | }) 163 | 164 | t.Run("relative path of ascendant", func(t *testing.T) { 165 | t.Run("single source", func(t *testing.T) { 166 | app := argocdv1alpha1.Application{ 167 | ObjectMeta: v1meta.ObjectMeta{ 168 | Annotations: map[string]string{ 169 | "argocd.argoproj.io/manifest-generate-paths": "../manifests1", 170 | }, 171 | }, 172 | Spec: argocdv1alpha1.ApplicationSpec{ 173 | Source: &argocdv1alpha1.ApplicationSource{ 174 | Path: "/applications/app1", 175 | }, 176 | }, 177 | } 178 | manifestGeneratePaths := getManifestGeneratePaths(app) 179 | want := []string{"/applications/manifests1"} 180 | if diff := cmp.Diff(want, manifestGeneratePaths); diff != "" { 181 | t.Errorf("want != manifestGeneratePaths:\n%s", diff) 182 | } 183 | }) 184 | t.Run("multiple sources", func(t *testing.T) { 185 | app := argocdv1alpha1.Application{ 186 | ObjectMeta: v1meta.ObjectMeta{ 187 | Annotations: map[string]string{ 188 | "argocd.argoproj.io/manifest-generate-paths": "../manifests1", 189 | }, 190 | }, 191 | Spec: argocdv1alpha1.ApplicationSpec{ 192 | Sources: argocdv1alpha1.ApplicationSources{ 193 | {Path: "/applications/app1"}, 194 | {Path: "/applications/app2"}, 195 | }, 196 | }, 197 | } 198 | manifestGeneratePaths := getManifestGeneratePaths(app) 199 | want := []string{"/applications/manifests1"} 200 | if diff := cmp.Diff(want, manifestGeneratePaths); diff != "" { 201 | t.Errorf("want != manifestGeneratePaths:\n%s", diff) 202 | } 203 | }) 204 | }) 205 | 206 | t.Run("relative path of period", func(t *testing.T) { 207 | t.Run("single source", func(t *testing.T) { 208 | app := argocdv1alpha1.Application{ 209 | ObjectMeta: v1meta.ObjectMeta{ 210 | Annotations: map[string]string{ 211 | "argocd.argoproj.io/manifest-generate-paths": ".", 212 | }, 213 | }, 214 | Spec: argocdv1alpha1.ApplicationSpec{ 215 | Source: &argocdv1alpha1.ApplicationSource{ 216 | Path: "/applications/app1", 217 | }, 218 | }, 219 | } 220 | manifestGeneratePaths := getManifestGeneratePaths(app) 221 | want := []string{"/applications/app1"} 222 | if diff := cmp.Diff(want, manifestGeneratePaths); diff != "" { 223 | t.Errorf("want != manifestGeneratePaths:\n%s", diff) 224 | } 225 | }) 226 | t.Run("multiple sources", func(t *testing.T) { 227 | app := argocdv1alpha1.Application{ 228 | ObjectMeta: v1meta.ObjectMeta{ 229 | Annotations: map[string]string{ 230 | "argocd.argoproj.io/manifest-generate-paths": ".", 231 | }, 232 | }, 233 | Spec: argocdv1alpha1.ApplicationSpec{ 234 | Sources: argocdv1alpha1.ApplicationSources{ 235 | {Path: "/applications/app1"}, 236 | {Path: "/applications/app2"}, 237 | }, 238 | }, 239 | } 240 | manifestGeneratePaths := getManifestGeneratePaths(app) 241 | want := []string{ 242 | "/applications/app1", 243 | "/applications/app2", 244 | } 245 | if diff := cmp.Diff(want, manifestGeneratePaths); diff != "" { 246 | t.Errorf("want != manifestGeneratePaths:\n%s", diff) 247 | } 248 | }) 249 | }) 250 | 251 | t.Run("multiple paths", func(t *testing.T) { 252 | t.Run("single source", func(t *testing.T) { 253 | app := argocdv1alpha1.Application{ 254 | ObjectMeta: v1meta.ObjectMeta{ 255 | Annotations: map[string]string{ 256 | "argocd.argoproj.io/manifest-generate-paths": ".;../manifests1", 257 | }, 258 | }, 259 | Spec: argocdv1alpha1.ApplicationSpec{ 260 | Source: &argocdv1alpha1.ApplicationSource{ 261 | Path: "/applications/app1", 262 | }, 263 | }, 264 | } 265 | manifestGeneratePaths := getManifestGeneratePaths(app) 266 | want := []string{ 267 | "/applications/app1", 268 | "/applications/manifests1", 269 | } 270 | if diff := cmp.Diff(want, manifestGeneratePaths); diff != "" { 271 | t.Errorf("want != manifestGeneratePaths:\n%s", diff) 272 | } 273 | }) 274 | t.Run("multiple sources", func(t *testing.T) { 275 | app := argocdv1alpha1.Application{ 276 | ObjectMeta: v1meta.ObjectMeta{ 277 | Annotations: map[string]string{ 278 | "argocd.argoproj.io/manifest-generate-paths": ".;../manifests1", 279 | }, 280 | }, 281 | Spec: argocdv1alpha1.ApplicationSpec{ 282 | Sources: argocdv1alpha1.ApplicationSources{ 283 | {Path: "/applications/app1"}, 284 | {Path: "/applications/app2"}, 285 | }, 286 | }, 287 | } 288 | manifestGeneratePaths := getManifestGeneratePaths(app) 289 | want := []string{ 290 | "/applications/app1", 291 | "/applications/app2", 292 | "/applications/manifests1", 293 | } 294 | if diff := cmp.Diff(want, manifestGeneratePaths); diff != "" { 295 | t.Errorf("want != manifestGeneratePaths:\n%s", diff) 296 | } 297 | }) 298 | }) 299 | } 300 | --------------------------------------------------------------------------------