├── .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 | 
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 | {{$k}} |
15 | {{$v}} |
16 |
17 | {{end}}
18 |
19 |
20 | ## Common Annotations
21 |
22 |
23 | {{range $k, $v := $payload.CommonAnnotations}}
24 |
25 | {{$k}} |
26 | {{$v}} |
27 |
28 | {{end}}
29 |
30 |
31 | ## Alerts
32 |
33 |
34 |
35 | {{range $payload.LabelKeysExceptCommon -}}
36 | {{.}} |
37 | {{end -}}
38 | {{range $payload.AnnotationKeysExceptCommon -}}
39 | {{.}} |
40 | {{end -}}
41 | StartsAt |
42 | Links |
43 |
44 | {{range $alert := $payload.Alerts -}}
45 |
46 | {{range $key := $payload.LabelKeysExceptCommon -}}
47 | {{index $alert.Labels $key}} |
48 | {{end -}}
49 | {{range $key := $payload.AnnotationKeysExceptCommon -}}
50 | {{index $alert.Labels $key}} |
51 | {{end -}}
52 | {{$alert.StartsAt}} |
53 | GeneratorURL |
54 |
55 | {{end -}}
56 |
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 |
--------------------------------------------------------------------------------