├── .github ├── release.yml └── workflows │ ├── ci.yaml │ ├── release.yaml │ └── tagpr.yaml ├── .gitignore ├── .tagpr ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── doc └── screenshot.png ├── example ├── curl.sh ├── kubernetes │ ├── deployment.yaml │ └── service.yaml └── prometheus │ ├── .gitignore │ ├── alertmanager.yml │ ├── prometheus.yml │ └── rules │ └── example.yaml ├── go.mod ├── go.sum ├── main.go └── pkg ├── cli ├── cli.go ├── cli_test.go ├── flag.go ├── flag_test.go ├── samples │ └── issue.json └── templates │ ├── body.tmpl │ └── title.tmpl ├── notifier ├── github.go └── notifier.go ├── server ├── server.go └── server_test.go ├── template └── template.go └── types ├── payload.go └── payload_test.go /.github/release.yml: -------------------------------------------------------------------------------- 1 | # 2 | # This is a configuration for automatic changelog generation 3 | # tagpr respects this. 4 | # ref: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes 5 | # 6 | changelog: 7 | exclude: 8 | labels: 9 | - release-note/skip 10 | - tagpr 11 | categories: 12 | - title: '💣 Breaking Changes' 13 | labels: 14 | - 'release-note/breaking-change' 15 | - title: '🚀 Features' 16 | labels: 17 | - 'release-note/feature' 18 | - title: '🐛 Bug Fixes' 19 | labels: 20 | - 'release-note/bugfix' 21 | - title: '📜 Documentation' 22 | labels: 23 | - 'release-note/document' 24 | - title: '🧰 Maintenance' 25 | labels: 26 | - 'release-note/chore' 27 | - title: '🔬 Other Changes' 28 | labels: 29 | - "*" 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths-ignore: ['**.md'] 7 | pull_request: 8 | types: [opened, synchronize] 9 | paths-ignore: ['**.md'] 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: make lint 17 | - run: make test 18 | 19 | docker-build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - run: make docker-build-all 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | # tagged manually 5 | push: 6 | tags: ["v*"] 7 | # dispatch from tagpr.yaml workflow 8 | workflow_dispatch: 9 | 10 | jobs: 11 | run: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Login to GitHub Container Registry 16 | uses: docker/login-action@v3 17 | with: 18 | registry: ghcr.io 19 | username: ${{ github.actor }} 20 | password: ${{ secrets.GITHUB_TOKEN }} 21 | - run: make docker-build-all 22 | - run: make push-all 23 | -------------------------------------------------------------------------------- /.github/workflows/tagpr.yaml: -------------------------------------------------------------------------------- 1 | name: tagpr 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | types: ["labeled", "unlabeled"] 8 | branches: ["tagpr-from-v*"] 9 | 10 | jobs: 11 | tagpr: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - id: tagpr 16 | name: Tagpr 17 | uses: Songmu/tagpr@v1 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | - name: Trigger Release Workflow(only when tagged) 22 | uses: actions/github-script@v7 23 | if: "steps.tagpr.outputs.tag != ''" 24 | with: 25 | script: | 26 | github.rest.actions.createWorkflowDispatch({ 27 | owner: context.repo.owner, 28 | repo: context.repo.repo, 29 | workflow_id: 'release.yaml', 30 | ref: "refs/tags/${{ steps.tagpr.outputs.tag }}", 31 | }) 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /bin 3 | tmp 4 | -------------------------------------------------------------------------------- /.tagpr: -------------------------------------------------------------------------------- 1 | # config file for the tagpr in git config format 2 | # The tagpr generates the initial configuration, which you can rewrite to suit your environment. 3 | # CONFIGURATIONS: 4 | # tagpr.releaseBranch 5 | # Generally, it is "main." It is the branch for releases. The pcpr tracks this branch, 6 | # creates or updates a pull request as a release candidate, or tags when they are merged. 7 | # 8 | # tagpr.versionFile 9 | # Versioning file containing the semantic version needed to be updated at release. 10 | # It will be synchronized with the "git tag". 11 | # Often this is a meta-information file such as gemspec, setup.cfg, package.json, etc. 12 | # Sometimes the source code file, such as version.go or Bar.pm, is used. 13 | # If you do not want to use versioning files but only git tags, specify the "-" string here. 14 | # You can specify multiple version files by comma separated strings. 15 | # 16 | # tagpr.vPrefix 17 | # Flag whether or not v-prefix is added to semver when git tagging. (e.g. v1.2.3 if true) 18 | # This is only a tagging convention, not how it is described in the version file. 19 | # 20 | # tagpr.changelog (Optional) 21 | # Flag whether or not changelog is added or changed during the release. 22 | # 23 | # tagpr.command (Optional) 24 | # Command to change files just before release. 25 | # 26 | # tagpr.tmplate (Optional) 27 | # Pull request template in go template format 28 | # 29 | # tagpr.release (Optional) 30 | # GitHub Release creation behavior after tagging [true, draft, false] 31 | # If this value is not set, the release is to be created. 32 | [tagpr] 33 | vPrefix = true 34 | releaseBranch = master 35 | versionFile = - 36 | changelog = false 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:1.24 AS base 2 | WORKDIR /workspace 3 | ENV CGO_ENABLED=0 4 | COPY go.* . 5 | RUN --mount=type=cache,target=/go/pkg/mod \ 6 | go mod download 7 | 8 | FROM golangci/golangci-lint:v2.1.6 AS lint-base 9 | FROM base AS lint 10 | RUN --mount=target=. \ 11 | --mount=from=lint-base,src=/usr/bin/golangci-lint,target=/usr/bin/golangci-lint \ 12 | --mount=type=cache,target=/go/pkg/mod \ 13 | --mount=type=cache,target=/root/.cache/go-build \ 14 | --mount=type=cache,target=/root/.cache/golangci-lint \ 15 | golangci-lint run --timeout 10m0s ./... 16 | 17 | FROM base AS unit-test 18 | RUN --mount=target=. \ 19 | --mount=type=cache,target=/go/pkg/mod \ 20 | --mount=type=cache,target=/root/.cache/go-build \ 21 | go test -v ./... 22 | 23 | FROM base AS build 24 | ARG TARGETOS TARGETARCH 25 | RUN --mount=target=. \ 26 | --mount=type=cache,target=/go/pkg/mod \ 27 | --mount=type=cache,target=/root/.cache/go-build \ 28 | GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/alertmanager-to-github . 29 | 30 | FROM scratch AS export 31 | COPY --from=build /out/alertmanager-to-github / 32 | 33 | FROM --platform=$BUILDPLATFORM gcr.io/distroless/static:nonroot 34 | COPY --from=build /out/alertmanager-to-github / 35 | ENTRYPOINT ["/alertmanager-to-github"] 36 | CMD ["start"] 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2020 Preferred Networks, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TAG := $(shell git describe --tags --always --dirty) 2 | IMAGE ?= ghcr.io/pfnet-research/alertmanager-to-github:$(TAG) 3 | ARCH ?= amd64 4 | ALL_ARCH ?= amd64 arm64 5 | 6 | DOCKER_BUILD ?= DOCKER_BUILDKIT=1 docker build --progress=plain 7 | 8 | .PHONY: build 9 | build: 10 | $(DOCKER_BUILD) --target export --output bin/ . 11 | 12 | .PHONY: test 13 | test: 14 | $(DOCKER_BUILD) --target unit-test . 15 | 16 | .PHONY: lint 17 | lint: 18 | $(DOCKER_BUILD) --target lint . 19 | 20 | .PHONY: clean 21 | clean: 22 | rm -rf bin 23 | 24 | .PHONY: docker-build 25 | docker-build: 26 | $(DOCKER_BUILD) --pull --progress=plain --platform $(ARCH) -t $(IMAGE)-$(ARCH) . 27 | 28 | docker-build-%: 29 | $(MAKE) ARCH=$* docker-build 30 | 31 | .PHONY: docker-build-all 32 | docker-build-all: $(addprefix docker-build-,$(ALL_ARCH)) 33 | 34 | .PHONY: docker-push 35 | docker-push: 36 | docker push $(IMAGE)-$(ARCH) 37 | 38 | docker-push-%: 39 | $(MAKE) ARCH=$* docker-push 40 | 41 | .PHONY: docker-push-all 42 | docker-push-all: $(addprefix docker-push-,$(ALL_ARCH)) 43 | 44 | .PHONY: docker-manifest-push 45 | docker-manifest-push: 46 | docker manifest create --amend $(IMAGE) $(addprefix $(IMAGE)-,$(ALL_ARCH)) 47 | @for arch in $(ALL_ARCH); do docker manifest annotate --arch $${arch} $(IMAGE) $(IMAGE)-$${arch}; done 48 | docker manifest push --purge $(IMAGE) 49 | 50 | .PHONY: push-all 51 | push-all: docker-push-all docker-manifest-push 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # alertmanager-to-github 2 | 3 | This receives webhook requests from Alertmanager and creates GitHub issues. 4 | 5 | It does: 6 | 7 | - open an issue on a new alert 8 | - close the issue when the alert is in resolved status 9 | - reopen the issue when the alert is in firing status 10 | - alerts are identified by `groupKey`; configurable via `--alert-id-template` option 11 | 12 | ![screen shot](doc/screenshot.png) 13 | 14 | ## Installation 15 | 16 | ### Docker image 17 | 18 | ```shell 19 | docker pull ghcr.io/pfnet-research/alertmanager-to-github:v0.1.0 20 | ``` 21 | 22 | ### go get 23 | 24 | ```shell 25 | go get github.com/pfnet-research/alertmanager-to-github 26 | ``` 27 | 28 | ## Usage 29 | 30 | Set GitHub API credentials to environment variables: 31 | 32 | ```shell 33 | $ read ATG_GITHUB_APP_PRIVATE_KEY 34 | (GitHub App's private key) 35 | $ export ATG_GITHUB_APP_PRIVATE_KEY 36 | ``` 37 | 38 | or, 39 | 40 | ```shell 41 | $ read ATG_GITHUB_TOKEN 42 | (Personal Access Token) 43 | $ export ATG_GITHUB_TOKEN 44 | ``` 45 | 46 | Start webhook server: 47 | 48 | ``` 49 | $ alertmanager-to-github start 50 | ``` 51 | 52 | Add a receiver to Alertmanager config: 53 | 54 | ```yaml 55 | route: 56 | receiver: "togithub" # default 57 | 58 | receivers: 59 | - name: "togithub" 60 | webhook_configs: 61 | # Create issues in "bar" repo in "foo" organization. 62 | # these are the default values and can be overriden by labels on the alert 63 | # repo and owner parameters must be URL-encoded. 64 | - url: "http://localhost:8080/v1/webhook?owner=foo&repo=bar" 65 | ``` 66 | 67 | ## Configuration 68 | 69 | ```shell 70 | $ alertmanager-to-github start -h 71 | NAME: 72 | alertmanager-to-github start - Start webhook HTTP server 73 | 74 | USAGE: 75 | alertmanager-to-github start [command options] [arguments...] 76 | 77 | OPTIONS: 78 | --listen value HTTP listen on (default: ":8080") [$ATG_LISTEN] 79 | --github-url value GitHub Enterprise URL (e.g. https://github.example.com) [$ATG_GITHUB_URL] 80 | --labels value [ --labels value ] Issue labels [$ATG_LABELS] 81 | --body-template-file value Body template file [$ATG_BODY_TEMPLATE_FILE] 82 | --title-template-file value Title template file [$ATG_TITLE_TEMPLATE_FILE] 83 | --alert-id-template value Alert ID template (default: "{{.Payload.GroupKey}}") [$ATG_ALERT_ID_TEMPLATE] 84 | --github-app-id value GitHub App ID (default: 0) [$ATG_GITHUB_APP_ID] 85 | --github-app-installation-id value GitHub App installation ID (default: 0) [$ATG_GITHUB_APP_INSTALLATION_ID] 86 | --github-app-private-key value GitHub App private key (command line argument is not recommended) [$ATG_GITHUB_APP_PRIVATE_KEY] 87 | --github-token value GitHub API token (command line argument is not recommended) [$ATG_GITHUB_TOKEN] 88 | --auto-close-resolved-issues Should issues be automatically closed when resolved. If alerts have 'atg_skip_auto_close=true' annotation, issues will not be auto-closed. (default: true) [$ATG_AUTO_CLOSE_RESOLVED_ISSUES] 89 | --reopen-window value Alerts will create a new issue instead of reopening closed issues if the specified duration has passed [$ATG_REOPEN_WINDOW] 90 | --help, -h show help 91 | ``` 92 | 93 | ### GitHub Enterprise 94 | 95 | To create issues in GHE, set `--github-url` option or `ATG_GITHUB_URL` environment variable. 96 | 97 | ### Customize issue title and body 98 | 99 | Issue title and body are rendered from [Go template](https://golang.org/pkg/text/template/) and you can use custom templates via `--body-template-file` and `--title-template-file` options. In the templates, you can use the following variables and functions. 100 | 101 | - Variables 102 | - `.Payload`: Webhook payload incoming to this receiver. For more information, see `WebhookPayload` in [pkg/types/payload.go](https://github.com/pfnet-research/alertmanager-to-github/blob/master/pkg/types/payload.go) 103 | - `.PreviousIssue`: The previous issue with the same alert ID, or `nil` if there is no such issue. For more information, see `Issue` in [github.com/google/go-github/v54/github](https://pkg.go.dev/github.com/google/go-github/v54@v54.0.0/github#Issue). Useful when `--reopen-window` is specified. 104 | - Functions 105 | - `urlQueryEscape`: Escape a string as a URL query 106 | - `json`: Marshal an object to JSON string 107 | - `timeNow`: Get current time 108 | 109 | ### Automatically close issues when alerts are resolved 110 | 111 | You can use the `--auto-close-resolved-issues` flag to automatically close issues when alerts are resolved. 112 | 113 | If you want to skip auto-close for some alerts, add the `atg_skip_auto_close=true` annotation to them. 114 | 115 | ```yaml 116 | - alert: HighRequestLatency 117 | expr: job:request_latency_seconds:mean5m{job="myjob"} > 0.5 118 | labels: 119 | severity: critical 120 | annotations: 121 | atg_skip_auto_close: "true" 122 | ``` 123 | 124 | ## Customize organization and repository 125 | 126 | The organization/repository where issues are raised can be customized per-alert by specifying the `atg_owner` label for the organization and/or the `atg_repo` label for the repository on the alert. 127 | 128 | e.g. 129 | 130 | ```yaml 131 | - alert: HighRequestLatency 132 | expr: job:request_latency_seconds:mean5m{job="myjob"} > 0.5 133 | for: 10m 134 | labels: 135 | severity: page 136 | atg_owner: my-alternative-org 137 | atg_repo: specific-service-repository 138 | annotations: 139 | summary: High request latency 140 | ``` 141 | 142 | This mechanism has precedence over the receiver URL query parameters. 143 | 144 | ## Deployment 145 | 146 | ### Kubernetes 147 | 148 | https://github.com/pfnet-research/alertmanager-to-github/tree/master/example/kubernetes 149 | 150 | ## Releaese 151 | 152 | The release process is fully automated by [tagpr](https://github.com/Songmu/tagpr). To release, just merge [the latest release PR](https://github.com/pfnet-research/alertmanager-to-github/pulls?q=is:pr+is:open+label:tagpr). 153 | -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pfnet-research/alertmanager-to-github/11d1778abc04e5612f8a6a59ffad9a129a100f6b/doc/screenshot.png -------------------------------------------------------------------------------- /example/curl.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | msg='{ 3 | "version": "4", 4 | "groupKey": "{}:{severity=\"page\"}", 5 | "truncatedAlerts": 0, 6 | "status": "firing", 7 | "receiver": "webhook", 8 | "groupLabels": { 9 | "severity": "page", 10 | "alertgroup": "example1" 11 | }, 12 | "commonLabels": { 13 | "alertname": "Alert1", 14 | "category": "web", 15 | "severity": "page" 16 | }, 17 | "commonAnnotations": { 18 | "summary": "2 is more than 1" 19 | }, 20 | "externalURL": "http://example.com:9093", 21 | "alerts": [ 22 | { 23 | "status": "firing", 24 | "labels": { 25 | "alertname": "Alert1", 26 | "category": "web", 27 | "severity": "page", 28 | "a": "b", 29 | "c": "d" 30 | }, 31 | "annotations": { 32 | "summary": "2 is more than 1" 33 | }, 34 | "startsAt": "2020-06-09T10:22:00.309791183+09:00", 35 | "endsAt": "0001-01-01T00:00:00Z", 36 | "generatorURL": "http://example.com:9090/graph?g0.expr=1+%3C+bool+2&g0.tab=1" 37 | } 38 | ] 39 | }' 40 | 41 | curl -XPOST --data-binary "${msg}" http://localhost:8000/v1/webhook -------------------------------------------------------------------------------- /example/kubernetes/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: alertmanager-to-github 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app.kubernetes.io/name: alertmanager-to-github 10 | template: 11 | metadata: 12 | labels: 13 | app.kubernetes.io/name: alertmanager-to-github 14 | spec: 15 | containers: 16 | - name: alertmanager-to-github 17 | image: ghcr.io/pfnet-research/alertmanager-to-github:v0.1.0 18 | env: 19 | - name: ATG_LISTEN 20 | value: ':8080' 21 | - name: ATG_GITHUB_TOKEN 22 | valueFrom: 23 | secretKeyRef: 24 | name: alertmanager-to-github 25 | key: ATG_GITHUB_TOKEN 26 | livenessProbe: 27 | httpGet: 28 | path: /metrics 29 | port: 8080 30 | securityContext: 31 | allowPrivilegeEscalation: false 32 | runAsNonRoot: true 33 | readOnlyRootFilesystem: true 34 | capabilities: 35 | drop: 36 | - ALL 37 | seccompProfile: 38 | type: RuntimeDefault 39 | -------------------------------------------------------------------------------- /example/kubernetes/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: alertmanager-to-github 5 | spec: 6 | selector: 7 | app.kubernetes.io/name: alertmanager-to-github 8 | ports: 9 | - protocol: TCP 10 | port: 80 11 | targetPort: 8080 12 | -------------------------------------------------------------------------------- /example/prometheus/.gitignore: -------------------------------------------------------------------------------- 1 | /data 2 | -------------------------------------------------------------------------------- /example/prometheus/alertmanager.yml: -------------------------------------------------------------------------------- 1 | route: 2 | receiver: webhook 3 | group_by: ['severity'] 4 | group_wait: 1s 5 | 6 | receivers: 7 | - name: 'webhook' 8 | webhook_configs: 9 | - url: 'http://127.0.0.1:8000/v1/webhook' 10 | 11 | -------------------------------------------------------------------------------- /example/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | evaluation_interval: 1s 3 | 4 | scrape_configs: 5 | - job_name: prometheus 6 | static_configs: 7 | - targets: ['localhost:9090'] 8 | 9 | rule_files: 10 | - 'rules/*.yaml' 11 | 12 | alerting: 13 | alertmanagers: 14 | - static_configs: 15 | - targets: 16 | - "127.0.0.1:9093" 17 | 18 | -------------------------------------------------------------------------------- /example/prometheus/rules/example.yaml: -------------------------------------------------------------------------------- 1 | groups: 2 | - name: example 3 | rules: 4 | - alert: Alert1 5 | expr: '1 < bool 2' 6 | for: 1s 7 | labels: 8 | severity: page 9 | category: web 10 | annotations: 11 | summary: 2 is more than 1 12 | - alert: Alert2 13 | expr: '1 < bool 3' 14 | for: 1s 15 | labels: 16 | severity: page 17 | category: db 18 | annotations: 19 | summary: 3 is more than 1 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pfnet-research/alertmanager-to-github 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/bradleyfalzon/ghinstallation/v2 v2.16.0 7 | github.com/gin-gonic/gin v1.10.1 8 | github.com/google/go-github/v54 v54.0.0 9 | github.com/prometheus/client_golang v1.22.0 10 | github.com/rs/zerolog v1.34.0 11 | github.com/stretchr/testify v1.10.0 12 | github.com/urfave/cli/v2 v2.27.6 13 | golang.org/x/oauth2 v0.30.0 14 | ) 15 | 16 | require ( 17 | github.com/ProtonMail/go-crypto v1.3.0 // indirect 18 | github.com/beorn7/perks v1.0.1 // indirect 19 | github.com/bytedance/sonic v1.13.3 // indirect 20 | github.com/bytedance/sonic/loader v0.2.4 // indirect 21 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 22 | github.com/cloudflare/circl v1.6.1 // indirect 23 | github.com/cloudwego/base64x v0.1.5 // indirect 24 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 27 | github.com/gin-contrib/sse v1.1.0 // indirect 28 | github.com/go-playground/locales v0.14.1 // indirect 29 | github.com/go-playground/universal-translator v0.18.1 // indirect 30 | github.com/go-playground/validator/v10 v10.26.0 // indirect 31 | github.com/goccy/go-json v0.10.5 // indirect 32 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 33 | github.com/google/go-github/v72 v72.0.0 // indirect 34 | github.com/google/go-querystring v1.1.0 // indirect 35 | github.com/json-iterator/go v1.1.12 // indirect 36 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 37 | github.com/kr/text v0.2.0 // indirect 38 | github.com/leodido/go-urn v1.4.0 // indirect 39 | github.com/mattn/go-colorable v0.1.14 // indirect 40 | github.com/mattn/go-isatty v0.0.20 // indirect 41 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 42 | github.com/modern-go/reflect2 v1.0.2 // indirect 43 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 44 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 45 | github.com/pmezard/go-difflib v1.0.0 // indirect 46 | github.com/prometheus/client_model v0.6.2 // indirect 47 | github.com/prometheus/common v0.64.0 // indirect 48 | github.com/prometheus/procfs v0.16.1 // indirect 49 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 50 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 51 | github.com/ugorji/go/codec v1.2.14 // indirect 52 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 53 | golang.org/x/arch v0.17.0 // indirect 54 | golang.org/x/crypto v0.38.0 // indirect 55 | golang.org/x/net v0.40.0 // indirect 56 | golang.org/x/sys v0.33.0 // indirect 57 | golang.org/x/text v0.25.0 // indirect 58 | google.golang.org/protobuf v1.36.6 // indirect 59 | gopkg.in/yaml.v3 v3.0.1 // indirect 60 | ) 61 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 2 | github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 | github.com/bradleyfalzon/ghinstallation/v2 v2.16.0 h1:B91r9bHtXp/+XRgS5aZm6ZzTdz3ahgJYmkt4xZkgDz8= 6 | github.com/bradleyfalzon/ghinstallation/v2 v2.16.0/go.mod h1:OeVe5ggFzoBnmgitZe/A+BqGOnv1DvU/0uiLQi1wutM= 7 | github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= 8 | github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= 9 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 10 | github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= 11 | github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 12 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 13 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 14 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 15 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 16 | github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= 17 | github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 18 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 19 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 20 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 21 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 22 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 23 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= 27 | github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= 28 | github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 29 | github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= 30 | github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= 31 | github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 32 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 33 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 34 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 35 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 36 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 37 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 38 | github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= 39 | github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 40 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 41 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 42 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 43 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 44 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 45 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 46 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 47 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 48 | github.com/google/go-github/v54 v54.0.0 h1:OZdXwow4EAD5jEo5qg+dGFH2DpkyZvVsAehjvJuUL/c= 49 | github.com/google/go-github/v54 v54.0.0/go.mod h1:Sw1LXWHhXRZtzJ9LI5fyJg9wbQzYvFhW8W5P2yaAQ7s= 50 | github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= 51 | github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= 52 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 53 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 54 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 55 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 56 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 57 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 58 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 59 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 60 | github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 61 | github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 62 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 63 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 64 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 65 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 66 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 67 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 68 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 69 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 70 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 71 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 72 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 73 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 74 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 75 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 76 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 77 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 78 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 79 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 80 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 81 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 82 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 83 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 84 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 85 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 86 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 87 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 88 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 89 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 90 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 91 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 92 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 93 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 94 | github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 95 | github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 96 | github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 97 | github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 98 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 99 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 100 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 101 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 102 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 103 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 104 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 105 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 106 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 107 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 108 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 109 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 110 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 111 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 112 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 113 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 114 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 115 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 116 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 117 | github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw= 118 | github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 119 | github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= 120 | github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 121 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 122 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 123 | golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= 124 | golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= 125 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 126 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 127 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 128 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 129 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 130 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 131 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 132 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 133 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 134 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 135 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 136 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 137 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 138 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 139 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 140 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 141 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 142 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 143 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 144 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 145 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 146 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 147 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 148 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/rs/zerolog/log" 5 | "github.com/pfnet-research/alertmanager-to-github/pkg/cli" 6 | "os" 7 | ) 8 | 9 | func main() { 10 | err := cli.App().Run(os.Args) 11 | if err != nil { 12 | log.Fatal().Err(err) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkg/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "os" 12 | "strings" 13 | "time" 14 | 15 | "github.com/bradleyfalzon/ghinstallation/v2" 16 | "github.com/google/go-github/v54/github" 17 | "github.com/pfnet-research/alertmanager-to-github/pkg/notifier" 18 | "github.com/pfnet-research/alertmanager-to-github/pkg/server" 19 | "github.com/pfnet-research/alertmanager-to-github/pkg/template" 20 | "github.com/pfnet-research/alertmanager-to-github/pkg/types" 21 | "github.com/rs/zerolog/log" 22 | "github.com/urfave/cli/v2" 23 | "golang.org/x/oauth2" 24 | ) 25 | 26 | const flagListen = "listen" 27 | const flagGitHubURL = "github-url" 28 | const flagLabels = "labels" 29 | const flagBodyTemplateFile = "body-template-file" 30 | const flagTitleTemplateFile = "title-template-file" 31 | const flagGitHubAppID = "github-app-id" 32 | const flagGitHubAppInstallationID = "github-app-installation-id" 33 | const flagGitHubAppPrivateKey = "github-app-private-key" 34 | const flagGitHubToken = "github-token" 35 | const flagAlertIDTemplate = "alert-id-template" 36 | const flagTemplateFile = "template-file" 37 | const flagPayloadFile = "payload-file" 38 | const flagAutoCloseResolvedIssues = "auto-close-resolved-issues" 39 | const flagReopenWindow = "reopen-window" 40 | const flagNoPreviousIssue = "no-previous-issue" 41 | 42 | const defaultPayload = `{ 43 | "version": "4", 44 | "groupKey": "groupKey1", 45 | "status": "firing", 46 | "receiver": "receiver1", 47 | "groupLabels": { 48 | "groupLabelKey1": "groupLabelValue1", 49 | "groupLabelKey2": "groupLabelValue2" 50 | }, 51 | "commonLabels": { 52 | "groupLabelKey1": "groupLabelValue1", 53 | "groupLabelKey2": "groupLabelValue2", 54 | "commonLabelKey1": "commonLabelValue1", 55 | "commonLabelKey2": "commonLabelValue2" 56 | }, 57 | "commonAnnotations": { 58 | "commonAnnotationKey1": "commonAnnotationValue1", 59 | "commonAnnotationKey2": "commonAnnotationValue2" 60 | }, 61 | "externalURL": "https://externalurl.example.com", 62 | "alerts": [ 63 | { 64 | "status": "firing", 65 | "labels": { 66 | "groupLabelKey1": "groupLabelValue1", 67 | "groupLabelKey2": "groupLabelValue2", 68 | "commonLabelKey1": "commonLabelValue1", 69 | "commonLabelKey2": "commonLabelValue2", 70 | "labelKey1": "labelValue1", 71 | "labelKey2": "labelValue2" 72 | }, 73 | "annotations": { 74 | "commonAnnotationKey1": "commonAnnotationValue1", 75 | "commonAnnotationKey2": "commonAnnotationValue2", 76 | "annotationKey1": "annotationValue1", 77 | "annotationKey2": "annotationValue2" 78 | }, 79 | "startsAt": "2020-06-15T11:56:07+09:00", 80 | "generatorURL": "https://generatorurl.example.com" 81 | }, 82 | { 83 | "status": "firing", 84 | "labels": { 85 | "groupLabelKey1": "groupLabelValue1", 86 | "groupLabelKey2": "groupLabelValue2", 87 | "commonLabelKey1": "commonLabelValue1", 88 | "commonLabelKey2": "commonLabelValue2", 89 | "labelKey1": "labelValue3", 90 | "labelKey2": "labelValue4" 91 | }, 92 | "annotations": { 93 | "commonAnnotationKey1": "commonAnnotationValue1", 94 | "commonAnnotationKey2": "commonAnnotationValue2", 95 | "annotationKey1": "annotationValue3", 96 | "annotationKey2": "annotationValue4" 97 | }, 98 | "startsAt": "2020-06-15T11:56:07+09:00", 99 | "generatorURL": "https://generatorurl.example.com" 100 | } 101 | ] 102 | }` 103 | 104 | //go:embed samples/issue.json 105 | var sampleIssue string 106 | 107 | //go:embed templates/*.tmpl 108 | var templates embed.FS 109 | 110 | func App() *cli.App { 111 | return &cli.App{ 112 | Name: os.Args[0], 113 | Usage: "Webhook receiver Alertmanager to create GitHub issues", 114 | Commands: []*cli.Command{ 115 | { 116 | Name: "start", 117 | Usage: "Start webhook HTTP server", 118 | Action: func(c *cli.Context) error { 119 | if err := actionStart(c); err != nil { 120 | return cli.Exit(fmt.Errorf("error: %w", err), 1) 121 | } 122 | return nil 123 | }, 124 | Flags: []cli.Flag{ 125 | &cli.StringFlag{ 126 | Name: flagListen, 127 | Value: ":8080", 128 | Usage: "HTTP listen on", 129 | EnvVars: []string{"ATG_LISTEN"}, 130 | }, 131 | &cli.StringFlag{ 132 | Name: flagGitHubURL, 133 | Usage: "GitHub Enterprise URL (e.g. https://github.example.com)", 134 | EnvVars: []string{"ATG_GITHUB_URL"}, 135 | }, 136 | &cli.StringSliceFlag{ 137 | Name: flagLabels, 138 | Usage: "Issue labels", 139 | EnvVars: []string{"ATG_LABELS"}, 140 | }, 141 | &cli.StringFlag{ 142 | Name: flagBodyTemplateFile, 143 | Usage: "Body template file", 144 | EnvVars: []string{"ATG_BODY_TEMPLATE_FILE"}, 145 | }, 146 | &cli.StringFlag{ 147 | Name: flagTitleTemplateFile, 148 | Usage: "Title template file", 149 | EnvVars: []string{"ATG_TITLE_TEMPLATE_FILE"}, 150 | }, 151 | &cli.StringFlag{ 152 | Name: flagAlertIDTemplate, 153 | Value: "{{.Payload.GroupKey}}", 154 | Usage: "Alert ID template", 155 | EnvVars: []string{"ATG_ALERT_ID_TEMPLATE"}, 156 | }, 157 | &cli.Int64Flag{ 158 | Name: flagGitHubAppID, 159 | Required: false, 160 | Usage: "GitHub App ID", 161 | EnvVars: []string{"ATG_GITHUB_APP_ID"}, 162 | }, 163 | &cli.Int64Flag{ 164 | Name: flagGitHubAppInstallationID, 165 | Required: false, 166 | Usage: "GitHub App installation ID", 167 | EnvVars: []string{"ATG_GITHUB_APP_INSTALLATION_ID"}, 168 | }, 169 | &cli.StringFlag{ 170 | Name: flagGitHubAppPrivateKey, 171 | Required: false, 172 | Usage: "GitHub App private key (command line argument is not recommended)", 173 | EnvVars: []string{"ATG_GITHUB_APP_PRIVATE_KEY"}, 174 | }, 175 | &cli.StringFlag{ 176 | Name: flagGitHubToken, 177 | Required: false, 178 | Usage: "GitHub API token (command line argument is not recommended)", 179 | EnvVars: []string{"ATG_GITHUB_TOKEN"}, 180 | }, 181 | &cli.BoolFlag{ 182 | Name: flagAutoCloseResolvedIssues, 183 | Required: false, 184 | Value: true, 185 | Usage: "Should issues be automatically closed when resolved. If alerts have 'atg_skip_auto_close=true' annotation, issues will not be auto-closed.", 186 | EnvVars: []string{"ATG_AUTO_CLOSE_RESOLVED_ISSUES"}, 187 | }, 188 | &noDefaultDurationFlag{ 189 | cli.DurationFlag{ 190 | Name: flagReopenWindow, 191 | Required: false, 192 | Usage: "Alerts will create a new issue instead of reopening closed issues if the specified duration has passed", 193 | EnvVars: []string{"ATG_REOPEN_WINDOW"}, 194 | }, 195 | }, 196 | }, 197 | }, 198 | { 199 | Name: "test-template", 200 | Usage: "Test rendering a template", 201 | Flags: []cli.Flag{ 202 | &cli.StringFlag{ 203 | Name: flagTemplateFile, 204 | Usage: "Template file", 205 | Required: true, 206 | }, 207 | &cli.StringFlag{ 208 | Name: flagPayloadFile, 209 | Usage: "Payload data file", 210 | }, 211 | &cli.BoolFlag{ 212 | Name: flagNoPreviousIssue, 213 | Usage: "Set `.PreviousIssue` to nil", 214 | }, 215 | }, 216 | Action: func(c *cli.Context) error { 217 | if err := actionTestTemplate(c); err != nil { 218 | return cli.Exit(fmt.Errorf("error: %w", err), 1) 219 | } 220 | return nil 221 | }, 222 | }, 223 | }, 224 | } 225 | } 226 | 227 | func buildGitHubClientWithAppCredentials( 228 | githubURL string, appID int64, installationID int64, privateKey []byte, 229 | ) (*github.Client, error) { 230 | fmt.Printf( 231 | "Building a GitHub client with GitHub App credentials (app ID: %d, installation ID: %d)...\n", 232 | appID, installationID, 233 | ) 234 | 235 | tr, err := ghinstallation.New(http.DefaultTransport, appID, installationID, privateKey) 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | if githubURL == "" { 241 | return github.NewClient(&http.Client{Transport: tr}), nil 242 | } 243 | 244 | tr.BaseURL = githubURL 245 | return github.NewEnterpriseClient(githubURL, githubURL, &http.Client{Transport: tr}) 246 | } 247 | 248 | func buildGitHubClientWithToken(githubURL string, token string) (*github.Client, error) { 249 | fmt.Println("Building a GitHub client with token...") 250 | 251 | ctx := context.TODO() 252 | ts := oauth2.StaticTokenSource( 253 | &oauth2.Token{AccessToken: token}, 254 | ) 255 | tc := oauth2.NewClient(ctx, ts) 256 | 257 | if githubURL == "" { 258 | return github.NewClient(tc), nil 259 | } 260 | 261 | return github.NewEnterpriseClient(githubURL, githubURL, tc) 262 | } 263 | 264 | func templateFromReader(r io.Reader) (*template.Template, error) { 265 | b, err := io.ReadAll(r) 266 | if err != nil { 267 | return nil, err 268 | } 269 | return templateFromString(string(b)) 270 | } 271 | 272 | func templateFromFile(path string) (*template.Template, error) { 273 | b, err := os.ReadFile(path) 274 | if err != nil { 275 | return nil, err 276 | } 277 | return templateFromString(string(b)) 278 | } 279 | 280 | func templateFromString(s string) (*template.Template, error) { 281 | t, err := template.Parse(s) 282 | if err != nil { 283 | return nil, err 284 | } 285 | return t, nil 286 | } 287 | 288 | func actionStart(c *cli.Context) error { 289 | githubClient, err := func() (*github.Client, error) { 290 | appID := c.Int64(flagGitHubAppID) 291 | installationID := c.Int64(flagGitHubAppInstallationID) 292 | appKey := c.String(flagGitHubAppPrivateKey) 293 | if appID != 0 && installationID != 0 && appKey != "" { 294 | return buildGitHubClientWithAppCredentials(c.String(flagGitHubURL), appID, installationID, []byte(appKey)) 295 | } 296 | 297 | if token := c.String(flagGitHubToken); token != "" { 298 | return buildGitHubClientWithToken(c.String(flagGitHubURL), token) 299 | } 300 | 301 | return nil, errors.New("GitHub credentials must be specified") 302 | }() 303 | if err != nil { 304 | return err 305 | } 306 | 307 | bodyReader, err := openReader(c.String(flagBodyTemplateFile), "templates/body.tmpl") 308 | if err != nil { 309 | return err 310 | } 311 | defer func() { 312 | if err := bodyReader.Close(); err != nil { 313 | log.Error().Err(err).Msg("failed to close bodyReader") 314 | } 315 | }() 316 | bodyTemplate, err := templateFromReader(bodyReader) 317 | if err != nil { 318 | return err 319 | } 320 | 321 | titleReader, err := openReader(c.String(flagTitleTemplateFile), "templates/title.tmpl") 322 | if err != nil { 323 | return err 324 | } 325 | defer func() { 326 | if err := titleReader.Close(); err != nil { 327 | log.Error().Err(err).Msg("failed to close titleReader") 328 | } 329 | }() 330 | titleTemplate, err := templateFromReader(titleReader) 331 | if err != nil { 332 | return err 333 | } 334 | 335 | alertIDTemplate, err := templateFromString(c.String(flagAlertIDTemplate)) 336 | if err != nil { 337 | return err 338 | } 339 | 340 | var reopenWindow *time.Duration 341 | if c.IsSet(flagReopenWindow) { 342 | d := c.Duration(flagReopenWindow) 343 | reopenWindow = &d 344 | } 345 | 346 | nt, err := notifier.NewGitHub() 347 | if err != nil { 348 | return err 349 | } 350 | nt.GitHubClient = githubClient 351 | nt.Labels = c.StringSlice(flagLabels) 352 | if nt.Labels == nil { 353 | nt.Labels = []string{} 354 | } 355 | nt.BodyTemplate = bodyTemplate 356 | nt.TitleTemplate = titleTemplate 357 | nt.AlertIDTemplate = alertIDTemplate 358 | nt.AutoCloseResolvedIssues = c.Bool(flagAutoCloseResolvedIssues) 359 | nt.ReopenWindow = reopenWindow 360 | 361 | router := server.New(nt).Router() 362 | if err := router.Run(c.String(flagListen)); err != nil { 363 | return err 364 | } 365 | 366 | return nil 367 | } 368 | 369 | func actionTestTemplate(c *cli.Context) error { 370 | t, err := templateFromFile(c.String(flagTemplateFile)) 371 | if err != nil { 372 | return err 373 | } 374 | 375 | payloadData := defaultPayload 376 | if path := c.String(flagPayloadFile); path != "" { 377 | b, err := os.ReadFile(path) 378 | if err != nil { 379 | return err 380 | } 381 | payloadData = string(b) 382 | } 383 | 384 | payload := &types.WebhookPayload{} 385 | 386 | dec := json.NewDecoder(strings.NewReader(payloadData)) 387 | err = dec.Decode(payload) 388 | if err != nil { 389 | return err 390 | } 391 | 392 | var previousIssue *github.Issue 393 | if !c.Bool(flagNoPreviousIssue) { 394 | previousIssue = &github.Issue{} 395 | err = json.NewDecoder(strings.NewReader(sampleIssue)).Decode(previousIssue) 396 | if err != nil { 397 | return err 398 | } 399 | } 400 | 401 | s, err := t.Execute(payload, previousIssue) 402 | if err != nil { 403 | return err 404 | } 405 | fmt.Printf("%s\n", s) 406 | 407 | return nil 408 | } 409 | 410 | func openReader(path string, defaultFile string) (io.ReadCloser, error) { 411 | if path == "" { 412 | return templates.Open(defaultFile) 413 | } else { 414 | return os.Open(path) 415 | } 416 | } 417 | -------------------------------------------------------------------------------- /pkg/cli/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestOpenReader(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | err string 11 | }{ 12 | { 13 | name: "templates/body.tmpl", 14 | err: "", 15 | }, 16 | { 17 | name: "templates/title.tmpl", 18 | err: "", 19 | }, 20 | { 21 | name: "templates/unknown.tmpl", 22 | err: "open templates/unknown.tmpl: file does not exist", 23 | }, 24 | } 25 | 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | _, err := openReader("", tt.name) 29 | if err == nil { 30 | if tt.err != "" { 31 | t.Errorf("expected %v, but got nil", tt.err) 32 | } 33 | } else if tt.err != err.Error() { 34 | t.Errorf("expected %v, but got %v", tt.err, err) 35 | } 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/cli/flag.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | ) 6 | 7 | // cli.DurationFlag without the default value "0s" 8 | type noDefaultDurationFlag struct { 9 | cli.DurationFlag 10 | } 11 | 12 | func (f *noDefaultDurationFlag) GetDefaultText() string { 13 | return "" 14 | } 15 | 16 | func (f *noDefaultDurationFlag) String() string { 17 | return cli.FlagStringer(f) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/cli/flag_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | func TestNoDefaultDurationFlag(t *testing.T) { 12 | writer := bytes.Buffer{} 13 | app := &cli.App{ 14 | Name: "app", 15 | Usage: "app usage", 16 | Flags: []cli.Flag{ 17 | &noDefaultDurationFlag{ 18 | cli.DurationFlag{ 19 | Name: "no-default-flag", 20 | Usage: "no default flag usage", 21 | }, 22 | }, 23 | }, 24 | Writer: &writer, 25 | } 26 | 27 | err := app.Run([]string{"help"}) 28 | 29 | assert.NoError(t, err) 30 | assert.Contains(t, writer.String(), "--no-default-flag value no default flag usage\n") 31 | assert.NotContains(t, writer.String(), "default: 0s") 32 | } 33 | -------------------------------------------------------------------------------- /pkg/cli/samples/issue.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "node_id": "ABCDEFGHIJKL", 4 | "url": "https://api.github.com/repos/pfnet-research/alertmanager-to-github/issues/1", 5 | "repository_url": "https://api.github.com/repos/pfnet-research/alertmanager-to-github", 6 | "labels_url": "https://api.github.com/repos/pfnet-research/alertmanager-to-github/issues/1/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/pfnet-research/alertmanager-to-github/issues/1/comments", 8 | "events_url": "https://api.github.com/repos/pfnet-research/alertmanager-to-github/issues/1/events", 9 | "html_url": "https://github.com/pfnet-research/alertmanager-to-github/issues/1", 10 | "number": 1, 11 | "state": "closed", 12 | "title": "[ALERT] SampleAlert", 13 | "body": "Alert firing.", 14 | "user": { 15 | "login": "octocat", 16 | "id": 1, 17 | "node_id": "abcdefghijkl", 18 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 19 | "gravatar_id": "", 20 | "url": "https://api.github.com/users/octocat", 21 | "html_url": "https://github.com/octocat", 22 | "followers_url": "https://api.github.com/users/octocat/followers", 23 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 24 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 25 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 26 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 27 | "organizations_url": "https://api.github.com/users/octocat/orgs", 28 | "repos_url": "https://api.github.com/users/octocat/repos", 29 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 30 | "received_events_url": "https://api.github.com/users/octocat/received_events", 31 | "type": "User", 32 | "site_admin": false 33 | }, 34 | "labels": [ 35 | { 36 | "id": 123456789, 37 | "node_id": "abcdefghijklmnopqrstuvwxyz", 38 | "url": "https://api.github.com/repos/pfnet-research/alertmanager-to-github/labels/label", 39 | "name": "label", 40 | "color": "ffffff", 41 | "default": true, 42 | "description": "Sample Label" 43 | } 44 | ], 45 | "assignee": { 46 | "login": "octocat", 47 | "id": 1, 48 | "node_id": "abcdefghijkl", 49 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 50 | "gravatar_id": "", 51 | "url": "https://api.github.com/users/octocat", 52 | "html_url": "https://github.com/octocat", 53 | "followers_url": "https://api.github.com/users/octocat/followers", 54 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 55 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 56 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 57 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 58 | "organizations_url": "https://api.github.com/users/octocat/orgs", 59 | "repos_url": "https://api.github.com/users/octocat/repos", 60 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 61 | "received_events_url": "https://api.github.com/users/octocat/received_events", 62 | "type": "User", 63 | "site_admin": false 64 | }, 65 | "assignees": [ 66 | { 67 | "login": "octocat", 68 | "id": 1, 69 | "node_id": "abcdefghijkl", 70 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 71 | "gravatar_id": "", 72 | "url": "https://api.github.com/users/octocat", 73 | "html_url": "https://github.com/octocat", 74 | "followers_url": "https://api.github.com/users/octocat/followers", 75 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 76 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 77 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 78 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 79 | "organizations_url": "https://api.github.com/users/octocat/orgs", 80 | "repos_url": "https://api.github.com/users/octocat/repos", 81 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 82 | "received_events_url": "https://api.github.com/users/octocat/received_events", 83 | "type": "User", 84 | "site_admin": false 85 | } 86 | ], 87 | "milestone": null, 88 | "locked": false, 89 | "active_lock_reason": null, 90 | "comments": 0, 91 | "pull_request": null, 92 | "closed_at": "2020-01-02T03:04:06Z", 93 | "created_at": "2020-01-02T03:04:05Z", 94 | "updated_at": "2020-01-02T03:04:06Z", 95 | "closed_by": { 96 | "login": "octocat", 97 | "id": 1, 98 | "node_id": "abcdefghijkl", 99 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 100 | "gravatar_id": "", 101 | "url": "https://api.github.com/users/octocat", 102 | "html_url": "https://github.com/octocat", 103 | "followers_url": "https://api.github.com/users/octocat/followers", 104 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 105 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 106 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 107 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 108 | "organizations_url": "https://api.github.com/users/octocat/orgs", 109 | "repos_url": "https://api.github.com/users/octocat/repos", 110 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 111 | "received_events_url": "https://api.github.com/users/octocat/received_events", 112 | "type": "User", 113 | "site_admin": false 114 | }, 115 | "author_association": "OWNER", 116 | "state_reason": null 117 | } 118 | -------------------------------------------------------------------------------- /pkg/cli/templates/body.tmpl: -------------------------------------------------------------------------------- 1 | {{- $payload := .Payload -}} 2 | {{- $previousIssue := .PreviousIssue -}} 3 | (Updated at {{timeNow}}) 4 | {{- if $previousIssue }} 5 | 6 | Previous Issue: {{ $previousIssue.HTMLURL }} 7 | {{- end }} 8 | 9 | ## Common Labels 10 | 11 | 12 | {{range $k, $v := $payload.CommonLabels}} 13 | 14 | 15 | 16 | 17 | {{end}} 18 |
{{$k}}{{$v}}
19 | 20 | ## Common Annotations 21 | 22 | 23 | {{range $k, $v := $payload.CommonAnnotations}} 24 | 25 | 26 | 27 | 28 | {{end}} 29 |
{{$k}}{{$v}}
30 | 31 | ## Alerts 32 | 33 | 34 | 35 | {{range $payload.LabelKeysExceptCommon -}} 36 | 37 | {{end -}} 38 | {{range $payload.AnnotationKeysExceptCommon -}} 39 | 40 | {{end -}} 41 | 42 | 43 | 44 | {{range $alert := $payload.Alerts -}} 45 | 46 | {{range $key := $payload.LabelKeysExceptCommon -}} 47 | 48 | {{end -}} 49 | {{range $key := $payload.AnnotationKeysExceptCommon -}} 50 | 51 | {{end -}} 52 | 53 | 54 | 55 | {{end -}} 56 |
{{.}}{{.}}StartsAtLinks
{{index $alert.Labels $key}}{{index $alert.Labels $key}}{{$alert.StartsAt}}GeneratorURL
57 | 58 | {{- if $payload.HasSkipAutoCloseAnnotation }} 59 | 60 | *This issue will not be auto-closed because the alerts have `atg_skip_auto_close=true` annotation.* 61 | {{- end }} 62 | 63 | 64 | -------------------------------------------------------------------------------- /pkg/cli/templates/title.tmpl: -------------------------------------------------------------------------------- 1 | [ALERT] {{range $k, $v := .Payload.CommonLabels}}{{$k}}:{{$v}} {{end}} 2 | -------------------------------------------------------------------------------- /pkg/notifier/github.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "fmt" 7 | "net/url" 8 | "sort" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/google/go-github/v54/github" 14 | "github.com/pfnet-research/alertmanager-to-github/pkg/template" 15 | "github.com/pfnet-research/alertmanager-to-github/pkg/types" 16 | "github.com/prometheus/client_golang/prometheus" 17 | "github.com/prometheus/client_golang/prometheus/promauto" 18 | "github.com/rs/zerolog/log" 19 | ) 20 | 21 | const ( 22 | ownerLabelName = "atg_owner" 23 | repoLabelName = "atg_repo" 24 | ) 25 | 26 | var ( 27 | rateLimit = promauto.NewGaugeVec( 28 | prometheus.GaugeOpts{ 29 | Name: "github_api_rate_limit", 30 | Help: "The limit of API requests the client can make.", 31 | }, 32 | // The GitHub API this rate limit applies to. e.g. "search" or "issues" 33 | []string{"api"}, 34 | ) 35 | rateRemaining = promauto.NewGaugeVec( 36 | prometheus.GaugeOpts{ 37 | Name: "github_api_rate_remaining", 38 | Help: "The remaining API requests the client can make until reset time.", 39 | }, 40 | // The GitHub API this rate limit applies to. e.g. "search" or "issues" 41 | []string{"api"}, 42 | ) 43 | rateResetTime = promauto.NewGaugeVec( 44 | prometheus.GaugeOpts{ 45 | Name: "github_api_rate_reset", 46 | Help: "The time when the current rate limit will reset.", 47 | }, 48 | // The GitHub API this rate limit applies to. e.g. "search" or "issues" 49 | []string{"api"}, 50 | ) 51 | operationCount = promauto.NewCounterVec( 52 | prometheus.CounterOpts{ 53 | Name: "github_api_requests_total", 54 | Help: "Number of API operations performed.", 55 | }, 56 | // api: The GitHub API this rate limit applies to. e.g. "search" or "issues" 57 | // status: The status code of the response 58 | []string{"api", "status"}, 59 | ) 60 | ) 61 | 62 | type GitHubNotifier struct { 63 | GitHubClient *github.Client 64 | BodyTemplate *template.Template 65 | TitleTemplate *template.Template 66 | AlertIDTemplate *template.Template 67 | Labels []string 68 | AutoCloseResolvedIssues bool 69 | ReopenWindow *time.Duration 70 | } 71 | 72 | func NewGitHub() (*GitHubNotifier, error) { 73 | return &GitHubNotifier{}, nil 74 | } 75 | 76 | func resolveRepository(payload *types.WebhookPayload, queryParams url.Values) (string, string, error) { 77 | owner := queryParams.Get("owner") 78 | repo := queryParams.Get("repo") 79 | 80 | if payload.CommonLabels[ownerLabelName] != "" { 81 | owner = payload.CommonLabels[ownerLabelName] 82 | } 83 | if payload.CommonLabels[repoLabelName] != "" { 84 | repo = payload.CommonLabels[repoLabelName] 85 | } 86 | if owner == "" { 87 | return "", "", fmt.Errorf("owner was not specified in either the webhook URL, or the alert labels") 88 | } 89 | if repo == "" { 90 | return "", "", fmt.Errorf("repo was not specified in either the webhook URL, or the alert labels") 91 | } 92 | return owner, repo, nil 93 | } 94 | 95 | func isClosed(issue *github.Issue) bool { 96 | return issue != nil && issue.GetState() == "closed" 97 | } 98 | 99 | func (n *GitHubNotifier) Notify(ctx context.Context, payload *types.WebhookPayload, queryParams url.Values) error { 100 | owner, repo, err := resolveRepository(payload, queryParams) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | labels := n.Labels 106 | if l := queryParams.Get("labels"); l != "" { 107 | labels = strings.Split(l, ",") 108 | } 109 | 110 | alertID, err := n.getAlertID(payload) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | query := fmt.Sprintf(`repo:%s/%s "%s"`, owner, repo, alertID) 116 | searchResult, response, err := n.GitHubClient.Search.Issues(ctx, query, &github.SearchOptions{ 117 | TextMatch: true, 118 | Sort: "created", 119 | Order: "desc", 120 | }) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | updateGithubApiMetrics("search", response) 126 | if err = checkSearchResponse(response); err != nil { 127 | return err 128 | } 129 | 130 | issues := searchResult.Issues 131 | sort.Slice(issues, func(i, j int) bool { 132 | return issues[i].GetCreatedAt().After(issues[j].GetCreatedAt().Time) 133 | }) 134 | 135 | var issue, previousIssue *github.Issue 136 | if len(issues) == 1 { 137 | issue = issues[0] 138 | } else if len(issues) > 1 { 139 | issue = issues[0] 140 | previousIssue = issues[1] 141 | if n.ReopenWindow == nil { 142 | // If issues are always reopened, the search result is expected to be unique. 143 | log.Warn().Interface("searchResultTotal", searchResult.GetTotal()). 144 | Str("groupKey", payload.GroupKey).Msg("too many search result") 145 | } 146 | } 147 | 148 | if n.ReopenWindow != nil && issue != nil && isClosed(issue) && payload.Status == types.AlertStatusFiring { 149 | deadline := issue.GetClosedAt().Add(*n.ReopenWindow) 150 | if time.Now().After(deadline) { 151 | // A new issue will be created instead of reopening the existing issue. 152 | previousIssue = issue 153 | issue = nil 154 | } 155 | } 156 | 157 | body, err := n.BodyTemplate.Execute(payload, previousIssue) 158 | if err != nil { 159 | return err 160 | } 161 | body += fmt.Sprintf("\n\n", alertID) 162 | 163 | title, err := n.TitleTemplate.Execute(payload, previousIssue) 164 | if err != nil { 165 | return err 166 | } 167 | // prevent trailing newline characters in the title due to template formatting 168 | // newlines in titles prevent Github->Slack webhooks working with issues as of 2022-05-06 169 | title = strings.TrimSpace(title) 170 | 171 | req := &github.IssueRequest{ 172 | Title: &title, 173 | Body: &body, 174 | Labels: &labels, 175 | } 176 | 177 | if issue == nil { 178 | issue, response, err = n.GitHubClient.Issues.Create(ctx, owner, repo, req) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | updateGithubApiMetrics("issues", response) 184 | log.Info().Msgf("created an issue: %s", issue.GetURL()) 185 | } else { 186 | // we have to merge existing labels because Edit api replaces its labels 187 | mergedLabels := []string{} 188 | labelSet := map[string]bool{} 189 | for _, l := range issue.Labels { 190 | name := *l.Name 191 | if !labelSet[name] { 192 | labelSet[name] = true 193 | mergedLabels = append(mergedLabels, name) 194 | } 195 | } 196 | for _, l := range labels { 197 | if !labelSet[l] { 198 | labelSet[l] = true 199 | mergedLabels = append(mergedLabels, l) 200 | } 201 | } 202 | req.Labels = &mergedLabels 203 | issue, _, err = n.GitHubClient.Issues.Edit(ctx, owner, repo, issue.GetNumber(), req) 204 | if err != nil { 205 | return err 206 | } 207 | 208 | updateGithubApiMetrics("issues", response) 209 | log.Info().Msgf("edited an issue: %s", issue.GetURL()) 210 | } 211 | 212 | var desiredState string 213 | switch payload.Status { 214 | case types.AlertStatusFiring: 215 | desiredState = "open" 216 | case types.AlertStatusResolved: 217 | desiredState = "closed" 218 | default: 219 | return fmt.Errorf("invalid alert status %s", payload.Status) 220 | } 221 | 222 | currentState := issue.GetState() 223 | canUpdateState := desiredState == "open" || n.shouldAutoCloseIssue(payload) 224 | 225 | if desiredState != currentState && canUpdateState { 226 | req = &github.IssueRequest{ 227 | State: github.String(desiredState), 228 | } 229 | issue, response, err = n.GitHubClient.Issues.Edit(ctx, owner, repo, issue.GetNumber(), req) 230 | if err != nil { 231 | return err 232 | } 233 | 234 | updateGithubApiMetrics("issues", response) 235 | log.Info().Str("state", desiredState).Msgf("updated state of the issue: %s", issue.GetURL()) 236 | } 237 | 238 | if err := n.cleanupIssues(ctx, owner, repo, alertID); err != nil { 239 | return err 240 | } 241 | 242 | return nil 243 | } 244 | 245 | func (n *GitHubNotifier) cleanupIssues(ctx context.Context, owner, repo, alertID string) error { 246 | query := fmt.Sprintf(`repo:%s/%s "%s"`, owner, repo, alertID) 247 | searchResult, response, err := n.GitHubClient.Search.Issues(ctx, query, &github.SearchOptions{ 248 | TextMatch: true, 249 | Sort: "created", 250 | Order: "desc", 251 | }) 252 | if err != nil { 253 | return err 254 | } 255 | 256 | updateGithubApiMetrics("search", response) 257 | if err = checkSearchResponse(response); err != nil { 258 | return err 259 | } 260 | 261 | issues := searchResult.Issues 262 | if len(issues) <= 1 { 263 | return nil 264 | } 265 | 266 | sort.Slice(issues, func(i, j int) bool { 267 | return issues[i].GetCreatedAt().Before(issues[j].GetCreatedAt().Time) 268 | }) 269 | 270 | latestIssue := issues[len(issues)-1] 271 | oldIssues := issues[:len(issues)-1] 272 | for _, issue := range oldIssues { 273 | if n.ReopenWindow != nil && isClosed(issue) { 274 | // If the reopen window is set, multiple closed issues are expected. 275 | // Keep them untouched. 276 | continue 277 | } 278 | req := &github.IssueRequest{ 279 | Body: github.String(fmt.Sprintf("duplicated %s", latestIssue.GetHTMLURL())), 280 | State: github.String("closed"), 281 | } 282 | issue, response, err = n.GitHubClient.Issues.Edit(ctx, owner, repo, issue.GetNumber(), req) 283 | if err != nil { 284 | return err 285 | } 286 | 287 | updateGithubApiMetrics("issues", response) 288 | log.Info().Msgf("closed an issue: %s", issue.GetURL()) 289 | } 290 | 291 | return nil 292 | } 293 | 294 | func (n *GitHubNotifier) getAlertID(payload *types.WebhookPayload) (string, error) { 295 | id, err := n.AlertIDTemplate.Execute(payload, nil) 296 | if err != nil { 297 | return "", err 298 | } 299 | 300 | return fmt.Sprintf("%x", sha256.Sum256([]byte(id))), nil 301 | } 302 | 303 | func (n *GitHubNotifier) shouldAutoCloseIssue(payload *types.WebhookPayload) bool { 304 | if !n.AutoCloseResolvedIssues { 305 | return false 306 | } 307 | 308 | return !payload.HasSkipAutoCloseAnnotation() 309 | } 310 | 311 | func checkSearchResponse(response *github.Response) error { 312 | if response.StatusCode < 200 || 300 <= response.StatusCode { 313 | return fmt.Errorf("issue search returned %d", response.StatusCode) 314 | } 315 | return nil 316 | } 317 | 318 | func updateGithubApiMetrics(apiName string, resp *github.Response) { 319 | rateLimit.WithLabelValues(apiName).Set(float64(resp.Rate.Limit)) 320 | rateRemaining.WithLabelValues(apiName).Set(float64(resp.Rate.Remaining)) 321 | rateResetTime.WithLabelValues(apiName).Set(float64(resp.Rate.Reset.UTC().Unix())) 322 | operationCount.WithLabelValues(apiName, strconv.Itoa(resp.StatusCode)).Inc() 323 | } 324 | -------------------------------------------------------------------------------- /pkg/notifier/notifier.go: -------------------------------------------------------------------------------- 1 | package notifier 2 | 3 | import ( 4 | "context" 5 | "github.com/pfnet-research/alertmanager-to-github/pkg/types" 6 | "net/url" 7 | ) 8 | 9 | type Notifier interface { 10 | Notify(context.Context, *types.WebhookPayload, url.Values) error 11 | } 12 | -------------------------------------------------------------------------------- /pkg/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/pfnet-research/alertmanager-to-github/pkg/notifier" 9 | "github.com/pfnet-research/alertmanager-to-github/pkg/types" 10 | "github.com/prometheus/client_golang/prometheus/promhttp" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | type Server struct { 15 | Notifier notifier.Notifier 16 | } 17 | 18 | func New(notifier notifier.Notifier) (*Server) { 19 | return &Server{ 20 | Notifier: notifier, 21 | } 22 | } 23 | 24 | func (s *Server) Router() *gin.Engine { 25 | router := gin.Default() 26 | router.GET("/metrics", gin.WrapH(promhttp.Handler())) 27 | router.POST("/v1/webhook", s.v1Webhook) 28 | 29 | return router 30 | } 31 | 32 | func (s *Server) v1Webhook(c *gin.Context) { 33 | payload := &types.WebhookPayload{} 34 | 35 | if err := c.ShouldBindJSON(payload); err != nil { 36 | log.Error().Err(err).Msg("error binding JSON") 37 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 38 | return 39 | } 40 | 41 | ctx := context.TODO() 42 | if err := s.Notifier.Notify(ctx, payload, c.Request.URL.Query()); err != nil { 43 | log.Error().Err(err).Msg("error notifying") 44 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 45 | return 46 | } 47 | 48 | c.JSON(http.StatusOK, gin.H{}) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net/http/httptest" 6 | "net/url" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/pfnet-research/alertmanager-to-github/pkg/types" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | type dummyNotifier struct { 16 | payloads []*types.WebhookPayload 17 | } 18 | 19 | func (n *dummyNotifier) Notify(ctx context.Context, payload *types.WebhookPayload, params url.Values) error { 20 | n.payloads = append(n.payloads, payload) 21 | return nil 22 | } 23 | 24 | func TestV1Webhook(t *testing.T) { 25 | nt := &dummyNotifier{} 26 | router := New(nt).Router() 27 | w := httptest.NewRecorder() 28 | req := httptest.NewRequest("POST", "/v1/webhook", strings.NewReader(`{ 29 | "version": "4", 30 | "groupKey": "group1", 31 | "truncatedAlerts": 2, 32 | "status": "firing", 33 | "receiver": "receiver1", 34 | "groupLabels": {"a": "b"}, 35 | "commonLabels": {"c": "d"}, 36 | "commonAnnotations": {"e": "f"}, 37 | "externalURL": "http://alert.example.com", 38 | "alerts": [ 39 | { 40 | "status": "firing", 41 | "labels": {"g": "h"}, 42 | "annotations": {"i": "j"}, 43 | "startsAt": "2020-01-01T12:34:56Z", 44 | "endsAt": "2020-01-02T12:34:56Z", 45 | "generatorURL": "http://alert.example.com" 46 | } 47 | ] 48 | }`)) 49 | router.ServeHTTP(w, req) 50 | if !assert.Equal(t, 200, w.Code) { 51 | t.Log(w.Body.String()) 52 | } 53 | if assert.Len(t, nt.payloads, 1) { 54 | assert.Equal(t, nt.payloads[0], &types.WebhookPayload{ 55 | Version: "4", 56 | GroupKey: "group1", 57 | TruncatedAlerts: 2, 58 | Status: "firing", 59 | Receiver: "receiver1", 60 | GroupLabels: map[string]string{"a": "b"}, 61 | CommonLabels: map[string]string{"c": "d"}, 62 | CommonAnnotations: map[string]string{"e": "f"}, 63 | ExternalURL: "http://alert.example.com", 64 | Alerts: []types.WebhookAlert{ 65 | { 66 | Status: "firing", 67 | Labels: map[string]string{"g": "h"}, 68 | Annotations: map[string]string{"i": "j"}, 69 | StartsAt: mustParseTime("2020-01-01T12:34:56Z"), 70 | EndsAt: mustParseTime("2020-01-02T12:34:56Z"), 71 | GeneratorURL: "http://alert.example.com", 72 | }, 73 | }, 74 | }) 75 | } 76 | } 77 | 78 | func TestMetrics(t *testing.T) { 79 | nt := &dummyNotifier{} 80 | router := New(nt).Router() 81 | w := httptest.NewRecorder() 82 | req := httptest.NewRequest("GET", "/metrics", nil) 83 | router.ServeHTTP(w, req) 84 | if !assert.Equal(t, 200, w.Code) { 85 | t.Log(w.Body.String()) 86 | } 87 | } 88 | 89 | func mustParseTime(s string) time.Time { 90 | t, err := time.Parse(time.RFC3339, s) 91 | if err != nil { 92 | panic(err) 93 | } 94 | return t 95 | } 96 | -------------------------------------------------------------------------------- /pkg/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/url" 7 | "text/template" 8 | "time" 9 | 10 | "github.com/google/go-github/v54/github" 11 | "github.com/pfnet-research/alertmanager-to-github/pkg/types" 12 | ) 13 | 14 | type Vars struct { 15 | Payload *types.WebhookPayload 16 | PreviousIssue *github.Issue 17 | } 18 | 19 | type Template struct { 20 | inner *template.Template 21 | } 22 | 23 | func Parse(s string) (*Template, error) { 24 | funcs := map[string]interface{}{ 25 | "urlQueryEscape": url.QueryEscape, 26 | "json": marshalToJSON, 27 | "timeNow": timeNow, 28 | } 29 | t, err := template.New("template").Funcs(funcs).Parse(s) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return &Template{inner: t}, nil 34 | } 35 | 36 | func (t *Template) Execute(payload *types.WebhookPayload, previousIssue *github.Issue) (string, error) { 37 | vars := &Vars{ 38 | Payload: payload, 39 | PreviousIssue: previousIssue, 40 | } 41 | 42 | var buf bytes.Buffer 43 | if err := t.inner.Execute(&buf, vars); err != nil { 44 | return "", err 45 | } 46 | 47 | return buf.String(), nil 48 | } 49 | 50 | func marshalToJSON(obj interface{}) (string, error) { 51 | jsonb, err := json.Marshal(obj) 52 | if err != nil { 53 | return "", err 54 | } 55 | return string(jsonb), nil 56 | } 57 | 58 | func timeNow() time.Time { 59 | return time.Now() 60 | } 61 | -------------------------------------------------------------------------------- /pkg/types/payload.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "sort" 5 | "time" 6 | ) 7 | 8 | type AlertStatus string 9 | 10 | const ( 11 | AlertStatusResolved AlertStatus = "resolved" 12 | AlertStatusFiring AlertStatus = "firing" 13 | 14 | skipAutoCloseAnnotationKey = "atg_skip_auto_close" 15 | skipAutoCloseAnnotationValue = "true" 16 | ) 17 | 18 | type WebhookPayload struct { 19 | Version string `json:"version"` 20 | GroupKey string `json:"groupKey"` 21 | TruncatedAlerts uint64 `json:"truncatedAlerts"` 22 | Status AlertStatus `json:"status"` 23 | Receiver string `json:"receiver"` 24 | GroupLabels map[string]string `json:"groupLabels"` 25 | CommonLabels map[string]string `json:"commonLabels"` 26 | CommonAnnotations map[string]string `json:"commonAnnotations"` 27 | ExternalURL string `json:"externalURL"` 28 | Alerts []WebhookAlert `json:"alerts"` 29 | } 30 | 31 | type WebhookAlert struct { 32 | Status AlertStatus `json:"status"` 33 | Labels map[string]string `json:"labels"` 34 | Annotations map[string]string `json:"annotations"` 35 | StartsAt time.Time `json:"startsAt"` 36 | EndsAt time.Time `json:"endsAt"` 37 | GeneratorURL string `json:"generatorURL"` 38 | } 39 | 40 | func (p *WebhookPayload) LabelKeysExceptCommon() []string { 41 | m := map[string]struct{}{} 42 | for _, alert := range p.Alerts { 43 | for k := range alert.Labels { 44 | m[k] = struct{}{} 45 | } 46 | } 47 | 48 | var keys []string 49 | for k := range m { 50 | if _, ok := p.CommonLabels[k]; ok { 51 | continue 52 | } 53 | 54 | keys = append(keys, k) 55 | } 56 | 57 | sort.Strings(keys) 58 | 59 | return keys 60 | } 61 | 62 | func (p *WebhookPayload) AnnotationKeysExceptCommon() []string { 63 | m := map[string]struct{}{} 64 | for _, alert := range p.Alerts { 65 | for k := range alert.Annotations { 66 | m[k] = struct{}{} 67 | } 68 | } 69 | 70 | var keys []string 71 | for k := range m { 72 | if _, ok := p.CommonAnnotations[k]; ok { 73 | continue 74 | } 75 | 76 | keys = append(keys, k) 77 | } 78 | 79 | sort.Strings(keys) 80 | 81 | return keys 82 | } 83 | 84 | func (p *WebhookPayload) HasSkipAutoCloseAnnotation() bool { 85 | for _, alert := range p.Alerts { 86 | if alert.Annotations == nil { 87 | continue 88 | } 89 | 90 | val, ok := alert.Annotations[skipAutoCloseAnnotationKey] 91 | if ok && val == skipAutoCloseAnnotationValue { 92 | return true 93 | } 94 | } 95 | 96 | return false 97 | } 98 | -------------------------------------------------------------------------------- /pkg/types/payload_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestWebhookPayloadHasSkipAutoCloseAnnotation(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | payload *WebhookPayload 13 | expected bool 14 | }{ 15 | { 16 | name: "no annotations", 17 | payload: &WebhookPayload{ 18 | Alerts: []WebhookAlert{{ 19 | Labels: map[string]string{ 20 | "job": "example", 21 | }, 22 | }}, 23 | }, 24 | expected: false, 25 | }, 26 | { 27 | name: "has the annotation", 28 | payload: &WebhookPayload{ 29 | Alerts: []WebhookAlert{{ 30 | Annotations: map[string]string{ 31 | "atg_skip_auto_close": "true", 32 | }, 33 | }}, 34 | }, 35 | expected: true, 36 | }, 37 | { 38 | name: "don't has the annotation", 39 | payload: &WebhookPayload{ 40 | Alerts: []WebhookAlert{{ 41 | Annotations: map[string]string{ 42 | "description": "example", 43 | }, 44 | }}, 45 | }, 46 | expected: false, 47 | }, 48 | { 49 | name: "no alerts has the annotation", 50 | payload: &WebhookPayload{ 51 | Alerts: []WebhookAlert{ 52 | { 53 | Labels: map[string]string{ 54 | "job": "example", 55 | }, 56 | }, 57 | { 58 | Labels: map[string]string{ 59 | "job": "example", 60 | }, 61 | }, 62 | }, 63 | }, 64 | expected: false, 65 | }, 66 | { 67 | name: "some alerts have the annotation", 68 | payload: &WebhookPayload{ 69 | Alerts: []WebhookAlert{ 70 | { 71 | Annotations: map[string]string{ 72 | "atg_skip_auto_close": "true", 73 | }, 74 | }, 75 | { 76 | Labels: map[string]string{ 77 | "job": "example", 78 | }, 79 | }, 80 | }, 81 | }, 82 | expected: true, 83 | }, 84 | } 85 | 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | actual := tt.payload.HasSkipAutoCloseAnnotation() 89 | assert.Equal(t, tt.expected, actual) 90 | }) 91 | } 92 | } 93 | --------------------------------------------------------------------------------