From cd38548cb46e632dfca57867d9a9e737563b65e3 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 30 Dec 2025 07:56:31 -0300 Subject: [PATCH 1/6] feat: first implementation Signed-off-by: Gustavo Carvalho --- .github/workflows/build-and-upload.yml | 28 ++ .github/workflows/push.yml | 13 + .github/workflows/release.yml | 13 + .github/workflows/test.yml | 15 + .gitignore | 6 + .goreleaser.yaml | 42 +++ Makefile | 34 ++ PLAN.md | 74 +++++ go.mod | 58 ++++ go.sum | 365 ++++++++++++++++++++ internal/jira/client.go | 287 ++++++++++++++++ internal/jira/types.go | 152 +++++++++ main.go | 443 +++++++++++++++++++++++++ 13 files changed, 1530 insertions(+) create mode 100644 .github/workflows/build-and-upload.yml create mode 100644 .github/workflows/push.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 Makefile create mode 100644 PLAN.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/jira/client.go create mode 100644 internal/jira/types.go create mode 100644 main.go diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml new file mode 100644 index 0000000..25b8b35 --- /dev/null +++ b/.github/workflows/build-and-upload.yml @@ -0,0 +1,28 @@ +name: Build and Upload Artifacts + +on: + workflow_call: + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + # 'latest', 'nightly', or a semver + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install gooci cli + run: go install github.com/compliance-framework/gooci@latest + - name: Authenticate gooci cli + run: gooci login ghcr.io --username ${{ github.actor }} --password ${{ secrets.GITHUB_TOKEN }} + - name: gooci Upload Version + run: gooci upload dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{github.ref_name}} + - name: gooci Upload Latest + if: "!github.event.release.prerelease" + run: gooci upload dist/ ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..e6447a2 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,13 @@ +name: Push + +on: + pull_request: + push: + branches: + - '*' + +jobs: + test: + permissions: + contents: read + uses: ./.github/workflows/test.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2a50955 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,13 @@ +name: New Release + +on: + push: + tags: + - '*' + +jobs: + release: + permissions: + packages: write + contents: write + uses: ./.github/workflows/build-and-upload.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..84d5222 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,15 @@ +name: Go Test + +on: + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + + - name: Test + run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0063ff9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +dist/ +.DS_Store +plugin +.env +.envrc \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..df6e9e3 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,42 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com + +# The lines below are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/need to use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 + +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy + # you may remove this if you don't need go generate + - go generate ./... + +builds: + - binary: plugin + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..14d8665 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +# The help target prints out all targets with their descriptions organized +# beneath their categories. The categories are represented by '##@' and the +# target descriptions by '##'. The awk commands is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI catalog characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +# Check if OPA CLI is installed +OPA := $(shell command -v opa 2> /dev/null) +ifeq ($(OPA),) +$(error "opa CLI not found. Please install it: https://www.openpolicyagent.org/docs/latest/cli/") +endif + +##@ Help +help: ## Display this concise help, ie only the porcelain target + @awk 'BEGIN {FS = ":.*##"; printf "\033[1mUsage\033[0m\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-30s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +help-all: ## Display all help items, ie including plumbing targets + @awk 'BEGIN {FS = ":.*#"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?#/ { printf " \033[36m%-25s\033[0m %s\n", $$1, $$2 } /^#@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + + + + +# Bundle the policies into a tarball for OCI registry +clean: # Cleanup build artifacts + @rm -rf dist/* + +build: clean ## Build the policy bundle + @mkdir -p dist/ + @go build -o dist/plugin main.go \ No newline at end of file diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..2197d80 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,74 @@ +# Jira Compliance Plugin Plan + +This plugin for the `compliance-framework` evaluates Jira/JSM data to ensure Change Request processes are followed correctly before deployments. + +## Architecture + +The plugin implements the `Runner` interface from the `compliance-framework/agent`. It collects data from multiple Jira APIs and evaluates policies to generate compliance evidence. + +## Goals + +* **Authentication**: Support Service Account + OAuth2 (2LO) and API Tokens. +* **Data Collection**: + * **Jira Platform**: Projects, Workflows, Schemes, Issues, Fields, Audit Records, Permissions, Remote Links. + * **JSM**: Service Desks, Request Types, Approvals, SLAs. + * **Jira Software**: Dev Info (PRs/commits), Deployment info. +* **Compliance Checks**: + * Approval gate presence in workflows. + * GitHub link binding for projects. + * Minimal approvals verification. + * Deployment date vs. Approval date correctness. + * Detection of bypasses or compliance drift. + +## Implementation Roadmap + +### Phase 1: Foundation +- [x] Initialize repository and update `go.mod` (module name: `github.com/compliance-framework/plugin-jira`). +- [x] Define `PluginConfig` structure for Jira-specific settings (URL, Auth, Project filters). +- [x] Implement `Configure` method to handle plugin setup. + +### Phase 2: Jira Client & Authentication +- [x] Implement Jira client wrapper supporting Cloud (/v3) and DC (/v2) endpoints. +- [x] Add OAuth2 (2LO) client credentials flow. +- [x] Add API Token / Basic Auth fallback. + +### Phase 3: Data Collection (Collectors) +- [x] **Platform Collector**: + * `GetProjects()`: List and metadata. + * `GetWorkflows()`: Workflow steps and transitions. + * `GetWorkflowSchemes()`: Project-workflow mapping. + * `SearchIssues(jql)`: Find Change Requests. + * `GetChangelog(issueId)`: History of transitions and approvals. +- [x] **JSM Collector**: + * `GetApprovals(issueId)`: Extraction of JSM-native approvals. + * `GetSLAs(issueId)`: Timing signals for approvals. +- [x] **Software Collector**: + * `GetDevInfo(issueId)`: Linked PRs and commits. + * `GetDeployments(issueId)`: Deployment records. + +### Phase 4: Policy Evaluation & Evidence +- [x] Map Jira data to `TrackedJiraData` structure for policy evaluation. +- [x] Implement `EvaluatePolicies` logic using `policyManager`. +- [ ] Generate OPA-compatible inputs from collected Jira metadata. +- [ ] Define standard evidence structure for Change Request compliance. + +### Phase 5: Refinement & Testing +- [ ] Add comprehensive logging. +- [ ] Implement unit tests for data extraction logic. +- [x] Document configuration parameters in `README.md`. + +## Configuration Schema + +```go +type PluginConfig struct { + BaseURL string `mapstructure:"base_url"` + AuthType string `mapstructure:"auth_type"` // "oauth2" or "token" + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + APIToken string `mapstructure:"api_token"` + UserEmail string `mapstructure:"user_email"` + ProjectKeys []string `mapstructure:"project_keys"` + ExcludedWorkflows []string `mapstructure:"excluded_workflows"` + PolicyLabels string `mapstructure:"policy_labels"` +} +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6df63ac --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module github.com/compliance-framework/plugin-jira + +go 1.25.4 + +require ( + github.com/compliance-framework/agent v0.2.1 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-plugin v1.7.0 + github.com/mitchellh/mapstructure v1.5.0 + golang.org/x/oauth2 v0.34.0 +) + +require ( + github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/agnivade/levenshtein v1.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/compliance-framework/api v0.4.4 // indirect + github.com/defenseunicorns/go-oscal v0.6.3 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/run v1.2.0 // indirect + github.com/open-policy-agent/opa v1.0.0 // indirect + github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.57.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/tchap/go-patricia/v2 v2.3.1 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2605985 --- /dev/null +++ b/go.sum @@ -0,0 +1,365 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= +github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY= +github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA= +github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/compliance-framework/agent v0.2.1 h1:I2cvHRdkBiIXeud7STptpg0+pzHBSUMiFxIuI4EzdGc= +github.com/compliance-framework/agent v0.2.1/go.mod h1:fpUMZejzNNfwadGnrN8HpAAyka+UANx8LVhiLZeoPhg= +github.com/compliance-framework/api v0.4.4 h1:qY6Az+CBfx9cku/tzmrPX2d0qRaAfAXnQVopDIYwlQs= +github.com/compliance-framework/api v0.4.4/go.mod h1:UjL+VppIb0jmFbViiQSKkhUfY8X9I29faML7gl0fD1M= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/defenseunicorns/go-oscal v0.6.3 h1:3j5aBobVX+Fy2GEIRCeg9MhsAgCKceOagVEDQPMuzZc= +github.com/defenseunicorns/go-oscal v0.6.3/go.mod h1:m55Ny/RTh4xWuxVSOD/poCZs9V9GOjNtjT0NujoxI6I= +github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg= +github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= +github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw= +github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v2.0.8+incompatible h1:ivUb1cGomAB101ZM1T0nOiWz9pSrTMoa9+EiY7igmkM= +github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/open-policy-agent/opa v1.0.0 h1:fZsEwxg1knpPvUn0YDJuJZBcbVg4G3zKpWa3+CnYK+I= +github.com/open-policy-agent/opa v1.0.0/go.mod h1:+JyoH12I0+zqyC1iX7a2tmoQlipwAEGvOhVJMhmy+rM= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.57.0 h1:Ro/rKjwdq9mZn1K5QPctzh+MA4Lp0BuYk5ZZEVhoNcY= +github.com/prometheus/common v0.57.0/go.mod h1:7uRPFSUTbfZWsJ7MHY56sqt7hLQu3bxXHDnNhl8E9qI= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= +github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk= +github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes= +github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= +github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I= +gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= +gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/jira/client.go b/internal/jira/client.go new file mode 100644 index 0000000..4de6cd5 --- /dev/null +++ b/internal/jira/client.go @@ -0,0 +1,287 @@ +package jira + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/hashicorp/go-hclog" +) + +// Client handles communication with Jira APIs +type Client struct { + BaseURL string + HTTPClient *http.Client + Logger hclog.Logger +} + +func NewClient(baseURL string, httpClient *http.Client, logger hclog.Logger) *Client { + return &Client{ + BaseURL: baseURL, + HTTPClient: httpClient, + Logger: logger, + } +} + +type tokenAuthTransport struct { + email string + token string + base http.RoundTripper +} + +func (t *tokenAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.SetBasicAuth(t.email, t.token) + return t.base.RoundTrip(req) +} + +func NewTokenAuthClient(email, token string) *http.Client { + return &http.Client{ + Transport: &tokenAuthTransport{ + email: email, + token: token, + base: http.DefaultTransport, + }, + } +} + +func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + rel, err := url.Parse(path) + if err != nil { + return nil, err + } + u, _ := url.Parse(c.BaseURL) + u = u.ResolveReference(rel) + + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return c.HTTPClient.Do(req) +} + +func (c *Client) FetchProjects(ctx context.Context) ([]JiraProject, error) { + resp, err := c.do(ctx, "GET", "/rest/api/3/project", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var projects []JiraProject + if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil { + return nil, err + } + return projects, nil +} + +func (c *Client) FetchWorkflows(ctx context.Context) ([]JiraWorkflow, error) { + resp, err := c.do(ctx, "GET", "/rest/api/3/workflow/search", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Values []JiraWorkflow `json:"values"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Values, nil +} + +func (c *Client) FetchWorkflowSchemes(ctx context.Context) ([]JiraWorkflowScheme, error) { + resp, err := c.do(ctx, "GET", "/rest/api/3/workflowscheme", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Values []JiraWorkflowScheme `json:"values"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Values, nil +} + +func (c *Client) FetchIssueTypes(ctx context.Context) ([]JiraIssueType, error) { + resp, err := c.do(ctx, "GET", "/rest/api/3/issuetype", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var types []JiraIssueType + if err := json.NewDecoder(resp.Body).Decode(&types); err != nil { + return nil, err + } + return types, nil +} + +func (c *Client) FetchFields(ctx context.Context) ([]JiraField, error) { + resp, err := c.do(ctx, "GET", "/rest/api/3/field", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var fields []JiraField + if err := json.NewDecoder(resp.Body).Decode(&fields); err != nil { + return nil, err + } + return fields, nil +} + +func (c *Client) FetchAuditRecords(ctx context.Context) ([]JiraAuditRecord, error) { + resp, err := c.do(ctx, "GET", "/rest/api/3/auditing/record", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Records []JiraAuditRecord `json:"records"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Records, nil +} + +func (c *Client) FetchGlobalPermissions(ctx context.Context) ([]JiraPermission, error) { + resp, err := c.do(ctx, "GET", "/rest/api/3/permissions", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Permissions map[string]JiraPermission `json:"permissions"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + perms := make([]JiraPermission, 0, len(result.Permissions)) + for _, p := range result.Permissions { + perms = append(perms, p) + } + return perms, nil +} + +func (c *Client) FetchIssueSLAs(ctx context.Context, issueKey string) ([]JiraSLA, error) { + resp, err := c.do(ctx, "GET", fmt.Sprintf("/rest/servicedeskapi/request/%s/sla", issueKey), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Values []JiraSLA `json:"values"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Values, nil +} + +func (c *Client) FetchIssueDevInfo(ctx context.Context, issueKey string) (*JiraDevInfo, error) { + resp, err := c.do(ctx, "GET", fmt.Sprintf("/rest/devinfo/0.10/bulk?issueKeys=%s", issueKey), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var info JiraDevInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, err + } + return &info, nil +} + +func (c *Client) FetchIssueDeployments(ctx context.Context, issueKey string) ([]JiraDeployment, error) { + resp, err := c.do(ctx, "GET", fmt.Sprintf("/rest/deployments/0.1/bulk?issueKeys=%s", issueKey), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Deployments []JiraDeployment `json:"deployments"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Deployments, nil +} + +func (c *Client) SearchChangeRequests(ctx context.Context) ([]JiraIssue, error) { + jql := "issuetype in ('Change Request', 'Change') AND status != 'Draft'" + query := url.Values{} + query.Set("jql", jql) + query.Set("expand", "changelog") + + resp, err := c.do(ctx, "GET", "/rest/api/3/search?"+query.Encode(), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Issues []JiraIssue `json:"issues"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Issues, nil +} + +func (c *Client) FetchIssueChangelog(ctx context.Context, issueKey string) ([]JiraChangelogEntry, error) { + resp, err := c.do(ctx, "GET", fmt.Sprintf("/rest/api/3/issue/%s/changelog", issueKey), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Values []JiraChangelogEntry `json:"values"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Values, nil +} + +func (c *Client) FetchIssueApprovals(ctx context.Context, issueKey string) ([]JiraApproval, error) { + resp, err := c.do(ctx, "GET", fmt.Sprintf("/rest/servicedeskapi/request/%s/approval", issueKey), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Values []JiraApproval `json:"values"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result.Values, nil +} + +func (c *Client) FetchProjectRemoteLinks(ctx context.Context, projectKey string) ([]JiraRemoteLink, error) { + // Placeholder as requested in original code + return nil, nil +} diff --git a/internal/jira/types.go b/internal/jira/types.go new file mode 100644 index 0000000..e31335e --- /dev/null +++ b/internal/jira/types.go @@ -0,0 +1,152 @@ +package jira + +import "time" + +type JiraData struct { + Projects []JiraProject `json:"projects"` + Issues []JiraIssue `json:"issues"` + Workflows []JiraWorkflow `json:"workflows"` + WorkflowSchemes []JiraWorkflowScheme `json:"workflow_schemes"` + IssueTypes []JiraIssueType `json:"issue_types"` + Fields []JiraField `json:"fields"` + AuditRecords []JiraAuditRecord `json:"audit_records"` + GlobalPermissions []JiraPermission `json:"global_permissions"` +} + +type JiraProject struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Category *JiraProjectCategory `json:"projectCategory,omitempty"` + Components []JiraComponent `json:"components,omitempty"` + WorkflowScheme *JiraWorkflowScheme `json:"workflow_scheme,omitempty"` + Permissions *JiraPermissionScheme `json:"permission_scheme,omitempty"` + RemoteLinks []JiraRemoteLink `json:"remote_links"` +} + +type JiraProjectCategory struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type JiraComponent struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type JiraWorkflow struct { + Name string `json:"name"` + Description string `json:"description"` + Statuses []JiraStatus `json:"statuses"` + Transitions []JiraTransition `json:"transitions"` +} + +type JiraStatus struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type JiraTransition struct { + ID string `json:"id"` + Name string `json:"name"` + To string `json:"to"` +} + +type JiraWorkflowScheme struct { + ID int64 `json:"id"` + Name string `json:"name"` + Mappings map[string]string `json:"issueTypeMappings"` // IssueType -> Workflow Name + Default string `json:"defaultWorkflow"` +} + +type JiraIssueType struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type JiraField struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"schema,omitempty"` +} + +type JiraIssue struct { + Key string `json:"key"` + Fields map[string]interface{} `json:"fields"` + Changelog []JiraChangelogEntry `json:"changelog"` + Approvals []JiraApproval `json:"approvals"` + SLAs []JiraSLA `json:"slas,omitempty"` + DevInfo *JiraDevInfo `json:"dev_info,omitempty"` + Deployments []JiraDeployment `json:"deployments,omitempty"` +} + +type JiraChangelogEntry struct { + Author string `json:"author"` + Created time.Time `json:"created"` + Items []JiraChangelogItem `json:"items"` +} + +type JiraChangelogItem struct { + Field string `json:"field"` + From string `json:"fromString"` + To string `json:"toString"` +} + +type JiraApproval struct { + ID string `json:"id"` + Status string `json:"status"` + Approvers []string `json:"approvers"` + Completed time.Time `json:"completed_date"` +} + +type JiraSLA struct { + Name string `json:"name"` + Breached bool `json:"breached"` + Remaining string `json:"remaining_time"` + Target time.Time `json:"target_date"` +} + +type JiraDevInfo struct { + PullRequests []JiraPR `json:"pull_requests"` +} + +type JiraPR struct { + ID string `json:"id"` + Status string `json:"status"` + URL string `json:"url"` +} + +type JiraDeployment struct { + ID string `json:"id"` + Environment string `json:"environment"` + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` +} + +type JiraRemoteLink struct { + ID int64 `json:"id"` + Object struct { + URL string `json:"url"` + Title string `json:"title"` + } `json:"object"` +} + +type JiraAuditRecord struct { + ID int64 `json:"id"` + Summary string `json:"summary"` + Created time.Time `json:"created"` + AuthorName string `json:"authorName"` + Category string `json:"category"` +} + +type JiraPermission struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` +} + +type JiraPermissionScheme struct { + ID int64 `json:"id"` + Name string `json:"name"` +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..8a10a76 --- /dev/null +++ b/main.go @@ -0,0 +1,443 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "net/http" + "slices" + "strings" + + policyManager "github.com/compliance-framework/agent/policy-manager" + "github.com/compliance-framework/agent/runner" + "github.com/compliance-framework/agent/runner/proto" + "github.com/compliance-framework/plugin-jira/internal/jira" + "github.com/hashicorp/go-hclog" + goplugin "github.com/hashicorp/go-plugin" + "github.com/mitchellh/mapstructure" + "golang.org/x/oauth2/clientcredentials" +) + +type Validator interface { + Validate() error +} + +type PluginConfig struct { + BaseURL string `mapstructure:"base_url"` + AuthType string `mapstructure:"auth_type"` // "oauth2" or "token" + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + APIToken string `mapstructure:"api_token"` + UserEmail string `mapstructure:"user_email"` + ProjectKeys string `mapstructure:"project_keys"` // Comma-separated list + + // Hack to configure policy labels and generate correct evidence UUIDs + PolicyLabels string `mapstructure:"policy_labels"` +} + +// ParsedConfig holds the parsed and processed configuration +type ParsedConfig struct { + BaseURL string `mapstructure:"base_url"` + AuthType string `mapstructure:"auth_type"` + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + APIToken string `mapstructure:"api_token"` + UserEmail string `mapstructure:"user_email"` + ProjectKeys []string `mapstructure:"project_keys"` + PolicyLabels map[string]string `mapstructure:"policy_labels"` +} + +func (c *PluginConfig) Parse() (*ParsedConfig, error) { + policyLabels := map[string]string{} + if c.PolicyLabels != "" { + if err := json.Unmarshal([]byte(c.PolicyLabels), &policyLabels); err != nil { + return nil, fmt.Errorf("could not parse policy labels: %w", err) + } + } + + parsed := &ParsedConfig{ + BaseURL: c.BaseURL, + AuthType: c.AuthType, + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + APIToken: c.APIToken, + UserEmail: c.UserEmail, + PolicyLabels: policyLabels, + } + + if c.ProjectKeys != "" { + parts := strings.Split(c.ProjectKeys, ",") + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + parsed.ProjectKeys = append(parsed.ProjectKeys, s) + } + } + } + + return parsed, nil +} + +func (c *PluginConfig) Validate() error { + if c.BaseURL == "" { + return errors.New("base_url is required") + } + if c.AuthType != "oauth2" && c.AuthType != "token" { + return errors.New("auth_type must be either 'oauth2' or 'token'") + } + if c.AuthType == "oauth2" { + if c.ClientID == "" || c.ClientSecret == "" { + return errors.New("client_id and client_secret are required for oauth2") + } + } + if c.AuthType == "token" { + if c.APIToken == "" || c.UserEmail == "" { + return errors.New("api_token and user_email are required for token auth") + } + } + return nil +} + +// TrackedFileInfo holds information about a tracked file and its attestation +type JiraPlugin struct { + Logger hclog.Logger + + config *PluginConfig + parsedConfig *ParsedConfig + client *http.Client +} + +func (l *JiraPlugin) initClient(ctx context.Context) error { + if l.parsedConfig.AuthType == "oauth2" { + conf := &clientcredentials.Config{ + ClientID: l.parsedConfig.ClientID, + ClientSecret: l.parsedConfig.ClientSecret, + TokenURL: l.parsedConfig.BaseURL + "/rest/api/3/oauth2/token", // This might need to be configurable + } + l.client = conf.Client(ctx) + } else { + // Token auth + l.client = jira.NewTokenAuthClient(l.parsedConfig.UserEmail, l.parsedConfig.APIToken) + } + return nil +} + +func (l *JiraPlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureResponse, error) { + l.Logger.Info("Configuring Jira Plugin") + config := &PluginConfig{} + + if err := mapstructure.Decode(req.Config, config); err != nil { + l.Logger.Error("Error decoding config", "error", err) + return nil, err + } + + if err := config.Validate(); err != nil { + l.Logger.Error("Error validating config", "error", err) + return nil, err + } + + l.config = config + // Parse JSON-encoded configuration fields + parsed, err := config.Parse() + if err != nil { + l.Logger.Error("Error parsing config", "error", err) + return nil, err + } + l.parsedConfig = parsed + + return &proto.ConfigureResponse{}, nil +} + +func (l *JiraPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { + ctx := context.Background() + + if err := l.initClient(ctx); err != nil { + l.Logger.Error("Error initializing Jira client", "error", err) + return nil, err + } + + client := jira.NewClient(l.parsedConfig.BaseURL, l.client, l.Logger) + + jiraData, err := l.collectData(ctx, client) + if err != nil { + l.Logger.Error("Error collecting Jira data", "error", err) + return &proto.EvalResponse{ + Status: proto.ExecutionStatus_FAILURE, + }, err + } + + evidences, err := l.EvaluatePolicies(ctx, jiraData, req) + if err != nil { + l.Logger.Error("Error evaluating policies", "error", err) + return &proto.EvalResponse{ + Status: proto.ExecutionStatus_FAILURE, + }, err + } + l.Logger.Debug("calculated evidences", "evidences", evidences) + if err := apiHelper.CreateEvidence(ctx, evidences); err != nil { + l.Logger.Error("Error creating evidence", "error", err) + return &proto.EvalResponse{ + Status: proto.ExecutionStatus_FAILURE, + }, err + } + + return &proto.EvalResponse{ + Status: proto.ExecutionStatus_SUCCESS, + }, nil +} + +func (l *JiraPlugin) collectData(ctx context.Context, client *jira.Client) (*jira.JiraData, error) { + data := &jira.JiraData{} + + // 1. Fetch Global Metadata + var err error + data.Workflows, err = client.FetchWorkflows(ctx) + if err != nil { + l.Logger.Warn("failed to fetch workflows", "error", err) + } + + data.WorkflowSchemes, err = client.FetchWorkflowSchemes(ctx) + if err != nil { + l.Logger.Warn("failed to fetch workflow schemes", "error", err) + } + + data.IssueTypes, err = client.FetchIssueTypes(ctx) + if err != nil { + l.Logger.Warn("failed to fetch issue types", "error", err) + } + + data.Fields, err = client.FetchFields(ctx) + if err != nil { + l.Logger.Warn("failed to fetch fields", "error", err) + } + + data.AuditRecords, err = client.FetchAuditRecords(ctx) + if err != nil { + l.Logger.Warn("failed to fetch audit records", "error", err) + } + + data.GlobalPermissions, err = client.FetchGlobalPermissions(ctx) + if err != nil { + l.Logger.Warn("failed to fetch global permissions", "error", err) + } + + // 2. Fetch Projects + projects, err := client.FetchProjects(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch projects: %w", err) + } + + // Filter by project keys if configured + if len(l.parsedConfig.ProjectKeys) > 0 { + filtered := []jira.JiraProject{} + for _, p := range projects { + for _, key := range l.parsedConfig.ProjectKeys { + if p.Key == key { + filtered = append(filtered, p) + break + } + } + } + data.Projects = filtered + } else { + data.Projects = projects + } + + // 3. Fetch Project-specific details + for i, p := range data.Projects { + remoteLinks, err := client.FetchProjectRemoteLinks(ctx, p.Key) + if err != nil { + l.Logger.Warn("failed to fetch remote links for project", "project", p.Key, "error", err) + } else { + data.Projects[i].RemoteLinks = remoteLinks + } + } + + // 4. Search for Change Request issues + issues, err := client.SearchChangeRequests(ctx) + if err != nil { + return nil, fmt.Errorf("failed to search issues: %w", err) + } + data.Issues = issues + + // 5. Fetch Details, Changelog, Approvals, SLAs, DevInfo, and Deployments for each issue + for i, issue := range data.Issues { + changelog, err := client.FetchIssueChangelog(ctx, issue.Key) + if err != nil { + l.Logger.Warn("failed to fetch changelog for issue", "issue", issue.Key, "error", err) + } else { + data.Issues[i].Changelog = changelog + } + + approvals, err := client.FetchIssueApprovals(ctx, issue.Key) + if err != nil { + l.Logger.Warn("failed to fetch approvals for issue", "issue", issue.Key, "error", err) + } else { + data.Issues[i].Approvals = approvals + } + + slas, err := client.FetchIssueSLAs(ctx, issue.Key) + if err != nil { + l.Logger.Warn("failed to fetch SLAs for issue", "issue", issue.Key, "error", err) + } else { + data.Issues[i].SLAs = slas + } + + devInfo, err := client.FetchIssueDevInfo(ctx, issue.Key) + if err != nil { + l.Logger.Warn("failed to fetch dev info for issue", "issue", issue.Key, "error", err) + } else { + data.Issues[i].DevInfo = devInfo + } + + deployments, err := client.FetchIssueDeployments(ctx, issue.Key) + if err != nil { + l.Logger.Warn("failed to fetch deployments for issue", "issue", issue.Key, "error", err) + } else { + data.Issues[i].Deployments = deployments + } + } + + return data, nil +} + +func (l *JiraPlugin) EvaluatePolicies(ctx context.Context, data *jira.JiraData, req *proto.EvalRequest) ([]*proto.Evidence, error) { + var accumulatedErrors error + + activities := make([]*proto.Activity, 0) + evidences := make([]*proto.Evidence, 0) + activities = append(activities, &proto.Activity{ + Title: "Collect Jira Compliance Data", + Steps: []*proto.Step{ + { + Title: "Authenticate with Jira", + Description: "Authenticate with Jira Platform and Service Management APIs.", + }, + { + Title: "Fetch Jira Projects and Workflows", + Description: "Retrieve project metadata, workflow configurations, and issue types.", + }, + { + Title: "Search and Analyze Change Requests", + Description: "Search for Change Request issues and analyze their transitions, approvals, and linked development data.", + }, + }, + }) + + actors := []*proto.OriginActor{ + { + Title: "The Continuous Compliance Framework", + Type: "assessment-platform", + Links: []*proto.Link{ + { + Href: "https://compliance-framework.github.io/docs/", + Rel: policyManager.Pointer("reference"), + Text: policyManager.Pointer("The Continuous Compliance Framework"), + }, + }, + Props: nil, + }, + { + Title: "Continuous Compliance Framework - Jira Plugin", + Type: "tool", + Links: []*proto.Link{ + { + Href: "https://github.com/compliance-framework/plugin-jira", + Rel: policyManager.Pointer("reference"), + Text: policyManager.Pointer("The Continuous Compliance Framework Jira Plugin"), + }, + }, + Props: nil, + }, + } + + components := []*proto.Component{ + { + Identifier: "jira-platform", + Type: "service", + Title: "Jira Platform", + Description: "Atlassian Jira Platform providing project and workflow management.", + Purpose: "To serve as the system of record for change management and workflows.", + Links: []*proto.Link{ + { + Href: l.config.BaseURL, + Rel: policyManager.Pointer("component"), + Text: policyManager.Pointer("Jira Instance"), + }, + }, + }, + } + + inventory := []*proto.InventoryItem{ + { + Identifier: "jira-data-collection", + Type: "jira-compliance-data", + Title: "Jira Compliance Data", + Props: []*proto.Property{}, + Links: []*proto.Link{ + { + Href: l.config.BaseURL, + Text: policyManager.Pointer("Jira Base URL"), + }, + }, + ImplementedComponents: []*proto.InventoryItemImplementedComponent{ + { + Identifier: "jira-platform", + }, + }, + }, + } + + subjects := []*proto.Subject{ + { + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + Identifier: "jira-platform", + }, + } + + labels := map[string]string{} + maps.Copy(labels, l.parsedConfig.PolicyLabels) + labels["provider"] = "jira" + + for _, policyPath := range req.GetPolicyPaths() { + processor := policyManager.NewPolicyProcessor( + l.Logger, + labels, + subjects, + components, + inventory, + actors, + activities, + ) + evidence, err := processor.GenerateResults(ctx, policyPath, data) + evidences = slices.Concat(evidences, evidence) + if err != nil { + accumulatedErrors = errors.Join(accumulatedErrors, err) + } + } + + return evidences, accumulatedErrors +} + +func main() { + logger := hclog.New(&hclog.LoggerOptions{ + Level: hclog.Trace, + JSONFormat: true, + }) + + jiraPlugin := &JiraPlugin{ + Logger: logger, + } + + logger.Info("Starting Jira Plugin") + goplugin.Serve(&goplugin.ServeConfig{ + HandshakeConfig: runner.HandshakeConfig, + Plugins: map[string]goplugin.Plugin{ + "runner": &runner.RunnerGRPCPlugin{ + Impl: jiraPlugin, + }, + }, + GRPCServer: goplugin.DefaultGRPCServer, + }) +} From 62aaf8446e8e5a0eb3d0b7176d5013c66d747510 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 7 Jan 2026 06:18:08 -0300 Subject: [PATCH 2/6] fix: implementation details and adjustments against real system Signed-off-by: Gustavo Carvalho --- .gitignore | 4 +- go.mod | 5 + go.sum | 17 +++ internal/jira/client.go | 211 ++++++++++++++++++++++++++++++------- internal/jira/types.go | 223 ++++++++++++++++++++++++++++++++++------ main.go | 35 +++---- 6 files changed, 407 insertions(+), 88 deletions(-) diff --git a/.gitignore b/.gitignore index 0063ff9..1e87e1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ +bin/ +data/ .idea/ dist/ .DS_Store plugin .env -.envrc \ No newline at end of file +.envrc diff --git a/go.mod b/go.mod index 6df63ac..45fd160 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.4 require ( github.com/compliance-framework/agent v0.2.1 + github.com/ctreminiom/go-atlassian v1.6.1 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 github.com/mitchellh/mapstructure v1.5.0 @@ -11,6 +12,7 @@ require ( ) require ( + dario.cat/mergo v1.0.1 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect github.com/agnivade/levenshtein v1.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -39,6 +41,9 @@ require ( github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/tchap/go-patricia/v2 v2.3.1 // indirect + github.com/tidwall/gjson v1.17.1 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect diff --git a/go.sum b/go.sum index 2605985..10752b6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= @@ -40,6 +41,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/ctreminiom/go-atlassian v1.6.1 h1:thH/oaWlvWLN5a4AcgQ30yPmnn0mQaTiqsq1M6bA9BY= +github.com/ctreminiom/go-atlassian v1.6.1/go.mod h1:dd5M0O8Co3bALyLQqWxPXoBfQNr6FFlpzUrA19IpLEo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -246,8 +249,16 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -264,6 +275,12 @@ github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc= github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= diff --git a/internal/jira/client.go b/internal/jira/client.go index 4de6cd5..b16207e 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strings" "github.com/hashicorp/go-hclog" ) @@ -16,14 +17,105 @@ type Client struct { BaseURL string HTTPClient *http.Client Logger hclog.Logger + CloudID string } -func NewClient(baseURL string, httpClient *http.Client, logger hclog.Logger) *Client { +func NewClient(baseURL string, httpClient *http.Client, logger hclog.Logger) (*Client, error) { + // First, fetch the Cloud ID + cloudID, err := fetchCloudID(baseURL) + if err != nil { + logger.Error("Failed to fetch Cloud ID", "error", err) + return nil, fmt.Errorf("failed to fetch Cloud ID: %w", err) + } + + logger.Debug("Got Cloud ID", "cloudID", cloudID) + return &Client{ BaseURL: baseURL, HTTPClient: httpClient, Logger: logger, + CloudID: cloudID, + }, nil +} + +func fetchCloudID(baseURL string) (string, error) { + // Make request to get tenant info + resp, err := http.Get(baseURL + "/_edge/tenant_info") + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get tenant info: %d", resp.StatusCode) } + + var result struct { + CloudID string `json:"cloudId"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode tenant info: %w", err) + } + + if result.CloudID == "" { + return "", fmt.Errorf("no Cloud ID found in tenant info") + } + + return result.CloudID, nil +} + +type oauth2Transport struct { + base http.RoundTripper + logger hclog.Logger + tokenURL string + clientID string + secret string + resource string +} + +func (t *oauth2Transport) RoundTrip(req *http.Request) (*http.Response, error) { + // Get OAuth2 token with required scopes + tokenReq, err := http.NewRequest("POST", t.tokenURL, nil) + if err != nil { + return nil, err + } + tokenReq.SetBasicAuth(t.clientID, t.secret) + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Form encode the parameters + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("scope", "read:jira-user read:jira-work read:workflow:jira read:permission:jira read:workflow-scheme:jira read:audit-log:jira read:avatar:jira read:group:jira read:issue-type:jira read:project-category:jira read:project:jira read:user:jira read:application-role:jira") + tokenReq.Body = io.NopCloser(strings.NewReader(data.Encode())) + + resp, err := t.base.RoundTrip(tokenReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to get token: %d, body: %s", resp.StatusCode, string(body)) + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + Scope string `json:"scope"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + } + body, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to decode token response: %w, body: %s", err, string(body)) + } + t.logger.Debug("Got OAuth2 token", "scope", tokenResp.Scope, "expiresIn", tokenResp.ExpiresIn) + + // Clone the request and set the bearer token + newReq := req.Clone(req.Context()) + newReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken) + t.logger.Debug("Making request with Bearer token", "url", newReq.URL.String(), "auth", "Bearer "+tokenResp.AccessToken[:10]+"...") + return t.base.RoundTrip(newReq) } type tokenAuthTransport struct { @@ -47,15 +139,37 @@ func NewTokenAuthClient(email, token string) *http.Client { } } -func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { - rel, err := url.Parse(path) +func (c *Client) logSuccessOrWarn(request string, resp *http.Response, err error) { if err != nil { - return nil, err + c.Logger.Warn("<<>> Fail with the request", "request", request, "error", err) + return } - u, _ := url.Parse(c.BaseURL) - u = u.ResolveReference(rel) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + c.Logger.Warn("<<>> Request status code != 200", "request", request, "status code", resp.StatusCode, "body", string(body)) + return + } + c.Logger.Info("<<>> Request Successful! ", "request", request) +} +func NewOAuth2Client(clientID, clientSecret, baseURL string, logger hclog.Logger) *http.Client { + return &http.Client{ + Transport: &oauth2Transport{ + base: http.DefaultTransport, + tokenURL: "https://auth.atlassian.com/oauth/token", + clientID: clientID, + secret: clientSecret, + resource: "api.atlassian.com", + logger: logger, + }, + } +} - req, err := http.NewRequestWithContext(ctx, method, u.String(), body) +func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + // Use the correct API endpoint format: https://api.atlassian.com/ex/jira//rest/api/3/... + apiURL := fmt.Sprintf("https://api.atlassian.com/ex/jira/%s%s", c.CloudID, path) + c.Logger.Debug("Requesting", "method", method, "url", apiURL) + + req, err := http.NewRequestWithContext(ctx, method, apiURL, body) if err != nil { return nil, err } @@ -68,37 +182,43 @@ func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (* } func (c *Client) FetchProjects(ctx context.Context) ([]JiraProject, error) { - resp, err := c.do(ctx, "GET", "/rest/api/3/project", nil) + resp, err := c.do(ctx, "GET", "/rest/api/3/project/search", nil) if err != nil { return nil, err } defer resp.Body.Close() - + c.logSuccessOrWarn("FetchProjects", resp, err) if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) } - - var projects []JiraProject - if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil { + body := resp.Body + var searchResp JiraProjectSearchResponse + if err := json.NewDecoder(body).Decode(&searchResp); err != nil { return nil, err } - return projects, nil + c.Logger.Debug("Fetched projects", "status", resp.StatusCode, "total", searchResp.Total, "projects", len(searchResp.Values)) + return searchResp.Values, nil } func (c *Client) FetchWorkflows(ctx context.Context) ([]JiraWorkflow, error) { - resp, err := c.do(ctx, "GET", "/rest/api/3/workflow/search", nil) + resp, err := c.do(ctx, "GET", "/rest/api/3/workflows/search", nil) if err != nil { return nil, err } defer resp.Body.Close() - - var result struct { - Values []JiraWorkflow `json:"values"` + c.logSuccessOrWarn("FetchWorkflows", resp, err) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + + var searchResp JiraWorkflowSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { return nil, err } - return result.Values, nil + c.Logger.Debug("Fetched workflows", "status", resp.StatusCode, "total", searchResp.Total, "workflows", len(searchResp.Values)) + return searchResp.Values, nil } func (c *Client) FetchWorkflowSchemes(ctx context.Context) ([]JiraWorkflowScheme, error) { @@ -107,14 +227,18 @@ func (c *Client) FetchWorkflowSchemes(ctx context.Context) ([]JiraWorkflowScheme return nil, err } defer resp.Body.Close() - - var result struct { - Values []JiraWorkflowScheme `json:"values"` + c.logSuccessOrWarn("FetchWorkflowSchemes", resp, err) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + + var searchResp JiraWorkflowSchemeSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { return nil, err } - return result.Values, nil + c.Logger.Debug("Fetched workflow schemes", "status", resp.StatusCode, "total", searchResp.Total, "schemes", len(searchResp.Values)) + return searchResp.Values, nil } func (c *Client) FetchIssueTypes(ctx context.Context) ([]JiraIssueType, error) { @@ -123,11 +247,12 @@ func (c *Client) FetchIssueTypes(ctx context.Context) ([]JiraIssueType, error) { return nil, err } defer resp.Body.Close() - + c.logSuccessOrWarn("FetchIssueTypes", resp, err) var types []JiraIssueType if err := json.NewDecoder(resp.Body).Decode(&types); err != nil { return nil, err } + c.Logger.Debug("<<>> Got Issue Types") return types, nil } @@ -137,7 +262,7 @@ func (c *Client) FetchFields(ctx context.Context) ([]JiraField, error) { return nil, err } defer resp.Body.Close() - + c.logSuccessOrWarn("FetchFields", resp, err) var fields []JiraField if err := json.NewDecoder(resp.Body).Decode(&fields); err != nil { return nil, err @@ -151,13 +276,27 @@ func (c *Client) FetchAuditRecords(ctx context.Context) ([]JiraAuditRecord, erro return nil, err } defer resp.Body.Close() + c.logSuccessOrWarn("FetchAuditRecords", resp, err) + + // Read the raw response for debugging + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + c.Logger.Debug("Raw audit records response", "body", string(body)) var result struct { Records []JiraAuditRecord `json:"records"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + if err := json.Unmarshal(body, &result); err != nil { return nil, err } + + // Log the parsed records for debugging + for i, record := range result.Records { + c.Logger.Debug("Parsed audit record", "index", i, "id", record.ID, "summary", record.Summary, "created", record.Created.ToTime(), "authorName", record.AuthorName) + } + return result.Records, nil } @@ -167,7 +306,7 @@ func (c *Client) FetchGlobalPermissions(ctx context.Context) ([]JiraPermission, return nil, err } defer resp.Body.Close() - + c.logSuccessOrWarn("FetchGlobalPermissions", resp, err) var result struct { Permissions map[string]JiraPermission `json:"permissions"` } @@ -188,7 +327,7 @@ func (c *Client) FetchIssueSLAs(ctx context.Context, issueKey string) ([]JiraSLA return nil, err } defer resp.Body.Close() - + c.logSuccessOrWarn("FetchIssueSLAs", resp, err) var result struct { Values []JiraSLA `json:"values"` } @@ -204,7 +343,7 @@ func (c *Client) FetchIssueDevInfo(ctx context.Context, issueKey string) (*JiraD return nil, err } defer resp.Body.Close() - + c.logSuccessOrWarn("FetchIssueDevInfo", resp, err) var info JiraDevInfo if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return nil, err @@ -218,7 +357,7 @@ func (c *Client) FetchIssueDeployments(ctx context.Context, issueKey string) ([] return nil, err } defer resp.Body.Close() - + c.logSuccessOrWarn("FetchIssueDeployments", resp, err) var result struct { Deployments []JiraDeployment `json:"deployments"` } @@ -234,12 +373,12 @@ func (c *Client) SearchChangeRequests(ctx context.Context) ([]JiraIssue, error) query.Set("jql", jql) query.Set("expand", "changelog") - resp, err := c.do(ctx, "GET", "/rest/api/3/search?"+query.Encode(), nil) + resp, err := c.do(ctx, "GET", "/rest/api/3/search/jql?"+query.Encode(), nil) if err != nil { return nil, err } defer resp.Body.Close() - + c.logSuccessOrWarn("SearchChangeRequests", resp, err) var result struct { Issues []JiraIssue `json:"issues"` } @@ -255,7 +394,7 @@ func (c *Client) FetchIssueChangelog(ctx context.Context, issueKey string) ([]Ji return nil, err } defer resp.Body.Close() - + c.logSuccessOrWarn("FetchIssueChangelog", resp, err) var result struct { Values []JiraChangelogEntry `json:"values"` } @@ -271,7 +410,7 @@ func (c *Client) FetchIssueApprovals(ctx context.Context, issueKey string) ([]Ji return nil, err } defer resp.Body.Close() - + c.logSuccessOrWarn("FetchIssueApprovals", resp, err) var result struct { Values []JiraApproval `json:"values"` } diff --git a/internal/jira/types.go b/internal/jira/types.go index e31335e..e8a4a11 100644 --- a/internal/jira/types.go +++ b/internal/jira/types.go @@ -1,6 +1,45 @@ package jira -import "time" +import ( + "encoding/json" + "strings" + "time" +) + +// JiraAuditTime handles the custom timestamp format used in JIRA audit records +type JiraAuditTime time.Time + +func (jat *JiraAuditTime) UnmarshalJSON(data []byte) error { + str := strings.Trim(string(data), "\"") + if str == "" || str == "null" { + return nil + } + + // JIRA audit format: 2026-01-06T16:11:45.660+0000 + // Convert to RFC3339 format: 2026-01-06T16:11:45.660+00:00 + if len(str) >= 5 && str[len(str)-5] == '+' { + str = str[:len(str)-2] + ":" + str[len(str)-2:] + } + + t, err := time.Parse(time.RFC3339, str) + if err != nil { + return err + } + *jat = JiraAuditTime(t) + return nil +} + +func (jat JiraAuditTime) MarshalJSON() ([]byte, error) { + if time.Time(jat).IsZero() { + return []byte("null"), nil + } + return json.Marshal(time.Time(jat).Format(time.RFC3339)) +} + +// ToTime converts JiraAuditTime back to time.Time +func (jat JiraAuditTime) ToTime() time.Time { + return time.Time(jat) +} type JiraData struct { Projects []JiraProject `json:"projects"` @@ -14,50 +53,164 @@ type JiraData struct { } type JiraProject struct { - ID string `json:"id"` - Key string `json:"key"` - Name string `json:"name"` - Category *JiraProjectCategory `json:"projectCategory,omitempty"` - Components []JiraComponent `json:"components,omitempty"` - WorkflowScheme *JiraWorkflowScheme `json:"workflow_scheme,omitempty"` - Permissions *JiraPermissionScheme `json:"permission_scheme,omitempty"` - RemoteLinks []JiraRemoteLink `json:"remote_links"` + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Self string `json:"self,omitempty"` + Description string `json:"description,omitempty"` + AvatarUrls map[string]string `json:"avatarUrls,omitempty"` + ProjectCategory *JiraProjectCategory `json:"projectCategory,omitempty"` + Insight *JiraProjectInsight `json:"insight,omitempty"` + Simplified bool `json:"simplified,omitempty"` + Style string `json:"style,omitempty"` + Lead *JiraUser `json:"lead,omitempty"` + Components []JiraComponent `json:"components,omitempty"` + IssueTypes []JiraIssueType `json:"issueTypes,omitempty"` } type JiraProjectCategory struct { ID string `json:"id"` Name string `json:"name"` - Description string `json:"description"` + Description string `json:"description,omitempty"` + Self string `json:"self,omitempty"` } type JiraComponent struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Self string `json:"self,omitempty"` + Assignee *JiraUser `json:"assignee,omitempty"` + AssigneeType string `json:"assigneeType,omitempty"` + Project string `json:"project,omitempty"` + ProjectID int64 `json:"projectId,omitempty"` +} + +type JiraProjectInsight struct { + LastIssueUpdateTime string `json:"lastIssueUpdateTime,omitempty"` + TotalIssueCount int64 `json:"totalIssueCount,omitempty"` +} + +type JiraUser struct { + AccountID string `json:"accountId,omitempty"` + AccountType string `json:"accountType,omitempty"` + Active bool `json:"active,omitempty"` + AvatarUrls map[string]string `json:"avatarUrls,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Email string `json:"emailAddress,omitempty"` + Key string `json:"key,omitempty"` + Name string `json:"name,omitempty"` + Self string `json:"self,omitempty"` +} + +type JiraProjectSearchResponse struct { + IsLast bool `json:"isLast"` + MaxResults int `json:"maxResults"` + NextPage string `json:"nextPage,omitempty"` + Self string `json:"self"` + StartAt int `json:"startAt"` + Total int `json:"total"` + Values []JiraProject `json:"values"` } type JiraWorkflow struct { - Name string `json:"name"` - Description string `json:"description"` - Statuses []JiraStatus `json:"statuses"` - Transitions []JiraTransition `json:"transitions"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Scope JiraScope `json:"scope"` + IsEditable bool `json:"isEditable,omitempty"` + StartPointLayout JiraLayout `json:"startPointLayout,omitempty"` + Statuses []JiraWorkflowStatus `json:"statuses,omitempty"` + Transitions []JiraWorkflowTransition `json:"transitions,omitempty"` + Version JiraWorkflowVersion `json:"version,omitempty"` +} + +type JiraWorkflowSearchResponse struct { + IsLast bool `json:"isLast"` + MaxResults int `json:"maxResults"` + NextPage string `json:"nextPage,omitempty"` + Self string `json:"self"` + StartAt int `json:"startAt"` + Total int `json:"total"` + Values []JiraWorkflow `json:"values"` + Statuses []JiraStatus `json:"statuses,omitempty"` +} + +type JiraScope struct { + Type string `json:"type"` + Project *JiraProject `json:"project,omitempty"` +} + +type JiraLayout struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type JiraWorkflowStatus struct { + Deprecated bool `json:"deprecated,omitempty"` + Layout JiraLayout `json:"layout,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` + StatusReference string `json:"statusReference"` +} + +type JiraWorkflowTransition struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ToStatusReference string `json:"toStatusReference,omitempty"` + Type string `json:"type"` + Actions []interface{} `json:"actions,omitempty"` + Links []JiraTransitionLink `json:"links,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` + Triggers []interface{} `json:"triggers,omitempty"` + Validators []interface{} `json:"validators,omitempty"` +} + +type JiraTransitionLink struct { + FromPort int `json:"fromPort,omitempty"` + FromStatusReference string `json:"fromStatusReference,omitempty"` + ToPort int `json:"toPort,omitempty"` +} + +type JiraWorkflowVersion struct { + ID string `json:"id"` + VersionNumber int `json:"versionNumber"` } type JiraStatus struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Scope JiraScope `json:"scope"` + StatusCategory string `json:"statusCategory,omitempty"` + StatusReference string `json:"statusReference,omitempty"` } +// JiraTransition kept for backward compatibility type JiraTransition struct { ID string `json:"id"` Name string `json:"name"` - To string `json:"to"` + To string `json:"to,omitempty"` +} + +type JiraWorkflowSchemeSearchResponse struct { + IsLast bool `json:"isLast"` + MaxResults int `json:"maxResults"` + NextPage string `json:"nextPage,omitempty"` + Self string `json:"self"` + StartAt int `json:"startAt"` + Total int `json:"total"` + Values []JiraWorkflowScheme `json:"values"` } type JiraWorkflowScheme struct { - ID int64 `json:"id"` - Name string `json:"name"` - Mappings map[string]string `json:"issueTypeMappings"` // IssueType -> Workflow Name - Default string `json:"defaultWorkflow"` + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DefaultWorkflow string `json:"defaultWorkflow,omitempty"` + IssueTypeMappings map[string]string `json:"issueTypeMappings,omitempty"` + Draft bool `json:"draft,omitempty"` + Self string `json:"self,omitempty"` } type JiraIssueType struct { @@ -66,9 +219,17 @@ type JiraIssueType struct { } type JiraField struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"schema,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Schema JiraSchema `json:"schema,omitempty"` +} + +type JiraSchema struct { + Type string `json:"type,omitempty"` + System string `json:"system,omitempty"` + Items string `json:"items,omitempty"` + Custom string `json:"custom,omitempty"` + CustomID int64 `json:"customId,omitempty"` } type JiraIssue struct { @@ -133,11 +294,11 @@ type JiraRemoteLink struct { } type JiraAuditRecord struct { - ID int64 `json:"id"` - Summary string `json:"summary"` - Created time.Time `json:"created"` - AuthorName string `json:"authorName"` - Category string `json:"category"` + ID int64 `json:"id"` + Summary string `json:"summary"` + Created JiraAuditTime `json:"created"` + AuthorName string `json:"authorName"` + Category string `json:"category"` } type JiraPermission struct { diff --git a/main.go b/main.go index 8a10a76..a390040 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "fmt" "maps" "net/http" + "os" "slices" "strings" @@ -17,7 +18,6 @@ import ( "github.com/hashicorp/go-hclog" goplugin "github.com/hashicorp/go-plugin" "github.com/mitchellh/mapstructure" - "golang.org/x/oauth2/clientcredentials" ) type Validator interface { @@ -110,13 +110,10 @@ type JiraPlugin struct { func (l *JiraPlugin) initClient(ctx context.Context) error { if l.parsedConfig.AuthType == "oauth2" { - conf := &clientcredentials.Config{ - ClientID: l.parsedConfig.ClientID, - ClientSecret: l.parsedConfig.ClientSecret, - TokenURL: l.parsedConfig.BaseURL + "/rest/api/3/oauth2/token", // This might need to be configurable - } - l.client = conf.Client(ctx) + l.Logger.Debug("Initializing Jira client with OAuth2") + l.client = jira.NewOAuth2Client(l.parsedConfig.ClientID, l.parsedConfig.ClientSecret, l.parsedConfig.BaseURL, l.Logger) } else { + l.Logger.Debug("Initializing Jira client with Token") // Token auth l.client = jira.NewTokenAuthClient(l.parsedConfig.UserEmail, l.parsedConfig.APIToken) } @@ -131,6 +128,7 @@ func (l *JiraPlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureRes l.Logger.Error("Error decoding config", "error", err) return nil, err } + l.Logger.Debug("configuration decoded", "config", config) if err := config.Validate(); err != nil { l.Logger.Error("Error validating config", "error", err) @@ -157,9 +155,15 @@ func (l *JiraPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (* return nil, err } - client := jira.NewClient(l.parsedConfig.BaseURL, l.client, l.Logger) + client, err := jira.NewClient(l.parsedConfig.BaseURL, l.client, l.Logger) + if err != nil { + l.Logger.Error("Error creating JIRA client", "error", err) + return nil, err + } jiraData, err := l.collectData(ctx, client) + indentedJSON, _ := json.MarshalIndent(jiraData, "", " ") + os.WriteFile("/data/jira_data.json", indentedJSON, 0o644) if err != nil { l.Logger.Error("Error collecting Jira data", "error", err) return &proto.EvalResponse{ @@ -181,7 +185,6 @@ func (l *JiraPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (* Status: proto.ExecutionStatus_FAILURE, }, err } - return &proto.EvalResponse{ Status: proto.ExecutionStatus_SUCCESS, }, nil @@ -244,15 +247,8 @@ func (l *JiraPlugin) collectData(ctx context.Context, client *jira.Client) (*jir data.Projects = projects } - // 3. Fetch Project-specific details - for i, p := range data.Projects { - remoteLinks, err := client.FetchProjectRemoteLinks(ctx, p.Key) - if err != nil { - l.Logger.Warn("failed to fetch remote links for project", "project", p.Key, "error", err) - } else { - data.Projects[i].RemoteLinks = remoteLinks - } - } + // 3. Projects are already fetched with all available details + l.Logger.Debug("Project details fetched", "count", len(data.Projects)) // 4. Search for Change Request issues issues, err := client.SearchChangeRequests(ctx) @@ -260,9 +256,9 @@ func (l *JiraPlugin) collectData(ctx context.Context, client *jira.Client) (*jir return nil, fmt.Errorf("failed to search issues: %w", err) } data.Issues = issues - // 5. Fetch Details, Changelog, Approvals, SLAs, DevInfo, and Deployments for each issue for i, issue := range data.Issues { + l.Logger.Info("Fetching details for issue", "issue", issue.Key) changelog, err := client.FetchIssueChangelog(ctx, issue.Key) if err != nil { l.Logger.Warn("failed to fetch changelog for issue", "issue", issue.Key, "error", err) @@ -298,7 +294,6 @@ func (l *JiraPlugin) collectData(ctx context.Context, client *jira.Client) (*jir data.Issues[i].Deployments = deployments } } - return data, nil } From d43198a9b010308280eec8f0146a2ecb84717d38 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 7 Jan 2026 07:00:38 -0300 Subject: [PATCH 3/6] feat: more api calls, more fixes to make it work Signed-off-by: Gustavo Carvalho --- internal/jira/client.go | 68 +++++++++++++--- internal/jira/types.go | 172 +++++++++++++++++++++++++++++++++------- main.go | 26 ++++++ 3 files changed, 227 insertions(+), 39 deletions(-) diff --git a/internal/jira/client.go b/internal/jira/client.go index b16207e..8ad6409 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -283,7 +283,7 @@ func (c *Client) FetchAuditRecords(ctx context.Context) ([]JiraAuditRecord, erro if err != nil { return nil, err } - c.Logger.Debug("Raw audit records response", "body", string(body)) + c.Logger.Info("Raw audit records response", "body", string(body)) var result struct { Records []JiraAuditRecord `json:"records"` @@ -292,11 +292,6 @@ func (c *Client) FetchAuditRecords(ctx context.Context) ([]JiraAuditRecord, erro return nil, err } - // Log the parsed records for debugging - for i, record := range result.Records { - c.Logger.Debug("Parsed audit record", "index", i, "id", record.ID, "summary", record.Summary, "created", record.Created.ToTime(), "authorName", record.AuthorName) - } - return result.Records, nil } @@ -307,9 +302,8 @@ func (c *Client) FetchGlobalPermissions(ctx context.Context) ([]JiraPermission, } defer resp.Body.Close() c.logSuccessOrWarn("FetchGlobalPermissions", resp, err) - var result struct { - Permissions map[string]JiraPermission `json:"permissions"` - } + + var result JiraPermissionsResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } @@ -424,3 +418,59 @@ func (c *Client) FetchProjectRemoteLinks(ctx context.Context, projectKey string) // Placeholder as requested in original code return nil, nil } + +func (c *Client) GetAllStatuses(ctx context.Context) ([]JiraStatus, error) { + resp, err := c.do(ctx, "GET", "/rest/api/3/statuses/search", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + c.logSuccessOrWarn("GetAllStatuses", resp, err) + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + var searchResp JiraStatusSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return nil, err + } + + c.Logger.Debug("Fetched all statuses", "status", resp.StatusCode, "total", searchResp.Total, "statuses", len(searchResp.Values)) + return searchResp.Values, nil +} + +func (c *Client) GetWorkflowSchemeProjectAssociations(ctx context.Context, projectIds []int64) ([]JiraWorkflowSchemeProjectAssociation, error) { + // Build query parameters for project IDs + if len(projectIds) == 0 { + return nil, fmt.Errorf("at least one project ID is required") + } + + // Convert project IDs to query parameters + params := url.Values{} + for _, id := range projectIds { + params.Add("projectId", fmt.Sprintf("%d", id)) + } + + url := fmt.Sprintf("/rest/api/3/workflowscheme/project?%s", params.Encode()) + resp, err := c.do(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + c.logSuccessOrWarn("GetWorkflowSchemeProjectAssociations", resp, err) + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + var result JiraWorkflowSchemeProjectAssociationsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + c.Logger.Debug("Fetched workflow scheme project associations", "status", resp.StatusCode, "associations", len(result.Values)) + return result.Values, nil +} diff --git a/internal/jira/types.go b/internal/jira/types.go index e8a4a11..bf1b886 100644 --- a/internal/jira/types.go +++ b/internal/jira/types.go @@ -42,14 +42,16 @@ func (jat JiraAuditTime) ToTime() time.Time { } type JiraData struct { - Projects []JiraProject `json:"projects"` - Issues []JiraIssue `json:"issues"` - Workflows []JiraWorkflow `json:"workflows"` - WorkflowSchemes []JiraWorkflowScheme `json:"workflow_schemes"` - IssueTypes []JiraIssueType `json:"issue_types"` - Fields []JiraField `json:"fields"` - AuditRecords []JiraAuditRecord `json:"audit_records"` - GlobalPermissions []JiraPermission `json:"global_permissions"` + Projects []JiraProject `json:"projects"` + Issues []JiraIssue `json:"issues"` + Workflows []JiraWorkflow `json:"workflows"` + WorkflowSchemes []JiraWorkflowScheme `json:"workflow_schemes"` + WorkflowSchemeProjectAssociations []JiraWorkflowSchemeProjectAssociation `json:"workflow_scheme_project_associations"` + IssueTypes []JiraIssueType `json:"issue_types"` + Fields []JiraField `json:"fields"` + AuditRecords []JiraAuditRecord `json:"audit_records"` + GlobalPermissions []JiraPermission `json:"global_permissions"` + Statuses []JiraStatus `json:"statuses"` } type JiraProject struct { @@ -123,6 +125,12 @@ type JiraWorkflow struct { Statuses []JiraWorkflowStatus `json:"statuses,omitempty"` Transitions []JiraWorkflowTransition `json:"transitions,omitempty"` Version JiraWorkflowVersion `json:"version,omitempty"` + // Additional fields from official API - use strings for timestamps + Created string `json:"created,omitempty"` + Modified string `json:"modified,omitempty"` + DefaultStatus string `json:"defaultStatus,omitempty"` + Published bool `json:"published,omitempty"` + WorkflowOwner string `json:"workflowOwner,omitempty"` } type JiraWorkflowSearchResponse struct { @@ -147,23 +155,39 @@ type JiraLayout struct { } type JiraWorkflowStatus struct { - Deprecated bool `json:"deprecated,omitempty"` - Layout JiraLayout `json:"layout,omitempty"` - Properties map[string]interface{} `json:"properties,omitempty"` - StatusReference string `json:"statusReference"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Scope JiraScope `json:"scope"` + StatusCategory string `json:"statusCategory,omitempty"` + StatusReference string `json:"statusReference"` + Deprecated bool `json:"deprecated,omitempty"` + Layout JiraLayout `json:"layout,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` + ApprovalConfiguration *JiraApprovalConfiguration `json:"approvalConfiguration,omitempty"` +} + +type JiraApprovalConfiguration struct { + Approvals []JiraApprovalStatus `json:"approvals,omitempty"` +} + +type JiraApprovalStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` } type JiraWorkflowTransition struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - ToStatusReference string `json:"toStatusReference,omitempty"` - Type string `json:"type"` - Actions []interface{} `json:"actions,omitempty"` - Links []JiraTransitionLink `json:"links,omitempty"` - Properties map[string]interface{} `json:"properties,omitempty"` - Triggers []interface{} `json:"triggers,omitempty"` - Validators []interface{} `json:"validators,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ToStatusReference string `json:"toStatusReference,omitempty"` + Type string `json:"type"` + Actions []JiraWorkflowRule `json:"actions,omitempty"` + Links []JiraTransitionLink `json:"links,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` + Triggers []JiraWorkflowTrigger `json:"triggers,omitempty"` + Validators []JiraWorkflowValidator `json:"validators,omitempty"` } type JiraTransitionLink struct { @@ -177,6 +201,38 @@ type JiraWorkflowVersion struct { VersionNumber int `json:"versionNumber"` } +type JiraWorkflowTimestamp struct { + Timestamp int64 `json:"timestamp,omitempty"` + Format string `json:"format,omitempty"` +} + +type JiraWorkflowRule struct { + RuleKey string `json:"ruleKey"` + RuleType string `json:"ruleType"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` +} + +type JiraWorkflowTrigger struct { + RuleKey string `json:"ruleKey"` + RuleType string `json:"ruleType"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` +} + +type JiraWorkflowValidator struct { + RuleKey string `json:"ruleKey"` + RuleType string `json:"ruleType"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Expression string `json:"expression,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` +} + type JiraStatus struct { ID string `json:"id"` Name string `json:"name"` @@ -184,6 +240,28 @@ type JiraStatus struct { Scope JiraScope `json:"scope"` StatusCategory string `json:"statusCategory,omitempty"` StatusReference string `json:"statusReference,omitempty"` + // Additional fields from API + IconUrl string `json:"iconUrl,omitempty"` + Self string `json:"self,omitempty"` +} + +type JiraStatusSearchResponse struct { + IsLast bool `json:"isLast"` + MaxResults int `json:"maxResults"` + NextPage string `json:"nextPage,omitempty"` + Self string `json:"self"` + StartAt int `json:"startAt"` + Total int `json:"total"` + Values []JiraStatus `json:"values"` +} + +type JiraWorkflowSchemeProjectAssociation struct { + ProjectIds []string `json:"projectIds"` + WorkflowScheme JiraWorkflowScheme `json:"workflowScheme"` +} + +type JiraWorkflowSchemeProjectAssociationsResponse struct { + Values []JiraWorkflowSchemeProjectAssociation `json:"values"` } // JiraTransition kept for backward compatibility @@ -294,17 +372,51 @@ type JiraRemoteLink struct { } type JiraAuditRecord struct { - ID int64 `json:"id"` - Summary string `json:"summary"` - Created JiraAuditTime `json:"created"` - AuthorName string `json:"authorName"` - Category string `json:"category"` + ID int64 `json:"id"` + Summary string `json:"summary"` + Created JiraAuditTime `json:"created"` + AuthorAccountId string `json:"authorAccountId,omitempty"` + AuthorKey string `json:"authorKey,omitempty"` + Category string `json:"category"` + Description string `json:"description,omitempty"` + EventSource string `json:"eventSource,omitempty"` + RemoteAddress string `json:"remoteAddress,omitempty"` + AssociatedItems []JiraAuditAssociatedItem `json:"associatedItems,omitempty"` + ChangedValues []JiraAuditChangedValue `json:"changedValues,omitempty"` + ObjectItem *JiraAuditObjectItem `json:"objectItem,omitempty"` +} + +type JiraAuditAssociatedItem struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + ParentID string `json:"parentId,omitempty"` + ParentName string `json:"parentName,omitempty"` + TypeName string `json:"typeName,omitempty"` +} + +type JiraAuditChangedValue struct { + ChangedFrom string `json:"changedFrom,omitempty"` + ChangedTo string `json:"changedTo,omitempty"` + FieldName string `json:"fieldName,omitempty"` +} + +type JiraAuditObjectItem struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + ParentID string `json:"parentId,omitempty"` + ParentName string `json:"parentName,omitempty"` + TypeName string `json:"typeName,omitempty"` } type JiraPermission struct { - ID string `json:"id"` - Key string `json:"key"` - Name string `json:"name"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` +} + +type JiraPermissionsResponse struct { + Permissions map[string]JiraPermission `json:"permissions"` } type JiraPermissionScheme struct { diff --git a/main.go b/main.go index a390040..b1de360 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "slices" + "strconv" "strings" policyManager "github.com/compliance-framework/agent/policy-manager" @@ -225,6 +226,11 @@ func (l *JiraPlugin) collectData(ctx context.Context, client *jira.Client) (*jir l.Logger.Warn("failed to fetch global permissions", "error", err) } + data.Statuses, err = client.GetAllStatuses(ctx) + if err != nil { + l.Logger.Warn("failed to fetch statuses", "error", err) + } + // 2. Fetch Projects projects, err := client.FetchProjects(ctx) if err != nil { @@ -250,6 +256,26 @@ func (l *JiraPlugin) collectData(ctx context.Context, client *jira.Client) (*jir // 3. Projects are already fetched with all available details l.Logger.Debug("Project details fetched", "count", len(data.Projects)) + // Fetch workflow scheme project associations for all projects + if len(data.Projects) > 0 { + projectIds := make([]int64, 0, len(data.Projects)) + for _, project := range data.Projects { + if project.ID != "" { + // Convert string ID to int64 + if id, err := strconv.ParseInt(project.ID, 10, 64); err == nil { + projectIds = append(projectIds, id) + } + } + } + + if len(projectIds) > 0 { + data.WorkflowSchemeProjectAssociations, err = client.GetWorkflowSchemeProjectAssociations(ctx, projectIds) + if err != nil { + l.Logger.Warn("failed to fetch workflow scheme project associations", "error", err) + } + } + } + // 4. Search for Change Request issues issues, err := client.SearchChangeRequests(ctx) if err != nil { From f4f9a62dfbf3942a99c1560a70aae413f57a5bc5 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Thu, 8 Jan 2026 20:43:13 -0300 Subject: [PATCH 4/6] feat: adjustments, new types, etc Signed-off-by: Gustavo Carvalho --- config-example.yaml | 19 ++ internal/jira/client.go | 552 +++++++++++++++++++++++++++++++--------- internal/jira/types.go | 501 ++++++++++++++++++++++++++++++++++-- main.go | 79 ++++-- 4 files changed, 978 insertions(+), 173 deletions(-) create mode 100644 config-example.yaml diff --git a/config-example.yaml b/config-example.yaml new file mode 100644 index 0000000..2a11b7f --- /dev/null +++ b/config-example.yaml @@ -0,0 +1,19 @@ +# Example configuration for Jira plugin with change request issue types + +# Basic Jira connection settings +base_url: "https://your-domain.atlassian.net" +auth_type: "token" +api_token: "your-api-token" +user_email: "your-email@example.com" + +# Optional: Filter by specific projects (comma-separated) +# If not specified, all projects will be included +project_keys: "PROJ1,PROJ2" + +# NEW: Configure which issue types to consider as change requests +# This is a comma-separated list of issue type names +# If not specified, defaults to: "Change Request,Change" +change_request_issue_types: "Change Request,Task,Story,Incident" + +# Optional: Policy labels for evidence generation +policy_labels: "{\"environment\": \"production\", \"team\": \"compliance\"}" diff --git a/internal/jira/client.go b/internal/jira/client.go index 8ad6409..7d867b1 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -85,7 +85,7 @@ func (t *oauth2Transport) RoundTrip(req *http.Request) (*http.Response, error) { // Form encode the parameters data := url.Values{} data.Set("grant_type", "client_credentials") - data.Set("scope", "read:jira-user read:jira-work read:workflow:jira read:permission:jira read:workflow-scheme:jira read:audit-log:jira read:avatar:jira read:group:jira read:issue-type:jira read:project-category:jira read:project:jira read:user:jira read:application-role:jira") + data.Set("scope", "read:jira-user read:jira-work read:workflow:jira read:permission:jira read:workflow-scheme:jira read:audit-log:jira read:avatar:jira read:group:jira read:issue-type:jira read:project-category:jira read:project:jira read:user:jira read:application-role:jira read:servicedesk-request") tokenReq.Body = io.NopCloser(strings.NewReader(data.Encode())) resp, err := t.base.RoundTrip(tokenReq) @@ -182,63 +182,149 @@ func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (* } func (c *Client) FetchProjects(ctx context.Context) ([]JiraProject, error) { - resp, err := c.do(ctx, "GET", "/rest/api/3/project/search", nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - c.logSuccessOrWarn("FetchProjects", resp, err) - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) - } - body := resp.Body - var searchResp JiraProjectSearchResponse - if err := json.NewDecoder(body).Decode(&searchResp); err != nil { - return nil, err - } - c.Logger.Debug("Fetched projects", "status", resp.StatusCode, "total", searchResp.Total, "projects", len(searchResp.Values)) - return searchResp.Values, nil + var allProjects []JiraProject + startAt := 0 + maxResults := 50 // Jira default max per page + + for { + // Build URL with pagination parameters and expand to include lead + url := fmt.Sprintf("/rest/api/3/project/search?startAt=%d&maxResults=%d&expand=lead", startAt, maxResults) + resp, err := c.do(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + c.logSuccessOrWarn("FetchProjects", resp, err) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + body := resp.Body + var searchResp JiraProjectSearchResponse + if err := json.NewDecoder(body).Decode(&searchResp); err != nil { + return nil, err + } + + // Add current page results to all projects + allProjects = append(allProjects, searchResp.Values...) + c.Logger.Debug("Fetched projects page", "startAt", startAt, "count", len(searchResp.Values), "total", searchResp.Total) + + // Check if this is the last page + if searchResp.IsLast || len(searchResp.Values) == 0 { + break + } + + // Move to next page + startAt += len(searchResp.Values) + } + + c.Logger.Debug("Fetched all projects", "total", len(allProjects)) + return allProjects, nil } -func (c *Client) FetchWorkflows(ctx context.Context) ([]JiraWorkflow, error) { - resp, err := c.do(ctx, "GET", "/rest/api/3/workflows/search", nil) +func (c *Client) FetchWorkflowCapabilities(ctx context.Context, workflowID string) (*JiraWorkflowCapabilities, error) { + url := fmt.Sprintf("/rest/api/3/workflows/capabilities?workflowId=%s", workflowID) + resp, err := c.do(ctx, "GET", url, nil) if err != nil { return nil, err } defer resp.Body.Close() - c.logSuccessOrWarn("FetchWorkflows", resp, err) + c.logSuccessOrWarn("FetchWorkflowCapabilities", resp, err) + if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) } - var searchResp JiraWorkflowSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + var capabilities JiraWorkflowCapabilities + if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil { return nil, err } - c.Logger.Debug("Fetched workflows", "status", resp.StatusCode, "total", searchResp.Total, "workflows", len(searchResp.Values)) - return searchResp.Values, nil + + c.Logger.Debug("Fetched workflow capabilities", "workflowId", workflowID) + return &capabilities, nil } -func (c *Client) FetchWorkflowSchemes(ctx context.Context) ([]JiraWorkflowScheme, error) { - resp, err := c.do(ctx, "GET", "/rest/api/3/workflowscheme", nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - c.logSuccessOrWarn("FetchWorkflowSchemes", resp, err) - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) - } +func (c *Client) FetchWorkflows(ctx context.Context) ([]JiraWorkflow, error) { + var allWorkflows []JiraWorkflow + startAt := 0 + maxResults := 50 // Jira default max per page + + for { + // Build URL with pagination parameters and expand to include transitions + url := fmt.Sprintf("/rest/api/3/workflows/search?startAt=%d&maxResults=%d&expand=values.transitions", startAt, maxResults) + resp, err := c.do(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + c.logSuccessOrWarn("FetchWorkflows", resp, err) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + var searchResp JiraWorkflowSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return nil, err + } + + // Add current page results to all workflows + allWorkflows = append(allWorkflows, searchResp.Values...) + c.Logger.Debug("Fetched workflows page", "startAt", startAt, "count", len(searchResp.Values), "total", searchResp.Total) + + // Check if this is the last page + if searchResp.IsLast || len(searchResp.Values) == 0 { + break + } + + // Move to next page + startAt += len(searchResp.Values) + } + + c.Logger.Debug("Fetched all workflows", "total", len(allWorkflows)) + return allWorkflows, nil +} - var searchResp JiraWorkflowSchemeSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return nil, err - } - c.Logger.Debug("Fetched workflow schemes", "status", resp.StatusCode, "total", searchResp.Total, "schemes", len(searchResp.Values)) - return searchResp.Values, nil +func (c *Client) FetchWorkflowSchemes(ctx context.Context) ([]JiraWorkflowScheme, error) { + var allWorkflowSchemes []JiraWorkflowScheme + startAt := 0 + maxResults := 50 // Jira default max per page + + for { + // Build URL with pagination parameters + url := fmt.Sprintf("/rest/api/3/workflowscheme?startAt=%d&maxResults=%d", startAt, maxResults) + resp, err := c.do(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + c.logSuccessOrWarn("FetchWorkflowSchemes", resp, err) + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + var searchResp JiraWorkflowSchemeSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return nil, err + } + + // Add current page results to all workflow schemes + allWorkflowSchemes = append(allWorkflowSchemes, searchResp.Values...) + c.Logger.Debug("Fetched workflow schemes page", "startAt", startAt, "count", len(searchResp.Values), "total", searchResp.Total) + + // Check if this is the last page + if searchResp.IsLast || len(searchResp.Values) == 0 { + break + } + + // Move to next page + startAt += len(searchResp.Values) + } + + c.Logger.Debug("Fetched all workflow schemes", "total", len(allWorkflowSchemes)) + return allWorkflowSchemes, nil } func (c *Client) FetchIssueTypes(ctx context.Context) ([]JiraIssueType, error) { @@ -283,7 +369,6 @@ func (c *Client) FetchAuditRecords(ctx context.Context) ([]JiraAuditRecord, erro if err != nil { return nil, err } - c.Logger.Info("Raw audit records response", "body", string(body)) var result struct { Records []JiraAuditRecord `json:"records"` @@ -316,19 +401,54 @@ func (c *Client) FetchGlobalPermissions(ctx context.Context) ([]JiraPermission, } func (c *Client) FetchIssueSLAs(ctx context.Context, issueKey string) ([]JiraSLA, error) { - resp, err := c.do(ctx, "GET", fmt.Sprintf("/rest/servicedeskapi/request/%s/sla", issueKey), nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - c.logSuccessOrWarn("FetchIssueSLAs", resp, err) - var result struct { - Values []JiraSLA `json:"values"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - return result.Values, nil + var allSLAs []JiraSLA + startAt := 0 + maxResults := 50 // Jira default max per page + + for { + // Build URL with pagination parameters + url := fmt.Sprintf("/rest/servicedeskapi/request/%s/sla?startAt=%d&maxResults=%d", issueKey, startAt, maxResults) + resp, err := c.do(ctx, "GET", url, nil) + if err != nil { + c.Logger.Error("Error fetching SLAs", "issue", issueKey, "error", err) + return nil, err + } + defer resp.Body.Close() + c.logSuccessOrWarn("FetchIssueSLAs", resp, err) + + // Read the response body for debugging + body, err := io.ReadAll(resp.Body) + if err != nil { + c.Logger.Error("Error reading SLA response body", "issue", issueKey, "error", err) + return nil, err + } + + var result struct { + Values []JiraSLA `json:"values"` + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + } + + if err := json.Unmarshal(body, &result); err != nil { + c.Logger.Error("Error unmarshaling SLA response", "issue", issueKey, "error", err, "body", string(body)) + return nil, err + } + + // Add current page results to all SLAs + allSLAs = append(allSLAs, result.Values...) + c.Logger.Debug("Fetched SLAs page", "issue", issueKey, "startAt", startAt, "count", len(result.Values), "total", result.Total) + + // Check if this is the last page + if len(result.Values) == 0 || startAt+len(result.Values) >= result.Total { + break + } + + // Move to next page + startAt += len(result.Values) + } + + return allSLAs, nil } func (c *Client) FetchIssueDevInfo(ctx context.Context, issueKey string) (*JiraDevInfo, error) { @@ -361,57 +481,167 @@ func (c *Client) FetchIssueDeployments(ctx context.Context, issueKey string) ([] return result.Deployments, nil } -func (c *Client) SearchChangeRequests(ctx context.Context) ([]JiraIssue, error) { - jql := "issuetype in ('Change Request', 'Change') AND status != 'Draft'" - query := url.Values{} - query.Set("jql", jql) - query.Set("expand", "changelog") - - resp, err := c.do(ctx, "GET", "/rest/api/3/search/jql?"+query.Encode(), nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - c.logSuccessOrWarn("SearchChangeRequests", resp, err) - var result struct { - Issues []JiraIssue `json:"issues"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - return result.Issues, nil +func (c *Client) SearchChangeRequests(ctx context.Context, projectKeys []string, issueTypes []string) ([]JiraIssue, error) { + // Build JQL query for issue types + var issueTypeFilter string + if len(issueTypes) > 0 { + // Build issue type filter: issuetype in ('Type1', 'Type2', 'Type3') + issueTypeFilter = "issuetype in (" + for i, issueType := range issueTypes { + if i > 0 { + issueTypeFilter += ", " + } + issueTypeFilter += fmt.Sprintf("'%s'", issueType) + } + issueTypeFilter += ")" + } else { + // Fallback to default if no issue types provided + issueTypeFilter = "issuetype in ('Change Request', 'Change')" + } + + jql := fmt.Sprintf("%s AND status != 'Draft'", issueTypeFilter) + + // Add project filter if project keys are specified + if len(projectKeys) > 0 { + c.Logger.Debug("<> Filtering by project keys", "projectKeys", projectKeys) + // Build project filter: project in (KEY1, KEY2, KEY3) + projectFilter := "project in (" + for i, key := range projectKeys { + if i > 0 { + projectFilter += ", " + } + projectFilter += fmt.Sprintf("'%s'", key) + } + projectFilter += ")" + jql = fmt.Sprintf("%s AND %s", projectFilter, jql) + } + + var allIssues []JiraIssue + startAt := 0 + maxResults := 50 // Jira default max per page + + for { + query := url.Values{} + query.Set("jql", jql) + query.Set("expand", "changelog") + query.Set("fields", "project,issuetype,status,summary,description,reporter,assignee,priority,created,updated,duedate,environment,approvals") + query.Set("startAt", fmt.Sprintf("%d", startAt)) + query.Set("maxResults", fmt.Sprintf("%d", maxResults)) + + resp, err := c.do(ctx, "GET", "/rest/api/3/search/jql?"+query.Encode(), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + c.logSuccessOrWarn("SearchChangeRequests", resp, err) + + var result struct { + Issues []JiraIssue `json:"issues"` + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + // Add current page results to all issues + allIssues = append(allIssues, result.Issues...) + c.Logger.Debug("Searched change requests page", "startAt", startAt, "count", len(result.Issues), "total", result.Total) + + // Check if this is the last page + if len(result.Issues) == 0 || startAt+len(result.Issues) >= result.Total { + break + } + + // Move to next page + startAt += len(result.Issues) + } + + c.Logger.Debug("Searched all change requests", "total", len(allIssues)) + return allIssues, nil } func (c *Client) FetchIssueChangelog(ctx context.Context, issueKey string) ([]JiraChangelogEntry, error) { - resp, err := c.do(ctx, "GET", fmt.Sprintf("/rest/api/3/issue/%s/changelog", issueKey), nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - c.logSuccessOrWarn("FetchIssueChangelog", resp, err) - var result struct { - Values []JiraChangelogEntry `json:"values"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - return result.Values, nil + var allEntries []JiraChangelogEntry + startAt := 0 + maxResults := 50 // Jira default max per page + + for { + // Build URL with pagination parameters + url := fmt.Sprintf("/rest/api/3/issue/%s/changelog?startAt=%d&maxResults=%d", issueKey, startAt, maxResults) + resp, err := c.do(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + c.logSuccessOrWarn("FetchIssueChangelog", resp, err) + + var result struct { + Values []JiraChangelogEntry `json:"values"` + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + // Add current page results to all entries + allEntries = append(allEntries, result.Values...) + c.Logger.Debug("Fetched changelog page", "issue", issueKey, "startAt", startAt, "count", len(result.Values), "total", result.Total) + + // Check if this is the last page + if len(result.Values) == 0 || startAt+len(result.Values) >= result.Total { + break + } + + // Move to next page + startAt += len(result.Values) + } + + return allEntries, nil } func (c *Client) FetchIssueApprovals(ctx context.Context, issueKey string) ([]JiraApproval, error) { - resp, err := c.do(ctx, "GET", fmt.Sprintf("/rest/servicedeskapi/request/%s/approval", issueKey), nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - c.logSuccessOrWarn("FetchIssueApprovals", resp, err) - var result struct { - Values []JiraApproval `json:"values"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - return result.Values, nil + var allApprovals []JiraApproval + startAt := 0 + maxResults := 50 // Jira default max per page + + for { + // Build URL with pagination parameters + url := fmt.Sprintf("/rest/servicedeskapi/request/%s/approval?startAt=%d&maxResults=%d", issueKey, startAt, maxResults) + resp, err := c.do(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + c.logSuccessOrWarn("FetchIssueApprovals", resp, err) + + var result struct { + Values []JiraApproval `json:"values"` + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + // Add current page results to all approvals + allApprovals = append(allApprovals, result.Values...) + c.Logger.Debug("Fetched approvals page", "issue", issueKey, "startAt", startAt, "count", len(result.Values), "total", result.Total) + + // Check if this is the last page + if len(result.Values) == 0 || startAt+len(result.Values) >= result.Total { + break + } + + // Move to next page + startAt += len(result.Values) + } + + return allApprovals, nil } func (c *Client) FetchProjectRemoteLinks(ctx context.Context, projectKey string) ([]JiraRemoteLink, error) { @@ -420,25 +650,45 @@ func (c *Client) FetchProjectRemoteLinks(ctx context.Context, projectKey string) } func (c *Client) GetAllStatuses(ctx context.Context) ([]JiraStatus, error) { - resp, err := c.do(ctx, "GET", "/rest/api/3/statuses/search", nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - c.logSuccessOrWarn("GetAllStatuses", resp, err) - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) - } - - var searchResp JiraStatusSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return nil, err - } - - c.Logger.Debug("Fetched all statuses", "status", resp.StatusCode, "total", searchResp.Total, "statuses", len(searchResp.Values)) - return searchResp.Values, nil + var allStatuses []JiraStatus + startAt := 0 + maxResults := 50 // Jira default max per page + + for { + // Build URL with pagination parameters + url := fmt.Sprintf("/rest/api/3/statuses/search?startAt=%d&maxResults=%d", startAt, maxResults) + resp, err := c.do(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + c.logSuccessOrWarn("GetAllStatuses", resp, err) + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + var searchResp JiraStatusSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return nil, err + } + + // Add current page results to all statuses + allStatuses = append(allStatuses, searchResp.Values...) + c.Logger.Debug("Fetched statuses page", "startAt", startAt, "count", len(searchResp.Values), "total", searchResp.Total) + + // Check if this is the last page + if searchResp.IsLast || len(searchResp.Values) == 0 { + break + } + + // Move to next page + startAt += len(searchResp.Values) + } + + c.Logger.Debug("Fetched all statuses", "total", len(allStatuses)) + return allStatuses, nil } func (c *Client) GetWorkflowSchemeProjectAssociations(ctx context.Context, projectIds []int64) ([]JiraWorkflowSchemeProjectAssociation, error) { @@ -474,3 +724,65 @@ func (c *Client) GetWorkflowSchemeProjectAssociations(ctx context.Context, proje c.Logger.Debug("Fetched workflow scheme project associations", "status", resp.StatusCode, "associations", len(result.Values)) return result.Values, nil } + +// GetWorkflowSchemesForProjects retrieves workflow schemes for specific project IDs +func (c *Client) GetWorkflowSchemesForProjects(ctx context.Context, projectIds []int64) ([]JiraWorkflowScheme, error) { + associations, err := c.GetWorkflowSchemeProjectAssociations(ctx, projectIds) + if err != nil { + return nil, err + } + + // Extract unique workflow scheme IDs + schemeIDs := make(map[int64]bool) + for _, assoc := range associations { + schemeIDs[assoc.WorkflowScheme.ID] = true + } + + // Fetch all workflow schemes + allSchemes, err := c.FetchWorkflowSchemes(ctx) + if err != nil { + return nil, err + } + + // Filter schemes that are associated with the projects + var filteredSchemes []JiraWorkflowScheme + for _, scheme := range allSchemes { + if schemeIDs[scheme.ID] { + filteredSchemes = append(filteredSchemes, scheme) + } + } + + return filteredSchemes, nil +} + +// GetWorkflowsForWorkflowSchemes retrieves workflows for specific workflow schemes +func (c *Client) GetWorkflowsForWorkflowSchemes(ctx context.Context, workflowSchemes []JiraWorkflowScheme) ([]JiraWorkflow, error) { + // Get all workflows + allWorkflows, err := c.FetchWorkflows(ctx) + if err != nil { + return nil, err + } + + // Create a map of workflow names from schemes + workflowNames := make(map[string]bool) + for _, scheme := range workflowSchemes { + // Add default workflow + if scheme.DefaultWorkflow != "" { + workflowNames[scheme.DefaultWorkflow] = true + } + // Add workflows from issue type mappings + for _, workflowName := range scheme.IssueTypeMappings { + workflowNames[workflowName] = true + } + } + + // Filter workflows that are referenced in the schemes + var filteredWorkflows []JiraWorkflow + for _, workflow := range allWorkflows { + if workflowNames[workflow.Name] { + filteredWorkflows = append(filteredWorkflows, workflow) + } + } + + return filteredWorkflows, nil +} diff --git a/internal/jira/types.go b/internal/jira/types.go index bf1b886..0f99bc0 100644 --- a/internal/jira/types.go +++ b/internal/jira/types.go @@ -41,6 +41,41 @@ func (jat JiraAuditTime) ToTime() time.Time { return time.Time(jat) } +// JiraChangelogTime handles the timestamp format used in JIRA changelog entries +type JiraChangelogTime time.Time + +func (jct *JiraChangelogTime) UnmarshalJSON(data []byte) error { + str := strings.Trim(string(data), "\"") + if str == "" || str == "null" { + return nil + } + + // JIRA changelog format: 2026-01-07T14:29:44.470+0100 + // Convert to RFC3339 format: 2026-01-07T14:29:44.470+01:00 + if len(str) >= 5 && (str[len(str)-5] == '+' || str[len(str)-5] == '-') { + str = str[:len(str)-2] + ":" + str[len(str)-2:] + } + + t, err := time.Parse(time.RFC3339, str) + if err != nil { + return err + } + *jct = JiraChangelogTime(t) + return nil +} + +func (jct JiraChangelogTime) MarshalJSON() ([]byte, error) { + if time.Time(jct).IsZero() { + return []byte("null"), nil + } + return json.Marshal(time.Time(jct).Format(time.RFC3339)) +} + +// ToTime converts JiraChangelogTime back to time.Time +func (jct JiraChangelogTime) ToTime() time.Time { + return time.Time(jct) +} + type JiraData struct { Projects []JiraProject `json:"projects"` Issues []JiraIssue `json:"issues"` @@ -52,6 +87,358 @@ type JiraData struct { AuditRecords []JiraAuditRecord `json:"audit_records"` GlobalPermissions []JiraPermission `json:"global_permissions"` Statuses []JiraStatus `json:"statuses"` + WorkflowCapabilities *JiraWorkflowCapabilities `json:"workflow_capabilities,omitempty"` +} + +// ProjectCentricData organizes all Jira information by projects +type ProjectCentricData struct { + Projects []ProjectData `json:"projects"` + GlobalPermissions []JiraPermission `json:"global_permissions,omitempty"` + AuditRecords []JiraAuditRecord `json:"audit_records,omitempty"` +} + +// ProjectData contains all information related to a specific project +type ProjectData struct { + Project JiraProject `json:"project"` + WorkflowSchemes []JiraWorkflowScheme `json:"workflow_schemes,omitempty"` + Workflows []JiraWorkflow `json:"workflows,omitempty"` + Issues []ProjectIssue `json:"issues,omitempty"` + IssueTypes []JiraIssueType `json:"issue_types,omitempty"` + Fields []JiraField `json:"fields,omitempty"` + Statuses []JiraStatus `json:"statuses,omitempty"` +} + +// ProjectIssue represents an issue within a project context +type ProjectIssue struct { + JiraIssue + Approvals []JiraApproval `json:"approvals,omitempty"` + SLAs []JiraSLA `json:"slas,omitempty"` + DevInfo *JiraDevInfo `json:"dev_info,omitempty"` + Deployments []JiraDeployment `json:"deployments,omitempty"` +} + +// isOpenIssue determines if an issue is still open based on its status category +func isOpenIssue(issue JiraIssue) bool { + // Access status through the Fields map + statusField, exists := issue.Fields["status"] + if !exists { + // Default to including issues without status + return true + } + + // Type assert to map[string]interface{} + statusMap, ok := statusField.(map[string]interface{}) + if !ok { + // Default to including issues with malformed status + return true + } + + // Access status category + statusCategoryField, exists := statusMap["statusCategory"] + if !exists { + // Default to including issues without status category + return true + } + + // Type assert to map[string]interface{} + statusCategoryMap, ok := statusCategoryField.(map[string]interface{}) + if !ok { + // Default to including issues with malformed status category + return true + } + + // Get status category name + categoryNameField, exists := statusCategoryMap["name"] + if !exists { + // Default to including issues without category name + return true + } + + categoryName, ok := categoryNameField.(string) + if !ok { + // Default to including issues with non-string category name + return true + } + + switch categoryName { + case "To Do", "In Progress": + return true + case "Done": + return false + default: + // Default to including unknown categories as "open" for safety + return true + } +} + +// ToProjectCentric converts JiraData to ProjectCentricData by organizing all information by projects +func (jd *JiraData) ToProjectCentric() *ProjectCentricData { + result := &ProjectCentricData{ + Projects: make([]ProjectData, 0), + GlobalPermissions: jd.GlobalPermissions, + AuditRecords: jd.AuditRecords, + } + + // Create maps for efficient lookups + workflowSchemeMap := make(map[int64]JiraWorkflowScheme) + for _, ws := range jd.WorkflowSchemes { + workflowSchemeMap[ws.ID] = ws + } + + workflowMap := make(map[string]JiraWorkflow) + for _, w := range jd.Workflows { + workflowMap[w.Name] = w + } + + issueTypeMap := make(map[string]JiraIssueType) + for _, it := range jd.IssueTypes { + issueTypeMap[it.Name] = it + } + + fieldMap := make(map[string]JiraField) + for _, f := range jd.Fields { + fieldMap[f.ID] = f + } + + statusMap := make(map[string]JiraStatus) + for _, s := range jd.Statuses { + statusMap[s.Name] = s + } + + // Group issues by project + issuesByProject := make(map[string][]JiraIssue) + for _, issue := range jd.Issues { + if issue.Fields != nil { + if projectObj, exists := issue.Fields["project"]; exists { + if projectMap, ok := projectObj.(map[string]interface{}); ok { + if id, exists := projectMap["id"]; exists { + if projectId, ok := id.(string); ok { + issuesByProject[projectId] = append(issuesByProject[projectId], issue) + } + } + } + } + } + } + + // Process each project + for _, project := range jd.Projects { + projectData := ProjectData{ + Project: project, + } + + // Find workflow schemes for this project + projectSchemeIDs := make(map[int64]bool) + for _, assoc := range jd.WorkflowSchemeProjectAssociations { + for _, projectID := range assoc.ProjectIds { + if projectID == project.ID { + projectSchemeIDs[assoc.WorkflowScheme.ID] = true + break + } + } + } + + // Add workflow schemes for this project + for schemeID := range projectSchemeIDs { + if scheme, exists := workflowSchemeMap[schemeID]; exists { + projectData.WorkflowSchemes = append(projectData.WorkflowSchemes, scheme) + } + } + + // Add workflows referenced by the project's workflow schemes AND used by project's issue types + projectWorkflowNames := make(map[string]bool) + projectIssueTypeNames := make(map[string]bool) + + // First, collect all issue types used by this project's issues + if projectIssues, exists := issuesByProject[project.ID]; exists { + for _, issue := range projectIssues { + if issue.Fields != nil { + if issueTypeObj, exists := issue.Fields["issuetype"]; exists { + if issueTypeMap, ok := issueTypeObj.(map[string]interface{}); ok { + if name, exists := issueTypeMap["name"]; exists { + if issueTypeName, ok := name.(string); ok { + projectIssueTypeNames[issueTypeName] = true + } + } + } + } + } + } + } + + // Then, collect workflows from workflow schemes that are mapped to the used issue types + for _, scheme := range projectData.WorkflowSchemes { + // Check default workflow if it's mapped to a used issue type + if scheme.DefaultWorkflow != "" { + // Check if any issue type mapping uses this workflow + for issueTypeName := range projectIssueTypeNames { + if mapping, exists := scheme.IssueTypeMappings[issueTypeName]; exists && mapping == scheme.DefaultWorkflow { + projectWorkflowNames[scheme.DefaultWorkflow] = true + break + } + } + } + + // Check issue type mappings for workflows used by our issue types + for issueTypeName, workflowName := range scheme.IssueTypeMappings { + if _, issueTypeUsed := projectIssueTypeNames[issueTypeName]; issueTypeUsed { + projectWorkflowNames[workflowName] = true + } + } + } + + // Fallback: If no workflow schemes found, try to include workflows that might be related to this project + // This handles cases where associations are missing or projects use different workflow management + if len(projectWorkflowNames) == 0 { + // Try to match workflows by project ID in workflow name or scope + for _, workflow := range jd.Workflows { + // Check if workflow name contains project ID or key + if strings.Contains(workflow.Name, project.ID) || strings.Contains(workflow.Name, project.Key) { + projectWorkflowNames[workflow.Name] = true + } + // For next-gen projects, check if workflow scope matches project + if workflow.Scope.Type == "PROJECT" && workflow.Scope.Project.ID == project.ID { + projectWorkflowNames[workflow.Name] = true + } + } + } + + for workflowName := range projectWorkflowNames { + if workflow, exists := workflowMap[workflowName]; exists { + // Create a copy of the workflow to avoid modifying the original + workflowCopy := workflow + + // Merge approval configurations from global workflow statuses + if len(workflowCopy.Statuses) > 0 { + // Find the corresponding global workflow to get approval configurations + for _, globalWorkflow := range jd.Workflows { + if globalWorkflow.Name == workflow.Name { + // Create a map of statusReference to approval configuration + globalApprovalConfigs := make(map[string]*JiraApprovalConfiguration) + for _, globalStatus := range globalWorkflow.Statuses { + if globalStatus.ApprovalConfiguration != nil { + globalApprovalConfigs[globalStatus.StatusReference] = globalStatus.ApprovalConfiguration + } + } + + // Apply approval configurations to project workflow statuses + for i, projectStatus := range workflowCopy.Statuses { + if approvalConfig, exists := globalApprovalConfigs[projectStatus.StatusReference]; exists { + workflowCopy.Statuses[i].ApprovalConfiguration = approvalConfig + } + } + break + } + } + + // Enrich workflow statuses with global status details using statusReference + // Create a map of status ID to full status details from global statuses + globalStatusMap := make(map[string]*JiraStatus) + for _, globalStatus := range jd.Statuses { + globalStatusMap[globalStatus.ID] = &globalStatus + } + + // Apply global status details to workflow statuses + for i, projectStatus := range workflowCopy.Statuses { + if projectStatus.StatusReference != "" { + if globalStatus, exists := globalStatusMap[projectStatus.StatusReference]; exists { + // Only fill in missing fields from global status + if projectStatus.ID == "" { + workflowCopy.Statuses[i].ID = globalStatus.ID + } + if projectStatus.Name == "" { + workflowCopy.Statuses[i].Name = globalStatus.Name + } + if projectStatus.Description == "" && globalStatus.Description != "" { + workflowCopy.Statuses[i].Description = globalStatus.Description + } + if projectStatus.StatusCategory == "" && globalStatus.StatusCategory != "" { + workflowCopy.Statuses[i].StatusCategory = globalStatus.StatusCategory + } + } + } + } + } + + projectData.Workflows = append(projectData.Workflows, workflowCopy) + } + } + + // Add issues for this project (only open issues) + if projectIssues, exists := issuesByProject[project.ID]; exists { + for _, issue := range projectIssues { + // Only include open issues + if !isOpenIssue(issue) { + continue + } + + projectIssue := ProjectIssue{ + JiraIssue: issue, + // Copy the service desk fetched data + Approvals: issue.Approvals, + SLAs: issue.SLAs, + DevInfo: issue.DevInfo, + Deployments: issue.Deployments, + } + projectData.Issues = append(projectData.Issues, projectIssue) + } + } + + for issueTypeName := range projectIssueTypeNames { + if issueType, exists := issueTypeMap[issueTypeName]; exists { + projectData.IssueTypes = append(projectData.IssueTypes, issueType) + } + } + + // Add fields used by this project's issues + projectFieldIDs := make(map[string]bool) + for _, issue := range projectData.Issues { + if issue.Fields != nil { + for fieldName := range issue.Fields { + // Try to find field by name (this is a simplified approach) + // In practice, you might need a more sophisticated mapping + for _, field := range jd.Fields { + if field.Name == fieldName { + projectFieldIDs[field.ID] = true + break + } + } + } + } + } + + for fieldID := range projectFieldIDs { + if field, exists := fieldMap[fieldID]; exists { + projectData.Fields = append(projectData.Fields, field) + } + } + + // Add statuses used by this project's issues + projectStatusNames := make(map[string]bool) + for _, issue := range projectData.Issues { + if issue.Fields != nil { + if statusObj, exists := issue.Fields["status"]; exists { + if statusMap, ok := statusObj.(map[string]interface{}); ok { + if name, exists := statusMap["name"]; exists { + if statusName, ok := name.(string); ok { + projectStatusNames[statusName] = true + } + } + } + } + } + } + + for statusName := range projectStatusNames { + if status, exists := statusMap[statusName]; exists { + projectData.Statuses = append(projectData.Statuses, status) + } + } + + result.Projects = append(result.Projects, projectData) + } + + return result } type JiraProject struct { @@ -168,7 +555,14 @@ type JiraWorkflowStatus struct { } type JiraApprovalConfiguration struct { - Approvals []JiraApprovalStatus `json:"approvals,omitempty"` + Active string `json:"active"` // Whether the approval configuration is active + ConditionType string `json:"conditionType"` // "number", "percent", "numberPerPrincipal" + ConditionValue string `json:"conditionValue"` // Number or percentage of approvals required + Exclude []string `json:"exclude,omitempty"` // Roles to exclude as possible approvers + FieldID string `json:"fieldId"` // Custom field ID of "Approvers" or "Approver Groups" field + PrePopulatedFieldID *string `json:"prePopulatedFieldId,omitempty"` // Field used to pre-populate Approver field + TransitionApproved string `json:"transitionApproved"` // Transition ID for approved state + TransitionRejected string `json:"transitionRejected"` // Transition ID for rejected state } type JiraApprovalStatus struct { @@ -178,22 +572,35 @@ type JiraApprovalStatus struct { } type JiraWorkflowTransition struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - ToStatusReference string `json:"toStatusReference,omitempty"` - Type string `json:"type"` - Actions []JiraWorkflowRule `json:"actions,omitempty"` - Links []JiraTransitionLink `json:"links,omitempty"` - Properties map[string]interface{} `json:"properties,omitempty"` - Triggers []JiraWorkflowTrigger `json:"triggers,omitempty"` - Validators []JiraWorkflowValidator `json:"validators,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ToStatusReference string `json:"toStatusReference,omitempty"` + FromStatus []string `json:"fromStatus,omitempty"` + Screen *JiraWorkflowScreen `json:"screen,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` + Type string `json:"type,omitempty"` + IsInitial bool `json:"isInitial,omitempty"` + IsLooped bool `json:"isLooped,omitempty"` + IsConditional bool `json:"isConditional,omitempty"` +} + +type JiraWorkflowScreen struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` } -type JiraTransitionLink struct { - FromPort int `json:"fromPort,omitempty"` - FromStatusReference string `json:"fromStatusReference,omitempty"` - ToPort int `json:"toPort,omitempty"` +type JiraWorkflowCapabilities struct { + ApprovalsEnabled bool `json:"approvalsEnabled"` // Whether approvals are enabled for workflows + ConditionsEnabled bool `json:"conditionsEnabled"` // Whether conditions are enabled for workflows + ValidatorsEnabled bool `json:"validatorsEnabled"` // Whether validators are enabled for workflows + PostFunctionsEnabled bool `json:"postFunctionsEnabled"` // Whether post functions are enabled for workflows + RulesEnabled bool `json:"rulesEnabled"` // Whether rules are enabled for workflows + ScreensEnabled bool `json:"screensEnabled"` // Whether screens are enabled for workflows + PropertiesEnabled bool `json:"propertiesEnabled"` // Whether properties are enabled for workflows + TransitionsEnabled bool `json:"transitionsEnabled"` // Whether transitions are enabled for workflows + StatusesEnabled bool `json:"statusesEnabled"` // Whether statuses are enabled for workflows } type JiraWorkflowVersion struct { @@ -313,16 +720,24 @@ type JiraSchema struct { type JiraIssue struct { Key string `json:"key"` Fields map[string]interface{} `json:"fields"` - Changelog []JiraChangelogEntry `json:"changelog"` + Changelog *JiraChangelog `json:"changelog"` Approvals []JiraApproval `json:"approvals"` SLAs []JiraSLA `json:"slas,omitempty"` DevInfo *JiraDevInfo `json:"dev_info,omitempty"` Deployments []JiraDeployment `json:"deployments,omitempty"` } +// JiraChangelog represents the changelog object returned by Jira API +type JiraChangelog struct { + Histories []JiraChangelogEntry `json:"histories"` + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` +} + type JiraChangelogEntry struct { - Author string `json:"author"` - Created time.Time `json:"created"` + Author *JiraUser `json:"author"` + Created JiraChangelogTime `json:"created"` Items []JiraChangelogItem `json:"items"` } @@ -333,17 +748,51 @@ type JiraChangelogItem struct { } type JiraApproval struct { - ID string `json:"id"` - Status string `json:"status"` - Approvers []string `json:"approvers"` - Completed time.Time `json:"completed_date"` + ID string `json:"id"` + Name string `json:"name,omitempty"` + FinalDecision string `json:"finalDecision,omitempty"` + CanAnswerApproval bool `json:"canAnswerApproval,omitempty"` + Approvers []JiraApproverItem `json:"approvers"` + CreatedDate JiraApprovalTimestamp `json:"createdDate,omitempty"` + CompletedDate JiraApprovalTimestamp `json:"completedDate,omitempty"` +} + +type JiraApproverItem struct { + Approver JiraUser `json:"approver"` + ApproverDecision string `json:"approverDecision,omitempty"` +} + +type JiraApprovalTimestamp struct { + EpochMillis int64 `json:"epochMillis,omitempty"` + Friendly string `json:"friendly,omitempty"` + ISO8601 string `json:"iso8601,omitempty"` + Jira string `json:"jira,omitempty"` } type JiraSLA struct { - Name string `json:"name"` - Breached bool `json:"breached"` - Remaining string `json:"remaining_time"` - Target time.Time `json:"target_date"` + ID string `json:"id"` + Name string `json:"name"` + CompletedCycles []JiraSLACycle `json:"completedCycles,omitempty"` + OngoingCycle *JiraSLACycle `json:"ongoingCycle,omitempty"` + State string `json:"state"` // "MET", "BREACHED", "IN_PROGRESS" +} + +type JiraSLACycle struct { + BreachTime *JiraSLATime `json:"breachTime,omitempty"` + ElapsedTime *JiraSLATime `json:"elapsedTime,omitempty"` + RemainingTime *JiraSLATime `json:"remainingTime,omitempty"` + StartTime *JiraSLATime `json:"startTime,omitempty"` + StopTime *JiraSLATime `json:"stopTime,omitempty"` + GoalDuration *JiraSLATime `json:"goalDuration,omitempty"` + Breached bool `json:"breached,omitempty"` + WithinCalendar bool `json:"withinCalendar,omitempty"` +} + +type JiraSLATime struct { + Friendly string `json:"friendly"` + Millis int64 `json:"millis"` + Jira string `json:"jira"` + ISO8601 string `json:"iso8601,omitempty"` } type JiraDevInfo struct { diff --git a/main.go b/main.go index b1de360..7b2defb 100644 --- a/main.go +++ b/main.go @@ -26,13 +26,14 @@ type Validator interface { } type PluginConfig struct { - BaseURL string `mapstructure:"base_url"` - AuthType string `mapstructure:"auth_type"` // "oauth2" or "token" - ClientID string `mapstructure:"client_id"` - ClientSecret string `mapstructure:"client_secret"` - APIToken string `mapstructure:"api_token"` - UserEmail string `mapstructure:"user_email"` - ProjectKeys string `mapstructure:"project_keys"` // Comma-separated list + BaseURL string `mapstructure:"base_url"` + AuthType string `mapstructure:"auth_type"` // "oauth2" or "token" + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + APIToken string `mapstructure:"api_token"` + UserEmail string `mapstructure:"user_email"` + ProjectKeys string `mapstructure:"project_keys"` // Comma-separated list + ChangeRequestIssueTypes string `mapstructure:"change_request_issue_types"` // Comma-separated list of issue types to consider as change requests // Hack to configure policy labels and generate correct evidence UUIDs PolicyLabels string `mapstructure:"policy_labels"` @@ -40,14 +41,15 @@ type PluginConfig struct { // ParsedConfig holds the parsed and processed configuration type ParsedConfig struct { - BaseURL string `mapstructure:"base_url"` - AuthType string `mapstructure:"auth_type"` - ClientID string `mapstructure:"client_id"` - ClientSecret string `mapstructure:"client_secret"` - APIToken string `mapstructure:"api_token"` - UserEmail string `mapstructure:"user_email"` - ProjectKeys []string `mapstructure:"project_keys"` - PolicyLabels map[string]string `mapstructure:"policy_labels"` + BaseURL string `mapstructure:"base_url"` + AuthType string `mapstructure:"auth_type"` + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + APIToken string `mapstructure:"api_token"` + UserEmail string `mapstructure:"user_email"` + ProjectKeys []string `mapstructure:"project_keys"` + ChangeRequestIssueTypes []string `mapstructure:"change_request_issue_types"` + PolicyLabels map[string]string `mapstructure:"policy_labels"` } func (c *PluginConfig) Parse() (*ParsedConfig, error) { @@ -77,6 +79,19 @@ func (c *PluginConfig) Parse() (*ParsedConfig, error) { } } + // Parse change request issue types with defaults + if c.ChangeRequestIssueTypes != "" { + parts := strings.Split(c.ChangeRequestIssueTypes, ",") + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + parsed.ChangeRequestIssueTypes = append(parsed.ChangeRequestIssueTypes, s) + } + } + } else { + // Default values + parsed.ChangeRequestIssueTypes = []string{"Change Request", "Change"} + } + return parsed, nil } @@ -163,8 +178,11 @@ func (l *JiraPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (* } jiraData, err := l.collectData(ctx, client) - indentedJSON, _ := json.MarshalIndent(jiraData, "", " ") - os.WriteFile("/data/jira_data.json", indentedJSON, 0o644) + jiraJSON, _ := json.MarshalIndent(jiraData, "", " ") + converted := jiraData.ToProjectCentric() + indentedJSON, _ := json.MarshalIndent(converted, "", " ") + os.WriteFile("/data/project_data.json", indentedJSON, 0o644) + os.WriteFile("/data/jira_data.json", jiraJSON, 0o644) if err != nil { l.Logger.Error("Error collecting Jira data", "error", err) return &proto.EvalResponse{ @@ -172,7 +190,7 @@ func (l *JiraPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (* }, err } - evidences, err := l.EvaluatePolicies(ctx, jiraData, req) + evidences, err := l.EvaluatePolicies(ctx, converted, req) if err != nil { l.Logger.Error("Error evaluating policies", "error", err) return &proto.EvalResponse{ @@ -201,6 +219,17 @@ func (l *JiraPlugin) collectData(ctx context.Context, client *jira.Client) (*jir l.Logger.Warn("failed to fetch workflows", "error", err) } + // Fetch workflow capabilities for each workflow + if len(data.Workflows) > 0 { + // For now, fetch capabilities for the first workflow as an example + // In the future, we could fetch for all workflows or specific ones + firstWorkflow := data.Workflows[0] + data.WorkflowCapabilities, err = client.FetchWorkflowCapabilities(ctx, firstWorkflow.ID) + if err != nil { + l.Logger.Warn("failed to fetch workflow capabilities", "workflowId", firstWorkflow.ID, "error", err) + } + } + data.WorkflowSchemes, err = client.FetchWorkflowSchemes(ctx) if err != nil { l.Logger.Warn("failed to fetch workflow schemes", "error", err) @@ -277,20 +306,16 @@ func (l *JiraPlugin) collectData(ctx context.Context, client *jira.Client) (*jir } // 4. Search for Change Request issues - issues, err := client.SearchChangeRequests(ctx) + issues, err := client.SearchChangeRequests(ctx, l.parsedConfig.ProjectKeys, l.parsedConfig.ChangeRequestIssueTypes) if err != nil { return nil, fmt.Errorf("failed to search issues: %w", err) } data.Issues = issues - // 5. Fetch Details, Changelog, Approvals, SLAs, DevInfo, and Deployments for each issue + // 5. Fetch Details, Approvals, SLAs, DevInfo, and Deployments for each issue for i, issue := range data.Issues { l.Logger.Info("Fetching details for issue", "issue", issue.Key) - changelog, err := client.FetchIssueChangelog(ctx, issue.Key) - if err != nil { - l.Logger.Warn("failed to fetch changelog for issue", "issue", issue.Key, "error", err) - } else { - data.Issues[i].Changelog = changelog - } + // Note: Changelog is already fetched via expand=changelog in SearchChangeRequests + // No need to fetch it separately unless we need additional pagination approvals, err := client.FetchIssueApprovals(ctx, issue.Key) if err != nil { @@ -323,7 +348,7 @@ func (l *JiraPlugin) collectData(ctx context.Context, client *jira.Client) (*jir return data, nil } -func (l *JiraPlugin) EvaluatePolicies(ctx context.Context, data *jira.JiraData, req *proto.EvalRequest) ([]*proto.Evidence, error) { +func (l *JiraPlugin) EvaluatePolicies(ctx context.Context, data *jira.ProjectCentricData, req *proto.EvalRequest) ([]*proto.Evidence, error) { var accumulatedErrors error activities := make([]*proto.Activity, 0) From 312300cf86a55d1627f559d549e7316a87b3ef74 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 9 Jan 2026 06:30:12 -0300 Subject: [PATCH 5/6] fix: copilot comments Signed-off-by: Gustavo Carvalho --- internal/jira/client.go | 209 ++++++++++++++++++++++++++++++++++------ main.go | 15 ++- 2 files changed, 183 insertions(+), 41 deletions(-) diff --git a/internal/jira/client.go b/internal/jira/client.go index 7d867b1..7d33208 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -8,6 +8,8 @@ import ( "net/http" "net/url" "strings" + "sync" + "time" "github.com/hashicorp/go-hclog" ) @@ -22,7 +24,7 @@ type Client struct { func NewClient(baseURL string, httpClient *http.Client, logger hclog.Logger) (*Client, error) { // First, fetch the Cloud ID - cloudID, err := fetchCloudID(baseURL) + cloudID, err := fetchCloudID(baseURL, logger) if err != nil { logger.Error("Failed to fetch Cloud ID", "error", err) return nil, fmt.Errorf("failed to fetch Cloud ID: %w", err) @@ -38,13 +40,18 @@ func NewClient(baseURL string, httpClient *http.Client, logger hclog.Logger) (*C }, nil } -func fetchCloudID(baseURL string) (string, error) { +func fetchCloudID(baseURL string, logger hclog.Logger) (string, error) { // Make request to get tenant info resp, err := http.Get(baseURL + "/_edge/tenant_info") if err != nil { return "", err } - defer resp.Body.Close() + defer func() { + err := resp.Body.Close() + if err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("failed to get tenant info: %d", resp.StatusCode) @@ -71,9 +78,26 @@ type oauth2Transport struct { clientID string secret string resource string + + // Token caching fields + mu sync.Mutex + cachedToken string + expiresAt time.Time } func (t *oauth2Transport) RoundTrip(req *http.Request) (*http.Response, error) { + // Check if we have a valid cached token + t.mu.Lock() + if t.cachedToken != "" && time.Now().Before(t.expiresAt) { + token := t.cachedToken + t.mu.Unlock() + // Use cached token + newReq := req.Clone(req.Context()) + newReq.Header.Set("Authorization", "Bearer "+token) + return t.base.RoundTrip(newReq) + } + t.mu.Unlock() + // Get OAuth2 token with required scopes tokenReq, err := http.NewRequest("POST", t.tokenURL, nil) if err != nil { @@ -92,7 +116,12 @@ func (t *oauth2Transport) RoundTrip(req *http.Request) (*http.Response, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + err := resp.Body.Close() + if err != nil { + t.logger.Error("failed to close body", "error", err) + } + }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) @@ -111,10 +140,16 @@ func (t *oauth2Transport) RoundTrip(req *http.Request) (*http.Response, error) { } t.logger.Debug("Got OAuth2 token", "scope", tokenResp.Scope, "expiresIn", tokenResp.ExpiresIn) + // Cache the token with a buffer before expiration (subtract 60 seconds) + t.mu.Lock() + t.cachedToken = tokenResp.AccessToken + t.expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn-60) * time.Second) + t.mu.Unlock() + // Clone the request and set the bearer token newReq := req.Clone(req.Context()) newReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken) - t.logger.Debug("Making request with Bearer token", "url", newReq.URL.String(), "auth", "Bearer "+tokenResp.AccessToken[:10]+"...") + t.logger.Debug("Making request with Bearer token", "url", newReq.URL.String()) return t.base.RoundTrip(newReq) } @@ -125,8 +160,9 @@ type tokenAuthTransport struct { } func (t *tokenAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.SetBasicAuth(t.email, t.token) - return t.base.RoundTrip(req) + newReq := req.Clone(req.Context()) + newReq.SetBasicAuth(t.email, t.token) + return t.base.RoundTrip(newReq) } func NewTokenAuthClient(email, token string) *http.Client { @@ -141,15 +177,17 @@ func NewTokenAuthClient(email, token string) *http.Client { func (c *Client) logSuccessOrWarn(request string, resp *http.Response, err error) { if err != nil { - c.Logger.Warn("<<>> Fail with the request", "request", request, "error", err) + c.Logger.Warn("Request failed", "request", request, "error", err) return } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - c.Logger.Warn("<<>> Request status code != 200", "request", request, "status code", resp.StatusCode, "body", string(body)) + bodyLen := len(body) + // Only log a summary of the error, not the full body which may contain sensitive data + c.Logger.Warn("Request returned non-OK status", "request", request, "statusCode", resp.StatusCode, "bodyLength", bodyLen) return } - c.Logger.Info("<<>> Request Successful! ", "request", request) + c.Logger.Info("Request successful", "request", request) } func NewOAuth2Client(clientID, clientSecret, baseURL string, logger hclog.Logger) *http.Client { return &http.Client{ @@ -193,17 +231,27 @@ func (c *Client) FetchProjects(ctx context.Context) ([]JiraProject, error) { if err != nil { return nil, err } - defer resp.Body.Close() c.logSuccessOrWarn("FetchProjects", resp, err) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) } - body := resp.Body var searchResp JiraProjectSearchResponse - if err := json.NewDecoder(body).Decode(&searchResp); err != nil { + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, err } + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } // Add current page results to all projects allProjects = append(allProjects, searchResp.Values...) @@ -228,7 +276,12 @@ func (c *Client) FetchWorkflowCapabilities(ctx context.Context, workflowID strin if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + err := resp.Body.Close() + if err != nil { + c.Logger.Error("failed to close body", "error", err) + } + }() c.logSuccessOrWarn("FetchWorkflowCapabilities", resp, err) if resp.StatusCode != http.StatusOK { @@ -257,17 +310,28 @@ func (c *Client) FetchWorkflows(ctx context.Context) ([]JiraWorkflow, error) { if err != nil { return nil, err } - defer resp.Body.Close() c.logSuccessOrWarn("FetchWorkflows", resp, err) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) } var searchResp JiraWorkflowSearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, err } + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } // Add current page results to all workflows allWorkflows = append(allWorkflows, searchResp.Values...) @@ -298,17 +362,28 @@ func (c *Client) FetchWorkflowSchemes(ctx context.Context) ([]JiraWorkflowScheme if err != nil { return nil, err } - defer resp.Body.Close() c.logSuccessOrWarn("FetchWorkflowSchemes", resp, err) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) } var searchResp JiraWorkflowSchemeSearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, err } + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } // Add current page results to all workflow schemes allWorkflowSchemes = append(allWorkflowSchemes, searchResp.Values...) @@ -332,7 +407,12 @@ func (c *Client) FetchIssueTypes(ctx context.Context) ([]JiraIssueType, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + err := resp.Body.Close() + if err != nil { + c.Logger.Error("failed to close body", "error", err) + } + }() c.logSuccessOrWarn("FetchIssueTypes", resp, err) var types []JiraIssueType if err := json.NewDecoder(resp.Body).Decode(&types); err != nil { @@ -347,7 +427,12 @@ func (c *Client) FetchFields(ctx context.Context) ([]JiraField, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + err := resp.Body.Close() + if err != nil { + c.Logger.Error("failed to close body", "error", err) + } + }() c.logSuccessOrWarn("FetchFields", resp, err) var fields []JiraField if err := json.NewDecoder(resp.Body).Decode(&fields); err != nil { @@ -361,7 +446,12 @@ func (c *Client) FetchAuditRecords(ctx context.Context) ([]JiraAuditRecord, erro if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + err := resp.Body.Close() + if err != nil { + c.Logger.Error("failed to close body", "error", err) + } + }() c.logSuccessOrWarn("FetchAuditRecords", resp, err) // Read the raw response for debugging @@ -385,7 +475,12 @@ func (c *Client) FetchGlobalPermissions(ctx context.Context) ([]JiraPermission, if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + err := resp.Body.Close() + if err != nil { + c.Logger.Error("failed to close body", "error", err) + } + }() c.logSuccessOrWarn("FetchGlobalPermissions", resp, err) var result JiraPermissionsResponse @@ -413,11 +508,14 @@ func (c *Client) FetchIssueSLAs(ctx context.Context, issueKey string) ([]JiraSLA c.Logger.Error("Error fetching SLAs", "issue", issueKey, "error", err) return nil, err } - defer resp.Body.Close() c.logSuccessOrWarn("FetchIssueSLAs", resp, err) - // Read the response body for debugging + // Read the response body body, err := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } if err != nil { c.Logger.Error("Error reading SLA response body", "issue", issueKey, "error", err) return nil, err @@ -431,7 +529,7 @@ func (c *Client) FetchIssueSLAs(ctx context.Context, issueKey string) ([]JiraSLA } if err := json.Unmarshal(body, &result); err != nil { - c.Logger.Error("Error unmarshaling SLA response", "issue", issueKey, "error", err, "body", string(body)) + c.Logger.Error("Error unmarshaling SLA response", "issue", issueKey, "error", err) return nil, err } @@ -456,7 +554,12 @@ func (c *Client) FetchIssueDevInfo(ctx context.Context, issueKey string) (*JiraD if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + err := resp.Body.Close() + if err != nil { + c.Logger.Error("failed to close body", "error", err) + } + }() c.logSuccessOrWarn("FetchIssueDevInfo", resp, err) var info JiraDevInfo if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { @@ -470,7 +573,12 @@ func (c *Client) FetchIssueDeployments(ctx context.Context, issueKey string) ([] if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + err := resp.Body.Close() + if err != nil { + c.Logger.Error("failed to close body", "error", err) + } + }() c.logSuccessOrWarn("FetchIssueDeployments", resp, err) var result struct { Deployments []JiraDeployment `json:"deployments"` @@ -532,7 +640,6 @@ func (c *Client) SearchChangeRequests(ctx context.Context, projectKeys []string, if err != nil { return nil, err } - defer resp.Body.Close() c.logSuccessOrWarn("SearchChangeRequests", resp, err) var result struct { @@ -542,8 +649,16 @@ func (c *Client) SearchChangeRequests(ctx context.Context, projectKeys []string, Total int `json:"total"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, err } + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } // Add current page results to all issues allIssues = append(allIssues, result.Issues...) @@ -574,7 +689,6 @@ func (c *Client) FetchIssueChangelog(ctx context.Context, issueKey string) ([]Ji if err != nil { return nil, err } - defer resp.Body.Close() c.logSuccessOrWarn("FetchIssueChangelog", resp, err) var result struct { @@ -584,8 +698,16 @@ func (c *Client) FetchIssueChangelog(ctx context.Context, issueKey string) ([]Ji Total int `json:"total"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, err } + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } // Add current page results to all entries allEntries = append(allEntries, result.Values...) @@ -615,7 +737,6 @@ func (c *Client) FetchIssueApprovals(ctx context.Context, issueKey string) ([]Ji if err != nil { return nil, err } - defer resp.Body.Close() c.logSuccessOrWarn("FetchIssueApprovals", resp, err) var result struct { @@ -625,8 +746,16 @@ func (c *Client) FetchIssueApprovals(ctx context.Context, issueKey string) ([]Ji Total int `json:"total"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, err } + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } // Add current page results to all approvals allApprovals = append(allApprovals, result.Values...) @@ -661,18 +790,29 @@ func (c *Client) GetAllStatuses(ctx context.Context) ([]JiraStatus, error) { if err != nil { return nil, err } - defer resp.Body.Close() c.logSuccessOrWarn("GetAllStatuses", resp, err) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) } var searchResp JiraStatusSearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } return nil, err } + closeErr := resp.Body.Close() + if closeErr != nil { + c.Logger.Error("failed to close body", "error", closeErr) + } // Add current page results to all statuses allStatuses = append(allStatuses, searchResp.Values...) @@ -703,12 +843,17 @@ func (c *Client) GetWorkflowSchemeProjectAssociations(ctx context.Context, proje params.Add("projectId", fmt.Sprintf("%d", id)) } - url := fmt.Sprintf("/rest/api/3/workflowscheme/project?%s", params.Encode()) - resp, err := c.do(ctx, "GET", url, nil) + endpoint := fmt.Sprintf("/rest/api/3/workflowscheme/project?%s", params.Encode()) + resp, err := c.do(ctx, "GET", endpoint, nil) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + err := resp.Body.Close() + if err != nil { + c.Logger.Error("failed to close body", "error", err) + } + }() c.logSuccessOrWarn("GetWorkflowSchemeProjectAssociations", resp, err) if resp.StatusCode != http.StatusOK { diff --git a/main.go b/main.go index 7b2defb..cdffb83 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,6 @@ import ( "fmt" "maps" "net/http" - "os" "slices" "strconv" "strings" @@ -115,7 +114,7 @@ func (c *PluginConfig) Validate() error { return nil } -// TrackedFileInfo holds information about a tracked file and its attestation +// JiraPlugin implements the Jira integration plugin, managing configuration and the Jira HTTP client. type JiraPlugin struct { Logger hclog.Logger @@ -144,7 +143,7 @@ func (l *JiraPlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureRes l.Logger.Error("Error decoding config", "error", err) return nil, err } - l.Logger.Debug("configuration decoded", "config", config) + l.Logger.Debug("configuration decoded", "baseURL", config.BaseURL, "authType", config.AuthType) if err := config.Validate(); err != nil { l.Logger.Error("Error validating config", "error", err) @@ -178,11 +177,6 @@ func (l *JiraPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (* } jiraData, err := l.collectData(ctx, client) - jiraJSON, _ := json.MarshalIndent(jiraData, "", " ") - converted := jiraData.ToProjectCentric() - indentedJSON, _ := json.MarshalIndent(converted, "", " ") - os.WriteFile("/data/project_data.json", indentedJSON, 0o644) - os.WriteFile("/data/jira_data.json", jiraJSON, 0o644) if err != nil { l.Logger.Error("Error collecting Jira data", "error", err) return &proto.EvalResponse{ @@ -190,6 +184,9 @@ func (l *JiraPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (* }, err } + converted := jiraData.ToProjectCentric() + l.Logger.Debug("Collected Jira data", "projectCount", len(converted.Projects)) + evidences, err := l.EvaluatePolicies(ctx, converted, req) if err != nil { l.Logger.Error("Error evaluating policies", "error", err) @@ -197,7 +194,7 @@ func (l *JiraPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (* Status: proto.ExecutionStatus_FAILURE, }, err } - l.Logger.Debug("calculated evidences", "evidences", evidences) + l.Logger.Debug("calculated evidences", "count", len(evidences)) if err := apiHelper.CreateEvidence(ctx, evidences); err != nil { l.Logger.Error("Error creating evidence", "error", err) return &proto.EvalResponse{ From e4aaa8513ef924233470eda467f52a96ee926f67 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 9 Jan 2026 06:36:00 -0300 Subject: [PATCH 6/6] fix: remove plan Signed-off-by: Gustavo Carvalho --- PLAN.md | 74 --------------------------------------------------------- 1 file changed, 74 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 2197d80..0000000 --- a/PLAN.md +++ /dev/null @@ -1,74 +0,0 @@ -# Jira Compliance Plugin Plan - -This plugin for the `compliance-framework` evaluates Jira/JSM data to ensure Change Request processes are followed correctly before deployments. - -## Architecture - -The plugin implements the `Runner` interface from the `compliance-framework/agent`. It collects data from multiple Jira APIs and evaluates policies to generate compliance evidence. - -## Goals - -* **Authentication**: Support Service Account + OAuth2 (2LO) and API Tokens. -* **Data Collection**: - * **Jira Platform**: Projects, Workflows, Schemes, Issues, Fields, Audit Records, Permissions, Remote Links. - * **JSM**: Service Desks, Request Types, Approvals, SLAs. - * **Jira Software**: Dev Info (PRs/commits), Deployment info. -* **Compliance Checks**: - * Approval gate presence in workflows. - * GitHub link binding for projects. - * Minimal approvals verification. - * Deployment date vs. Approval date correctness. - * Detection of bypasses or compliance drift. - -## Implementation Roadmap - -### Phase 1: Foundation -- [x] Initialize repository and update `go.mod` (module name: `github.com/compliance-framework/plugin-jira`). -- [x] Define `PluginConfig` structure for Jira-specific settings (URL, Auth, Project filters). -- [x] Implement `Configure` method to handle plugin setup. - -### Phase 2: Jira Client & Authentication -- [x] Implement Jira client wrapper supporting Cloud (/v3) and DC (/v2) endpoints. -- [x] Add OAuth2 (2LO) client credentials flow. -- [x] Add API Token / Basic Auth fallback. - -### Phase 3: Data Collection (Collectors) -- [x] **Platform Collector**: - * `GetProjects()`: List and metadata. - * `GetWorkflows()`: Workflow steps and transitions. - * `GetWorkflowSchemes()`: Project-workflow mapping. - * `SearchIssues(jql)`: Find Change Requests. - * `GetChangelog(issueId)`: History of transitions and approvals. -- [x] **JSM Collector**: - * `GetApprovals(issueId)`: Extraction of JSM-native approvals. - * `GetSLAs(issueId)`: Timing signals for approvals. -- [x] **Software Collector**: - * `GetDevInfo(issueId)`: Linked PRs and commits. - * `GetDeployments(issueId)`: Deployment records. - -### Phase 4: Policy Evaluation & Evidence -- [x] Map Jira data to `TrackedJiraData` structure for policy evaluation. -- [x] Implement `EvaluatePolicies` logic using `policyManager`. -- [ ] Generate OPA-compatible inputs from collected Jira metadata. -- [ ] Define standard evidence structure for Change Request compliance. - -### Phase 5: Refinement & Testing -- [ ] Add comprehensive logging. -- [ ] Implement unit tests for data extraction logic. -- [x] Document configuration parameters in `README.md`. - -## Configuration Schema - -```go -type PluginConfig struct { - BaseURL string `mapstructure:"base_url"` - AuthType string `mapstructure:"auth_type"` // "oauth2" or "token" - ClientID string `mapstructure:"client_id"` - ClientSecret string `mapstructure:"client_secret"` - APIToken string `mapstructure:"api_token"` - UserEmail string `mapstructure:"user_email"` - ProjectKeys []string `mapstructure:"project_keys"` - ExcludedWorkflows []string `mapstructure:"excluded_workflows"` - PolicyLabels string `mapstructure:"policy_labels"` -} -```