├── version.json ├── images └── example_panel.png ├── cmd └── prometheus_exporter │ └── main.go ├── internal ├── prometheus_exporter │ ├── types.go │ ├── server_test.go │ └── server.go ├── github_client │ ├── types.go │ ├── github_client.go │ └── github_client_test.go └── utils │ ├── utils_test.go │ └── utils.go ├── .github ├── dependabot.yml └── workflows │ ├── pull_request.yaml │ ├── codeql-analysis.yml │ └── release.yaml ├── .gitignore ├── helm └── github-rate-limits-prometheus-exporter │ ├── templates │ ├── serviceaccount.yaml │ ├── service.yaml │ ├── tests │ │ └── test-connection.yaml │ ├── hpa.yaml │ ├── NOTES.txt │ ├── _helpers.tpl │ └── deployment.yaml │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ └── values.yaml ├── Dockerfile ├── .sonarcloud.properties ├── LICENSE ├── go.mod ├── README.md └── go.sum /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-rate-limits-prometheus-exporter", 3 | "version": "3.2.0" 4 | } -------------------------------------------------------------------------------- /images/example_panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalgurn/github-rate-limits-prometheus-exporter/HEAD/images/example_panel.png -------------------------------------------------------------------------------- /cmd/prometheus_exporter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/kalgurn/github-rate-limits-prometheus-exporter/internal/prometheus_exporter" 4 | 5 | func main() { 6 | prometheus_exporter.Run() 7 | } 8 | -------------------------------------------------------------------------------- /internal/prometheus_exporter/types.go: -------------------------------------------------------------------------------- 1 | package prometheus_exporter 2 | 3 | import "github.com/prometheus/client_golang/prometheus" 4 | 5 | type LimitsCollector struct { 6 | LimitTotal *prometheus.Desc 7 | LimitRemaining *prometheus.Desc 8 | LimitUsed *prometheus.Desc 9 | SecondsLeft *prometheus.Desc 10 | } 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | commit-message: 8 | prefix: "chore" 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | commit-message: 14 | prefix: "chore" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | vendor/ 16 | # Test kubeconfig files 17 | *-test.yaml 18 | 19 | # Intermediate binary 20 | 21 | secrets/ 22 | 23 | .vscode/ 24 | 25 | .DS_Store -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "github-app-limits-prometheus-exporter.serviceAccountName" . }} 6 | labels: 7 | {{- include "github-app-limits-prometheus-exporter.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM golang:alpine AS build 4 | 5 | WORKDIR /app 6 | 7 | COPY go.mod ./ 8 | COPY go.sum ./ 9 | COPY cmd ./cmd 10 | COPY internal ./internal 11 | RUN go mod download 12 | 13 | RUN CGO_ENABLED=0 GO111MODULE=auto go build -o /grl-exporter cmd/prometheus_exporter/main.go 14 | RUN ls -la 15 | 16 | FROM gcr.io/distroless/base-debian11 17 | 18 | WORKDIR / 19 | 20 | COPY --from=build /grl-exporter /grl-exporter 21 | 22 | EXPOSE 2112 23 | 24 | USER nonroot:nonroot 25 | 26 | ENTRYPOINT ["/grl-exporter"] -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "github-app-limits-prometheus-exporter.fullname" . }} 5 | labels: 6 | {{- include "github-app-limits-prometheus-exporter.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "github-app-limits-prometheus-exporter.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /internal/github_client/types.go: -------------------------------------------------------------------------------- 1 | package github_client 2 | 3 | import ( 4 | "github.com/google/go-github/v65/github" 5 | ) 6 | 7 | type AppConfig struct { 8 | AppID int64 9 | InstallationID int64 10 | OrgName string 11 | RepoName string 12 | PrivateKeyPath string 13 | } 14 | 15 | type TokenConfig struct { 16 | Token string 17 | } 18 | 19 | type RateLimits struct { 20 | Limit int 21 | Remaining int 22 | Used int 23 | SecondsLeft float64 24 | } 25 | 26 | type GithubClient interface { 27 | InitClient() *github.Client 28 | } 29 | -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "github-app-limits-prometheus-exporter.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "github-app-limits-prometheus-exporter.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "github-app-limits-prometheus-exporter.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | sonar.sources=. 2 | sonar.exclusions=**/*_test.go,**/vendor/** 3 | 4 | sonar.tests=. 5 | sonar.test.inclusions=**/*_test.go 6 | sonar.test.exclusions=**/vendor/** 7 | 8 | sonar.sourceEncoding=UTF-8 9 | 10 | 11 | # ===================================================== 12 | # Properties specific to Go 13 | # ===================================================== 14 | 15 | # sonar.go.gometalinter.reportPaths=gometalinter-report.out 16 | #sonar.go.govet.reportPaths=govet-report.out 17 | #sonar.go.golint.reportPaths=golint-report.out 18 | # sonar.go.tests.reportPaths=report.json 19 | sonar.go.coverage.reportPaths=coverage.out -------------------------------------------------------------------------------- /internal/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGetOSVar(t *testing.T) { 12 | assert := assert.New(t) 13 | os.Setenv("TESTVAR", "TESTVALUE") 14 | 15 | testvalue := "TESTVALUE" 16 | testvar := GetOSVar("TESTVAR") 17 | assert.Equal(testvalue, testvar, "should be equal") 18 | 19 | testvalue2 := "" 20 | testvar2 := GetOSVar("TESTVAR2") 21 | assert.Equal(testvalue2, testvar2, "should be equal") 22 | 23 | } 24 | 25 | func TestRespError(t *testing.T) { 26 | err := errors.New("test") 27 | assert.Equal(t, errors.New("there was an error during the call execution: test"), RespError(err)) 28 | } 29 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "os" 8 | ) 9 | 10 | // RespError logs and returns a wrapped error for callers to handle. 11 | // This function does not terminate the process. 12 | func RespError(err error) error { 13 | if err != nil { 14 | errMsg := fmt.Sprintf("there was an error during the call execution: %s", err) 15 | log.Printf("%s", errMsg) 16 | return errors.New(errMsg) 17 | } 18 | return nil 19 | } 20 | 21 | func GetOSVar(envVar string) string { 22 | value, present := os.LookupEnv(envVar) 23 | if !present { 24 | err := fmt.Sprintf("environment variable %s not set", envVar) 25 | // Log the error but do not terminate; caller should validate the value. 26 | RespError(errors.New(err)) 27 | return "" 28 | } 29 | return value 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kostiantyn Kulbachnyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "github-app-limits-prometheus-exporter.fullname" . }} 6 | labels: 7 | {{- include "github-app-limits-prometheus-exporter.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "github-app-limits-prometheus-exporter.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 21 | {{- end }} 22 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 23 | - type: Resource 24 | resource: 25 | name: memory 26 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 27 | {{- end }} 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: github-rate-limits-prometheus-exporter 3 | description: A Helm chart for Github Rate Limits prometheus exporter 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.2.15 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "v3.2.0" 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kalgurn/github-rate-limits-prometheus-exporter 2 | 3 | go 1.24.4 4 | 5 | toolchain go1.24.5 6 | 7 | require ( 8 | github.com/bradleyfalzon/ghinstallation/v2 v2.16.0 9 | github.com/google/go-github/v65 v65.0.0 10 | github.com/migueleliasweb/go-github-mock v1.4.0 11 | github.com/prometheus/client_golang v1.22.0 12 | github.com/stretchr/testify v1.10.0 13 | golang.org/x/oauth2 v0.30.0 14 | ) 15 | 16 | require github.com/golang-jwt/jwt/v5 v5.2.3 17 | 18 | require ( 19 | github.com/beorn7/perks v1.0.1 // indirect 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 21 | github.com/davecgh/go-spew v1.1.1 // indirect 22 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 23 | github.com/google/go-github/v72 v72.0.0 // indirect 24 | github.com/google/go-github/v73 v73.0.0 // indirect 25 | github.com/google/go-querystring v1.1.0 // indirect 26 | github.com/gorilla/mux v1.8.1 // indirect 27 | github.com/kr/text v0.2.0 // indirect 28 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 29 | github.com/pmezard/go-difflib v1.0.0 // indirect 30 | github.com/prometheus/client_model v0.6.1 // indirect 31 | github.com/prometheus/common v0.62.0 // indirect 32 | github.com/prometheus/procfs v0.15.1 // indirect 33 | golang.org/x/sys v0.30.0 // indirect 34 | golang.org/x/time v0.11.0 // indirect 35 | google.golang.org/protobuf v1.36.5 // indirect 36 | gopkg.in/yaml.v3 v3.0.1 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "github-app-limits-prometheus-exporter.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "github-app-limits-prometheus-exporter.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "github-app-limits-prometheus-exporter.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "github-app-limits-prometheus-exporter.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | 7 | env: 8 | IMAGE_NAME: grl-exporter 9 | 10 | jobs: 11 | 12 | build: 13 | name: Build 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | goos: 18 | - linux 19 | - darwin 20 | goarch: 21 | - amd64 22 | - arm64 23 | go-version: 24 | - '1.24' 25 | steps: 26 | 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 30 | 31 | - name: Setup Go 32 | uses: actions/setup-go@v5 33 | with: 34 | go-version: ${{ matrix.go-version }} # The Go version to download (if necessary) and use. 35 | go-version-file: go.mod 36 | cache: false 37 | 38 | # Install all the dependencies 39 | - name: Install dependencies 40 | run: | 41 | go version 42 | go get -u golang.org/x/lint/golint 43 | 44 | # Run build of the application 45 | - name: Run build 46 | run: ./build.sh ${{ github.sha }} 47 | env: 48 | GOOS: ${{ matrix.goos }} 49 | GOARCH: ${{ matrix.goarch }} 50 | CGO_ENABLED: 0 51 | 52 | - name: Store intermediate artifact 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: ${{ env.IMAGE_NAME}}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ github.sha }}.zip 56 | path: ${{ env.IMAGE_NAME}}-${{ matrix.goos }}-${{ matrix.goarch }}.zip 57 | 58 | test: 59 | name: Test 60 | runs-on: ubuntu-latest 61 | strategy: 62 | matrix: 63 | go-version: 64 | - '1.24' 65 | needs: 66 | - build 67 | steps: 68 | 69 | - uses: actions/checkout@v4 70 | with: 71 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 72 | 73 | - name: Setup Go 74 | uses: actions/setup-go@v5 75 | with: 76 | go-version: ${{ matrix.go-version }} # The Go version to download (if necessary) and use. 77 | go-version-file: go.mod 78 | cache: false 79 | 80 | - name: Run Unit tests 81 | run: go test ./... -test.v 82 | -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "github-app-limits-prometheus-exporter.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "github-app-limits-prometheus-exporter.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "github-app-limits-prometheus-exporter.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "github-app-limits-prometheus-exporter.labels" -}} 37 | helm.sh/chart: {{ include "github-app-limits-prometheus-exporter.chart" . }} 38 | {{ include "github-app-limits-prometheus-exporter.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "github-app-limits-prometheus-exporter.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "github-app-limits-prometheus-exporter.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "github-app-limits-prometheus-exporter.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "github-app-limits-prometheus-exporter.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/README.md: -------------------------------------------------------------------------------- 1 | # Github Rate Limit prometheus exporter helm chart 2 | 3 | This helm chart helps to install and configure [github-rate-limits-prometheus-exporter](../../README.md) 4 | 5 | The helm chart itself is a simplified version of a generated helm chart for 'any' service. Values which can be configured can be viewed [here](values.yaml) 6 | 7 | To add a repository to your local helm repos 8 | ```sh 9 | helm repo add grl-exporter https://kalgurn.github.io/github-rate-limits-prometheus-exporter-charts/ 10 | ``` 11 | 12 | To install the chart 13 | ```sh 14 | helm upgrade --install \ 15 | release_name grl-exporter/github-rate-limits-prometheus-exporter \ 16 | -f path_to_values/with_github_configuration.yaml 17 | ``` 18 | 19 | ## Application specific configuration 20 | GitHub PAT 21 | 22 | ```yaml 23 | github: 24 | authType: pat 25 | secretName: secret # Name of a secret which stores PAT 26 | ``` 27 | 28 | GitHub App 29 | ```yaml 30 | github: 31 | authType: app 32 | appID: 1 # GitHub applicaiton ID 33 | installationID: 1 # GitHub App installation ID 34 | privateKeyPath: "/tmp" # path to which the private key will be mounted 35 | secretName: secret # name of a secret which stores key.pem 36 | ``` 37 | 38 | Example values file 39 | ```yaml 40 | github: 41 | authType: pat 42 | secretName: gh-token 43 | 44 | replicaCount: 1 45 | 46 | image: 47 | repository: ghcr.io/kalgurn/grl-exporter 48 | pullPolicy: IfNotPresent 49 | # Overrides the image tag whose default is the chart appVersion. 50 | tag: "v0.1.4" 51 | 52 | resources: 53 | limits: 54 | cpu: 100m 55 | memory: 128Mi 56 | requests: 57 | cpu: 100m 58 | memory: 128Mi 59 | ``` 60 | 61 | ## Prerequisites 62 | 63 | For the application to run the Kubernetes secrets should be installed in the same namespace. E.g., for GitHub App you can create a secret from the key.pem with the command below. 64 | 65 | ```sh 66 | kubectl create secret generic github-key --from-file=key.pem 67 | ``` 68 | 69 | It will create a secret with a name __github-key__ and a private key stored within the keys `data["key.pem"]` 70 | 71 | For the PAT the easiest way would be 72 | 73 | ```sh 74 | echo -n 'ghb_token' > ./token 75 | kubectl create secret generic gh-token --from-file=token 76 | ``` 77 | 78 | The command above will create a secret __gh-token__ in the current namespace and a token stored within keys `data["token"]` 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=kalgurn_github-rate-limits-prometheus-exporter&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=kalgurn_github-rate-limits-prometheus-exporter) 2 | [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=kalgurn_github-rate-limits-prometheus-exporter&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=kalgurn_github-rate-limits-prometheus-exporter) 3 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=kalgurn_github-rate-limits-prometheus-exporter&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=kalgurn_github-rate-limits-prometheus-exporter) 4 | [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/github-rate-limit-prometheus-exporter)](https://artifacthub.io/packages/search?repo=github-rate-limit-prometheus-exporter) 5 | # Github Rate Limit Prometheus Exporter 6 | 7 | A [prometheus](https://prometheus.io/) exporter which scrapes GitHub API for the rate limits used by PAT/GitHub App. 8 | 9 | Helm Chart with values and deployment can be found [here](./helm/github-rate-limits-prometheus-exporter) 10 | 11 | For the exporter to run you need to supply either a GitHub Token or a set of a GitHub App credentials, alongside with a type of authentication to use(pat/app) 12 | 13 | You can use the environment variable `GITHUB_LOG_METRIC_COLLECTION` (boolean) to control if rate limit metrics are also logged to the console when they're collected by Prometheus. As the functionality is backed by [Golang `strconv.ParseBool`](https://pkg.go.dev/strconv#ParseBool), it accepts 1, t, T, TRUE, true, True, 0, f, F, FALSE, false, False. Any other value or lack thereof will default to enabling logs. 14 | 15 | ### The metrics can then be represented on a [grafana](https://grafana.com) dashboard 16 | 17 | 18 | ![Grafana panel example](./images/example_panel.png) 19 | 20 | 21 | 22 | ## Docker 23 | 24 | PAT 25 | ```sh 26 | docker run -d \ 27 | -e GITHUB_AUTH_TYPE=PAT \ 28 | -e GITHUB_ACCOUNT_NAME=name_of_my_app 29 | -e GITHUB_TOKEN=my_token \ 30 | -p 2112:2112 \ 31 | ghcr.io/kalgurn/grl-exporter:latest 32 | ``` 33 | 34 | GitHub APP 35 | ``` 36 | docker run -d \ 37 | -e GITHUB_AUTH_TYPE=APP \ 38 | -e GITHUB_APP_ID=my_app_id \ 39 | -e GITHUB_INSTALLATION_ID=my_app_installation_id \ 40 | -e GITHUB_ACCOUNT_NAME=name_of_my_app 41 | -e GITHUB_PRIVATE_KEY_PATH=/tmp 42 | -v $PWD/path_to/key.pem:/tmp/key.pem 43 | -p 2112:2112 \ 44 | ghcr.io/kalgurn/grl-exporter:latest 45 | ``` -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '25 4 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for github-app-limits-prometheus-exporter. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | # Definition of the github credentials for either PAT or APP credentials 6 | # For PAT 7 | # github: 8 | # authType: pat 9 | # secretName: secret 10 | 11 | github: 12 | authType: app 13 | appID: 1 14 | installationID: 1 15 | privateKeyPath: "/tmp" 16 | secretName: secret 17 | 18 | replicaCount: 1 19 | 20 | logging: 21 | logMetricCollection: true 22 | 23 | image: 24 | repository: ghcr.io/kalgurn/grl-exporter 25 | pullPolicy: IfNotPresent 26 | # Overrides the image tag whose default is the chart appVersion. 27 | tag: "" 28 | 29 | imagePullSecrets: [] 30 | nameOverride: "" 31 | fullnameOverride: "" 32 | 33 | serviceAccount: 34 | # Specifies whether a service account should be created 35 | create: true 36 | # Annotations to add to the service account 37 | annotations: {} 38 | # The name of the service account to use. 39 | # If not set and create is true, a name is generated using the fullname template 40 | name: "" 41 | 42 | podAnnotations: 43 | prometheus.io/scrape: 'true' 44 | prometheus.io/port: '2112' 45 | 46 | podSecurityContext: 47 | {} 48 | # fsGroup: 2000 49 | 50 | securityContext: 51 | {} 52 | # capabilities: 53 | # drop: 54 | # - ALL 55 | # readOnlyRootFilesystem: true 56 | # runAsNonRoot: true 57 | # runAsUser: 1000 58 | 59 | service: 60 | type: ClusterIP 61 | port: 2112 62 | 63 | ingress: 64 | enabled: false 65 | className: "" 66 | annotations: {} 67 | # kubernetes.io/ingress.class: nginx 68 | # kubernetes.io/tls-acme: "true" 69 | hosts: 70 | - host: chart-example.local 71 | paths: 72 | - path: / 73 | pathType: ImplementationSpecific 74 | tls: [] 75 | # - secretName: chart-example-tls 76 | # hosts: 77 | # - chart-example.local 78 | 79 | resources: 80 | {} 81 | # We usually recommend not to specify default resources and to leave this as a conscious 82 | # choice for the user. This also increases chances charts run on environments with little 83 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 84 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 85 | # limits: 86 | # cpu: 100m 87 | # memory: 128Mi 88 | # requests: 89 | # cpu: 100m 90 | # memory: 128Mi 91 | 92 | autoscaling: 93 | enabled: false 94 | minReplicas: 1 95 | maxReplicas: 100 96 | targetCPUUtilizationPercentage: 80 97 | # targetMemoryUtilizationPercentage: 80 98 | 99 | nodeSelector: {} 100 | 101 | tolerations: [] 102 | 103 | affinity: {} 104 | -------------------------------------------------------------------------------- /internal/prometheus_exporter/server_test.go: -------------------------------------------------------------------------------- 1 | package prometheus_exporter 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/prometheus/client_golang/prometheus" 12 | "github.com/prometheus/client_golang/prometheus/promhttp" 13 | ) 14 | 15 | type FakeCollector struct { 16 | LimitTotal *prometheus.Desc 17 | LimitRemaining *prometheus.Desc 18 | LimitUsed *prometheus.Desc 19 | SecondsLeft *prometheus.Desc 20 | } 21 | 22 | func newFakeCollector() *LimitsCollector { 23 | return &LimitsCollector{ 24 | LimitTotal: prometheus.NewDesc(prometheus.BuildFQName(githubAccount, "", "limit_total"), 25 | "Total limit of requests for the installation", 26 | nil, nil), 27 | LimitRemaining: prometheus.NewDesc(prometheus.BuildFQName(githubAccount, "", "limit_remaining"), 28 | "Amount of remaining requests for the installation", 29 | nil, nil), 30 | LimitUsed: prometheus.NewDesc(prometheus.BuildFQName(githubAccount, "", "limit_used"), 31 | "Amount of used requests for the installation", 32 | nil, nil), 33 | SecondsLeft: prometheus.NewDesc(prometheus.BuildFQName(githubAccount, "", "seconds_left"), 34 | "Time left in seconds until limit is reset for the installation", 35 | nil, nil), 36 | } 37 | } 38 | 39 | func (collector *FakeCollector) Describe(ch chan<- *prometheus.Desc) { 40 | ch <- collector.LimitTotal 41 | ch <- collector.LimitRemaining 42 | ch <- collector.LimitUsed 43 | ch <- collector.SecondsLeft 44 | } 45 | 46 | func (collector *FakeCollector) Collect(ch chan<- prometheus.Metric) { 47 | 48 | log.Printf("Collecting metrics for %s", githubAccount) 49 | //Write latest value for each metric in the prometheus metric channel. 50 | //Note that you can pass CounterValue, GaugeValue, or UntypedValue types here. 51 | m1 := prometheus.MustNewConstMetric(collector.LimitTotal, prometheus.GaugeValue, float64(10)) 52 | m2 := prometheus.MustNewConstMetric(collector.LimitRemaining, prometheus.GaugeValue, float64(6)) 53 | m3 := prometheus.MustNewConstMetric(collector.LimitUsed, prometheus.GaugeValue, float64(4)) 54 | m4 := prometheus.MustNewConstMetric(collector.SecondsLeft, prometheus.GaugeValue, time.Duration(time.Second*30).Seconds()) 55 | m1 = prometheus.NewMetricWithTimestamp(time.Now().Add(-time.Hour), m1) 56 | m2 = prometheus.NewMetricWithTimestamp(time.Now(), m2) 57 | m3 = prometheus.NewMetricWithTimestamp(time.Now(), m3) 58 | m4 = prometheus.NewMetricWithTimestamp(time.Now(), m4) 59 | ch <- m1 60 | ch <- m2 61 | ch <- m3 62 | ch <- m4 63 | } 64 | 65 | func TestNewLimitsCollector(t *testing.T) { 66 | newCollector := newFakeCollector() 67 | prometheus.MustRegister(newCollector) 68 | 69 | mux := http.NewServeMux() 70 | 71 | mux.Handle("/limits", promhttp.Handler()) 72 | 73 | ts := httptest.NewServer(mux) 74 | defer ts.Close() 75 | 76 | resp, err := http.Get("0.0.0.0:2112/limits") 77 | if err != nil { 78 | log.Print(err) 79 | } 80 | fmt.Println(resp) 81 | 82 | } 83 | -------------------------------------------------------------------------------- /internal/prometheus_exporter/server.go: -------------------------------------------------------------------------------- 1 | package prometheus_exporter 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/kalgurn/github-rate-limits-prometheus-exporter/internal/github_client" 10 | "github.com/kalgurn/github-rate-limits-prometheus-exporter/internal/utils" 11 | "github.com/prometheus/client_golang/prometheus" 12 | "github.com/prometheus/client_golang/prometheus/promhttp" 13 | ) 14 | 15 | var ( 16 | githubAccount = utils.GetOSVar("GITHUB_ACCOUNT_NAME") 17 | logMetricCollection, logMetricCollectionParseErr = strconv.ParseBool(utils.GetOSVar("GITHUB_LOG_METRIC_COLLECTION")) 18 | ) 19 | 20 | func newLimitsCollector() *LimitsCollector { 21 | return &LimitsCollector{ 22 | LimitTotal: prometheus.NewDesc(prometheus.BuildFQName("github", "limit", "total"), 23 | "Total limit of requests for the installation", 24 | nil, prometheus.Labels{ 25 | "account": githubAccount, 26 | }), 27 | LimitRemaining: prometheus.NewDesc(prometheus.BuildFQName("github", "limit", "remaining"), 28 | "Amount of remaining requests for the installation", 29 | nil, prometheus.Labels{ 30 | "account": githubAccount, 31 | }), 32 | LimitUsed: prometheus.NewDesc(prometheus.BuildFQName("github", "limit", "used"), 33 | "Amount of used requests for the installation", 34 | nil, prometheus.Labels{ 35 | "account": githubAccount, 36 | }), 37 | SecondsLeft: prometheus.NewDesc(prometheus.BuildFQName("github", "limit", "time_left_seconds"), 38 | "Time left in seconds until rate limit gets reset for the installation", 39 | nil, prometheus.Labels{ 40 | "account": githubAccount, 41 | }), 42 | } 43 | } 44 | 45 | func (collector *LimitsCollector) Describe(ch chan<- *prometheus.Desc) { 46 | ch <- collector.LimitTotal 47 | ch <- collector.LimitRemaining 48 | ch <- collector.LimitUsed 49 | ch <- collector.SecondsLeft 50 | } 51 | 52 | func (collector *LimitsCollector) emitMetrics(ch chan<- prometheus.Metric, limits github_client.RateLimits) { 53 | m1 := prometheus.MustNewConstMetric(collector.LimitTotal, prometheus.GaugeValue, float64(limits.Limit)) 54 | m2 := prometheus.MustNewConstMetric(collector.LimitRemaining, prometheus.GaugeValue, float64(limits.Remaining)) 55 | m3 := prometheus.MustNewConstMetric(collector.LimitUsed, prometheus.GaugeValue, float64(limits.Used)) 56 | m4 := prometheus.MustNewConstMetric(collector.SecondsLeft, prometheus.GaugeValue, limits.SecondsLeft) 57 | now := time.Now() 58 | m1 = prometheus.NewMetricWithTimestamp(now, m1) 59 | m2 = prometheus.NewMetricWithTimestamp(now, m2) 60 | m3 = prometheus.NewMetricWithTimestamp(now, m3) 61 | m4 = prometheus.NewMetricWithTimestamp(now, m4) 62 | ch <- m1 63 | ch <- m2 64 | ch <- m3 65 | ch <- m4 66 | } 67 | 68 | func (collector *LimitsCollector) Collect(ch chan<- prometheus.Metric) { 69 | auth := github_client.InitConfig() 70 | limits, err := github_client.GetRemainingLimits(auth.InitClient()) 71 | if err != nil { 72 | // On error expose zeros and return to avoid panics during scraping 73 | if logMetricCollection { 74 | log.Printf("error collecting metrics for %s: %v", githubAccount, err) 75 | } 76 | collector.emitMetrics(ch, github_client.RateLimits{}) 77 | return 78 | } 79 | if logMetricCollection { 80 | log.Printf("Collected metrics for %s", githubAccount) 81 | log.Printf("Limit: %d | Used: %d | Remaining: %d", limits.Limit, limits.Used, limits.Remaining) 82 | } 83 | collector.emitMetrics(ch, limits) 84 | } 85 | 86 | func Run() { 87 | // Default to logging metric collection 88 | if logMetricCollectionParseErr != nil { 89 | logMetricCollection = true 90 | } 91 | 92 | limit := newLimitsCollector() 93 | prometheus.NewRegistry() 94 | prometheus.MustRegister(limit) 95 | 96 | http.Handle("/metrics", promhttp.Handler()) 97 | http.ListenAndServe(":2112", nil) 98 | } 99 | -------------------------------------------------------------------------------- /helm/github-rate-limits-prometheus-exporter/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "github-app-limits-prometheus-exporter.fullname" . }} 5 | labels: 6 | {{- include "github-app-limits-prometheus-exporter.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "github-app-limits-prometheus-exporter.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "github-app-limits-prometheus-exporter.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.imagePullSecrets }} 24 | imagePullSecrets: 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | serviceAccountName: {{ include "github-app-limits-prometheus-exporter.serviceAccountName" . }} 28 | securityContext: 29 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 30 | containers: 31 | - name: {{ .Chart.Name }} 32 | securityContext: 33 | {{- toYaml .Values.securityContext | nindent 12 }} 34 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 35 | imagePullPolicy: {{ .Values.image.pullPolicy }} 36 | env: 37 | - name: GITHUB_LOG_METRIC_COLLECTION 38 | value: {{ .Values.logging.logMetricCollection | quote }} 39 | {{- if eq .Values.github.authType "app" }} 40 | - name: GITHUB_AUTH_TYPE 41 | value: {{ .Values.github.authType | upper | quote }} 42 | - name: GITHUB_APP_ID 43 | value: {{ .Values.github.appID | quote}} 44 | - name: GITHUB_INSTALLATION_ID 45 | value: {{ .Values.github.installationID | quote}} 46 | - name: GITHUB_PRIVATE_KEY_PATH 47 | value: "{{ .Values.github.privateKeyPath }}key.pem" 48 | - name: GITHUB_ACCOUNT_NAME 49 | value: {{ .Release.Name }} 50 | volumeMounts: 51 | - name: key-volume 52 | readOnly: true 53 | mountPath: {{ .Values.github.privateKeyPath | quote }} 54 | {{- else if eq .Values.github.authType "pat" }} 55 | - name: GITHUB_ACCOUNT_NAME 56 | value: {{ .Release.Name }} 57 | - name: GITHUB_AUTH_TYPE 58 | value: {{ .Values.github.authType | upper | quote }} 59 | - name: GITHUB_TOKEN 60 | valueFrom: 61 | secretKeyRef: 62 | name: {{ .Values.github.secretName }} 63 | key: token 64 | {{- end }} 65 | ports: 66 | - name: http 67 | containerPort: {{ .Values.service.port }} 68 | protocol: TCP 69 | livenessProbe: 70 | httpGet: 71 | path: /metrics 72 | port: http 73 | readinessProbe: 74 | httpGet: 75 | path: /metrics 76 | port: http 77 | resources: 78 | {{- toYaml .Values.resources | nindent 12 }} 79 | {{- if eq .Values.github.authType "app" }} 80 | volumes: 81 | - name: key-volume 82 | secret: 83 | secretName: {{ .Values.github.secretName }} 84 | {{- end }} 85 | {{- with .Values.nodeSelector }} 86 | nodeSelector: 87 | {{- toYaml . | nindent 8 }} 88 | {{- end }} 89 | {{- with .Values.affinity }} 90 | affinity: 91 | {{- toYaml . | nindent 8 }} 92 | {{- end }} 93 | {{- with .Values.tolerations }} 94 | tolerations: 95 | {{- toYaml . | nindent 8 }} 96 | {{- end }} 97 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | # Trigger the workflow on push or pull request, 4 | # but only for the main branch 5 | push: 6 | branches: 7 | - master 8 | 9 | env: 10 | REGISTRY: ghcr.io 11 | IMAGE_NAME: grl-exporter 12 | OWNER: kalgurn 13 | 14 | jobs: 15 | test: 16 | name: Test 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | go-version: 21 | - '1.24' 22 | steps: 23 | 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 27 | 28 | - name: Setup Go 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: ${{ matrix.go-version }} # The Go version to download (if necessary) and use. 32 | go-version-file: go.mod 33 | cache: false 34 | 35 | - name: Run Unit tests 36 | run: go test ./... -test.v 37 | 38 | build-and-push-image: 39 | runs-on: ubuntu-latest 40 | permissions: 41 | contents: read 42 | packages: write 43 | needs: 44 | - test 45 | 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@v4 49 | 50 | - name: Log in to the Container registry 51 | uses: docker/login-action@v3.4.0 52 | with: 53 | registry: ${{ env.REGISTRY }} 54 | username: ${{ github.actor }} 55 | password: ${{ secrets.GHCR_TOKEN }} 56 | 57 | - name: Extract metadata (tags, labels) for Docker 58 | id: meta 59 | uses: docker/metadata-action@v5.7.0 60 | with: 61 | images: ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.IMAGE_NAME }} 62 | 63 | - name: Build and push Docker image 64 | uses: docker/build-push-action@v6.13.0 65 | with: 66 | context: . 67 | push: true 68 | tags: ${{ steps.meta.outputs.tags }} 69 | labels: ${{ steps.meta.outputs.labels }} 70 | 71 | build: 72 | name: Build 73 | runs-on: ubuntu-latest 74 | needs: 75 | - test 76 | outputs: 77 | changelog: ${{ steps.changelog.outputs.clean_changelog }} 78 | changelog_tag: ${{ steps.changelog.outputs.tag }} 79 | changelog_skipped: ${{ steps.changelog.outputs.skipped }} 80 | strategy: 81 | matrix: 82 | goos: 83 | - linux 84 | - darwin 85 | goarch: 86 | - amd64 87 | - arm64 88 | go-version: 89 | - '1.24' 90 | steps: 91 | 92 | - uses: actions/checkout@v4 93 | with: 94 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 95 | 96 | - name: Conventional Changelog Action 97 | id: changelog 98 | uses: TriPSs/conventional-changelog-action@v3 99 | with: 100 | github-token: ${{ secrets.github_token }} 101 | git-message: 'chore(release): {version}' 102 | tag-prefix: 'v' 103 | version-file: './version.json' 104 | preset: 'conventionalcommits' 105 | output-file: 'false' 106 | 107 | - name: Setup Go 108 | uses: actions/setup-go@v5 109 | with: 110 | go-version: ${{ matrix.go-version }} # The Go version to download (if necessary) and use. 111 | go-version-file: go.mod 112 | cache: false 113 | 114 | # Install all the dependencies 115 | - name: Install dependencies 116 | run: | 117 | go version 118 | go get -u golang.org/x/lint/golint 119 | 120 | # Run build of the application 121 | - name: Run build 122 | run: ./build.sh ${{ steps.changelog.outputs.tag }} 123 | env: 124 | GOOS: ${{ matrix.goos }} 125 | GOARCH: ${{ matrix.goarch }} 126 | CGO_ENABLED: 0 127 | 128 | - name: Store intermediate artifact 129 | uses: actions/upload-artifact@v4 130 | with: 131 | name: grl-exporter-${{ matrix.goos }}-${{ matrix.goarch }}-${{ steps.changelog.outputs.tag }}.zip 132 | path: grl-exporter-${{ matrix.goos }}-${{ matrix.goarch }}.zip 133 | 134 | release: 135 | name: Release 136 | runs-on: ubuntu-latest 137 | needs: 138 | - build 139 | steps: 140 | 141 | - uses: actions/checkout@v4 142 | with: 143 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 144 | 145 | - name: Download artifacts 146 | id: download 147 | uses: actions/download-artifact@v4 148 | 149 | - name: Display structure of downloaded files 150 | id: files 151 | run: | 152 | echo "::set-output name=list::$(ls ./**/*.zip | jq --raw-input --slurp '.')" 153 | 154 | - name: Release with Notes 155 | uses: softprops/action-gh-release@v1 156 | with: 157 | body: ${{ needs.build.outputs.changelog }} 158 | draft: true 159 | files: ${{ fromJSON(steps.files.outputs.list) }} 160 | -------------------------------------------------------------------------------- /internal/github_client/github_client.go: -------------------------------------------------------------------------------- 1 | package github_client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/bradleyfalzon/ghinstallation/v2" 12 | "github.com/golang-jwt/jwt/v5" 13 | "github.com/google/go-github/v65/github" 14 | "github.com/kalgurn/github-rate-limits-prometheus-exporter/internal/utils" 15 | "golang.org/x/oauth2" 16 | ) 17 | 18 | func GetRemainingLimits(c *github.Client) (RateLimits, error) { 19 | if c == nil { 20 | return RateLimits{}, utils.RespError(fmt.Errorf("github client is nil")) 21 | } 22 | 23 | // Set a timeout of 5 seconds for the request 24 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 25 | defer cancel() 26 | 27 | limits, _, err := c.RateLimit.Get(ctx) 28 | if err != nil { 29 | return RateLimits{}, utils.RespError(err) 30 | } 31 | if limits == nil || limits.Core == nil { 32 | return RateLimits{}, utils.RespError(fmt.Errorf("rate limit response is nil")) 33 | } 34 | 35 | return RateLimits{ 36 | Limit: limits.Core.Limit, 37 | Remaining: limits.Core.Remaining, 38 | Used: limits.Core.Limit - limits.Core.Remaining, 39 | SecondsLeft: time.Until(limits.Core.Reset.Time).Seconds(), 40 | }, nil 41 | } 42 | 43 | func (c *TokenConfig) InitClient() *github.Client { 44 | return initTokenClient(c, http.DefaultClient) 45 | } 46 | 47 | func (c *AppConfig) InitClient() *github.Client { 48 | return initAppClient(c, http.DefaultClient) 49 | } 50 | 51 | func InitConfig() GithubClient { 52 | // determine type (app or pat) 53 | var auth GithubClient 54 | authType := utils.GetOSVar("GITHUB_AUTH_TYPE") 55 | if authType == "PAT" { 56 | auth = &TokenConfig{ 57 | Token: utils.GetOSVar("GITHUB_TOKEN"), 58 | } 59 | 60 | } else if authType == "APP" { 61 | appID, _ := strconv.ParseInt(utils.GetOSVar("GITHUB_APP_ID"), 10, 64) 62 | 63 | var installationID int64 64 | envInstallationID := utils.GetOSVar("GITHUB_INSTALLATION_ID") 65 | if envInstallationID != "" { 66 | installationID, _ = strconv.ParseInt(envInstallationID, 10, 64) 67 | } 68 | 69 | // Only read organization/repo names if InstallationID is not provided 70 | var orgName, repoName string 71 | if envInstallationID == "" { 72 | orgName = utils.GetOSVar("GITHUB_ORG_NAME") 73 | repoName = utils.GetOSVar("GITHUB_REPO_NAME") 74 | } 75 | 76 | auth = &AppConfig{ 77 | AppID: appID, 78 | InstallationID: installationID, 79 | OrgName: orgName, 80 | RepoName: repoName, 81 | PrivateKeyPath: utils.GetOSVar("GITHUB_PRIVATE_KEY_PATH"), 82 | } 83 | } else { 84 | err := fmt.Errorf("invalid auth type") 85 | utils.RespError(err) 86 | return nil 87 | } 88 | 89 | return auth 90 | 91 | } 92 | 93 | // Helper function to allow testing client initialization with custom http clients 94 | func initTokenClient(c *TokenConfig, httpClient *http.Client) *github.Client { 95 | if httpClient == http.DefaultClient { 96 | ctx := context.Background() 97 | ts := oauth2.StaticTokenSource( 98 | &oauth2.Token{AccessToken: c.Token}, 99 | ) 100 | httpClient = oauth2.NewClient(ctx, ts) 101 | } 102 | return github.NewClient(httpClient) 103 | } 104 | 105 | // Helper function to allow testing client initialization with custom http clients 106 | func initAppClient(c *AppConfig, httpClient *http.Client) *github.Client { 107 | if c.InstallationID == 0 && c.OrgName != "" { 108 | // Retrieve the installation ID if not provided 109 | auth := &TokenConfig{ 110 | Token: generateJWT(c.AppID, c.PrivateKeyPath), 111 | } 112 | client := initTokenClient(auth, httpClient) 113 | 114 | var err error 115 | var installation *github.Installation 116 | ctx := context.Background() 117 | if c.RepoName != "" { 118 | installation, _, err = client.Apps.FindRepositoryInstallation(ctx, c.OrgName, c.RepoName) 119 | } else { 120 | installation, _, err = client.Apps.FindOrganizationInstallation(ctx, c.OrgName) 121 | } 122 | utils.RespError(err) 123 | 124 | c.InstallationID = installation.GetID() 125 | } 126 | 127 | if httpClient == nil { 128 | httpClient = &http.Client{} 129 | } 130 | 131 | if httpClient == http.DefaultClient { 132 | tr := http.DefaultTransport 133 | itr, err := ghinstallation.NewKeyFromFile(tr, c.AppID, c.InstallationID, c.PrivateKeyPath) 134 | utils.RespError(err) 135 | httpClient = &http.Client{Transport: itr} 136 | } else { 137 | // Wrap the existing transport 138 | tr := httpClient.Transport 139 | if tr == nil { 140 | tr = http.DefaultTransport 141 | } 142 | itr, err := ghinstallation.NewKeyFromFile(tr, c.AppID, c.InstallationID, c.PrivateKeyPath) 143 | utils.RespError(err) 144 | httpClient.Transport = itr 145 | } 146 | 147 | return github.NewClient(httpClient) 148 | } 149 | 150 | // Helper function to generate JWT for GitHub App 151 | func generateJWT(appID int64, privateKeyPath string) string { 152 | privateKey, err := os.ReadFile(privateKeyPath) 153 | utils.RespError(err) 154 | 155 | parsedKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey) 156 | utils.RespError(err) 157 | 158 | now := time.Now() 159 | claims := jwt.RegisteredClaims{ 160 | Issuer: fmt.Sprintf("%d", appID), 161 | IssuedAt: jwt.NewNumericDate(now), 162 | ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), 163 | } 164 | token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) 165 | 166 | signedToken, err := token.SignedString(parsedKey) 167 | utils.RespError(err) 168 | 169 | return signedToken 170 | } 171 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/bradleyfalzon/ghinstallation/v2 v2.16.0 h1:B91r9bHtXp/+XRgS5aZm6ZzTdz3ahgJYmkt4xZkgDz8= 4 | github.com/bradleyfalzon/ghinstallation/v2 v2.16.0/go.mod h1:OeVe5ggFzoBnmgitZe/A+BqGOnv1DvU/0uiLQi1wutM= 5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 11 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 12 | github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 13 | github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 14 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 15 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 16 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 17 | github.com/google/go-github/v65 v65.0.0 h1:pQ7BmO3DZivvFk92geC0jB0q2m3gyn8vnYPgV7GSLhQ= 18 | github.com/google/go-github/v65 v65.0.0/go.mod h1:DvrqWo5hvsdhJvHd4WyVF9ttANN3BniqjP8uTFMNb60= 19 | github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= 20 | github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= 21 | github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24= 22 | github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= 23 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 24 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 25 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 26 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 27 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 28 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 29 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 30 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 31 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 32 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 33 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 34 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 35 | github.com/migueleliasweb/go-github-mock v1.4.0 h1:pQ6K8r348m2q79A8Khb0PbEeNQV7t3h1xgECV+jNpXk= 36 | github.com/migueleliasweb/go-github-mock v1.4.0/go.mod h1:/DUmhXkxrgVlDOVBqGoUXkV4w0ms5n1jDQHotYm135o= 37 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 38 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 39 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 40 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 41 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 42 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 43 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 44 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 45 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 46 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 47 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 48 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 49 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 50 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 51 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 52 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 53 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 54 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 55 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 56 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 57 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 58 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 59 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 60 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 61 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 62 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 63 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 64 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 65 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 66 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 67 | -------------------------------------------------------------------------------- /internal/github_client/github_client_test.go: -------------------------------------------------------------------------------- 1 | package github_client 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/json" 8 | "encoding/pem" 9 | "fmt" 10 | "math" 11 | "net/http" 12 | "os" 13 | "testing" 14 | "time" 15 | 16 | "github.com/golang-jwt/jwt/v5" 17 | "github.com/google/go-github/v65/github" 18 | "github.com/migueleliasweb/go-github-mock/src/mock" 19 | "github.com/stretchr/testify/assert" 20 | ) 21 | 22 | func generateTestPrivateKey(t *testing.T) (string, *rsa.PrivateKey) { 23 | // Generate RSA private key 24 | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) 25 | if err != nil { 26 | t.Fatalf("Failed to generate RSA private key: %v", err) 27 | } 28 | 29 | // Convert private key to PEM format 30 | privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) 31 | privateKeyPEM := pem.EncodeToMemory(&pem.Block{ 32 | Type: "RSA PRIVATE KEY", 33 | Bytes: privateKeyBytes, 34 | }) 35 | 36 | // Write private key to a temp file 37 | tempKeyFile, err := os.CreateTemp("", "testkey") 38 | if err != nil { 39 | t.Fatalf("Failed to create temp file: %v", err) 40 | } 41 | defer tempKeyFile.Close() 42 | 43 | if _, err := tempKeyFile.Write(privateKeyPEM); err != nil { 44 | t.Fatalf("Failed to write to temp key file: %v", err) 45 | } 46 | 47 | return tempKeyFile.Name(), privateKey 48 | } 49 | 50 | func TestGetRemainingLimits(t *testing.T) { 51 | var ( 52 | limit = 100 53 | remaining = 63 54 | used = 37 55 | seconds_left = 1500 56 | ) 57 | mockedHTTPClient := mock.NewMockedHTTPClient( 58 | mock.WithRequestMatch( 59 | mock.GetRateLimit, 60 | struct { 61 | Resources *github.RateLimits 62 | }{ 63 | Resources: &github.RateLimits{ 64 | Core: &github.Rate{ 65 | Limit: limit, 66 | Remaining: remaining, 67 | Reset: github.Timestamp{Time: time.Now().Add(time.Second * time.Duration(seconds_left))}, 68 | }, 69 | Search: &github.Rate{}, 70 | }, 71 | }, 72 | ), 73 | ) 74 | c := github.NewClient(mockedHTTPClient) 75 | limits, err := GetRemainingLimits(c) 76 | assert.NoError(t, err) 77 | 78 | assert.Equal(t, limit, limits.Limit, "The limits should be equal") 79 | assert.Equal(t, remaining, limits.Remaining, "The remaining limits should be equal") 80 | assert.Equal(t, used, limits.Used, "The used value should be equal") 81 | assert.Equal(t, seconds_left, int(math.Ceil(limits.SecondsLeft)), "The seconds left value should be equal") 82 | 83 | assert.NotEqual(t, 99, limits.Limit, "The limit should not be equal") 84 | assert.NotEqual(t, 99, limits.Remaining, "The remaining limits should not be equal") 85 | assert.NotEqual(t, 18, limits.Used, "The used value should not be equal") 86 | assert.NotEqual(t, 18, limits.Used, "The seconds left value should not be equal") 87 | } 88 | 89 | func TestInitConfigApp(t *testing.T) { 90 | os.Setenv("GITHUB_AUTH_TYPE", "APP") 91 | os.Setenv("GITHUB_APP_ID", "1") 92 | os.Setenv("GITHUB_INSTALLATION_ID", "1") 93 | os.Setenv("GITHUB_PRIVATE_KEY_PATH", "/home") 94 | defer os.Unsetenv("GITHUB_INSTALLATION_ID") 95 | 96 | testAuth := &AppConfig{ 97 | AppID: 1, 98 | InstallationID: 1, 99 | PrivateKeyPath: "/home", 100 | } 101 | 102 | appInitConfig := InitConfig() 103 | 104 | assert.Equal(t, appInitConfig, testAuth, "should be equal") 105 | 106 | } 107 | 108 | func TestInitConfigPAT(t *testing.T) { 109 | os.Setenv("GITHUB_AUTH_TYPE", "PAT") 110 | os.Setenv("GITHUB_TOKEN", "token_ahsd") 111 | 112 | testAuth := &TokenConfig{ 113 | Token: "token_ahsd", 114 | } 115 | 116 | patInitConfig := InitConfig() 117 | 118 | assert.Equal(t, patInitConfig, testAuth, "should be equal") 119 | 120 | } 121 | 122 | func TestInitConfigFailure(t *testing.T) { 123 | os.Setenv("GITHUB_AUTH_TYPE", "test") 124 | 125 | patInitConfig := InitConfig() 126 | 127 | assert.Equal(t, nil, patInitConfig) 128 | 129 | } 130 | 131 | func TestInitConfigAppWithoutInstallationID(t *testing.T) { 132 | os.Setenv("GITHUB_AUTH_TYPE", "APP") 133 | os.Setenv("GITHUB_APP_ID", "1") 134 | os.Setenv("GITHUB_ORG_NAME", "org") 135 | os.Setenv("GITHUB_PRIVATE_KEY_PATH", "/home") 136 | 137 | testAuth := &AppConfig{ 138 | AppID: 1, 139 | OrgName: "org", 140 | PrivateKeyPath: "/home", 141 | } 142 | 143 | appInitConfig := InitConfig() 144 | 145 | assert.Equal(t, appInitConfig, testAuth, "should be equal") 146 | } 147 | 148 | func TestAppConfig_InitClient(t *testing.T) { 149 | testCases := []struct { 150 | name string 151 | orgName string 152 | repoName string 153 | providedInstallID int64 // InstallationID provided directly in AppConfig 154 | expectedInstallID int64 // Expected InstallationID after InitClient 155 | expectedPattern string 156 | method string 157 | }{ 158 | { 159 | name: "WithInstallationID", 160 | orgName: "", 161 | repoName: "", 162 | providedInstallID: 654321, 163 | expectedInstallID: 654321, 164 | expectedPattern: "", // No API call expected 165 | method: "", 166 | }, 167 | { 168 | name: "WithOrgName", 169 | orgName: "testorg", 170 | repoName: "", 171 | providedInstallID: 0, // To be retrieved via API 172 | expectedInstallID: 654321, 173 | expectedPattern: "/orgs/{org}/installation", 174 | method: "GET", 175 | }, 176 | { 177 | name: "WithOrgAndRepoName", 178 | orgName: "testorg", 179 | repoName: "testrepo", 180 | providedInstallID: 0, // To be retrieved via API 181 | expectedInstallID: 654321, 182 | expectedPattern: "/repos/{owner}/{repo}/installation", 183 | method: "GET", 184 | }, 185 | } 186 | 187 | for _, tc := range testCases { 188 | t.Run(tc.name, func(t *testing.T) { 189 | privateKeyPath, _ := generateTestPrivateKey(t) 190 | defer os.Remove(privateKeyPath) 191 | 192 | appID := int64(123456) 193 | var httpClient *http.Client 194 | 195 | if tc.expectedPattern != "" { 196 | // Create a mock HTTP client to simulate API call 197 | mockClient := mock.NewMockedHTTPClient( 198 | mock.WithRequestMatchHandler( 199 | mock.EndpointPattern{Pattern: tc.expectedPattern, Method: tc.method}, 200 | http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 201 | // Return mock installation data 202 | installation := &github.Installation{ 203 | ID: github.Int64(tc.expectedInstallID), 204 | } 205 | data, _ := json.Marshal(installation) 206 | w.WriteHeader(http.StatusOK) 207 | w.Write(data) 208 | }), 209 | ), 210 | ) 211 | httpClient = mockClient 212 | } else { 213 | httpClient = nil // No HTTP client needed; no API call expected 214 | } 215 | 216 | // Initialize the AppConfig 217 | c := &AppConfig{ 218 | AppID: appID, 219 | InstallationID: tc.providedInstallID, 220 | OrgName: tc.orgName, 221 | RepoName: tc.repoName, 222 | PrivateKeyPath: privateKeyPath, 223 | } 224 | 225 | client := initAppClient(c, httpClient) 226 | assert.NotNil(t, client, "Expected client not to be nil") 227 | 228 | assert.Equal(t, tc.expectedInstallID, c.InstallationID, "Expected InstallationID to be set correctly") 229 | }) 230 | } 231 | } 232 | 233 | func TestGenerateJWT(t *testing.T) { 234 | privateKeyPath, privateKey := generateTestPrivateKey(t) 235 | defer os.Remove(privateKeyPath) 236 | 237 | appID := int64(123456) 238 | token := generateJWT(appID, privateKeyPath) 239 | 240 | assert.NotEmpty(t, token, "expected token not to be empty") 241 | 242 | // Verify the token 243 | parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { 244 | return &privateKey.PublicKey, nil 245 | }) 246 | 247 | if err != nil { 248 | t.Fatalf("Failed to parse token: %v", err) 249 | } 250 | 251 | assert.True(t, parsedToken.Valid, "the token should be valid") 252 | 253 | // Check claims 254 | if claims, ok := parsedToken.Claims.(jwt.MapClaims); ok { 255 | issuer := claims["iss"] 256 | assert.Equal(t, fmt.Sprintf("%d", appID), issuer, "expected issuer to be equal app id") 257 | 258 | exp := int64(claims["exp"].(float64)) 259 | now := time.Now().Unix() 260 | assert.LessOrEqual(t, now, exp, "expected token to not be expired") 261 | } else { 262 | t.Error("Failed to parse claims") 263 | } 264 | } 265 | --------------------------------------------------------------------------------