diff --git a/.cursor/rules/owned-plugins.mdc b/.cursor/rules/owned-plugins.mdc new file mode 100644 index 00000000000..97e67060af3 --- /dev/null +++ b/.cursor/rules/owned-plugins.mdc @@ -0,0 +1,41 @@ +--- +description: Conventions for our team's owned plugins (aireview, codecov, testregistry) in this Apache DevLake fork +globs: backend/plugins/{aireview,codecov,testregistry}/** +alwaysApply: false +--- + +# Owned Plugin Development Rules + +This repo is a fork of Apache DevLake. Our team owns only three plugins: +`aireview`, `codecov`, `testregistry`. Do NOT modify upstream code outside these directories. + +## Plugin Checklist (every change) + +- [ ] All models listed in `GetTablesInfo()` in `impl/impl.go` +- [ ] New models have a migration script in `models/migrationscripts/` AND registered in `register.go:All()` +- [ ] Migration filename: `YYYYMMDD_description.go` +- [ ] Apache 2.0 license header on every new `.go` file +- [ ] No cross-plugin imports (`plugins/X` must not import `plugins/Y`) +- [ ] Secrets use `serializer:encdec` gorm tag, sanitized in API responses + +## Testing + +```bash +cd backend +go test ./plugins//... -v # unit tests +go vet ./plugins//... # vet +golangci-lint run ./plugins//... # lint +``` + +E2E tests (aireview only, needs MySQL with `lake_test`): +```bash +export E2E_DB_URL="mysql://merico:merico@localhost:3306/lake_test?charset=utf8mb4&parseTime=True" +go test ./plugins/aireview/e2e/... -v +``` + +## Common Patterns + +- **Data-source plugin** (codecov, testregistry): Connection → Scope → ScopeConfig → Collector → Extractor → Converter +- **Metric plugin** (aireview): No connection; uses domain-layer data from github/gitlab; configured via Projects +- **Subtask ordering**: Defined by `SubTaskMetas()` in `impl/impl.go` — order matters +- **New migration**: Create file, add struct with `Up()`, register in `All()` diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000000..78cdadb1eac --- /dev/null +++ b/.cursorrules @@ -0,0 +1,696 @@ +# Common AI Assistant Rules + +These rules apply to all repositories using centralized AI rules management. + +--- + +## 1. Golden Rules + +1. **One message = all related operations** + + - Batch reads/writes, shell commands, todos, and agent spawns in a single message. + +2. **No working files in repo root** + + - Root is only for repo metadata and config: `AGENTS.md`, `CLAUDE.md`, `TODO.md`, `README.md`, `LICENSE`, `.gitignore`, dependency files, tooling configs. + - All code, docs, tests, notes, experiments, and scripts go into subfolders. + +3. **Planning lives in `TODO.md` at root** + + - Use `TODO.md` for high-level plans. + - Keep it short and actionable. + - Remove steps once they're done (this file should always reflect the current plan, not history). + +4. **Keep instructions lean** + + - Enough structure to be consistent. + - No walls of text; use lists and checklists. + +5. **Prefer editing over creating** + - Edit existing files rather than creating new ones when possible. + - Only create new files when a clear responsibility or context is missing. + +--- + +## 2. Repository Structure + +### 2.1 Root (allowed files) + +Root directory may contain only: + +- `CLAUDE.md` – AI/human collaboration rules +- `TODO.md` – current plan / task list +- `README.md` – short project overview +- `LICENSE`, `.gitignore`, `.editorconfig` +- Tooling configs and dependency files (language-specific) +- No code, no docs content, no tests, no random notes. + +### 2.2 Common subfolders + +Use these by default (adapt per project, especially for language-specific alternatives): + +- `src/` – production source code +- `tests/` – all tests (unit, integration, etc.) +- `docs/` – internally facing documentation +- `docs/adr/` – Architecture Decision Records (ADRs) +- `docs/research/` – research notes, content libraries, reusable frameworks +- `config/` – configuration files (YAML, JSON, env templates) +- `scripts/` or `tools/` – automation, utility scripts, and small code tools +- `examples/` – example usage code or demo projects + +**Infrastructure projects** (adapt naming as needed): + +- `pulumi/` – Pulumi infra code +- `kubernetes/` – manifests + Kustomize overlays + +**Presentation projects**: + +- `/` + - `speaker-notes.md` + - `slides.pptx` + - Optional `README.md` +- Shared resources under `docs/` + +**Knowledge projects** + +For projects exposing documentation through `mkdoc`, externally facing documentation goes to: + +- `knowledge/` + +--- + +## 3. Documentation & ADRs + +### 3.1 Documentation rules + +- All internal documentation files go under `docs/` or a specific subfolder. +- Avoid creating new root-level `.md` files except: `README.md`, `TODO.md` + +### 3.2 ADRs (Architecture Decision Records) + +- Location: `docs/adr/` +- File name: `NNNN-title-with-hyphens.md` (e.g., `0001-infrastructure-as-code-with-pulumi.md`) +- Each ADR contains: **Status**, **Context**, **Decision**, **Consequences**. +- Maintain `docs/adr/README.md` as an index. + +Use an ADR when: + +- Architecture significantly changes +- Tooling or core tech stack changes +- Security, reliability, or cost decisions with long-term impact + +--- + +## 4. Naming Conventions + +### 4.1 General rules + +- Names should be descriptive, not cute +- Stable over time (avoid date prefixes; use ADRs for history) +- See language-specific profiles for file naming conventions + +### 4.2 Internal Documentation files + +- Project overviews: `docs/overview.md`, `docs/architecture.md` +- Guides: `docs/.md` +- Research: `docs/research/.md` + +--- + +## 5. AI Assistant & Human Collaboration + +### 5.1 When AI changes code + +- Read relevant existing files before editing. +- Keep imports at top, respect code patterns. +- When adding features: + - Consider security and consistency first. + - Update docs where relevant. + - Add or update tests. +- When fixing bugs: + - Understand root cause first. + - Add regression tests. + +### 5.2 When AI creates docs + +- Put them under `docs/` or `knowledge` depending on audience – not root. +- For presentations: slides are visual anchors, speaker notes have full narrative. + +### 5.3 When humans work + +- Keep `TODO.md` current. +- Use ADRs for major decisions. +- Keep repo structure clean. +- Prefer small, focused commits with clear messages. + +--- + +## 6. Pre-Action Checklists + +### 6.1 Before creating a new file + +- [ ] Does an existing file already cover this responsibility? +- [ ] Am I putting it in the right folder (`src/`, `tests/`, `docs/`, etc.)? +- [ ] Is the name clear and consistent with conventions? +- [ ] Is this going to live longer than one session? + +### 6.2 Before committing + +- [ ] No working files in root (only allowed meta files). +- [ ] `TODO.md` is up to date (completed steps removed). +- [ ] New decisions captured in `docs/adr/` if needed. +- [ ] Tests updated or added where appropriate. +- [ ] Repo structure still matches conventions. + +--- + +## 7. JIRA Integration (DPROD Project) + +### 7.1 Issue Types + +| Type | When to Use | +| ------------ | ----------------------------------------------- | +| **Epic** | Large feature or initiative | +| **Story** | User-facing functionality | +| **Task** | Technical/implementation work | +| **Spike** | Research, discovery, PoC with uncertain outcome | +| **Sub-task** | Breakdown of Story/Task | + +### 7.2 Required Fields + +| Field | Field ID | Required For | Example | +| ---------------- | ---------------------- | ------------------ | ----------------------- | +| Epic Name | `customfield_12311141` | Epic | `"User Authentication"` | +| Epic Link | `customfield_12311140` | Story, Task, Spike | `"DPROD-876"` | +| Git Pull Request | `customfield_12310220` | Any (optional) | MR/PR URL | + +### 7.3 Story Points + +Story points cannot be set during issue creation. Update them after the issue is created: + +```python +jira_update_issue( + issue_key="DPROD-123", + fields={}, + additional_fields={ + "customfield_12310243": 3 # Story Points field + } +) +``` + +### 7.4 Labels + +Always include `ai-generated` label when creating issues via AI: + +```python +additional_fields={"labels": ["ai-generated"]} +``` + +### 7.5 Priority Mapping + +| Priority | When to Use | +| -------- | ------------------------------------------- | +| Blocker | Blocking release or other work | +| Critical | Production issues, security vulnerabilities | +| Major | High-value features, P1 items | +| Normal | Standard work, P2 items | +| Minor | Nice-to-have, P3 items | + +### 7.6 JIRA Markup + +| Markdown | JIRA Markup | +| ------------- | ------------- | +| `**bold**` | `*bold*` | +| `# Heading` | `h1. Heading` | +| `## Heading` | `h2. Heading` | +| `- bullet` | `* bullet` | +| `[text](url)` | `[text\|url]` | + +--- + +## 8. Security + +- Never commit secrets or credentials +- Validate user input at system boundaries +- Follow principle of least privilege +- Log errors with context but without sensitive data + +--- + +## 9. Commit Style + +Use conventional commits format: + +``` +(): + +[optional body] + +[optional footer] +``` + +**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` + +When AI assists with commits, include Co-Authored-By trailer: + +``` +feat: add user authentication + +Implement JWT-based authentication for API endpoints. + +Co-Authored-By: +``` + +# Go Profile + +Rules for Go development. + +--- + +## 10. Code Organization + +- Use `internal/` for private packages; add `pkg/` only if you intentionally support external imports as a stable public API. +- One package per directory; split across multiple files freely when it improves clarity (organize by responsibility, avoid "god files"). +- Keep `main.go` minimal – delegate to internal packages. + +``` +project/ +├── cmd/ +│ └── myapp/ +│ └── main.go +├── internal/ +│ ├── config/ +│ ├── server/ +│ └── storage/ +├── pkg/ # Only if public API needed +├── go.mod +└── go.sum +``` + +--- + +## 11. Naming Conventions + +- Use short, concise variable names in small scopes (`i`, `err`, `ctx`). +- Use descriptive names for package-level and exported identifiers. +- Avoid stuttering: `config.Config` not `config.ConfigStruct`. +- Interfaces: single-method uses `-er` suffix (`Reader`, `Writer`, `Closer`). + +```go +// Good +type Reader interface { + Read(p []byte) (n int, err error) +} + +// Avoid +type IReader interface { + Read(p []byte) (n int, err error) +} +``` + +--- + +## 12. Error Handling + +- Return errors, don't panic (except in `init()` for truly fatal issues). +- Wrap errors with context: `fmt.Errorf("doing X: %w", err)`. +- Use custom error types for errors that need programmatic handling. +- Check errors immediately after the call. +- Avoid logging inside libraries/internal packages by default; return errors and let `main` decide how to report them. + +```go +// Good +result, err := doSomething() +if err != nil { + return fmt.Errorf("processing item %s: %w", id, err) +} + +// Avoid +result, err := doSomething() +if err != nil { + log.Printf("error: %v", err) + return err +} +``` + +--- + +## 13. Testing + +- Table-driven tests for multiple cases. +- Prefer stdlib `testing`; use `testify` only when it materially improves clarity. +- Test file next to source: `foo.go` → `foo_test.go`. +- Use `testdata/` for fixtures. +- Use `t.TempDir()` for file-based tests. + +```go +func TestProcess(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {"empty input", "", "", true}, + {"valid input", "hello", "HELLO", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Process(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("Process() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Process() = %v, want %v", got, tt.want) + } + }) + } +} +``` + +--- + +## 14. Formatting & Linting + +- `gofmt` / `goimports` on save. +- Run `golangci-lint` with default config. +- No `//nolint` without justification comment. + +### golangci-lint Rules + +**Octal literals** – Use `0o` prefix: +```go +os.WriteFile(path, data, 0o644) // Good +os.WriteFile(path, data, 0644) // Bad +``` + +**Import ordering** – Three groups separated by blank lines: stdlib, third-party, local. Fix with `goimports -local -w .` +```go +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "myproject/internal/check" +) +``` + +**Unused parameters** – Rename to `_`: +```go +func handler(_ *cobra.Command, _ []string) error { return doWork() } +``` + +**Slice pre-allocation** – Pre-allocate when size is known: +```go +results := make([]string, 0, len(items)) +``` + +**Explicit error handling** – Check or explicitly ignore with `_`: +```go +if err := tags.ForEach(fn); err != nil { return err } // Check +_ = tags.ForEach(fn) // Explicit ignore +defer func() { _ = resource.Cleanup() }() // Cleanup in defer +``` + +**No os.Exit after defer** – Separate execution from exit handling so defers run. + +**String comparisons** – Use `strings.EqualFold` for case-insensitive: +```go +strings.EqualFold(tag, "v"+version) // Good +strings.ToLower(tag) == "v"+version // Bad +``` + +**Empty string checks** – Use direct comparison: +```go +if s != "" { } // Good +if len(s) > 0 { } // Bad +``` + +```bash +gofmt -w . +golangci-lint run ./... +``` + +--- + +## 15. Dependencies + +- Prefer stdlib over third-party when reasonable. +- Vendor dependencies (`go mod vendor`) only if you need offline/hermetic builds or policy requires it. +- Minimize dependency count. + +--- + +## 16. Concurrency + +- Prefer mutexes for shared state; use channels for coordination/signaling and ownership handoff. +- Use `context.Context` for cancellation/timeouts. +- Document goroutine ownership and lifecycle. + +```go +// Good - context for cancellation +func fetchData(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + // ... +} +``` + +--- + +## 17. I/O & Determinism + +- Prefer `io/fs` (`fs.FS`, `os.DirFS`) abstractions to keep file I/O testable. +- Deterministic output: stable ordering, stable formatting, consistent newlines across OSes. +- Use `\n` (LF) for all generated files. + +--- + +## 18. Documentation + +- Document exported functions, types, and packages. +- Start comments with the name being documented. +- Keep comments concise but informative. + +```go +// Config holds the application configuration. +// It is loaded from environment variables or a config file. +type Config struct { + // Port is the HTTP server port. + Port int + // Debug enables debug logging. + Debug bool +} + +// NewConfig creates a Config from environment variables. +func NewConfig() (*Config, error) { + // ... +} +``` + +--- + +## 19. Common Commands + +```bash +# Initialize module +go mod init example.com/myapp + +# Build +go build ./... + +# Test +go test ./... +go test -v -race -coverprofile=coverage.out ./... + +# Lint +golangci-lint run ./... + +# Format +gofmt -w . +goimports -w . +``` + +# Cursor-Specific Rules + +Rules specific to Cursor IDE assistant. + +--- + +## 20. Output Format + +- Keep responses concise and actionable. +- Use code blocks with appropriate language tags. +- Prefer editing existing files over creating new ones. +- Use markdown for formatting explanations. + +--- + +## 21. Code Generation + +- Generate code that follows project conventions. +- Include appropriate imports at the top of the file. +- Add necessary error handling. +- Respect existing code patterns and style. + +--- + +## 22. Refactoring + +- Preserve existing behavior unless explicitly asked to change. +- Maintain test coverage. +- Update related documentation. +- Make atomic, focused changes. + +--- + +## 23. File Operations + +- Read relevant files before making edits. +- Make changes in context of the full file. +- Preserve formatting and indentation style. + +# DevLake Local Development (AI Reference) + +Quick reference for AI agents working on this project. + +## Environment + +- **Container runtime**: podman (not docker) +- **Compose file**: `docker-compose-dev.yml` +- **Database**: MySQL 8, credentials `merico:merico`, database `lake` +- **Test database**: `lake_test` (auto-created via `scripts/mysql-init.sql`) +- **MySQL root**: `root:admin` (for admin operations) +- **Go version**: 1.21+ + +## Service URLs + +| Service | URL | +|---------|-----| +| Config UI | http://localhost:4000 | +| DevLake API | http://localhost:8080 | +| Grafana | http://localhost:4000/grafana/ | +| MySQL | localhost:3306 | + +## Custom CA (CEE GitLab) + +For internal services using Red Hat CA: + +```bash +# Export Red Hat IT Root CA (macOS) +security find-certificate -a -p -c "Red Hat IT Root CA" /Library/Keychains/System.keychain > ./custom-ca.crt + +# Set in .env +CA_CERT_FILE=./custom-ca.crt + +# Start with custom CA +podman compose -f docker-compose-dev.yml -f docker-compose-custom-ca.yml up -d +``` + +## Common Commands + +```bash +# Start all services +podman compose -f docker-compose-dev.yml up -d + +# Start just MySQL (for local Go dev) +podman compose -f docker-compose-dev.yml up -d mysql + +# Stop services +podman compose -f docker-compose-dev.yml down + +# Rebuild and restart devlake container +podman compose -f docker-compose-dev.yml up -d --build devlake + +# View logs +podman compose -f docker-compose-dev.yml logs -f devlake +``` + +## Building (from backend/) + +```bash +cd backend +make go-dep # Install dependencies +make build # Build everything (plugins + server) +make build-plugin # Build plugins only +make run # Run server +make dev # Build and run +``` + +## Testing + +```bash +cd backend + +# All tests (unit + e2e) +make test + +# Unit tests only +make unit-test + +# E2E tests (requires MySQL running with lake_test) +export E2E_DB_URL="mysql://merico:merico@localhost:3306/lake_test?charset=utf8mb4&parseTime=True" +make e2e-test + +# Specific plugin tests +go test ./plugins/aireview/... -v + +# Lint and format +make lint +make fmt +``` + +## Database + +```bash +# Connect to MySQL +podman compose -f docker-compose-dev.yml exec mysql mysql -umerico -pmerico lake + +# Check migrations +podman compose -f docker-compose-dev.yml exec mysql mysql -umerico -pmerico lake -e \ + "SELECT * FROM _devlake_migration_history ORDER BY created_at DESC LIMIT 10;" + +# Trigger migration after schema changes +curl -s http://localhost:4000/api/proceed-db-migration +``` + +## Plugin Verification + +```bash +# Check plugin is loaded +curl -s http://localhost:4000/api/plugins | jq '.[] | select(.plugin == "aireview")' + +# Check aireview tables +podman compose -f docker-compose-dev.yml exec mysql mysql -umerico -pmerico lake -e \ + "SHOW TABLES LIKE '_tool_aireview%';" +``` + +## Before Running Commands + +1. Ensure podman machine is running: `podman machine start` +2. Check if services are already running: `podman ps` +3. For E2E tests, MySQL must be running with `lake_test` database + +## If E2E tests fail with "Access denied for lake_test" + +Existing MySQL volumes may not have `lake_test`. Fix with: + +```bash +podman compose -f docker-compose-dev.yml exec mysql mysql -uroot -padmin -e " + CREATE DATABASE IF NOT EXISTS lake_test; + GRANT ALL PRIVILEGES ON lake_test.* TO 'merico'@'%'; + FLUSH PRIVILEGES; +" +``` + +## Project Conventions + +- Plugin code: `backend/plugins//` +- Tests next to source: `foo.go` → `foo_test.go` +- E2E test fixtures: `e2e/raw_tables/*.csv` +- Migrations: `models/migrationscripts/` diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 4bf165303bd..0e1e7eeb415 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -37,6 +37,7 @@ services: MYSQL_PASSWORD: merico TZ: UTC command: --character-set-server=utf8mb4 --collation-server=utf8mb4_bin --skip-log-bin + --sql-mode="ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION" networks: - default grafana: @@ -48,6 +49,9 @@ services: environment: GF_SERVER_ROOT_URL: "http://localhost:4000/grafana" GF_USERS_DEFAULT_THEME: "light" + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin" + GF_AUTH_DISABLE_LOGIN_FORM: "true" MYSQL_URL: mysql:3306 MYSQL_DATABASE: lake MYSQL_USER: merico @@ -61,4 +65,4 @@ services: volumes: mysql-storage: - grafana-storage: \ No newline at end of file + grafana-storage: diff --git a/.fullsend/config.yaml b/.fullsend/config.yaml new file mode 100644 index 00000000000..5ad1358c36f --- /dev/null +++ b/.fullsend/config.yaml @@ -0,0 +1,21 @@ +# fullsend per-repo configuration +# https://github.com/fullsend-ai/fullsend +# +# This file configures fullsend for per-repo installation mode. +# See https://fullsend.sh/docs/guides/infrastructure/layered-config-reference +version: "1" +roles: + - fullsend + - triage + - coder + - fix + - review + - prioritize +allowed_remote_resources: + - https://raw.githubusercontent.com/fullsend-ai/fullsend/ + - https://raw.githubusercontent.com/fullsend-ai/agents/ +create_issues: + allow_targets: + repos: + - konflux-ci/devlake + - fullsend-ai/fullsend diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 36d11bd7c63..7b13ed88402 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -25,7 +25,7 @@ body: value: > Please make sure what you are reporting is indeed a bug with reproducible steps, if you want to ask questions or share ideas, you can head to our - [Discussions](https://github.com/apache/incubator-devlake/discussions) tab, you can also + [Discussions](https://github.com/apache/devlake/discussions) tab, you can also [join our slack](https://join.slack.com/t/devlake-io/shared_invite/zt-18uayb6ut-cHOjiYcBwERQ8VVPZ9cQQw) and send your question to channel `#questions` @@ -35,11 +35,11 @@ body: attributes: label: Search before asking description: > - Please make sure to search in the [issues](https://github.com/apache/incubator-devlake/issues?q=is%3Aissue) + Please make sure to search in the [issues](https://github.com/apache/devlake/issues?q=is%3Aissue) first to see whether the same issue was reported already. options: - label: > - I had searched in the [issues](https://github.com/apache/incubator-devlake/issues?q=is%3Aissue) and found + I had searched in the [issues](https://github.com/apache/devlake/issues?q=is%3Aissue) and found no similar issues. required: true @@ -63,7 +63,7 @@ body: attributes: label: How to reproduce description: > - If you are not able to provide a reproducible case, please open a [Discussion](https://github.com/apache/incubator-devlake/discussions) instead. + If you are not able to provide a reproducible case, please open a [Discussion](https://github.com/apache/devlake/discussions) instead. placeholder: > Please provide detailed steps to reproduce the problem. validations: @@ -105,4 +105,3 @@ body: I agree to follow this project's [Code of Conduct](https://www.apache.org/foundation/policies/conduct) required: true - diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index c139df1ecdf..46d6776bd8f 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -17,7 +17,7 @@ name: Feature Request description: Suggest an idea for this project title: "[Feature][Module Name] Feature title" -labels: [ "type/feature-request"] +labels: ["type/feature-request"] body: - type: markdown attributes: @@ -28,11 +28,11 @@ body: attributes: label: Search before asking description: > - Please make sure to search in the [issues](https://github.com/apache/incubator-devlake/issues?q=is%3Aissue) first + Please make sure to search in the [issues](https://github.com/apache/devlake/issues?q=is%3Aissue) first to see whether the same feature was requested already. options: - label: > - I had searched in the [issues](https://github.com/apache/incubator-devlake/issues?q=is%3Aissue) and found no + I had searched in the [issues](https://github.com/apache/devlake/issues?q=is%3Aissue) and found no similar feature requirement. required: true diff --git a/.github/workflows/codespell.yml.action-blocked-by-asf b/.github/workflows/codespell.yml.action-blocked-by-asf index be468623373..5bc5fe3760c 100644 --- a/.github/workflows/codespell.yml.action-blocked-by-asf +++ b/.github/workflows/codespell.yml.action-blocked-by-asf @@ -34,6 +34,6 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Codespell - uses: codespell-project/actions-codespell@v2 + uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2 diff --git a/.github/workflows/fullsend.yaml b/.github/workflows/fullsend.yaml new file mode 100644 index 00000000000..9a10f59f3bd --- /dev/null +++ b/.github/workflows/fullsend.yaml @@ -0,0 +1,122 @@ +# This file is managed by fullsend. Do not edit it directly. +# Upstream: https://github.com/fullsend-ai/fullsend/blob/main/internal/scaffold/fullsend-repo/.github/workflows/fullsend.yaml +--- +# fullsend shim workflow (per-repo installation mode) +# Routes events to agent workflows via reusable-dispatch.yml. +# All agent execution happens in this repo's context — no external +# config repo is needed. +# +# Security: pull_request_target runs the BASE branch version of this workflow, +# preventing PRs from modifying it to exfiltrate credentials. +# This shim never checks out PR code, so it is not vulnerable to "pwn request" +# attacks. +# +# Routing: this shim forwards the raw event context to reusable-dispatch.yml, +# which determines the stage and runs the agent inline (ADR 62). +# Adding a new stage requires only a job in reusable-dispatch.yml — zero changes to this repo. +# +# Concurrency: per-role cancel-in-progress groups live in reusable-dispatch.yml +# stage jobs with -agent- suffix. Roles operate independently (#2452). +name: fullsend + +on: + issues: + types: [opened, edited, labeled] + issue_comment: + types: [created] + pull_request_target: + types: [opened, synchronize, ready_for_review, closed, labeled, unlabeled] + pull_request_review: + types: [submitted] + +permissions: {} + +jobs: + dispatch: + if: >- + (github.event_name != 'pull_request_target' && github.event_name != 'pull_request_review' + || github.event.pull_request.head.ref != 'fullsend/scaffold-install') + && (github.event_name != 'issue_comment' + || (startsWith(github.event.comment.body, '/fs-') + && github.event.comment.user.type != 'Bot')) + permissions: + actions: write + id-token: write + contents: write + issues: write + packages: read + pull-requests: write + uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@d5f36921ac754705619f38c637ef692873809fbc # v0.43.0 + with: + event_action: ${{ github.event.action }} + install_mode: per-repo + mint_url: ${{ vars.FULLSEND_MINT_URL }} + gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + project_number: ${{ vars.FULLSEND_PROJECT_NUMBER }} + runner_image: ubuntu-24.04 + secrets: + FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} + OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} + + stop-fix: + # Job-level if: is intentionally coarse — it only screens for the + # /fs-fix-stop command on a PR from a non-bot. The authoritative + # authorization decision (collaborator permission API + PR-author escape + # hatch) is made in the step below, so a maintainer whose author_association + # is not MEMBER (e.g. private org membership) is not filtered out (ADR 0054). + if: >- + github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.comment.user.type != 'Bot' + && github.event.comment.body == '/fs-fix-stop' + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Add fullsend-no-fix label and notify + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + COMMENT_USER_LOGIN: ${{ github.event.comment.user.login }} + ISSUE_USER_LOGIN: ${{ github.event.issue.user.login }} + run: | + set -euo pipefail + # ADR 0054: authorize via the collaborator permission API + # (admin|maintain|write), not author_association — the latter grants + # contributor status to anyone with a single merged PR (issue #5421). + # Mirrors has_repo_permission() in dispatch.yml; keep the two in sync. + # The PR author may always stop the fix agent on their own PR. + authorized=false + if [[ -n "$COMMENT_USER_LOGIN" && "$COMMENT_USER_LOGIN" == "$ISSUE_USER_LOGIN" ]]; then + authorized=true + else + if api_err=$(mktemp); then + if role=$(gh api "repos/$REPO/collaborators/$COMMENT_USER_LOGIN/permission" \ + --jq '.role_name' 2>"$api_err"); then + case "$role" in + admin|maintain|write) authorized=true ;; + esac + else + echo "::warning::Permission API call failed for $COMMENT_USER_LOGIN: $(cat "$api_err")" + fi + rm -f "$api_err" + else + echo "::warning::Failed to create temp file for permission check of $COMMENT_USER_LOGIN" + fi + fi + if [[ "$authorized" != "true" ]]; then + echo "::notice::User $COMMENT_USER_LOGIN is not authorized to stop the fix agent (requires write access or PR authorship)" + exit 0 + fi + gh label create "fullsend-no-fix" --repo "$REPO" \ + --description "Skip bot-triggered fix agent runs" --color "FBCA04" \ + --force 2>/dev/null || true + gh pr edit "$PR_NUMBER" --repo "$REPO" \ + --add-label "fullsend-no-fix" + gh pr comment "$PR_NUMBER" --repo "$REPO" \ + --body "Fix agent disabled for this PR. Remove the \`fullsend-no-fix\` label or use \`/fs-fix\` to re-engage." diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 00000000000..f4477e5c556 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,55 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +name: gitleaks +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + gitleaks: + name: secret detection + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + + - name: Install Gitleaks + run: | + set -o errexit -o nounset -o pipefail + version="8.30.1" + archive="gitleaks_${version}_linux_x64.tar.gz" + curl -sSfLO "https://github.com/gitleaks/gitleaks/releases/download/v${version}/${archive}" + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${version}/gitleaks_${version}_checksums.txt" \ + | grep "$archive" | sha256sum --check --strict + tar -xzf "$archive" + sudo mv gitleaks /usr/local/bin/ + rm -f "$archive" + + - name: Run Gitleaks + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: >- + gitleaks detect + --source . + --config .gitleaks.toml + --redact + --log-opts="${BASE_SHA}...HEAD" diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml new file mode 100644 index 00000000000..cc7bb2cdeb6 --- /dev/null +++ b/.github/workflows/hadolint.yml @@ -0,0 +1,40 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +name: hadolint +on: + pull_request: + branches: [main] + paths: + - '**/Dockerfile*' + - '.hadolint.yaml' + +permissions: + contents: read + +jobs: + hadolint: + name: Dockerfile lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Run Hadolint + uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0 + with: + recursive: true + config: .hadolint.yaml diff --git a/.github/workflows/prioritize.yml b/.github/workflows/prioritize.yml new file mode 100644 index 00000000000..3e89e64a5ce --- /dev/null +++ b/.github/workflows/prioritize.yml @@ -0,0 +1,50 @@ +# This file is managed by fullsend. Do not edit it directly. +# Upstream: https://github.com/fullsend-ai/fullsend/blob/main/internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml +--- +# fullsend-stage: prioritize +name: Prioritize + +permissions: + actions: write + contents: read + id-token: write + issues: write + +on: + workflow_dispatch: + inputs: + event_type: + required: true + type: string + source_repo: + required: true + type: string + event_payload: + required: true + type: string + project_number: + description: GitHub Projects V2 project number for RICE scoring + required: false + type: string + +concurrency: + group: fullsend-prioritize-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }} + cancel-in-progress: true + +jobs: + prioritize: + uses: fullsend-ai/fullsend/.github/workflows/reusable-prioritize.yml@d5f36921ac754705619f38c637ef692873809fbc # v0.43.0 + with: + event_type: ${{ inputs.event_type }} + source_repo: ${{ inputs.source_repo }} + event_payload: ${{ inputs.event_payload }} + mint_url: ${{ vars.FULLSEND_MINT_URL }} + gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + project_number: ${{ inputs.project_number || vars.FULLSEND_PROJECT_NUMBER }} + install_mode: per-repo + runner_image: ubuntu-24.04 + secrets: + FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} + OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} + OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} diff --git a/.github/workflows/test-e2e-coverage.yml b/.github/workflows/test-e2e-coverage.yml new file mode 100644 index 00000000000..41f6ccb4fa2 --- /dev/null +++ b/.github/workflows/test-e2e-coverage.yml @@ -0,0 +1,163 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +name: test-e2e-coverage + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + e2e-coverage: + runs-on: ubuntu-24.04-arm + permissions: + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Create .env file + run: | + cp env.example .env + sed -i 's|127.0.0.1:3306|mysql:3306|g' .env + + - name: Start services with coverage + run: | + docker compose -f docker-compose-dev.yml -f docker-compose-e2e-coverage.yml up -d --build + + - name: Wait for API to be ready + run: | + echo "Waiting for DevLake API..." + for i in $(seq 1 120); do + if curl -s http://localhost:8080/ping > /dev/null 2>&1; then + echo "API ready after $((i*2))s" + break + fi + sleep 2 + done + curl -sf http://localhost:8080/ping || (echo "API failed to start"; docker compose -f docker-compose-dev.yml -f docker-compose-e2e-coverage.yml logs devlake mysql; exit 1) + + - name: Trigger and wait for DB migration + run: | + curl -s http://localhost:8080/proceed-db-migration || true + echo "Waiting for migration to complete..." + migration_done=false + for i in $(seq 1 60); do + code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/blueprints) + if [ "$code" = "200" ]; then + echo "Migration complete after $((i*10))s" + migration_done=true + break + fi + sleep 10 + done + if [ "$migration_done" != "true" ]; then + echo "Migration did not complete (last HTTP $code)" + docker compose -f docker-compose-dev.yml -f docker-compose-e2e-coverage.yml logs devlake + exit 1 + fi + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '18' + + - name: Wait for Config-UI to be ready + run: | + echo "Waiting for Config-UI..." + for i in $(seq 1 60); do + if curl -s http://localhost:4000 > /dev/null 2>&1; then + echo "Config-UI ready after $((i*2))s" + break + fi + sleep 2 + done + curl -sf http://localhost:4000 || (echo "Config-UI failed to start"; docker compose -f docker-compose-dev.yml -f docker-compose-e2e-coverage.yml logs config-ui; exit 1) + + - name: Install e2e dependencies + run: | + cd e2e + npm ci + npx playwright install chromium --with-deps + + - name: Run basic-flow Playwright e2e tests + run: | + cd e2e + npx playwright test basic-flow.spec.ts + + - name: Verify Codecov e2e prerequisites + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + run: | + if [ -z "$CODECOV_TOKEN" ]; then + echo "::error::CODECOV_TOKEN GitHub Actions secret is required for codecov e2e tests" + exit 1 + fi + + - name: Run codecov-flow Playwright e2e tests + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + CODECOV_ORG: konflux-ci + CODECOV_SERVICE: github + CODECOV_ENDPOINT: https://api.codecov.io + CODECOV_REPO: konflux-ci/coverport + run: | + cd e2e + npx playwright test codecov-flow.spec.ts + + - name: Collect Go e2e coverage via coverport + if: always() + run: | + mkdir -p e2e-coverage/raw + echo "Fetching coverage from coverport server..." + RESP=$(curl -sf http://localhost:9095/coverage) || { echo "::warning::Coverage endpoint unreachable"; exit 0; } + + echo "$RESP" | jq -r '.meta_filename' > /tmp/meta_name + echo "$RESP" | jq -r '.meta_data' | base64 -d > "e2e-coverage/raw/$(cat /tmp/meta_name)" + echo "$RESP" | jq -r '.counters_filename' > /tmp/counter_name + echo "$RESP" | jq -r '.counters_data' | base64 -d > "e2e-coverage/raw/$(cat /tmp/counter_name)" + + echo "Raw coverage files:" + ls -la e2e-coverage/raw/ + + go tool covdata textfmt -i=./e2e-coverage/raw -o=./e2e-coverage/go-coverage.out + echo "Go e2e coverage generated:" + go tool cover -func=./e2e-coverage/go-coverage.out | tail -1 + + - name: Upload Go e2e coverage to Codecov + if: always() + uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 + with: + use_oidc: true + flags: e2e-go + files: e2e-coverage/go-coverage.out + fail_ci_if_error: false + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: playwright-report + path: e2e/playwright-report/ + + - name: Stop all services + if: always() + run: docker compose -f docker-compose-dev.yml -f docker-compose-e2e-coverage.yml down diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 21dfc96530d..d80651372a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -79,3 +79,51 @@ jobs: cp env.example .env cd backend make unit-test + - name: Custom plugins coverage + if: always() + run: | + cd backend + bash scripts/coverage-custom-plugins.sh + - name: Upload coverage artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: unit-coverage + if-no-files-found: warn + retention-days: 1 + path: | + backend/coverage-custom-plugins.out + backend/python/coverage/ + + # Fork-only: upload from the Ubuntu runner so codecov-action has gpg. + upload-coverage: + needs: test + if: always() && needs.test.result != 'cancelled' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v7 + - name: Download coverage artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: unit-coverage + path: backend + if-no-artifact-found: warn + - name: Upload Go coverage to Codecov + uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 + with: + use_oidc: true + flags: unit-tests-go + files: backend/coverage-custom-plugins.out + fail_ci_if_error: false + - name: Upload Python coverage to Codecov + if: always() + uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5 + with: + use_oidc: true + flags: unit-tests-python + directory: backend/python/coverage + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 0877343e6bc..bce00cf4a0c 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,22 @@ venv /backend/plugins/gitextractor/parser/output* /backend/plugins/gitextractor/parser/demo_repo/ -__debug_bin* \ No newline at end of file +__debug_bin* + +# AI assistant runtime data +.claude-flow/ +.hive-mind/ + +# Sensitive files +cookies.txt +custom-ca.crt + +# agentready assessment reports +.agentready/ + +# E2E coverage data +e2e-coverage/ +e2e/coverage/ +e2e/playwright-report/ +e2e/test-results/ +backend/coverage.out \ No newline at end of file diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000000..16a35bb6e87 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,34 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +title = "devlake gitleaks config" + +[extend] +useDefault = true + +[allowlist] +description = "Global allowlist" +paths = [ + '''^vendor/''', + '''^backend/vendor/''', + # Test fixtures and mock data + '''^backend/.*/testdata/''', + '''^backend/.*/e2e/''', + # Generated mocks + '''^backend/mocks/''', + # Grafana dashboard JSON (contains example data, not secrets) + # Provisioning configs under grafana/provisioning/ are NOT excluded + '''^grafana/dashboards/''', +] diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 00000000000..51d494897a4 --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,56 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +failure-threshold: warning + +trustedRegistries: + - docker.io + - mcr.microsoft.com + - golang + - node + - alpine + - grafana + +ignored: + # DL3003: Use WORKDIR to switch to a directory — baselined + - DL3003 + # DL3007: Using latest is prone to errors — baselined for dev containers + - DL3007 + # DL3008: Pin versions in apt-get — versions managed by base image + - DL3008 + # DL3009: Delete apt-get lists — baselined + - DL3009 + # DL3013: Pin pip versions — managed externally + - DL3013 + # DL3015: Avoid additional packages without --no-install-recommends — baselined + - DL3015 + # DL3018: Pin versions in apk — versions managed by base image + - DL3018 + # DL3025: Use JSON notation for CMD/ENTRYPOINT — baselined + - DL3025 + # DL3027: Do not use apt, use apt-get — baselined + - DL3027 + # DL3029: Do not use --platform with FROM — intentional for multi-arch builds + - DL3029 + # DL3042: Avoid pip cache directory — baselined + - DL3042 + # DL3047: Avoid wget without progress bar — baselined + - DL3047 + # DL3059: Multiple consecutive RUN — intentional for layer caching + - DL3059 + # DL4001: Either use Wget or Curl — multiple download methods used + - DL4001 + # DL4006: Set SHELL -o pipefail — baselined for existing Dockerfiles + - DL4006 diff --git a/.licenserc.yaml b/.licenserc.yaml index 1ccd948b738..2bdda0e8dac 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -45,6 +45,7 @@ header: - "go.sum" - "requirements.txt" - "**/testdata/*.txt" + - "backend/plugins/aisdlc/tasks/testdata/**/*.md" - "**/empty" - "**/*.conf" - "deployment/helm/templates/_helpers.tpl" @@ -53,5 +54,21 @@ header: - "config-ui/yarn.lock" - ".github/ISSUE_TEMPLATE/*.md" - "**/.tool-versions" + - ".cursor/**" + - ".cursorrules" + - ".pr_agent.toml" + - ".gitleaks.toml" + - ".github/workflows/fullsend.yaml" + - ".github/workflows/prioritize.yml" + - ".fullsend/**" + - "CLAUDE.md" + - "CODEOWNERS" + - ".tekton/**" + - "**/AGENTS.md" + - "**/SKILL.md" + - "backend/plugins/*/README.md" + - "backend/plugins/*/docs/*.md" + - "docker-compose-custom-ca.yml" + - "docs/*.md" comment: on-failure diff --git a/.pr_agent.toml b/.pr_agent.toml new file mode 100644 index 00000000000..b37b0f1130e --- /dev/null +++ b/.pr_agent.toml @@ -0,0 +1,15 @@ +[github_app] +pr_commands = [ + "/agentic_describe", + "/agentic_review" +] +handle_push_trigger = true +push_commands = [ + "/agentic_review" +] + +[review_agent] +comments_location_policy = "both" +inline_comments_severity_threshold = 3 +issues_user_guidelines = "" +compliance_user_guidelines = "" diff --git a/.tekton/devlake-backend-d41aa-pull-request.yaml b/.tekton/devlake-backend-d41aa-pull-request.yaml new file mode 100644 index 00000000000..53a349d3c6c --- /dev/null +++ b/.tekton/devlake-backend-d41aa-pull-request.yaml @@ -0,0 +1,626 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + annotations: + build.appstudio.openshift.io/repo: https://github.com/konflux-ci/devlake?rev={{revision}} + build.appstudio.redhat.com/commit_sha: '{{revision}}' + build.appstudio.redhat.com/pull_request_number: '{{pull_request_number}}' + build.appstudio.redhat.com/target_branch: '{{target_branch}}' + pipelinesascode.tekton.dev/cancel-in-progress: "true" + pipelinesascode.tekton.dev/max-keep-runs: "3" + pipelinesascode.tekton.dev/on-cel-expression: event == "pull_request" && target_branch == "main" + creationTimestamp: + labels: + appstudio.openshift.io/application: devlake + appstudio.openshift.io/component: devlake-backend-d41aa + pipelines.appstudio.openshift.io/type: build + name: devlake-backend-d41aa-on-pull-request + namespace: rh-konflux-devprod-tenant +spec: + params: + - name: git-url + value: '{{source_url}}' + - name: revision + value: '{{revision}}' + - name: output-image + value: quay.io/konflux-ci/konflux-devprod/devlake-backend:on-pr-{{revision}} + - name: image-expires-after + value: 5d + - name: dockerfile + value: backend/Containerfile + - name: path-context + value: backend + pipelineSpec: + description: | + This pipeline is ideal for building container images from a Containerfile while maintaining trust after pipeline customization. + + _Uses `buildah` to create a container image leveraging [trusted artifacts](https://konflux-ci.dev/architecture/ADR/0036-trusted-artifacts.html). It also optionally creates a source image and runs some build-time tests. Information is shared between tasks using OCI artifacts instead of PVCs. EC will pass the [`trusted_task.trusted`](https://conforma.dev/docs/policy/packages/release_trusted_task.html#trusted_task__trusted) policy as long as all data used to build the artifact is generated from trusted tasks. + This pipeline is pushed as a Tekton bundle to [quay.io](https://quay.io/repository/konflux-ci/tekton-catalog/pipeline-docker-build-oci-ta?tab=tags)_ + params: + - description: Source Repository URL + name: git-url + type: string + - default: "" + description: Revision of the Source Repository + name: revision + type: string + - description: Fully Qualified Output Image + name: output-image + type: string + - default: . + description: Path to the source code of an application's component from where to build image. + name: path-context + type: string + - default: Dockerfile + description: Path to the Dockerfile inside the context specified by parameter path-context + name: dockerfile + type: string + - default: "false" + description: Force rebuild image + name: rebuild + type: string + - default: "false" + description: Skip checks against built image + name: skip-checks + type: string + - default: "false" + description: Execute the build with network isolation + name: hermetic + type: string + - default: "" + description: Build dependencies to be prefetched + name: prefetch-input + type: string + - default: "" + description: Image tag expiration time, time values could be something like 1h, 2d, 3w for hours, days, and weeks, respectively. + name: image-expires-after + type: string + - default: "false" + description: Build a source image. + name: build-source-image + type: string + - default: "false" + description: Add built image into an OCI image index + name: build-image-index + type: string + - default: docker + description: The format for the resulting image's mediaType. Valid values are oci or docker. + name: buildah-format + type: string + - default: + - "BUILDPLATFORM=linux/amd64" + description: Array of --build-arg values ("arg=value" strings) for buildah + name: build-args + type: array + - default: "" + description: Path to a file with build arguments for buildah, see https://www.mankier.com/1/buildah-build#--build-arg-file + name: build-args-file + type: string + - default: "false" + description: Whether to enable privileged mode, should be used only with remote VMs + name: privileged-nested + type: string + - name: enable-cache-proxy + default: 'false' + description: Enable cache proxy configuration + type: string + results: + - description: "" + name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - description: "" + name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - description: "" + name: CHAINS-GIT_URL + value: $(tasks.clone-repository.results.url) + - description: "" + name: CHAINS-GIT_COMMIT + value: $(tasks.clone-repository.results.commit) + tasks: + - name: init + params: + - name: image-url + value: $(params.output-image) + - name: rebuild + value: $(params.rebuild) + - name: skip-checks + value: $(params.skip-checks) + - name: enable-cache-proxy + value: $(params.enable-cache-proxy) + taskRef: + params: + - name: name + value: init + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:ebf06778aeacbbeb081f9231eafbdfdb8e380ad04e211d7ed80ae9101e37fd82 + - name: kind + value: task + resolver: bundles + - name: clone-repository + params: + - name: url + value: $(params.git-url) + - name: revision + value: $(params.revision) + - name: ociStorage + value: $(params.output-image).git + - name: ociArtifactExpiresAfter + value: $(params.image-expires-after) + runAfter: + - init + taskRef: + params: + - name: name + value: git-clone-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.1@sha256:306b69e6db435ad4a7cf258b6219d9b998eb37da44f5e9ac882ac86a08109154 + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + workspaces: + - name: basic-auth + workspace: git-auth + - name: prefetch-dependencies + params: + - name: input + value: $(params.prefetch-input) + - name: SOURCE_ARTIFACT + value: $(tasks.clone-repository.results.SOURCE_ARTIFACT) + - name: ociStorage + value: $(params.output-image).prefetch + - name: ociArtifactExpiresAfter + value: $(params.image-expires-after) + runAfter: + - clone-repository + taskRef: + params: + - name: name + value: prefetch-dependencies-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.2@sha256:c664a6df6514b59c3ce53570b0994b45af66ecc89ba2a8e41834eae0622addf6 + - name: kind + value: task + resolver: bundles + workspaces: + - name: git-basic-auth + workspace: git-auth + - name: netrc + workspace: netrc + - name: build-container + computeResources: + limits: + memory: 12Gi + requests: + memory: 4Gi + params: + - name: IMAGE + value: $(params.output-image) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: HERMETIC + value: $(params.hermetic) + - name: PREFETCH_INPUT + value: $(params.prefetch-input) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: BUILD_ARGS + value: + - $(params.build-args[*]) + - name: BUILD_ARGS_FILE + value: $(params.build-args-file) + - name: PRIVILEGED_NESTED + value: $(params.privileged-nested) + - name: SOURCE_URL + value: $(tasks.clone-repository.results.url) + - name: BUILDAH_FORMAT + value: $(params.buildah-format) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + - name: HTTP_PROXY + value: $(tasks.init.results.http-proxy) + - name: NO_PROXY + value: $(tasks.init.results.no-proxy) + runAfter: + - prefetch-dependencies + taskRef: + params: + - name: name + value: buildah-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.8@sha256:8a984185d22d3180ecd575dbf6b6a63edb1b3ec754be0b8e5e4247a9eae9112b + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - name: build-image-index + params: + - name: IMAGE + value: $(params.output-image) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: ALWAYS_BUILD_INDEX + value: $(params.build-image-index) + - name: IMAGES + value: + - $(tasks.build-container.results.IMAGE_URL)@$(tasks.build-container.results.IMAGE_DIGEST) + - name: BUILDAH_FORMAT + value: $(params.buildah-format) + runAfter: + - build-container + taskRef: + params: + - name: name + value: build-image-index + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.2@sha256:05d3d8a5ded44c51b074a56a408ddf5d65c56b4c15e110abb1a99e3aff269d49 + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - name: build-source-image + params: + - name: BINARY_IMAGE + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: BINARY_IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: source-build-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3@sha256:c35ba219390d77a48ee19347e5ee8d13e5c23e3984299e02291d6da1ed8a986c + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - input: $(params.build-source-image) + operator: in + values: + - "true" + - name: deprecated-base-image-check + params: + - name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: deprecated-image-check + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:e3a55ccdf1091b4a35507f9ee2d1918d8e89a5f96babcb5486b491226da03d6f + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: clair-scan + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: clair-scan + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.3@sha256:dadfea7633d82e4303ba73d5e9c7e2bc16834bde0fd7688880453b26452067eb + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: ecosystem-cert-preflight-checks + params: + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: ecosystem-cert-preflight-checks + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:204fd3914d83c7b60e8eee72b5a944337720c79a3e660e7c994435456dcf7175 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-snyk-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-snyk-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.4@sha256:a70272ae12f6d7f0da2902158e1bcee756877aa8f71fd1a22ef9afd8b177fb41 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: clamav-scan + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: clamav-scan + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3@sha256:76efc0119a10bc8a420dbbb0cdab9ef8eafd263f6827498d2b644e450e93f446 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-coverity-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE + value: $(params.output-image) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: HERMETIC + value: $(params.hermetic) + - name: PREFETCH_INPUT + value: $(params.prefetch-input) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: BUILD_ARGS + value: + - $(params.build-args[*]) + - name: BUILD_ARGS_FILE + value: $(params.build-args-file) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - coverity-availability-check + taskRef: + params: + - name: name + value: sast-coverity-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-coverity-check-oci-ta:0.3@sha256:9d0bc704bca6b6faad37b2ce8106b96e0cef35e1f647d037a878bf416589de9d + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - input: $(tasks.coverity-availability-check.results.STATUS) + operator: in + values: + - success + - name: coverity-availability-check + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: coverity-availability-check + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-coverity-availability-check:0.2@sha256:267d5bc069a0323f41e24732ddfd1057e5c639e853d1e620c67505fab78f1301 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-shell-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-shell-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:e7a51575f9188a1461d4520da25aaa4efdd3b896c97dc750941fa22840e55c13 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-unicode-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-unicode-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:1818a5b3e4fa86c838ae71226a157241967d1f19c5ed377e4b2fddad7a3ceefe + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: apply-tags + params: + - name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: apply-tags + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3@sha256:510b6d2a3b188adeb716e49566b57d611ab36bd69a2794b5ddfc11dbf014c2ca + - name: kind + value: task + resolver: bundles + - name: push-dockerfile + params: + - name: IMAGE + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: push-dockerfile-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.1@sha256:08bba4a659ecd48f871bef00b80af58954e5a09fcbb28a1783ddd640c4f6535e + - name: kind + value: task + resolver: bundles + - name: rpms-signature-scan + params: + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: rpms-signature-scan + - name: bundle + value: quay.io/konflux-ci/konflux-vanguard/task-rpms-signature-scan:0.2@sha256:3d016c63bcab64ab82da762a52b013c0bcb534e9523b8c0e073cc3a0c02f0cac + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + workspaces: + - name: git-auth + optional: true + - name: netrc + optional: true + taskRunSpecs: + - pipelineTaskName: build-container + stepSpecs: + - name: build + computeResources: + requests: + memory: 16Gi + cpu: "6" + limits: + memory: 16Gi + cpu: "6" + taskRunTemplate: + serviceAccountName: build-pipeline-devlake-backend-d41aa + workspaces: + - name: git-auth + secret: + secretName: '{{ git_auth_secret }}' +status: {} diff --git a/.tekton/devlake-backend-d41aa-push.yaml b/.tekton/devlake-backend-d41aa-push.yaml new file mode 100644 index 00000000000..792280ecd02 --- /dev/null +++ b/.tekton/devlake-backend-d41aa-push.yaml @@ -0,0 +1,618 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + annotations: + build.appstudio.openshift.io/repo: https://github.com/konflux-ci/devlake?rev={{revision}} + build.appstudio.redhat.com/commit_sha: '{{revision}}' + build.appstudio.redhat.com/target_branch: '{{target_branch}}' + pipelinesascode.tekton.dev/cancel-in-progress: "false" + pipelinesascode.tekton.dev/max-keep-runs: "3" + pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch == "main" + creationTimestamp: + labels: + appstudio.openshift.io/application: devlake + appstudio.openshift.io/component: devlake-backend-d41aa + pipelines.appstudio.openshift.io/type: build + name: devlake-backend-d41aa-on-push + namespace: rh-konflux-devprod-tenant +spec: + params: + - name: git-url + value: '{{source_url}}' + - name: revision + value: '{{revision}}' + - name: output-image + value: quay.io/konflux-ci/konflux-devprod/devlake-backend:{{revision}} + - name: dockerfile + value: backend/Containerfile + - name: path-context + value: backend + pipelineSpec: + description: | + This pipeline is ideal for building container images from a Containerfile while maintaining trust after pipeline customization. + + _Uses `buildah` to create a container image leveraging [trusted artifacts](https://konflux-ci.dev/architecture/ADR/0036-trusted-artifacts.html). It also optionally creates a source image and runs some build-time tests. Information is shared between tasks using OCI artifacts instead of PVCs. EC will pass the [`trusted_task.trusted`](https://conforma.dev/docs/policy/packages/release_trusted_task.html#trusted_task__trusted) policy as long as all data used to build the artifact is generated from trusted tasks. + This pipeline is pushed as a Tekton bundle to [quay.io](https://quay.io/repository/konflux-ci/tekton-catalog/pipeline-docker-build-oci-ta?tab=tags)_ + params: + - description: Source Repository URL + name: git-url + type: string + - default: "" + description: Revision of the Source Repository + name: revision + type: string + - description: Fully Qualified Output Image + name: output-image + type: string + - default: . + description: Path to the source code of an application's component from where to build image. + name: path-context + type: string + - default: Dockerfile + description: Path to the Dockerfile inside the context specified by parameter path-context + name: dockerfile + type: string + - default: "false" + description: Force rebuild image + name: rebuild + type: string + - default: "false" + description: Skip checks against built image + name: skip-checks + type: string + - default: "false" + description: Execute the build with network isolation + name: hermetic + type: string + - default: "" + description: Build dependencies to be prefetched + name: prefetch-input + type: string + - default: "" + description: Image tag expiration time, time values could be something like 1h, 2d, 3w for hours, days, and weeks, respectively. + name: image-expires-after + type: string + - default: "false" + description: Build a source image. + name: build-source-image + type: string + - default: "false" + description: Add built image into an OCI image index + name: build-image-index + type: string + - default: docker + description: The format for the resulting image's mediaType. Valid values are oci or docker. + name: buildah-format + type: string + - default: + - "BUILDPLATFORM=linux/amd64" + description: Array of --build-arg values ("arg=value" strings) for buildah + name: build-args + type: array + - default: "" + description: Path to a file with build arguments for buildah, see https://www.mankier.com/1/buildah-build#--build-arg-file + name: build-args-file + type: string + - default: "false" + description: Whether to enable privileged mode, should be used only with remote VMs + name: privileged-nested + type: string + - name: enable-cache-proxy + default: 'false' + description: Enable cache proxy configuration + type: string + results: + - description: "" + name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - description: "" + name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - description: "" + name: CHAINS-GIT_URL + value: $(tasks.clone-repository.results.url) + - description: "" + name: CHAINS-GIT_COMMIT + value: $(tasks.clone-repository.results.commit) + tasks: + - name: init + params: + - name: image-url + value: $(params.output-image) + - name: rebuild + value: $(params.rebuild) + - name: skip-checks + value: $(params.skip-checks) + - name: enable-cache-proxy + value: $(params.enable-cache-proxy) + taskRef: + params: + - name: name + value: init + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:ebf06778aeacbbeb081f9231eafbdfdb8e380ad04e211d7ed80ae9101e37fd82 + - name: kind + value: task + resolver: bundles + - name: clone-repository + params: + - name: url + value: $(params.git-url) + - name: revision + value: $(params.revision) + - name: ociStorage + value: $(params.output-image).git + - name: ociArtifactExpiresAfter + value: $(params.image-expires-after) + runAfter: + - init + taskRef: + params: + - name: name + value: git-clone-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.1@sha256:306b69e6db435ad4a7cf258b6219d9b998eb37da44f5e9ac882ac86a08109154 + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + workspaces: + - name: basic-auth + workspace: git-auth + - name: prefetch-dependencies + params: + - name: input + value: $(params.prefetch-input) + - name: SOURCE_ARTIFACT + value: $(tasks.clone-repository.results.SOURCE_ARTIFACT) + - name: ociStorage + value: $(params.output-image).prefetch + - name: ociArtifactExpiresAfter + value: $(params.image-expires-after) + runAfter: + - clone-repository + taskRef: + params: + - name: name + value: prefetch-dependencies-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.2@sha256:c664a6df6514b59c3ce53570b0994b45af66ecc89ba2a8e41834eae0622addf6 + - name: kind + value: task + resolver: bundles + workspaces: + - name: git-basic-auth + workspace: git-auth + - name: netrc + workspace: netrc + - name: build-container + params: + - name: IMAGE + value: $(params.output-image) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: HERMETIC + value: $(params.hermetic) + - name: PREFETCH_INPUT + value: $(params.prefetch-input) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: BUILD_ARGS + value: + - $(params.build-args[*]) + - name: BUILD_ARGS_FILE + value: $(params.build-args-file) + - name: PRIVILEGED_NESTED + value: $(params.privileged-nested) + - name: SOURCE_URL + value: $(tasks.clone-repository.results.url) + - name: BUILDAH_FORMAT + value: $(params.buildah-format) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + - name: HTTP_PROXY + value: $(tasks.init.results.http-proxy) + - name: NO_PROXY + value: $(tasks.init.results.no-proxy) + runAfter: + - prefetch-dependencies + taskRef: + params: + - name: name + value: buildah-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.8@sha256:8a984185d22d3180ecd575dbf6b6a63edb1b3ec754be0b8e5e4247a9eae9112b + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - name: build-image-index + params: + - name: IMAGE + value: $(params.output-image) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: ALWAYS_BUILD_INDEX + value: $(params.build-image-index) + - name: IMAGES + value: + - $(tasks.build-container.results.IMAGE_URL)@$(tasks.build-container.results.IMAGE_DIGEST) + - name: BUILDAH_FORMAT + value: $(params.buildah-format) + runAfter: + - build-container + taskRef: + params: + - name: name + value: build-image-index + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.2@sha256:05d3d8a5ded44c51b074a56a408ddf5d65c56b4c15e110abb1a99e3aff269d49 + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - name: build-source-image + params: + - name: BINARY_IMAGE + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: BINARY_IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: source-build-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3@sha256:c35ba219390d77a48ee19347e5ee8d13e5c23e3984299e02291d6da1ed8a986c + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - input: $(params.build-source-image) + operator: in + values: + - "true" + - name: deprecated-base-image-check + params: + - name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: deprecated-image-check + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:e3a55ccdf1091b4a35507f9ee2d1918d8e89a5f96babcb5486b491226da03d6f + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: clair-scan + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: clair-scan + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.3@sha256:dadfea7633d82e4303ba73d5e9c7e2bc16834bde0fd7688880453b26452067eb + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: ecosystem-cert-preflight-checks + params: + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: ecosystem-cert-preflight-checks + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:204fd3914d83c7b60e8eee72b5a944337720c79a3e660e7c994435456dcf7175 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-snyk-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-snyk-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.4@sha256:a70272ae12f6d7f0da2902158e1bcee756877aa8f71fd1a22ef9afd8b177fb41 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: clamav-scan + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: clamav-scan + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3@sha256:76efc0119a10bc8a420dbbb0cdab9ef8eafd263f6827498d2b644e450e93f446 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-coverity-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE + value: $(params.output-image) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: HERMETIC + value: $(params.hermetic) + - name: PREFETCH_INPUT + value: $(params.prefetch-input) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: BUILD_ARGS + value: + - $(params.build-args[*]) + - name: BUILD_ARGS_FILE + value: $(params.build-args-file) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - coverity-availability-check + taskRef: + params: + - name: name + value: sast-coverity-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-coverity-check-oci-ta:0.3@sha256:9d0bc704bca6b6faad37b2ce8106b96e0cef35e1f647d037a878bf416589de9d + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - input: $(tasks.coverity-availability-check.results.STATUS) + operator: in + values: + - success + - name: coverity-availability-check + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: coverity-availability-check + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-coverity-availability-check:0.2@sha256:267d5bc069a0323f41e24732ddfd1057e5c639e853d1e620c67505fab78f1301 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-shell-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-shell-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:e7a51575f9188a1461d4520da25aaa4efdd3b896c97dc750941fa22840e55c13 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-unicode-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-unicode-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:1818a5b3e4fa86c838ae71226a157241967d1f19c5ed377e4b2fddad7a3ceefe + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: apply-tags + params: + - name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: apply-tags + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3@sha256:510b6d2a3b188adeb716e49566b57d611ab36bd69a2794b5ddfc11dbf014c2ca + - name: kind + value: task + resolver: bundles + - name: push-dockerfile + params: + - name: IMAGE + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: push-dockerfile-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.1@sha256:08bba4a659ecd48f871bef00b80af58954e5a09fcbb28a1783ddd640c4f6535e + - name: kind + value: task + resolver: bundles + - name: rpms-signature-scan + params: + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: rpms-signature-scan + - name: bundle + value: quay.io/konflux-ci/konflux-vanguard/task-rpms-signature-scan:0.2@sha256:3d016c63bcab64ab82da762a52b013c0bcb534e9523b8c0e073cc3a0c02f0cac + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + workspaces: + - name: git-auth + optional: true + - name: netrc + optional: true + taskRunSpecs: + - pipelineTaskName: build-container + stepSpecs: + - name: build + computeResources: + requests: + memory: 16Gi + cpu: "6" + limits: + memory: 16Gi + cpu: "6" + taskRunTemplate: + serviceAccountName: build-pipeline-devlake-backend-d41aa + workspaces: + - name: git-auth + secret: + secretName: '{{ git_auth_secret }}' +status: {} diff --git a/.tekton/devlake-frontend-ba03c-pull-request.yaml b/.tekton/devlake-frontend-ba03c-pull-request.yaml new file mode 100644 index 00000000000..c3dbb65ca75 --- /dev/null +++ b/.tekton/devlake-frontend-ba03c-pull-request.yaml @@ -0,0 +1,609 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + annotations: + build.appstudio.openshift.io/repo: https://github.com/konflux-ci/devlake?rev={{revision}} + build.appstudio.redhat.com/commit_sha: '{{revision}}' + build.appstudio.redhat.com/pull_request_number: '{{pull_request_number}}' + build.appstudio.redhat.com/target_branch: '{{target_branch}}' + pipelinesascode.tekton.dev/cancel-in-progress: "true" + pipelinesascode.tekton.dev/max-keep-runs: "3" + pipelinesascode.tekton.dev/on-cel-expression: event == "pull_request" && target_branch == "main" && ( "config-ui/***".pathChanged() || ".tekton/devlake-frontend-ba03c-pull-request.yaml".pathChanged() || "config-ui/frontend".pathChanged() ) + creationTimestamp: + labels: + appstudio.openshift.io/application: devlake + appstudio.openshift.io/component: devlake-frontend-ba03c + pipelines.appstudio.openshift.io/type: build + name: devlake-frontend-ba03c-on-pull-request + namespace: rh-konflux-devprod-tenant +spec: + params: + - name: git-url + value: '{{source_url}}' + - name: revision + value: '{{revision}}' + - name: output-image + value: quay.io/konflux-ci/konflux-devprod/devlake-frontend:on-pr-{{revision}} + - name: image-expires-after + value: 5d + - name: dockerfile + value: config-ui/Containerfile + - name: path-context + value: config-ui + pipelineSpec: + description: | + This pipeline is ideal for building container images from a Containerfile while maintaining trust after pipeline customization. + + _Uses `buildah` to create a container image leveraging [trusted artifacts](https://konflux-ci.dev/architecture/ADR/0036-trusted-artifacts.html). It also optionally creates a source image and runs some build-time tests. Information is shared between tasks using OCI artifacts instead of PVCs. EC will pass the [`trusted_task.trusted`](https://conforma.dev/docs/policy/packages/release_trusted_task.html#trusted_task__trusted) policy as long as all data used to build the artifact is generated from trusted tasks. + This pipeline is pushed as a Tekton bundle to [quay.io](https://quay.io/repository/konflux-ci/tekton-catalog/pipeline-docker-build-oci-ta?tab=tags)_ + params: + - description: Source Repository URL + name: git-url + type: string + - default: "" + description: Revision of the Source Repository + name: revision + type: string + - description: Fully Qualified Output Image + name: output-image + type: string + - default: . + description: Path to the source code of an application's component from where to build image. + name: path-context + type: string + - default: Dockerfile + description: Path to the Dockerfile inside the context specified by parameter path-context + name: dockerfile + type: string + - default: "false" + description: Force rebuild image + name: rebuild + type: string + - default: "false" + description: Skip checks against built image + name: skip-checks + type: string + - default: "false" + description: Execute the build with network isolation + name: hermetic + type: string + - default: "" + description: Build dependencies to be prefetched + name: prefetch-input + type: string + - default: "" + description: Image tag expiration time, time values could be something like 1h, 2d, 3w for hours, days, and weeks, respectively. + name: image-expires-after + type: string + - default: "false" + description: Build a source image. + name: build-source-image + type: string + - default: "false" + description: Add built image into an OCI image index + name: build-image-index + type: string + - default: docker + description: The format for the resulting image's mediaType. Valid values are oci or docker. + name: buildah-format + type: string + - default: [] + description: Array of --build-arg values ("arg=value" strings) for buildah + name: build-args + type: array + - default: "" + description: Path to a file with build arguments for buildah, see https://www.mankier.com/1/buildah-build#--build-arg-file + name: build-args-file + type: string + - default: "false" + description: Whether to enable privileged mode, should be used only with remote VMs + name: privileged-nested + type: string + - name: enable-cache-proxy + default: 'false' + description: Enable cache proxy configuration + type: string + results: + - description: "" + name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - description: "" + name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - description: "" + name: CHAINS-GIT_URL + value: $(tasks.clone-repository.results.url) + - description: "" + name: CHAINS-GIT_COMMIT + value: $(tasks.clone-repository.results.commit) + tasks: + - name: init + params: + - name: image-url + value: $(params.output-image) + - name: rebuild + value: $(params.rebuild) + - name: skip-checks + value: $(params.skip-checks) + - name: enable-cache-proxy + value: $(params.enable-cache-proxy) + taskRef: + params: + - name: name + value: init + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:ebf06778aeacbbeb081f9231eafbdfdb8e380ad04e211d7ed80ae9101e37fd82 + - name: kind + value: task + resolver: bundles + - name: clone-repository + params: + - name: url + value: $(params.git-url) + - name: revision + value: $(params.revision) + - name: ociStorage + value: $(params.output-image).git + - name: ociArtifactExpiresAfter + value: $(params.image-expires-after) + runAfter: + - init + taskRef: + params: + - name: name + value: git-clone-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.1@sha256:306b69e6db435ad4a7cf258b6219d9b998eb37da44f5e9ac882ac86a08109154 + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + workspaces: + - name: basic-auth + workspace: git-auth + - name: prefetch-dependencies + params: + - name: input + value: $(params.prefetch-input) + - name: SOURCE_ARTIFACT + value: $(tasks.clone-repository.results.SOURCE_ARTIFACT) + - name: ociStorage + value: $(params.output-image).prefetch + - name: ociArtifactExpiresAfter + value: $(params.image-expires-after) + runAfter: + - clone-repository + taskRef: + params: + - name: name + value: prefetch-dependencies-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.2@sha256:c664a6df6514b59c3ce53570b0994b45af66ecc89ba2a8e41834eae0622addf6 + - name: kind + value: task + resolver: bundles + workspaces: + - name: git-basic-auth + workspace: git-auth + - name: netrc + workspace: netrc + - name: build-container + params: + - name: IMAGE + value: $(params.output-image) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: HERMETIC + value: $(params.hermetic) + - name: PREFETCH_INPUT + value: $(params.prefetch-input) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: BUILD_ARGS + value: + - $(params.build-args[*]) + - name: BUILD_ARGS_FILE + value: $(params.build-args-file) + - name: PRIVILEGED_NESTED + value: $(params.privileged-nested) + - name: SOURCE_URL + value: $(tasks.clone-repository.results.url) + - name: BUILDAH_FORMAT + value: $(params.buildah-format) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + - name: HTTP_PROXY + value: $(tasks.init.results.http-proxy) + - name: NO_PROXY + value: $(tasks.init.results.no-proxy) + runAfter: + - prefetch-dependencies + taskRef: + params: + - name: name + value: buildah-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.8@sha256:8a984185d22d3180ecd575dbf6b6a63edb1b3ec754be0b8e5e4247a9eae9112b + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - name: build-image-index + params: + - name: IMAGE + value: $(params.output-image) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: ALWAYS_BUILD_INDEX + value: $(params.build-image-index) + - name: IMAGES + value: + - $(tasks.build-container.results.IMAGE_URL)@$(tasks.build-container.results.IMAGE_DIGEST) + - name: BUILDAH_FORMAT + value: $(params.buildah-format) + runAfter: + - build-container + taskRef: + params: + - name: name + value: build-image-index + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.2@sha256:05d3d8a5ded44c51b074a56a408ddf5d65c56b4c15e110abb1a99e3aff269d49 + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - name: build-source-image + params: + - name: BINARY_IMAGE + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: BINARY_IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: source-build-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3@sha256:c35ba219390d77a48ee19347e5ee8d13e5c23e3984299e02291d6da1ed8a986c + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - input: $(params.build-source-image) + operator: in + values: + - "true" + - name: deprecated-base-image-check + params: + - name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: deprecated-image-check + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:e3a55ccdf1091b4a35507f9ee2d1918d8e89a5f96babcb5486b491226da03d6f + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: clair-scan + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: clair-scan + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.3@sha256:dadfea7633d82e4303ba73d5e9c7e2bc16834bde0fd7688880453b26452067eb + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: ecosystem-cert-preflight-checks + params: + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: ecosystem-cert-preflight-checks + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:204fd3914d83c7b60e8eee72b5a944337720c79a3e660e7c994435456dcf7175 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-snyk-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-snyk-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.4@sha256:a70272ae12f6d7f0da2902158e1bcee756877aa8f71fd1a22ef9afd8b177fb41 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: clamav-scan + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: clamav-scan + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3@sha256:76efc0119a10bc8a420dbbb0cdab9ef8eafd263f6827498d2b644e450e93f446 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-coverity-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE + value: $(params.output-image) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: HERMETIC + value: $(params.hermetic) + - name: PREFETCH_INPUT + value: $(params.prefetch-input) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: BUILD_ARGS + value: + - $(params.build-args[*]) + - name: BUILD_ARGS_FILE + value: $(params.build-args-file) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - coverity-availability-check + taskRef: + params: + - name: name + value: sast-coverity-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-coverity-check-oci-ta:0.3@sha256:9d0bc704bca6b6faad37b2ce8106b96e0cef35e1f647d037a878bf416589de9d + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - input: $(tasks.coverity-availability-check.results.STATUS) + operator: in + values: + - success + - name: coverity-availability-check + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: coverity-availability-check + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-coverity-availability-check:0.2@sha256:267d5bc069a0323f41e24732ddfd1057e5c639e853d1e620c67505fab78f1301 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-shell-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-shell-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:e7a51575f9188a1461d4520da25aaa4efdd3b896c97dc750941fa22840e55c13 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-unicode-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-unicode-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:1818a5b3e4fa86c838ae71226a157241967d1f19c5ed377e4b2fddad7a3ceefe + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: apply-tags + params: + - name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: apply-tags + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3@sha256:510b6d2a3b188adeb716e49566b57d611ab36bd69a2794b5ddfc11dbf014c2ca + - name: kind + value: task + resolver: bundles + - name: push-dockerfile + params: + - name: IMAGE + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: push-dockerfile-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.1@sha256:08bba4a659ecd48f871bef00b80af58954e5a09fcbb28a1783ddd640c4f6535e + - name: kind + value: task + resolver: bundles + - name: rpms-signature-scan + params: + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: rpms-signature-scan + - name: bundle + value: quay.io/konflux-ci/konflux-vanguard/task-rpms-signature-scan:0.2@sha256:3d016c63bcab64ab82da762a52b013c0bcb534e9523b8c0e073cc3a0c02f0cac + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + workspaces: + - name: git-auth + optional: true + - name: netrc + optional: true + taskRunTemplate: + serviceAccountName: build-pipeline-devlake-frontend-ba03c + workspaces: + - name: git-auth + secret: + secretName: '{{ git_auth_secret }}' +status: {} diff --git a/.tekton/devlake-frontend-ba03c-push.yaml b/.tekton/devlake-frontend-ba03c-push.yaml new file mode 100644 index 00000000000..c8402983ce3 --- /dev/null +++ b/.tekton/devlake-frontend-ba03c-push.yaml @@ -0,0 +1,606 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + annotations: + build.appstudio.openshift.io/repo: https://github.com/konflux-ci/devlake?rev={{revision}} + build.appstudio.redhat.com/commit_sha: '{{revision}}' + build.appstudio.redhat.com/target_branch: '{{target_branch}}' + pipelinesascode.tekton.dev/cancel-in-progress: "false" + pipelinesascode.tekton.dev/max-keep-runs: "3" + pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch == "main" && ( "config-ui/***".pathChanged() || ".tekton/devlake-frontend-ba03c-push.yaml".pathChanged() || "config-ui/frontend".pathChanged() ) + creationTimestamp: + labels: + appstudio.openshift.io/application: devlake + appstudio.openshift.io/component: devlake-frontend-ba03c + pipelines.appstudio.openshift.io/type: build + name: devlake-frontend-ba03c-on-push + namespace: rh-konflux-devprod-tenant +spec: + params: + - name: git-url + value: '{{source_url}}' + - name: revision + value: '{{revision}}' + - name: output-image + value: quay.io/konflux-ci/konflux-devprod/devlake-frontend:{{revision}} + - name: dockerfile + value: config-ui/Containerfile + - name: path-context + value: config-ui + pipelineSpec: + description: | + This pipeline is ideal for building container images from a Containerfile while maintaining trust after pipeline customization. + + _Uses `buildah` to create a container image leveraging [trusted artifacts](https://konflux-ci.dev/architecture/ADR/0036-trusted-artifacts.html). It also optionally creates a source image and runs some build-time tests. Information is shared between tasks using OCI artifacts instead of PVCs. EC will pass the [`trusted_task.trusted`](https://conforma.dev/docs/policy/packages/release_trusted_task.html#trusted_task__trusted) policy as long as all data used to build the artifact is generated from trusted tasks. + This pipeline is pushed as a Tekton bundle to [quay.io](https://quay.io/repository/konflux-ci/tekton-catalog/pipeline-docker-build-oci-ta?tab=tags)_ + params: + - description: Source Repository URL + name: git-url + type: string + - default: "" + description: Revision of the Source Repository + name: revision + type: string + - description: Fully Qualified Output Image + name: output-image + type: string + - default: . + description: Path to the source code of an application's component from where to build image. + name: path-context + type: string + - default: Dockerfile + description: Path to the Dockerfile inside the context specified by parameter path-context + name: dockerfile + type: string + - default: "false" + description: Force rebuild image + name: rebuild + type: string + - default: "false" + description: Skip checks against built image + name: skip-checks + type: string + - default: "false" + description: Execute the build with network isolation + name: hermetic + type: string + - default: "" + description: Build dependencies to be prefetched + name: prefetch-input + type: string + - default: "" + description: Image tag expiration time, time values could be something like 1h, 2d, 3w for hours, days, and weeks, respectively. + name: image-expires-after + type: string + - default: "false" + description: Build a source image. + name: build-source-image + type: string + - default: "false" + description: Add built image into an OCI image index + name: build-image-index + type: string + - default: docker + description: The format for the resulting image's mediaType. Valid values are oci or docker. + name: buildah-format + type: string + - default: [] + description: Array of --build-arg values ("arg=value" strings) for buildah + name: build-args + type: array + - default: "" + description: Path to a file with build arguments for buildah, see https://www.mankier.com/1/buildah-build#--build-arg-file + name: build-args-file + type: string + - default: "false" + description: Whether to enable privileged mode, should be used only with remote VMs + name: privileged-nested + type: string + - name: enable-cache-proxy + default: 'false' + description: Enable cache proxy configuration + type: string + results: + - description: "" + name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - description: "" + name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - description: "" + name: CHAINS-GIT_URL + value: $(tasks.clone-repository.results.url) + - description: "" + name: CHAINS-GIT_COMMIT + value: $(tasks.clone-repository.results.commit) + tasks: + - name: init + params: + - name: image-url + value: $(params.output-image) + - name: rebuild + value: $(params.rebuild) + - name: skip-checks + value: $(params.skip-checks) + - name: enable-cache-proxy + value: $(params.enable-cache-proxy) + taskRef: + params: + - name: name + value: init + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:ebf06778aeacbbeb081f9231eafbdfdb8e380ad04e211d7ed80ae9101e37fd82 + - name: kind + value: task + resolver: bundles + - name: clone-repository + params: + - name: url + value: $(params.git-url) + - name: revision + value: $(params.revision) + - name: ociStorage + value: $(params.output-image).git + - name: ociArtifactExpiresAfter + value: $(params.image-expires-after) + runAfter: + - init + taskRef: + params: + - name: name + value: git-clone-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.1@sha256:306b69e6db435ad4a7cf258b6219d9b998eb37da44f5e9ac882ac86a08109154 + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + workspaces: + - name: basic-auth + workspace: git-auth + - name: prefetch-dependencies + params: + - name: input + value: $(params.prefetch-input) + - name: SOURCE_ARTIFACT + value: $(tasks.clone-repository.results.SOURCE_ARTIFACT) + - name: ociStorage + value: $(params.output-image).prefetch + - name: ociArtifactExpiresAfter + value: $(params.image-expires-after) + runAfter: + - clone-repository + taskRef: + params: + - name: name + value: prefetch-dependencies-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.2@sha256:c664a6df6514b59c3ce53570b0994b45af66ecc89ba2a8e41834eae0622addf6 + - name: kind + value: task + resolver: bundles + workspaces: + - name: git-basic-auth + workspace: git-auth + - name: netrc + workspace: netrc + - name: build-container + params: + - name: IMAGE + value: $(params.output-image) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: HERMETIC + value: $(params.hermetic) + - name: PREFETCH_INPUT + value: $(params.prefetch-input) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: BUILD_ARGS + value: + - $(params.build-args[*]) + - name: BUILD_ARGS_FILE + value: $(params.build-args-file) + - name: PRIVILEGED_NESTED + value: $(params.privileged-nested) + - name: SOURCE_URL + value: $(tasks.clone-repository.results.url) + - name: BUILDAH_FORMAT + value: $(params.buildah-format) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + - name: HTTP_PROXY + value: $(tasks.init.results.http-proxy) + - name: NO_PROXY + value: $(tasks.init.results.no-proxy) + runAfter: + - prefetch-dependencies + taskRef: + params: + - name: name + value: buildah-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.8@sha256:8a984185d22d3180ecd575dbf6b6a63edb1b3ec754be0b8e5e4247a9eae9112b + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - name: build-image-index + params: + - name: IMAGE + value: $(params.output-image) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: ALWAYS_BUILD_INDEX + value: $(params.build-image-index) + - name: IMAGES + value: + - $(tasks.build-container.results.IMAGE_URL)@$(tasks.build-container.results.IMAGE_DIGEST) + - name: BUILDAH_FORMAT + value: $(params.buildah-format) + runAfter: + - build-container + taskRef: + params: + - name: name + value: build-image-index + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.2@sha256:05d3d8a5ded44c51b074a56a408ddf5d65c56b4c15e110abb1a99e3aff269d49 + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - name: build-source-image + params: + - name: BINARY_IMAGE + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: BINARY_IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: source-build-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3@sha256:c35ba219390d77a48ee19347e5ee8d13e5c23e3984299e02291d6da1ed8a986c + - name: kind + value: task + resolver: bundles + when: + - input: $(tasks.init.results.build) + operator: in + values: + - "true" + - input: $(params.build-source-image) + operator: in + values: + - "true" + - name: deprecated-base-image-check + params: + - name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: deprecated-image-check + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:e3a55ccdf1091b4a35507f9ee2d1918d8e89a5f96babcb5486b491226da03d6f + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: clair-scan + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: clair-scan + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.3@sha256:dadfea7633d82e4303ba73d5e9c7e2bc16834bde0fd7688880453b26452067eb + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: ecosystem-cert-preflight-checks + params: + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: ecosystem-cert-preflight-checks + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:204fd3914d83c7b60e8eee72b5a944337720c79a3e660e7c994435456dcf7175 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-snyk-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-snyk-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.4@sha256:a70272ae12f6d7f0da2902158e1bcee756877aa8f71fd1a22ef9afd8b177fb41 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: clamav-scan + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: clamav-scan + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3@sha256:76efc0119a10bc8a420dbbb0cdab9ef8eafd263f6827498d2b644e450e93f446 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-coverity-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE + value: $(params.output-image) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: HERMETIC + value: $(params.hermetic) + - name: PREFETCH_INPUT + value: $(params.prefetch-input) + - name: IMAGE_EXPIRES_AFTER + value: $(params.image-expires-after) + - name: COMMIT_SHA + value: $(tasks.clone-repository.results.commit) + - name: BUILD_ARGS + value: + - $(params.build-args[*]) + - name: BUILD_ARGS_FILE + value: $(params.build-args-file) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - coverity-availability-check + taskRef: + params: + - name: name + value: sast-coverity-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-coverity-check-oci-ta:0.3@sha256:9d0bc704bca6b6faad37b2ce8106b96e0cef35e1f647d037a878bf416589de9d + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - input: $(tasks.coverity-availability-check.results.STATUS) + operator: in + values: + - success + - name: coverity-availability-check + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: coverity-availability-check + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-coverity-availability-check:0.2@sha256:267d5bc069a0323f41e24732ddfd1057e5c639e853d1e620c67505fab78f1301 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-shell-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-shell-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:e7a51575f9188a1461d4520da25aaa4efdd3b896c97dc750941fa22840e55c13 + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: sast-unicode-check + params: + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + - name: CACHI2_ARTIFACT + value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: sast-unicode-check-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:1818a5b3e4fa86c838ae71226a157241967d1f19c5ed377e4b2fddad7a3ceefe + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + - name: apply-tags + params: + - name: IMAGE_URL + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: apply-tags + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3@sha256:510b6d2a3b188adeb716e49566b57d611ab36bd69a2794b5ddfc11dbf014c2ca + - name: kind + value: task + resolver: bundles + - name: push-dockerfile + params: + - name: IMAGE + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: IMAGE_DIGEST + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + - name: DOCKERFILE + value: $(params.dockerfile) + - name: CONTEXT + value: $(params.path-context) + - name: SOURCE_ARTIFACT + value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: push-dockerfile-oci-ta + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.1@sha256:08bba4a659ecd48f871bef00b80af58954e5a09fcbb28a1783ddd640c4f6535e + - name: kind + value: task + resolver: bundles + - name: rpms-signature-scan + params: + - name: image-url + value: $(tasks.build-image-index.results.IMAGE_URL) + - name: image-digest + value: $(tasks.build-image-index.results.IMAGE_DIGEST) + runAfter: + - build-image-index + taskRef: + params: + - name: name + value: rpms-signature-scan + - name: bundle + value: quay.io/konflux-ci/konflux-vanguard/task-rpms-signature-scan:0.2@sha256:3d016c63bcab64ab82da762a52b013c0bcb534e9523b8c0e073cc3a0c02f0cac + - name: kind + value: task + resolver: bundles + when: + - input: $(params.skip-checks) + operator: in + values: + - "false" + workspaces: + - name: git-auth + optional: true + - name: netrc + optional: true + taskRunTemplate: + serviceAccountName: build-pipeline-devlake-frontend-ba03c + workspaces: + - name: git-auth + secret: + secretName: '{{ git_auth_secret }}' +status: {} diff --git a/.yamllint b/.yamllint new file mode 100644 index 00000000000..53e53937b70 --- /dev/null +++ b/.yamllint @@ -0,0 +1,55 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ignore: | + /vendor + /backend/vendor + /node_modules + /config-ui/node_modules + /grafana/dashboards + # Tekton PipelineRuns are generated/managed by Konflux + /.tekton/ + # Historical release files — not worth fixing retroactively + /devops/releases/ + # ASF infrastructure config — managed externally + /.asf.yaml + +rules: + braces: enable + brackets: enable + colons: enable + commas: enable + comments: + level: warning + comments-indentation: + level: warning + document-end: disable + document-start: disable + empty-lines: enable + empty-values: disable + hyphens: enable + key-duplicates: enable + key-ordering: disable + line-length: disable + new-line-at-end-of-file: enable + new-lines: enable + octal-values: enable + quoted-strings: disable + trailing-spaces: enable + truthy: + level: warning + allowed-values: ["true", "false", "yes", "no"] + indentation: + indent-sequences: whatever diff --git a/AGENTS.md b/AGENTS.md index b896b7af69c..6cebe5723ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,117 +16,50 @@ limitations under the License. --> # Apache DevLake - AI Coding Agent Instructions -## Project Overview -Apache DevLake is a dev data platform that ingests data from DevOps tools (GitHub, GitLab, Jira, Jenkins, etc.), transforms it into standardized domain models, and enables metrics/dashboards via Grafana. +Apache DevLake ingests data from DevOps tools (GitHub, GitLab, Jira, Jenkins, etc.), transforms it into standardized domain models, and enables metrics/dashboards via Grafana. ## Architecture -### Three-Layer Data Model -1. **Raw Layer** (`_raw_*` tables): JSON data collected from APIs, stored for replay/debugging -2. **Tool Layer** (`_tool_*` tables): Plugin-specific models extracted from raw data -3. **Domain Layer** (standardized tables): Normalized models in [backend/core/models/domainlayer/](backend/core/models/domainlayer/) - CODE, TICKET, CICD, CODEREVIEW, CODEQUALITY, CROSS +Three-layer data model: **Raw** (`_raw_*`) → **Tool** (`_tool_*`) → **Domain** (standardized tables in `backend/core/models/domainlayer/`). -### Key Components -- **backend/**: Go server + plugins (main codebase) -- **backend/python/**: Python plugin framework via RPC -- **config-ui/**: React frontend (TypeScript, Vite, Ant Design) -- **grafana/**: Dashboard definitions +Key components: `backend/` (Go server + plugins), `config-ui/` (React frontend), `grafana/` (dashboards). -## Plugin Development (Go) +## Owned Plugins -### Plugin Structure -Each plugin in `backend/plugins//` follows this layout: -``` -api/ # REST endpoints (connections, scopes, scope-configs) -impl/ # Plugin implementation (implements core interfaces) -models/ # Tool layer models + migrationscripts/ -tasks/ # Collectors, Extractors, Converters -e2e/ # Integration tests with CSV fixtures -``` +This is a fork of Apache DevLake. An **owned plugin** is identified by having an +`AGENTS.md` file in its directory (`backend/plugins//AGENTS.md`). +Read the plugin's `AGENTS.md` before making changes. -### Required Interfaces -See [backend/plugins/gitlab/impl/impl.go](backend/plugins/gitlab/impl/impl.go) for reference: -- `PluginMeta`: Name, Description, RootPkgPath -- `PluginTask`: SubTaskMetas(), PrepareTaskData() -- `PluginModel`: GetTablesInfo() - **must list all models or CI fails** -- `PluginMigration`: MigrationScripts() for DB schema evolution -- `PluginSource`: Connection(), Scope(), ScopeConfig() - -### Subtask Pattern (Collector → Extractor → Converter) -```go -// 1. Register subtask in tasks/register.go via init() -func init() { - RegisterSubtaskMeta(&CollectIssuesMeta) -} - -// 2. Define dependencies for execution order -var CollectIssuesMeta = plugin.SubTaskMeta{ - Name: "Collect Issues", - Dependencies: []*plugin.SubTaskMeta{}, // or reference other metas -} -``` +To add a new owned plugin, create `backend/plugins//AGENTS.md` with +build/test commands, layout, and conventions for that plugin. -### API Collectors -- Use `helper.NewStatefulApiCollector` for incremental collection with time-based bookmarking -- See [backend/plugins/gitlab/tasks/issue_collector.go](backend/plugins/gitlab/tasks/issue_collector.go) +## Upstream Divergence -### Migration Scripts -- Located in `models/migrationscripts/` -- Register all scripts in `register.go`'s `All()` function -- Version format: `YYYYMMDD_description.go` +This fork occasionally modifies files from upstream Apache DevLake. All such +modifications are tracked in [`docs/upstream-diffs.md`](docs/upstream-diffs.md). -## Build & Development Commands +**Before modifying any file outside an owned plugin directory:** -```bash -# From repo root -make dep # Install Go + Python dependencies -make build # Build plugins + server -make dev # Build + run server -make godev # Go-only dev (no Python plugins) -make unit-test # Run all unit tests -make e2e-test # Run E2E tests - -# From backend/ -make swag # Regenerate Swagger docs (required after API changes) -make lint # Run golangci-lint -``` +1. Check `docs/upstream-diffs.md` — if the file is listed, follow the noted constraints. +2. If introducing a new divergence, add an entry to `docs/upstream-diffs.md` with: + files changed, reason, upstream status, upstream PR link, owner, and rebase notes. +3. Use `Upstream-Status:` trailer in commit messages for upstream-divergent changes. -### Running Locally -```bash -docker-compose -f docker-compose-dev.yml up mysql grafana # Start deps -make dev # Run server on :8080 -cd config-ui && yarn && yarn start # UI on :4000 -``` +## Quick Reference -## Testing - -### Unit Tests -Place `*_test.go` files alongside source. Use mocks from `backend/mocks/`. - -### E2E Tests for Plugins -Use CSV fixtures in `e2e/` directory. See [backend/test/helper/](backend/test/helper/) for the Go test client that can spin up an in-memory DevLake instance. - -### Integration Testing -```go -helper.ConnectLocalServer(t, &helper.LocalClientConfig{ - ServerPort: 8080, - DbURL: "mysql://merico:merico@127.0.0.1:3306/lake", - CreateServer: true, - Plugins: []plugin.PluginMeta{gitlab.Gitlab{}}, -}) +```bash +make build # Build plugins + server +make dev # Build + run +make unit-test # Unit tests +make lint # golangci-lint (from backend/) ``` -## Python Plugins -Located in `backend/python/plugins/`. Use Poetry for dependencies. See [backend/python/README.md](backend/python/README.md). - -## Code Conventions -- Tool model table names: `_tool__` (e.g., `_tool_gitlab_issues`) -- Domain model IDs: Use `didgen.NewDomainIdGenerator` for consistent cross-plugin IDs -- All plugins must be independent - no cross-plugin imports -- Apache 2.0 license header required on all source files +For local environment setup (podman, MySQL, service URLs): see [docs/local-dev.md](docs/local-dev.md). ## Common Pitfalls + - Forgetting to add models to `GetTablesInfo()` fails `plugins/table_info_test.go` -- Migration scripts must be added to `All()` in `register.go` -- API changes require running `make swag` to update Swagger docs -- Python plugins require `libgit2` for gitextractor functionality +- Migration scripts must be added to `All()` in `migrationscripts/register.go` +- API changes require `make swag` to update Swagger docs +- No cross-plugin imports (`plugins/X` must not import `plugins/Y`) +- Apache 2.0 license header required on all new `.go` files diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..3aa5e2ace09 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,786 @@ +# Common AI Assistant Rules + +These rules apply to all repositories using centralized AI rules management. + +--- + +## 1. Golden Rules + +1. **One message = all related operations** + + - Batch reads/writes, shell commands, todos, and agent spawns in a single message. + +2. **No working files in repo root** + + - Root is only for repo metadata and config: `AGENTS.md`, `CLAUDE.md`, `TODO.md`, `README.md`, `LICENSE`, `.gitignore`, dependency files, tooling configs. + - All code, docs, tests, notes, experiments, and scripts go into subfolders. + +3. **Planning lives in `TODO.md` at root** + + - Use `TODO.md` for high-level plans. + - Keep it short and actionable. + - Remove steps once they're done (this file should always reflect the current plan, not history). + +4. **Keep instructions lean** + + - Enough structure to be consistent. + - No walls of text; use lists and checklists. + +5. **Prefer editing over creating** + - Edit existing files rather than creating new ones when possible. + - Only create new files when a clear responsibility or context is missing. + +--- + +## 2. Repository Structure + +### 2.1 Root (allowed files) + +Root directory may contain only: + +- `CLAUDE.md` – AI/human collaboration rules +- `TODO.md` – current plan / task list +- `README.md` – short project overview +- `LICENSE`, `.gitignore`, `.editorconfig` +- Tooling configs and dependency files (language-specific) +- No code, no docs content, no tests, no random notes. + +### 2.2 Common subfolders + +Use these by default (adapt per project, especially for language-specific alternatives): + +- `src/` – production source code +- `tests/` – all tests (unit, integration, etc.) +- `docs/` – internally facing documentation +- `docs/adr/` – Architecture Decision Records (ADRs) +- `docs/research/` – research notes, content libraries, reusable frameworks +- `config/` – configuration files (YAML, JSON, env templates) +- `scripts/` or `tools/` – automation, utility scripts, and small code tools +- `examples/` – example usage code or demo projects + +**Infrastructure projects** (adapt naming as needed): + +- `pulumi/` – Pulumi infra code +- `kubernetes/` – manifests + Kustomize overlays + +**Presentation projects**: + +- `/` + - `speaker-notes.md` + - `slides.pptx` + - Optional `README.md` +- Shared resources under `docs/` + +**Knowledge projects** + +For projects exposing documentation through `mkdoc`, externally facing documentation goes to: + +- `knowledge/` + +--- + +## 3. Documentation & ADRs + +### 3.1 Documentation rules + +- All internal documentation files go under `docs/` or a specific subfolder. +- Avoid creating new root-level `.md` files except: `README.md`, `TODO.md` + +### 3.2 ADRs (Architecture Decision Records) + +- Location: `docs/adr/` +- File name: `NNNN-title-with-hyphens.md` (e.g., `0001-infrastructure-as-code-with-pulumi.md`) +- Each ADR contains: **Status**, **Context**, **Decision**, **Consequences**. +- Maintain `docs/adr/README.md` as an index. + +Use an ADR when: + +- Architecture significantly changes +- Tooling or core tech stack changes +- Security, reliability, or cost decisions with long-term impact + +--- + +## 4. Naming Conventions + +### 4.1 General rules + +- Names should be descriptive, not cute +- Stable over time (avoid date prefixes; use ADRs for history) +- See language-specific profiles for file naming conventions + +### 4.2 Internal Documentation files + +- Project overviews: `docs/overview.md`, `docs/architecture.md` +- Guides: `docs/.md` +- Research: `docs/research/.md` + +--- + +## 5. AI Assistant & Human Collaboration + +### 5.1 When AI changes code + +- Read relevant existing files before editing. +- Keep imports at top, respect code patterns. +- When adding features: + - Consider security and consistency first. + - Update docs where relevant. + - Add or update tests. +- When fixing bugs: + - Understand root cause first. + - Add regression tests. + +### 5.2 When AI creates docs + +- Put them under `docs/` or `knowledge` depending on audience – not root. +- For presentations: slides are visual anchors, speaker notes have full narrative. + +### 5.3 When humans work + +- Keep `TODO.md` current. +- Use ADRs for major decisions. +- Keep repo structure clean. +- Prefer small, focused commits with clear messages. + +--- + +## 6. Pre-Action Checklists + +### 6.1 Before creating a new file + +- [ ] Does an existing file already cover this responsibility? +- [ ] Am I putting it in the right folder (`src/`, `tests/`, `docs/`, etc.)? +- [ ] Is the name clear and consistent with conventions? +- [ ] Is this going to live longer than one session? + +### 6.2 Before committing + +- [ ] No working files in root (only allowed meta files). +- [ ] `TODO.md` is up to date (completed steps removed). +- [ ] New decisions captured in `docs/adr/` if needed. +- [ ] Tests updated or added where appropriate. +- [ ] Repo structure still matches conventions. + +--- + +## 7. JIRA Integration (DPROD Project) + +### 7.1 Issue Types + +| Type | When to Use | +| ------------ | ----------------------------------------------- | +| **Epic** | Large feature or initiative | +| **Story** | User-facing functionality | +| **Task** | Technical/implementation work | +| **Spike** | Research, discovery, PoC with uncertain outcome | +| **Sub-task** | Breakdown of Story/Task | + +### 7.2 Required Fields + +| Field | Field ID | Required For | Example | +| ---------------- | ---------------------- | ------------------ | ----------------------- | +| Epic Name | `customfield_12311141` | Epic | `"User Authentication"` | +| Epic Link | `customfield_12311140` | Story, Task, Spike | `"DPROD-876"` | +| Git Pull Request | `customfield_12310220` | Any (optional) | MR/PR URL | + +### 7.3 Story Points + +Story points cannot be set during issue creation. Update them after the issue is created: + +```python +jira_update_issue( + issue_key="DPROD-123", + fields={}, + additional_fields={ + "customfield_12310243": 3 # Story Points field + } +) +``` + +### 7.4 Labels + +Always include `ai-generated` label when creating issues via AI: + +```python +additional_fields={"labels": ["ai-generated"]} +``` + +### 7.5 Priority Mapping + +| Priority | When to Use | +| -------- | ------------------------------------------- | +| Blocker | Blocking release or other work | +| Critical | Production issues, security vulnerabilities | +| Major | High-value features, P1 items | +| Normal | Standard work, P2 items | +| Minor | Nice-to-have, P3 items | + +### 7.6 JIRA Markup + +| Markdown | JIRA Markup | +| ------------- | ------------- | +| `**bold**` | `*bold*` | +| `# Heading` | `h1. Heading` | +| `## Heading` | `h2. Heading` | +| `- bullet` | `* bullet` | +| `[text](url)` | `[text\|url]` | + +--- + +## 8. Security + +- Never commit secrets or credentials +- Validate user input at system boundaries +- Follow principle of least privilege +- Log errors with context but without sensitive data + +--- + +## 9. Commit Style + +Use conventional commits format: + +``` +(): + +[optional body] + +[optional footer] +``` + +**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` + +When AI assists with commits, include Co-Authored-By trailer: + +``` +feat: add user authentication + +Implement JWT-based authentication for API endpoints. + +Co-Authored-By: +``` + +# Go Profile + +Rules for Go development. + +--- + +## 10. Code Organization + +- Use `internal/` for private packages; add `pkg/` only if you intentionally support external imports as a stable public API. +- One package per directory; split across multiple files freely when it improves clarity (organize by responsibility, avoid "god files"). +- Keep `main.go` minimal – delegate to internal packages. + +``` +project/ +├── cmd/ +│ └── myapp/ +│ └── main.go +├── internal/ +│ ├── config/ +│ ├── server/ +│ └── storage/ +├── pkg/ # Only if public API needed +├── go.mod +└── go.sum +``` + +--- + +## 11. Naming Conventions + +- Use short, concise variable names in small scopes (`i`, `err`, `ctx`). +- Use descriptive names for package-level and exported identifiers. +- Avoid stuttering: `config.Config` not `config.ConfigStruct`. +- Interfaces: single-method uses `-er` suffix (`Reader`, `Writer`, `Closer`). + +```go +// Good +type Reader interface { + Read(p []byte) (n int, err error) +} + +// Avoid +type IReader interface { + Read(p []byte) (n int, err error) +} +``` + +--- + +## 12. Error Handling + +- Return errors, don't panic (except in `init()` for truly fatal issues). +- Wrap errors with context: `fmt.Errorf("doing X: %w", err)`. +- Use custom error types for errors that need programmatic handling. +- Check errors immediately after the call. +- Avoid logging inside libraries/internal packages by default; return errors and let `main` decide how to report them. + +```go +// Good +result, err := doSomething() +if err != nil { + return fmt.Errorf("processing item %s: %w", id, err) +} + +// Avoid +result, err := doSomething() +if err != nil { + log.Printf("error: %v", err) + return err +} +``` + +--- + +## 13. Testing + +- Table-driven tests for multiple cases. +- Prefer stdlib `testing`; use `testify` only when it materially improves clarity. +- Test file next to source: `foo.go` → `foo_test.go`. +- Use `testdata/` for fixtures. +- Use `t.TempDir()` for file-based tests. + +```go +func TestProcess(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {"empty input", "", "", true}, + {"valid input", "hello", "HELLO", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Process(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("Process() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Process() = %v, want %v", got, tt.want) + } + }) + } +} +``` + +--- + +## 14. Formatting & Linting + +- `gofmt` / `goimports` on save. +- Run `golangci-lint` with default config. +- No `//nolint` without justification comment. + +### golangci-lint Rules + +**Octal literals** – Use `0o` prefix: +```go +os.WriteFile(path, data, 0o644) // Good +os.WriteFile(path, data, 0644) // Bad +``` + +**Import ordering** – Three groups separated by blank lines: stdlib, third-party, local. Fix with `goimports -local -w .` +```go +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "myproject/internal/check" +) +``` + +**Unused parameters** – Rename to `_`: +```go +func handler(_ *cobra.Command, _ []string) error { return doWork() } +``` + +**Slice pre-allocation** – Pre-allocate when size is known: +```go +results := make([]string, 0, len(items)) +``` + +**Explicit error handling** – Check or explicitly ignore with `_`: +```go +if err := tags.ForEach(fn); err != nil { return err } // Check +_ = tags.ForEach(fn) // Explicit ignore +defer func() { _ = resource.Cleanup() }() // Cleanup in defer +``` + +**No os.Exit after defer** – Separate execution from exit handling so defers run. + +**String comparisons** – Use `strings.EqualFold` for case-insensitive: +```go +strings.EqualFold(tag, "v"+version) // Good +strings.ToLower(tag) == "v"+version // Bad +``` + +**Empty string checks** – Use direct comparison: +```go +if s != "" { } // Good +if len(s) > 0 { } // Bad +``` + +```bash +gofmt -w . +golangci-lint run ./... +``` + +--- + +## 15. Dependencies + +- Prefer stdlib over third-party when reasonable. +- Vendor dependencies (`go mod vendor`) only if you need offline/hermetic builds or policy requires it. +- Minimize dependency count. + +--- + +## 16. Concurrency + +- Prefer mutexes for shared state; use channels for coordination/signaling and ownership handoff. +- Use `context.Context` for cancellation/timeouts. +- Document goroutine ownership and lifecycle. + +```go +// Good - context for cancellation +func fetchData(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + // ... +} +``` + +--- + +## 17. I/O & Determinism + +- Prefer `io/fs` (`fs.FS`, `os.DirFS`) abstractions to keep file I/O testable. +- Deterministic output: stable ordering, stable formatting, consistent newlines across OSes. +- Use `\n` (LF) for all generated files. + +--- + +## 18. Documentation + +- Document exported functions, types, and packages. +- Start comments with the name being documented. +- Keep comments concise but informative. + +```go +// Config holds the application configuration. +// It is loaded from environment variables or a config file. +type Config struct { + // Port is the HTTP server port. + Port int + // Debug enables debug logging. + Debug bool +} + +// NewConfig creates a Config from environment variables. +func NewConfig() (*Config, error) { + // ... +} +``` + +--- + +## 19. Common Commands + +```bash +# Initialize module +go mod init example.com/myapp + +# Build +go build ./... + +# Test +go test ./... +go test -v -race -coverprofile=coverage.out ./... + +# Lint +golangci-lint run ./... + +# Format +gofmt -w . +goimports -w . +``` + +# Claude-Specific Rules + +Rules specific to Claude Code assistant. + +--- + +## 20. Output Format + +- Keep responses concise and actionable. +- Use code blocks with appropriate language tags. +- Prefer editing existing files over creating new ones. +- Use GitHub-flavored markdown for formatting. + +--- + +## 21. Tool Usage + +- Use Read tool before editing files. +- Use Grep/Glob for searching, not bash grep/find. +- Make atomic, focused changes. +- Batch related operations in a single message. + +--- + +## 22. Claude Flow & Multi-Agent Coordination + +Core principle: **Claude Flow coordinates, Claude Code executes.** + +### 22.1 Golden rule for agents + +- **"1 message = all related operations"** + - For a given task, one message should: + - Spawn all agents (via Task tool) + - Batch all todos (TodoWrite) + - Batch all file operations + - Batch all shell commands + +### 22.2 Roles + +**MCP / Claude Flow tools:** +- Swarm+coordination: `swarm_init`, `agent_spawn`, `task_orchestrate`, etc. +- Setup topology, memory patterns, coordination – **not** actual file work. + +**Claude Code:** +- Runs the real work: + - `Task(...)` for agents (`coder`, `tester`, `reviewer`, `planner`, etc.) + - File operations (read, write, edit) + - Bash commands + - Git operations + - Package management + - Test runs + +### 22.3 Execution pattern + +For a non-trivial task: + +1. (Optional) Use Claude Flow MCP to configure coordination. +2. In one Claude Code message: + - Spawn all relevant agents with clear roles. + - Batch all planned file reads/writes/edits. + - Batch all shell commands. + - Batch all todos in one `TodoWrite` call (5–10+ items). +3. Respect folder structure and root rules: + - Never create code, tests, or docs in root (except `TODO.md`, `README.md`, `CLAUDE.md`). + +--- + +## 23. Presentation Workflow + +When creating presentations using Claude Flow: + +### 23.1 Structure + +``` +/ +├── speaker-notes.md # Full narrative (2-3× slide detail) +├── slides.pptx # Visual anchors, minimal text +└── README.md # Optional: abstract, audience, duration +``` + +### 23.2 Process + +1. **Research phase**: Gather content from knowledge base, docs, research folder. +2. **Outline phase**: Create structure with key points per slide. +3. **Speaker notes**: Write full narrative in markdown. +4. **Slides**: Create visual deck (can be manual or via tooling). +5. **Review**: Cross-check notes against slides for consistency. + +### 23.3 Agent coordination + +For complex presentations, coordinate agents: +- `researcher`: Gathers relevant content +- `writer`: Creates speaker notes +- `reviewer`: Checks consistency and flow + +--- + +## 24. Task Tool Best Practices + +When using the Task tool to spawn agents: + +- Provide clear, specific prompts. +- Specify what the agent should do (research vs. implement). +- Use appropriate agent types: + - `Explore`: For codebase exploration + - `Plan`: For implementation planning + - `Bash`: For command execution + - `general-purpose`: For multi-step tasks + +``` +Task( + subagent_type="Explore", + prompt="Find all files that handle user authentication", + description="Find auth files" +) +``` + +--- + +## 25. Memory & Context + +- Use TodoWrite to track progress across turns. +- Reference earlier context when resuming work. +- Keep TODO.md updated for persistence across sessions. +- Use clear markers for work-in-progress sections. + +# DevLake Local Development (AI Reference) + +Quick reference for AI agents working on this project. + +## Environment + +- **Container runtime**: podman (not docker) +- **Compose file**: `docker-compose-dev.yml` +- **Database**: MySQL 8, credentials `merico:merico`, database `lake` +- **Test database**: `lake_test` (auto-created via `scripts/mysql-init.sql`) +- **MySQL root**: `root:admin` (for admin operations) +- **Go version**: 1.21+ + +## Service URLs + +| Service | URL | +|---------|-----| +| Config UI | http://localhost:4000 | +| DevLake API | http://localhost:8080 | +| Grafana | http://localhost:4000/grafana/ | +| MySQL | localhost:3306 | + +## Custom CA (CEE GitLab) + +For internal services using Red Hat CA: + +```bash +# Export Red Hat IT Root CA (macOS) +security find-certificate -a -p -c "Red Hat IT Root CA" /Library/Keychains/System.keychain > ./custom-ca.crt + +# Set in .env +CA_CERT_FILE=./custom-ca.crt + +# Start with custom CA +podman compose -f docker-compose-dev.yml -f docker-compose-custom-ca.yml up -d +``` + +## Common Commands + +```bash +# Start all services +podman compose -f docker-compose-dev.yml up -d + +# Start just MySQL (for local Go dev) +podman compose -f docker-compose-dev.yml up -d mysql + +# Stop services +podman compose -f docker-compose-dev.yml down + +# Rebuild and restart devlake container +podman compose -f docker-compose-dev.yml up -d --build devlake + +# View logs +podman compose -f docker-compose-dev.yml logs -f devlake +``` + +## Building (from backend/) + +```bash +cd backend +make go-dep # Install dependencies +make build # Build everything (plugins + server) +make build-plugin # Build plugins only +make run # Run server +make dev # Build and run +``` + +## Testing + +```bash +cd backend + +# All tests (unit + e2e) +make test + +# Unit tests only +make unit-test + +# E2E tests (requires MySQL running with lake_test) +export E2E_DB_URL="mysql://merico:merico@localhost:3306/lake_test?charset=utf8mb4&parseTime=True" +make e2e-test + +# Specific plugin tests +go test ./plugins/aireview/... -v + +# Lint and format +make lint +make fmt +``` + +## Database + +```bash +# Connect to MySQL +podman compose -f docker-compose-dev.yml exec mysql mysql -umerico -pmerico lake + +# Check migrations +podman compose -f docker-compose-dev.yml exec mysql mysql -umerico -pmerico lake -e \ + "SELECT * FROM _devlake_migration_history ORDER BY created_at DESC LIMIT 10;" + +# Trigger migration after schema changes +curl -s http://localhost:4000/api/proceed-db-migration +``` + +## Plugin Verification + +```bash +# Check plugin is loaded +curl -s http://localhost:4000/api/plugins | jq '.[] | select(.plugin == "aireview")' + +# Check aireview tables +podman compose -f docker-compose-dev.yml exec mysql mysql -umerico -pmerico lake -e \ + "SHOW TABLES LIKE '_tool_aireview%';" +``` + +## Before Running Commands + +1. Ensure podman machine is running: `podman machine start` +2. Check if services are already running: `podman ps` +3. For E2E tests, MySQL must be running with `lake_test` database + +## If E2E tests fail with "Access denied for lake_test" + +Existing MySQL volumes may not have `lake_test`. Fix with: + +```bash +podman compose -f docker-compose-dev.yml exec mysql mysql -uroot -padmin -e " + CREATE DATABASE IF NOT EXISTS lake_test; + GRANT ALL PRIVILEGES ON lake_test.* TO 'merico'@'%'; + FLUSH PRIVILEGES; +" +``` + +## Project Conventions + +- Plugin code: `backend/plugins//` +- Tests next to source: `foo.go` → `foo_test.go` +- E2E test fixtures: `e2e/raw_tables/*.csv` +- Migrations: `models/migrationscripts/` + +Also follow the rules in [AGENTS.md](./AGENTS.md) — plugin ownership and upstream +divergence tracking. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000000..9d9bb90c5df --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,11 @@ +# CODEOWNERS +# See: https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# Default — covers everything not matched by a more specific rule below +* @konflux-ci/konflux-devprod-team + +# Agent restraints — always require human review. +CODEOWNERS @konflux-ci/konflux-devprod-team +AGENTS.md @konflux-ci/konflux-devprod-team +CLAUDE.md @konflux-ci/konflux-devprod-team +.github/workflows/ @konflux-ci/konflux-devprod-team diff --git a/Makefile b/Makefile index 8639ce2dc6f..c41692ea239 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ build-server-image: make build-server-image -C backend build-config-ui-image: - cd config-ui; docker build -t $(IMAGE_REPO)/devlake-config-ui:$(TAG) --file ./Dockerfile . + cd config-ui; docker build -t $(IMAGE_REPO)/devlake-config-ui:$(TAG) --file ./Containerfile . build-grafana-image: cd grafana; docker build -t $(IMAGE_REPO)/devlake-dashboard:$(TAG) --file ./Dockerfile . diff --git a/backend/Containerfile b/backend/Containerfile new file mode 100644 index 00000000000..cae4d8d27a2 --- /dev/null +++ b/backend/Containerfile @@ -0,0 +1,227 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# UBI 9-based DevLake backend image (parity with Dockerfile.konflux). + +ARG GO_VERSION=1.27.3 +ARG TINI_VERSION=v0.19.0 + +# Pin to multi-arch manifest digest (not :latest) for reproducible builds. +FROM registry.access.redhat.com/ubi9/ubi@sha256:9d99826a5299a54fa92e9b47d74e6cd72efb04cb57eb8fe748ed93e08ebb6184 AS builder + +# Do not default TARGETARCH/BUILDARCH — a default shadows BuildKit's auto values +# (e.g. CI on ubuntu-24.04-arm would download amd64 Go and hit Exec format error). +ARG BUILDARCH +ARG TARGETARCH +ARG GOPROXY= +ARG HTTP_PROXY= +ARG HTTPS_PROXY= +ARG GO_VERSION=1.27.3 +# SHA-256 from https://go.dev/dl/?mode=json&include=all (go1.27.3 linux archives) +ARG GO_SHA256_AMD64=65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40 +ARG GO_SHA256_ARM64=a06e99309dbcbdca1a8ccd2282c3ee146e55930886e26b4f1193269587165f49 + +ENV GOPROXY=${GOPROXY} \ + HTTP_PROXY=${HTTP_PROXY} \ + HTTPS_PROXY=${HTTPS_PROXY} \ + GOPATH=/go \ + PATH=/go/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +RUN dnf -y update && \ + dnf -y install \ + gcc \ + gcc-c++ \ + make \ + cmake \ + binutils \ + pkgconfig \ + openssl-devel \ + zlib-devel \ + wget \ + tar \ + gzip \ + git \ + perl \ + diffutils \ + findutils \ + which && \ + dnf clean all + +# Go toolchain must match the builder machine (BUILDARCH), not the target. +RUN case "${BUILDARCH}" in \ + amd64) GO_SHA256="${GO_SHA256_AMD64}" ;; \ + arm64) GO_SHA256="${GO_SHA256_ARM64}" ;; \ + *) echo "unsupported BUILDARCH=${BUILDARCH}"; exit 1 ;; \ + esac && \ + curl -fsSL -o /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-${BUILDARCH}.tar.gz" && \ + echo "${GO_SHA256} /tmp/go.tar.gz" | sha256sum -c - && \ + tar -C /usr/local -xzf /tmp/go.tar.gz && \ + rm -f /tmp/go.tar.gz + +RUN go install github.com/vektra/mockery/v3@v3.8.1 && \ + go install github.com/swaggo/swag/cmd/swag@v1.16.6 + +# libssh2-devel is not in UBI; build from source for libgit2 SSH support +ARG LIBSSH2_VERSION=1.11.0 +RUN mkdir -p /tmp/build && cd /tmp/build && \ + wget "https://github.com/libssh2/libssh2/releases/download/libssh2-${LIBSSH2_VERSION}/libssh2-${LIBSSH2_VERSION}.tar.gz" -O - | tar -xz && \ + cd "libssh2-${LIBSSH2_VERSION}" && \ + mkdir build && cd build && \ + cmake .. \ + -DBUILD_SHARED_LIBS=ON \ + -DCRYPTO_BACKEND=OpenSSL \ + -DCMAKE_INSTALL_PREFIX=/usr/local/deps/target \ + -DCMAKE_INSTALL_LIBDIR=lib && \ + make -j"$(nproc)" install && \ + cd / && rm -rf /tmp/build + +# Build libgit2 against native UBI libraries (same version as Dockerfile.konflux) +RUN mkdir -p /tmp/build && cd /tmp/build && \ + wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.3.2.tar.gz -O - | tar -xz && \ + cd libgit2-1.3.2 && \ + mkdir build && cd build && \ + cmake .. \ + -DBUILD_SHARED_LIBS=ON \ + -DUSE_SSH=ON \ + -DCMAKE_PREFIX_PATH=/usr/local/deps/target \ + -DCMAKE_INSTALL_PREFIX=/usr/local/deps/target \ + -DCMAKE_INSTALL_LIBDIR=lib && \ + make -j"$(nproc)" install && \ + cd / && rm -rf /tmp/build + +FROM builder AS build + +WORKDIR /app +COPY . /app +ENV GOBIN=/app/bin + +ARG TARGETARCH +ARG TAG= +ARG SHA= +ARG GO_PLUGINS= +ARG DEVLAKE_COVER= + +RUN --mount=type=cache,target=/root/.cache/go-build \ + export PKG_CONFIG_PATH=/usr/local/deps/target/lib/pkgconfig && \ + export LD_LIBRARY_PATH=/usr/local/deps/target/lib && \ + export CGO_ENABLED=1 && \ + export CGO_LDFLAGS="-L/usr/local/deps/target/lib -Wl,-rpath,/usr/local/deps/target/lib" && \ + export GOARCH="${TARGETARCH}" && \ + export GOMAXPROCS=4 && \ + export DEVLAKE_COVER="$DEVLAKE_COVER" && \ + DEVLAKE_PLUGINS="$GO_PLUGINS" make all + +# remove symlink in lib, we will recreate in final image +RUN cd /usr/local/deps/target/lib && \ + for file in *.so* ; do \ + if [ -L "$file" ] ; then \ + unlink "$file" ; \ + fi \ + done + +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:57c8151c51445a07e503dab9dc9211dc3cdeac9d45ed81a10954b7d770659b3b AS base + +ARG TARGETARCH +ARG TINI_VERSION=v0.19.0 +# SHA-256 from https://github.com/krallin/tini/releases/tag/v0.19.0 (*.sha256sum) +ARG TINI_SHA256_AMD64=93dcc18adc78c65a028a84799ecf8ad40c936fdfc5f2a57b1acda5a8117fa82c +ARG TINI_SHA256_ARM64=07952557df20bfd2a95f9bef198b445e006171969499a1d361bd9e6f8e5e0e81 + +# ubi-minimal ships curl-minimal (conflicts with curl). Prefer it. +RUN microdnf -y update && \ + microdnf -y install \ + python3 \ + python3-pip \ + python3-devel \ + gcc \ + gcc-c++ \ + make \ + tar \ + gzip \ + pkgconf-pkg-config \ + ca-certificates \ + openssl \ + zlib \ + libffi-devel \ + mariadb-connector-c-devel \ + libpq-devel \ + git \ + openssh-clients \ + shadow-utils \ + findutils \ + which && \ + microdnf clean all && \ + rm -rf /var/cache/yum /var/cache/dnf + +# tini is not in UBI repos; install static binary with checksum verification +RUN case "${TARGETARCH}" in \ + amd64) TINI_SHA256="${TINI_SHA256_AMD64}" ;; \ + arm64) TINI_SHA256="${TINI_SHA256_ARM64}" ;; \ + *) echo "unsupported TARGETARCH=${TARGETARCH}"; exit 1 ;; \ + esac && \ + curl -fsSL -o /usr/bin/tini \ + "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${TARGETARCH}" && \ + echo "${TINI_SHA256} /usr/bin/tini" | sha256sum -c - && \ + chmod +x /usr/bin/tini + +EXPOSE 8080 + +RUN useradd -ms /bin/bash -d /app devlake -u 1010 && \ + chown -R devlake:devlake /etc/pki/ca-trust + +USER devlake +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN mkdir logs +VOLUME /app/logs + +COPY --chown=devlake:devlake python/ /app/python/ +RUN python3 -m pip install --no-cache --upgrade pip setuptools && \ + python3 -m pip install --no-cache -r python/requirements.txt && \ + python3 -m pip install --upgrade pip + +RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_UNMANAGED_INSTALL=/app/.local/bin sh +ENV PATH="/app/.local/bin:${PATH}" + +RUN /app/python/build.sh + +FROM base AS devlake-base +ARG DEBUG= + +ENV LD_LIBRARY_PATH=/app/libs +RUN mkdir -p /app/libs +COPY --from=build /usr/local/deps/target/lib/*.so* /app/libs +COPY --from=build /go/bin /usr/bin +USER root +RUN echo "/app/libs" > /etc/ld.so.conf.d/devlake-libs.conf && ldconfig +USER devlake + +COPY --from=build /app/bin /app/bin +COPY --from=build /app/resources /app/resources + +USER root +COPY --from=quay.io/konflux-ci/oras:latest /usr/bin/oras /usr/local/bin/oras +RUN chmod +x /usr/local/bin/oras && \ + chgrp -R 0 /app && \ + chmod -R g=u /app +USER devlake + +ENV PATH="/app/bin:${PATH}" +ENV DEBUG="$DEBUG" + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["lake"] diff --git a/backend/Dockerfile b/backend/Dockerfile index b85a5ec8d1a..613523a163a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -23,6 +23,9 @@ #While incubation status is not necessarily a reflection of the completeness or stability of the code, #it does indicate that the project has yet to be fully endorsed by the ASF. +# DEPRECATED: Prefer backend/Containerfile (UBI 9) for local compose and Konflux builds. +# This Debian Dockerfile is retained for upstream compatibility and will be removed later. + FROM --platform=linux/amd64 debian:bookworm as debian-amd64 RUN apt-get update RUN apt-get install -y libssh2-1-dev libssl-dev zlib1g-dev diff --git a/backend/Makefile b/backend/Makefile index 3e40d4c008c..1c03e2f26d1 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -120,7 +120,7 @@ clean: @rm -rf bin build-server-image: - docker build -t $(IMAGE_REPO)/devlake:$(TAG) --build-arg TAG=$(TAG) --build-arg SHA=$(SHA) --file ./Dockerfile . + docker build -t $(IMAGE_REPO)/devlake:$(TAG) --build-arg TAG=$(TAG) --build-arg SHA=$(SHA) --file ./Containerfile . migration-script-lint: go run core/migration/linter/main.go -- $$(find . -path '**/migrationscripts/**.go') diff --git a/backend/core/config/config_viper.go b/backend/core/config/config_viper.go index b4e50727325..571439e47ca 100644 --- a/backend/core/config/config_viper.go +++ b/backend/core/config/config_viper.go @@ -105,7 +105,7 @@ func setDefaultValue(v *viper.Viper) { v.SetDefault("REMOTE_PLUGIN_DIR", "python/plugins") v.SetDefault("SWAGGER_DOCS_DIR", "resources/swagger") v.SetDefault("RESUME_PIPELINES", true) - // v.SetDefault("CORS_ALLOW_ORIGIN", "*") + v.SetDefault("CORS_ALLOW_ORIGIN", "*") v.SetDefault("CONSUME_PIPELINES", true) } diff --git a/backend/core/models/domainlayer/code/ai_commit.go b/backend/core/models/domainlayer/code/ai_commit.go new file mode 100644 index 00000000000..6b426c50c0c --- /dev/null +++ b/backend/core/models/domainlayer/code/ai_commit.go @@ -0,0 +1,42 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package code + +import ( + "time" + + "github.com/apache/devlake/core/models/domainlayer" +) + +// AiCommit is the project-scoped domain representation of an AI-assisted commit. +// Converted from _tool_aireview_commits; stamped with project_name so Grafana +// can filter with a single WHERE clause instead of joining through project_mapping. +type AiCommit struct { + domainlayer.DomainEntity + + ProjectName string `gorm:"index;type:varchar(255)"` + CommitSha string `gorm:"index;type:varchar(40)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + AuthorName string `gorm:"type:varchar(255)"` + AuthoredDate time.Time `gorm:"index"` +} + +func (AiCommit) TableName() string { + return "ai_commits" +} diff --git a/backend/core/models/domainlayer/code/ai_failure_prediction.go b/backend/core/models/domainlayer/code/ai_failure_prediction.go new file mode 100644 index 00000000000..318b28092da --- /dev/null +++ b/backend/core/models/domainlayer/code/ai_failure_prediction.go @@ -0,0 +1,57 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package code + +import ( + "time" + + "github.com/apache/devlake/core/models/domainlayer" +) + +// AiFailurePrediction is the project-scoped domain representation of a +// per-PR AI failure prediction outcome (TP/FP/FN/TN/NO_CI). +// Converted from _tool_aireview_failure_predictions. +type AiFailurePrediction struct { + domainlayer.DomainEntity + + ProjectName string `gorm:"index;type:varchar(255)"` + PullRequestId string `gorm:"index;type:varchar(255)"` + PullRequestKey string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + RepoName string `gorm:"type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + CiFailureSource string `gorm:"type:varchar(20);index"` + + // PR display metadata for drill-down dashboards + PrTitle string `gorm:"type:varchar(500)"` + PrUrl string `gorm:"type:varchar(1024)"` + PrAuthor string `gorm:"type:varchar(255)"` + PrCreatedAt time.Time + Additions int + Deletions int + + WasFlaggedRisky bool + RiskScore int + FlaggedAt time.Time + HadCiFailure bool + PredictionOutcome string `gorm:"type:varchar(20)"` +} + +func (AiFailurePrediction) TableName() string { + return "ai_failure_predictions" +} diff --git a/backend/core/models/domainlayer/code/ai_prediction_metrics.go b/backend/core/models/domainlayer/code/ai_prediction_metrics.go new file mode 100644 index 00000000000..044399b69a0 --- /dev/null +++ b/backend/core/models/domainlayer/code/ai_prediction_metrics.go @@ -0,0 +1,65 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package code + +import ( + "time" + + "github.com/apache/devlake/core/models/domainlayer" +) + +// AiPredictionMetrics is the project-scoped domain representation of aggregated +// AI failure-prediction accuracy metrics (precision, recall, AUC, etc.) per period. +// Converted from _tool_aireview_prediction_metrics. +type AiPredictionMetrics struct { + domainlayer.DomainEntity + + ProjectName string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + CiFailureSource string `gorm:"type:varchar(20);index"` + + PeriodStart time.Time `gorm:"index"` + PeriodEnd time.Time + PeriodType string `gorm:"type:varchar(20)"` + + TruePositives int + FalsePositives int + FalseNegatives int + TrueNegatives int + + Precision float64 + Recall float64 + Accuracy float64 + F1Score float64 + Specificity float64 + FprPct float64 + PrAuc float64 + RocAuc float64 + + WarningThreshold int + TotalPrs int + FlaggedPrs int + FailedPrs int + RecommendedAutonomyLevel string `gorm:"type:varchar(50)"` + CalculatedAt time.Time +} + +func (AiPredictionMetrics) TableName() string { + return "ai_prediction_metrics" +} diff --git a/backend/core/models/domainlayer/code/ai_review.go b/backend/core/models/domainlayer/code/ai_review.go new file mode 100644 index 00000000000..6350d389f51 --- /dev/null +++ b/backend/core/models/domainlayer/code/ai_review.go @@ -0,0 +1,58 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package code + +import ( + "time" + + "github.com/apache/devlake/core/models/domainlayer" +) + +// AiReview is the project-scoped domain representation of an AI code review. +// Converted from _tool_aireview_reviews; stamped with project_name so Grafana +// can filter with a single WHERE clause instead of joining through project_mapping. +type AiReview struct { + domainlayer.DomainEntity + + ProjectName string `gorm:"index;type:varchar(255)"` + PullRequestId string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + + CreatedDate time.Time `gorm:"index"` + + RiskLevel string `gorm:"type:varchar(50)"` + RiskScore int + + IssuesFound int + SuggestionsCount int + + PreMergeChecksPassed int `gorm:"default:0"` + PreMergeChecksFailed int `gorm:"default:0"` + + ReactionsTotalCount int `gorm:"default:0"` + ReactionsThumbsUp int `gorm:"default:0"` + ReactionsThumbsDown int `gorm:"default:0"` + + ReviewState string `gorm:"type:varchar(50)"` + SourceUrl string `gorm:"type:varchar(500)"` +} + +func (AiReview) TableName() string { + return "ai_reviews" +} diff --git a/backend/core/models/domainlayer/domaininfo/domaininfo.go b/backend/core/models/domainlayer/domaininfo/domaininfo.go index 17c9406c528..c6e3861dbed 100644 --- a/backend/core/models/domainlayer/domaininfo/domaininfo.go +++ b/backend/core/models/domainlayer/domaininfo/domaininfo.go @@ -30,6 +30,10 @@ import ( func GetDomainTablesInfo() []dal.Tabler { return []dal.Tabler{ // code + &code.AiReview{}, + &code.AiCommit{}, + &code.AiFailurePrediction{}, + &code.AiPredictionMetrics{}, &code.Commit{}, &code.CommitFile{}, &code.CommitFileComponent{}, diff --git a/backend/core/models/migrationscripts/20260422000001_add_ai_review_domain_tables.go b/backend/core/models/migrationscripts/20260422000001_add_ai_review_domain_tables.go new file mode 100644 index 00000000000..0c5ec2c3d07 --- /dev/null +++ b/backend/core/models/migrationscripts/20260422000001_add_ai_review_domain_tables.go @@ -0,0 +1,150 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addAiReviewDomainTables)(nil) + +type addAiReviewDomainTables struct{} + +// Archived snapshots of the three domain structs — frozen at migration time so +// future model changes do not affect the schema this migration creates. + +type archivedAiReview20260422 struct { + Id string `gorm:"primaryKey;type:varchar(255)"` + + ProjectName string `gorm:"index;type:varchar(255)"` + PullRequestId string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + CreatedDate time.Time `gorm:"index"` + + RiskLevel string `gorm:"type:varchar(50)"` + RiskScore int + + IssuesFound int + SuggestionsCount int + + PreMergeChecksPassed int `gorm:"default:0"` + PreMergeChecksFailed int `gorm:"default:0"` + + ReactionsTotalCount int `gorm:"default:0"` + ReactionsThumbsUp int `gorm:"default:0"` + ReactionsThumbsDown int `gorm:"default:0"` + + ReviewState string `gorm:"type:varchar(50)"` + SourceUrl string `gorm:"type:varchar(500)"` + + CreatedAt time.Time + UpdatedAt *time.Time +} + +func (archivedAiReview20260422) TableName() string { return "ai_reviews" } + +type archivedAiFailurePrediction20260422 struct { + Id string `gorm:"primaryKey;type:varchar(255)"` + + ProjectName string `gorm:"index;type:varchar(255)"` + PullRequestId string `gorm:"index;type:varchar(255)"` + PullRequestKey string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + RepoName string `gorm:"type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + CiFailureSource string `gorm:"type:varchar(20);index"` + PrTitle string `gorm:"type:varchar(500)"` + PrUrl string `gorm:"type:varchar(1024)"` + PrAuthor string `gorm:"type:varchar(255)"` + PrCreatedAt time.Time + Additions int + Deletions int + + WasFlaggedRisky bool + RiskScore int + FlaggedAt time.Time + HadCiFailure bool + PredictionOutcome string `gorm:"type:varchar(20)"` + + CreatedAt time.Time + UpdatedAt *time.Time +} + +func (archivedAiFailurePrediction20260422) TableName() string { return "ai_failure_predictions" } + +type archivedAiPredictionMetrics20260422 struct { + Id string `gorm:"primaryKey;type:varchar(255)"` + + ProjectName string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + CiFailureSource string `gorm:"type:varchar(20);index"` + PeriodStart time.Time `gorm:"index"` + PeriodEnd time.Time + PeriodType string `gorm:"type:varchar(20)"` + + TruePositives int + FalsePositives int + FalseNegatives int + TrueNegatives int + + Precision float64 + Recall float64 + Accuracy float64 + F1Score float64 + Specificity float64 + FprPct float64 + PrAuc float64 + RocAuc float64 + + WarningThreshold int + TotalPrs int + FlaggedPrs int + FailedPrs int + RecommendedAutonomyLevel string `gorm:"type:varchar(50)"` + CalculatedAt time.Time + + CreatedAt time.Time + UpdatedAt *time.Time +} + +func (archivedAiPredictionMetrics20260422) TableName() string { return "ai_prediction_metrics" } + +func (*addAiReviewDomainTables) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + if err := db.AutoMigrate(&archivedAiReview20260422{}); err != nil { + return err + } + if err := db.AutoMigrate(&archivedAiFailurePrediction20260422{}); err != nil { + return err + } + return db.AutoMigrate(&archivedAiPredictionMetrics20260422{}) +} + +func (*addAiReviewDomainTables) Version() uint64 { + return 20260422000001 +} + +func (*addAiReviewDomainTables) Name() string { + return "add ai_reviews, ai_failure_predictions, ai_prediction_metrics domain tables" +} diff --git a/backend/core/models/migrationscripts/20260422000002_fix_ai_review_domain_columns.go b/backend/core/models/migrationscripts/20260422000002_fix_ai_review_domain_columns.go new file mode 100644 index 00000000000..64f535e49c7 --- /dev/null +++ b/backend/core/models/migrationscripts/20260422000002_fix_ai_review_domain_columns.go @@ -0,0 +1,154 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*fixAiReviewDomainColumns)(nil) + +type fixAiReviewDomainColumns struct{} + +// rawDataOriginFields is the frozen snapshot of common.NoPKModel fields that +// GORM expects when calling CreateOrUpdate on domain entities. +type rawDataOriginFields struct { + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)"` + RawDataId uint64 `gorm:"column:_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark"` +} + +type fullAiReview20260422 struct { + Id string `gorm:"primaryKey;type:varchar(255)"` + ProjectName string `gorm:"index;type:varchar(255)"` + PullRequestId string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + CreatedDate time.Time `gorm:"index"` + RiskLevel string `gorm:"type:varchar(50)"` + RiskScore int + IssuesFound int + SuggestionsCount int + PreMergeChecksPassed int `gorm:"default:0"` + PreMergeChecksFailed int `gorm:"default:0"` + ReactionsTotalCount int `gorm:"default:0"` + ReactionsThumbsUp int `gorm:"default:0"` + ReactionsThumbsDown int `gorm:"default:0"` + ReviewState string `gorm:"type:varchar(50)"` + SourceUrl string `gorm:"type:varchar(500)"` + CreatedAt time.Time + UpdatedAt *time.Time + rawDataOriginFields +} + +func (fullAiReview20260422) TableName() string { return "ai_reviews" } + +type fullAiFailurePrediction20260422 struct { + Id string `gorm:"primaryKey;type:varchar(255)"` + ProjectName string `gorm:"index;type:varchar(255)"` + PullRequestId string `gorm:"index;type:varchar(255)"` + PullRequestKey string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + RepoName string `gorm:"type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + CiFailureSource string `gorm:"type:varchar(20);index"` + PrTitle string `gorm:"type:varchar(500)"` + PrUrl string `gorm:"type:varchar(1024)"` + PrAuthor string `gorm:"type:varchar(255)"` + PrCreatedAt time.Time + Additions int + Deletions int + WasFlaggedRisky bool + RiskScore int + FlaggedAt time.Time + HadCiFailure bool + PredictionOutcome string `gorm:"type:varchar(20)"` + CreatedAt time.Time + UpdatedAt *time.Time + rawDataOriginFields +} + +func (fullAiFailurePrediction20260422) TableName() string { return "ai_failure_predictions" } + +type fullAiPredictionMetrics20260422 struct { + Id string `gorm:"primaryKey;type:varchar(255)"` + ProjectName string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + CiFailureSource string `gorm:"type:varchar(20);index"` + PeriodStart time.Time `gorm:"index"` + PeriodEnd time.Time + PeriodType string `gorm:"type:varchar(20)"` + TruePositives int + FalsePositives int + FalseNegatives int + TrueNegatives int + Precision float64 + Recall float64 + Accuracy float64 + F1Score float64 + Specificity float64 + FprPct float64 + PrAuc float64 + RocAuc float64 + WarningThreshold int + TotalPrs int + FlaggedPrs int + FailedPrs int + RecommendedAutonomyLevel string `gorm:"type:varchar(50)"` + CalculatedAt time.Time + CreatedAt time.Time + UpdatedAt *time.Time + rawDataOriginFields +} + +func (fullAiPredictionMetrics20260422) TableName() string { return "ai_prediction_metrics" } + +func (*fixAiReviewDomainColumns) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + // AutoMigrate with embedded structs does not reliably add columns whose names + // start with '_'. Use explicit ALTER TABLE statements instead. + tables := []string{"ai_reviews", "ai_failure_predictions", "ai_prediction_metrics"} + for _, table := range tables { + sqls := []string{ + "ALTER TABLE `" + table + "` ADD COLUMN `_raw_data_params` varchar(255)", + "ALTER TABLE `" + table + "` ADD COLUMN `_raw_data_table` varchar(255)", + "ALTER TABLE `" + table + "` ADD COLUMN `_raw_data_id` bigint unsigned", + "ALTER TABLE `" + table + "` ADD COLUMN `_raw_data_remark` longtext", + } + for _, sql := range sqls { + if err := db.Exec(sql); err != nil { + return errors.Default.Wrap(err, "failed: "+sql) + } + } + } + return nil +} + +func (*fixAiReviewDomainColumns) Version() uint64 { + return 20260422000002 +} + +func (*fixAiReviewDomainColumns) Name() string { + return "add missing _raw_data_* columns to ai review domain tables" +} diff --git a/backend/core/models/migrationscripts/20260826_add_ai_commits_domain.go b/backend/core/models/migrationscripts/20260826_add_ai_commits_domain.go new file mode 100644 index 00000000000..d387e1a7cb1 --- /dev/null +++ b/backend/core/models/migrationscripts/20260826_add_ai_commits_domain.go @@ -0,0 +1,79 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addAiCommitsDomain)(nil) + +type addAiCommitsDomain struct{} + +type archivedAiCommit20260826 struct { + Id string `gorm:"primaryKey;type:varchar(255)"` + ProjectName string `gorm:"index;type:varchar(255)"` + CommitSha string `gorm:"index;type:varchar(40)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + AuthorName string `gorm:"type:varchar(255)"` + AuthoredDate time.Time + CreatedAt time.Time + UpdatedAt *time.Time + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)"` + RawDataId uint64 `gorm:"column:_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark"` +} + +func (archivedAiCommit20260826) TableName() string { return "ai_commits" } + +func (*addAiCommitsDomain) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + if err := db.AutoMigrate(&archivedAiCommit20260826{}); err != nil { + return err + } + // AutoMigrate often skips columns whose names start with '_'. + alters := []struct{ column, sql string }{ + {"_raw_data_params", "ALTER TABLE `ai_commits` ADD COLUMN `_raw_data_params` varchar(255)"}, + {"_raw_data_table", "ALTER TABLE `ai_commits` ADD COLUMN `_raw_data_table` varchar(255)"}, + {"_raw_data_id", "ALTER TABLE `ai_commits` ADD COLUMN `_raw_data_id` bigint unsigned"}, + {"_raw_data_remark", "ALTER TABLE `ai_commits` ADD COLUMN `_raw_data_remark` longtext"}, + } + for _, a := range alters { + if db.HasColumn("ai_commits", a.column) { + continue + } + if err := db.Exec(a.sql); err != nil { + return errors.Default.Wrap(err, "failed: "+a.sql) + } + } + return nil +} + +func (*addAiCommitsDomain) Version() uint64 { + return 20260826000001 +} + +func (*addAiCommitsDomain) Name() string { + return "add ai_commits domain table" +} diff --git a/backend/core/models/migrationscripts/register.go b/backend/core/models/migrationscripts/register.go index 94994b612e0..b5b1f834e8c 100644 --- a/backend/core/models/migrationscripts/register.go +++ b/backend/core/models/migrationscripts/register.go @@ -155,5 +155,8 @@ func All() []plugin.MigrationScript { new(backfillSubProjectFromMonorepo), new(addBlueprintIdIndexToPipelines), new(expandDomainTextColumns), + new(addAiReviewDomainTables), + new(fixAiReviewDomainColumns), + new(addAiCommitsDomain), } } diff --git a/backend/go.mod b/backend/go.mod index 338b70f60d4..773d1b916f9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module github.com/apache/devlake go 1.27.0 require ( + cloud.google.com/go/storage v1.70.0 github.com/aws/aws-sdk-go-v2 v1.47.4 github.com/aws/aws-sdk-go-v2/config v1.33.10 github.com/aws/aws-sdk-go-v2/credentials v1.20.10 @@ -11,6 +12,7 @@ require ( github.com/chainguard-dev/git-urls v1.0.2 github.com/cockroachdb/errors v1.14.0 github.com/coreos/go-oidc/v3 v3.21.0 + github.com/gin-contrib/cors v1.7.9 // github.com/gin-contrib/cors v1.6.0 github.com/gin-gonic/gin v1.12.0 github.com/go-git/go-git/v5 v5.19.3 @@ -21,13 +23,16 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.11.0 + github.com/konflux-ci/coverport/instrumentation/go v0.0.0-20261009063148-e4b4dacb404f github.com/libgit2/git2go/v34 v34.0.0 github.com/magiconair/properties v1.18.12 github.com/merico-ai/graphql v0.0.0-20260206020408-b7fd267bcfac + github.com/mitchellh/mapstructure v1.5.0 github.com/panjf2000/ants/v2 v2.12.1 github.com/robfig/cron/v3 v3.0.1 github.com/rogpeppe/go-internal v1.16.0 github.com/sirupsen/logrus v1.10.2 + github.com/snowflakedb/gosnowflake v1.19.1 github.com/spf13/cast v1.10.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -41,6 +46,8 @@ require ( golang.org/x/mod v0.42.0 golang.org/x/oauth2 v0.37.0 golang.org/x/sync v0.24.0 + google.golang.org/api v0.309.0 + gopkg.in/yaml.v3 v3.0.1 gorm.io/datatypes v1.2.7 gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.3 @@ -48,15 +55,35 @@ require ( ) require ( + cel.dev/expr v0.25.2 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.24.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect + cloud.google.com/go/compute/metadata v0.10.0 // indirect + cloud.google.com/go/iam v1.12.0 // indirect + cloud.google.com/go/monitoring v1.30.0 // indirect dario.cat/mergo v1.0.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/99designs/keyring v1.2.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/apache/arrow-go/v18 v18.4.0 // indirect + github.com/apache/thrift v0.22.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.24 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.7 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.7 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.7 // indirect @@ -69,33 +96,49 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.51.5 // indirect github.com/aws/smithy-go v1.28.6 // indirect - github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.15.0 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.2 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect - github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect github.com/cockroachdb/redact v1.1.5 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/getsentry/sentry-go v0.46.0 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.2 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.6 // indirect github.com/go-openapi/spec v0.20.4 // indirect github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/google/s2a-go v0.1.11 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.23 // indirect + github.com/googleapis/gax-go/v2 v2.26.2 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -106,25 +149,34 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/asmfmt v1.3.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.5.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.23 // indirect + github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect + github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/mtibben/percent v0.2.1 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.7.0 // indirect + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.1 // indirect + github.com/quic-go/quic-go v0.60.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -132,14 +184,32 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.45.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + go.opentelemetry.io/otel/sdk v1.45.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect + go.opentelemetry.io/otel/trace v1.45.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.57.0 // indirect - golang.org/x/net v0.59.0 // indirect - golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect + golang.org/x/arch v0.29.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157 // indirect + golang.org/x/term v0.47.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.51.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/grpc v1.84.0 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 14a262a56a0..289d1dbdfb0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,7 +1,53 @@ +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.24.1 h1:NTmd2iJb4DsGIav0KiqFMYI88nMshA2wnFMBatRmoCs= +cloud.google.com/go/auth v0.24.1/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c= +cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88= +cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk= +cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= +cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= +cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk= +cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY= +cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q= +cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA= +cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM= +cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0= +cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY= +cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM= +cloud.google.com/go/storage v1.70.0 h1:LQNHn0rRF94jVFfF3e416IzdQqIufa5GfF6HilQxb80= +cloud.google.com/go/storage v1.70.0/go.mod h1:W84cNa070TZIegvTW67CBY+f5rxux8EJMb7YyiHdrls= +cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 h1:rTnT/Jrcm+figWlYz4Ixzt0SJVR2cMC8lvZcimipiEY= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0 h1:QkAcEIAKbNL4KoFr4SathZPhDhF4mVwpBMFlYjyAqy8= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 h1:+5VZ72z0Qan5Bog5C+ZkgSqUbeVUd9wgtHOrIKuc5b8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 h1:u/LLAOFgsMv7HmNL4Qufg58y+qElGOt5qv0z1mURkRY= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0/go.mod h1:2e8rMJtl2+2j+HXbTBwnyGpm5Nou7KhvSfxOq8JpTag= +github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1 h1:BWe8a+f/t+7KY7zH2mqygeUD0t8hNFXe08p1Pb3/jKE= +github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= 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.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -13,8 +59,14 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN 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/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apache/arrow-go/v18 v18.4.0 h1:/RvkGqH517iY8bZKc4FD5/kkdwXJGjxf28JIXbJ/oB0= +github.com/apache/arrow-go/v18 v18.4.0/go.mod h1:Aawvwhj8x2jURIzD9Moy72cF0FyJXOpkYpdmGRHcw14= +github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= +github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2 v1.47.4 h1:bTvk6UdSSSvaQsfLVj1JRDEEqj/PDRwGf1KanaRzau0= @@ -27,6 +79,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.20.10 h1:ItSjcLk+2M15RnRxO105uVIqLiV github.com/aws/aws-sdk-go-v2/credentials v1.20.10/go.mod h1:eO4GlmGXB+gx+zDbhu3IENEFOmjrbVKJtOptJiIhngc= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.4 h1:7ksj/zMap9+831SmNQZkcpmfuATp3DKr4BfLNnBdYXM= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.4/go.mod h1:zhi8vDrD1dj76ysTWS6Ny1y6/EEW1Jrq2y8lzKeoqfs= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15 h1:7Zwtt/lP3KNRkeZre7soMELMGNoBrutx8nobg1jKWmo= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15/go.mod h1:436h2adoHb57yd+8W+gYPrrA9U/R/SuAuOO42Ushzhw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.7 h1:fSNAt6hs4qLjb9tMZc6vj3PHlOCYGXK2JjJZ7AW2tf4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.7/go.mod h1:TMav+EuXQu5kNpZZz/6rIC8xx4mCsW+6AKHrjvOSAsE= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.7 h1:7NS6kqBjkWIN8P/AEdmtyq6eQGAhN1oHTZdzXclpH6Y= @@ -55,18 +109,22 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.51.5 h1:OuyNaMncYP4ftrvcnOHJ9j3AxO3z github.com/aws/aws-sdk-go-v2/service/sts v1.51.5/go.mod h1:Bhu66tgvf94uunUSiAc0Ew0avhnpSm0J8Hl1MYocRbo= github.com/aws/smithy-go v1.28.6 h1:Pj+/sUviNYjow8ELRtNwKHZ9ECeF+7pATX3+shjLIrw= github.com/aws/smithy-go v1.28.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= -github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= +github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +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/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ= github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= -github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cockroachdb/errors v1.14.0 h1:EfdVEJpN3z8rPMo43Yit59LxoiIa470fSXpZXuEs+ZI= github.com/cockroachdb/errors v1.14.0/go.mod h1:xRa70jZ9sNBQmISt5KmJmAD++E4dQHm89oCRiZGEdq0= github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= @@ -79,13 +137,30 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/dnaeon/go-vcr v1.1.0 h1:ReYa/UBrRyQdant9B4fNHGoCNKw6qh6P0fsdGmZpR7c= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= +github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -94,10 +169,12 @@ github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/getsentry/sentry-go v0.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0= github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= +github.com/gin-contrib/cors v1.7.9 h1:69rz5YU6PW7XlQ4VPXacl77ldFiUvR0E0bmEqmbq86w= +github.com/gin-contrib/cors v1.7.9/go.mod h1:KTqiTA5HzlWPgVz7p4tolzAuptzGO7DEJn/CbklG0nU= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= -github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -114,6 +191,11 @@ github.com/go-git/go-git/v5 v5.19.3 h1:qHttbZ+Am7wEjdTpR1XMQ4rl42FK9SIXzZ4o9x7s6 github.com/go-git/go-git/v5 v5.19.3/go.mod h1:Ye4C8dVigkqrv9UsB4NMdSLV4yAIkLiIhW61gcOyhl4= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/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-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -138,12 +220,16 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gocarina/gocsv v0.0.0-20220707092902-b9da1f06c77e h1:GMIV+S6grz+vlIaUsP+fedQ6L+FovyMPMY26WO8dwQE= github.com/gocarina/gocsv v0.0.0-20220707092902-b9da1f06c77e/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= 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 v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= @@ -152,13 +238,29 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +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 v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.11 h1:7v1V1TraNGP63WptsAkNqr2w8jgm4vM6A6qx6Lsw9iM= +github.com/google/s2a-go v0.1.11/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 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/googleapis/enterprise-certificate-proxy v0.3.23 h1:J+VGF1wjAM2dU2kKUXZxJdDppRLReWP7KWcclfngQhc= +github.com/googleapis/enterprise-certificate-proxy v0.3.23/go.mod h1:Y8NO/15GNLkrXI3OR5xOFmeU3XLX1sLSdtlXgJ0zmIw= +github.com/googleapis/gax-go/v2 v2.26.2 h1:ydkmNXxj7bEmmeK5AihkKnWxyOyBR9TDebvp5L5izk8= +github.com/googleapis/gax-go/v2 v2.26.2/go.mod h1:sMKqnMesnKH+3wiRJROcttA+cJoZoGbZl1vDQ8XYtGk= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -183,8 +285,14 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/konflux-ci/coverport/instrumentation/go v0.0.0-20261009063148-e4b4dacb404f h1:JVZW5quKMUMyJQOP/+aqaWnYBm51iJAO/OmS9GcGNmQ= +github.com/konflux-ci/coverport/instrumentation/go v0.0.0-20261009063148-e4b4dacb404f/go.mod h1:i6BCROSU0M/Fk4QhzBbnY4R6JVq0hU7jlUSSxjd8dD4= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -192,6 +300,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= github.com/libgit2/git2go/v34 v34.0.0 h1:UKoUaKLmiCRbOCD3PtUi2hD6hESSXzME/9OUZrGcgu8= @@ -203,38 +313,54 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -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/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/merico-ai/graphql v0.0.0-20260206020408-b7fd267bcfac h1:J3wm8OohrRkYf/+Zy5GxqvhzcP2qHGJRduxTUUbBMdw= github.com/merico-ai/graphql v0.0.0-20260206020408-b7fd267bcfac/go.mod h1:4WSOgvd/Sv6eLKJE6fvrXlmxePBDGhbwRYKEBeqJr8g= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/panjf2000/ants/v2 v2.12.1 h1:BWvU2wHpyXWxhhNXsGB6JXLCNbshyLd1QxvoAmZnu10= github.com/panjf2000/ants/v2 v2.12.1/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pjbgf/sha1cd v0.7.0 h1:ZRNPKHj+gfkLBf0KJv/p1Hmz+7bqCT5o311YX0nq+DA= github.com/pjbgf/sha1cd v0.7.0/go.mod h1:pKR5Li+qTCo+ebqITZfwPlJGoCFCvSO00qxjbNtTUFQ= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= -github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -250,6 +376,8 @@ github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBB github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/snowflakedb/gosnowflake v1.19.1 h1:NZMErtdZMu6kooehbONNQmu/W5BPsaX8hYdlBBEHgxs= +github.com/snowflakedb/gosnowflake v1.19.1/go.mod h1:9vGW6LYbUD1UqfjpuNN5a5vtha+u4n1AlsR1BqhHwPA= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -263,6 +391,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= 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= @@ -302,11 +432,39 @@ github.com/viant/afs v1.30.0 h1:dbgVVSCPwGHUgpgkWJ5gdjKBqssT7OV7Z2M81CjwZEY= github.com/viant/afs v1.30.0/go.mod h1:rScbFd9LJPGTM8HOI8Kjwee0AZ+MZMupAvFpPg+Qdj4= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= -go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= +go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.45.0 h1:9jR0ZPRok9ryaOQ2Wx8rg5F7Aon59mxrqbVI60/vlBk= +go.opentelemetry.io/contrib/detectors/gcp v1.45.0/go.mod h1:VSme3o2fvSg5bVg0dRzyHaj4Z5EVhG+g2Fde6LKzmQA= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= 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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -314,16 +472,16 @@ go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= -golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= +golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -340,8 +498,8 @@ golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -360,26 +518,30 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157 h1:DftxASHKl6JolFPqoN+2WrFO53JvkHwRXvFqf8gsivA= +golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -391,12 +553,27 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.309.0 h1:DXP86di4cL3R3R6aUrJNUNthav2djJ99QbSOrN63238= +google.golang.org/api v0.309.0/go.mod h1:4XMH+b/ZEfuTbK7FnrFRY9c4xfFHVflGTKuy/i5wUmE= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d h1:QwnJwPte4XXAkhPu26LTDIahnsMSUV0kK8HkxbC+Pc4= +google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/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/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= diff --git a/backend/helpers/oidchelper/config.go b/backend/helpers/oidchelper/config.go index 6f0a4f0dc7b..c84c25e242b 100644 --- a/backend/helpers/oidchelper/config.go +++ b/backend/helpers/oidchelper/config.go @@ -93,11 +93,11 @@ func (c *Config) ProviderNames() []string { } // LoadConfig reads auth env vars via Viper and validates required fields. -// AUTH_ENABLED defaults to false unless it is explicitly set to true. +// AUTH_ENABLED defaults to true unless it is explicitly set to false. func LoadConfig(basicRes context.BasicRes) (*Config, error) { cfg := basicRes.GetConfigReader() - authEnabled := false + authEnabled := true if cfg.IsSet("AUTH_ENABLED") { authEnabled = cfg.GetBool("AUTH_ENABLED") } diff --git a/backend/helpers/oidchelper/config_test.go b/backend/helpers/oidchelper/config_test.go index c74d47c4147..27f63c23037 100644 --- a/backend/helpers/oidchelper/config_test.go +++ b/backend/helpers/oidchelper/config_test.go @@ -105,15 +105,15 @@ func (b basicResStub) ReplaceLogger(log.Logger) corectx.BasicRes { } func (b basicResStub) GetDal() dal.Dal { return nil } -func TestLoadConfigDefaultsAuthDisabled(t *testing.T) { +func TestLoadConfigDefaultsAuthEnabled(t *testing.T) { v := viper.New() cfg, err := LoadConfig(basicResStub{cfg: v}) if err != nil { t.Fatalf("LoadConfig returned error: %v", err) } - if cfg.AuthEnabled { - t.Fatal("AuthEnabled should default to false when AUTH_ENABLED is unset") + if !cfg.AuthEnabled { + t.Fatal("AuthEnabled should default to true when AUTH_ENABLED is unset") } if cfg.OIDCEnabled { t.Fatal("OIDCEnabled should default to false") @@ -123,6 +123,19 @@ func TestLoadConfigDefaultsAuthDisabled(t *testing.T) { } } +func TestLoadConfigExplicitAuthDisabled(t *testing.T) { + v := viper.New() + v.Set("AUTH_ENABLED", false) + + cfg, err := LoadConfig(basicResStub{cfg: v}) + if err != nil { + t.Fatalf("LoadConfig returned error: %v", err) + } + if cfg.AuthEnabled { + t.Fatal("AuthEnabled should be false when AUTH_ENABLED=false") + } +} + func TestLoadConfigRequiresSessionSecretForOIDC(t *testing.T) { v := viper.New() v.Set("AUTH_ENABLED", true) diff --git a/backend/helpers/pluginhelper/api/graphql_collector.go b/backend/helpers/pluginhelper/api/graphql_collector.go index 2601e4e4f51..a5ac7d51cbd 100644 --- a/backend/helpers/pluginhelper/api/graphql_collector.go +++ b/backend/helpers/pluginhelper/api/graphql_collector.go @@ -20,6 +20,7 @@ package api import ( "context" "encoding/json" + "fmt" "net/http" "reflect" "strings" @@ -187,9 +188,11 @@ func (collector *GraphqlCollector) Execute() errors.Error { collector.args.GraphqlClient.Wait() if collector.HasError() { + for i, workerErr := range collector.workerErrors { + logger.Error(nil, "graphql collector worker error [%d/%d]: %s", i+1, len(collector.workerErrors), workerErr) + } err = errors.Default.Combine(collector.workerErrors) logger.Error(err, "ended Graphql collector execution with error") - logger.Error(collector.workerErrors[0], "the first error of them") return err } else { logger.Info("ended api collection without error") @@ -270,7 +273,9 @@ func (collector *GraphqlCollector) fetchAsync(reqData *GraphqlRequestData, handl // direct error message for error combine collector.checkError(err) } else { - collector.checkError(errors.Default.Wrap(err, `graphql query failed`)) + msg := formatGraphqlQueryFailure(err, variables) + logger.Error(nil, "%s", msg) + collector.checkError(errors.Default.New(msg)) } return } @@ -279,11 +284,13 @@ func (collector *GraphqlCollector) fetchAsync(reqData *GraphqlRequestData, handl hasNonIgnorableDataErrors := false for _, dataError := range dataErrors { if isIgnorableGraphqlQueryError(dataError) { - logger.Warn(nil, "Issue may have been transferred or deleted.") + logger.Warn(nil, "ignorable graphql data error (issue transferred or deleted): %s", formatGraphqlDataError(dataError, variables)) continue } hasNonIgnorableDataErrors = true - collector.checkError(errors.Default.Wrap(dataError, `graphql query got error`)) + msg := formatGraphqlDataError(dataError, variables) + logger.Error(nil, "%s", msg) + collector.checkError(errors.Default.New(msg)) } if hasNonIgnorableDataErrors { return @@ -352,4 +359,31 @@ func isIgnorableGraphqlQueryError(err error) bool { return err != nil && strings.Contains(err.Error(), "Could not resolve to an Issue") } +func formatGraphqlQueryFailure(err error, variables map[string]interface{}) string { + return fmt.Sprintf("graphql query failed: %s%s", err.Error(), formatGraphqlVariables(variables)) +} + +func formatGraphqlDataError(err error, variables map[string]interface{}) string { + msg := err.Error() + if de, ok := err.(graphql.DataError); ok && len(de.Locations) > 0 { + locs := make([]string, 0, len(de.Locations)) + for _, loc := range de.Locations { + locs = append(locs, fmt.Sprintf("line %d col %d", loc.Line, loc.Column)) + } + msg = fmt.Sprintf("%s (%s)", msg, strings.Join(locs, ", ")) + } + return fmt.Sprintf("graphql query got error: %s%s", msg, formatGraphqlVariables(variables)) +} + +func formatGraphqlVariables(variables map[string]interface{}) string { + if len(variables) == 0 { + return "" + } + varsJSON, err := json.Marshal(variables) + if err != nil { + return fmt.Sprintf(" variables=%v", variables) + } + return fmt.Sprintf(" variables=%s", varsJSON) +} + var _ plugin.SubTask = (*GraphqlCollector)(nil) diff --git a/backend/helpers/pluginhelper/api/graphql_collector_test.go b/backend/helpers/pluginhelper/api/graphql_collector_test.go index fcab4dce098..ef530c4bef4 100644 --- a/backend/helpers/pluginhelper/api/graphql_collector_test.go +++ b/backend/helpers/pluginhelper/api/graphql_collector_test.go @@ -21,6 +21,7 @@ import ( "errors" "testing" + "github.com/merico-ai/graphql" "github.com/stretchr/testify/assert" ) @@ -29,3 +30,29 @@ func TestIsIgnorableGraphqlQueryError(t *testing.T) { assert.False(t, isIgnorableGraphqlQueryError(errors.New("some other graphql error"))) assert.False(t, isIgnorableGraphqlQueryError(nil)) } + +func TestFormatGraphqlDataError(t *testing.T) { + err := graphql.DataError{ + Message: "Could not resolve to a PullRequest with the number of 42.", + Locations: []struct { + Line int + Column int + }{{Line: 3, Column: 5}}, + } + got := formatGraphqlDataError(err, map[string]interface{}{ + "owner": "acme", + "name": "repo", + }) + assert.Contains(t, got, "Could not resolve to a PullRequest with the number of 42.") + assert.Contains(t, got, "line 3 col 5") + assert.Contains(t, got, `"owner":"acme"`) + assert.Contains(t, got, `"name":"repo"`) +} + +func TestFormatGraphqlQueryFailure(t *testing.T) { + got := formatGraphqlQueryFailure(errors.New("non-200 OK status code: 502"), map[string]interface{}{ + "pageSize": 10, + }) + assert.Contains(t, got, "graphql query failed: non-200 OK status code: 502") + assert.Contains(t, got, `"pageSize":10`) +} diff --git a/backend/pkg/gcshelper/client.go b/backend/pkg/gcshelper/client.go new file mode 100644 index 00000000000..5c89b65918f --- /dev/null +++ b/backend/pkg/gcshelper/client.go @@ -0,0 +1,151 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package gcshelper provides a lightweight Google Cloud Storage client for +// reading objects from public GCS buckets, primarily for Openshift CI / Prow +// test result retrieval. +package gcshelper + +import ( + "context" + "fmt" + "io" + + "cloud.google.com/go/storage" + "github.com/apache/devlake/core/errors" + "google.golang.org/api/iterator" + "google.golang.org/api/option" +) + +// OpenshiftCIBucketName is the anonymously readable GCS bucket that stores +// Prow job artifacts. OpenShift CI cut over from test-platform-results to +// test-platform-results-public around 2026-09-17; the old bucket no longer +// allows unauthenticated listing. +const OpenshiftCIBucketName = "test-platform-results-public" + +// maxFileSize caps individual GCS object reads at 10 MB. +const maxFileSize = 10 * 1024 * 1024 + +// HistoryStore is the minimal interface required for fetching Prow job metadata +// from a GCS-like store. Implementations include *GCSBucket (real GCS) and +// test fakes. +type HistoryStore interface { + // ListSubdirectories returns the immediate child "directory" prefixes one + // level below the given prefix (using "/" as the GCS delimiter). + ListSubdirectories(ctx context.Context, prefix string) ([]string, error) + // ReadFile retrieves the full content of the object at path. + ReadFile(ctx context.Context, path string) ([]byte, error) +} + +// GCSBucket wraps a Google Cloud Storage bucket handle. +type GCSBucket struct { + client *storage.Client + bkt *storage.BucketHandle +} + +// New creates a GCSBucket client for the given bucket using unauthenticated +// (public) access. +func New(ctx context.Context, bucketName string) (*GCSBucket, errors.Error) { + client, err := storage.NewClient(ctx, option.WithoutAuthentication()) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to create GCS client") + } + return &GCSBucket{ + client: client, + bkt: client.Bucket(bucketName), + }, nil +} + +// Close releases resources held by the GCS client. +func (b *GCSBucket) Close() error { + return b.client.Close() +} + +// ListSubdirectories returns the immediate child "directory" prefixes one level +// below the given prefix. It uses "/" as the GCS delimiter so that only direct +// children are returned, not all recursive objects. +func (b *GCSBucket) ListSubdirectories(ctx context.Context, prefix string) ([]string, error) { + query := &storage.Query{Prefix: prefix, Delimiter: "/"} + var subdirs []string + it := b.bkt.Objects(ctx, query) + for { + attrs, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return subdirs, fmt.Errorf("GCS listing failed for %s: %w", prefix, err) + } + if attrs.Prefix != "" { + subdirs = append(subdirs, attrs.Prefix) + } + } + return subdirs, nil +} + +// ReadFile retrieves the full content of the GCS object at path. +func (b *GCSBucket) ReadFile(ctx context.Context, path string) ([]byte, error) { + data, err := b.GetContent(ctx, path) + if err != nil { + return nil, err + } + return data, nil +} + +// GetContent retrieves the content of a GCS object, capped at maxFileSize. +func (b *GCSBucket) GetContent(ctx context.Context, path string) ([]byte, errors.Error) { + if path == "" { + return nil, errors.BadInput.New("missing path to GCS content") + } + + obj := b.bkt.Object(path) + objAttrs, err := obj.Attrs(ctx) + if err != nil { + return nil, errors.Default.Wrap(err, "error reading GCS attributes") + } + obj = obj.Generation(objAttrs.Generation) + if objAttrs.Size > maxFileSize { + return nil, errors.Default.New(fmt.Sprintf("GCS object too large (%d bytes, limit %d)", objAttrs.Size, maxFileSize)) + } + + gcsReader, err := obj.NewReader(ctx) + if err != nil { + return nil, errors.Default.Wrap(err, "error reading GCS content") + } + defer gcsReader.Close() + + content, err := io.ReadAll(io.LimitReader(gcsReader, maxFileSize)) + if err != nil { + return nil, errors.Default.Wrap(err, "error reading GCS content") + } + return content, nil +} + +// BucketHandle returns the underlying *storage.BucketHandle for callers that +// need raw GCS API access beyond what HistoryStore provides. +func (b *GCSBucket) BucketHandle() *storage.BucketHandle { + return b.bkt +} + +// ContentExists checks whether an object exists at the given GCS path. +func (b *GCSBucket) ContentExists(ctx context.Context, path string) bool { + if path == "" { + return false + } + _, err := b.bkt.Object(path).Attrs(ctx) + return err == nil +} diff --git a/backend/pkg/gcshelper/client_test.go b/backend/pkg/gcshelper/client_test.go new file mode 100644 index 00000000000..964ff170ba4 --- /dev/null +++ b/backend/pkg/gcshelper/client_test.go @@ -0,0 +1,28 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcshelper + +import "testing" + +func TestOpenshiftCIBucketNameIsPublic(t *testing.T) { + // Prow view_url values since 2026-09-17 use this bucket. Unauthenticated + // listing of the previous name (test-platform-results) is denied. + if OpenshiftCIBucketName != "test-platform-results-public" { + t.Fatalf("OpenshiftCIBucketName = %q, want test-platform-results-public", OpenshiftCIBucketName) + } +} diff --git a/backend/pkg/gcshelper/prow.go b/backend/pkg/gcshelper/prow.go new file mode 100644 index 00000000000..55e5ea36b71 --- /dev/null +++ b/backend/pkg/gcshelper/prow.go @@ -0,0 +1,114 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcshelper + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" +) + +// FinishedJSON mirrors the structure of finished.json written by Prow after +// each build completes. +type FinishedJSON struct { + Timestamp int64 `json:"timestamp"` + Passed bool `json:"passed"` + Result string `json:"result"` + Revision string `json:"revision"` +} + +// StartedJSON mirrors the structure of started.json written by Prow when a +// build starts. +type StartedJSON struct { + Timestamp int64 `json:"timestamp"` + Commit string `json:"commit"` +} + +// ParseFinishedJSON unmarshals a finished.json payload. +func ParseFinishedJSON(data []byte) (*FinishedJSON, error) { + var f FinishedJSON + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parse finished.json: %w", err) + } + return &f, nil +} + +// ReadStartedJSON fetches and parses the started.json file located directly +// inside buildPrefix (e.g. "pr-logs/pull/org_repo/42/job-name/1234/"). +func ReadStartedJSON(ctx context.Context, store HistoryStore, buildPrefix string) (*StartedJSON, error) { + data, err := store.ReadFile(ctx, buildPrefix+"started.json") + if err != nil { + return nil, fmt.Errorf("read started.json at %s: %w", buildPrefix, err) + } + var s StartedJSON + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("parse started.json at %s: %w", buildPrefix, err) + } + return &s, nil +} + +// MapProwResult converts a Prow result string and passed flag to a +// normalised CI result string ("success", "failure", "aborted", "error"). +func MapProwResult(result string, passed bool) string { + switch strings.ToUpper(result) { + case "SUCCESS": + return "success" + case "FAILURE": + return "failure" + case "ABORTED": + return "aborted" + case "ERROR": + return "error" + } + // Fallback to the passed flag when result string is absent or unrecognised. + if passed { + return "success" + } + return "failure" +} + +// LastSegment returns the last non-empty path component of a slash-delimited +// GCS prefix. For example: +// +// "pr-logs/pull/org_repo/42/job-name/1234/" → "1234" +func LastSegment(prefix string) string { + parts := strings.Split(strings.TrimSuffix(prefix, "/"), "/") + for i := len(parts) - 1; i >= 0; i-- { + if parts[i] != "" { + return parts[i] + } + } + return prefix +} + +// SortBuildIDsDescending sorts a slice of GCS build prefixes by their numeric +// build ID (last path segment) in descending order. Prefixes whose last +// segment is not a valid integer are placed at the end. +func SortBuildIDsDescending(prefixes []string) { + sort.Slice(prefixes, func(i, j int) bool { + a, errA := strconv.ParseInt(LastSegment(prefixes[i]), 10, 64) + b, errB := strconv.ParseInt(LastSegment(prefixes[j]), 10, 64) + if errA != nil || errB != nil { + return errA == nil + } + return a > b + }) +} diff --git a/backend/plugins/agentready/AGENTS.md b/backend/plugins/agentready/AGENTS.md new file mode 100644 index 00000000000..07317ee6374 --- /dev/null +++ b/backend/plugins/agentready/AGENTS.md @@ -0,0 +1,56 @@ +# agentready Plugin — Agent Context + +Data-source plugin that collects AI readiness assessments from a GitHub submissions repository, extracts structured findings per attribute, computes aggregated metrics, and exposes results via REST API and Grafana dashboards. + +## Build & Test + +```bash +cd backend +go test ./plugins/agentready/... -v # unit tests +golangci-lint run ./plugins/agentready/... # lint +``` + +Single-file verification: `go vet ./plugins/agentready/...` + +No e2e tests yet — data is validated via unit tests and manual Grafana dashboard checks. + +## Layout + +- `impl/impl.go` — plugin interfaces (PluginMeta, PluginInit, PluginApi, PluginModel, PluginMigration, PluginTask, DataSourcePluginBlueprintV200) +- `models/` — tool-layer models (connection, scope, scope_config, assessment, finding, metric) + `migrationscripts/register.go` +- `tasks/` — 3-stage pipeline: collector → extractor → calculator +- `tasks/task_data.go` — shared `AgentReadyOptions` and `AgentReadyTaskData` structs +- `api/` — REST endpoints (connections, scopes, scope-configs, remote-scopes, assessments, stats, blueprints) +- `grafana/` — 3 dashboards (fleet-overview, findings-analysis, repo-detail) + +## Conventions + +- This is a **data-source plugin**: has Connection, Scope (`org/repo`), ScopeConfig models +- Implements `DataSourcePluginBlueprintV200` for blueprint-driven collection +- Subtask execution order: `collectSubmissions` → `extractAssessments` → `calculateMetrics` (see `SubTaskMetas()`) +- `FullName` format: `"org/repo"` — derived from the GitHub submissions repo tree structure +- Composite primary keys: `(id, connection_id)` on assessments, findings, and metrics tables +- GitHub delegation: references a GitHub connection ID for API auth; does not store credentials itself +- Default branch auto-detection: `PrepareTaskData()` resolves the actual default branch from the GitHub API + +## Don'ts + +- Don't add models to `GetTablesInfo()` without a migration script in `migrationscripts/register.go` +- Don't import from other plugins (plugins must be independent) +- Don't skip the Apache 2.0 license header on new files +- Don't hardcode branch names — use the auto-detected branch from `PrepareTaskData()` +- Don't store GitHub credentials in the connection model — reference the GitHub connection ID instead + +## Pattern References + +| Change Type | Example File | +|---|---| +| Add new subtask | `tasks/submissions_collector.go` + register in `impl/impl.go:SubTaskMetas()` | +| Add migration | `models/migrationscripts/20260609_add_composite_primary_keys.go` | +| Add model field | model file + migration + update `GetTablesInfo()` | +| Add API endpoint | `api/assessments.go`, register in `impl/impl.go:ApiResources()` | +| Add Grafana dashboard | `grafana/fleet-overview.json` | + +## Skills + +- **PR Definition of Done**: see [skills/pr-definition-of-done/SKILL.md](skills/pr-definition-of-done/SKILL.md) diff --git a/backend/plugins/agentready/README.md b/backend/plugins/agentready/README.md new file mode 100644 index 00000000000..98e281c6c64 --- /dev/null +++ b/backend/plugins/agentready/README.md @@ -0,0 +1,321 @@ +# AgentReady Plugin + +## Overview + +The AgentReady plugin collects and analyzes AI readiness assessments from a centralized submissions repository. It fetches structured assessment JSON files via the GitHub API, extracts findings with tiered pass/fail results, and calculates aggregate metrics. Three Grafana dashboards provide fleet-level, findings-level, and per-repo visibility. + +Assessments are generated by [AgentReady](https://github.com/ambient-code/agentready), an open-source tool that evaluates git repositories for AI-assisted development readiness. AgentReady scores repositories across multiple evidence-based categories and produces certification levels (Platinum, Gold, Silver, Bronze). This plugin ingests those assessment outputs into DevLake for centralized tracking and trend analysis across your fleet. + +### Submissions Repository Structure + +The plugin reads assessment files from a GitHub repository organized by org and repo: + +``` +submissions/ + {org}/ + {repo}/ + 2026-02-07T14-42-31-assessment.json + 2026-03-01T10-00-00-assessment.json + another-org/ + another-repo/ + ... +``` + +Each `{org}/{repo}` folder becomes a **scope** — a trackable unit that can be independently collected and analyzed. + +## Plugin Architecture + +This is a **data source plugin** using the DsHelper framework: + +- Has its own Connection → Scope → ScopeConfig hierarchy +- Implements `DataSourcePluginBlueprintV200` interface +- Configured via Connections (like GitHub, GitLab plugins) +- References an existing GitHub connection for API authentication + +### Directory Structure + +``` +plugins/agentready/ +├── agentready.go # Entry point (standalone debugging) +├── impl/ +│ └── impl.go # Plugin implementation (interfaces) +├── models/ +│ ├── connection.go # Connection model (refs GitHub connection) +│ ├── connection_test.go # Connection tests +│ ├── scope.go # Scope model ({org}/{repo}) +│ ├── scope_test.go # Scope tests +│ ├── scope_config.go # Scope config (minimal, common.ScopeConfig) +│ ├── assessment.go # Assessment model +│ ├── finding.go # Finding model +│ ├── metric.go # Metric model +│ └── migrationscripts/ +│ ├── init_schema.go # Initial schema +│ ├── add_submissions_config.go # Submissions config fields +│ ├── add_submissions_branch.go # Submissions branch field +│ ├── add_connection_model.go # Connection/scope tables + ConnectionId +│ └── register.go # Migration registration +├── tasks/ +│ ├── task_data.go # Task options (connectionId, fullName) +│ ├── submissions_collector.go # Fetch assessment JSONs from submissions repo +│ ├── submissions_collector_test.go # Collector tests +│ ├── assessment_extractor.go # Parse assessments into findings +│ ├── assessment_extractor_test.go # Extractor tests +│ ├── metrics_calculator.go # Aggregate metrics from findings +│ └── metrics_calculator_test.go # Calculator tests +├── api/ +│ ├── init.go # DsHelper initialization +│ ├── connection.go # Connection CRUD + test endpoints +│ ├── scope.go # Scope CRUD endpoints +│ ├── scope_state_api.go # Scope sync state +│ ├── scope_config.go # Scope config CRUD (DsHelper) +│ ├── remote_api.go # Remote scope discovery +│ ├── blueprint_v200.go # Pipeline planning +│ ├── assessments.go # Assessment + findings endpoints +│ └── stats.go # Aggregated stats endpoint +└── grafana/ + ├── fleet-overview.json # Fleet-level aggregate dashboard + ├── findings-analysis.json # Common failures + tier trends + └── repo-detail.json # Single-repo detail dashboard +``` + +### Interfaces Implemented + +```go +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginApi + plugin.PluginModel + plugin.PluginMigration + plugin.PluginTask + plugin.DataSourcePluginBlueprintV200 +} = (*AgentReady)(nil) +``` + +## Connection Model + +An AgentReady connection represents access to a submissions repository: + +| Field | Type | Description | +|-------|------|-------------| +| `githubConnectionId` | uint64 | ID of an existing GitHub connection for API authentication | +| `submissionsRepo` | string | GitHub full name of the submissions repo (e.g., `ambient-code/agentready`) | +| `submissionsPath` | string | Subdirectory containing `{org}/{repo}/*.json` (default: `submissions`) | +| `branch` | string | Git branch to read from (default: repo default branch) | + +## Data Models + +### Assessment + +Stores assessment records fetched from the submissions repository: +- Repository identity (ID, name, provider, connectionId) +- Schema version and overall score +- Certification level +- Attributes assessed vs. total available +- Branch, commit hash, and assessment timestamp +- Duration and raw JSON payload + +Primary key: `(id, connection_id)` where id = `{repoId}:{commitHash}` + +### Finding + +Individual findings extracted from each assessment: +- Attribute identity (ID, name, category) +- Tier (1=Essential, 2=Critical, 3=Important, 4=Advanced) +- Status (pass, fail, skipped, error) +- Score, measured value, and threshold +- Evidence, remediation summary, and remediation steps + +Primary key: `(id, connection_id)` where id = `{assessmentId}:{attributeId}` + +### Metric + +Aggregated metrics computed per assessment: +- Pass, fail, and skip counts +- Per-tier pass rates (Tier 1 through Tier 4) as percentages +- Category scores (JSON map of category to average score) + +Primary key: `(id, connection_id)` where id = `{repoId}:{timestamp}:{assessmentId}` + +## Subtasks + +The plugin pipeline has three stages: + +1. **collectSubmissions**: Fetches the Git tree from the submissions repo, filters entries matching the scope's `{org}/{repo}`, downloads each assessment JSON via the Contents API, and stores raw JSON. + +2. **extractAssessments**: Parses raw JSON into structured assessment and finding records. Findings with status `not_applicable` are excluded. Depends on `collectSubmissions`. + +3. **calculateMetrics**: Aggregates findings per assessment into pass/fail/skip counts, tier pass rates, and category scores. Depends on `extractAssessments`. + +## API Endpoints + +### Connection Management + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/connections` | Create a connection | +| GET | `/connections` | List all connections | +| GET | `/connections/:id` | Get a connection | +| PATCH | `/connections/:id` | Update a connection | +| DELETE | `/connections/:id` | Delete a connection | +| POST | `/connections/:id/test` | Test an existing connection | +| POST | `/test` | Test a new connection (before saving) | + +### Scope Management + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/connections/:id/remote-scopes` | Discover scopes from submissions tree | +| PUT | `/connections/:id/scopes` | Register scopes | +| GET | `/connections/:id/scopes` | List scopes | +| GET | `/connections/:id/scopes/*scopeId` | Get scope detail | +| PATCH | `/connections/:id/scopes/*scopeId` | Update scope | +| DELETE | `/connections/:id/scopes/*scopeId` | Delete scope | + +### Scope Config + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/connections/:id/scope-configs` | Create scope config | +| GET | `/connections/:id/scope-configs` | List scope configs | +| GET | `/connections/:id/scope-configs/:scId` | Get scope config | +| PATCH | `/connections/:id/scope-configs/:scId` | Update scope config | +| DELETE | `/connections/:id/scope-configs/:scId` | Delete scope config | + +### Assessments & Stats + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/assessments` | List assessments. Filters: `projectName`, `repoId`, `certification`, `page`, `pageSize` | +| GET | `/assessments/:id` | Get assessment with findings | +| GET | `/assessments/:id/findings` | Get findings. Filters: `tier`, `status`, `category` | +| GET | `/stats` | Aggregate stats: `totalAssessments`, `averageScore`, `certificationDistribution` | + +## Usage + +### Prerequisites + +1. A GitHub connection configured in DevLake +2. A submissions repository with assessment JSON files + +### Setup via Config-UI + +1. Go to **Connections** → **AgentReady** → **Create Connection** +2. Select a GitHub connection, enter submissions repo info +3. Test the connection +4. Add scopes (discovered automatically from the submissions tree) + +### Setup via API + +```bash +# Create connection +curl -X POST http://localhost:4000/api/plugins/agentready/connections \ + -H "Content-Type: application/json" \ + -d '{ + "name": "my-assessments", + "githubConnectionId": 1, + "submissionsRepo": "ambient-code/agentready", + "submissionsPath": "submissions" + }' + +# Discover scopes +curl http://localhost:4000/api/plugins/agentready/connections/1/remote-scopes + +# Register scopes +curl -X PUT http://localhost:4000/api/plugins/agentready/connections/1/scopes \ + -H "Content-Type: application/json" \ + -d '[{"fullName": "myorg/myrepo", "name": "myrepo"}]' +``` + +### Blueprint Configuration + +```json +{ + "plugin": "agentready", + "options": { + "connectionId": 1, + "fullName": "myorg/myrepo" + } +} +``` + +### CLI Debugging + +```bash +cd backend +go run plugins/agentready/agentready.go -c 1 -f "myorg/myrepo" +``` + +## Database Tables + +- `_tool_agentready_connections`: Connection configuration +- `_tool_agentready_scopes`: Tracked `{org}/{repo}` scopes +- `_tool_agentready_scope_configs`: Per-scope configuration +- `_tool_agentready_assessments`: Assessment records with scores and metadata +- `_tool_agentready_findings`: Individual findings per assessment +- `_tool_agentready_metrics`: Aggregated pass rates and category scores + +## Assessment JSON Format + +```json +{ + "schema_version": "1.0", + "repository": { + "name": "my-repo", + "branch": "main", + "commit_hash": "abc123" + }, + "timestamp": "2024-01-15T10:30:00Z", + "overall_score": 85.5, + "certification_level": "Gold", + "attributes_assessed": 15, + "attributes_total": 20, + "duration_seconds": 123.45, + "findings": [ + { + "attribute": { + "id": "sec-scanning", + "name": "Security Scanning", + "category": "Security", + "tier": 1, + "default_weight": 0.1 + }, + "status": "pass", + "score": 95.0, + "measured_value": "enabled", + "threshold": "enabled", + "evidence": ["SAST configured in CI pipeline"], + "remediation": { + "summary": "Enable security scanning", + "steps": ["Add SAST stage to CI config"] + } + } + ] +} +``` + +## Grafana Dashboards + +### Fleet Overview (`fleet-overview.json`) +Aggregate view across all repositories: average scores, certification distribution, and fleet-level trends. + +### Findings Analysis (`findings-analysis.json`) +Top 15 most common failures (bar chart) and tier pass rates over time (time series). + +### Repository Detail (`repo-detail.json`) +Single-repo deep dive with current score, certification level, and detailed findings breakdown. + +## Schema-Agnostic Design + +This plugin parses assessment JSON generically, it does not hardcode attribute IDs, names, or weights. When the upstream AgentReady tool adds, removes, or rebalances assessors, new findings appear automatically on the next collection cycle without any plugin code changes. + +For the full list of assessors and their weights, see the [AgentReady attributes documentation](https://github.com/ambient-code/agentready/blob/main/docs/attributes.md). + +## Tier Model + +| Tier | Name | Description | +|------|------|-------------| +| 1 | Essential | Baseline requirements every repository must meet | +| 2 | Critical | Important practices for production readiness | +| 3 | Important | Recommended practices for mature repositories | +| 4 | Advanced | Best-in-class practices for leading repositories | diff --git a/backend/plugins/agentready/agentready.go b/backend/plugins/agentready/agentready.go new file mode 100644 index 00000000000..f4610e29c1d --- /dev/null +++ b/backend/plugins/agentready/agentready.go @@ -0,0 +1,39 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package main + +import ( + "github.com/apache/devlake/core/runner" + "github.com/apache/devlake/plugins/agentready/impl" + "github.com/spf13/cobra" +) + +var PluginEntry impl.AgentReady + +func main() { + cmd := &cobra.Command{Use: "agentready"} + connectionId := cmd.Flags().Uint64P("connectionId", "c", 0, "connection ID") + fullName := cmd.Flags().StringP("fullName", "f", "", "scope full name (org/repo)") + + cmd.Run = func(_ *cobra.Command, args []string) { + runner.DirectRun(cmd, args, PluginEntry, map[string]any{ + "connectionId": *connectionId, + "fullName": *fullName, + }, "") + } + runner.RunCmd(cmd) +} diff --git a/backend/plugins/agentready/api/assessments.go b/backend/plugins/agentready/api/assessments.go new file mode 100644 index 00000000000..6e1936bd83d --- /dev/null +++ b/backend/plugins/agentready/api/assessments.go @@ -0,0 +1,191 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package api + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/agentready/models" +) + +func parseConnectionId(params map[string]string) (uint64, error) { + raw, ok := params["connectionId"] + if !ok || raw == "" { + return 0, fmt.Errorf("missing connectionId") + } + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid connectionId %q: %w", raw, err) + } + if id == 0 { + return 0, fmt.Errorf("connectionId must be > 0") + } + return id, nil +} + +func GetAssessments(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + db := basicRes.GetDal() + + connectionId, pErr := parseConnectionId(input.Params) + if pErr != nil { + return nil, errors.BadInput.Wrap(pErr, "invalid connectionId") + } + + page, _ := strconv.Atoi(input.Query.Get("page")) + if page <= 0 { + page = 1 + } + pageSize, _ := strconv.Atoi(input.Query.Get("pageSize")) + if pageSize <= 0 || pageSize > 100 { + pageSize = 50 + } + offset := (page - 1) * pageSize + + var clauses []dal.Clause + + if projectName := input.Query.Get("projectName"); projectName != "" { + clauses = []dal.Clause{ + dal.Select("DISTINCT a.*"), + dal.From("_tool_agentready_assessments a"), + dal.Join("JOIN project_mapping pm ON a.repo_id = pm.row_id"), + dal.Where("pm.project_name = ? AND pm.`table` = ? AND a.connection_id = ?", projectName, models.ProjectMappingTable, connectionId), + } + } else { + clauses = []dal.Clause{ + dal.From(&models.AgentReadyAssessment{}), + dal.Where("connection_id = ?", connectionId), + } + if repoId := input.Query.Get("repoId"); repoId != "" { + clauses = append(clauses, dal.Where("repo_id = ?", repoId)) + } + } + + if cert := input.Query.Get("certification"); cert != "" { + clauses = append(clauses, dal.Where("certification_level = ?", cert)) + } + + countClauses := make([]dal.Clause, len(clauses)) + copy(countClauses, clauses) + total, err := db.Count(countClauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to count assessments") + } + + clauses = append(clauses, + dal.Orderby("assessed_at DESC"), + dal.Limit(pageSize), + dal.Offset(offset), + ) + + var assessments []models.AgentReadyAssessment + err = db.All(&assessments, clauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to query assessments") + } + + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "assessments": assessments, + "page": page, + "pageSize": pageSize, + "total": total, + }, + Status: http.StatusOK, + }, nil +} + +func GetAssessment(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + db := basicRes.GetDal() + + connectionId, pErr := parseConnectionId(input.Params) + if pErr != nil { + return nil, errors.BadInput.Wrap(pErr, "invalid connectionId") + } + + id := input.Params["id"] + var assessment models.AgentReadyAssessment + err := db.First(&assessment, dal.Where("id = ? AND connection_id = ?", id, connectionId)) + if err != nil { + if db.IsErrorNotFound(err) { + return nil, errors.HttpStatus(http.StatusNotFound).Wrap(err, "assessment not found") + } + return nil, errors.Default.Wrap(err, "failed to get assessment") + } + + var findings []models.AgentReadyFinding + err = db.All(&findings, + dal.From(&models.AgentReadyFinding{}), + dal.Where("assessment_id = ? AND connection_id = ?", id, connectionId), + dal.Orderby("tier ASC, status ASC"), + ) + + if err != nil { + return nil, errors.Default.Wrap(err, "failed to query findings for assessment") + } + + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "assessment": assessment, + "findings": findings, + }, + Status: http.StatusOK, + }, nil +} + +func GetAssessmentFindings(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + db := basicRes.GetDal() + + connectionId, pErr := parseConnectionId(input.Params) + if pErr != nil { + return nil, errors.BadInput.Wrap(pErr, "invalid connectionId") + } + + assessmentId := input.Params["id"] + + clauses := []dal.Clause{ + dal.From(&models.AgentReadyFinding{}), + dal.Where("assessment_id = ? AND connection_id = ?", assessmentId, connectionId), + } + + if tier := input.Query.Get("tier"); tier != "" { + clauses = append(clauses, dal.Where("tier = ?", tier)) + } + if status := input.Query.Get("status"); status != "" { + clauses = append(clauses, dal.Where("status = ?", status)) + } + if category := input.Query.Get("category"); category != "" { + clauses = append(clauses, dal.Where("category = ?", category)) + } + + clauses = append(clauses, dal.Orderby("tier ASC, status ASC")) + + var findings []models.AgentReadyFinding + err := db.All(&findings, clauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to query findings") + } + + return &plugin.ApiResourceOutput{ + Body: findings, + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/agentready/api/assessments_test.go b/backend/plugins/agentready/api/assessments_test.go new file mode 100644 index 00000000000..e7ae3da2b14 --- /dev/null +++ b/backend/plugins/agentready/api/assessments_test.go @@ -0,0 +1,49 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "testing" +) + +func TestParseConnectionId(t *testing.T) { + tests := []struct { + name string + params map[string]string + want uint64 + wantErr bool + }{ + {"valid id", map[string]string{"connectionId": "42"}, 42, false}, + {"missing id", map[string]string{}, 0, true}, + {"invalid id", map[string]string{"connectionId": "abc"}, 0, true}, + {"zero id", map[string]string{"connectionId": "0"}, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseConnectionId(tt.params) + if (err != nil) != tt.wantErr { + t.Errorf("parseConnectionId() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("parseConnectionId() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/plugins/agentready/api/blueprint_v200.go b/backend/plugins/agentready/api/blueprint_v200.go new file mode 100644 index 00000000000..818fe9d89e4 --- /dev/null +++ b/backend/plugins/agentready/api/blueprint_v200.go @@ -0,0 +1,96 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package api + +import ( + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/plugin" + helperapi "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/helpers/srvhelper" + "github.com/apache/devlake/plugins/agentready/models" + "github.com/apache/devlake/plugins/agentready/tasks" +) + +func MakeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + connection, err := dsHelper.ConnSrv.FindByPk(connectionId) + if err != nil { + return nil, nil, err + } + scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes) + if err != nil { + return nil, nil, err + } + plan, err := makePipelinePlan(subtaskMetas, scopeDetails, connection) + if err != nil { + return nil, nil, err + } + scopes := makeScopes(scopeDetails) + return plan, scopes, nil +} + +func makePipelinePlan( + subtaskMetas []plugin.SubTaskMeta, + scopeDetails []*srvhelper.ScopeDetail[models.AgentReadyScope, models.AgentReadyScopeConfig], + connection *models.AgentReadyConnection, +) (coreModels.PipelinePlan, errors.Error) { + plan := make(coreModels.PipelinePlan, len(scopeDetails)) + for i, scopeDetail := range scopeDetails { + scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig + + entities := []string{plugin.DOMAIN_TYPE_CODE} + if scopeConfig != nil && len(scopeConfig.Entities) > 0 { + entities = scopeConfig.Entities + } + + task, err := helperapi.MakePipelinePlanTask( + "agentready", + subtaskMetas, + entities, + tasks.AgentReadyOptions{ + ConnectionId: connection.ID, + FullName: scope.FullName, + ScopeConfig: scopeConfig, + }, + ) + if err != nil { + return nil, err + } + plan[i] = coreModels.PipelineStage{task} + } + return plan, nil +} + +func makeScopes( + scopeDetails []*srvhelper.ScopeDetail[models.AgentReadyScope, models.AgentReadyScopeConfig], +) []plugin.Scope { + scopes := make([]plugin.Scope, 0, len(scopeDetails)) + for _, scopeDetail := range scopeDetails { + scope := scopeDetail.Scope + scopes = append(scopes, &code.Repo{ + DomainEntity: domainlayer.DomainEntity{Id: scope.FullName}, + Name: scope.Name, + }) + } + return scopes +} diff --git a/backend/plugins/agentready/api/connection.go b/backend/plugins/agentready/api/connection.go new file mode 100644 index 00000000000..5062ec5aae6 --- /dev/null +++ b/backend/plugins/agentready/api/connection.go @@ -0,0 +1,179 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package api + +import ( + gocontext "context" + "fmt" + "net/http" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/agentready/models" + "github.com/apache/devlake/plugins/agentready/tasks" +) + +func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var conn models.AgentReadyConnection + if err := api.Decode(input.Body, &conn, nil); err != nil { + return nil, errors.BadInput.Wrap(err, "failed to decode connection") + } + resolveDefaultBranch(gocontext.Background(), &conn) + if conn.Branch != "" { + input.Body["branch"] = conn.Branch + } + return dsHelper.ConnApi.Post(input) +} + +func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + model, err := dsHelper.ConnApi.PatchModel(input, true) + if err != nil { + return nil, errors.Convert(err) + } + resolveDefaultBranch(gocontext.Background(), model) + if updateErr := dsHelper.ConnApi.ConnectionSrvHelper.Update(model); updateErr != nil { + return nil, updateErr + } + return &plugin.ApiResourceOutput{ + Body: dsHelper.ConnApi.Sanitize(model), + }, nil +} + +func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Delete(input) +} + +func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetAll(input) +} + +func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetDetail(input) +} + +type githubConn struct { + ID uint64 `gorm:"primaryKey;column:id"` + Endpoint string `gorm:"column:endpoint"` + Token string `gorm:"column:token;serializer:encdec"` +} + +func (githubConn) TableName() string { return "_tool_github_connections" } + +func resolveDefaultBranch(ctx gocontext.Context, conn *models.AgentReadyConnection) { + if conn.Branch != "" { + return + } + if conn.SubmissionsRepo == "" { + return + } + if conn.GitHubConnectionId == 0 { + return + } + + logger := basicRes.GetLogger() + + db := basicRes.GetDal() + var ghConn githubConn + if err := db.First(&ghConn, dal.Where("id = ?", conn.GitHubConnectionId)); err != nil { + logger.Warn(err, "could not look up GitHub connection %d for branch resolution, defaulting to main", conn.GitHubConnectionId) + conn.Branch = "main" + return + } + + endpoint := ghConn.Endpoint + if endpoint == "" { + endpoint = "https://api.github.com" + } + + branch, fetchErr := tasks.FetchDefaultBranch(ctx, endpoint, conn.SubmissionsRepo, ghConn.Token) + if fetchErr != nil { + logger.Warn(nil, "could not resolve default branch for %s: %v, defaulting to main", conn.SubmissionsRepo, fetchErr) + conn.Branch = "main" + return + } + conn.Branch = branch +} + +func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var conn models.AgentReadyConnection + if err := api.Decode(input.Body, &conn, nil); err != nil { + return nil, errors.BadInput.Wrap(err, "failed to decode connection") + } + return testAgentReadyConnection(gocontext.Background(), &conn) +} + +func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection, err := dsHelper.ConnApi.GetMergedConnection(input) + if err != nil { + return nil, errors.Convert(err) + } + return testAgentReadyConnection(gocontext.Background(), connection) +} + +func testAgentReadyConnection(ctx gocontext.Context, conn *models.AgentReadyConnection) (*plugin.ApiResourceOutput, errors.Error) { + if conn.GitHubConnectionId == 0 { + return nil, errors.BadInput.New("githubConnectionId is required") + } + if conn.SubmissionsRepo == "" { + return nil, errors.BadInput.New("submissionsRepo is required") + } + + db := basicRes.GetDal() + var ghConn githubConn + if err := db.First(&ghConn, dal.Where("id = ?", conn.GitHubConnectionId)); err != nil { + return nil, errors.BadInput.New(fmt.Sprintf("GitHub connection %d not found", conn.GitHubConnectionId)) + } + + endpoint := ghConn.Endpoint + if endpoint == "" { + endpoint = "https://api.github.com" + } + token := ghConn.Token + if token == "" { + return nil, errors.BadInput.New("referenced GitHub connection has no token") + } + + apiClient, err := api.NewApiClient(ctx, endpoint, nil, 0, "", basicRes) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to create API client") + } + apiClient.SetHeaders(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", token), + }) + + repoURL := fmt.Sprintf("repos/%s", conn.SubmissionsRepo) + resp, err := apiClient.Get(repoURL, nil, nil) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to reach GitHub API") + } + if resp.StatusCode == http.StatusNotFound { + return nil, errors.BadInput.New(fmt.Sprintf("repository %q not found or not accessible", conn.SubmissionsRepo)) + } + if resp.StatusCode != http.StatusOK { + return nil, errors.Default.New(fmt.Sprintf("GitHub API returned %d for repo %s", resp.StatusCode, conn.SubmissionsRepo)) + } + + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "success": true, + "message": fmt.Sprintf("Successfully connected to %s", conn.SubmissionsRepo), + }, + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/agentready/api/init.go b/backend/plugins/agentready/api/init.go new file mode 100644 index 00000000000..48876ee8be9 --- /dev/null +++ b/backend/plugins/agentready/api/init.go @@ -0,0 +1,44 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package api + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/agentready/models" +) + +var basicRes context.BasicRes + +var dsHelper *api.DsHelper[models.AgentReadyConnection, models.AgentReadyScope, models.AgentReadyScopeConfig] + +func Init(br context.BasicRes, p plugin.PluginMeta) { + basicRes = br + dsHelper = api.NewDataSourceHelper[ + models.AgentReadyConnection, models.AgentReadyScope, models.AgentReadyScopeConfig, + ]( + br, + p.Name(), + []string{"full_name"}, + func(c models.AgentReadyConnection) models.AgentReadyConnection { + return c.Sanitize() + }, + nil, + nil, + ) +} diff --git a/backend/plugins/agentready/api/remote_api.go b/backend/plugins/agentready/api/remote_api.go new file mode 100644 index 00000000000..56b96728856 --- /dev/null +++ b/backend/plugins/agentready/api/remote_api.go @@ -0,0 +1,101 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package api + +import ( + gocontext "context" + "fmt" + "net/http" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helperapi "github.com/apache/devlake/helpers/pluginhelper/api" + dsmodels "github.com/apache/devlake/helpers/pluginhelper/api/models" + "github.com/apache/devlake/plugins/agentready/models" + "github.com/apache/devlake/plugins/agentready/tasks" +) + +func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection, err := dsHelper.ConnApi.ModelApiHelper.FindByPk(input) + if err != nil { + return nil, err + } + + db := basicRes.GetDal() + var ghConn githubConn + if dbErr := db.First(&ghConn, dal.Where("id = ?", connection.GitHubConnectionId)); dbErr != nil { + return nil, errors.Default.New(fmt.Sprintf("GitHub connection %d not found", connection.GitHubConnectionId)) + } + + endpoint := ghConn.Endpoint + if endpoint == "" { + endpoint = "https://api.github.com" + } + + branch := connection.Branch + if branch == "" { + resolvedBranch, branchErr := tasks.FetchDefaultBranch(gocontext.Background(), endpoint, connection.SubmissionsRepo, ghConn.Token) + if branchErr != nil { + return nil, errors.Default.Wrap(branchErr, "failed to resolve default branch") + } + branch = resolvedBranch + } + submissionsPath := connection.SubmissionsPath + if submissionsPath == "" { + submissionsPath = "submissions" + } + + treeResp, fetchErr := tasks.FetchGithubTree(gocontext.Background(), endpoint, connection.SubmissionsRepo, branch, ghConn.Token) + if fetchErr != nil { + return nil, errors.Default.Wrap(fetchErr, "failed to fetch submissions tree") + } + + entries := tasks.ParseSubmissionEntries(treeResp.Tree, submissionsPath) + + seen := map[string]bool{} + var children []dsmodels.DsRemoteApiScopeListEntry[models.AgentReadyScope] + + for _, entry := range entries { + fullName := fmt.Sprintf("%s/%s", entry.Org, entry.Repo) + if seen[fullName] { + continue + } + seen[fullName] = true + + scopeData := &models.AgentReadyScope{ + FullName: fullName, + Name: entry.Repo, + } + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.AgentReadyScope]{ + Type: helperapi.RAS_ENTRY_TYPE_SCOPE, + ParentId: nil, + Id: scopeData.ScopeId(), + Name: scopeData.ScopeName(), + FullName: scopeData.ScopeFullName(), + Data: scopeData, + }) + } + + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "children": children, + "nextPageToken": "", + }, + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/agentready/api/scope.go b/backend/plugins/agentready/api/scope.go new file mode 100644 index 00000000000..794f438f050 --- /dev/null +++ b/backend/plugins/agentready/api/scope.go @@ -0,0 +1,56 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package api + +import ( + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +func GetScopeDispatcher(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + scopeIdWithSuffix := strings.TrimLeft(input.Params["scopeId"], "/") + if scopeId, found := strings.CutSuffix(scopeIdWithSuffix, "/latest-sync-state"); found { + input.Params["scopeId"] = scopeId + return GetScopeLatestSyncState(input) + } + return GetScope(input) +} + +func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.GetScopeDetail(input) +} + +func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.Patch(input) +} + +func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.Delete(input) +} + +func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetPage(input) +} + +func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.PutMultiple(input) +} diff --git a/backend/plugins/agentready/api/scope_config.go b/backend/plugins/agentready/api/scope_config.go new file mode 100644 index 00000000000..658049f20c6 --- /dev/null +++ b/backend/plugins/agentready/api/scope_config.go @@ -0,0 +1,46 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package api + +import ( + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +func CreateScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Post(input) +} + +func UpdateScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Patch(input) +} + +func GetScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetDetail(input) +} + +func DeleteScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Delete(input) +} + +func GetScopeConfigList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetAll(input) +} + +func GetProjectsByScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetProjectsByScopeConfig(input) +} diff --git a/backend/plugins/agentready/api/scope_state_api.go b/backend/plugins/agentready/api/scope_state_api.go new file mode 100644 index 00000000000..1657e0c976e --- /dev/null +++ b/backend/plugins/agentready/api/scope_state_api.go @@ -0,0 +1,29 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package api + +import ( + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +func GetScopeLatestSyncState(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.GetScopeLatestSyncState(input) +} diff --git a/backend/plugins/agentready/api/stats.go b/backend/plugins/agentready/api/stats.go new file mode 100644 index 00000000000..72c9b57d22c --- /dev/null +++ b/backend/plugins/agentready/api/stats.go @@ -0,0 +1,99 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package api + +import ( + "net/http" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/agentready/models" +) + +type AggResult struct { + Total int64 `gorm:"column:total"` + AvgScore float64 `gorm:"column:avg_score"` +} + +type CertCount struct { + CertificationLevel string `gorm:"column:certification_level" json:"certificationLevel"` + Count int64 `gorm:"column:count" json:"count"` +} + +func GetStats(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + db := basicRes.GetDal() + + connectionId, pErr := parseConnectionId(input.Params) + if pErr != nil { + return nil, errors.BadInput.Wrap(pErr, "invalid connectionId") + } + + var baseClauses []dal.Clause + + if projectName := input.Query.Get("projectName"); projectName != "" { + baseClauses = []dal.Clause{ + dal.From("_tool_agentready_assessments a"), + dal.Join("JOIN project_mapping pm ON a.repo_id = pm.row_id"), + dal.Where("pm.project_name = ? AND pm.`table` = ? AND a.connection_id = ?", projectName, models.ProjectMappingTable, connectionId), + } + } else { + baseClauses = []dal.Clause{ + dal.From(&models.AgentReadyAssessment{}), + dal.Where("connection_id = ?", connectionId), + } + } + baseClauses = append(baseClauses, dal.Where("id != ''")) + + aggClauses := make([]dal.Clause, len(baseClauses)) + copy(aggClauses, baseClauses) + aggClauses = append(aggClauses, + dal.Select("COUNT(*) as total, AVG(overall_score) as avg_score"), + ) + + var agg AggResult + err := db.First(&agg, aggClauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to get assessment stats") + } + + var certCounts []CertCount + certClauses := make([]dal.Clause, len(baseClauses)) + copy(certClauses, baseClauses) + certClauses = append(certClauses, + dal.Select("certification_level, COUNT(*) as count"), + dal.Groupby("certification_level"), + ) + err = db.All(&certCounts, certClauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to get certification distribution") + } + + certDist := make(map[string]int, len(certCounts)) + for _, c := range certCounts { + certDist[c.CertificationLevel] = int(c.Count) + } + + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "totalAssessments": agg.Total, + "averageScore": agg.AvgScore, + "certificationDistribution": certDist, + }, + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/agentready/grafana/findings-analysis.json b/backend/plugins/agentready/grafana/findings-analysis.json new file mode 100644 index 00000000000..2f81283747c --- /dev/null +++ b/backend/plugins/agentready/grafana/findings-analysis.json @@ -0,0 +1,139 @@ +{ + "__inputs": [ + { + "name": "DS_DEVLAKE", + "label": "DevLake", + "description": "", + "type": "datasource", + "pluginId": "mysql", + "pluginName": "MySQL" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "panels": [ + { + "title": "Most Common Failures", + "type": "barchart", + "gridPos": { "h": 10, "w": 12, "x": 0, "y": 0 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT\n f.attribute_name,\n COUNT(*) AS failure_count\nFROM _tool_agentready_findings f\nJOIN project_mapping pm ON f.repo_id = pm.row_id AND pm.`table` = 'repos'\nWHERE f.status = 'fail' AND pm.project_name in (${project})\nGROUP BY f.attribute_id, f.attribute_name\nORDER BY failure_count DESC\nLIMIT 15", + "format": "table", + "refId": "A" + } + ], + "options": { + "orientation": "horizontal" + }, + "fieldConfig": { + "defaults": { + "color": { "fixedColor": "red", "mode": "fixed" } + } + } + }, + { + "title": "Tier Pass Rates Over Time", + "type": "timeseries", + "gridPos": { "h": 10, "w": 12, "x": 12, "y": 0 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT\n DATE(m.assessed_at) AS time,\n AVG(m.tier1_pass_rate) AS 'Essential',\n AVG(m.tier2_pass_rate) AS 'Critical',\n AVG(m.tier3_pass_rate) AS 'Important',\n AVG(m.tier4_pass_rate) AS 'Advanced'\nFROM _tool_agentready_metrics m\nJOIN project_mapping pm ON m.repo_id = pm.row_id AND pm.`table` = 'repos'\nWHERE pm.project_name in (${project}) AND $__timeFilter(m.assessed_at)\nGROUP BY DATE(m.assessed_at)\nORDER BY time", + "format": "time_series", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "min": 0, + "max": 100, + "unit": "percent", + "custom": { + "lineWidth": 2, + "fillOpacity": 5 + } + } + } + }, + { + "title": "Remediation Backlog", + "type": "table", + "gridPos": { "h": 12, "w": 24, "x": 0, "y": 10 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT\n f.attribute_name,\n f.category,\n f.tier,\n f.default_weight,\n COUNT(DISTINCT f.repo_id) AS affected_repos,\n f.remediation_summary\nFROM _tool_agentready_findings f\nJOIN project_mapping pm ON f.repo_id = pm.row_id AND pm.`table` = 'repos'\nWHERE f.status = 'fail' AND pm.project_name in (${project})\nGROUP BY f.attribute_id, f.attribute_name, f.category, f.tier, f.default_weight, f.remediation_summary\nORDER BY f.tier ASC, f.default_weight DESC", + "format": "table", + "refId": "A" + } + ], + "fieldConfig": { + "overrides": [ + { + "matcher": { "id": "byName", "options": "affected_repos" }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { "type": "color-text" } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 3 }, + { "color": "red", "value": 5 } + ] + } + } + ] + } + ] + } + } + ], + "schemaVersion": 38, + "style": "dark", + "tags": ["agentready", "devlake"], + "templating": { + "list": [ + { + "name": "DS_DEVLAKE", + "type": "datasource", + "query": "mysql" + }, + { + "current": { + "selected": true, + "text": ["All"], + "value": ["$__all"] + }, + "datasource": "${DS_DEVLAKE}", + "definition": "select distinct name from projects", + "hide": 0, + "includeAll": true, + "label": "Project", + "multi": true, + "name": "project", + "options": [], + "query": "select distinct name from projects", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-90d", + "to": "now" + }, + "title": "AgentReady - Findings Analysis", + "uid": "agentready-findings-analysis" +} diff --git a/backend/plugins/agentready/grafana/fleet-overview.json b/backend/plugins/agentready/grafana/fleet-overview.json new file mode 100644 index 00000000000..5cf41b2315f --- /dev/null +++ b/backend/plugins/agentready/grafana/fleet-overview.json @@ -0,0 +1,222 @@ +{ + "__inputs": [ + { + "name": "DS_DEVLAKE", + "label": "DevLake", + "description": "", + "type": "datasource", + "pluginId": "mysql", + "pluginName": "MySQL" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "title": "Fleet Stats", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT AVG(a.overall_score) AS 'Avg Score'\nFROM _tool_agentready_assessments a\nJOIN project_mapping pm ON a.repo_id = pm.row_id AND pm.`table` = 'repos'\nINNER JOIN (\n SELECT aa.repo_id, MAX(aa.assessed_at) as max_assessed\n FROM _tool_agentready_assessments aa\n JOIN project_mapping pm2 ON aa.repo_id = pm2.row_id AND pm2.`table` = 'repos'\n WHERE aa.id != '' AND pm2.project_name in (${project})\n GROUP BY aa.repo_id\n) latest ON a.repo_id = latest.repo_id AND a.assessed_at = latest.max_assessed\nWHERE pm.project_name in (${project})", + "format": "table", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "decimals": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 40 }, + { "color": "yellow", "value": 60 }, + { "color": "green", "value": 80 } + ] + }, + "color": { "mode": "thresholds" } + } + } + }, + { + "title": "Total Repos Assessed", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT COUNT(DISTINCT a.repo_id) AS 'Total Repos'\nFROM _tool_agentready_assessments a\nJOIN project_mapping pm ON a.repo_id = pm.row_id AND pm.`table` = 'repos'\nWHERE a.id != '' AND pm.project_name in (${project})", + "format": "table", + "refId": "A" + } + ] + }, + { + "title": "Certification Distribution", + "type": "piechart", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT\n a.certification_level AS metric,\n COUNT(*) AS value\nFROM _tool_agentready_assessments a\nJOIN project_mapping pm ON a.repo_id = pm.row_id AND pm.`table` = 'repos'\nINNER JOIN (\n SELECT aa.repo_id, MAX(aa.assessed_at) as max_assessed\n FROM _tool_agentready_assessments aa\n JOIN project_mapping pm2 ON aa.repo_id = pm2.row_id AND pm2.`table` = 'repos'\n WHERE aa.id != '' AND pm2.project_name in (${project})\n GROUP BY aa.repo_id\n) latest ON a.repo_id = latest.repo_id AND a.assessed_at = latest.max_assessed\nWHERE pm.project_name in (${project})\nGROUP BY a.certification_level", + "format": "table", + "refId": "A" + } + ], + "options": { + "reduceOptions": { + "values": true, + "calcs": [] + }, + "pieType": "donut", + "legend": { + "displayMode": "table", + "placement": "right", + "values": ["value", "percent"] + } + }, + "fieldConfig": { + "overrides": [ + { + "matcher": { "id": "byName", "options": "Platinum" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#E5E4E2" } }] + }, + { + "matcher": { "id": "byName", "options": "Gold" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#FFD700" } }] + }, + { + "matcher": { "id": "byName", "options": "Silver" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#C0C0C0" } }] + }, + { + "matcher": { "id": "byName", "options": "Bronze" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#CD7F32" } }] + }, + { + "matcher": { "id": "byName", "options": "Needs Improvement" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#FF4444" } }] + }, + { + "matcher": { "id": "byName", "options": "None" }, + "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#808080" } }] + } + ] + } + }, + { + "title": "Score by Repository", + "type": "table", + "gridPos": { "h": 12, "w": 12, "x": 0, "y": 4 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT\n a.repo_name,\n a.overall_score,\n a.certification_level,\n a.attributes_assessed,\n a.attributes_total,\n a.assessed_at\nFROM _tool_agentready_assessments a\nJOIN project_mapping pm ON a.repo_id = pm.row_id AND pm.`table` = 'repos'\nINNER JOIN (\n SELECT aa.repo_id, MAX(aa.assessed_at) as max_assessed\n FROM _tool_agentready_assessments aa\n JOIN project_mapping pm2 ON aa.repo_id = pm2.row_id AND pm2.`table` = 'repos'\n WHERE aa.id != '' AND pm2.project_name in (${project})\n GROUP BY aa.repo_id\n) latest ON a.repo_id = latest.repo_id AND a.assessed_at = latest.max_assessed\nWHERE pm.project_name in (${project})\nORDER BY a.overall_score DESC", + "format": "table", + "refId": "A" + } + ], + "fieldConfig": { + "overrides": [ + { + "matcher": { "id": "byName", "options": "overall_score" }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { "type": "gauge", "mode": "gradient" } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 40 }, + { "color": "yellow", "value": 60 }, + { "color": "green", "value": 80 } + ] + } + }, + { "id": "max", "value": 100 } + ] + } + ] + } + }, + { + "title": "Average Score Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT\n DATE(a.assessed_at) AS time,\n AVG(a.overall_score) AS 'Average Score'\nFROM _tool_agentready_assessments a\nJOIN project_mapping pm ON a.repo_id = pm.row_id AND pm.`table` = 'repos'\nINNER JOIN (\n SELECT aa.repo_id, DATE(aa.assessed_at) AS day, MAX(aa.assessed_at) AS max_assessed\n FROM _tool_agentready_assessments aa\n JOIN project_mapping pm2 ON aa.repo_id = pm2.row_id AND pm2.`table` = 'repos'\n WHERE aa.id != '' AND pm2.project_name in (${project})\n GROUP BY aa.repo_id, DATE(aa.assessed_at)\n) latest ON a.repo_id = latest.repo_id AND DATE(a.assessed_at) = latest.day AND a.assessed_at = latest.max_assessed\nWHERE pm.project_name in (${project}) AND $__timeFilter(a.assessed_at)\nGROUP BY DATE(a.assessed_at)\nORDER BY time", + "format": "time_series", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "pointSize": 5, + "showPoints": "auto" + }, + "min": 0, + "max": 100, + "unit": "none" + } + } + } + ], + "schemaVersion": 38, + "style": "dark", + "tags": ["agentready", "devlake"], + "templating": { + "list": [ + { + "name": "DS_DEVLAKE", + "type": "datasource", + "query": "mysql" + }, + { + "current": { + "selected": true, + "text": ["All"], + "value": ["$__all"] + }, + "datasource": "${DS_DEVLAKE}", + "definition": "select distinct name from projects", + "hide": 0, + "includeAll": true, + "label": "Project", + "multi": true, + "name": "project", + "options": [], + "query": "select distinct name from projects", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-90d", + "to": "now" + }, + "title": "AgentReady - Fleet Overview", + "uid": "agentready-fleet-overview" +} diff --git a/backend/plugins/agentready/grafana/repo-detail.json b/backend/plugins/agentready/grafana/repo-detail.json new file mode 100644 index 00000000000..c7bb3433799 --- /dev/null +++ b/backend/plugins/agentready/grafana/repo-detail.json @@ -0,0 +1,233 @@ +{ + "__inputs": [ + { + "name": "DS_DEVLAKE", + "label": "DevLake", + "description": "", + "type": "datasource", + "pluginId": "mysql", + "pluginName": "MySQL" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "panels": [ + { + "title": "Current Score", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT overall_score AS 'Score'\nFROM _tool_agentready_assessments\nWHERE repo_id = '${repo_id}' AND id != ''\nORDER BY assessed_at DESC LIMIT 1", + "format": "table", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "none", + "decimals": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 40 }, + { "color": "yellow", "value": 60 }, + { "color": "green", "value": 80 } + ] + }, + "color": { "mode": "thresholds" } + } + } + }, + { + "title": "Certification Level", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT certification_level AS 'Level'\nFROM _tool_agentready_assessments\nWHERE repo_id = '${repo_id}' AND id != ''\nORDER BY assessed_at DESC LIMIT 1", + "format": "table", + "refId": "A" + } + ], + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "/^Level$/" + }, + "textMode": "value", + "colorMode": "background" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "fixed", "fixedColor": "blue" }, + "mappings": [ + { + "type": "value", + "options": { + "Platinum": { "color": "#E5E4E2", "text": "Platinum" }, + "Gold": { "color": "#FFD700", "text": "Gold" }, + "Silver": { "color": "#C0C0C0", "text": "Silver" }, + "Bronze": { "color": "#CD7F32", "text": "Bronze" }, + "Needs Improvement": { "color": "#FF4444", "text": "Needs Improvement" }, + "None": { "color": "#808080", "text": "None" } + } + } + ] + } + } + }, + { + "title": "Assessment Metadata", + "type": "table", + "gridPos": { "h": 4, "w": 12, "x": 12, "y": 0 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT\n repo_name AS 'Repository',\n attributes_assessed AS 'Assessed',\n attributes_total AS 'Total',\n branch AS 'Branch',\n ROUND(duration_seconds, 1) AS 'Duration (s)'\nFROM _tool_agentready_assessments\nWHERE repo_id = '${repo_id}' AND id != ''\nORDER BY assessed_at DESC LIMIT 1", + "format": "table", + "refId": "A" + } + ] + }, + { + "title": "Score History", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT assessed_at AS time, overall_score AS 'Score'\nFROM _tool_agentready_assessments\nWHERE repo_id = '${repo_id}' AND id != '' AND $__timeFilter(assessed_at)\nORDER BY assessed_at", + "format": "time_series", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "min": 0, + "max": 100, + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "pointSize": 5, + "showPoints": "always" + } + } + } + }, + { + "title": "Findings by Tier", + "type": "barchart", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT\n CASE tier WHEN 1 THEN 'Essential' WHEN 2 THEN 'Critical' WHEN 3 THEN 'Important' WHEN 4 THEN 'Advanced' END AS tier_name,\n SUM(CASE WHEN status = 'pass' THEN 1 ELSE 0 END) AS 'Pass',\n SUM(CASE WHEN status = 'fail' THEN 1 ELSE 0 END) AS 'Fail'\nFROM _tool_agentready_findings f\nJOIN _tool_agentready_assessments a ON f.assessment_id = a.id\nWHERE a.repo_id = '${repo_id}'\n AND a.assessed_at = (SELECT MAX(assessed_at) FROM _tool_agentready_assessments WHERE repo_id = '${repo_id}' AND id != '')\nGROUP BY tier\nORDER BY tier", + "format": "table", + "refId": "A" + } + ], + "options": { + "stacking": { "mode": "normal" }, + "orientation": "horizontal" + }, + "fieldConfig": { + "overrides": [ + { + "matcher": { "id": "byName", "options": "Pass" }, + "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "Fail" }, + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] + } + ] + } + }, + { + "title": "Finding Details", + "type": "table", + "gridPos": { "h": 12, "w": 24, "x": 0, "y": 12 }, + "datasource": "${DS_DEVLAKE}", + "targets": [ + { + "rawSql": "SELECT\n f.attribute_name,\n f.category,\n f.tier,\n f.status,\n f.score,\n f.measured_value,\n f.remediation_summary\nFROM _tool_agentready_findings f\nJOIN _tool_agentready_assessments a ON f.assessment_id = a.id\nWHERE a.repo_id = '${repo_id}'\n AND a.assessed_at = (SELECT MAX(assessed_at) FROM _tool_agentready_assessments WHERE repo_id = '${repo_id}' AND id != '')\nORDER BY f.tier, f.status DESC", + "format": "table", + "refId": "A" + } + ], + "fieldConfig": { + "overrides": [ + { + "matcher": { "id": "byName", "options": "status" }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { "type": "color-text" } + }, + { + "id": "mappings", + "value": [ + { "type": "value", "options": { "pass": { "color": "green", "text": "PASS" } } }, + { "type": "value", "options": { "fail": { "color": "red", "text": "FAIL" } } } + ] + } + ] + } + ] + } + } + ], + "schemaVersion": 38, + "style": "dark", + "tags": ["agentready", "devlake"], + "templating": { + "list": [ + { + "name": "DS_DEVLAKE", + "type": "datasource", + "query": "mysql" + }, + { + "current": { + "selected": true, + "text": ["All"], + "value": ["$__all"] + }, + "datasource": "${DS_DEVLAKE}", + "definition": "select distinct name from projects", + "hide": 0, + "includeAll": true, + "label": "Project", + "multi": true, + "name": "project", + "options": [], + "query": "select distinct name from projects", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "name": "repo_id", + "type": "query", + "datasource": "${DS_DEVLAKE}", + "query": "SELECT COALESCE(a.repo_name, a.repo_id) AS __text, a.repo_id AS __value FROM _tool_agentready_assessments a JOIN project_mapping pm ON a.repo_id = pm.row_id AND pm.`table` = 'repos' WHERE a.id != '' AND pm.project_name in (${project}) GROUP BY a.repo_id, a.repo_name ORDER BY a.repo_name", + "refresh": 1, + "sort": 0 + } + ] + }, + "time": { + "from": "now-90d", + "to": "now" + }, + "title": "AgentReady - Repository Detail", + "uid": "agentready-repo-detail" +} diff --git a/backend/plugins/agentready/impl/impl.go b/backend/plugins/agentready/impl/impl.go new file mode 100644 index 00000000000..60e5750eb84 --- /dev/null +++ b/backend/plugins/agentready/impl/impl.go @@ -0,0 +1,163 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package impl + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/plugin" + pluginhelper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/agentready/api" + "github.com/apache/devlake/plugins/agentready/models" + "github.com/apache/devlake/plugins/agentready/models/migrationscripts" + "github.com/apache/devlake/plugins/agentready/tasks" +) + +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginApi + plugin.PluginModel + plugin.PluginMigration + plugin.PluginTask + plugin.DataSourcePluginBlueprintV200 +} = (*AgentReady)(nil) + +type AgentReady struct{} + +func (p AgentReady) Description() string { + return "Collect and analyze AI readiness assessments from submissions repositories" +} + +func (p AgentReady) Name() string { + return "agentready" +} + +func (p AgentReady) Init(basicRes context.BasicRes) errors.Error { + api.Init(basicRes, p) + return nil +} + +func (p AgentReady) RootPkgPath() string { + return "github.com/apache/devlake/plugins/agentready" +} + +func (p AgentReady) GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + &models.AgentReadyConnection{}, + &models.AgentReadyScope{}, + &models.AgentReadyScopeConfig{}, + &models.AgentReadyAssessment{}, + &models.AgentReadyFinding{}, + &models.AgentReadyMetric{}, + } +} + +func (p AgentReady) SubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{ + tasks.CollectSubmissionsMeta, + tasks.ExtractAssessmentsMeta, + tasks.CalculateMetricsMeta, + } +} + +func (p AgentReady) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]any) (any, errors.Error) { + var op tasks.AgentReadyOptions + if err := pluginhelper.Decode(options, &op, nil); err != nil { + return nil, err + } + + connectionHelper := pluginhelper.NewConnectionHelper(taskCtx, nil, p.Name()) + connection := &models.AgentReadyConnection{} + if err := connectionHelper.FirstById(connection, op.ConnectionId); err != nil { + return nil, err + } + + return &tasks.AgentReadyTaskData{ + Options: &op, + Connection: connection, + }, nil +} + +func (p AgentReady) MakeDataSourcePipelinePlanV200( + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (pp coreModels.PipelinePlan, sc []plugin.Scope, err errors.Error) { + return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, bpScopes) +} + +func (p AgentReady) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +func (p AgentReady) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{ + "test": { + "POST": api.TestConnection, + }, + "connections": { + "POST": api.PostConnections, + "GET": api.ListConnections, + }, + "connections/:connectionId": { + "GET": api.GetConnection, + "PATCH": api.PatchConnection, + "DELETE": api.DeleteConnection, + }, + "connections/:connectionId/test": { + "POST": api.TestExistingConnection, + }, + "connections/:connectionId/remote-scopes": { + "GET": api.RemoteScopes, + }, + "connections/:connectionId/scopes/*scopeId": { + "GET": api.GetScopeDispatcher, + "PATCH": api.PatchScope, + "DELETE": api.DeleteScope, + }, + "connections/:connectionId/scopes": { + "GET": api.GetScopeList, + "PUT": api.PutScopes, + }, + "connections/:connectionId/scope-configs": { + "POST": api.CreateScopeConfig, + "GET": api.GetScopeConfigList, + }, + "connections/:connectionId/scope-configs/:scopeConfigId": { + "PATCH": api.UpdateScopeConfig, + "GET": api.GetScopeConfig, + "DELETE": api.DeleteScopeConfig, + }, + "scope-config/:scopeConfigId/projects": { + "GET": api.GetProjectsByScopeConfig, + }, + "connections/:connectionId/assessments": { + "GET": api.GetAssessments, + }, + "connections/:connectionId/assessments/:id": { + "GET": api.GetAssessment, + }, + "connections/:connectionId/assessments/:id/findings": { + "GET": api.GetAssessmentFindings, + }, + "connections/:connectionId/stats": { + "GET": api.GetStats, + }, + } +} diff --git a/backend/plugins/agentready/models/assessment.go b/backend/plugins/agentready/models/assessment.go new file mode 100644 index 00000000000..30184aaeed7 --- /dev/null +++ b/backend/plugins/agentready/models/assessment.go @@ -0,0 +1,59 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +type AgentReadyAssessment struct { + common.NoPKModel + + Id string `gorm:"primaryKey;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + RepoName string `gorm:"type:varchar(255)"` + ConnectionId uint64 `gorm:"primaryKey"` + Provider string `gorm:"type:varchar(50)"` + SchemaVersion string `gorm:"type:varchar(20)"` + OverallScore float64 `gorm:"type:float"` + CertificationLevel string `gorm:"type:varchar(50)"` + AttributesAssessed int `gorm:"type:int"` + AttributesTotal int `gorm:"type:int"` + Branch string `gorm:"type:varchar(255)"` + CommitHash string `gorm:"type:varchar(40)"` + DurationSeconds float64 `gorm:"type:float"` + AssessedAt time.Time `gorm:"index"` + CollectedAt time.Time + RawJSON string `gorm:"type:longtext"` +} + +func (AgentReadyAssessment) TableName() string { + return "_tool_agentready_assessments" +} + +const ( + CertPlatinum = "Platinum" + CertGold = "Gold" + CertSilver = "Silver" + CertBronze = "Bronze" + CertNeedsImprovement = "Needs Improvement" + CertNone = "None" + + ProjectMappingTable = "repos" +) diff --git a/backend/plugins/agentready/models/connection.go b/backend/plugins/agentready/models/connection.go new file mode 100644 index 00000000000..9ef5ddf4996 --- /dev/null +++ b/backend/plugins/agentready/models/connection.go @@ -0,0 +1,37 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package models + +import ( + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +type AgentReadyConnection struct { + helper.BaseConnection `mapstructure:",squash"` + GitHubConnectionId uint64 `mapstructure:"githubConnectionId" json:"githubConnectionId" gorm:"column:github_connection_id;not null"` + SubmissionsRepo string `mapstructure:"submissionsRepo" json:"submissionsRepo" validate:"required" gorm:"column:submissions_repo;type:varchar(255);not null"` + SubmissionsPath string `mapstructure:"submissionsPath" json:"submissionsPath" gorm:"column:submissions_path;type:varchar(255);default:submissions"` + Branch string `mapstructure:"branch" json:"branch" gorm:"column:branch;type:varchar(255)"` +} + +func (AgentReadyConnection) TableName() string { + return "_tool_agentready_connections" +} + +func (c AgentReadyConnection) Sanitize() AgentReadyConnection { + return c +} diff --git a/backend/plugins/agentready/models/connection_test.go b/backend/plugins/agentready/models/connection_test.go new file mode 100644 index 00000000000..2be8ade2026 --- /dev/null +++ b/backend/plugins/agentready/models/connection_test.go @@ -0,0 +1,42 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package models + +import ( + "testing" +) + +func TestConnectionTableName(t *testing.T) { + conn := AgentReadyConnection{} + want := "_tool_agentready_connections" + if got := conn.TableName(); got != want { + t.Errorf("TableName() = %q, want %q", got, want) + } +} + +func TestConnectionSanitize(t *testing.T) { + conn := AgentReadyConnection{ + GitHubConnectionId: 42, + SubmissionsRepo: "ambient-code/agentready", + SubmissionsPath: "submissions", + Branch: "main", + } + sanitized := conn.Sanitize() + if sanitized.SubmissionsRepo != "ambient-code/agentready" { + t.Errorf("Sanitize() changed SubmissionsRepo") + } +} diff --git a/backend/plugins/agentready/models/finding.go b/backend/plugins/agentready/models/finding.go new file mode 100644 index 00000000000..0f55fc09401 --- /dev/null +++ b/backend/plugins/agentready/models/finding.go @@ -0,0 +1,59 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +type AgentReadyFinding struct { + common.NoPKModel + + Id string `gorm:"primaryKey;type:varchar(255)"` + ConnectionId uint64 `gorm:"primaryKey"` + AssessmentId string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AttributeId string `gorm:"type:varchar(255)"` + AttributeName string `gorm:"type:varchar(255)"` + Category string `gorm:"type:varchar(255)"` + Tier int `gorm:"type:int"` + Status string `gorm:"type:varchar(50)"` + Score *float64 `gorm:"type:float"` + MeasuredValue string `gorm:"type:text"` + Threshold string `gorm:"type:text"` + Evidence string `gorm:"type:text"` + RemediationSummary string `gorm:"type:text"` + RemediationSteps string `gorm:"type:text"` + DefaultWeight float64 `gorm:"type:float"` +} + +func (AgentReadyFinding) TableName() string { + return "_tool_agentready_findings" +} + +const ( + FindingStatusPass = "pass" + FindingStatusFail = "fail" + FindingStatusSkipped = "skipped" + FindingStatusError = "error" + FindingStatusNotApplicable = "not_applicable" + + TierEssential = 1 + TierCritical = 2 + TierImportant = 3 + TierAdvanced = 4 +) diff --git a/backend/plugins/agentready/models/metric.go b/backend/plugins/agentready/models/metric.go new file mode 100644 index 00000000000..c1d1017596b --- /dev/null +++ b/backend/plugins/agentready/models/metric.go @@ -0,0 +1,44 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +type AgentReadyMetric struct { + common.NoPKModel + + Id string `gorm:"primaryKey;type:varchar(255)"` + ConnectionId uint64 `gorm:"primaryKey"` + RepoId string `gorm:"index;type:varchar(255)"` + AssessedAt time.Time `gorm:"index"` + PassCount int `gorm:"type:int"` + FailCount int `gorm:"type:int"` + SkipCount int `gorm:"type:int"` + Tier1PassRate float64 `gorm:"type:float"` + Tier2PassRate float64 `gorm:"type:float"` + Tier3PassRate float64 `gorm:"type:float"` + Tier4PassRate float64 `gorm:"type:float"` + CategoryScores string `gorm:"type:text"` +} + +func (AgentReadyMetric) TableName() string { + return "_tool_agentready_metrics" +} diff --git a/backend/plugins/agentready/models/migrationscripts/20260609_add_composite_primary_keys.go b/backend/plugins/agentready/models/migrationscripts/20260609_add_composite_primary_keys.go new file mode 100644 index 00000000000..1bdeee4e009 --- /dev/null +++ b/backend/plugins/agentready/models/migrationscripts/20260609_add_composite_primary_keys.go @@ -0,0 +1,71 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "net/url" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addCompositePrimaryKeys)(nil) + +type addCompositePrimaryKeys struct{} + +func (script *addCompositePrimaryKeys) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + dbURL := basicRes.GetConfig("DB_URL") + if dbURL == "" { + return errors.BadInput.New("DB_URL is required") + } + u, err := url.Parse(dbURL) + if err != nil { + return errors.Convert(err) + } + + tables := []string{ + "_tool_agentready_assessments", + "_tool_agentready_findings", + "_tool_agentready_metrics", + } + for _, table := range tables { + if u.Scheme == "mysql" { + if err := db.Exec("ALTER TABLE " + table + " DROP PRIMARY KEY"); err != nil { + return err + } + } else { + if err := db.Exec("ALTER TABLE " + table + " DROP CONSTRAINT " + table + "_pkey"); err != nil { + return err + } + } + if err := db.Exec("ALTER TABLE " + table + " ADD PRIMARY KEY (id, connection_id)"); err != nil { + return err + } + } + return nil +} + +func (script *addCompositePrimaryKeys) Version() uint64 { + return 20260609000001 +} + +func (script *addCompositePrimaryKeys) Name() string { + return "agentready add composite primary keys for connection_id" +} diff --git a/backend/plugins/agentready/models/migrationscripts/20260624_drop_project_from_connections.go b/backend/plugins/agentready/models/migrationscripts/20260624_drop_project_from_connections.go new file mode 100644 index 00000000000..512c265046d --- /dev/null +++ b/backend/plugins/agentready/models/migrationscripts/20260624_drop_project_from_connections.go @@ -0,0 +1,40 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*dropProjectFromConnections)(nil) + +type dropProjectFromConnections struct{} + +func (script *dropProjectFromConnections) Up(basicRes context.BasicRes) errors.Error { + return basicRes.GetDal().DropColumns("_tool_agentready_connections", "project") +} + +func (script *dropProjectFromConnections) Version() uint64 { + return 20260624000001 +} + +func (script *dropProjectFromConnections) Name() string { + return "agentready drop unused project column from connections" +} diff --git a/backend/plugins/agentready/models/migrationscripts/init_schema.go b/backend/plugins/agentready/models/migrationscripts/init_schema.go new file mode 100644 index 00000000000..f815232f030 --- /dev/null +++ b/backend/plugins/agentready/models/migrationscripts/init_schema.go @@ -0,0 +1,187 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/migrationhelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +var _ plugin.MigrationScript = (*initSchema)(nil) + +type initSchema struct{} + +type agentReadyConnection20260604 struct { + helper.BaseConnection `mapstructure:",squash"` + Project string `gorm:"column:project;type:varchar(200)"` + GitHubConnectionId uint64 `gorm:"column:github_connection_id;not null"` + SubmissionsRepo string `gorm:"column:submissions_repo;type:varchar(255);not null"` + SubmissionsPath string `gorm:"column:submissions_path;type:varchar(255);default:submissions"` + Branch string `gorm:"column:branch;type:varchar(255)"` +} + +func (agentReadyConnection20260604) TableName() string { + return "_tool_agentready_connections" +} + +type agentReadyScope20260604 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // common.RawDataOrigin fields + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // common.Scope fields + ConnectionId uint64 `json:"connectionId" gorm:"primaryKey"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty"` + // agentready-specific fields + FullName string `gorm:"primaryKey;type:varchar(255)"` + Name string `gorm:"type:varchar(255)"` +} + +func (agentReadyScope20260604) TableName() string { + return "_tool_agentready_scopes" +} + +type agentReadyScopeConfig20260604 struct { + // common.Model fields + ID uint64 `gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // common.ScopeConfig fields + Entities []string `gorm:"type:json;serializer:json" json:"entities"` + ConnectionId uint64 `json:"connectionId" gorm:"index"` + Name string `gorm:"type:varchar(255);uniqueIndex" json:"name"` +} + +func (agentReadyScopeConfig20260604) TableName() string { + return "_tool_agentready_scope_configs" +} + +type agentReadyAssessment20260604 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + Id string `gorm:"primaryKey;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + RepoName string `gorm:"type:varchar(255)"` + ConnectionId uint64 `gorm:"primaryKey"` + Provider string `gorm:"type:varchar(50)"` + SchemaVersion string `gorm:"type:varchar(20)"` + OverallScore float64 `gorm:"type:float"` + CertificationLevel string `gorm:"type:varchar(50)"` + AttributesAssessed int `gorm:"type:int"` + AttributesTotal int `gorm:"type:int"` + Branch string `gorm:"type:varchar(255)"` + CommitHash string `gorm:"type:varchar(40)"` + DurationSeconds float64 `gorm:"type:float"` + AssessedAt time.Time `gorm:"index"` + CollectedAt time.Time + RawJSON string `gorm:"type:longtext"` +} + +func (agentReadyAssessment20260604) TableName() string { + return "_tool_agentready_assessments" +} + +type agentReadyFinding20260604 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + Id string `gorm:"primaryKey;type:varchar(255)"` + ConnectionId uint64 `gorm:"primaryKey"` + AssessmentId string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AttributeId string `gorm:"type:varchar(255)"` + AttributeName string `gorm:"type:varchar(255)"` + Category string `gorm:"type:varchar(255)"` + Tier int `gorm:"type:int"` + Status string `gorm:"type:varchar(50)"` + Score *float64 `gorm:"type:float"` + MeasuredValue string `gorm:"type:text"` + Threshold string `gorm:"type:text"` + Evidence string `gorm:"type:text"` + RemediationSummary string `gorm:"type:text"` + RemediationSteps string `gorm:"type:text"` + DefaultWeight float64 `gorm:"type:float"` +} + +func (agentReadyFinding20260604) TableName() string { + return "_tool_agentready_findings" +} + +type agentReadyMetric20260604 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + Id string `gorm:"primaryKey;type:varchar(255)"` + ConnectionId uint64 `gorm:"primaryKey"` + RepoId string `gorm:"index;type:varchar(255)"` + AssessedAt time.Time `gorm:"index"` + PassCount int `gorm:"type:int"` + FailCount int `gorm:"type:int"` + SkipCount int `gorm:"type:int"` + Tier1PassRate float64 `gorm:"type:float"` + Tier2PassRate float64 `gorm:"type:float"` + Tier3PassRate float64 `gorm:"type:float"` + Tier4PassRate float64 `gorm:"type:float"` + CategoryScores string `gorm:"type:text"` +} + +func (agentReadyMetric20260604) TableName() string { + return "_tool_agentready_metrics" +} + +func (script *initSchema) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &agentReadyConnection20260604{}, + &agentReadyScope20260604{}, + &agentReadyScopeConfig20260604{}, + &agentReadyAssessment20260604{}, + &agentReadyFinding20260604{}, + &agentReadyMetric20260604{}, + ) +} + +func (script *initSchema) Version() uint64 { + return 20260604000001 +} + +func (script *initSchema) Name() string { + return "agentready init schema" +} diff --git a/backend/plugins/agentready/models/migrationscripts/register.go b/backend/plugins/agentready/models/migrationscripts/register.go new file mode 100644 index 00000000000..71612b6faac --- /dev/null +++ b/backend/plugins/agentready/models/migrationscripts/register.go @@ -0,0 +1,30 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/plugin" +) + +func All() []plugin.MigrationScript { + return []plugin.MigrationScript{ + &initSchema{}, + &addCompositePrimaryKeys{}, + &dropProjectFromConnections{}, + } +} diff --git a/backend/plugins/agentready/models/scope.go b/backend/plugins/agentready/models/scope.go new file mode 100644 index 00000000000..9e3ce83acc1 --- /dev/null +++ b/backend/plugins/agentready/models/scope.go @@ -0,0 +1,68 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package models + +import ( + "encoding/json" + + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" +) + +type AgentReadyScope struct { + common.Scope `mapstructure:",squash"` + FullName string `gorm:"primaryKey;type:varchar(255)" json:"fullName" mapstructure:"fullName" validate:"required"` + Name string `gorm:"type:varchar(255)" json:"name" mapstructure:"name"` + Id string `gorm:"-" json:"id" mapstructure:"-"` +} + +func (s AgentReadyScope) MarshalJSON() ([]byte, error) { + type Alias AgentReadyScope + alias := Alias(s) + alias.Id = s.FullName + return json.Marshal(alias) +} + +func (AgentReadyScope) TableName() string { + return "_tool_agentready_scopes" +} + +func (s AgentReadyScope) ScopeId() string { + return s.FullName +} + +func (s AgentReadyScope) ScopeName() string { + return s.Name +} + +func (s AgentReadyScope) ScopeFullName() string { + return s.FullName +} + +func (s AgentReadyScope) ScopeParams() interface{} { + return &AgentReadyApiParams{ + ConnectionId: s.ConnectionId, + FullName: s.FullName, + } +} + +type AgentReadyApiParams struct { + ConnectionId uint64 `json:"connectionId"` + FullName string `json:"fullName"` +} + +var _ plugin.ToolLayerScope = (*AgentReadyScope)(nil) diff --git a/backend/plugins/agentready/models/scope_config.go b/backend/plugins/agentready/models/scope_config.go new file mode 100644 index 00000000000..6d15d5998ab --- /dev/null +++ b/backend/plugins/agentready/models/scope_config.go @@ -0,0 +1,29 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +type AgentReadyScopeConfig struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` +} + +func (AgentReadyScopeConfig) TableName() string { + return "_tool_agentready_scope_configs" +} diff --git a/backend/plugins/agentready/models/scope_test.go b/backend/plugins/agentready/models/scope_test.go new file mode 100644 index 00000000000..9eddc5b32f1 --- /dev/null +++ b/backend/plugins/agentready/models/scope_test.go @@ -0,0 +1,70 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package models + +import ( + "encoding/json" + "testing" +) + +func TestScopeTableName(t *testing.T) { + s := AgentReadyScope{} + want := "_tool_agentready_scopes" + if got := s.TableName(); got != want { + t.Errorf("TableName() = %q, want %q", got, want) + } +} + +func TestScopeId(t *testing.T) { + s := AgentReadyScope{FullName: "myorg/myrepo", Name: "myrepo"} + if got := s.ScopeId(); got != "myorg/myrepo" { + t.Errorf("ScopeId() = %q, want %q", got, "myorg/myrepo") + } + if got := s.ScopeName(); got != "myrepo" { + t.Errorf("ScopeName() = %q, want %q", got, "myrepo") + } + if got := s.ScopeFullName(); got != "myorg/myrepo" { + t.Errorf("ScopeFullName() = %q, want %q", got, "myorg/myrepo") + } +} + +func TestScopeMarshalJSON(t *testing.T) { + s := AgentReadyScope{FullName: "myorg/myrepo", Name: "myrepo"} + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Unmarshal error = %v", err) + } + if m["id"] != "myorg/myrepo" { + t.Errorf("MarshalJSON() id = %v, want %q", m["id"], "myorg/myrepo") + } +} + +func TestScopeParams(t *testing.T) { + s := AgentReadyScope{FullName: "myorg/myrepo"} + s.ConnectionId = 5 + params := s.ScopeParams().(*AgentReadyApiParams) + if params.ConnectionId != 5 { + t.Errorf("ScopeParams().ConnectionId = %d, want 5", params.ConnectionId) + } + if params.FullName != "myorg/myrepo" { + t.Errorf("ScopeParams().FullName = %q, want %q", params.FullName, "myorg/myrepo") + } +} diff --git a/backend/plugins/agentready/tasks/assessment_extractor.go b/backend/plugins/agentready/tasks/assessment_extractor.go new file mode 100644 index 00000000000..c9cef0363c3 --- /dev/null +++ b/backend/plugins/agentready/tasks/assessment_extractor.go @@ -0,0 +1,206 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package tasks + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/agentready/models" +) + +var ExtractAssessmentsMeta = plugin.SubTaskMeta{ + Name: "extractAssessments", + EntryPoint: ExtractAssessments, + EnabledByDefault: true, + Description: "Parse raw assessment JSON into structured assessment and finding rows", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&CollectSubmissionsMeta}, +} + +type assessmentJSON struct { + SchemaVersion string `json:"schema_version"` + Repository repositoryJSON `json:"repository"` + Timestamp string `json:"timestamp"` + OverallScore float64 `json:"overall_score"` + CertificationLevel string `json:"certification_level"` + AttributesAssessed int `json:"attributes_assessed"` + AttributesTotal int `json:"attributes_total"` + DurationSeconds float64 `json:"duration_seconds"` + Findings []findingJSON `json:"findings"` +} + +type repositoryJSON struct { + Name string `json:"name"` + Branch string `json:"branch"` + CommitHash string `json:"commit_hash"` +} + +type findingJSON struct { + Attribute attributeJSON `json:"attribute"` + Status string `json:"status"` + Score *float64 `json:"score"` + MeasuredVal string `json:"measured_value"` + Threshold string `json:"threshold"` + Evidence []string `json:"evidence"` + Remediation *remediationJSON `json:"remediation"` +} + +type attributeJSON struct { + Id string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Tier int `json:"tier"` + DefaultWeight float64 `json:"default_weight"` +} + +type remediationJSON struct { + Summary string `json:"summary"` + Steps []string `json:"steps"` +} + +func ExtractAssessments(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AgentReadyTaskData) + + connectionId := data.Connection.ID + repoId := data.Options.FullName + + var rawAssessments []models.AgentReadyAssessment + err := db.All(&rawAssessments, + dal.From(&models.AgentReadyAssessment{}), + dal.Where("connection_id = ? AND repo_id = ? AND raw_json != ''", connectionId, repoId), + ) + if err != nil { + return errors.Default.Wrap(err, "failed to query raw assessments") + } + + logger.Info("Extracting %d assessments for scope %s", len(rawAssessments), repoId) + taskCtx.SetProgress(0, len(rawAssessments)) + + for i := range rawAssessments { + parsed, parseErr := parseRawAssessment(rawAssessments[i].RawJSON) + if parseErr != nil { + logger.Warn(nil, "Failed to parse assessment for %s: %v", repoId, parseErr) + taskCtx.IncProgress(1) + continue + } + assessment, assessErr := parseAssessmentJSON(&rawAssessments[i], parsed) + if assessErr != nil { + logger.Warn(nil, "Failed to extract assessment for %s: %v", repoId, assessErr) + taskCtx.IncProgress(1) + continue + } + + if dbErr := db.CreateOrUpdate(assessment); dbErr != nil { + logger.Warn(dbErr, "Failed to save parsed assessment %s", assessment.Id) + } + + findings, findErr := parseFindings(parsed, assessment.Id, assessment.RepoId, connectionId) + if findErr != nil { + logger.Warn(nil, "Failed to parse findings for %s: %v", repoId, findErr) + } + for _, f := range findings { + if dbErr := db.CreateOrUpdate(f); dbErr != nil { + logger.Warn(dbErr, "Failed to save finding %s", f.Id) + } + } + + taskCtx.IncProgress(1) + } + + return nil +} + +func parseAssessmentJSON(assessment *models.AgentReadyAssessment, parsed *assessmentJSON) (*models.AgentReadyAssessment, error) { + assessedAt, err := time.Parse(time.RFC3339, parsed.Timestamp) + if err != nil { + assessedAt = assessment.CollectedAt + } + + assessment.Id = fmt.Sprintf("%s:%s", assessment.RepoId, parsed.Repository.CommitHash) + assessment.SchemaVersion = parsed.SchemaVersion + assessment.OverallScore = parsed.OverallScore + assessment.CertificationLevel = parsed.CertificationLevel + assessment.AttributesAssessed = parsed.AttributesAssessed + assessment.AttributesTotal = parsed.AttributesTotal + assessment.Branch = parsed.Repository.Branch + assessment.CommitHash = parsed.Repository.CommitHash + assessment.DurationSeconds = parsed.DurationSeconds + assessment.AssessedAt = assessedAt + + return assessment, nil +} + +func parseFindings(parsed *assessmentJSON, assessmentId, repoId string, connectionId uint64) ([]*models.AgentReadyFinding, error) { + var findings []*models.AgentReadyFinding + for _, f := range parsed.Findings { + if f.Status == models.FindingStatusNotApplicable { + continue + } + + finding := &models.AgentReadyFinding{ + Id: fmt.Sprintf("%s:%s", assessmentId, f.Attribute.Id), + ConnectionId: connectionId, + AssessmentId: assessmentId, + RepoId: repoId, + AttributeId: f.Attribute.Id, + AttributeName: f.Attribute.Name, + Category: f.Attribute.Category, + Tier: f.Attribute.Tier, + Status: f.Status, + Score: f.Score, + MeasuredValue: f.MeasuredVal, + Threshold: f.Threshold, + DefaultWeight: f.Attribute.DefaultWeight, + } + + if len(f.Evidence) > 0 { + evidenceJSON, marshalErr := json.Marshal(f.Evidence) + if marshalErr == nil { + finding.Evidence = string(evidenceJSON) + } + } + + if f.Remediation != nil { + finding.RemediationSummary = f.Remediation.Summary + if len(f.Remediation.Steps) > 0 { + stepsJSON, marshalErr := json.Marshal(f.Remediation.Steps) + if marshalErr == nil { + finding.RemediationSteps = string(stepsJSON) + } + } + } + + findings = append(findings, finding) + } + + return findings, nil +} + +func parseRawAssessment(rawJSON string) (*assessmentJSON, error) { + var parsed assessmentJSON + if err := json.Unmarshal([]byte(rawJSON), &parsed); err != nil { + return nil, fmt.Errorf("parsing assessment JSON: %w", err) + } + return &parsed, nil +} diff --git a/backend/plugins/agentready/tasks/assessment_extractor_test.go b/backend/plugins/agentready/tasks/assessment_extractor_test.go new file mode 100644 index 00000000000..795953434d3 --- /dev/null +++ b/backend/plugins/agentready/tasks/assessment_extractor_test.go @@ -0,0 +1,191 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package tasks + +import ( + "testing" + "time" + + "github.com/apache/devlake/plugins/agentready/models" +) + +func TestParseAssessmentJSON(t *testing.T) { + rawJSON := `{ + "schema_version": "1.0.0", + "repository": { + "name": "my-repo", + "branch": "main", + "commit_hash": "abc123def456abc123def456abc123def456abc1" + }, + "timestamp": "2026-05-10T14:30:00Z", + "overall_score": 85.5, + "certification_level": "Gold", + "attributes_assessed": 20, + "attributes_total": 25, + "duration_seconds": 12.3, + "findings": [ + { + "attribute": { + "id": "doc-readme", + "name": "README Quality", + "category": "Documentation Standards", + "tier": 1, + "default_weight": 0.8 + }, + "status": "pass", + "score": 100.0, + "measured_value": "README exists with 500 words", + "threshold": "README with >100 words", + "evidence": ["README.md found", "500 words detected"] + }, + { + "attribute": { + "id": "sec-secrets", + "name": "No Secrets", + "category": "Security", + "tier": 2, + "default_weight": 0.9 + }, + "status": "fail", + "score": 30.0, + "measured_value": "2 potential secrets found", + "threshold": "0 secrets", + "evidence": [".env file contains API_KEY"], + "remediation": { + "summary": "Remove secrets from codebase", + "steps": ["Add .env to .gitignore", "Rotate exposed keys"] + } + }, + { + "attribute": { + "id": "na-attr", + "name": "N/A Attribute", + "category": "Other", + "tier": 4, + "default_weight": 0.1 + }, + "status": "not_applicable" + } + ] + }` + + assessment := &models.AgentReadyAssessment{ + RepoId: "github:GithubRepo:1:123", + ConnectionId: 1, + Provider: "github", + RepoName: "myorg/my-repo", + CollectedAt: time.Now(), + RawJSON: rawJSON, + } + + parsed, parseErr := parseRawAssessment(assessment.RawJSON) + if parseErr != nil { + t.Fatalf("unexpected error: %v", parseErr) + } + result, err := parseAssessmentJSON(assessment, parsed) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.OverallScore != 85.5 { + t.Errorf("OverallScore = %v, want 85.5", result.OverallScore) + } + if result.CertificationLevel != "Gold" { + t.Errorf("CertificationLevel = %v, want Gold", result.CertificationLevel) + } + if result.CommitHash != "abc123def456abc123def456abc123def456abc1" { + t.Errorf("CommitHash = %v, want abc123...", result.CommitHash) + } + if result.Id != "github:GithubRepo:1:123:abc123def456abc123def456abc123def456abc1" { + t.Errorf("Id = %v, want composite key with repo:commit", result.Id) + } + if result.SchemaVersion != "1.0.0" { + t.Errorf("SchemaVersion = %v, want 1.0.0", result.SchemaVersion) + } + if result.AttributesAssessed != 20 { + t.Errorf("AttributesAssessed = %v, want 20", result.AttributesAssessed) + } + if result.Branch != "main" { + t.Errorf("Branch = %v, want main", result.Branch) + } +} + +func TestParseFindings(t *testing.T) { + rawJSON := `{ + "schema_version": "1.0.0", + "repository": {"name": "r", "branch": "main", "commit_hash": "aaa"}, + "timestamp": "2026-05-10T14:30:00Z", + "overall_score": 50, + "certification_level": "Bronze", + "attributes_assessed": 2, + "attributes_total": 3, + "duration_seconds": 1, + "findings": [ + { + "attribute": {"id": "a1", "name": "A1", "category": "Cat1", "tier": 1, "default_weight": 0.5}, + "status": "pass", + "score": 100.0, + "evidence": ["ok"] + }, + { + "attribute": {"id": "a2", "name": "A2", "category": "Cat2", "tier": 2, "default_weight": 0.7}, + "status": "fail", + "score": 30.0, + "remediation": {"summary": "Fix it", "steps": ["step1", "step2"]} + }, + { + "attribute": {"id": "a3", "name": "A3", "category": "Cat3", "tier": 3, "default_weight": 0.1}, + "status": "not_applicable" + } + ] + }` + + assessmentId := "repo1:aaa" + repoId := "repo1" + + parsed, parseErr := parseRawAssessment(rawJSON) + if parseErr != nil { + t.Fatalf("unexpected error: %v", parseErr) + } + findings, err := parseFindings(parsed, assessmentId, repoId, 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(findings) != 2 { + t.Fatalf("expected 2 findings (not_applicable filtered), got %d", len(findings)) + } + + f1 := findings[0] + if f1.AttributeId != "a1" { + t.Errorf("finding[0].AttributeId = %v, want a1", f1.AttributeId) + } + if f1.Status != "pass" { + t.Errorf("finding[0].Status = %v, want pass", f1.Status) + } + if f1.Score == nil || *f1.Score != 100.0 { + t.Errorf("finding[0].Score = %v, want 100.0", f1.Score) + } + + f2 := findings[1] + if f2.RemediationSummary != "Fix it" { + t.Errorf("finding[1].RemediationSummary = %v, want 'Fix it'", f2.RemediationSummary) + } + if f2.Tier != 2 { + t.Errorf("finding[1].Tier = %v, want 2", f2.Tier) + } +} diff --git a/backend/plugins/agentready/tasks/metrics_calculator.go b/backend/plugins/agentready/tasks/metrics_calculator.go new file mode 100644 index 00000000000..acd8056e659 --- /dev/null +++ b/backend/plugins/agentready/tasks/metrics_calculator.go @@ -0,0 +1,154 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package tasks + +import ( + "encoding/json" + "fmt" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/agentready/models" +) + +var CalculateMetricsMeta = plugin.SubTaskMeta{ + Name: "calculateMetrics", + EntryPoint: CalculateMetrics, + EnabledByDefault: true, + Description: "Compute aggregated pass rates and category scores per assessment", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&ExtractAssessmentsMeta}, +} + +func CalculateMetrics(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AgentReadyTaskData) + + connectionId := data.Connection.ID + repoId := data.Options.FullName + + var assessments []models.AgentReadyAssessment + err := db.All(&assessments, + dal.From(&models.AgentReadyAssessment{}), + dal.Where("connection_id = ? AND repo_id = ?", connectionId, repoId), + ) + if err != nil { + return errors.Default.Wrap(err, "failed to query assessments") + } + + if len(assessments) == 0 { + logger.Info("No assessments found for scope %s, skipping metrics", repoId) + return nil + } + + logger.Info("Calculating metrics for %d assessments", len(assessments)) + taskCtx.SetProgress(0, len(assessments)) + + assessmentIds := make([]string, 0, len(assessments)) + for _, a := range assessments { + assessmentIds = append(assessmentIds, a.Id) + } + + var allFindings []*models.AgentReadyFinding + err = db.All(&allFindings, + dal.From(&models.AgentReadyFinding{}), + dal.Where("connection_id = ? AND assessment_id IN (?)", connectionId, assessmentIds), + ) + if err != nil { + return errors.Default.Wrap(err, "failed to query findings") + } + + findingsByAssessment := map[string][]*models.AgentReadyFinding{} + for _, f := range allFindings { + findingsByAssessment[f.AssessmentId] = append(findingsByAssessment[f.AssessmentId], f) + } + + for _, assessment := range assessments { + findings := findingsByAssessment[assessment.Id] + metric, calcErr := CalculateMetricsFromFindings(findings) + if calcErr != nil { + logger.Warn(nil, "Failed to calculate metrics for assessment %s: %v", assessment.Id, calcErr) + } + metric.Id = fmt.Sprintf("%s:%s:%s", assessment.RepoId, assessment.AssessedAt.Format("20060102T150405"), assessment.Id) + metric.ConnectionId = connectionId + metric.RepoId = assessment.RepoId + metric.AssessedAt = assessment.AssessedAt + + if dbErr := db.CreateOrUpdate(metric); dbErr != nil { + logger.Warn(dbErr, "Failed to save metric %s", metric.Id) + } + taskCtx.IncProgress(1) + } + + return nil +} + +func CalculateMetricsFromFindings(findings []*models.AgentReadyFinding) (*models.AgentReadyMetric, error) { + metric := &models.AgentReadyMetric{} + + tierPass := map[int]int{1: 0, 2: 0, 3: 0, 4: 0} + tierTotal := map[int]int{1: 0, 2: 0, 3: 0, 4: 0} + catScoreSum := map[string]float64{} + catCount := map[string]int{} + + for _, f := range findings { + switch f.Status { + case models.FindingStatusPass: + metric.PassCount++ + tierPass[f.Tier]++ + tierTotal[f.Tier]++ + case models.FindingStatusFail: + metric.FailCount++ + tierTotal[f.Tier]++ + default: + metric.SkipCount++ + } + + if f.Score != nil { + catScoreSum[f.Category] += *f.Score + catCount[f.Category]++ + } + } + + metric.Tier1PassRate = tierPassRate(tierPass[1], tierTotal[1]) + metric.Tier2PassRate = tierPassRate(tierPass[2], tierTotal[2]) + metric.Tier3PassRate = tierPassRate(tierPass[3], tierTotal[3]) + metric.Tier4PassRate = tierPassRate(tierPass[4], tierTotal[4]) + + catAvg := map[string]float64{} + for cat, sum := range catScoreSum { + if catCount[cat] > 0 { + catAvg[cat] = sum / float64(catCount[cat]) + } + } + catJSON, marshalErr := json.Marshal(catAvg) + if marshalErr != nil { + return metric, fmt.Errorf("marshaling category scores: %w", marshalErr) + } + metric.CategoryScores = string(catJSON) + + return metric, nil +} + +func tierPassRate(pass, total int) float64 { + if total == 0 { + return 0 + } + return float64(pass) / float64(total) * 100 +} diff --git a/backend/plugins/agentready/tasks/metrics_calculator_test.go b/backend/plugins/agentready/tasks/metrics_calculator_test.go new file mode 100644 index 00000000000..169a2fb498b --- /dev/null +++ b/backend/plugins/agentready/tasks/metrics_calculator_test.go @@ -0,0 +1,66 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package tasks + +import ( + "testing" + + "github.com/apache/devlake/plugins/agentready/models" +) + +func TestCalculateMetricsFromFindings(t *testing.T) { + score100 := 100.0 + score30 := 30.0 + + findings := []*models.AgentReadyFinding{ + {Tier: 1, Status: "pass", Score: &score100, Category: "Docs", DefaultWeight: 0.5}, + {Tier: 1, Status: "fail", Score: &score30, Category: "Docs", DefaultWeight: 0.5}, + {Tier: 2, Status: "pass", Score: &score100, Category: "Security", DefaultWeight: 0.8}, + {Tier: 3, Status: "pass", Score: &score100, Category: "Quality", DefaultWeight: 0.3}, + {Tier: 3, Status: "skipped", Score: nil, Category: "Quality", DefaultWeight: 0.3}, + } + + metric, err := CalculateMetricsFromFindings(findings) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if metric.PassCount != 3 { + t.Errorf("PassCount = %d, want 3", metric.PassCount) + } + if metric.FailCount != 1 { + t.Errorf("FailCount = %d, want 1", metric.FailCount) + } + if metric.SkipCount != 1 { + t.Errorf("SkipCount = %d, want 1", metric.SkipCount) + } + if metric.Tier1PassRate != 50.0 { + t.Errorf("Tier1PassRate = %v, want 50.0", metric.Tier1PassRate) + } + if metric.Tier2PassRate != 100.0 { + t.Errorf("Tier2PassRate = %v, want 100.0", metric.Tier2PassRate) + } + if metric.Tier3PassRate != 100.0 { + t.Errorf("Tier3PassRate = %v, want 100.0", metric.Tier3PassRate) + } + if metric.Tier4PassRate != 0.0 { + t.Errorf("Tier4PassRate = %v, want 0.0", metric.Tier4PassRate) + } + if metric.CategoryScores == "" { + t.Error("CategoryScores should not be empty") + } +} diff --git a/backend/plugins/agentready/tasks/submissions_collector.go b/backend/plugins/agentready/tasks/submissions_collector.go new file mode 100644 index 00000000000..d0e1e0717df --- /dev/null +++ b/backend/plugins/agentready/tasks/submissions_collector.go @@ -0,0 +1,394 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package tasks + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/agentready/models" +) + +const maxTreeResponseSize = 5 << 20 +// Upstream convention: each submissions/{org}/{repo}/ directory contains timestamped +// assessment files plus an assessment-latest.json symlink pointing to the most recent one. +// We only collect the symlink to avoid duplicate DB rows per repo. The GitHub Contents API +// follows symlinks transparently, so no special handling is needed. +const latestAssessmentFilename = "assessment-latest.json" + +var CollectSubmissionsMeta = plugin.SubTaskMeta{ + Name: "collectSubmissions", + EntryPoint: CollectSubmissions, + EnabledByDefault: true, + Description: "Fetch assessment JSON files from the submissions repository for a specific scope", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, +} + +type SubmissionEntry struct { + Org string + Repo string + Filename string + TreePath string +} + +type githubTreeResponse struct { + SHA string `json:"sha"` + Tree []GithubTreeEntry `json:"tree"` + Truncated bool `json:"truncated"` +} + +type GithubTreeEntry struct { + Path string `json:"path"` + Mode string `json:"mode"` + Type string `json:"type"` + SHA string `json:"sha"` + Size int `json:"size"` +} + +type githubConnLookup struct { + ID uint64 `gorm:"primaryKey;column:id"` + Endpoint string `gorm:"column:endpoint"` + Token string `gorm:"column:token;serializer:encdec"` +} + +func (githubConnLookup) TableName() string { return "_tool_github_connections" } + +type collectorAssessmentJSON struct { + Repository struct { + CommitHash string `json:"commit_hash"` + } `json:"repository"` +} + +var defaultHTTPClient = &http.Client{Timeout: 30 * time.Second} + +func CollectSubmissions(taskCtx plugin.SubTaskContext) errors.Error { + ctx := taskCtx.GetContext() + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AgentReadyTaskData) + conn := data.Connection + + var ghConn githubConnLookup + if err := db.First(&ghConn, dal.Where("id = ?", conn.GitHubConnectionId)); err != nil { + return errors.Default.Wrap(err, fmt.Sprintf("GitHub connection %d not found", conn.GitHubConnectionId)) + } + + endpoint := ghConn.Endpoint + if endpoint == "" { + endpoint = "https://api.github.com" + } + + submissionsPath := conn.SubmissionsPath + if submissionsPath == "" { + submissionsPath = "submissions" + } + + treeResp, fetchErr := FetchGithubTree(ctx, endpoint, conn.SubmissionsRepo, conn.Branch, ghConn.Token) + if fetchErr != nil { + return errors.Default.Wrap(fetchErr, "failed to fetch submissions tree") + } + + if treeResp.Truncated { + logger.Warn(nil, "Submissions repo tree is truncated; some entries may be missing") + } + + allEntries := ParseSubmissionEntries(treeResp.Tree, submissionsPath) + + scopeFullName := data.Options.FullName + var entries []SubmissionEntry + for _, e := range allEntries { + entryFullName := fmt.Sprintf("%s/%s", e.Org, e.Repo) + if entryFullName == scopeFullName { + entries = append(entries, e) + } + } + + logger.Info("Found %d assessment files for scope %s", len(entries), scopeFullName) + taskCtx.SetProgress(0, len(entries)) + + now := time.Now() + for _, entry := range entries { + rawJSON, fetchErr := FetchGithubAssessment(ctx, endpoint, conn.SubmissionsRepo, entry.TreePath, conn.Branch, ghConn.Token) + if fetchErr != nil { + logger.Warn(nil, "Failed to fetch submission %s: %v", entry.TreePath, fetchErr) + taskCtx.IncProgress(1) + continue + } + if rawJSON == "" { + taskCtx.IncProgress(1) + continue + } + + var partial collectorAssessmentJSON + if jsonErr := json.Unmarshal([]byte(rawJSON), &partial); jsonErr != nil { + logger.Warn(nil, "Failed to parse submission JSON %s: %v", entry.TreePath, jsonErr) + taskCtx.IncProgress(1) + continue + } + + commitHash := partial.Repository.CommitHash + if commitHash == "" { + logger.Warn(nil, "Submission %s has no commit_hash, skipping", entry.TreePath) + taskCtx.IncProgress(1) + continue + } + + repoId := scopeFullName + assessment := &models.AgentReadyAssessment{ + Id: fmt.Sprintf("%s:%s", repoId, commitHash), + RepoId: repoId, + RepoName: scopeFullName, + ConnectionId: conn.ID, + Provider: "github", + CollectedAt: now, + RawJSON: rawJSON, + } + + if saveErr := db.CreateOrUpdate(assessment); saveErr != nil { + logger.Warn(saveErr, "Failed to save submission assessment for %s", entry.TreePath) + } + taskCtx.IncProgress(1) + } + + return nil +} + +func ParseSubmissionEntries(tree []GithubTreeEntry, submissionsPath string) []SubmissionEntry { + prefix := submissionsPath + "/" + var entries []SubmissionEntry + + for _, entry := range tree { + if entry.Type != "blob" { + continue + } + if !strings.HasPrefix(entry.Path, prefix) { + continue + } + if !strings.HasSuffix(entry.Path, ".json") { + continue + } + + relPath := strings.TrimPrefix(entry.Path, prefix) + parts := strings.SplitN(relPath, "/", 4) + if len(parts) != 3 { + continue + } + + if parts[2] != latestAssessmentFilename { + continue + } + + entries = append(entries, SubmissionEntry{ + Org: parts[0], + Repo: parts[1], + Filename: parts[2], + TreePath: entry.Path, + }) + } + + return entries +} + +func FetchGithubTree(ctx context.Context, endpoint, fullName, branch, token string, client ...*http.Client) (*githubTreeResponse, error) { + if token == "" { + return nil, fmt.Errorf("a GitHub token is required to fetch the tree") + } + if branch == "" { + return nil, fmt.Errorf("branch is required; resolve the default branch before calling FetchGithubTree") + } + + hc := defaultHTTPClient + if len(client) > 0 && client[0] != nil { + hc = client[0] + } + + endpoint = strings.TrimSuffix(endpoint, "/") + apiURL := fmt.Sprintf("%s/repos/%s/git/trees/%s?recursive=1", endpoint, fullName, url.PathEscape(branch)) + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("creating tree request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := hc.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching tree from GitHub: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256)) + if readErr != nil { + return nil, fmt.Errorf("GitHub Trees API returned %d (body unreadable: %w)", resp.StatusCode, readErr) + } + return nil, fmt.Errorf("GitHub Trees API returned %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxTreeResponseSize+1)) + if err != nil { + return nil, fmt.Errorf("reading tree response: %w", err) + } + if len(body) > maxTreeResponseSize { + return nil, fmt.Errorf("tree response exceeds %d bytes limit", maxTreeResponseSize) + } + + var treeResp githubTreeResponse + if err := json.Unmarshal(body, &treeResp); err != nil { + return nil, fmt.Errorf("decoding tree response: %w", err) + } + + return &treeResp, nil +} + +func FetchDefaultBranch(ctx context.Context, endpoint, fullName, token string, client ...*http.Client) (string, error) { + if token == "" { + return "", fmt.Errorf("a GitHub token is required to fetch the default branch") + } + + hc := defaultHTTPClient + if len(client) > 0 && client[0] != nil { + hc = client[0] + } + + endpoint = strings.TrimSuffix(endpoint, "/") + apiURL := fmt.Sprintf("%s/repos/%s", endpoint, fullName) + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := hc.Do(req) + if err != nil { + return "", fmt.Errorf("fetching from GitHub: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256)) + if readErr != nil { + return "", fmt.Errorf("GitHub API returned %d (body unreadable: %w)", resp.StatusCode, readErr) + } + return "", fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + DefaultBranch string `json:"default_branch"` + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxTreeResponseSize+1)) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + if len(body) > maxTreeResponseSize { + return "", fmt.Errorf("response exceeds %d bytes limit", maxTreeResponseSize) + } + + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("decoding response: %w", err) + } + + if result.DefaultBranch == "" { + return "", fmt.Errorf("default_branch is empty in response") + } + + return result.DefaultBranch, nil +} + +const maxAssessmentSize = 10 << 20 + +func FetchGithubAssessment(ctx context.Context, endpoint, fullName, filePath, branch, token string, client ...*http.Client) (string, error) { + hc := defaultHTTPClient + if len(client) > 0 && client[0] != nil { + hc = client[0] + } + + endpoint = strings.TrimSuffix(endpoint, "/") + apiURL := fmt.Sprintf("%s/repos/%s/contents/%s", endpoint, fullName, filePath) + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + if branch != "" { + q := req.URL.Query() + q.Set("ref", branch) + req.URL.RawQuery = q.Encode() + } + if token == "" { + return "", fmt.Errorf("a GitHub token is required to make requests") + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := hc.Do(req) + if err != nil { + return "", fmt.Errorf("fetching from GitHub: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return "", nil + } + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256)) + if readErr != nil { + return "", fmt.Errorf("GitHub API returned %d (body unreadable: %w)", resp.StatusCode, readErr) + } + return "", fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxAssessmentSize+1)) + if err != nil { + return "", fmt.Errorf("reading GitHub response: %w", err) + } + if len(body) > maxAssessmentSize { + return "", fmt.Errorf("GitHub response exceeds %d bytes limit", maxAssessmentSize) + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("decoding GitHub response: %w", err) + } + + if result.Encoding != "base64" { + return "", fmt.Errorf("unexpected encoding: %s", result.Encoding) + } + + cleaned := strings.ReplaceAll(result.Content, "\n", "") + decoded, err := base64.StdEncoding.DecodeString(cleaned) + if err != nil { + return "", fmt.Errorf("decoding base64 content: %w", err) + } + + return string(decoded), nil +} diff --git a/backend/plugins/agentready/tasks/submissions_collector_test.go b/backend/plugins/agentready/tasks/submissions_collector_test.go new file mode 100644 index 00000000000..ebf0fced0a5 --- /dev/null +++ b/backend/plugins/agentready/tasks/submissions_collector_test.go @@ -0,0 +1,302 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package tasks + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseSubmissionEntries(t *testing.T) { + tree := []GithubTreeEntry{ + {Path: "submissions/org1/repo1/assessment-20260207-144231.json", Type: "blob"}, + {Path: "submissions/org1/repo1/assessment-20260301-100000.json", Type: "blob"}, + {Path: "submissions/org1/repo1/assessment-latest.json", Type: "blob"}, + {Path: "submissions/org2/repoA/assessment-20260115-090000.json", Type: "blob"}, + {Path: "submissions/org2/repoA/assessment-latest.json", Type: "blob"}, + {Path: "submissions/.trigger", Type: "blob"}, + {Path: "submissions/org1", Type: "tree"}, + {Path: "submissions/org1/repo1", Type: "tree"}, + {Path: "README.md", Type: "blob"}, + {Path: "submissions/org1/repo1/notes.txt", Type: "blob"}, + {Path: "submissions/deep/nested/too/far/file.json", Type: "blob"}, + } + + entries := ParseSubmissionEntries(tree, "submissions") + + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + if entries[0].Org != "org1" { + t.Errorf("entries[0].Org = %q, want %q", entries[0].Org, "org1") + } + if entries[0].Repo != "repo1" { + t.Errorf("entries[0].Repo = %q, want %q", entries[0].Repo, "repo1") + } + if entries[0].Filename != "assessment-latest.json" { + t.Errorf("entries[0].Filename = %q, want %q", entries[0].Filename, "assessment-latest.json") + } + if entries[0].TreePath != "submissions/org1/repo1/assessment-latest.json" { + t.Errorf("entries[0].TreePath = %q, want %q", entries[0].TreePath, "submissions/org1/repo1/assessment-latest.json") + } + + if entries[1].Org != "org2" { + t.Errorf("entries[1].Org = %q, want %q", entries[1].Org, "org2") + } + if entries[1].Repo != "repoA" { + t.Errorf("entries[1].Repo = %q, want %q", entries[1].Repo, "repoA") + } + if entries[1].Filename != "assessment-latest.json" { + t.Errorf("entries[1].Filename = %q, want %q", entries[1].Filename, "assessment-latest.json") + } + if entries[1].TreePath != "submissions/org2/repoA/assessment-latest.json" { + t.Errorf("entries[1].TreePath = %q, want %q", entries[1].TreePath, "submissions/org2/repoA/assessment-latest.json") + } +} + +func TestParseSubmissionEntries_SkipsTimestampedFiles(t *testing.T) { + tree := []GithubTreeEntry{ + {Path: "submissions/org1/repo1/assessment-20260207-144231.json", Type: "blob"}, + {Path: "submissions/org1/repo1/assessment-20260301-100000.json", Type: "blob"}, + } + + entries := ParseSubmissionEntries(tree, "submissions") + + if len(entries) != 0 { + t.Fatalf("expected 0 entries for timestamped-only directory, got %d", len(entries)) + } +} + +func TestParseSubmissionEntries_CustomPath(t *testing.T) { + tree := []GithubTreeEntry{ + {Path: "data/assessments/org1/repo1/assessment-latest.json", Type: "blob"}, + {Path: "submissions/org1/repo1/assessment-latest.json", Type: "blob"}, + } + + entries := ParseSubmissionEntries(tree, "data/assessments") + + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Org != "org1" { + t.Errorf("Org = %q, want %q", entries[0].Org, "org1") + } + if entries[0].Repo != "repo1" { + t.Errorf("Repo = %q, want %q", entries[0].Repo, "repo1") + } + if entries[0].TreePath != "data/assessments/org1/repo1/assessment-latest.json" { + t.Errorf("TreePath = %q, want %q", entries[0].TreePath, "data/assessments/org1/repo1/assessment-latest.json") + } +} + +func TestParseSubmissionEntries_Empty(t *testing.T) { + entries := ParseSubmissionEntries(nil, "submissions") + + if len(entries) != 0 { + t.Errorf("expected 0 entries, got %d", len(entries)) + } +} + +func TestFetchGithubTree(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/owner/repo/git/trees/main" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("recursive") != "1" { + t.Errorf("expected recursive=1, got %s", r.URL.Query().Get("recursive")) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("expected Bearer test-token, got %s", r.Header.Get("Authorization")) + } + + resp := githubTreeResponse{ + SHA: "abc123", + Truncated: false, + Tree: []GithubTreeEntry{ + {Path: "submissions/org1/repo1/test.json", Mode: "100644", Type: "blob", SHA: "def456", Size: 1024}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + result, err := FetchGithubTree(context.Background(), server.URL, "owner/repo", "main", "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.SHA != "abc123" { + t.Errorf("SHA = %q, want %q", result.SHA, "abc123") + } + if len(result.Tree) != 1 { + t.Fatalf("expected 1 tree entry, got %d", len(result.Tree)) + } + if result.Tree[0].Path != "submissions/org1/repo1/test.json" { + t.Errorf("Tree[0].Path = %q, want %q", result.Tree[0].Path, "submissions/org1/repo1/test.json") + } +} + +func TestFetchGithubTree_NoToken(t *testing.T) { + _, err := FetchGithubTree(context.Background(), "https://api.github.com", "owner/repo", "main", "") + if err == nil { + t.Fatal("expected error for empty token") + } +} + +func TestFetchGithubTree_EmptyBranch(t *testing.T) { + _, err := FetchGithubTree(context.Background(), "https://api.github.com", "owner/repo", "", "test-token") + if err == nil { + t.Fatal("expected error for empty branch") + } + if !strings.Contains(err.Error(), "branch is required") { + t.Errorf("expected error about branch required, got: %v", err) + } +} + +func TestFetchGithubTree_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"message": "API rate limit exceeded"}`)) + })) + defer server.Close() + + _, err := FetchGithubTree(context.Background(), server.URL, "owner/repo", "main", "test-token") + if err == nil { + t.Fatal("expected error for 403 response") + } + if !strings.Contains(err.Error(), "403") { + t.Errorf("expected error containing '403', got %v", err) + } +} + +func TestFetchGithubTree_BranchWithSlash(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/repos/owner/repo/git/trees/feature%2Fsubmissions" + if r.URL.RawPath != expectedPath { + t.Errorf("expected RawPath %q, got %q", expectedPath, r.URL.RawPath) + } + resp := githubTreeResponse{SHA: "abc", Tree: []GithubTreeEntry{}} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + _, err := FetchGithubTree(context.Background(), server.URL, "owner/repo", "feature/submissions", "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFetchGithubAssessment_CustomBranch(t *testing.T) { + assessmentJSON := `{"overall_score": 70.0}` + encoded := base64.StdEncoding.EncodeToString([]byte(assessmentJSON)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ref := r.URL.Query().Get("ref"); ref != "test" { + t.Errorf("expected ref=test, got ref=%s", ref) + } + resp := map[string]any{ + "content": encoded, + "encoding": "base64", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + result, err := FetchGithubAssessment(context.Background(), server.URL, "org/submissions-repo", "submissions/myorg/myrepo/assessment.json", "test", "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != assessmentJSON { + t.Errorf("expected %q, got %q", assessmentJSON, result) + } +} + +func TestFetchDefaultBranch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/owner/repo" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("expected Bearer test-token, got %s", r.Header.Get("Authorization")) + } + + resp := map[string]any{ + "default_branch": "develop", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + result, err := FetchDefaultBranch(context.Background(), server.URL, "owner/repo", "test-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "develop" { + t.Errorf("expected %q, got %q", "develop", result) + } +} + +func TestFetchDefaultBranch_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message": "Not Found"}`)) + })) + defer server.Close() + + _, err := FetchDefaultBranch(context.Background(), server.URL, "owner/repo", "test-token") + if err == nil { + t.Fatal("expected error for 404 response") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("expected error containing '404', got %v", err) + } +} + +func TestFetchDefaultBranch_EmptyDefault(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"default_branch": ""}`)) + })) + defer server.Close() + + _, err := FetchDefaultBranch(context.Background(), server.URL, "owner/repo", "test-token") + if err == nil { + t.Fatal("expected error for empty default_branch") + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("expected error containing 'empty', got %v", err) + } +} + +func TestFetchDefaultBranch_NoToken(t *testing.T) { + _, err := FetchDefaultBranch(context.Background(), "https://api.github.com", "owner/repo", "") + if err == nil { + t.Fatal("expected error for empty token") + } + if !strings.Contains(err.Error(), "token is required") { + t.Errorf("expected error containing 'token is required', got %v", err) + } +} diff --git a/backend/plugins/agentready/tasks/task_data.go b/backend/plugins/agentready/tasks/task_data.go new file mode 100644 index 00000000000..8a39e5aefde --- /dev/null +++ b/backend/plugins/agentready/tasks/task_data.go @@ -0,0 +1,32 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package tasks + +import ( + "github.com/apache/devlake/plugins/agentready/models" +) + +type AgentReadyOptions struct { + ConnectionId uint64 `json:"connectionId"` + FullName string `json:"fullName"` + ScopeConfig *models.AgentReadyScopeConfig `json:"scopeConfig"` +} + +type AgentReadyTaskData struct { + Options *AgentReadyOptions + Connection *models.AgentReadyConnection +} diff --git a/backend/plugins/aireview/AGENTS.md b/backend/plugins/aireview/AGENTS.md new file mode 100644 index 00000000000..cd59f1e7b52 --- /dev/null +++ b/backend/plugins/aireview/AGENTS.md @@ -0,0 +1,53 @@ +# aireview Plugin — Agent Context + +Metric/transformer plugin that extracts AI-generated code reviews from GitHub/GitLab PR comments, tracks prediction accuracy against CI outcomes, and computes precision/recall/F1 for AI review tools (CodeRabbit, Qodo, Gemini, Cursor Bugbot). + +## Build & Test + +```bash +cd backend +go test ./plugins/aireview/... -v # unit tests +go test ./plugins/aireview/e2e/... -v # e2e tests (needs MySQL + lake_test) +golangci-lint run ./plugins/aireview/... # lint +``` + +Single-file verification: `go vet ./plugins/aireview/...` + +## Layout + +- `impl/impl.go` — plugin interfaces (PluginMeta, PluginTask, PluginMetric, MetricPluginBlueprintV200) +- `models/` — tool-layer models + `migrationscripts/register.go` (all migrations listed in `All()`) +- `models/scope_config.go` — per-team regex patterns for AI tool detection and risk classification +- `tasks/` — subtask pipeline: extract reviews → extract AI commits → enrich reactions → findings → convert → match diffs → fetch CI → predict → metrics +- `api/` — REST endpoints (reviews, findings, stats, scope-configs, analyze) +- `e2e/raw_tables/` — CSV fixtures for e2e tests + +## Conventions + +- This is a **metric plugin** (not data-source): no Connection model, configured via Projects +- Implements `MetricPluginBlueprintV200`; runs *after* github/gitlab plugins +- Subtask order matters: see `SubTaskMetas()` in `impl/impl.go` +- All regex patterns are compiled once in `tasks.CompilePatterns()` and stored in `AiReviewTaskData` +- New AI tool support: add fields to `AiReviewScopeConfig`, update `CompilePatterns()`, update `detectAiTool()` +- AI-assisted **commits** are classified by `detectAiCommit()` (trailer matchers + optional `AiCommitPatterns`) + +## Don'ts + +- Don't add models to `GetTablesInfo()` without a migration script in `migrationscripts/register.go` +- Don't import from other plugins (plugins must be independent) +- Don't skip the Apache 2.0 license header on new files +- Don't use `strings.ToLower()` for case-insensitive matching — use `strings.EqualFold()` or `(?i)` regex flag + +## Pattern References + +| Change Type | Example File | +|---|---| +| Add new AI tool support | `models/scope_config.go`, `tasks/extract_ai_reviews.go`, `tasks/detect_ai_commit.go` | +| Add migration | `models/migrationscripts/20260415_add_flaky_infra_filters.go` | +| Add subtask | `tasks/calculate_failure_predictions.go`, then register in `impl/impl.go:SubTaskMetas()` | +| Add e2e test | `e2e/aireview_test.go` + CSV fixtures in `e2e/raw_tables/` | +| Add API endpoint | `api/reviews.go`, register in `impl/impl.go:ApiResources()` | + +## Skills + +- **PR Definition of Done**: see [skills/pr-definition-of-done/SKILL.md](skills/pr-definition-of-done/SKILL.md) diff --git a/backend/plugins/aireview/README.md b/backend/plugins/aireview/README.md new file mode 100644 index 00000000000..4e84c91c38a --- /dev/null +++ b/backend/plugins/aireview/README.md @@ -0,0 +1,218 @@ +# AI Review Plugin + +## Overview + +The AI Review plugin extracts and analyzes AI-generated code reviews from pull requests to calculate the "AI Predicted Failure Avoidance" metric. This metric measures whether AI code review tools can effectively predict and flag risky code changes before they reach production. + +## Plugin Architecture + +This is a **metric/transformer plugin** (not a data source plugin): + +- Works on domain layer data from GitHub/GitLab plugins +- Implements `MetricPluginBlueprintV200` interface +- Configured via Projects, not Connections +- Runs after data collection plugins + +### Directory Structure + +``` +plugins/aireview/ +├── aireview.go # Entry point (standalone debugging) +├── impl/ +│ └── impl.go # Plugin implementation (interfaces) +├── models/ +│ ├── ai_review.go # AiReview model +│ ├── ai_commit.go # AiCommit model (AI-assisted commits) +│ ├── ai_review_finding.go # AiReviewFinding model +│ ├── ai_failure_prediction.go # Prediction models +│ ├── scope_config.go # Configurable patterns +│ └── migrationscripts/ +│ └── init_schema.go # Database migrations +├── tasks/ +│ ├── task_data.go # Task options and validation +│ ├── extract_ai_reviews.go # Extract AI reviews from comments +│ ├── extract_ai_commits.go # Classify AI-assisted commits +│ ├── convert_ai_commits.go +│ ├── extract_ai_review_findings.go +│ ├── calculate_failure_predictions.go +│ ├── calculate_prediction_metrics.go +│ └── extract_ai_reviews_test.go # Unit tests +└── e2e/ + ├── aireview_test.go # E2E tests + └── raw_tables/ # Test fixtures (CSVs) +``` + +### Interfaces Implemented + +```go +var _ interface { + plugin.PluginMeta + plugin.PluginTask + plugin.PluginModel + plugin.PluginMetric + plugin.PluginMigration + plugin.MetricPluginBlueprintV200 +} = (*AiReview)(nil) +``` + +## Features + +- **Multi-platform support**: Works with both GitHub PRs and GitLab MRs +- **Multi-tool support**: Currently supports CodeRabbit, with extensibility for Cursor Bugbot, SonarQube, and other AI review tools +- **AI-assisted commit classification**: Precomputes which commits were authored with Cursor/Claude/Copilot/CodeRabbit (from git trailers) into `ai_commits`. +- **Per-team configuration**: Teams can configure which AI tools they use and customize detection patterns +- **Prediction accuracy tracking**: Tracks AI predictions against actual outcomes (CI failures, bugs, rollbacks) +- **Metrics calculation**: Computes precision, recall, accuracy, and F1 scores for AI predictions + +## Metrics + +### AI Predicted Failure Avoidance + +This metric uses a confusion matrix approach: + +| Metric | Formula | Target | +|--------|---------|--------| +| Precision | TP / (TP + FP) | > 70% | +| Recall | TP / (TP + FN) | > 65% | +| Accuracy | (TP + TN) / Total | > 80% | + +Where: +- **True Positive (TP)**: AI flagged as risky AND failure occurred +- **False Positive (FP)**: AI flagged as risky AND no failure occurred +- **False Negative (FN)**: AI didn't flag AND failure occurred +- **True Negative (TN)**: AI didn't flag AND no failure occurred + +### Autonomy Level Recommendations + +Based on prediction accuracy, the plugin recommends AI autonomy levels: + +| Precision | Recall | Recommendation | +|-----------|--------|----------------| +| > 80% | > 70% | Auto-block risky PRs | +| 60-80% | 50-70% | Mandatory human review | +| < 60% | < 50% | Advisory only | + +## Data Models + +### AiReview +Stores AI-generated reviews with metadata: +- Review body and summary +- Risk assessment (level, score, confidence) +- Metrics (issues found, suggestions, files reviewed) +- Effort estimation (complexity, time) + +### AiReviewFinding +Individual findings from AI reviews: +- Category (security, performance, bug, style, etc.) +- Severity (info, warning, error, critical) +- Code location and suggested fixes +- Resolution tracking + +### AiFailurePrediction +Tracks prediction outcomes: +- Was the PR flagged as risky? +- Did CI fail after merge? +- Were bugs reported within the observation window? +- Was the change rolled back? + +### AiPredictionMetrics +Aggregated metrics over time periods: +- Confusion matrix counts +- Precision, recall, accuracy, F1 score +- Recommended autonomy level + +## Configuration + +### Scope Config Options + +```json +{ + "codeRabbitEnabled": true, + "codeRabbitUsername": "coderabbitai", + "codeRabbitPattern": "(?i)(coderabbit|walkthrough|summary by coderabbit)", + "cursorBugbotEnabled": false, + "cursorBugbotUsername": "cursor-bugbot", + "cursorBugbotPattern": "(?i)(cursor|bugbot)", + "riskHighPattern": "(?i)(critical|security|breaking|major)", + "riskMediumPattern": "(?i)(warning|medium|moderate)", + "riskLowPattern": "(?i)(minor|low|info|suggestion)", + "observationWindowDays": 14, + "bugLinkPattern": "(?i)(fixes|closes|resolves)\\s*#(\\d+)" +} +``` + +## Usage + +### Prerequisites + +This plugin requires data from either the GitHub or GitLab plugin. Ensure you have: +1. Collected PR/MR data using the respective plugin +2. PR comments are collected (including bot comments) + +### Running the Plugin + +The plugin runs as part of the DevLake pipeline. Configure it in your blueprint: + +```json +{ + "plugin": "aireview", + "options": { + "repoId": "github:GithubRepo:1:12345", + "scopeConfig": { + "codeRabbitEnabled": true, + "observationWindowDays": 14 + } + } +} +``` + +### Standalone Debugging + +```bash +go run plugins/aireview/aireview.go \ + --repoId="github:GithubRepo:1:12345" \ + --codeRabbitUsername="coderabbitai" \ + --timeAfter="2024-01-01T00:00:00Z" +``` + +## Subtasks + +1. **extractAiReviews**: Identifies and extracts AI-generated reviews from PR comments +2. **extractAiReviewFindings**: Parses reviews to extract individual findings +3. **calculateFailurePredictions**: Tracks prediction outcomes against actual failures +4. **calculatePredictionMetrics**: Aggregates data into precision/recall metrics + +## Database Tables + +- `_tool_aireview_reviews`: AI review records +- `_tool_aireview_findings`: Individual findings from reviews +- `_tool_aireview_failure_predictions`: Prediction outcome tracking +- `_tool_aireview_prediction_metrics`: Aggregated metrics +- `_tool_aireview_scope_configs`: Per-scope configuration +- `_tool_aireview_commits`: Sparse AI-assisted commit classifications +- `ai_commits`: Project-scoped domain table for Grafana + +## Extending for New AI Tools + +To add support for a new AI review tool: + +1. Add configuration fields to `AiReviewScopeConfig`: + ```go + NewToolEnabled bool `gorm:"type:boolean"` + NewToolUsername string `gorm:"type:varchar(255)"` + NewToolPattern string `gorm:"type:varchar(500)"` + ``` + +2. Update `CompilePatterns()` in `task_data.go` to compile the new patterns + +3. Update `detectAiTool()` in `extract_ai_reviews.go` to check for the new tool + +4. Add tool-specific parsing in `parseFindings()` if the tool has a unique format + +## Related Metrics + +This plugin is part of the AI Quality Metrics Framework and provides foundational data for: +- AI Value Delivery tracking +- Developer productivity metrics +- Code quality trends +- Review efficiency analysis diff --git a/backend/plugins/aireview/aireview.go b/backend/plugins/aireview/aireview.go new file mode 100644 index 00000000000..a09fea06887 --- /dev/null +++ b/backend/plugins/aireview/aireview.go @@ -0,0 +1,49 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/apache/devlake/core/runner" + "github.com/apache/devlake/plugins/aireview/impl" + "github.com/spf13/cobra" +) + +// PluginEntry is a variable exported for Framework to search and load +var PluginEntry impl.AiReview //nolint + +// standalone mode for debugging +func main() { + cmd := &cobra.Command{Use: "aireview"} + repoId := cmd.Flags().StringP("repoId", "r", "", "repository domain ID to analyze") + timeAfter := cmd.Flags().StringP("timeAfter", "a", "", "collect data that are created after specified time, ie 2006-01-02T15:04:05Z") + + // AI review tool patterns + codeRabbitUsername := cmd.Flags().String("codeRabbitUsername", "coderabbitai", "CodeRabbit bot username pattern") + codeRabbitPattern := cmd.Flags().String("codeRabbitPattern", "(?i)coderabbit", "CodeRabbit comment body pattern") + + cmd.Run = func(cmd *cobra.Command, args []string) { + runner.DirectRun(cmd, args, PluginEntry, map[string]interface{}{ + "repoId": *repoId, + "scopeConfig": map[string]interface{}{ + "codeRabbitUsername": *codeRabbitUsername, + "codeRabbitPattern": *codeRabbitPattern, + }, + }, *timeAfter) + } + runner.RunCmd(cmd) +} diff --git a/backend/plugins/aireview/api/analyze.go b/backend/plugins/aireview/api/analyze.go new file mode 100644 index 00000000000..2cff79fd1c8 --- /dev/null +++ b/backend/plugins/aireview/api/analyze.go @@ -0,0 +1,111 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/http" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/aireview/tasks" +) + +// AnalyzeRequest represents the request body for generating analysis pipeline +type AnalyzeRequest struct { + ProjectName string `json:"projectName"` + RepoId string `json:"repoId"` + ScopeConfigId uint64 `json:"scopeConfigId"` + TimeAfter string `json:"timeAfter"` +} + +// GenerateAnalysisPipeline generates a pipeline configuration for AI review analysis +// @Summary Generate analysis pipeline +// @Description Generate a pipeline configuration to analyze PR comments for AI reviews. +// @Description Submit the returned pipeline to POST /pipelines to execute. +// @Description Use this to re-analyze data after changing scope config patterns. +// @Tags plugins/aireview +// @Accept json +// @Param body body AnalyzeRequest true "Analysis parameters" +// @Success 200 {object} map[string]any +// @Router /plugins/aireview/analyze [post] +func GenerateAnalysisPipeline(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var request AnalyzeRequest + + // Decode request body + err := api.DecodeMapStruct(input.Body, &request, true) + if err != nil { + return nil, errors.BadInput.Wrap(err, "failed to decode request body") + } + + // Validate: either projectName or repoId is required + if request.ProjectName == "" && request.RepoId == "" { + return nil, errors.BadInput.New("either projectName or repoId is required") + } + + // Build pipeline options + opts := map[string]any{} + if request.ProjectName != "" { + opts["projectName"] = request.ProjectName + } + if request.RepoId != "" { + opts["repoId"] = request.RepoId + } + if request.ScopeConfigId != 0 { + opts["scopeConfigId"] = request.ScopeConfigId + } + if request.TimeAfter != "" { + opts["timeAfter"] = request.TimeAfter + } + + // Create pipeline plan + plan := models.PipelinePlan{ + { + { + Plugin: "aireview", + Options: opts, + Subtasks: []string{ + tasks.ExtractAiReviewsMeta.Name, + tasks.ExtractAiReviewFindingsMeta.Name, + tasks.CalculateFailurePredictionsMeta.Name, + tasks.CalculatePredictionMetricsMeta.Name, + }, + }, + }, + } + + // Generate pipeline name + pipelineName := "AI Review Analysis" + if request.ProjectName != "" { + pipelineName = "AI Review Analysis - " + request.ProjectName + } else if request.RepoId != "" { + pipelineName = "AI Review Analysis - " + request.RepoId + } + + // Return the pipeline configuration that can be submitted to /pipelines + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "name": pipelineName, + "plan": plan, + "message": "Submit this configuration to POST /pipelines to execute the analysis. " + + "Example: curl -X POST http://localhost:8080/pipelines -H 'Content-Type: application/json' -d ''", + }, + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/aireview/api/init.go b/backend/plugins/aireview/api/init.go new file mode 100644 index 00000000000..7f3d8e93f0e --- /dev/null +++ b/backend/plugins/aireview/api/init.go @@ -0,0 +1,33 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/plugin" +) + +var db dal.Dal +var basicRes context.BasicRes + +// Init initializes the API with basic resources +func Init(br context.BasicRes, _ plugin.PluginMeta) { + basicRes = br + db = basicRes.GetDal() +} diff --git a/backend/plugins/aireview/api/reviews.go b/backend/plugins/aireview/api/reviews.go new file mode 100644 index 00000000000..cafa1bbaf1b --- /dev/null +++ b/backend/plugins/aireview/api/reviews.go @@ -0,0 +1,282 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/http" + "strconv" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +// GetReviews returns a list of AI reviews with optional filtering +// @Summary Get AI reviews +// @Description Get a list of AI-generated code reviews +// @Tags plugins/aireview +// @Param page query int false "Page number" default(1) +// @Param pageSize query int false "Page size" default(50) +// @Param repoId query string false "Filter by repository ID" +// @Param projectName query string false "Filter by project name" +// @Param riskLevel query string false "Filter by risk level (high, medium, low)" +// @Param aiTool query string false "Filter by AI tool (coderabbit, cursor-bugbot)" +// @Success 200 {object} map[string]any +// @Router /plugins/aireview/reviews [get] +func GetReviews(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + // Parse pagination + page, _ := strconv.Atoi(input.Query.Get("page")) + if page <= 0 { + page = 1 + } + pageSize, _ := strconv.Atoi(input.Query.Get("pageSize")) + if pageSize <= 0 || pageSize > 100 { + pageSize = 50 + } + offset := (page - 1) * pageSize + + // Build base query clauses + var clauses []dal.Clause + + // Project filter requires join with project_mapping + if projectName := input.Query.Get("projectName"); projectName != "" { + clauses = []dal.Clause{ + dal.Select("r.*"), + dal.From("_tool_aireview_reviews r"), + dal.Join("JOIN project_mapping pm ON r.repo_id = pm.row_id"), + dal.Where("pm.project_name = ? AND pm.`table` = ?", projectName, "repos"), + } + } else { + clauses = []dal.Clause{ + dal.From(&models.AiReview{}), + } + // Apply filters + if repoId := input.Query.Get("repoId"); repoId != "" { + clauses = append(clauses, dal.Where("repo_id = ?", repoId)) + } + } + + if riskLevel := input.Query.Get("riskLevel"); riskLevel != "" { + clauses = append(clauses, dal.Where("risk_level = ?", riskLevel)) + } + if aiTool := input.Query.Get("aiTool"); aiTool != "" { + clauses = append(clauses, dal.Where("ai_tool = ?", aiTool)) + } + + // Get total count + countClauses := make([]dal.Clause, len(clauses)) + copy(countClauses, clauses) + total, err := db.Count(countClauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to count reviews") + } + + // Get paginated results + clauses = append(clauses, + dal.Orderby("created_date DESC"), + dal.Limit(pageSize), + dal.Offset(offset), + ) + + var reviews []models.AiReview + err = db.All(&reviews, clauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to query reviews") + } + + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "reviews": reviews, + "page": page, + "pageSize": pageSize, + "total": total, + }, + Status: http.StatusOK, + }, nil +} + +// GetReview returns a single AI review by ID +// @Summary Get AI review by ID +// @Description Get a single AI-generated code review +// @Tags plugins/aireview +// @Param id path string true "Review ID" +// @Success 200 {object} models.AiReview +// @Router /plugins/aireview/reviews/{id} [get] +func GetReview(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + reviewId := input.Params["id"] + if reviewId == "" { + return nil, errors.BadInput.New("review id is required") + } + + var review models.AiReview + err := db.First(&review, dal.Where("id = ?", reviewId)) + if err != nil { + if db.IsErrorNotFound(err) { + return nil, errors.NotFound.Wrap(err, "review not found") + } + return nil, errors.Default.Wrap(err, "failed to get review") + } + + return &plugin.ApiResourceOutput{ + Body: review, + Status: http.StatusOK, + }, nil +} + +// GetReviewStats returns aggregated statistics for AI reviews +// @Summary Get AI review statistics +// @Description Get aggregated statistics for AI-generated code reviews +// @Tags plugins/aireview +// @Param repoId query string false "Filter by repository ID" +// @Param projectName query string false "Filter by project name" +// @Success 200 {object} map[string]any +// @Router /plugins/aireview/stats [get] +func GetReviewStats(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + // Build base clauses for filtering + var baseClauses []dal.Clause + + if projectName := input.Query.Get("projectName"); projectName != "" { + baseClauses = []dal.Clause{ + dal.From("_tool_aireview_reviews r"), + dal.Join("JOIN project_mapping pm ON r.repo_id = pm.row_id"), + dal.Where("pm.project_name = ? AND pm.`table` = ?", projectName, "repos"), + } + } else { + baseClauses = []dal.Clause{ + dal.From(&models.AiReview{}), + } + if repoId := input.Query.Get("repoId"); repoId != "" { + baseClauses = append(baseClauses, dal.Where("repo_id = ?", repoId)) + } + } + + // Get total count + total, err := db.Count(baseClauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to get total count") + } + + // Risk level counts + type RiskCount struct { + RiskLevel string `gorm:"column:risk_level" json:"riskLevel"` + Count int64 `gorm:"column:count" json:"count"` + } + var riskCounts []RiskCount + riskClauses := append(baseClauses, + dal.Select("risk_level, COUNT(*) as count"), + dal.Groupby("risk_level"), + ) + err = db.All(&riskCounts, riskClauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to get risk counts") + } + + // AI tool counts + type ToolCount struct { + AiTool string `gorm:"column:ai_tool" json:"aiTool"` + Count int64 `gorm:"column:count" json:"count"` + } + var toolCounts []ToolCount + toolClauses := append(baseClauses, + dal.Select("ai_tool, COUNT(*) as count"), + dal.Groupby("ai_tool"), + ) + err = db.All(&toolCounts, toolClauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to get tool counts") + } + + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "total": total, + "byRiskLevel": riskCounts, + "byAiTool": toolCounts, + }, + Status: http.StatusOK, + }, nil +} + +// GetFindings returns a list of AI review findings +// @Summary Get AI review findings +// @Description Get a list of individual findings from AI reviews +// @Tags plugins/aireview +// @Param page query int false "Page number" default(1) +// @Param pageSize query int false "Page size" default(50) +// @Param reviewId query string false "Filter by review ID" +// @Param category query string false "Filter by category (security, bug, performance, etc.)" +// @Param severity query string false "Filter by severity (critical, major, minor, info)" +// @Success 200 {object} map[string]any +// @Router /plugins/aireview/findings [get] +func GetFindings(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + // Parse pagination + page, _ := strconv.Atoi(input.Query.Get("page")) + if page <= 0 { + page = 1 + } + pageSize, _ := strconv.Atoi(input.Query.Get("pageSize")) + if pageSize <= 0 || pageSize > 100 { + pageSize = 50 + } + offset := (page - 1) * pageSize + + // Build query clauses + clauses := []dal.Clause{ + dal.From(&models.AiReviewFinding{}), + } + + // Apply filters + if reviewId := input.Query.Get("reviewId"); reviewId != "" { + clauses = append(clauses, dal.Where("review_id = ?", reviewId)) + } + if category := input.Query.Get("category"); category != "" { + clauses = append(clauses, dal.Where("category = ?", category)) + } + if severity := input.Query.Get("severity"); severity != "" { + clauses = append(clauses, dal.Where("severity = ?", severity)) + } + + // Get total count + total, err := db.Count(clauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to count findings") + } + + // Get paginated results + clauses = append(clauses, + dal.Orderby("id DESC"), + dal.Limit(pageSize), + dal.Offset(offset), + ) + + var findings []models.AiReviewFinding + err = db.All(&findings, clauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to query findings") + } + + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "findings": findings, + "page": page, + "pageSize": pageSize, + "total": total, + }, + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/aireview/api/scope_config.go b/backend/plugins/aireview/api/scope_config.go new file mode 100644 index 00000000000..a16fa6948c5 --- /dev/null +++ b/backend/plugins/aireview/api/scope_config.go @@ -0,0 +1,250 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/http" + "strconv" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/aireview/models" +) + +// GetScopeConfigs returns a list of scope configurations +// @Summary Get scope configurations +// @Description Get a list of AI Review scope configurations +// @Tags plugins/aireview +// @Param page query int false "Page number" default(1) +// @Param pageSize query int false "Page size" default(50) +// @Success 200 {object} map[string]any +// @Router /plugins/aireview/scope-configs [get] +func GetScopeConfigs(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + // Parse pagination + page, _ := strconv.Atoi(input.Query.Get("page")) + if page <= 0 { + page = 1 + } + pageSize, _ := strconv.Atoi(input.Query.Get("pageSize")) + if pageSize <= 0 || pageSize > 100 { + pageSize = 50 + } + offset := (page - 1) * pageSize + + // Get total count + total, err := db.Count(dal.From(&models.AiReviewScopeConfig{})) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to count scope configs") + } + + // Get paginated results + var configs []models.AiReviewScopeConfig + err = db.All(&configs, + dal.From(&models.AiReviewScopeConfig{}), + dal.Orderby("id DESC"), + dal.Limit(pageSize), + dal.Offset(offset), + ) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to query scope configs") + } + + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "scopeConfigs": configs, + "page": page, + "pageSize": pageSize, + "total": total, + }, + Status: http.StatusOK, + }, nil +} + +// GetScopeConfig returns a single scope configuration by ID +// @Summary Get scope configuration by ID +// @Description Get a single AI Review scope configuration +// @Tags plugins/aireview +// @Param id path int true "Scope Config ID" +// @Success 200 {object} models.AiReviewScopeConfig +// @Router /plugins/aireview/scope-configs/{id} [get] +func GetScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + configId, err := strconv.ParseUint(input.Params["id"], 10, 64) + if err != nil { + return nil, errors.BadInput.Wrap(err, "invalid scope config id") + } + + var config models.AiReviewScopeConfig + dbErr := db.First(&config, dal.Where("id = ?", configId)) + if dbErr != nil { + if db.IsErrorNotFound(dbErr) { + return nil, errors.NotFound.Wrap(dbErr, "scope config not found") + } + return nil, errors.Default.Wrap(dbErr, "failed to get scope config") + } + + return &plugin.ApiResourceOutput{ + Body: config, + Status: http.StatusOK, + }, nil +} + +// CreateScopeConfig creates a new scope configuration +// @Summary Create scope configuration +// @Description Create a new AI Review scope configuration +// @Tags plugins/aireview +// @Accept json +// @Param body body models.AiReviewScopeConfig true "Scope configuration" +// @Success 201 {object} models.AiReviewScopeConfig +// @Router /plugins/aireview/scope-configs [post] +func CreateScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + config := models.GetDefaultScopeConfig() + + // Decode request body into config (overwriting defaults where provided) + err := api.DecodeMapStruct(input.Body, config, true) + if err != nil { + return nil, errors.BadInput.Wrap(err, "failed to decode scope config") + } + + // Upsert by name: if a scope config with the same name already exists, update it. + // This handles the common case where name="" and the unique index would otherwise reject the insert. + var existing models.AiReviewScopeConfig + dbErr := db.First(&existing, dal.Where("name = ?", config.Name)) + if dbErr == nil { + config.ID = existing.ID + dbErr = db.Update(config) + if dbErr != nil { + return nil, errors.Default.Wrap(dbErr, "failed to update scope config") + } + return &plugin.ApiResourceOutput{ + Body: config, + Status: http.StatusOK, + }, nil + } + + // Only fall through to Create when the lookup returned "not found". + // Any other error (connection failure, etc.) must be surfaced immediately. + if !db.IsErrorNotFound(dbErr) { + return nil, errors.Default.Wrap(dbErr, "failed to look up scope config by name") + } + + // No existing record — create new + dbErr = db.Create(config) + if dbErr != nil { + return nil, errors.Default.Wrap(dbErr, "failed to create scope config") + } + + return &plugin.ApiResourceOutput{ + Body: config, + Status: http.StatusCreated, + }, nil +} + +// UpdateScopeConfig updates an existing scope configuration +// @Summary Update scope configuration +// @Description Update an existing AI Review scope configuration +// @Tags plugins/aireview +// @Accept json +// @Param id path int true "Scope Config ID" +// @Param body body models.AiReviewScopeConfig true "Scope configuration" +// @Success 200 {object} models.AiReviewScopeConfig +// @Router /plugins/aireview/scope-configs/{id} [patch] +func UpdateScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + configId, err := strconv.ParseUint(input.Params["id"], 10, 64) + if err != nil { + return nil, errors.BadInput.Wrap(err, "invalid scope config id") + } + + // Get existing config + var config models.AiReviewScopeConfig + dbErr := db.First(&config, dal.Where("id = ?", configId)) + if dbErr != nil { + if db.IsErrorNotFound(dbErr) { + return nil, errors.NotFound.Wrap(dbErr, "scope config not found") + } + return nil, errors.Default.Wrap(dbErr, "failed to get scope config") + } + + // Decode request body into config (updating only provided fields) + err = api.DecodeMapStruct(input.Body, &config, true) + if err != nil { + return nil, errors.BadInput.Wrap(err, "failed to decode scope config") + } + + // Ensure ID is preserved + config.ID = configId + + // Update in database + dbErr = db.Update(&config) + if dbErr != nil { + return nil, errors.Default.Wrap(dbErr, "failed to update scope config") + } + + return &plugin.ApiResourceOutput{ + Body: config, + Status: http.StatusOK, + }, nil +} + +// DeleteScopeConfig deletes a scope configuration +// @Summary Delete scope configuration +// @Description Delete an AI Review scope configuration +// @Tags plugins/aireview +// @Param id path int true "Scope Config ID" +// @Success 204 +// @Router /plugins/aireview/scope-configs/{id} [delete] +func DeleteScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + configId, err := strconv.ParseUint(input.Params["id"], 10, 64) + if err != nil { + return nil, errors.BadInput.Wrap(err, "invalid scope config id") + } + + // Check if config exists + var config models.AiReviewScopeConfig + dbErr := db.First(&config, dal.Where("id = ?", configId)) + if dbErr != nil { + if db.IsErrorNotFound(dbErr) { + return nil, errors.NotFound.Wrap(dbErr, "scope config not found") + } + return nil, errors.Default.Wrap(dbErr, "failed to get scope config") + } + + // Delete from database + dbErr = db.Delete(&config) + if dbErr != nil { + return nil, errors.Default.Wrap(dbErr, "failed to delete scope config") + } + + return &plugin.ApiResourceOutput{ + Status: http.StatusNoContent, + }, nil +} + +// GetDefaultScopeConfig returns the default scope configuration +// @Summary Get default scope configuration +// @Description Get the default AI Review scope configuration with all default values +// @Tags plugins/aireview +// @Success 200 {object} models.AiReviewScopeConfig +// @Router /plugins/aireview/scope-configs/default [get] +func GetDefaultScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return &plugin.ApiResourceOutput{ + Body: models.GetDefaultScopeConfig(), + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/aireview/docs/METRICS_REFERENCE.md b/backend/plugins/aireview/docs/METRICS_REFERENCE.md new file mode 100644 index 00000000000..b33a4e2d28d --- /dev/null +++ b/backend/plugins/aireview/docs/METRICS_REFERENCE.md @@ -0,0 +1,225 @@ +# AI Review Metrics Reference + +This document describes all metrics collected and calculated by the AI Review plugin. + +## Data Tables + +### `_tool_aireview_reviews` + +Stores extracted AI-generated code reviews from pull request comments. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | string | Unique review ID (hash-based) | +| `pull_request_id` | string | Domain layer PR ID | +| `repo_id` | string | Domain layer repository ID | +| `ai_tool` | string | AI tool identifier (e.g., `coderabbit`, `cursor-bugbot`) | +| `ai_tool_user` | string | Username/account of the AI bot | +| `review_id` | string | Original comment ID | +| `body` | text | Full review comment body | +| `summary` | string | Extracted summary (max 500 chars) | +| `created_date` | datetime | When the review was posted | +| `risk_level` | string | Detected risk level: `high`, `medium`, `low` | +| `risk_score` | int | Numeric risk score (0-100) | +| `risk_confidence` | int | Confidence in risk assessment (0-100) | +| `issues_found` | int | Number of issues detected in review | +| `suggestions_count` | int | Number of suggestions made | +| `files_reviewed` | int | Number of files mentioned | +| `lines_reviewed` | int | Lines of code reviewed | +| `effort_complexity` | string | Complexity level: `trivial`, `simple`, `moderate`, `complex` | +| `effort_minutes` | int | Estimated review effort in minutes | +| `review_state` | string | Review outcome: `approved`, `changes_requested`, `commented` | +| `source_platform` | string | Source platform: `github`, `gitlab` | +| `source_url` | string | URL to the pull request | + +### `_tool_aireview_commits` + +Sparse table of commits classified as AI-assisted. Only AI-positive commits are stored. +Bot identity is **not** stored here — join `accounts.is_bot`. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | string | Unique ID (hash of `repo_id:sha`) | +| `commit_sha` | string | Commit SHA | +| `repo_id` | string | Domain layer repository ID | +| `ai_tool` | string | `cursor`, `claude`, `copilot`, `coderabbit`, `assisted_by_unknown`, `made_with_unknown`, `other` | +| `author_name` | string | Git author name | +| `authored_date` | datetime | Commit authored date | + +### `ai_commits` + +Project-scoped domain copy of `_tool_aireview_commits` (same columns plus `project_name`). Grafana and n8n should join this table, not `_tool_aireview_commits`. + +After an aireview **project** pipeline run: + +- `aiAssisted` / `aiToolUsed` = `LEFT JOIN ai_commits` (use `ai_tool` as stored; do not remap in SQL) +- `bot` = `accounts.is_bot` and not in `ai_commits` +- `human` = neither of the above, excluding merge-commit message prefixes (`Merge pull request #`, `Merge branch `, `Merge remote-tracking branch `) +- Merge commits are not bots. Join `accounts` on `email` / `user_name` (`commits.author_id` is a git email, not `accounts.id`). + +### `_tool_aireview_findings` + +Individual issues, suggestions, or observations extracted from reviews. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | string | Unique finding ID | +| `review_id` | string | Parent AI review ID | +| `pull_request_id` | string | Associated PR ID | +| `repo_id` | string | Repository ID | +| `category` | string | Finding category (see below) | +| `severity` | string | Severity: `critical`, `major`, `minor`, `info` | +| `title` | string | Brief finding title | +| `description` | text | Full finding description | +| `file_path` | string | Affected file path | +| `line_start` | int | Starting line number | +| `line_end` | int | Ending line number | +| `suggestion` | text | Suggested fix or improvement | +| `is_resolved` | bool | Whether the finding was addressed | + +#### Finding Categories + +| Category | Description | +|----------|-------------| +| `security` | Security vulnerabilities, authentication issues | +| `performance` | Performance problems, inefficiencies | +| `bug` | Logic errors, potential bugs | +| `style` | Code style, formatting issues | +| `documentation` | Missing or incorrect documentation | +| `testing` | Test coverage, test quality | +| `maintainability` | Code complexity, readability | + +### `_tool_aireview_failure_predictions` + +Tracks AI prediction accuracy against actual outcomes. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | string | Unique prediction ID | +| `review_id` | string | Source AI review ID | +| `pull_request_id` | string | Associated PR ID | +| `repo_id` | string | Repository ID | +| `predicted_risk` | string | AI predicted risk level | +| `predicted_score` | int | AI predicted risk score | +| `actual_outcome` | string | What actually happened | +| `outcome_type` | string | `TP`, `FP`, `FN`, `TN` (confusion matrix) | +| `merged_date` | datetime | When PR was merged | +| `failure_date` | datetime | When failure occurred (if any) | +| `observation_window_days` | int | Days after merge to observe | + +#### Outcome Types + +| Type | Description | +|------|-------------| +| `TP` | True Positive - AI predicted risk, failure occurred | +| `FP` | False Positive - AI predicted risk, no failure | +| `FN` | False Negative - AI missed risk, failure occurred | +| `TN` | True Negative - AI predicted safe, no failure | + +### `_tool_aireview_prediction_metrics` + +Aggregated prediction accuracy metrics. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | string | Unique metrics ID | +| `repo_id` | string | Repository ID | +| `time_period` | string | Aggregation period (e.g., `2024-01`) | +| `total_predictions` | int | Total predictions made | +| `true_positives` | int | TP count | +| `false_positives` | int | FP count | +| `false_negatives` | int | FN count | +| `true_negatives` | int | TN count | +| `precision` | float | TP / (TP + FP) | +| `recall` | float | TP / (TP + FN) | +| `f1_score` | float | 2 * (precision * recall) / (precision + recall) | +| `accuracy` | float | (TP + TN) / total | + +## Calculated Metrics + +### Risk Level Detection + +Risk levels are detected by matching patterns in review content: + +| Risk Level | Score | Trigger Patterns | +|------------|-------|------------------| +| High | 80 | `critical`, `security`, `vulnerability`, `breaking` | +| Medium | 50 | `warning`, `caution`, `potential`, `moderate` | +| Low | 20 | `minor`, `suggestion`, `consider`, `nitpick` | +| Default | 10 | No risk patterns matched | + +### Effort Estimation + +Effort is estimated from complexity keywords: + +| Complexity | Estimated Minutes | +|------------|-------------------| +| Trivial | 5 | +| Simple | 5 | +| Moderate | 15 | +| Complex | 30 | + +If explicit time mentions are found (e.g., "~12 minutes"), those override the estimate. + +### Review State Detection + +Review state is determined from content and status: + +| State | Detection Method | +|-------|------------------| +| `approved` | Contains "approved", "LGTM", or status is APPROVED | +| `changes_requested` | Contains "changes requested" or status is CHANGES_REQUESTED | +| `commented` | Default state for informational reviews | + +## Grafana Dashboard Queries + +### Total Reviews by Risk Level + +```sql +SELECT risk_level, COUNT(*) as count +FROM _tool_aireview_reviews +GROUP BY risk_level +``` + +### Reviews Over Time + +```sql +SELECT + DATE(created_date) as time, + risk_level, + COUNT(*) as count +FROM _tool_aireview_reviews +WHERE created_date >= DATE_SUB(NOW(), INTERVAL 90 DAY) +GROUP BY DATE(created_date), risk_level +ORDER BY time +``` + +### AI Prediction Accuracy + +```sql +SELECT + time_period, + precision, + recall, + f1_score, + accuracy +FROM _tool_aireview_prediction_metrics +WHERE repo_id = 'your-repo-id' +ORDER BY time_period +``` + +### High Risk Reviews Requiring Attention + +```sql +SELECT + created_date, + ai_tool, + summary, + issues_found, + source_url +FROM _tool_aireview_reviews +WHERE risk_level = 'high' + AND review_state != 'approved' +ORDER BY created_date DESC +LIMIT 20 +``` diff --git a/backend/plugins/aireview/docs/QUICK_START.md b/backend/plugins/aireview/docs/QUICK_START.md new file mode 100644 index 00000000000..d8e83cf32b5 --- /dev/null +++ b/backend/plugins/aireview/docs/QUICK_START.md @@ -0,0 +1,260 @@ +# AI Review Plugin Quick Start + +Get started with the AI Review plugin in 5 minutes. + +## Prerequisites + +- DevLake instance running +- GitHub or GitLab connection configured +- Pull request data already collected +- Commit data already collected (gitextractor) + +## Step 1: Verify Plugin is Loaded + +```bash +curl -s http://localhost:8080/plugins | jq '.[] | select(.plugin == "aireview")' +``` + +Expected output: +```json +{ + "plugin": "aireview", + "metric": { + "requiredDataEntities": [...], + "runAfter": ["github", "gitlab", "gitextractor"], + "isProjectMetric": true + } +} +``` + +## Step 2: Enable Plugin for Your Project + +Add the `aireview` metric to your project: + +```bash +curl -X PATCH http://localhost:8080/projects/YOUR_PROJECT_NAME \ + -H "Content-Type: application/json" \ + -d '{ + "metrics": [ + {"pluginName": "aireview", "pluginOption": {}, "enable": true} + ] + }' +``` + +## Step 3: Trigger Data Collection + +Run the project blueprint to collect and analyze data: + +```bash +# Find your blueprint ID +curl -s http://localhost:8080/projects/YOUR_PROJECT_NAME | jq '.blueprint.id' + +# Trigger the blueprint +curl -X POST http://localhost:8080/blueprints/BLUEPRINT_ID/trigger +``` + +## Step 4: Verify Data Collection + +Check that AI reviews were extracted: + +```bash +# Via API +curl -s http://localhost:8080/plugins | jq '.' + +# Via MySQL +podman exec devlake-mysql-1 mysql -u merico -pmerico lake \ + -e "SELECT ai_tool, risk_level, COUNT(*) FROM _tool_aireview_reviews GROUP BY ai_tool, risk_level;" +``` + +## Step 5: View in Grafana + +1. Open Grafana: http://localhost:4000/grafana/ +2. Go to **Dashboards** → **AI Code Review Analytics** +3. Or import the dashboard from `grafana/dashboards/AIReview.json` + +## Configuration Options + +### Scope Config + +Customize AI tool detection patterns. You can manage scope configs via the API: + +```bash +# Get default scope config (for reference) +curl -s http://localhost:8080/plugins/aireview/scope-configs/default | jq + +# List all scope configs +curl -s http://localhost:8080/plugins/aireview/scope-configs | jq + +# Create a new scope config +curl -X POST http://localhost:8080/plugins/aireview/scope-configs \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My Team Config", + "codeRabbitEnabled": true, + "codeRabbitUsername": "coderabbitai", + "cursorBugbotEnabled": true, + "cursorBugbotUsername": "cursor-bugbot" + }' + +# Update a scope config +curl -X PATCH http://localhost:8080/plugins/aireview/scope-configs/1 \ + -H "Content-Type: application/json" \ + -d '{ + "riskHighPattern": "(?i)(critical|security|urgent)" + }' + +# Delete a scope config +curl -X DELETE http://localhost:8080/plugins/aireview/scope-configs/1 +``` + +Scope config fields: + +```json +{ + "codeRabbitEnabled": true, + "codeRabbitUsername": "coderabbitai", + "codeRabbitPattern": "(?i)(coderabbit|walkthrough|summary by coderabbit)", + "cursorBugbotEnabled": false, + "cursorBugbotUsername": "cursor-bugbot", + "riskHighPattern": "(?i)(critical|security|vulnerability|breaking)", + "riskMediumPattern": "(?i)(warning|caution|potential|moderate)", + "riskLowPattern": "(?i)(minor|suggestion|consider|nitpick)" +} +``` + +### Running for a Specific Repository + +```bash +curl -X POST http://localhost:8080/pipelines \ + -H "Content-Type: application/json" \ + -d '{ + "name": "AI Review - Single Repo", + "plan": [[{ + "plugin": "aireview", + "options": { + "repoId": "github:GithubRepo:1:123456789" + } + }]] + }' +``` + +### Running for Entire Project + +```bash +curl -X POST http://localhost:8080/pipelines \ + -H "Content-Type: application/json" \ + -d '{ + "name": "AI Review - Project", + "plan": [[{ + "plugin": "aireview", + "options": { + "projectName": "my-project" + } + }]] + }' +``` + +## REST API Reference + +The plugin provides several API endpoints for querying data. + +### Query AI Reviews + +```bash +# List reviews (with pagination and filtering) +curl -s "http://localhost:8080/plugins/aireview/reviews?projectName=my-project&page=1&pageSize=20" | jq + +# Get a specific review +curl -s "http://localhost:8080/plugins/aireview/reviews/REVIEW_ID" | jq + +# Filter by risk level or AI tool +curl -s "http://localhost:8080/plugins/aireview/reviews?riskLevel=high&aiTool=coderabbit" | jq +``` + +### Get Statistics + +```bash +# Get aggregated stats for a project +curl -s "http://localhost:8080/plugins/aireview/stats?projectName=my-project" | jq + +# Response includes totals and breakdowns by risk level and AI tool +``` + +### Query Findings + +```bash +# List findings with filters +curl -s "http://localhost:8080/plugins/aireview/findings?category=security&severity=critical" | jq +``` + +### Trigger Re-Analysis + +Use this after changing scope config patterns or adding new AI tools: + +```bash +# Generate pipeline configuration for a project +curl -X POST http://localhost:8080/plugins/aireview/analyze \ + -H "Content-Type: application/json" \ + -d '{"projectName": "my-project", "scopeConfigId": 1}' | jq + +# The response contains a pipeline plan you can submit: +curl -X POST http://localhost:8080/pipelines \ + -H "Content-Type: application/json" \ + -d '{ + "name": "AI Review Analysis - my-project", + "plan": [[{ + "plugin": "aireview", + "options": {"projectName": "my-project", "scopeConfigId": 1}, + "subtasks": ["extractAiReviews", "extractAiCommits", "extractAiReviewFindings", "convertAiCommits", "calculateFailurePredictions", "calculatePredictionMetrics"] + }]] + }' +``` + +## Supported AI Tools + +| Tool | Default Username | Detection Pattern | Default | +|------|------------------|-------------------|---------| +| CodeRabbit | `coderabbitai` | Summary by CodeRabbit, Walkthrough | Enabled | +| Qodo | `qodo-merge` | PR Reviewer Guide, Estimated effort to review | Enabled | +| Cursor Bugbot | `cursor-bugbot` | cursor, bugbot | Disabled | + +## Troubleshooting + +### No reviews extracted + +1. Check that PR comments exist: + ```sql + SELECT COUNT(*) FROM pull_request_comments; + ``` + +2. Verify CodeRabbit comments are present: + ```sql + SELECT * FROM pull_request_comments + WHERE account_id LIKE '%coderabbit%' + OR body LIKE '%CodeRabbit%' + LIMIT 5; + ``` + +3. Check plugin logs: + ```bash + podman logs devlake-devlake-1 | grep aireview + ``` + +### Database migration required + +```bash +curl -s http://localhost:8080/proceed-db-migration +``` + +### Plugin not loaded + +Rebuild the DevLake container: +```bash +podman compose -f docker-compose-dev.yml up -d --build devlake +``` + +## Next Steps + +- See [METRICS_REFERENCE.md](METRICS_REFERENCE.md) for detailed metric documentation +- Customize the Grafana dashboard for your needs +- Add more AI tool patterns to `scope_config.go` diff --git a/backend/plugins/aireview/e2e/aireview_ai_commits_test.go b/backend/plugins/aireview/e2e/aireview_ai_commits_test.go new file mode 100644 index 00000000000..57f83cedf29 --- /dev/null +++ b/backend/plugins/aireview/e2e/aireview_ai_commits_test.go @@ -0,0 +1,173 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "testing" + + domainCode "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/models/domainlayer/crossdomain" + "github.com/apache/devlake/helpers/e2ehelper" + "github.com/apache/devlake/plugins/aireview/impl" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/apache/devlake/plugins/aireview/tasks" +) + +type githubCommitRow struct { + Sha string `gorm:"primaryKey;type:varchar(40);column:sha"` + Message string `gorm:"column:message"` + AuthorName string `gorm:"column:author_name"` + AuthoredDate string `gorm:"column:authored_date"` +} + +func (githubCommitRow) TableName() string { return "_tool_github_commits" } + +func TestExtractAndConvertAiCommits(t *testing.T) { + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + scopeConfig := models.GetDefaultScopeConfig() + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + ProjectName: testProject, + ScopeConfig: scopeConfig, + }, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + + tester.FlushTabler(&domainCode.Commit{}) + tester.FlushTabler(&domainCode.RepoCommit{}) + tester.FlushTabler(&domainCode.PullRequest{}) + tester.FlushTabler(&domainCode.PullRequestCommit{}) + tester.FlushTabler(&models.AiCommit{}) + tester.FlushTabler(&domainCode.AiCommit{}) + tester.FlushTabler(&crossdomain.ProjectMapping{}) + tester.FlushTabler(&repoRow{}) + tester.FlushTabler(&githubCommitRow{}) + + tester.ImportCsvIntoTabler("./raw_tables/ai_commits.csv", &domainCode.Commit{}) + tester.ImportCsvIntoTabler("./raw_tables/ai_repo_commits.csv", &domainCode.RepoCommit{}) + tester.ImportCsvIntoTabler("./raw_tables/ai_pull_requests.csv", &domainCode.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/ai_pull_request_commits.csv", &domainCode.PullRequestCommit{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_repos.csv", &repoRow{}) + tester.ImportCsvIntoTabler("./raw_tables/project_mapping.csv", &crossdomain.ProjectMapping{}) + + tester.Subtask(tasks.ExtractAiCommitsMeta, taskData) + tester.Subtask(tasks.ConvertAiCommitsMeta, taskData) + + var toolRows []models.AiCommit + if err := tester.Dal.All(&toolRows); err != nil { + t.Fatalf("Failed to query _tool_aireview_commits: %v", err) + } + if len(toolRows) != 4 { + t.Fatalf("Expected 4 AI-positive tool commits (cursor, claude, copilot, coderabbit), got %d", len(toolRows)) + } + + bySha := make(map[string]models.AiCommit, len(toolRows)) + for _, row := range toolRows { + bySha[row.CommitSha] = row + if row.RepoId != testRepoId { + t.Errorf("sha %s: expected repo_id=%q, got %q", row.CommitSha, testRepoId, row.RepoId) + } + } + + want := map[string]string{ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": models.AiToolCursor, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": models.AiToolClaude, + "cccccccccccccccccccccccccccccccccccccccc": models.AiToolCopilot, + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee": models.AiToolCodeRabbit, + } + for sha, tool := range want { + got, ok := bySha[sha] + if !ok { + t.Errorf("missing tool-layer row for sha %s (want tool %s)", sha, tool) + continue + } + if got.AiTool != tool { + t.Errorf("sha %s: ai_tool=%q, want %q", sha, got.AiTool, tool) + } + } + if _, ok := bySha["dddddddddddddddddddddddddddddddddddddddd"]; ok { + t.Error("human commit mentioning Claude in the body must not be classified as AI") + } + + var domainRows []domainCode.AiCommit + if err := tester.Dal.All(&domainRows); err != nil { + t.Fatalf("Failed to query ai_commits: %v", err) + } + if len(domainRows) != 4 { + t.Fatalf("Expected 4 domain ai_commits, got %d", len(domainRows)) + } + for _, row := range domainRows { + if row.ProjectName != testProject { + t.Errorf("domain sha %s: project_name=%q, want %q", row.CommitSha, row.ProjectName, testProject) + } + } +} + +func TestConvertAiCommits_SkipsInRepoIdMode(t *testing.T) { + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + scopeConfig := models.GetDefaultScopeConfig() + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: testRepoId, + ScopeConfig: scopeConfig, + }, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + + tester.FlushTabler(&domainCode.Commit{}) + tester.FlushTabler(&domainCode.RepoCommit{}) + tester.FlushTabler(&domainCode.PullRequest{}) + tester.FlushTabler(&domainCode.PullRequestCommit{}) + tester.FlushTabler(&models.AiCommit{}) + tester.FlushTabler(&domainCode.AiCommit{}) + tester.FlushTabler(&repoRow{}) + tester.FlushTabler(&githubCommitRow{}) + + tester.ImportCsvIntoTabler("./raw_tables/ai_commits.csv", &domainCode.Commit{}) + tester.ImportCsvIntoTabler("./raw_tables/ai_repo_commits.csv", &domainCode.RepoCommit{}) + tester.ImportCsvIntoTabler("./raw_tables/ai_pull_requests.csv", &domainCode.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/ai_pull_request_commits.csv", &domainCode.PullRequestCommit{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_repos.csv", &repoRow{}) + + tester.Subtask(tasks.ExtractAiCommitsMeta, taskData) + tester.Subtask(tasks.ConvertAiCommitsMeta, taskData) + + var domainRows []domainCode.AiCommit + if err := tester.Dal.All(&domainRows); err != nil { + t.Fatalf("Failed to query ai_commits: %v", err) + } + if len(domainRows) != 0 { + t.Errorf("Expected 0 domain ai_commits in single-repo mode, got %d", len(domainRows)) + } + + var toolRows []models.AiCommit + if err := tester.Dal.All(&toolRows); err != nil { + t.Fatalf("Failed to query _tool_aireview_commits: %v", err) + } + if len(toolRows) == 0 { + t.Error("Expected tool-layer AI commits in single-repo mode") + } +} diff --git a/backend/plugins/aireview/e2e/aireview_ci_prediction_test.go b/backend/plugins/aireview/e2e/aireview_ci_prediction_test.go new file mode 100644 index 00000000000..f13f84ece0b --- /dev/null +++ b/backend/plugins/aireview/e2e/aireview_ci_prediction_test.go @@ -0,0 +1,311 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "testing" + + "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/models/domainlayer/crossdomain" + "github.com/apache/devlake/helpers/e2ehelper" + "github.com/apache/devlake/plugins/aireview/impl" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/apache/devlake/plugins/aireview/tasks" +) + +// ciTestJob matches the ci_test_jobs domain table for fixture import. +type ciTestJob struct { + ConnectionId int64 `gorm:"primaryKey;column:connection_id"` + JobId string `gorm:"primaryKey;column:job_id"` + JobName string `gorm:"column:job_name"` + Repository string `gorm:"column:repository"` + PullRequestNumber int64 `gorm:"column:pull_request_number"` + TriggerType string `gorm:"column:trigger_type"` + Result string `gorm:"column:result"` + FinishedAt string `gorm:"column:finished_at"` + DurationSec float64 `gorm:"column:duration_sec"` +} + +func (ciTestJob) TableName() string { return "ci_test_jobs" } + +// ciTestCase matches the ci_test_cases domain table for fixture import. +type ciTestCase struct { + ConnectionId int64 `gorm:"primaryKey;column:connection_id"` + JobId string `gorm:"primaryKey;column:job_id"` + TestCaseId string `gorm:"primaryKey;column:test_case_id"` + Name string `gorm:"column:name"` + Status string `gorm:"column:status"` +} + +func (ciTestCase) TableName() string { return "ci_test_cases" } + +// repoRow matches the repos domain table for fixture import. +type repoRow struct { + Id string `gorm:"primaryKey;column:id"` + Name string `gorm:"column:name"` +} + +func (repoRow) TableName() string { return "repos" } + +// TestCalculateFailurePredictions verifies that: +// - Flaky tests (failed in periodic runs) are excluded from CI failure detection. +// - PRs with non-flaky failures are correctly flagged as HadCiFailure=true. +// - Prediction outcomes (TP/FP/FN/TN) are assigned correctly. +func TestCalculateFailurePredictions(t *testing.T) { + const repoId = "github:GithubRepo:1:200" + + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + scopeConfig := models.GetDefaultScopeConfig() + scopeConfig.WarningThreshold = 50 + + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: repoId, + ScopeConfig: scopeConfig, + }, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + + // Flush tables (also creates them in lake_test if missing). + tester.FlushTabler(&crossdomain.Account{}) + tester.FlushTabler(&code.PullRequest{}) + tester.FlushTabler(&code.PullRequestComment{}) + tester.FlushTabler(&models.AiReview{}) + tester.FlushTabler(&models.AiFailurePrediction{}) + tester.FlushTabler(&ciTestJob{}) + tester.FlushTabler(&ciTestCase{}) + tester.FlushTabler(&repoRow{}) + + // Import fixtures. + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_requests.csv", &code.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_request_comments.csv", &code.PullRequestComment{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_repos.csv", &repoRow{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_test_jobs.csv", &ciTestJob{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_test_cases.csv", &ciTestCase{}) + + // Run extraction then prediction. + tester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + tester.Subtask(tasks.CalculateFailurePredictionsMeta, taskData) + + // Collect results — default scope config uses CiSourceBoth, so 2 predictions per PR. + var predictions []models.AiFailurePrediction + if err := tester.Dal.All(&predictions); err != nil { + t.Fatalf("Failed to query predictions: %v", err) + } + + if len(predictions) == 0 { + t.Fatal("Expected predictions, got none") + } + + // Build a map by (pull_request_key, ci_failure_source) for assertions. + type predKey struct { + PR string + Source string + } + byKey := make(map[predKey]*models.AiFailurePrediction) + for i := range predictions { + p := &predictions[i] + byKey[predKey{p.PullRequestKey, p.CiFailureSource}] = p + } + + // Fixtures are designed so both sources produce consistent outcomes: + // - PR 101: unit-tests fails (non-flaky job/test) → HadCiFailure=true → TP + // - PR 102: flaky-integration fails (same job/test fails periodically) → HadCiFailure=false → FP + // - PR 103: e2e-tests fails (non-flaky job/test) → HadCiFailure=true → FN + // - PR 104: unit-tests passes → HadCiFailure=false → TN + type wantCase struct { + prKey, source string + flagged, ciFail bool + outcome string + } + cases := []wantCase{ + {"101", models.CiSourceTestCases, true, true, models.PredictionTP}, + {"102", models.CiSourceTestCases, true, false, models.PredictionFP}, + {"103", models.CiSourceTestCases, false, true, models.PredictionFN}, + {"104", models.CiSourceTestCases, false, false, models.PredictionTN}, + {"101", models.CiSourceJobResult, true, true, models.PredictionTP}, + {"102", models.CiSourceJobResult, true, false, models.PredictionFP}, + {"103", models.CiSourceJobResult, false, true, models.PredictionFN}, + {"104", models.CiSourceJobResult, false, false, models.PredictionTN}, + } + + for _, tc := range cases { + k := predKey{tc.prKey, tc.source} + p, ok := byKey[k] + if !ok { + t.Errorf("Missing prediction for PR %s source %s", tc.prKey, tc.source) + continue + } + assertPrediction(t, p, tc.prKey, tc.flagged, tc.ciFail, tc.outcome) + } + + t.Logf("Predictions verified: %d total", len(predictions)) +} + +// TestCalculatePredictionMetrics verifies that precision, recall, and AUC are +// computed from the predictions written by TestCalculateFailurePredictions. +func TestCalculatePredictionMetrics(t *testing.T) { + const repoId = "github:GithubRepo:1:200" + + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + scopeConfig := models.GetDefaultScopeConfig() + scopeConfig.WarningThreshold = 50 + + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: repoId, + ScopeConfig: scopeConfig, + }, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + + tester.FlushTabler(&code.PullRequest{}) + tester.FlushTabler(&code.PullRequestComment{}) + tester.FlushTabler(&models.AiReview{}) + tester.FlushTabler(&models.AiFailurePrediction{}) + tester.FlushTabler(&models.AiPredictionMetrics{}) + tester.FlushTabler(&ciTestJob{}) + tester.FlushTabler(&ciTestCase{}) + tester.FlushTabler(&repoRow{}) + + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_requests.csv", &code.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_request_comments.csv", &code.PullRequestComment{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_repos.csv", &repoRow{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_test_jobs.csv", &ciTestJob{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_test_cases.csv", &ciTestCase{}) + + tester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + tester.Subtask(tasks.CalculateFailurePredictionsMeta, taskData) + tester.Subtask(tasks.CalculatePredictionMetricsMeta, taskData) + + var metrics []models.AiPredictionMetrics + if err := tester.Dal.All(&metrics); err != nil { + t.Fatalf("Failed to query metrics: %v", err) + } + + if len(metrics) == 0 { + t.Fatal("Expected metrics records, got none") + } + + // Validate one of the rolling_60d records (covers all 4 test PRs). + for _, m := range metrics { + if m.PeriodType != "rolling_60d" { + continue + } + // With 4 PRs: TP=1, FP=1, FN=1, TN=1 at threshold=50 + // Precision = 1/(1+1) = 0.5, Recall = 1/(1+1) = 0.5 + if m.TruePositives < 0 || m.FalsePositives < 0 || m.FalseNegatives < 0 || m.TrueNegatives < 0 { + t.Errorf("Invalid confusion matrix: TP=%d FP=%d FN=%d TN=%d", m.TruePositives, m.FalsePositives, m.FalseNegatives, m.TrueNegatives) + } + if m.WarningThreshold != 50 { + t.Errorf("Expected WarningThreshold=50, got %d", m.WarningThreshold) + } + if m.PrAuc < 0 || m.PrAuc > 1 { + t.Errorf("PrAuc out of range: %f", m.PrAuc) + } + if m.RocAuc < 0 || m.RocAuc > 1 { + t.Errorf("RocAuc out of range: %f", m.RocAuc) + } + t.Logf("rolling_60d metrics: TP=%d FP=%d FN=%d TN=%d precision=%.2f recall=%.2f pr_auc=%.3f roc_auc=%.3f", + m.TruePositives, m.FalsePositives, m.FalseNegatives, m.TrueNegatives, + m.Precision, m.Recall, m.PrAuc, m.RocAuc) + break + } +} + +// TestCalculateFailurePredictions_NoCiData verifies that when a repo has AI +// reviews but no CI data (e.g. TestRegistry is not configured), no predictions +// are written. Before the fix, every flagged PR was incorrectly classified as +// FP because missing CI data was treated as "CI passed". +func TestCalculateFailurePredictions_NoCiData(t *testing.T) { + const repoId = "github:GithubRepo:1:999" + + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + scopeConfig := models.GetDefaultScopeConfig() + scopeConfig.WarningThreshold = 50 + + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: repoId, + ScopeConfig: scopeConfig, + }, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + + tester.FlushTabler(&crossdomain.Account{}) + tester.FlushTabler(&code.PullRequest{}) + tester.FlushTabler(&code.PullRequestComment{}) + tester.FlushTabler(&models.AiReview{}) + tester.FlushTabler(&models.AiFailurePrediction{}) + tester.FlushTabler(&ciTestJob{}) + tester.FlushTabler(&ciTestCase{}) + tester.FlushTabler(&repoRow{}) + + // Import PRs and AI review comments — but intentionally NO ci_test_jobs rows. + tester.ImportCsvIntoTabler("./raw_tables/no_ci_pull_requests.csv", &code.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/no_ci_pull_request_comments.csv", &code.PullRequestComment{}) + tester.ImportCsvIntoTabler("./raw_tables/no_ci_repos.csv", &repoRow{}) + + tester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + tester.Subtask(tasks.CalculateFailurePredictionsMeta, taskData) + + var predictions []models.AiFailurePrediction + if err := tester.Dal.All(&predictions); err != nil { + t.Fatalf("Failed to query predictions: %v", err) + } + + // Key assertion: PRs without CI data must produce NO_CI records (not TP/FP/FN/TN). + // This makes them visible in drill-down dashboards while keeping confusion matrix clean. + for _, p := range predictions { + if p.PredictionOutcome != models.PredictionNoCi { + t.Errorf("PR %s: expected NO_CI outcome for PR with no CI data, got %s (flagged=%v hadCI=%v)", + p.PullRequestKey, p.PredictionOutcome, p.WasFlaggedRisky, p.HadCiFailure) + } + if p.CiFailureSource != models.CiSourceNone { + t.Errorf("PR %s: expected ci_failure_source=%q, got %q", p.PullRequestKey, models.CiSourceNone, p.CiFailureSource) + } + } + if len(predictions) == 0 { + t.Error("expected NO_CI predictions for AI-reviewed PRs with no CI data, got none") + } +} + +func assertPrediction(t *testing.T, p *models.AiFailurePrediction, prKey string, wantFlagged, wantCiFail bool, wantOutcome string) { + t.Helper() + if p.WasFlaggedRisky != wantFlagged { + t.Errorf("PR %s: WasFlaggedRisky=%v, want %v", prKey, p.WasFlaggedRisky, wantFlagged) + } + if p.HadCiFailure != wantCiFail { + t.Errorf("PR %s: HadCiFailure=%v, want %v", prKey, p.HadCiFailure, wantCiFail) + } + if p.PredictionOutcome != wantOutcome { + t.Errorf("PR %s: PredictionOutcome=%s, want %s", prKey, p.PredictionOutcome, wantOutcome) + } +} diff --git a/backend/plugins/aireview/e2e/aireview_domain_converter_test.go b/backend/plugins/aireview/e2e/aireview_domain_converter_test.go new file mode 100644 index 00000000000..b56b9e07b0b --- /dev/null +++ b/backend/plugins/aireview/e2e/aireview_domain_converter_test.go @@ -0,0 +1,327 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "testing" + + domainCode "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/models/domainlayer/crossdomain" + "github.com/apache/devlake/helpers/e2ehelper" + "github.com/apache/devlake/plugins/aireview/impl" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/apache/devlake/plugins/aireview/tasks" +) + +const testProject = "test-project" +const testRepoId = "github:GithubRepo:1:200" + +// TestConvertAiReviews verifies that ConvertAiReviews writes project-scoped +// rows to the ai_reviews domain table with the correct project_name stamp. +func TestConvertAiReviews(t *testing.T) { + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + scopeConfig := models.GetDefaultScopeConfig() + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + ProjectName: testProject, + ScopeConfig: scopeConfig, + }, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + + tester.FlushTabler(&crossdomain.Account{}) + tester.FlushTabler(&domainCode.PullRequest{}) + tester.FlushTabler(&domainCode.PullRequestComment{}) + tester.FlushTabler(&models.AiReview{}) + tester.FlushTabler(&domainCode.AiReview{}) + tester.FlushTabler(&crossdomain.ProjectMapping{}) + tester.FlushTabler(&repoRow{}) + + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_requests.csv", &domainCode.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_request_comments.csv", &domainCode.PullRequestComment{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_repos.csv", &repoRow{}) + tester.ImportCsvIntoTabler("./raw_tables/project_mapping.csv", &crossdomain.ProjectMapping{}) + + tester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + tester.Subtask(tasks.ConvertAiReviewsMeta, taskData) + + var reviews []domainCode.AiReview + if err := tester.Dal.All(&reviews); err != nil { + t.Fatalf("Failed to query ai_reviews: %v", err) + } + if len(reviews) == 0 { + t.Fatal("Expected domain ai_reviews, got none") + } + + for _, r := range reviews { + if r.ProjectName != testProject { + t.Errorf("Expected project_name=%q, got %q", testProject, r.ProjectName) + } + if r.RepoId != testRepoId { + t.Errorf("Expected repo_id=%q, got %q", testRepoId, r.RepoId) + } + if r.Id == "" { + t.Error("Domain ai_review has empty Id") + } + if r.AiTool == "" { + t.Error("Domain ai_review has empty AiTool") + } + } + t.Logf("ai_reviews domain records: %d, all stamped with project %q", len(reviews), testProject) +} + +// TestConvertAiReviews_SkipsInRepoIdMode verifies that the converter is a no-op +// when only repoId is set (no project mode), keeping domain table empty. +func TestConvertAiReviews_SkipsInRepoIdMode(t *testing.T) { + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + scopeConfig := models.GetDefaultScopeConfig() + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: testRepoId, + ScopeConfig: scopeConfig, + }, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + + tester.FlushTabler(&domainCode.PullRequest{}) + tester.FlushTabler(&domainCode.PullRequestComment{}) + tester.FlushTabler(&models.AiReview{}) + tester.FlushTabler(&domainCode.AiReview{}) + tester.FlushTabler(&repoRow{}) + + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_requests.csv", &domainCode.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_request_comments.csv", &domainCode.PullRequestComment{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_repos.csv", &repoRow{}) + + tester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + tester.Subtask(tasks.ConvertAiReviewsMeta, taskData) + + var reviews []domainCode.AiReview + if err := tester.Dal.All(&reviews); err != nil { + t.Fatalf("Failed to query ai_reviews: %v", err) + } + // Converter must not write anything in single-repo mode. + if len(reviews) != 0 { + t.Errorf("Expected 0 domain ai_reviews in single-repo mode, got %d", len(reviews)) + } +} + +// TestConvertFailurePredictions verifies that ConvertFailurePredictions writes +// project-scoped rows to ai_failure_predictions with the correct project_name. +func TestConvertFailurePredictions(t *testing.T) { + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + scopeConfig := models.GetDefaultScopeConfig() + scopeConfig.WarningThreshold = 50 + + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + ProjectName: testProject, + ScopeConfig: scopeConfig, + }, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + + tester.FlushTabler(&crossdomain.Account{}) + tester.FlushTabler(&domainCode.PullRequest{}) + tester.FlushTabler(&domainCode.PullRequestComment{}) + tester.FlushTabler(&models.AiReview{}) + tester.FlushTabler(&models.AiFailurePrediction{}) + tester.FlushTabler(&domainCode.AiFailurePrediction{}) + tester.FlushTabler(&ciTestJob{}) + tester.FlushTabler(&ciTestCase{}) + tester.FlushTabler(&repoRow{}) + tester.FlushTabler(&crossdomain.ProjectMapping{}) + + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_requests.csv", &domainCode.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_request_comments.csv", &domainCode.PullRequestComment{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_repos.csv", &repoRow{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_test_jobs.csv", &ciTestJob{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_test_cases.csv", &ciTestCase{}) + tester.ImportCsvIntoTabler("./raw_tables/project_mapping.csv", &crossdomain.ProjectMapping{}) + + tester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + tester.Subtask(tasks.CalculateFailurePredictionsMeta, taskData) + tester.Subtask(tasks.ConvertFailurePredictionsMeta, taskData) + + var predictions []domainCode.AiFailurePrediction + if err := tester.Dal.All(&predictions); err != nil { + t.Fatalf("Failed to query ai_failure_predictions: %v", err) + } + if len(predictions) == 0 { + t.Fatal("Expected domain ai_failure_predictions, got none") + } + + for _, p := range predictions { + if p.ProjectName != testProject { + t.Errorf("Expected project_name=%q, got %q", testProject, p.ProjectName) + } + if p.Id == "" { + t.Error("Domain ai_failure_prediction has empty Id") + } + if p.PredictionOutcome == "" { + t.Errorf("Domain prediction PR %s has empty outcome", p.PullRequestKey) + } + } + t.Logf("ai_failure_predictions domain records: %d, all stamped with project %q", len(predictions), testProject) +} + +// TestConvertFailurePredictions_IsolatedByProject verifies that running the converter +// twice for two different projects does not mix predictions between projects. +func TestConvertFailurePredictions_IsolatedByProject(t *testing.T) { + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + scopeConfig := models.GetDefaultScopeConfig() + scopeConfig.WarningThreshold = 50 + + makeTaskData := func(proj string) *tasks.AiReviewTaskData { + return &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + ProjectName: proj, + ScopeConfig: scopeConfig, + }, + } + } + + tester.FlushTabler(&crossdomain.Account{}) + tester.FlushTabler(&domainCode.PullRequest{}) + tester.FlushTabler(&domainCode.PullRequestComment{}) + tester.FlushTabler(&models.AiReview{}) + tester.FlushTabler(&models.AiFailurePrediction{}) + tester.FlushTabler(&domainCode.AiFailurePrediction{}) + tester.FlushTabler(&ciTestJob{}) + tester.FlushTabler(&ciTestCase{}) + tester.FlushTabler(&repoRow{}) + tester.FlushTabler(&crossdomain.ProjectMapping{}) + + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_requests.csv", &domainCode.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_request_comments.csv", &domainCode.PullRequestComment{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_repos.csv", &repoRow{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_test_jobs.csv", &ciTestJob{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_test_cases.csv", &ciTestCase{}) + // Both projects point to the same repo — simulating the duplication scenario. + tester.ImportCsvIntoTabler("./raw_tables/project_mapping.csv", &crossdomain.ProjectMapping{}) + + // Run pipeline for project A. + dataA := makeTaskData(testProject) + if err := tasks.CompilePatterns(dataA); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + tester.Subtask(tasks.ExtractAiReviewsMeta, dataA) + tester.Subtask(tasks.CalculateFailurePredictionsMeta, dataA) + tester.Subtask(tasks.ConvertFailurePredictionsMeta, dataA) + + // Run pipeline for project B (same repo, different project). + dataB := makeTaskData("other-project") + if err := tasks.CompilePatterns(dataB); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + // project_mapping for other-project doesn't exist so converter writes 0 rows. + tester.Subtask(tasks.ConvertFailurePredictionsMeta, dataB) + + var allPredictions []domainCode.AiFailurePrediction + if err := tester.Dal.All(&allPredictions); err != nil { + t.Fatalf("Failed to query all ai_failure_predictions: %v", err) + } + + for _, p := range allPredictions { + if p.ProjectName != testProject { + t.Errorf("Found prediction with unexpected project_name=%q (only %q should exist)", p.ProjectName, testProject) + } + } + t.Logf("Isolation verified: %d predictions, all for project %q", len(allPredictions), testProject) +} + +// TestConvertPredictionMetrics verifies the full pipeline including metrics conversion. +func TestConvertPredictionMetrics(t *testing.T) { + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + scopeConfig := models.GetDefaultScopeConfig() + scopeConfig.WarningThreshold = 50 + + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + ProjectName: testProject, + ScopeConfig: scopeConfig, + }, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + + tester.FlushTabler(&crossdomain.Account{}) + tester.FlushTabler(&domainCode.PullRequest{}) + tester.FlushTabler(&domainCode.PullRequestComment{}) + tester.FlushTabler(&models.AiReview{}) + tester.FlushTabler(&models.AiFailurePrediction{}) + tester.FlushTabler(&models.AiPredictionMetrics{}) + tester.FlushTabler(&domainCode.AiPredictionMetrics{}) + tester.FlushTabler(&ciTestJob{}) + tester.FlushTabler(&ciTestCase{}) + tester.FlushTabler(&repoRow{}) + tester.FlushTabler(&crossdomain.ProjectMapping{}) + + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_requests.csv", &domainCode.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_request_comments.csv", &domainCode.PullRequestComment{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_repos.csv", &repoRow{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_test_jobs.csv", &ciTestJob{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_test_cases.csv", &ciTestCase{}) + tester.ImportCsvIntoTabler("./raw_tables/project_mapping.csv", &crossdomain.ProjectMapping{}) + + tester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + tester.Subtask(tasks.CalculateFailurePredictionsMeta, taskData) + tester.Subtask(tasks.CalculatePredictionMetricsMeta, taskData) + tester.Subtask(tasks.ConvertPredictionMetricsMeta, taskData) + + var metrics []domainCode.AiPredictionMetrics + if err := tester.Dal.All(&metrics); err != nil { + t.Fatalf("Failed to query ai_prediction_metrics: %v", err) + } + if len(metrics) == 0 { + t.Fatal("Expected domain ai_prediction_metrics, got none") + } + + for _, m := range metrics { + if m.ProjectName != testProject { + t.Errorf("Expected project_name=%q, got %q", testProject, m.ProjectName) + } + if m.PeriodType == "" { + t.Error("Domain metric has empty PeriodType") + } + if m.PrAuc < 0 || m.PrAuc > 1 { + t.Errorf("PrAuc out of range: %f", m.PrAuc) + } + if m.RocAuc < 0 || m.RocAuc > 1 { + t.Errorf("RocAuc out of range: %f", m.RocAuc) + } + } + t.Logf("ai_prediction_metrics domain records: %d, all stamped with project %q", len(metrics), testProject) +} diff --git a/backend/plugins/aireview/e2e/aireview_gemini_test.go b/backend/plugins/aireview/e2e/aireview_gemini_test.go new file mode 100644 index 00000000000..9f90c20f6cc --- /dev/null +++ b/backend/plugins/aireview/e2e/aireview_gemini_test.go @@ -0,0 +1,121 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "strings" + "testing" + + "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/models/domainlayer/crossdomain" + "github.com/apache/devlake/helpers/e2ehelper" + "github.com/apache/devlake/plugins/aireview/impl" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/apache/devlake/plugins/aireview/tasks" +) + +// TestExtractGeminiAiReviews verifies that Gemini Code Assist review comments +// are correctly extracted, classified, and summarised. +// +// Fixture data is based on real comments from google-gemini/gemini-cli PR #15147: +// - Issue comment 3661243217: PR-level "Summary of Changes" format +// - Review comment 2623871161: inline comment with critical priority badge +// (https://github.com/google-gemini/gemini-cli/pull/15147) +func TestExtractGeminiAiReviews(t *testing.T) { + var plug impl.AiReview + dataflowTester := e2ehelper.NewDataFlowTester(t, "aireview", plug) + + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: "github:GithubRepo:1:200", + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("failed to compile patterns: %v", err) + } + + dataflowTester.FlushTabler(&crossdomain.Account{}) + dataflowTester.FlushTabler(&code.PullRequest{}) + dataflowTester.FlushTabler(&code.PullRequestComment{}) + dataflowTester.FlushTabler(&models.AiReview{}) + + dataflowTester.ImportCsvIntoTabler("./raw_tables/pull_requests_gemini.csv", &code.PullRequest{}) + dataflowTester.ImportCsvIntoTabler("./raw_tables/pull_request_comments_gemini.csv", &code.PullRequestComment{}) + + dataflowTester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + + var reviews []models.AiReview + if err := dataflowTester.Dal.All(&reviews); err != nil { + t.Fatalf("failed to query reviews: %v", err) + } + + // Both Gemini comments should be detected and extracted. + if len(reviews) != 2 { + t.Fatalf("expected 2 Gemini reviews, got %d", len(reviews)) + } + + byComment := make(map[string]models.AiReview) + for _, r := range reviews { + byComment[r.ReviewId] = r + } + + // Shared properties: all reviews must be attributed to Gemini. + for _, r := range reviews { + if r.AiTool != models.AiToolGemini { + t.Errorf("expected AiTool=%s, got %s (review_id=%s)", models.AiToolGemini, r.AiTool, r.ReviewId) + } + if r.AiToolUser != "gemini-code-assist[bot]" { + t.Errorf("expected AiToolUser=gemini-code-assist[bot], got %s", r.AiToolUser) + } + if r.SourcePlatform != "github" { + t.Errorf("expected SourcePlatform=github, got %s", r.SourcePlatform) + } + if r.Summary == "" { + t.Errorf("expected non-empty summary for review_id=%s", r.ReviewId) + } + } + + // PR-level "Summary of Changes" comment (issue comment 3661243217). + // Summary extraction should pull the first substantive paragraph and Highlights items. + // Risk should be low — no risk keywords in the body. + if summary, ok := byComment["github:GithubPrComment:1:3661243217"]; ok { + if !strings.Contains(summary.Summary, "native Git integration") { + t.Errorf("PR summary should mention native Git integration, got: %s", summary.Summary) + } + if summary.RiskLevel != models.RiskLevelLow { + t.Errorf("PR summary should be low risk, got %s", summary.RiskLevel) + } + } else { + t.Error("PR-level summary comment not found in results") + } + + // Inline review comment with critical priority badge (review comment 2623871161). + // Summary extraction should strip the gstatic.com badge image. + // Risk should be high because the body contains the word "critical". + if inline, ok := byComment["github:GithubPrComment:1:2623871161"]; ok { + if inline.RiskLevel != models.RiskLevelHigh { + t.Errorf("inline review should be high risk (critical keyword), got %s", inline.RiskLevel) + } + if strings.Contains(inline.Summary, "gstatic.com") { + t.Errorf("inline summary should have badge URL stripped, got: %s", inline.Summary) + } + } else { + t.Error("inline review comment not found in results") + } +} diff --git a/backend/plugins/aireview/e2e/aireview_reactions_test.go b/backend/plugins/aireview/e2e/aireview_reactions_test.go new file mode 100644 index 00000000000..ecf14e8f59e --- /dev/null +++ b/backend/plugins/aireview/e2e/aireview_reactions_test.go @@ -0,0 +1,247 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/models/domainlayer/crossdomain" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/e2ehelper" + "github.com/apache/devlake/impls/dalgorm" + "github.com/apache/devlake/plugins/aireview/impl" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/apache/devlake/plugins/aireview/tasks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEnrichGithubReviewReactions verifies that GitHub PR comment reactions are enriched +// into _tool_aireview_reviews from the raw GitHub API data table. +// +// Synthetic data is modelled on the real reaction found at: +// https://github.com/meilisearch/meilisearch-rust/pull/756#discussion_r2736607742 +// (CodeRabbit review comment with 1 thumbsup). +// +// Test data covers three cases: +// - PR 1001 comment 5001: +1=2 (two thumbsups) +// - PR 1002 comment 5003: +1=1, -1=1 (mixed) +// - PR 1003 comment 5005: no reactions +func TestEnrichGithubReviewReactions(t *testing.T) { + var plug impl.AiReview + dataflowTester := e2ehelper.NewDataFlowTester(t, "aireview", plug) + + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: "github:GithubRepo:1:100", + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + require.NoError(t, tasks.CompilePatterns(taskData)) + + // Flush and load domain tables + dataflowTester.FlushTabler(&crossdomain.Account{}) + dataflowTester.FlushTabler(&code.PullRequest{}) + dataflowTester.FlushTabler(&code.PullRequestComment{}) + dataflowTester.FlushTabler(&models.AiReview{}) + + dataflowTester.ImportCsvIntoTabler("./raw_tables/pull_requests.csv", &code.PullRequest{}) + dataflowTester.ImportCsvIntoTabler("./raw_tables/pull_request_comments.csv", &code.PullRequestComment{}) + + // Load raw GitHub API comments table that contains $.reactions JSON + dataflowTester.FlushRawTable("_raw_github_api_comments") + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_github_api_comments.csv", "_raw_github_api_comments") + + // Step 1: extract reviews from comments + dataflowTester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + + // Step 2: enrich with reactions from raw table + dataflowTester.Subtask(tasks.EnrichGithubReviewReactionsMeta, taskData) + + // Verify reactions were written back to _tool_aireview_reviews + var reviews []models.AiReview + require.NoError(t, dataflowTester.Dal.All(&reviews)) + require.Equal(t, 3, len(reviews), "expected 3 AI reviews") + + // Build lookup by domain comment ID for deterministic assertions + type expected struct { + totalCount int + thumbsUp int + thumbsDown int + } + want := map[string]expected{ + "github:GithubPrComment:1:5001": {totalCount: 2, thumbsUp: 2, thumbsDown: 0}, + "github:GithubPrComment:1:5003": {totalCount: 2, thumbsUp: 1, thumbsDown: 1}, + "github:GithubPrComment:1:5005": {totalCount: 0, thumbsUp: 0, thumbsDown: 0}, + } + + for _, r := range reviews { + exp, ok := want[r.ReviewId] + if !ok { + t.Errorf("unexpected review with review_id %s", r.ReviewId) + continue + } + assert.Equal(t, exp.totalCount, r.ReactionsTotalCount, "total_count for %s", r.ReviewId) + assert.Equal(t, exp.thumbsUp, r.ReactionsThumbsUp, "thumbs_up for %s", r.ReviewId) + assert.Equal(t, exp.thumbsDown, r.ReactionsThumbsDown, "thumbs_down for %s", r.ReviewId) + } +} + +// TestEnrichGitlabReviewReactions verifies that GitLab award emoji reactions are fetched +// and stored against AI reviews. +// +// It uses a mock HTTP server serving a pre-recorded response captured from +// gitlab.cee.redhat.com MR #42 note 20330331 (cmulliga added a thumbsup). +// The fixture is at raw_tables/gitlab_award_emoji_note_20330331.json. +func TestEnrichGitlabReviewReactions(t *testing.T) { + // Load the fixture — real award emoji response captured from gitlab.cee.redhat.com. + // Project: devtools/developer-practices-documentation (ID 105624) + // MR: #42 (gitlab_id 2170267), note: 20330331 (1 thumbsup from cmulliga) + fixture, err := os.ReadFile("./raw_tables/gitlab_award_emoji_note_20330331.json") + require.NoError(t, err) + + // Constants matching the captured fixture data. + var connID uint64 = 1 + const ( + projectID = 105624 + mrGitlabID = 2170267 + mrIID = 42 + noteID = 20330331 + ) + + // Start a mock HTTP server that serves the award emoji fixture. + // The task calls: GET {endpoint}/projects/{id}/merge_requests/{iid}/notes/{noteId}/award_emoji + expectedPath := fmt.Sprintf("/projects/%d/merge_requests/%d/notes/%d/award_emoji", projectID, mrIID, noteID) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != expectedPath { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fixture) + })) + defer srv.Close() + + var plug impl.AiReview + dataflowTester := e2ehelper.NewDataFlowTester(t, "aireview", plug) + + // Register the encdec GORM serializer so the task can decrypt the connection token. + encryptionSecret := dataflowTester.Cfg.GetString("ENCRYPTION_SECRET") + require.NotEmpty(t, encryptionSecret, "ENCRYPTION_SECRET must be set in .env") + dalgorm.Init(encryptionSecret) + + // Pre-encrypt a dummy token; the mock server does not validate it. + encToken, encErr := plugin.Encrypt(encryptionSecret, "mock-token") + require.NoError(t, encErr) + + domainCommentID := fmt.Sprintf("gitlab:GitlabMrComment:%d:%d", connID, noteID) + repoID := "gitlab:GitlabRepo:1:100" + + // Create minimal _tool_gitlab_connections and insert connection pointing at mock server. + err = dataflowTester.Dal.Exec(` + CREATE TABLE IF NOT EXISTS _tool_gitlab_connections ( + id BIGINT UNSIGNED PRIMARY KEY, + endpoint VARCHAR(255), + token TEXT + )`) + require.NoError(t, err) + dataflowTester.Dal.Exec("DELETE FROM _tool_gitlab_connections WHERE id = ?", connID) + err = dataflowTester.Dal.Exec( + "INSERT INTO _tool_gitlab_connections (id, endpoint, token) VALUES (?, ?, ?)", + connID, srv.URL, encToken, + ) + require.NoError(t, err) + + // Create minimal _tool_gitlab_merge_requests and insert test MR. + err = dataflowTester.Dal.Exec(` + CREATE TABLE IF NOT EXISTS _tool_gitlab_merge_requests ( + connection_id BIGINT UNSIGNED, + gitlab_id INT, + iid INT, + project_id INT, + PRIMARY KEY (connection_id, gitlab_id) + )`) + require.NoError(t, err) + dataflowTester.Dal.Exec("DELETE FROM _tool_gitlab_merge_requests WHERE connection_id = ? AND gitlab_id = ?", connID, mrGitlabID) + err = dataflowTester.Dal.Exec( + "INSERT INTO _tool_gitlab_merge_requests (connection_id, gitlab_id, iid, project_id) VALUES (?, ?, ?, ?)", + connID, mrGitlabID, mrIID, projectID, + ) + require.NoError(t, err) + + // Create minimal _tool_gitlab_mr_comments and insert the note under test. + err = dataflowTester.Dal.Exec(` + CREATE TABLE IF NOT EXISTS _tool_gitlab_mr_comments ( + connection_id BIGINT UNSIGNED, + gitlab_id INT, + merge_request_id INT, + merge_request_iid INT, + PRIMARY KEY (connection_id, gitlab_id) + )`) + require.NoError(t, err) + dataflowTester.Dal.Exec("DELETE FROM _tool_gitlab_mr_comments WHERE connection_id = ? AND gitlab_id = ?", connID, noteID) + err = dataflowTester.Dal.Exec( + "INSERT INTO _tool_gitlab_mr_comments (connection_id, gitlab_id, merge_request_id, merge_request_iid) VALUES (?, ?, ?, ?)", + connID, noteID, mrGitlabID, mrIID, + ) + require.NoError(t, err) + + // Seed _tool_aireview_reviews with one GitLab review pointing at the test note. + dataflowTester.FlushTabler(&models.AiReview{}) + testReview := &models.AiReview{ + Id: "aireview:gitlab-e2e-test", + PullRequestId: fmt.Sprintf("gitlab:GitlabMergeRequest:%d:%d", connID, mrGitlabID), + RepoId: repoID, + AiTool: models.AiToolCodeRabbit, + AiToolUser: "coderabbitai", + ReviewId: domainCommentID, + Body: "test AI review comment", + SourcePlatform: "gitlab", + CreatedDate: time.Now(), + } + err = dataflowTester.Dal.CreateOrUpdate(testReview) + require.NoError(t, err) + + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: repoID, + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + require.NoError(t, tasks.CompilePatterns(taskData)) + + // Run the GitLab reaction enrichment task against the mock server. + dataflowTester.Subtask(tasks.EnrichGitlabReviewReactionsMeta, taskData) + + // Verify the review was enriched with the reactions from the fixture. + // Fixture has 1 thumbsup (cmulliga on note 20330331). + var reviews []models.AiReview + require.NoError(t, dataflowTester.Dal.All(&reviews)) + require.Equal(t, 1, len(reviews)) + + r := reviews[0] + assert.Equal(t, 1, r.ReactionsTotalCount, "expected total_count=1 from fixture") + assert.Equal(t, 1, r.ReactionsThumbsUp, "expected thumbsup=1 from fixture") + assert.Equal(t, 0, r.ReactionsThumbsDown, "expected thumbsdown=0 from fixture") +} diff --git a/backend/plugins/aireview/e2e/aireview_suggestion_acceptance_test.go b/backend/plugins/aireview/e2e/aireview_suggestion_acceptance_test.go new file mode 100644 index 00000000000..179909b7afe --- /dev/null +++ b/backend/plugins/aireview/e2e/aireview_suggestion_acceptance_test.go @@ -0,0 +1,92 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "testing" + + "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/models/domainlayer/crossdomain" + "github.com/apache/devlake/helpers/e2ehelper" + "github.com/apache/devlake/plugins/aireview/impl" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/apache/devlake/plugins/aireview/tasks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSuggestionAcceptanceTracking verifies that suggestion acceptance markers +// are parsed from AI tool comment bodies during review extraction. +// +// Test data covers three cases: +// - PR 2001 (CodeRabbit): 2x "✅ Resolved" in tracking table + "2 suggestions applied" summary → 4 accepted +// - PR 2002 (Qodo): 2x "[x]" checked + 2x "[ ]" unchecked checkboxes → 2 accepted +// - PR 2003 (CodeRabbit): no acceptance markers → 0 accepted +func TestSuggestionAcceptanceTracking(t *testing.T) { + var plug impl.AiReview + dataflowTester := e2ehelper.NewDataFlowTester(t, "aireview", plug) + + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: "github:GithubRepo:1:200", + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + require.NoError(t, tasks.CompilePatterns(taskData)) + + // Flush and load domain tables + dataflowTester.FlushTabler(&crossdomain.Account{}) + dataflowTester.FlushTabler(&code.PullRequest{}) + dataflowTester.FlushTabler(&code.PullRequestComment{}) + dataflowTester.FlushTabler(&models.AiReview{}) + + dataflowTester.ImportCsvIntoTabler("./raw_tables/suggestion_pull_requests.csv", &code.PullRequest{}) + dataflowTester.ImportCsvIntoTabler("./raw_tables/suggestion_pull_request_comments.csv", &code.PullRequestComment{}) + + // Run extraction + dataflowTester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + + // Verify results + var reviews []models.AiReview + require.NoError(t, dataflowTester.Dal.All(&reviews)) + require.Equal(t, 3, len(reviews), "expected 3 AI reviews") + + // Build lookup by review_id (the domain comment ID) + type expected struct { + aiTool string + suggestionsAccepted int + } + want := map[string]expected{ + // CodeRabbit: 2x "✅ Resolved" + "2 suggestions applied" = 4 + "github:GithubPrComment:1:6001": {aiTool: models.AiToolCodeRabbit, suggestionsAccepted: 4}, + // Qodo: 2 checked [x] + 2 unchecked [ ] = 2 accepted + "github:GithubPrComment:1:6002": {aiTool: models.AiToolQodo, suggestionsAccepted: 2}, + // CodeRabbit: no acceptance markers = 0 + "github:GithubPrComment:1:6003": {aiTool: models.AiToolCodeRabbit, suggestionsAccepted: 0}, + } + + for _, r := range reviews { + exp, ok := want[r.ReviewId] + if !ok { + t.Errorf("unexpected review with review_id %s", r.ReviewId) + continue + } + assert.Equal(t, exp.aiTool, r.AiTool, "ai_tool for %s", r.ReviewId) + assert.Equal(t, exp.suggestionsAccepted, r.SuggestionsAccepted, "suggestions_accepted for %s", r.ReviewId) + } +} diff --git a/backend/plugins/aireview/e2e/aireview_test.go b/backend/plugins/aireview/e2e/aireview_test.go new file mode 100644 index 00000000000..8bd1f6cd94b --- /dev/null +++ b/backend/plugins/aireview/e2e/aireview_test.go @@ -0,0 +1,133 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "testing" + + "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/helpers/e2ehelper" + "github.com/apache/devlake/plugins/aireview/impl" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/apache/devlake/plugins/aireview/tasks" +) + +func TestAiReviewDataFlow(t *testing.T) { + var plugin impl.AiReview + dataflowTester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + // Prepare task data + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: "github:GithubRepo:1:100", + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + + // Compile regex patterns + err := tasks.CompilePatterns(taskData) + if err != nil { + t.Fatalf("Failed to compile patterns: %v", err) + } + + // Import domain layer data (PRs and comments) + dataflowTester.FlushTabler(&code.PullRequest{}) + dataflowTester.FlushTabler(&code.PullRequestComment{}) + dataflowTester.FlushTabler(&models.AiReview{}) + + dataflowTester.ImportCsvIntoTabler("./raw_tables/pull_requests.csv", &code.PullRequest{}) + dataflowTester.ImportCsvIntoTabler("./raw_tables/pull_request_comments.csv", &code.PullRequestComment{}) + + // Run extraction subtask + dataflowTester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + + // Verify by querying the database directly since IDs are hash-based + var reviews []models.AiReview + err = dataflowTester.Dal.All(&reviews) + if err != nil { + t.Fatalf("Failed to query reviews: %v", err) + } + + // Verify we found the expected number of AI reviews (3 CodeRabbit comments) + if len(reviews) != 3 { + t.Errorf("Expected 3 AI reviews, got %d", len(reviews)) + } + + // Verify each review has expected properties + for _, review := range reviews { + if review.AiTool != models.AiToolCodeRabbit { + t.Errorf("Expected AI tool %s, got %s", models.AiToolCodeRabbit, review.AiTool) + } + if review.AiToolUser != "coderabbitai" { + t.Errorf("Expected AI tool user coderabbitai, got %s", review.AiToolUser) + } + if review.RepoId != "github:GithubRepo:1:100" { + t.Errorf("Expected repo ID github:GithubRepo:1:100, got %s", review.RepoId) + } + if review.SourcePlatform != "github" { + t.Errorf("Expected source platform github, got %s", review.SourcePlatform) + } + } + + t.Logf("Successfully extracted %d AI reviews", len(reviews)) +} + +func TestAiReviewFindingsDataFlow(t *testing.T) { + var plugin impl.AiReview + dataflowTester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + // Prepare task data + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: "github:GithubRepo:1:100", + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + + // Compile regex patterns + err := tasks.CompilePatterns(taskData) + if err != nil { + t.Fatalf("Failed to compile patterns: %v", err) + } + + // Import domain layer data + dataflowTester.FlushTabler(&code.PullRequest{}) + dataflowTester.FlushTabler(&code.PullRequestComment{}) + dataflowTester.FlushTabler(&models.AiReview{}) + dataflowTester.FlushTabler(&models.AiReviewFinding{}) + + dataflowTester.ImportCsvIntoTabler("./raw_tables/pull_requests.csv", &code.PullRequest{}) + dataflowTester.ImportCsvIntoTabler("./raw_tables/pull_request_comments.csv", &code.PullRequestComment{}) + + // Run extraction subtasks in order + dataflowTester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + dataflowTester.Subtask(tasks.ExtractAiReviewFindingsMeta, taskData) + + // Just verify the table has entries (findings parsing is complex) + var findings []models.AiReviewFinding + err = dataflowTester.Dal.All(&findings) + if err != nil { + t.Fatalf("Failed to query findings: %v", err) + } + + if len(findings) == 0 { + t.Log("Warning: No findings extracted - this may be expected if review body parsing doesn't match patterns") + } else { + t.Logf("Extracted %d findings", len(findings)) + } +} diff --git a/backend/plugins/aireview/e2e/fetch_missing_ci_jobs_e2e_test.go b/backend/plugins/aireview/e2e/fetch_missing_ci_jobs_e2e_test.go new file mode 100644 index 00000000000..fb31c30fc86 --- /dev/null +++ b/backend/plugins/aireview/e2e/fetch_missing_ci_jobs_e2e_test.go @@ -0,0 +1,203 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/helpers/e2ehelper" + "github.com/apache/devlake/pkg/gcshelper" + "github.com/apache/devlake/plugins/aireview/impl" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/apache/devlake/plugins/aireview/tasks" +) + +// e2eHistoryStore is an in-memory HistoryStore used in e2e tests to avoid real +// GCS network access. +type e2eHistoryStore struct { + subdirs map[string][]string + files map[string][]byte +} + +func (f *e2eHistoryStore) ListSubdirectories(_ context.Context, prefix string) ([]string, error) { + return f.subdirs[prefix], nil +} + +func (f *e2eHistoryStore) ReadFile(_ context.Context, path string) ([]byte, error) { + data, ok := f.files[path] + if !ok { + return nil, fmt.Errorf("not found: %s", path) + } + return data, nil +} + +var _ gcshelper.HistoryStore = (*e2eHistoryStore)(nil) + +// TestFetchMissingCiJobs verifies the end-to-end flow: +// 1. AI reviews are extracted from PR comments (no CI data present). +// 2. FetchMissingCiJobs detects the gap and fetches from the fake GCS store. +// 3. ci_test_jobs is populated with the correct rows. +// 4. CalculateFailurePredictions can then use the backfilled rows. +func TestFetchMissingCiJobs(t *testing.T) { + const repoId = "github:GithubRepo:1:200" + + var plugin impl.AiReview + tester := e2ehelper.NewDataFlowTester(t, "aireview", plugin) + + // Fake GCS store — provides build results for PRs 101 and 103. + // PR 101: unit-tests FAILED (high-risk AI review → TP) + // PR 103: e2e-tests FAILED (low-risk AI review → FN) + // PR 102 and 104: no GCS data (no builds recorded) + now := time.Now() + recentTS := now.Add(-48 * time.Hour).Unix() + + store := &e2eHistoryStore{ + subdirs: map[string][]string{ + "pr-logs/pull/myorg_myrepo/101/": { + "pr-logs/pull/myorg_myrepo/101/pull-ci-myorg-myrepo-main-unit-tests/", + }, + "pr-logs/pull/myorg_myrepo/101/pull-ci-myorg-myrepo-main-unit-tests/": { + "pr-logs/pull/myorg_myrepo/101/pull-ci-myorg-myrepo-main-unit-tests/9000001/", + }, + "pr-logs/pull/myorg_myrepo/103/": { + "pr-logs/pull/myorg_myrepo/103/pull-ci-myorg-myrepo-main-e2e-tests/", + }, + "pr-logs/pull/myorg_myrepo/103/pull-ci-myorg-myrepo-main-e2e-tests/": { + "pr-logs/pull/myorg_myrepo/103/pull-ci-myorg-myrepo-main-e2e-tests/9000003/", + }, + }, + files: map[string][]byte{ + "pr-logs/pull/myorg_myrepo/101/pull-ci-myorg-myrepo-main-unit-tests/9000001/finished.json": []byte( + fmt.Sprintf(`{"timestamp":%d,"passed":false,"result":"FAILURE"}`, recentTS), + ), + "pr-logs/pull/myorg_myrepo/103/pull-ci-myorg-myrepo-main-e2e-tests/9000003/finished.json": []byte( + fmt.Sprintf(`{"timestamp":%d,"passed":false,"result":"FAILURE"}`, recentTS), + ), + }, + } + + scopeConfig := models.GetDefaultScopeConfig() + scopeConfig.WarningThreshold = 50 + scopeConfig.CiBackfillEnabled = true + scopeConfig.CiBackfillDays = 90 + scopeConfig.CiFailureSource = models.CiSourceJobResult // simplest source for this test + + taskData := &tasks.AiReviewTaskData{ + Options: &tasks.AiReviewOptions{ + RepoId: repoId, + ScopeConfig: scopeConfig, + }, + GcsStoreOverride: store, + } + if err := tasks.CompilePatterns(taskData); err != nil { + t.Fatalf("CompilePatterns: %v", err) + } + + // Flush tables. + tester.FlushTabler(&code.PullRequest{}) + tester.FlushTabler(&code.PullRequestComment{}) + tester.FlushTabler(&models.AiReview{}) + tester.FlushTabler(&models.AiFailurePrediction{}) + tester.FlushTabler(&ciTestJob{}) + tester.FlushTabler(&repoRow{}) + + // Import fixtures — same PRs and comments as the main CI prediction test, + // but WITHOUT importing ci_test_jobs so all PRs are "missing". + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_requests.csv", &code.PullRequest{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_pull_request_comments.csv", &code.PullRequestComment{}) + tester.ImportCsvIntoTabler("./raw_tables/ci_repos.csv", &repoRow{}) + + // Step 1: extract AI reviews. + tester.Subtask(tasks.ExtractAiReviewsMeta, taskData) + + // Step 2: backfill CI data from fake GCS. + tester.Subtask(tasks.FetchMissingCiJobsMeta, taskData) + + // Verify ci_test_jobs was populated with the GCS-backfilled rows. + var jobs []ciTestJob + if err := tester.Dal.All(&jobs); err != nil { + t.Fatalf("Failed to query ci_test_jobs: %v", err) + } + if len(jobs) == 0 { + t.Fatal("Expected ci_test_jobs to be populated after GCS backfill, got 0 rows") + } + + // We should have exactly 2 rows (PR 101 and PR 103). + if len(jobs) != 2 { + t.Errorf("Expected 2 ci_test_jobs rows, got %d", len(jobs)) + } + + prJobMap := make(map[int64]string) // pr_number → result + for _, j := range jobs { + prJobMap[j.PullRequestNumber] = j.Result + // All backfilled rows use connection_id=0 sentinel. + if j.ConnectionId != 0 { + t.Errorf("Expected connection_id=0 for backfilled row, got %d", j.ConnectionId) + } + if j.TriggerType != "pull_request" { + t.Errorf("Expected trigger_type=pull_request, got %q", j.TriggerType) + } + } + + if prJobMap[101] != "failure" { + t.Errorf("PR 101: expected result=failure, got %q", prJobMap[101]) + } + if prJobMap[103] != "failure" { + t.Errorf("PR 103: expected result=failure, got %q", prJobMap[103]) + } + + // Step 3: run predictions on the backfilled data. + tester.Subtask(tasks.CalculateFailurePredictionsMeta, taskData) + + var predictions []models.AiFailurePrediction + if err := tester.Dal.All(&predictions); err != nil { + t.Fatalf("Failed to query predictions: %v", err) + } + + byPR := make(map[string]*models.AiFailurePrediction) + for i := range predictions { + p := &predictions[i] + if p.CiFailureSource == models.CiSourceJobResult { + byPR[p.PullRequestKey] = p + } + } + + // PR 101: high-risk AI review (score=80 > threshold=50), CI failed → TP + if p := byPR["101"]; p != nil { + if p.PredictionOutcome != models.PredictionTP { + t.Errorf("PR 101: expected TP, got %s", p.PredictionOutcome) + } + } else { + t.Error("PR 101: no prediction found") + } + + // PR 103: low-risk AI review (score=20 < threshold=50), CI failed → FN + if p := byPR["103"]; p != nil { + if p.PredictionOutcome != models.PredictionFN { + t.Errorf("PR 103: expected FN, got %s", p.PredictionOutcome) + } + } else { + t.Error("PR 103: no prediction found") + } + + t.Logf("Backfill e2e: %d ci_test_jobs rows, %d predictions", len(jobs), len(predictions)) +} diff --git a/backend/plugins/aireview/e2e/raw_tables/_raw_github_api_comments.csv b/backend/plugins/aireview/e2e/raw_tables/_raw_github_api_comments.csv new file mode 100644 index 00000000000..915e433373c --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/_raw_github_api_comments.csv @@ -0,0 +1,6 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""Name"":""meilisearch/meilisearch-rust""}","{""id"":5001,""pull_request_review_id"":1001,""user"":{""login"":""coderabbitai[bot]"",""id"":136622811},""body"":""Summary by CodeRabbit. Walkthrough included."",""html_url"":""https://github.com/test/repo/pull/1001#discussion_r5001"",""created_at"":""2024-01-15T10:30:00Z"",""updated_at"":""2024-01-15T10:30:00Z"",""reactions"":{""url"":""https://api.github.com/repos/test/repo/issues/comments/5001/reactions"",""total_count"":2,""+1"":2,""-1"":0,""laugh"":0,""hooray"":0,""confused"":0,""heart"":0,""rocket"":0,""eyes"":0}}",https://api.github.com/repos/test/repo/pulls/comments?per_page=100&page=1,null,2024-01-15 10:30:00.000 +2,"{""ConnectionId"":1,""Name"":""meilisearch/meilisearch-rust""}","{""id"":5002,""pull_request_review_id"":1001,""user"":{""login"":""developer3"",""id"":111},""body"":""LGTM approved!"",""html_url"":""https://github.com/test/repo/pull/1001#discussion_r5002"",""created_at"":""2024-01-15T12:00:00Z"",""updated_at"":""2024-01-15T12:00:00Z"",""reactions"":{""url"":""https://api.github.com/repos/test/repo/issues/comments/5002/reactions"",""total_count"":0,""+1"":0,""-1"":0,""laugh"":0,""hooray"":0,""confused"":0,""heart"":0,""rocket"":0,""eyes"":0}}",https://api.github.com/repos/test/repo/pulls/comments?per_page=100&page=1,null,2024-01-15 12:00:00.000 +3,"{""ConnectionId"":1,""Name"":""meilisearch/meilisearch-rust""}","{""id"":5003,""pull_request_review_id"":1002,""user"":{""login"":""coderabbitai[bot]"",""id"":136622811},""body"":""Walkthrough. Critical bug found. Suggested Fix: add null check."",""html_url"":""https://github.com/test/repo/pull/1002#discussion_r5003"",""created_at"":""2024-01-17T09:30:00Z"",""updated_at"":""2024-01-17T09:30:00Z"",""reactions"":{""url"":""https://api.github.com/repos/test/repo/issues/comments/5003/reactions"",""total_count"":2,""+1"":1,""-1"":1,""laugh"":0,""hooray"":0,""confused"":0,""heart"":0,""rocket"":0,""eyes"":0}}",https://api.github.com/repos/test/repo/pulls/comments?per_page=100&page=1,null,2024-01-17 09:30:00.000 +4,"{""ConnectionId"":1,""Name"":""meilisearch/meilisearch-rust""}","{""id"":5004,""pull_request_review_id"":1002,""user"":{""login"":""developer2"",""id"":112},""body"":""Fixed the null check as suggested"",""html_url"":""https://github.com/test/repo/pull/1002#discussion_r5004"",""created_at"":""2024-01-17T11:00:00Z"",""updated_at"":""2024-01-17T11:00:00Z"",""reactions"":{""url"":""https://api.github.com/repos/test/repo/issues/comments/5004/reactions"",""total_count"":0,""+1"":0,""-1"":0,""laugh"":0,""hooray"":0,""confused"":0,""heart"":0,""rocket"":0,""eyes"":0}}",https://api.github.com/repos/test/repo/pulls/comments?per_page=100&page=1,null,2024-01-17 11:00:00.000 +5,"{""ConnectionId"":1,""Name"":""meilisearch/meilisearch-rust""}","{""id"":5005,""pull_request_review_id"":1003,""user"":{""login"":""coderabbitai[bot]"",""id"":136622811},""body"":""Walkthrough. Moderate change with security WARNING."",""html_url"":""https://github.com/test/repo/pull/1003#discussion_r5005"",""created_at"":""2024-01-20T09:00:00Z"",""updated_at"":""2024-01-20T09:00:00Z"",""reactions"":{""url"":""https://api.github.com/repos/test/repo/issues/comments/5005/reactions"",""total_count"":0,""+1"":0,""-1"":0,""laugh"":0,""hooray"":0,""confused"":0,""heart"":0,""rocket"":0,""eyes"":0}}",https://api.github.com/repos/test/repo/pulls/comments?per_page=100&page=1,null,2024-01-20 09:00:00.000 diff --git a/backend/plugins/aireview/e2e/raw_tables/ai_commits.csv b/backend/plugins/aireview/e2e/raw_tables/ai_commits.csv new file mode 100644 index 00000000000..0ff577853aa --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/ai_commits.csv @@ -0,0 +1,12 @@ +sha,message,author_name,author_email,authored_date +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,"Fix nil check + +Co-Authored-By: Cursor ",Alice,alice@example.com,2026-03-01T10:00:00.000+00:00 +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,"Add tests + +Co-Authored-By: Claude ",Bob,bob@example.com,2026-03-02T10:00:00.000+00:00 +cccccccccccccccccccccccccccccccccccccccc,"lint + +Co-Authored-By: Copilot ",Carol,carol@example.com,2026-03-03T10:00:00.000+00:00 +dddddddddddddddddddddddddddddddddddddddd,Document how we evaluated Claude vs Copilot for reviews,Dave,dave@example.com,2026-03-04T10:00:00.000+00:00 +eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee,Apply suggestions from code review,coderabbitai[bot],136622811+coderabbitai[bot]@users.noreply.github.com,2026-03-05T10:00:00.000+00:00 diff --git a/backend/plugins/aireview/e2e/raw_tables/ai_pull_request_commits.csv b/backend/plugins/aireview/e2e/raw_tables/ai_pull_request_commits.csv new file mode 100644 index 00000000000..b37e4e0be4e --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/ai_pull_request_commits.csv @@ -0,0 +1,2 @@ +commit_sha,pull_request_id,commit_author_name,commit_author_email,commit_authored_date +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,github:GithubPullRequest:1:9001,Bob,bob@example.com,2026-03-02T10:00:00.000+00:00 diff --git a/backend/plugins/aireview/e2e/raw_tables/ai_pull_requests.csv b/backend/plugins/aireview/e2e/raw_tables/ai_pull_requests.csv new file mode 100644 index 00000000000..415196422f6 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/ai_pull_requests.csv @@ -0,0 +1,3 @@ +id,base_repo_id,status,merge_commit_sha,title,url,author_name,pull_request_key,created_date +github:GithubPullRequest:1:9001,github:GithubRepo:1:200,MERGED,,Claude commit PR,https://github.com/myorg/myrepo/pull/901,Bob,901,2026-03-02T09:00:00.000+00:00 +github:GithubPullRequest:1:9002,github:GithubRepo:1:200,MERGED,cccccccccccccccccccccccccccccccccccccccc,Copilot merge PR,https://github.com/myorg/myrepo/pull/902,Carol,902,2026-03-03T09:00:00.000+00:00 diff --git a/backend/plugins/aireview/e2e/raw_tables/ai_repo_commits.csv b/backend/plugins/aireview/e2e/raw_tables/ai_repo_commits.csv new file mode 100644 index 00000000000..5503839eb95 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/ai_repo_commits.csv @@ -0,0 +1,4 @@ +repo_id,commit_sha +github:GithubRepo:1:200,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +github:GithubRepo:1:200,dddddddddddddddddddddddddddddddddddddddd +github:GithubRepo:1:200,eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee diff --git a/backend/plugins/aireview/e2e/raw_tables/ci_pull_request_comments.csv b/backend/plugins/aireview/e2e/raw_tables/ci_pull_request_comments.csv new file mode 100644 index 00000000000..c5b2ac39adf --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/ci_pull_request_comments.csv @@ -0,0 +1,5 @@ +id,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,pull_request_id,body,account_id,created_date,commit_sha,type,review_id,status +github:GithubPrComment:1:6001,"{""ConnectionId"":1}",_raw_github_api_comments,1,,github:GithubPullRequest:1:2001,"Summary by CodeRabbit. **Risk Level: High** (score: 80). Critical security issue found in authentication module. Walkthrough: significant changes to auth flow. Estimated effort to review: Complex.",coderabbitai,2026-03-01T10:30:00.000+00:00,sha101a,REVIEW,review-ci-1,COMMENTED +github:GithubPrComment:1:6002,"{""ConnectionId"":1}",_raw_github_api_comments,2,,github:GithubPullRequest:1:2002,"Summary by CodeRabbit. **Risk Level: High** (score: 70). Warning: potential breaking change in API contract. Walkthrough: API interface modified. Estimated effort to review: Moderate.",coderabbitai,2026-03-02T10:30:00.000+00:00,sha102a,REVIEW,review-ci-2,COMMENTED +github:GithubPrComment:1:6003,"{""ConnectionId"":1}",_raw_github_api_comments,3,,github:GithubPullRequest:1:2003,"Summary by CodeRabbit. **Risk Level: Low** (score: 20). Minor refactoring only. Walkthrough: code cleanup with no functional changes. Estimated effort to review: Simple.",coderabbitai,2026-03-03T10:30:00.000+00:00,sha103a,REVIEW,review-ci-3,COMMENTED +github:GithubPrComment:1:6004,"{""ConnectionId"":1}",_raw_github_api_comments,4,,github:GithubPullRequest:1:2004,"Summary by CodeRabbit. **Risk Level: Low** (score: 10). Documentation update only. Walkthrough: README update. Estimated effort to review: Simple.",coderabbitai,2026-03-04T10:30:00.000+00:00,sha104a,REVIEW,review-ci-4,COMMENTED diff --git a/backend/plugins/aireview/e2e/raw_tables/ci_pull_requests.csv b/backend/plugins/aireview/e2e/raw_tables/ci_pull_requests.csv new file mode 100644 index 00000000000..bf03bd9c4fb --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/ci_pull_requests.csv @@ -0,0 +1,5 @@ +id,base_repo_id,head_repo_id,status,original_status,title,description,url,author_name,author_id,parent_pr_id,pull_request_key,created_date,merged_date,closed_date,type,component,merge_commit_sha,head_ref,base_ref,base_commit_sha,head_commit_sha,additions,deletions,is_draft,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark +github:GithubPullRequest:1:2001,github:GithubRepo:1:200,github:GithubRepo:1:200,MERGED,closed,High risk PR with real CI failure,TP case - AI warns and CI fails,https://github.com/myorg/myrepo/pull/101,dev1,github:GithubAccount:1:301,,101,2026-03-01T10:00:00.000+00:00,2026-03-01T14:00:00.000+00:00,,feature,,sha101a,feature/tp,main,sha101b,sha101c,100,10,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,1, +github:GithubPullRequest:1:2002,github:GithubRepo:1:200,github:GithubRepo:1:200,MERGED,closed,High risk PR with only flaky CI failure,FP case - AI warns but only flaky test fails,https://github.com/myorg/myrepo/pull/102,dev2,github:GithubAccount:1:302,,102,2026-03-02T10:00:00.000+00:00,2026-03-02T14:00:00.000+00:00,,feature,,sha102a,feature/fp,main,sha102b,sha102c,50,5,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,2, +github:GithubPullRequest:1:2003,github:GithubRepo:1:200,github:GithubRepo:1:200,MERGED,closed,Low risk PR with CI failure,FN case - AI does not warn but CI fails,https://github.com/myorg/myrepo/pull/103,dev3,github:GithubAccount:1:303,,103,2026-03-03T10:00:00.000+00:00,2026-03-03T14:00:00.000+00:00,,bugfix,,sha103a,bugfix/fn,main,sha103b,sha103c,20,3,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,3, +github:GithubPullRequest:1:2004,github:GithubRepo:1:200,github:GithubRepo:1:200,MERGED,closed,Low risk PR with passing CI,TN case - AI does not warn and CI passes,https://github.com/myorg/myrepo/pull/104,dev4,github:GithubAccount:1:304,,104,2026-03-04T10:00:00.000+00:00,2026-03-04T14:00:00.000+00:00,,docs,,sha104a,docs/tn,main,sha104b,sha104c,5,1,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,4, diff --git a/backend/plugins/aireview/e2e/raw_tables/ci_repos.csv b/backend/plugins/aireview/e2e/raw_tables/ci_repos.csv new file mode 100644 index 00000000000..e479e37d243 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/ci_repos.csv @@ -0,0 +1,2 @@ +id,name +github:GithubRepo:1:200,myorg/myrepo diff --git a/backend/plugins/aireview/e2e/raw_tables/ci_test_cases.csv b/backend/plugins/aireview/e2e/raw_tables/ci_test_cases.csv new file mode 100644 index 00000000000..a82d0518815 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/ci_test_cases.csv @@ -0,0 +1,7 @@ +connection_id,job_id,test_case_id,name,status +1,job-pr-101,tc-101-1,TestAuthHandler,failed +1,job-pr-101,tc-101-2,TestLoginFlow,passed +1,job-pr-102,tc-102-1,TestFlakyNetworkCall,failed +1,job-pr-103,tc-103-1,TestRefactorOutput,failed +1,job-pr-104,tc-104-1,TestDocsBuild,passed +1,job-periodic-1,tc-p1-1,TestFlakyNetworkCall,failed diff --git a/backend/plugins/aireview/e2e/raw_tables/ci_test_jobs.csv b/backend/plugins/aireview/e2e/raw_tables/ci_test_jobs.csv new file mode 100644 index 00000000000..72c74a80ad5 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/ci_test_jobs.csv @@ -0,0 +1,6 @@ +connection_id,job_id,job_name,repository,pull_request_number,trigger_type,result,finished_at,duration_sec +1,job-pr-101,unit-tests,myrepo,101,pull_request,FAILURE,2026-03-01T12:00:00.000+00:00,120.0 +1,job-pr-102,flaky-integration,myrepo,102,pull_request,FAILURE,2026-03-02T12:00:00.000+00:00,115.0 +1,job-pr-103,e2e-tests,myrepo,103,pull_request,FAILURE,2026-03-03T12:00:00.000+00:00,110.0 +1,job-pr-104,unit-tests,myrepo,104,pull_request,SUCCESS,2026-03-04T12:00:00.000+00:00,105.0 +1,job-periodic-1,flaky-integration,myrepo,0,periodic,FAILURE,2026-04-15T02:00:00.000+00:00,130.0 diff --git a/backend/plugins/aireview/e2e/raw_tables/gitlab_award_emoji_note_20330331.json b/backend/plugins/aireview/e2e/raw_tables/gitlab_award_emoji_note_20330331.json new file mode 100644 index 00000000000..bcfcb890e33 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/gitlab_award_emoji_note_20330331.json @@ -0,0 +1,21 @@ +[ + { + "id": 318275, + "name": "thumbsup", + "user": { + "id": 25277, + "username": "cmulliga", + "public_email": null, + "name": "Ciara Mulligan", + "state": "active", + "locked": false, + "avatar_url": "https://seccdn.libravatar.org/avatar/45f3386c9289d116dc770bfbaef0a1695163a6e6073adcbc18ae2fe75c919ca3?s=80&d=identicon", + "web_url": "https://gitlab.cee.redhat.com/cmulliga" + }, + "created_at": "2026-03-06T12:40:23.012Z", + "updated_at": "2026-03-06T12:40:23.012Z", + "awardable_id": 20330331, + "awardable_type": "Note", + "url": null + } +] diff --git a/backend/plugins/aireview/e2e/raw_tables/no_ci_pull_request_comments.csv b/backend/plugins/aireview/e2e/raw_tables/no_ci_pull_request_comments.csv new file mode 100644 index 00000000000..c73515f1b09 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/no_ci_pull_request_comments.csv @@ -0,0 +1,3 @@ +id,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,pull_request_id,body,account_id,created_date,commit_sha,type,review_id,status +github:GithubPrComment:1:9001,"{""ConnectionId"":1}",_raw_github_api_comments,1,,github:GithubPullRequest:1:9001,"Summary by CodeRabbit. **Risk Level: High** (score: 80). Critical security issue found. Walkthrough: significant changes. Estimated effort to review: Complex.",coderabbitai,2026-03-01T10:30:00.000+00:00,sha901a,REVIEW,review-no-ci-1,COMMENTED +github:GithubPrComment:1:9002,"{""ConnectionId"":1}",_raw_github_api_comments,2,,github:GithubPullRequest:1:9002,"Summary by CodeRabbit. **Risk Level: Low** (score: 20). Minor style changes. Walkthrough: formatting only. Estimated effort to review: Trivial.",coderabbitai,2026-03-02T10:30:00.000+00:00,sha902a,REVIEW,review-no-ci-2,COMMENTED diff --git a/backend/plugins/aireview/e2e/raw_tables/no_ci_pull_requests.csv b/backend/plugins/aireview/e2e/raw_tables/no_ci_pull_requests.csv new file mode 100644 index 00000000000..42a4e5631fd --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/no_ci_pull_requests.csv @@ -0,0 +1,3 @@ +id,base_repo_id,head_repo_id,status,original_status,title,description,url,author_name,author_id,parent_pr_id,pull_request_key,created_date,merged_date,closed_date,type,component,merge_commit_sha,head_ref,base_ref,base_commit_sha,head_commit_sha,additions,deletions,is_draft,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark +github:GithubPullRequest:1:9001,github:GithubRepo:1:999,github:GithubRepo:1:999,MERGED,closed,High risk PR no CI,AI flags high risk but no CI data exists,https://github.com/myorg/no-ci-repo/pull/901,dev1,github:GithubAccount:1:301,,901,2026-03-01T10:00:00.000+00:00,2026-03-01T14:00:00.000+00:00,,feature,,sha901a,feature/test,main,sha901b,sha901c,100,10,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,1, +github:GithubPullRequest:1:9002,github:GithubRepo:1:999,github:GithubRepo:1:999,MERGED,closed,Low risk PR no CI,AI flags low risk but no CI data exists,https://github.com/myorg/no-ci-repo/pull/902,dev2,github:GithubAccount:1:302,,902,2026-03-02T10:00:00.000+00:00,2026-03-02T14:00:00.000+00:00,,docs,,sha902a,docs/test,main,sha902b,sha902c,5,1,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,2, diff --git a/backend/plugins/aireview/e2e/raw_tables/no_ci_repos.csv b/backend/plugins/aireview/e2e/raw_tables/no_ci_repos.csv new file mode 100644 index 00000000000..21f5deabc97 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/no_ci_repos.csv @@ -0,0 +1,2 @@ +id,name +github:GithubRepo:1:999,myorg/no-ci-repo diff --git a/backend/plugins/aireview/e2e/raw_tables/project_mapping.csv b/backend/plugins/aireview/e2e/raw_tables/project_mapping.csv new file mode 100644 index 00000000000..879e408ba71 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/project_mapping.csv @@ -0,0 +1,2 @@ +project_name,table,row_id +test-project,repos,github:GithubRepo:1:200 diff --git a/backend/plugins/aireview/e2e/raw_tables/pull_request_comments.csv b/backend/plugins/aireview/e2e/raw_tables/pull_request_comments.csv new file mode 100644 index 00000000000..9b5d4cad99c --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/pull_request_comments.csv @@ -0,0 +1,6 @@ +id,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,pull_request_id,body,account_id,created_date,commit_sha,type,review_id,status +github:GithubPrComment:1:5001,"{""ConnectionId"":1}",_raw_github_api_comments,1,,github:GithubPullRequest:1:1001,Summary by CodeRabbit. This PR implements JWT authentication. Walkthrough included. Effort is Simple at 10 minutes.,coderabbitai,2024-01-15T10:30:00.000+00:00,abc123,REVIEW,review-1,COMMENTED +github:GithubPrComment:1:5002,"{""ConnectionId"":1}",_raw_github_api_comments,2,,github:GithubPullRequest:1:1001,LGTM approved!,developer3,2024-01-15T12:00:00.000+00:00,abc123,REVIEW,review-2,APPROVED +github:GithubPrComment:1:5003,"{""ConnectionId"":1}",_raw_github_api_comments,3,,github:GithubPullRequest:1:1002,Walkthrough. Critical bug fix. Issues Found include missing null check. Suggested Fix is to add null check.,coderabbitai,2024-01-17T09:30:00.000+00:00,jkl012,REVIEW,review-3,CHANGES_REQUESTED +github:GithubPrComment:1:5004,"{""ConnectionId"":1}",_raw_github_api_comments,4,,github:GithubPullRequest:1:1002,Fixed the null check as suggested,developer2,2024-01-17T11:00:00.000+00:00,jkl012,NORMAL,,COMMENTED +github:GithubPrComment:1:5005,"{""ConnectionId"":1}",_raw_github_api_comments,5,,github:GithubPullRequest:1:1003,Summary by CodeRabbit. Adding Redis caching layer. Complexity is Moderate at 25 minutes. WARNING about security.,coderabbitai,2024-01-20T11:30:00.000+00:00,stu901,REVIEW,review-5,COMMENTED diff --git a/backend/plugins/aireview/e2e/raw_tables/pull_request_comments_gemini.csv b/backend/plugins/aireview/e2e/raw_tables/pull_request_comments_gemini.csv new file mode 100644 index 00000000000..4ae658a05a6 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/pull_request_comments_gemini.csv @@ -0,0 +1,3 @@ +id,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,pull_request_id,body,account_id,created_date,commit_sha,type,review_id,status +github:GithubPrComment:1:3661243217,"{""ConnectionId"":1}",_raw_github_api_comments,10,,github:GithubPullRequest:1:15147,"## Summary of Changes\n\nHello @ayushsaksena30, I'm Gemini Code Assist! I'm currently reviewing this pull request.\n\nThis pull request initiates the implementation of native Git integration for the Gemini CLI, enhancing the AI agent's ability to manage code repositories.\n\n### Highlights\n\n* **Native Git Integration**: Introduces native Git integration capabilities.\n* **New get_git_status Tool**: Adds the get_git_status tool to query repository status.",gemini-code-assist[bot],2025-12-16T15:53:54.000+00:00,,NORMAL,,COMMENTED +github:GithubPrComment:1:2623871161,"{""ConnectionId"":1}",_raw_github_api_comments,11,,github:GithubPullRequest:1:15147,"![critical](https://www.gstatic.com/codereviewagent/critical.svg)\n\nThe current logic for displaying the remote repository status is flawed. It checks if ahead or behind is undefined when no tracking branch is set. This is a critical bug in how remote status is reported.",gemini-code-assist[bot],2025-12-16T16:00:59.000+00:00,,REVIEW,3583846291,COMMENTED diff --git a/backend/plugins/aireview/e2e/raw_tables/pull_requests.csv b/backend/plugins/aireview/e2e/raw_tables/pull_requests.csv new file mode 100644 index 00000000000..ff1e348cd05 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/pull_requests.csv @@ -0,0 +1,4 @@ +id,base_repo_id,head_repo_id,status,original_status,title,description,url,author_name,author_id,parent_pr_id,pull_request_key,created_date,merged_date,closed_date,type,component,merge_commit_sha,head_ref,base_ref,base_commit_sha,head_commit_sha,additions,deletions,is_draft,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark +github:GithubPullRequest:1:1001,github:GithubRepo:1:100,github:GithubRepo:1:100,MERGED,closed,Add user authentication,Implements JWT auth,https://github.com/test/repo/pull/1,developer1,github:GithubAccount:1:201,,1,2024-01-15T10:00:00.000+00:00,2024-01-16T14:30:00.000+00:00,,feature,auth,abc123,feature/auth,main,def456,ghi789,50,10,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,1, +github:GithubPullRequest:1:1002,github:GithubRepo:1:100,github:GithubRepo:1:100,MERGED,closed,Fix login bug,Fixes null pointer,https://github.com/test/repo/pull/2,developer2,github:GithubAccount:1:202,,2,2024-01-17T09:00:00.000+00:00,2024-01-17T16:00:00.000+00:00,,bugfix,auth,jkl012,fix/login,main,mno345,pqr678,20,5,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,2, +github:GithubPullRequest:1:1003,github:GithubRepo:1:100,github:GithubRepo:1:100,OPEN,open,Add caching layer,Implements Redis caching,https://github.com/test/repo/pull/3,developer1,github:GithubAccount:1:201,,3,2024-01-20T11:00:00.000+00:00,,,feature,cache,,feature/cache,main,stu901,vwx234,100,20,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,3, diff --git a/backend/plugins/aireview/e2e/raw_tables/pull_requests_gemini.csv b/backend/plugins/aireview/e2e/raw_tables/pull_requests_gemini.csv new file mode 100644 index 00000000000..2d8b2fd570d --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/pull_requests_gemini.csv @@ -0,0 +1,2 @@ +id,base_repo_id,head_repo_id,status,original_status,title,description,url,author_name,author_id,parent_pr_id,pull_request_key,created_date,merged_date,closed_date,type,component,merge_commit_sha,head_ref,base_ref,base_commit_sha,head_commit_sha,additions,deletions,is_draft,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark +github:GithubPullRequest:1:15147,github:GithubRepo:1:200,github:GithubRepo:1:200,OPEN,open,feat: native git integration,Implements get_git_status tool for native Git integration,https://github.com/google-gemini/gemini-cli/pull/15147,ayushsaksena30,github:GithubAccount:1:999,,15147,2025-12-16T15:50:00.000+00:00,,,feature,,,feature/git-status,main,,,200,50,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,10, diff --git a/backend/plugins/aireview/e2e/raw_tables/suggestion_pull_request_comments.csv b/backend/plugins/aireview/e2e/raw_tables/suggestion_pull_request_comments.csv new file mode 100644 index 00000000000..ae877023be9 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/suggestion_pull_request_comments.csv @@ -0,0 +1,4 @@ +id,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark,pull_request_id,body,account_id,created_date,commit_sha,type,review_id,status +github:GithubPrComment:1:6001,"{""ConnectionId"":1}",_raw_github_api_comments,101,,github:GithubPullRequest:1:2001,"Summary by CodeRabbit. Walkthrough: This PR adds error handling.\n\n**Tracking**\n| Thread | File | Action | Status |\n|--------|------|--------|--------|\n| 1 | api/handler.go:15 | Committable | ✅ Resolved |\n| 2 | api/handler.go:42 | Committable | ✅ Resolved |\n| 3 | api/middleware.go:8 | Suggestion | Pending |\n\n2 suggestions applied. Risk level: medium.",coderabbitai,2024-02-01T11:00:00.000+00:00,abc123,REVIEW,review-10,COMMENTED +github:GithubPrComment:1:6002,"{""ConnectionId"":1}",_raw_github_api_comments,102,,github:GithubPullRequest:1:2002,"PR Reviewer Guide by Qodo\n\nEstimated effort to review: 3\n\n**Suggestions**\n- [x] **Use constants for config keys** - Replace magic strings with constants\n- [ ] **Add validation for port range** - Validate port is between 1-65535\n- [x] **Extract default values** - Move defaults to a separate block\n- [ ] **Add config file documentation** - Document available config options\n\nNo security concerns identified.",qodo-merge-pro[bot],2024-02-03T10:00:00.000+00:00,jkl012,REVIEW,review-11,COMMENTED +github:GithubPrComment:1:6003,"{""ConnectionId"":1}",_raw_github_api_comments,103,,github:GithubPullRequest:1:2003,"Summary by CodeRabbit. Walkthrough: Adding logging middleware.\n\nSuggestion: consider using structured logging. Risk level: low. Simple change at 5 minutes.",coderabbitai,2024-02-05T12:00:00.000+00:00,stu901,REVIEW,review-12,COMMENTED diff --git a/backend/plugins/aireview/e2e/raw_tables/suggestion_pull_requests.csv b/backend/plugins/aireview/e2e/raw_tables/suggestion_pull_requests.csv new file mode 100644 index 00000000000..167a80f76c1 --- /dev/null +++ b/backend/plugins/aireview/e2e/raw_tables/suggestion_pull_requests.csv @@ -0,0 +1,4 @@ +id,base_repo_id,head_repo_id,status,original_status,title,description,url,author_name,author_id,parent_pr_id,pull_request_key,created_date,merged_date,closed_date,type,component,merge_commit_sha,head_ref,base_ref,base_commit_sha,head_commit_sha,additions,deletions,is_draft,_raw_data_params,_raw_data_table,_raw_data_id,_raw_data_remark +github:GithubPullRequest:1:2001,github:GithubRepo:1:200,github:GithubRepo:1:200,MERGED,closed,Add error handling,Adds error handling to API,https://github.com/test/repo/pull/10,developer1,github:GithubAccount:1:201,,10,2024-02-01T10:00:00.000+00:00,2024-02-02T14:30:00.000+00:00,,feature,api,abc123,feature/errors,main,def456,ghi789,80,15,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,10, +github:GithubPullRequest:1:2002,github:GithubRepo:1:200,github:GithubRepo:1:200,MERGED,closed,Refactor config loading,Simplifies config,https://github.com/test/repo/pull/11,developer2,github:GithubAccount:1:202,,11,2024-02-03T09:00:00.000+00:00,2024-02-03T16:00:00.000+00:00,,refactor,config,jkl012,refactor/config,main,mno345,pqr678,40,30,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,11, +github:GithubPullRequest:1:2003,github:GithubRepo:1:200,github:GithubRepo:1:200,OPEN,open,Add logging middleware,Structured logging,https://github.com/test/repo/pull/12,developer1,github:GithubAccount:1:201,,12,2024-02-05T11:00:00.000+00:00,,,feature,middleware,,feature/logging,main,stu901,vwx234,60,10,0,"{""ConnectionId"":1}",_raw_github_api_pull_requests,12, diff --git a/backend/plugins/aireview/e2e/snapshot_tables/_tool_aireview_reviews.csv b/backend/plugins/aireview/e2e/snapshot_tables/_tool_aireview_reviews.csv new file mode 100644 index 00000000000..798fc106c7a --- /dev/null +++ b/backend/plugins/aireview/e2e/snapshot_tables/_tool_aireview_reviews.csv @@ -0,0 +1,4 @@ +id,pull_request_id,repo_id,ai_tool,ai_tool_user,review_id,risk_level,risk_score,issues_found,suggestions_count,effort_complexity,effort_minutes,review_state,source_platform +aireview:*,github:GithubPullRequest:1:1001,github:GithubRepo:1:100,coderabbit,coderabbitai,github:GithubPrComment:1:5001,low,20,0,2,simple,10,commented,github +aireview:*,github:GithubPullRequest:1:1002,github:GithubRepo:1:100,coderabbit,coderabbitai,github:GithubPrComment:1:5003,high,80,2,1,,0,changes_requested,github +aireview:*,github:GithubPullRequest:1:1003,github:GithubRepo:1:100,coderabbit,coderabbitai,github:GithubPrComment:1:5005,medium,50,0,2,moderate,25,commented,github diff --git a/backend/plugins/aireview/impl/impl.go b/backend/plugins/aireview/impl/impl.go new file mode 100644 index 00000000000..157e070ee2c --- /dev/null +++ b/backend/plugins/aireview/impl/impl.go @@ -0,0 +1,271 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import ( + "encoding/json" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/aireview/api" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/apache/devlake/plugins/aireview/models/migrationscripts" + "github.com/apache/devlake/plugins/aireview/tasks" +) + +// Verify interface implementation +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginTask + plugin.PluginModel + plugin.PluginMetric + plugin.PluginMigration + plugin.PluginApi + plugin.MetricPluginBlueprintV200 +} = (*AiReview)(nil) + +// AiReview is the main plugin struct +type AiReview struct{} + +// Init initializes the plugin +func (p AiReview) Init(basicRes context.BasicRes) errors.Error { + api.Init(basicRes, p) + return nil +} + +func (p AiReview) Description() string { + return "Extract and analyze AI-generated code reviews from pull requests to calculate AI predicted failure metrics" +} + +func (p AiReview) Name() string { + return "aireview" +} + +func (p AiReview) RequiredDataEntities() (data []map[string]interface{}, err errors.Error) { + return []map[string]interface{}{ + { + "model": "pull_requests", + "requiredFields": map[string]string{ + "id": "string", + "status": "string", + }, + }, + { + "model": "pull_request_comments", + "requiredFields": map[string]string{ + "id": "string", + "pull_request_id": "string", + "body": "string", + }, + }, + { + "model": "commits", + "requiredFields": map[string]string{ + "sha": "string", + "message": "string", + }, + }, + }, nil +} + +func (p AiReview) GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + &models.AiReview{}, + &models.AiCommit{}, + &models.AiReviewFinding{}, + &models.AiFailurePrediction{}, + &models.AiPredictionMetrics{}, + &models.AiReviewScopeConfig{}, + } +} + +func (p AiReview) IsProjectMetric() bool { + return true +} + +func (p AiReview) RunAfter() ([]string, errors.Error) { + // Run after GitHub or GitLab plugins have collected PR data + return []string{"github", "gitlab", "gitextractor"}, nil +} + +func (p AiReview) Settings() interface{} { + return nil +} + +func (p AiReview) SubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{ + tasks.ExtractAiReviewsMeta, + tasks.ExtractAiCommitsMeta, + tasks.EnrichGithubReviewReactionsMeta, + tasks.EnrichGitlabReviewReactionsMeta, + tasks.ExtractAiReviewFindingsMeta, + tasks.ConvertAiReviewsMeta, + tasks.ConvertAiCommitsMeta, + tasks.MatchSuggestionDiffsMeta, + tasks.FetchMissingCiJobsMeta, + tasks.CalculateFailurePredictionsMeta, + tasks.ConvertFailurePredictionsMeta, + tasks.CalculatePredictionMetricsMeta, + tasks.ConvertPredictionMetricsMeta, + } +} + +func (p AiReview) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) { + logger := taskCtx.GetLogger() + logger.Debug("Preparing AI Review task data: %v", options) + + op, err := tasks.DecodeTaskOptions(options) + if err != nil { + return nil, err + } + + err = tasks.ValidateTaskOptions(op) + if err != nil { + return nil, err + } + + // Load scope config if ID is provided + if op.ScopeConfig == nil && op.ScopeConfigId != 0 { + var scopeConfig models.AiReviewScopeConfig + db := taskCtx.GetDal() + dbErr := db.First(&scopeConfig, dal.Where("id = ?", op.ScopeConfigId)) + if dbErr != nil && !db.IsErrorNotFound(dbErr) { + return nil, errors.BadInput.Wrap(dbErr, "failed to get scopeConfig") + } + op.ScopeConfig = &scopeConfig + } + + // Use default scope config if none provided + if op.ScopeConfig == nil { + op.ScopeConfig = models.GetDefaultScopeConfig() + } + + taskData := &tasks.AiReviewTaskData{ + Options: op, + } + + // Compile regex patterns + err = tasks.CompilePatterns(taskData) + if err != nil { + return nil, err + } + + return taskData, nil +} + +func (p AiReview) RootPkgPath() string { + return "github.com/apache/devlake/plugins/aireview" +} + +func (p AiReview) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{ + "reviews": { + "GET": api.GetReviews, + }, + "reviews/:id": { + "GET": api.GetReview, + }, + "stats": { + "GET": api.GetReviewStats, + }, + "findings": { + "GET": api.GetFindings, + }, + "scope-configs": { + "GET": api.GetScopeConfigs, + "POST": api.CreateScopeConfig, + }, + "scope-configs/default": { + "GET": api.GetDefaultScopeConfig, + }, + "scope-configs/:id": { + "GET": api.GetScopeConfig, + "PATCH": api.UpdateScopeConfig, + "DELETE": api.DeleteScopeConfig, + }, + "analyze": { + "POST": api.GenerateAnalysisPipeline, + }, + } +} + +func (p AiReview) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +// DecodeAndValidateTaskOptions is a helper for the API +func DecodeAndValidateTaskOptions(options map[string]interface{}) (*tasks.AiReviewOptions, errors.Error) { + op, err := tasks.DecodeTaskOptions(options) + if err != nil { + return nil, err + } + err = helper.DecodeMapStruct(options, op, true) + if err != nil { + return nil, err + } + return op, nil +} + +// MakeMetricPluginPipelinePlanV200 generates pipeline plan for project metrics +func (p AiReview) MakeMetricPluginPipelinePlanV200(projectName string, options json.RawMessage) (coreModels.PipelinePlan, errors.Error) { + op := &tasks.AiReviewOptions{} + if options != nil && string(options) != "\"\"" { + err := json.Unmarshal(options, op) + if err != nil { + return nil, errors.Default.WrapRaw(err) + } + } + + // Build options map with projectName + opts := map[string]interface{}{ + "projectName": projectName, + } + if op.ScopeConfigId != 0 { + opts["scopeConfigId"] = op.ScopeConfigId + } + + plan := coreModels.PipelinePlan{ + { + { + Plugin: "aireview", + Options: opts, + Subtasks: []string{ + tasks.ExtractAiReviewsMeta.Name, + tasks.ExtractAiCommitsMeta.Name, + tasks.EnrichGithubReviewReactionsMeta.Name, + tasks.EnrichGitlabReviewReactionsMeta.Name, + tasks.ExtractAiReviewFindingsMeta.Name, + tasks.ConvertAiReviewsMeta.Name, + tasks.ConvertAiCommitsMeta.Name, + tasks.MatchSuggestionDiffsMeta.Name, + tasks.FetchMissingCiJobsMeta.Name, + tasks.CalculateFailurePredictionsMeta.Name, + tasks.ConvertFailurePredictionsMeta.Name, + tasks.CalculatePredictionMetricsMeta.Name, + tasks.ConvertPredictionMetricsMeta.Name, + }, + }, + }, + } + return plan, nil +} diff --git a/backend/plugins/aireview/models/ai_commit.go b/backend/plugins/aireview/models/ai_commit.go new file mode 100644 index 00000000000..d527de93753 --- /dev/null +++ b/backend/plugins/aireview/models/ai_commit.go @@ -0,0 +1,41 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +// AiCommit is a tool-layer record for a commit classified as AI-assisted. +// Only AI-positive commits are stored (sparse). +type AiCommit struct { + common.NoPKModel + + Id string `gorm:"primaryKey;type:varchar(255)"` + CommitSha string `gorm:"index;type:varchar(40)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + AuthorName string `gorm:"type:varchar(255)"` + AuthoredDate time.Time `gorm:"index"` +} + +func (AiCommit) TableName() string { + return "_tool_aireview_commits" +} diff --git a/backend/plugins/aireview/models/ai_failure_prediction.go b/backend/plugins/aireview/models/ai_failure_prediction.go new file mode 100644 index 00000000000..74176bbf319 --- /dev/null +++ b/backend/plugins/aireview/models/ai_failure_prediction.go @@ -0,0 +1,172 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +// AiFailurePrediction tracks the accuracy of AI predictions vs actual outcomes +// This is used to calculate the "AI Predicted Failure Avoidance" metric +type AiFailurePrediction struct { + common.NoPKModel + + // Primary key + Id string `gorm:"primaryKey;type:varchar(255)"` + + // Foreign key to pull_requests domain table + PullRequestId string `gorm:"index;type:varchar(255)"` + + // PR number (pull_requests.pull_request_key) — used to join with ci_test_jobs.pull_request_number + PullRequestKey string `gorm:"index;type:varchar(255)"` + + // Repository reference + RepoId string `gorm:"index;type:varchar(255)"` + + // Short repo name (after '/') — used to join with ci_test_jobs.repository + RepoShortName string `gorm:"type:varchar(255)"` + + // Full org/repo name — for display in dashboards + RepoName string `gorm:"type:varchar(255)"` + + // AI tool that made the prediction + AiTool string `gorm:"type:varchar(100)"` + + // Which CI data source was used: "test_cases", "job_result", or "none" (NO_CI records) + CiFailureSource string `gorm:"type:varchar(20);index"` + + // PR display metadata — denormalised for performant dashboard drill-down + PrTitle string `gorm:"type:varchar(500)"` + PrUrl string `gorm:"type:varchar(1024)"` + PrAuthor string `gorm:"type:varchar(255)"` + PrCreatedAt time.Time + Additions int + Deletions int + + // Prediction data + WasFlaggedRisky bool // Did AI flag this PR as risky? + RiskScore int // Risk score assigned (0-100) + FlaggedAt time.Time // When AI made the assessment + + // Actual outcome data (tracked post-merge) + PrMergedAt *time.Time // When PR was merged + HadCiFailure bool // Did CI fail after merge? + CiFailureAt *time.Time // When CI failure occurred + HadBugReported bool // Was a bug reported within window? + BugReportedAt *time.Time // When bug was reported + BugIssueId string `gorm:"type:varchar(255)"` // Link to bug issue + HadRollback bool // Was the change rolled back? + RollbackAt *time.Time // When rollback occurred + + // Classification for confusion matrix + // TP: WasFlaggedRisky=true AND (HadCiFailure OR HadBugReported) + // FP: WasFlaggedRisky=true AND NOT (HadCiFailure OR HadBugReported) + // FN: WasFlaggedRisky=false AND (HadCiFailure OR HadBugReported) + // TN: WasFlaggedRisky=false AND NOT (HadCiFailure OR HadBugReported) + PredictionOutcome string `gorm:"type:varchar(20)"` // TP, FP, FN, TN, NO_CI + + // Time windows + ObservationWindowDays int // How many days after merge to track (default 14) + ObservationEndDate time.Time // When observation window ends + + // Metadata + CreatedAt time.Time + UpdatedAt *time.Time +} + +func (AiFailurePrediction) TableName() string { + return "_tool_aireview_failure_predictions" +} + +// Prediction outcome constants +const ( + PredictionTP = "TP" // True Positive: AI flagged, failure occurred + PredictionFP = "FP" // False Positive: AI flagged, no failure + PredictionFN = "FN" // False Negative: AI didn't flag, failure occurred + PredictionTN = "TN" // True Negative: AI didn't flag, no failure + PredictionNoCi = "NO_CI" // No CI data found for this PR in any source + + // CiSourceNone is the ci_failure_source value used for NO_CI prediction records. + // These records represent AI-reviewed PRs for which no matching CI pipeline data + // was found, so TP/FP/FN/TN classification is not possible. + CiSourceNone = "none" +) + +// AiPredictionMetrics stores aggregated prediction metrics for reporting +type AiPredictionMetrics struct { + common.NoPKModel + + // Primary key + Id string `gorm:"primaryKey;type:varchar(255)"` + + // Scope + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + CiFailureSource string `gorm:"type:varchar(20);index"` + + // Time period + PeriodStart time.Time `gorm:"index"` + PeriodEnd time.Time + PeriodType string `gorm:"type:varchar(20)"` // daily, weekly, monthly + + // Confusion matrix counts + TruePositives int + FalsePositives int + FalseNegatives int + TrueNegatives int + + // Calculated metrics + Precision float64 // TP / (TP + FP) + Recall float64 // TP / (TP + FN) + Accuracy float64 // (TP + TN) / Total + F1Score float64 // 2 * (Precision * Recall) / (Precision + Recall) + Specificity float64 // TN / (TN + FP) + FprPct float64 // FP / (FP + TN) × 100 + + // Area under curve metrics (computed at thresholds 0, 10, 20, 50, 80, 100) + PrAuc float64 // Precision-Recall AUC + RocAuc float64 // ROC AUC + + // The warning_threshold used when computing confusion matrix for this record + WarningThreshold int + + // Sample sizes + TotalPrs int + FlaggedPrs int + FailedPrs int + ObservedPrs int // PRs that completed observation window + + // Thresholds and recommendations + RecommendedAutonomyLevel string `gorm:"type:varchar(50)"` // auto_block, mandatory_review, advisory_only + + // Timestamps + CalculatedAt time.Time +} + +func (AiPredictionMetrics) TableName() string { + return "_tool_aireview_prediction_metrics" +} + +// Autonomy level constants +const ( + AutonomyAutoBlock = "auto_block" // Precision > 80%, Recall > 70% + AutonomyMandatoryReview = "mandatory_review" // Precision 60-80%, Recall 50-70% + AutonomyAdvisoryOnly = "advisory_only" // Precision < 60%, Recall < 50% +) diff --git a/backend/plugins/aireview/models/ai_review.go b/backend/plugins/aireview/models/ai_review.go new file mode 100644 index 00000000000..f92618707ea --- /dev/null +++ b/backend/plugins/aireview/models/ai_review.go @@ -0,0 +1,121 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +// AiReview represents an AI-generated code review on a pull request +type AiReview struct { + common.NoPKModel + + // Primary key fields + Id string `gorm:"primaryKey;type:varchar(255)"` + + // Foreign key to pull_requests domain table + PullRequestId string `gorm:"index;type:varchar(255)"` + + // Repository reference + RepoId string `gorm:"index;type:varchar(255)"` + + // AI tool information + AiTool string `gorm:"type:varchar(100)"` // coderabbit, cursor_bugbot, etc. + AiToolUser string `gorm:"type:varchar(255)"` // Bot username + + // Review metadata + ReviewId string `gorm:"type:varchar(255)"` // Original review/comment ID from source + Body string `gorm:"type:longtext"` // Full review body + Summary string `gorm:"type:text"` // AI-generated summary if available + CreatedDate time.Time `gorm:"index"` + UpdatedDate *time.Time + + // Risk assessment + RiskLevel string `gorm:"type:varchar(50)"` // low, medium, high, critical + RiskScore int // 0-100 risk score + RiskConfidence int // 0-100 confidence level + + // Metrics + IssuesFound int // Number of issues identified + SuggestionsCount int // Number of suggestions made + FilesReviewed int // Number of files reviewed + LinesReviewed int // Lines of code reviewed + + // Effort estimation (from CodeRabbit/Qodo) + EffortComplexity string `gorm:"type:varchar(50)"` // simple, moderate, complex + EffortRating int // 1-5 scale numeric rating (e.g., from "🎯 3 (Moderate)") + EffortMinutes int // Estimated review time in minutes + + // Pre-merge check results (from CodeRabbit) + PreMergeChecksPassed int `gorm:"default:0"` // Number of checks passed + PreMergeChecksFailed int `gorm:"default:0"` // Number of checks failed + PreMergeChecksInconclusive int `gorm:"default:0"` // Number of inconclusive checks + + // Suggestion acceptance tracking + SuggestionsAccepted int `gorm:"default:0"` // Number of suggestions accepted/applied by developer (marker-based) + SuggestionsDiffAccepted int `gorm:"default:0"` // Count of suggestions with diff-based match + SuggestionsDiffAcceptPct float64 `gorm:"default:0"` // Average acceptance % across matched suggestions + + // Developer reactions to the AI review comment + ReactionsTotalCount int `gorm:"default:0"` + ReactionsThumbsUp int `gorm:"default:0"` + ReactionsThumbsDown int `gorm:"default:0"` + + // Review outcome + ReviewState string `gorm:"type:varchar(50)"` // approved, changes_requested, commented + + // Source information + SourcePlatform string `gorm:"type:varchar(50)"` // github, gitlab + SourceUrl string `gorm:"type:varchar(500)"` +} + +func (AiReview) TableName() string { + return "_tool_aireview_reviews" +} + +// AI tool type constants +const ( + AiToolCodeRabbit = "coderabbit" + AiToolCursorBugbot = "cursor_bugbot" + AiToolCursor = "cursor" + AiToolClaude = "claude" + AiToolQodo = "qodo" + AiToolGemini = "gemini" + AiToolSonarQube = "sonarqube" + AiToolCopilot = "copilot" + AiToolAssistedByUnknown = "assisted_by_unknown" + AiToolMadeWithUnknown = "made_with_unknown" + AiToolOther = "other" +) + +// Risk level constants +const ( + RiskLevelLow = "low" + RiskLevelMedium = "medium" + RiskLevelHigh = "high" + RiskLevelCritical = "critical" +) + +// Review state constants +const ( + ReviewStateApproved = "approved" + ReviewStateChangesRequested = "changes_requested" + ReviewStateCommented = "commented" +) diff --git a/backend/plugins/aireview/models/ai_review_finding.go b/backend/plugins/aireview/models/ai_review_finding.go new file mode 100644 index 00000000000..e4489d7ceec --- /dev/null +++ b/backend/plugins/aireview/models/ai_review_finding.go @@ -0,0 +1,122 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +// AiReviewFinding represents a specific issue or suggestion from an AI review +type AiReviewFinding struct { + common.NoPKModel + + // Primary key + Id string `gorm:"primaryKey;type:varchar(255)"` + + // Foreign key to AiReview + AiReviewId string `gorm:"index;type:varchar(255)"` + + // Foreign key to pull_requests domain table + PullRequestId string `gorm:"index;type:varchar(255)"` + + // Repository reference + RepoId string `gorm:"index;type:varchar(255)"` + + // AI tool information + AiTool string `gorm:"type:varchar(100)"` + + // Finding classification + Category string `gorm:"type:varchar(100)"` // security, performance, best_practice, bug, style + Severity string `gorm:"type:varchar(50)"` // info, warning, error, critical + Type string `gorm:"type:varchar(100)"` // suggestion, issue, comment + + // Finding details + Title string `gorm:"type:varchar(500)"` + Description string `gorm:"type:text"` + FilePath string `gorm:"type:varchar(500)"` + LineStart int + LineEnd int + CommitSha string `gorm:"type:varchar(255)"` + + // Code context + CodeSnippet string `gorm:"type:text"` // Original code + SuggestedCode string `gorm:"type:text"` // Suggested fix + SuggestionApplied bool // Whether the suggestion was applied (marker-based) + + // Diff-based suggestion matching + SuggestionDiffMatched bool // Whether diff-based matching found a match + SuggestionMatchMethod string `gorm:"type:varchar(50)"` // marker, diff_commit_msg, diff_file_temporal, diff_line_pct, or "" + SuggestionMatchScore float64 // 0.0-100.0 percentage of non-trivial suggested lines found in the commit diff + SuggestionLinesMatched int // Number of non-trivial suggested lines found in commit diff + SuggestionLinesTotal int // Total non-trivial lines in the suggestion + MatchedCommitSha string `gorm:"type:varchar(40)"` // Commit SHA that applied the suggestion + MatchedFilePath string `gorm:"type:varchar(500)"` // File path resolved from raw data + + // Resolution tracking + IsResolved bool + ResolvedAt *time.Time + ResolvedBy string `gorm:"type:varchar(255)"` + Resolution string `gorm:"type:varchar(100)"` // fixed, wont_fix, false_positive + ResponseTime int // Minutes to resolution + + // Timestamps + CreatedDate time.Time `gorm:"index"` + + // Source information + SourceCommentId string `gorm:"type:varchar(255)"` +} + +func (AiReviewFinding) TableName() string { + return "_tool_aireview_findings" +} + +// Category constants +const ( + FindingCategorySecurity = "security" + FindingCategoryPerformance = "performance" + FindingCategoryBestPractice = "best_practice" + FindingCategoryBug = "bug" + FindingCategoryStyle = "style" + FindingCategoryDocumentation = "documentation" + FindingCategoryMaintainability = "maintainability" +) + +// Severity constants +const ( + FindingSeverityInfo = "info" + FindingSeverityWarning = "warning" + FindingSeverityError = "error" + FindingSeverityCritical = "critical" +) + +// Finding type constants +const ( + FindingTypeSuggestion = "suggestion" + FindingTypeIssue = "issue" + FindingTypeComment = "comment" + FindingTypeApproval = "approval" +) + +// Resolution constants +const ( + ResolutionFixed = "fixed" + ResolutionWontFix = "wont_fix" + ResolutionFalsePositive = "false_positive" +) diff --git a/backend/plugins/aireview/models/migrationscripts/20260130_add_effort_and_checks.go b/backend/plugins/aireview/models/migrationscripts/20260130_add_effort_and_checks.go new file mode 100644 index 00000000000..d5362cd526a --- /dev/null +++ b/backend/plugins/aireview/models/migrationscripts/20260130_add_effort_and_checks.go @@ -0,0 +1,60 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addEffortAndChecks)(nil) + +type addEffortAndChecks struct{} + +func (script *addEffortAndChecks) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + // Add effort_rating column to _tool_aireview_reviews + err := db.AutoMigrate(&aiReviewAddColumns20260130{}) + if err != nil { + return errors.Default.Wrap(err, "failed to add new columns to _tool_aireview_reviews") + } + + return nil +} + +func (script *addEffortAndChecks) Version() uint64 { + return 20260130000001 +} + +func (script *addEffortAndChecks) Name() string { + return "aireview add effort rating and pre-merge check columns" +} + +// aiReviewAddColumns20260130 represents the table structure with new columns +type aiReviewAddColumns20260130 struct { + EffortRating int `gorm:"default:0"` + PreMergeChecksPassed int `gorm:"default:0"` + PreMergeChecksFailed int `gorm:"default:0"` + PreMergeChecksInconclusive int `gorm:"default:0"` +} + +func (aiReviewAddColumns20260130) TableName() string { + return "_tool_aireview_reviews" +} diff --git a/backend/plugins/aireview/models/migrationscripts/20260219_add_gemini_config.go b/backend/plugins/aireview/models/migrationscripts/20260219_add_gemini_config.go new file mode 100644 index 00000000000..5fdbf0a0666 --- /dev/null +++ b/backend/plugins/aireview/models/migrationscripts/20260219_add_gemini_config.go @@ -0,0 +1,59 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addGeminiConfig)(nil) + +type addGeminiConfig struct{} + +func (script *addGeminiConfig) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + // Add Gemini config columns to _tool_aireview_scope_configs + err := db.AutoMigrate(&scopeConfigAddGemini20260219{}) + if err != nil { + return errors.Default.Wrap(err, "failed to add Gemini columns to _tool_aireview_scope_configs") + } + + return nil +} + +func (script *addGeminiConfig) Version() uint64 { + return 20260219000001 +} + +func (script *addGeminiConfig) Name() string { + return "aireview add Gemini Code Assist configuration" +} + +// scopeConfigAddGemini20260219 represents the table structure with new Gemini columns +type scopeConfigAddGemini20260219 struct { + GeminiEnabled bool `gorm:"type:boolean;default:true"` + GeminiUsername string `gorm:"type:varchar(255)"` + GeminiPattern string `gorm:"type:varchar(500)"` +} + +func (scopeConfigAddGemini20260219) TableName() string { + return "_tool_aireview_scope_configs" +} diff --git a/backend/plugins/aireview/models/migrationscripts/20260219_add_reactions.go b/backend/plugins/aireview/models/migrationscripts/20260219_add_reactions.go new file mode 100644 index 00000000000..61a6c007c1a --- /dev/null +++ b/backend/plugins/aireview/models/migrationscripts/20260219_add_reactions.go @@ -0,0 +1,57 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addReactions)(nil) + +type addReactions struct{} + +func (script *addReactions) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + err := db.AutoMigrate(&aiReviewAddReactions20260219{}) + if err != nil { + return errors.Default.Wrap(err, "failed to add reaction columns to _tool_aireview_reviews") + } + + return nil +} + +func (script *addReactions) Version() uint64 { + return 20260219000002 +} + +func (script *addReactions) Name() string { + return "aireview add developer reaction columns" +} + +type aiReviewAddReactions20260219 struct { + ReactionsTotalCount int `gorm:"default:0"` + ReactionsThumbsUp int `gorm:"default:0"` + ReactionsThumbsDown int `gorm:"default:0"` +} + +func (aiReviewAddReactions20260219) TableName() string { + return "_tool_aireview_reviews" +} diff --git a/backend/plugins/aireview/models/migrationscripts/20260402_add_ci_prediction_support.go b/backend/plugins/aireview/models/migrationscripts/20260402_add_ci_prediction_support.go new file mode 100644 index 00000000000..415f17628d6 --- /dev/null +++ b/backend/plugins/aireview/models/migrationscripts/20260402_add_ci_prediction_support.go @@ -0,0 +1,127 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addCiPredictionSupport)(nil) + +type addCiPredictionSupport struct{} + +// Up adds all CI failure prediction support columns in one step. +// +// Consolidates what were previously five incremental migrations +// (20260330, 20260331, 20260401, 20260402x1, 20260402x2) into a single +// idempotent migration. Safe to run on databases that already have some +// or all of these columns — AutoMigrate only adds missing ones. +// +// _tool_aireview_scope_configs gains: +// - warning_threshold int (default 50) — risk score cutoff for TP/FP/FN/TN +// - ci_failure_source varchar(20) — "test_cases", "job_result", or "both" +// - ci_backfill_enabled bool — fetch historical CI data from GCS +// - ci_backfill_days int (default 180) +// +// _tool_aireview_failure_predictions gains: +// - pull_request_key varchar(255) index — PR number for CI join +// - repo_short_name varchar(255) — after '/' for CI repo matching +// - ci_failure_source varchar(20) index — which source produced this record +// - repo_name varchar(255) — full org/repo for display +// - pr_title varchar(500) — denormalised for drill-down +// - pr_url varchar(1024) — link to the PR +// - pr_author varchar(255) +// - pr_created_at datetime +// - additions bigint +// - deletions bigint +// +// _tool_aireview_prediction_metrics gains: +// - specificity float64 +// - fpr_pct float64 +// - pr_auc float64 +// - roc_auc float64 +// - warning_threshold int +// - ci_failure_source varchar(20) index +func (script *addCiPredictionSupport) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + if err := db.AutoMigrate(&scopeConfigCiPrediction20260402{}); err != nil { + return errors.Default.Wrap(err, "failed to migrate _tool_aireview_scope_configs for CI prediction support") + } + if err := db.AutoMigrate(&failurePredictionCiSupport20260402{}); err != nil { + return errors.Default.Wrap(err, "failed to migrate _tool_aireview_failure_predictions for CI prediction support") + } + if err := db.AutoMigrate(&predictionMetricsCiSupport20260402{}); err != nil { + return errors.Default.Wrap(err, "failed to migrate _tool_aireview_prediction_metrics for CI prediction support") + } + + return nil +} + +func (script *addCiPredictionSupport) Version() uint64 { + return 20260402000003 +} + +func (script *addCiPredictionSupport) Name() string { + return "aireview add CI failure prediction support (scope config, predictions, metrics)" +} + +type scopeConfigCiPrediction20260402 struct { + WarningThreshold int `gorm:"default:50"` + CiFailureSource string `gorm:"type:varchar(20);default:'both'"` + CiBackfillEnabled bool `gorm:"type:boolean;default:false"` + CiBackfillDays int `gorm:"default:180"` +} + +func (scopeConfigCiPrediction20260402) TableName() string { + return "_tool_aireview_scope_configs" +} + +type failurePredictionCiSupport20260402 struct { + PullRequestKey string `gorm:"type:varchar(255);index"` + RepoShortName string `gorm:"type:varchar(255)"` + CiFailureSource string `gorm:"type:varchar(20);index"` + RepoName string `gorm:"type:varchar(255)"` + PrTitle string `gorm:"type:varchar(500)"` + PrUrl string `gorm:"type:varchar(1024)"` + PrAuthor string `gorm:"type:varchar(255)"` + PrCreatedAt time.Time `gorm:"column:pr_created_at"` + Additions int + Deletions int +} + +func (failurePredictionCiSupport20260402) TableName() string { + return "_tool_aireview_failure_predictions" +} + +type predictionMetricsCiSupport20260402 struct { + Specificity float64 + FprPct float64 + PrAuc float64 + RocAuc float64 + WarningThreshold int `gorm:"default:50"` + CiFailureSource string `gorm:"type:varchar(20);index"` +} + +func (predictionMetricsCiSupport20260402) TableName() string { + return "_tool_aireview_prediction_metrics" +} diff --git a/backend/plugins/aireview/models/migrationscripts/20260413_add_suggestions_accepted.go b/backend/plugins/aireview/models/migrationscripts/20260413_add_suggestions_accepted.go new file mode 100644 index 00000000000..fb24400d234 --- /dev/null +++ b/backend/plugins/aireview/models/migrationscripts/20260413_add_suggestions_accepted.go @@ -0,0 +1,53 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addSuggestionsAccepted)(nil) + +type addSuggestionsAccepted struct{} + +// Up adds suggestions_accepted column to _tool_aireview_reviews. +func (script *addSuggestionsAccepted) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + if err := db.AutoMigrate(&reviewSuggestionsAccepted20260413{}); err != nil { + return errors.Default.Wrap(err, "failed to migrate _tool_aireview_reviews for suggestions_accepted") + } + return nil +} + +func (script *addSuggestionsAccepted) Version() uint64 { + return 20260413000001 +} + +func (script *addSuggestionsAccepted) Name() string { + return "aireview add suggestions_accepted to reviews" +} + +type reviewSuggestionsAccepted20260413 struct { + SuggestionsAccepted int `gorm:"default:0"` +} + +func (reviewSuggestionsAccepted20260413) TableName() string { + return "_tool_aireview_reviews" +} diff --git a/backend/plugins/aireview/models/migrationscripts/20260414_add_diff_matching.go b/backend/plugins/aireview/models/migrationscripts/20260414_add_diff_matching.go new file mode 100644 index 00000000000..a2391fe8224 --- /dev/null +++ b/backend/plugins/aireview/models/migrationscripts/20260414_add_diff_matching.go @@ -0,0 +1,70 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addDiffMatching)(nil) + +type addDiffMatching struct{} + +func (script *addDiffMatching) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + if err := db.AutoMigrate(&findingDiffMatching20260414{}); err != nil { + return errors.Default.Wrap(err, "failed to migrate _tool_aireview_findings for diff matching") + } + if err := db.AutoMigrate(&reviewDiffAccepted20260414{}); err != nil { + return errors.Default.Wrap(err, "failed to migrate _tool_aireview_reviews for diff accepted") + } + return nil +} + +func (script *addDiffMatching) Version() uint64 { + return 20260414000001 +} + +func (script *addDiffMatching) Name() string { + return "aireview add diff-based suggestion matching fields" +} + +type findingDiffMatching20260414 struct { + SuggestionDiffMatched bool `gorm:"default:false"` + SuggestionMatchMethod string `gorm:"type:varchar(50)"` + SuggestionMatchScore float64 `gorm:"default:0"` + SuggestionLinesMatched int `gorm:"default:0"` + SuggestionLinesTotal int `gorm:"default:0"` + MatchedCommitSha string `gorm:"type:varchar(40)"` + MatchedFilePath string `gorm:"type:varchar(500)"` +} + +func (findingDiffMatching20260414) TableName() string { + return "_tool_aireview_findings" +} + +type reviewDiffAccepted20260414 struct { + SuggestionsDiffAccepted int `gorm:"default:0"` + SuggestionsDiffAcceptPct float64 `gorm:"default:0"` +} + +func (reviewDiffAccepted20260414) TableName() string { + return "_tool_aireview_reviews" +} diff --git a/backend/plugins/aireview/models/migrationscripts/20260415_add_flaky_infra_filters.go b/backend/plugins/aireview/models/migrationscripts/20260415_add_flaky_infra_filters.go new file mode 100644 index 00000000000..a43a4e87581 --- /dev/null +++ b/backend/plugins/aireview/models/migrationscripts/20260415_add_flaky_infra_filters.go @@ -0,0 +1,57 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addFlakyInfraFilters)(nil) + +type addFlakyInfraFilters struct{} + +// Up adds ExcludeFlakyTests and ExcludeInfraFailures columns to scope config. +// Both default to false (off) so existing behavior is preserved until explicitly enabled. +func (script *addFlakyInfraFilters) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + if err := db.AutoMigrate(&scopeConfigFlakyInfra20260415{}); err != nil { + return errors.Default.Wrap(err, "failed to migrate _tool_aireview_scope_configs for flaky/infra filters") + } + + return nil +} + +func (script *addFlakyInfraFilters) Version() uint64 { + return 20260415000001 +} + +func (script *addFlakyInfraFilters) Name() string { + return "aireview add flaky test and infrastructure failure filter toggles" +} + +type scopeConfigFlakyInfra20260415 struct { + ExcludeFlakyTests bool `gorm:"type:boolean;default:false"` + ExcludeInfraFailures bool `gorm:"type:boolean;default:false"` +} + +func (scopeConfigFlakyInfra20260415) TableName() string { + return "_tool_aireview_scope_configs" +} diff --git a/backend/plugins/aireview/models/migrationscripts/20260826_add_ai_commits.go b/backend/plugins/aireview/models/migrationscripts/20260826_add_ai_commits.go new file mode 100644 index 00000000000..a977021e310 --- /dev/null +++ b/backend/plugins/aireview/models/migrationscripts/20260826_add_ai_commits.go @@ -0,0 +1,65 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addAiCommits)(nil) + +type addAiCommits struct{} + +func (script *addAiCommits) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + if err := db.AutoMigrate(&aiCommit20260826{}); err != nil { + return errors.Default.Wrap(err, "failed to create _tool_aireview_commits") + } + return nil +} + +func (script *addAiCommits) Version() uint64 { + return 20260826000001 +} + +func (script *addAiCommits) Name() string { + return "aireview add _tool_aireview_commits" +} + +type aiCommit20260826 struct { + CreatedAt time.Time + UpdatedAt time.Time + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)"` + RawDataId uint64 `gorm:"column:_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark"` + Id string `gorm:"primaryKey;type:varchar(255)"` + CommitSha string `gorm:"index;type:varchar(40)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + AuthorName string `gorm:"type:varchar(255)"` + AuthoredDate time.Time `gorm:"index"` +} + +func (aiCommit20260826) TableName() string { + return "_tool_aireview_commits" +} diff --git a/backend/plugins/aireview/models/migrationscripts/init_schema.go b/backend/plugins/aireview/models/migrationscripts/init_schema.go new file mode 100644 index 00000000000..1c8a0f794f9 --- /dev/null +++ b/backend/plugins/aireview/models/migrationscripts/init_schema.go @@ -0,0 +1,223 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*initSchema)(nil) + +type initSchema struct{} + +type aiReview20260127 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + Id string `gorm:"primaryKey;type:varchar(255)"` + PullRequestId string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + AiToolUser string `gorm:"type:varchar(255)"` + ReviewId string `gorm:"type:varchar(255)"` + Body string `gorm:"type:longtext"` + Summary string `gorm:"type:text"` + CreatedDate time.Time + UpdatedDate *time.Time + RiskLevel string `gorm:"type:varchar(50)"` + RiskScore int + RiskConfidence int + IssuesFound int + SuggestionsCount int + FilesReviewed int + LinesReviewed int + EffortComplexity string `gorm:"type:varchar(50)"` + EffortMinutes int + ReviewState string `gorm:"type:varchar(50)"` + SourcePlatform string `gorm:"type:varchar(50)"` + SourceUrl string `gorm:"type:varchar(500)"` +} + +func (aiReview20260127) TableName() string { + return "_tool_aireview_reviews" +} + +type aiReviewFinding20260127 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + Id string `gorm:"primaryKey;type:varchar(255)"` + AiReviewId string `gorm:"index;type:varchar(255)"` + PullRequestId string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + Category string `gorm:"type:varchar(100)"` + Severity string `gorm:"type:varchar(50)"` + Type string `gorm:"type:varchar(100)"` + Title string `gorm:"type:varchar(500)"` + Description string `gorm:"type:text"` + FilePath string `gorm:"type:varchar(500)"` + LineStart int + LineEnd int + CommitSha string `gorm:"type:varchar(255)"` + CodeSnippet string `gorm:"type:text"` + SuggestedCode string `gorm:"type:text"` + SuggestionApplied bool + IsResolved bool + ResolvedAt *time.Time + ResolvedBy string `gorm:"type:varchar(255)"` + Resolution string `gorm:"type:varchar(100)"` + ResponseTime int + CreatedDate time.Time + SourceCommentId string `gorm:"type:varchar(255)"` +} + +func (aiReviewFinding20260127) TableName() string { + return "_tool_aireview_findings" +} + +type aiFailurePrediction20260127 struct { + // common.RawDataOrigin fields (from NoPKModel). + // CreatedAt/UpdatedAt from NoPKModel are omitted here because they are declared + // below with UpdatedAt as *time.Time, matching the original shadowing behavior. + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + Id string `gorm:"primaryKey;type:varchar(255)"` + PullRequestId string `gorm:"index;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + WasFlaggedRisky bool + RiskScore int + FlaggedAt time.Time + PrMergedAt *time.Time + HadCiFailure bool + CiFailureAt *time.Time + HadBugReported bool + BugReportedAt *time.Time + BugIssueId string `gorm:"type:varchar(255)"` + HadRollback bool + RollbackAt *time.Time + PredictionOutcome string `gorm:"type:varchar(20)"` + ObservationWindowDays int + ObservationEndDate time.Time + CreatedAt time.Time + UpdatedAt *time.Time +} + +func (aiFailurePrediction20260127) TableName() string { + return "_tool_aireview_failure_predictions" +} + +type aiPredictionMetrics20260127 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + Id string `gorm:"primaryKey;type:varchar(255)"` + RepoId string `gorm:"index;type:varchar(255)"` + AiTool string `gorm:"type:varchar(100)"` + PeriodStart time.Time + PeriodEnd time.Time + PeriodType string `gorm:"type:varchar(20)"` + TruePositives int + FalsePositives int + FalseNegatives int + TrueNegatives int + Precision float64 + Recall float64 + Accuracy float64 + F1Score float64 + TotalPrs int + FlaggedPrs int + FailedPrs int + ObservedPrs int + RecommendedAutonomyLevel string `gorm:"type:varchar(50)"` + CalculatedAt time.Time +} + +func (aiPredictionMetrics20260127) TableName() string { + return "_tool_aireview_prediction_metrics" +} + +type aiReviewScopeConfig20260127 struct { + // common.Model fields + ID uint64 `gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // common.ScopeConfig fields + Entities []string `gorm:"type:json;serializer:json" json:"entities"` + ConnectionId uint64 `json:"connectionId" gorm:"index"` + Name string `gorm:"type:varchar(255);uniqueIndex" json:"name"` + CodeRabbitEnabled bool `gorm:"type:boolean"` + CodeRabbitUsername string `gorm:"type:varchar(255)"` + CodeRabbitPattern string `gorm:"type:varchar(500)"` + CursorBugbotEnabled bool `gorm:"type:boolean"` + CursorBugbotUsername string `gorm:"type:varchar(255)"` + CursorBugbotPattern string `gorm:"type:varchar(500)"` + QodoEnabled bool `gorm:"type:boolean"` + QodoUsername string `gorm:"type:varchar(255)"` + QodoPattern string `gorm:"type:varchar(500)"` + AiCommitPatterns string `gorm:"type:text"` + AiPrLabelPattern string `gorm:"type:varchar(500)"` + RiskHighPattern string `gorm:"type:varchar(500)"` + RiskMediumPattern string `gorm:"type:varchar(500)"` + RiskLowPattern string `gorm:"type:varchar(500)"` + ObservationWindowDays int + BugLinkPattern string `gorm:"type:varchar(500)"` +} + +func (aiReviewScopeConfig20260127) TableName() string { + return "_tool_aireview_scope_configs" +} + +func (script *initSchema) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &aiReview20260127{}, + &aiReviewFinding20260127{}, + &aiFailurePrediction20260127{}, + &aiPredictionMetrics20260127{}, + &aiReviewScopeConfig20260127{}, + ) +} + +func (script *initSchema) Version() uint64 { + return 20260127000001 +} + +func (script *initSchema) Name() string { + return "aireview init schema" +} diff --git a/backend/plugins/aireview/models/migrationscripts/register.go b/backend/plugins/aireview/models/migrationscripts/register.go new file mode 100644 index 00000000000..7029bc44b85 --- /dev/null +++ b/backend/plugins/aireview/models/migrationscripts/register.go @@ -0,0 +1,37 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/plugin" +) + +// All returns all migration scripts for the aireview plugin +func All() []plugin.MigrationScript { + return []plugin.MigrationScript{ + &initSchema{}, + &addEffortAndChecks{}, + &addGeminiConfig{}, + &addReactions{}, + &addCiPredictionSupport{}, + &addFlakyInfraFilters{}, + &addSuggestionsAccepted{}, + &addDiffMatching{}, + &addAiCommits{}, + } +} diff --git a/backend/plugins/aireview/models/scope_config.go b/backend/plugins/aireview/models/scope_config.go new file mode 100644 index 00000000000..6f394b9cf83 --- /dev/null +++ b/backend/plugins/aireview/models/scope_config.go @@ -0,0 +1,132 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// AiReviewScopeConfig contains configuration for AI review extraction +type AiReviewScopeConfig struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` + + // CodeRabbit detection patterns + CodeRabbitEnabled bool `mapstructure:"codeRabbitEnabled" json:"codeRabbitEnabled" gorm:"type:boolean"` + CodeRabbitUsername string `mapstructure:"codeRabbitUsername" json:"codeRabbitUsername" gorm:"type:varchar(255)"` + CodeRabbitPattern string `mapstructure:"codeRabbitPattern" json:"codeRabbitPattern" gorm:"type:varchar(500)"` + + // Cursor Bugbot detection patterns (future) + CursorBugbotEnabled bool `mapstructure:"cursorBugbotEnabled" json:"cursorBugbotEnabled" gorm:"type:boolean"` + CursorBugbotUsername string `mapstructure:"cursorBugbotUsername" json:"cursorBugbotUsername" gorm:"type:varchar(255)"` + CursorBugbotPattern string `mapstructure:"cursorBugbotPattern" json:"cursorBugbotPattern" gorm:"type:varchar(500)"` + + // Qodo (formerly Codium) detection patterns + QodoEnabled bool `mapstructure:"qodoEnabled" json:"qodoEnabled" gorm:"type:boolean"` + QodoUsername string `mapstructure:"qodoUsername" json:"qodoUsername" gorm:"type:varchar(255)"` + QodoPattern string `mapstructure:"qodoPattern" json:"qodoPattern" gorm:"type:varchar(500)"` + + // Gemini Code Assist detection patterns + GeminiEnabled bool `mapstructure:"geminiEnabled" json:"geminiEnabled" gorm:"type:boolean"` + GeminiUsername string `mapstructure:"geminiUsername" json:"geminiUsername" gorm:"type:varchar(255)"` + GeminiPattern string `mapstructure:"geminiPattern" json:"geminiPattern" gorm:"type:varchar(500)"` + + // Generic AI detection patterns (for commit messages, PR descriptions) + // AiCommitPatterns is a single extra catch-all regex (not comma-separated) + // applied after built-in Co-Authored-By / Assisted-by / Made-with trailer matchers. + AiCommitPatterns string `mapstructure:"aiCommitPatterns" json:"aiCommitPatterns" gorm:"type:text"` + AiPrLabelPattern string `mapstructure:"aiPrLabelPattern" json:"aiPrLabelPattern" gorm:"type:varchar(500)"` + + // Risk detection configuration + RiskHighPattern string `mapstructure:"riskHighPattern" json:"riskHighPattern" gorm:"type:varchar(500)"` + RiskMediumPattern string `mapstructure:"riskMediumPattern" json:"riskMediumPattern" gorm:"type:varchar(500)"` + RiskLowPattern string `mapstructure:"riskLowPattern" json:"riskLowPattern" gorm:"type:varchar(500)"` + + // Failure tracking configuration + ObservationWindowDays int `mapstructure:"observationWindowDays" json:"observationWindowDays"` // Default 14 days + + // CI failure prediction threshold: PRs with risk_score >= this are "flagged risky" + // Used by calculateFailurePredictions to classify TP/FP/FN/TN against actual CI outcomes + WarningThreshold int `mapstructure:"warningThreshold" json:"warningThreshold"` // Default 50 + + // CiFailureSource controls which CI data is used to determine actual failures. + // "test_cases": join ci_test_cases with flaky-test quarantine (accurate, needs full collection) + // "job_result": use ci_test_jobs.result directly (fast, works without artifact collection) + // "both": compute predictions for both sources (stored with ci_failure_source tag) + CiFailureSource string `mapstructure:"ciFailureSource" json:"ciFailureSource" gorm:"type:varchar(20);default:'both'"` + + // Issue linking patterns (for tracking post-merge bugs) + BugLinkPattern string `mapstructure:"bugLinkPattern" json:"bugLinkPattern" gorm:"type:varchar(500)"` + + // ExcludeFlakyTests excludes tests/jobs that also fail on periodic/push runs + // from CI failure determination. When enabled, only non-flaky failures count + // toward the confusion matrix. Off by default. + ExcludeFlakyTests bool `mapstructure:"excludeFlakyTests" json:"excludeFlakyTests" gorm:"type:boolean;default:false"` + + // ExcludeInfraFailures excludes CI job failures where no test cases actually + // failed (indicating infrastructure issues like cluster provisioning, quota, + // or timeout). Only applies to the job_result CI source. Off by default. + ExcludeInfraFailures bool `mapstructure:"excludeInfraFailures" json:"excludeInfraFailures" gorm:"type:boolean;default:false"` + + // CiBackfillEnabled enables fetching CI job results from GCS for PRs that + // have AI reviews but no CI data. Disabled by default because it requires + // network access to the public Openshift CI GCS bucket. + CiBackfillEnabled bool `mapstructure:"ciBackfillEnabled" json:"ciBackfillEnabled" gorm:"type:boolean;default:false"` + + // CiBackfillDays controls how many days back to backfill CI data from GCS. + // 0 (the default) disables backfill. The task derives enabled/disabled from + // this value: CiBackfillDays > 0 means backfill is active. + CiBackfillDays int `mapstructure:"ciBackfillDays" json:"ciBackfillDays" gorm:"default:0"` +} + +// CI failure source constants +const ( + CiSourceTestCases = "test_cases" // Use ci_test_cases with flaky-test quarantine + CiSourceJobResult = "job_result" // Use ci_test_jobs.result directly + CiSourceBoth = "both" // Compute predictions for both sources +) + +func (AiReviewScopeConfig) TableName() string { + return "_tool_aireview_scope_configs" +} + +// GetDefaultScopeConfig returns a scope config with sensible defaults +func GetDefaultScopeConfig() *AiReviewScopeConfig { + return &AiReviewScopeConfig{ + CodeRabbitEnabled: true, + CodeRabbitUsername: "coderabbitai", + CodeRabbitPattern: `(?i)(coderabbit|walkthrough|summary by coderabbit)`, + CursorBugbotEnabled: false, + CursorBugbotUsername: "cursor-bugbot", + CursorBugbotPattern: `(?i)(cursor|bugbot)`, + QodoEnabled: true, + QodoUsername: "qodo-merge", + QodoPattern: `(?i)(qodo|pr reviewer guide|estimated effort to review)`, + GeminiEnabled: true, + GeminiUsername: "gemini-code-assist", + GeminiPattern: `(?i)(I'm Gemini Code Assist|codereviewagent|gstatic\.com/codereviewagent)`, + AiCommitPatterns: `(?i)(?:co-authored-by|assisted-by|made-with):.*(?:\bai\b|chatgpt|openai)`, + AiPrLabelPattern: `(?i)(ai-reviewed|coderabbit|automated-review)`, + RiskHighPattern: `(?i)(critical|security|breaking|major)`, + RiskMediumPattern: `(?i)(warning|medium|moderate)`, + RiskLowPattern: `(?i)(minor|low|info|suggestion)`, + ObservationWindowDays: 14, + WarningThreshold: 50, + CiFailureSource: CiSourceBoth, + BugLinkPattern: `(?i)(fixes|closes|resolves)\s*#(\d+)`, + } +} diff --git a/backend/plugins/aireview/skills/pr-definition-of-done/SKILL.md b/backend/plugins/aireview/skills/pr-definition-of-done/SKILL.md new file mode 100644 index 00000000000..10c48d163cb --- /dev/null +++ b/backend/plugins/aireview/skills/pr-definition-of-done/SKILL.md @@ -0,0 +1,44 @@ +--- +description: >- + Checklist for merge-ready PRs touching the aireview plugin. + Use when preparing, reviewing, or finalizing a PR for aireview. +--- + +# PR Definition of Done — aireview Plugin + +## Required for All PRs + +- [ ] Changes scoped to `backend/plugins/aireview/` +- [ ] `go vet ./plugins/aireview/...` passes +- [ ] `go test ./plugins/aireview/... -v` passes (unit tests) +- [ ] `golangci-lint run ./plugins/aireview/...` passes (CI only runs lint on main, so check locally) +- [ ] No `//nolint` without justification comment +- [ ] Apache 2.0 license header on every new `.go` file + +## Schema / Model Changes + +- [ ] Migration script in `models/migrationscripts/` named `YYYYMMDD_description.go` +- [ ] Migration registered in `migrationscripts/register.go:All()` +- [ ] New models added to `GetTablesInfo()` in `impl/impl.go` +- [ ] Table name follows `_tool_aireview_` convention + +## New Subtasks + +- [ ] Registered in `impl/impl.go:SubTaskMetas()` in correct execution order +- [ ] `SubTaskMeta` has `Name`, `EntryPoint`, `EnabledByDefault`, `Description`, `DomainTypes` + +## Plugin-Specific + +- [ ] New AI tool patterns added to `AiReviewScopeConfig` and `CompilePatterns()` +- [ ] `detectAiTool()` updated if new review-tool support added +- [ ] `detectAiCommit()` updated if commit-trailer detection changes +- [ ] Scope config defaults updated in `GetDefaultScopeConfig()` +- [ ] E2E CSV fixtures updated if domain output changed (`e2e/raw_tables/`) +- [ ] New domain tables: model in `backend/core/models/domainlayer/`, migration in `backend/core/models/migrationscripts/`, registered in `domaininfo.go` + +## CI Behavior + +- **Unit tests**: Run on PRs via `make unit-test-go` (excludes `e2e/` and `models/` packages) +- **E2E tests**: Run only on push to main via `make e2e-test-go-plugins` (auto-discovers `e2e/` dirs) +- **Lint**: Runs only on push to main/tags, not on PRs +- Upstream CI also runs `plugins/table_info_test.go` — fails if `GetTablesInfo()` is incomplete diff --git a/backend/plugins/aireview/tasks/calculate_failure_predictions.go b/backend/plugins/aireview/tasks/calculate_failure_predictions.go new file mode 100644 index 00000000000..5980f2e7499 --- /dev/null +++ b/backend/plugins/aireview/tasks/calculate_failure_predictions.go @@ -0,0 +1,599 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +var CalculateFailurePredictionsMeta = plugin.SubTaskMeta{ + Name: "calculateFailurePredictions", + EntryPoint: CalculateFailurePredictions, + EnabledByDefault: true, + Description: "Calculate AI failure prediction outcomes against actual CI test results", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW}, + Dependencies: []*plugin.SubTaskMeta{&ExtractAiReviewsMeta}, +} + +// prAiSummary holds the aggregated AI review outcome for one (PR, AI tool) pair. +type prAiSummary struct { + PullRequestId string + PullRequestKey string + RepoId string + RepoShortName string + RepoName string + AiTool string + MaxRiskScore int + CreatedDate time.Time + PrTitle string + PrUrl string + PrAuthor string + PrCreatedAt time.Time + Additions int + Deletions int +} + +// prCiKey identifies a PR in the ci_test_jobs table. +type prCiKey struct { + PullRequestNumber string + Repository string +} + +// CalculateFailurePredictions joins AI risk assessments with actual CI test outcomes. +// +// Algorithm: +// 1. Determine which CI source(s) to use from scope config (test_cases / job_result / both). +// 2. Load all AI-reviewed PRs for the repo, grouped by (PR, AI tool). +// 3. For each enabled source, load CI outcomes and persist one AiFailurePrediction +// per (PR, AI tool, CI source). +func CalculateFailurePredictions(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + warningThreshold := data.Options.ScopeConfig.WarningThreshold + if warningThreshold == 0 { + warningThreshold = 50 + } + + ciFailureSource := data.Options.ScopeConfig.CiFailureSource + if ciFailureSource == "" { + ciFailureSource = models.CiSourceJobResult + } + + excludeFlakyTests := data.Options.ScopeConfig.ExcludeFlakyTests + excludeInfraFailures := data.Options.ScopeConfig.ExcludeInfraFailures + + sources := []string{ciFailureSource} + if ciFailureSource == models.CiSourceBoth { + sources = []string{models.CiSourceTestCases, models.CiSourceJobResult} + } + + logger.Info("Calculating failure predictions for repo %s (warning_threshold=%d, ci_source=%s, exclude_flaky=%v, exclude_infra=%v)", + data.Options.RepoId, warningThreshold, ciFailureSource, excludeFlakyTests, excludeInfraFailures) + + if excludeInfraFailures && ciFailureSource == models.CiSourceJobResult { + logger.Info("Warning: excludeInfraFailures requires ci_test_cases to be populated. If test case data is not collected, all job failures will be treated as infrastructure failures and excluded.") + } + + // Load AI-reviewed PR summaries (same for all sources). + prSummaries, err := loadAiReviewPrSummaries(db, data.Options.RepoId, data.Options.ProjectName) + if err != nil { + return err + } + if len(prSummaries) == 0 { + logger.Info("No AI-reviewed PRs found for repo %s", data.Options.RepoId) + return nil + } + logger.Info("Loaded %d (PR, AI tool) pairs", len(prSummaries)) + + repoShortNames := uniqueRepoShortNames(prSummaries) + + // Pre-build flaky sets only when the exclude_flaky_tests flag is enabled. + var flakyTests map[prCiKey]bool + var flakyJobs map[string]bool + if excludeFlakyTests { + for _, source := range sources { + switch source { + case models.CiSourceTestCases: + if flakyTests == nil { + flakyTests, err = buildFlakyTestSet(db) + if err != nil { + return err + } + logger.Info("Loaded %d flaky test entries", len(flakyTests)) + } + case models.CiSourceJobResult: + if flakyJobs == nil { + flakyJobs, err = buildFlakyJobSet(db) + if err != nil { + return err + } + logger.Info("Loaded %d flaky job entries", len(flakyJobs)) + } + } + } + } else { + logger.Info("Flaky test/job filtering is disabled") + } + + now := time.Now() + totalWritten := 0 + + // Track which (pullRequestId, aiTool) pairs matched CI data in at least one source. + // Unmatched pairs get a NO_CI record so they are visible in drill-down dashboards. + coveredKeys := make(map[string]bool, len(prSummaries)) + + for _, source := range sources { + var ciOutcomes map[prCiKey]ciOutcomeEntry + switch source { + case models.CiSourceTestCases: + ciOutcomes, err = loadCiOutcomesByTestCases(db, repoShortNames, flakyTests) + case models.CiSourceJobResult: + ciOutcomes, err = loadCiOutcomesByJobResult(db, repoShortNames, flakyJobs, excludeInfraFailures) + } + if err != nil { + return err + } + logger.Info("Source %s: loaded CI outcomes for %d (PR, repo) pairs", source, len(ciOutcomes)) + + batch := make([]*models.AiFailurePrediction, 0, 100) + writtenThisSource := 0 + for i := range prSummaries { + ps := &prSummaries[i] + ciKey := prCiKey{PullRequestNumber: ps.PullRequestKey, Repository: ps.RepoShortName} + outcome, hasCiData := ciOutcomes[ciKey] + + if !hasCiData { + continue + } + coveredKeys[ps.PullRequestId+":"+ps.AiTool] = true + + wasFlaggedRisky := ps.MaxRiskScore >= warningThreshold + hadCiFailure := outcome.HadNonFlakyFailure + + batch = append(batch, &models.AiFailurePrediction{ + Id: generatePredictionId(ps.PullRequestId, ps.AiTool, source), + PullRequestId: ps.PullRequestId, + PullRequestKey: ps.PullRequestKey, + RepoId: ps.RepoId, + RepoShortName: ps.RepoShortName, + RepoName: ps.RepoName, + AiTool: ps.AiTool, + CiFailureSource: source, + WasFlaggedRisky: wasFlaggedRisky, + RiskScore: ps.MaxRiskScore, + FlaggedAt: ps.CreatedDate, + HadCiFailure: hadCiFailure, + PredictionOutcome: calculateOutcome(wasFlaggedRisky, hadCiFailure), + PrTitle: ps.PrTitle, + PrUrl: ps.PrUrl, + PrAuthor: ps.PrAuthor, + PrCreatedAt: ps.PrCreatedAt, + Additions: ps.Additions, + Deletions: ps.Deletions, + ObservationWindowDays: 0, + CreatedAt: now, + }) + writtenThisSource++ + + if len(batch) >= 100 { + if saveErr := savePredictionsBatch(db, batch); saveErr != nil { + return saveErr + } + batch = batch[:0] + } + } + + if len(batch) > 0 { + if saveErr := savePredictionsBatch(db, batch); saveErr != nil { + return saveErr + } + } + totalWritten += writtenThisSource + } + + // Write one NO_CI record per (PR, AI tool) pair that had no CI data in any source. + // This makes unclassified PRs visible in drill-down dashboards. + noCiBatch := make([]*models.AiFailurePrediction, 0, 100) + for i := range prSummaries { + ps := &prSummaries[i] + if coveredKeys[ps.PullRequestId+":"+ps.AiTool] { + continue + } + noCiBatch = append(noCiBatch, &models.AiFailurePrediction{ + Id: generatePredictionId(ps.PullRequestId, ps.AiTool, models.CiSourceNone), + PullRequestId: ps.PullRequestId, + PullRequestKey: ps.PullRequestKey, + RepoId: ps.RepoId, + RepoShortName: ps.RepoShortName, + RepoName: ps.RepoName, + AiTool: ps.AiTool, + CiFailureSource: models.CiSourceNone, + WasFlaggedRisky: ps.MaxRiskScore >= warningThreshold, + RiskScore: ps.MaxRiskScore, + FlaggedAt: ps.CreatedDate, + HadCiFailure: false, + PredictionOutcome: models.PredictionNoCi, + PrTitle: ps.PrTitle, + PrUrl: ps.PrUrl, + PrAuthor: ps.PrAuthor, + PrCreatedAt: ps.PrCreatedAt, + Additions: ps.Additions, + Deletions: ps.Deletions, + ObservationWindowDays: 0, + CreatedAt: now, + }) + totalWritten++ + + if len(noCiBatch) >= 100 { + if saveErr := savePredictionsBatch(db, noCiBatch); saveErr != nil { + return saveErr + } + noCiBatch = noCiBatch[:0] + } + } + if len(noCiBatch) > 0 { + if saveErr := savePredictionsBatch(db, noCiBatch); saveErr != nil { + return saveErr + } + } + + logger.Info("Completed failure prediction calculation: %d predictions written (%d NO_CI)", + totalWritten, len(noCiBatch)) + return nil +} + +// buildFlakyTestSet returns a set of (testName, repository) pairs that failed +// on periodic or push runs in the last 30 days. Used by loadCiOutcomesByTestCases +// to exclude environment-flaky test failures from PR outcome determination. +func buildFlakyTestSet(db dal.Dal) (map[prCiKey]bool, errors.Error) { + thirtyDaysAgo := time.Now().AddDate(0, 0, -30) + + var rows []struct { + Name string `gorm:"column:name"` + Repository string `gorm:"column:repository"` + } + + err := db.All(&rows, + dal.Select("DISTINCT tc.name, j.repository"), + dal.From("ci_test_cases tc"), + dal.Join("JOIN ci_test_jobs j ON tc.connection_id = j.connection_id AND tc.job_id = j.job_id"), + dal.Where("tc.status = 'failed' AND j.trigger_type IN ('periodic', 'push') AND j.finished_at >= ?", thirtyDaysAgo), + ) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to build flaky test set") + } + + flakyTests := make(map[prCiKey]bool, len(rows)) + for _, r := range rows { + flakyTests[prCiKey{PullRequestNumber: r.Name, Repository: r.Repository}] = true + } + return flakyTests, nil +} + +// buildFlakyJobSet returns a set of "job_name|repository" keys for CI jobs that +// failed on periodic or push runs in the last 30 days. Used by loadCiOutcomesByJobResult +// to exclude environment-flaky job failures from PR outcome determination. +func buildFlakyJobSet(db dal.Dal) (map[string]bool, errors.Error) { + thirtyDaysAgo := time.Now().AddDate(0, 0, -30) + + var rows []struct { + JobName string `gorm:"column:job_name"` + Repository string `gorm:"column:repository"` + } + + err := db.All(&rows, + dal.Select("DISTINCT job_name, repository"), + dal.From("ci_test_jobs"), + dal.Where("result != 'SUCCESS' AND trigger_type IN ('periodic', 'push') AND finished_at >= ?", thirtyDaysAgo), + ) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to build flaky job set") + } + + flakyJobs := make(map[string]bool, len(rows)) + for _, r := range rows { + flakyJobs[r.JobName+"|"+r.Repository] = true + } + return flakyJobs, nil +} + +// loadAiReviewPrSummaries returns one row per (pull_request_id, ai_tool) with +// the max risk_score and the PR key / repo short name needed to join CI data. +// Supports both single-repo mode (repoId set) and project mode (projectName set). +func loadAiReviewPrSummaries(db dal.Dal, repoId, projectName string) ([]prAiSummary, errors.Error) { + var rows []struct { + PullRequestId string `gorm:"column:pull_request_id"` + PullRequestKey string `gorm:"column:pull_request_key"` + RepoId string `gorm:"column:repo_id"` + RepoName string `gorm:"column:repo_name"` + AiTool string `gorm:"column:ai_tool"` + MaxRiskScore int `gorm:"column:max_risk_score"` + CreatedDate time.Time `gorm:"column:created_date"` + PrTitle string `gorm:"column:pr_title"` + PrUrl string `gorm:"column:pr_url"` + PrAuthor string `gorm:"column:pr_author"` + PrCreatedAt time.Time `gorm:"column:pr_created_at"` + Additions int `gorm:"column:additions"` + Deletions int `gorm:"column:deletions"` + } + + const selectCols = "ar.pull_request_id, pr.pull_request_key, ar.repo_id, r.name AS repo_name, ar.ai_tool," + + " MAX(ar.risk_score) AS max_risk_score, MIN(ar.created_date) AS created_date," + + " MAX(pr.title) AS pr_title, MAX(pr.url) AS pr_url, MAX(pr.author_name) AS pr_author," + + " MAX(pr.created_date) AS pr_created_at, MAX(pr.additions) AS additions, MAX(pr.deletions) AS deletions" + + var clauses []dal.Clause + if repoId != "" { + clauses = []dal.Clause{ + dal.Select(selectCols), + dal.From("_tool_aireview_reviews ar"), + dal.Join("JOIN pull_requests pr ON ar.pull_request_id = pr.id"), + dal.Join("JOIN repos r ON ar.repo_id = r.id"), + dal.Where("ar.repo_id = ? AND ar.body NOT LIKE '%Review skipped%'", repoId), + dal.Groupby("ar.pull_request_id, pr.pull_request_key, ar.repo_id, r.name, ar.ai_tool"), + } + } else { + clauses = []dal.Clause{ + dal.Select(selectCols), + dal.From("_tool_aireview_reviews ar"), + dal.Join("JOIN pull_requests pr ON ar.pull_request_id = pr.id"), + dal.Join("JOIN repos r ON ar.repo_id = r.id"), + dal.Join("JOIN project_mapping pm ON ar.repo_id = pm.row_id AND pm.`table` = 'repos'"), + dal.Where("pm.project_name = ? AND ar.body NOT LIKE '%Review skipped%'", projectName), + dal.Groupby("ar.pull_request_id, pr.pull_request_key, ar.repo_id, r.name, ar.ai_tool"), + } + } + + err := db.All(&rows, clauses...) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to load AI review PR summaries") + } + + summaries := make([]prAiSummary, len(rows)) + for i, r := range rows { + summaries[i] = prAiSummary{ + PullRequestId: r.PullRequestId, + PullRequestKey: r.PullRequestKey, + RepoId: r.RepoId, + RepoShortName: repoShortNameFrom(r.RepoName), + RepoName: r.RepoName, + AiTool: r.AiTool, + MaxRiskScore: r.MaxRiskScore, + CreatedDate: r.CreatedDate, + PrTitle: r.PrTitle, + PrUrl: r.PrUrl, + PrAuthor: r.PrAuthor, + PrCreatedAt: r.PrCreatedAt, + Additions: r.Additions, + Deletions: r.Deletions, + } + } + return summaries, nil +} + +// ciOutcomeEntry records whether a PR had at least one non-flaky CI failure. +type ciOutcomeEntry struct { + HadNonFlakyFailure bool +} + +// loadCiOutcomesByTestCases joins ci_test_jobs with ci_test_cases and returns +// a map indicating whether each PR had a non-flaky test-case-level failure. +// Requires ci_test_cases to be populated (needs full testregistry collection). +func loadCiOutcomesByTestCases(db dal.Dal, repoShortNames []string, flakyTests map[prCiKey]bool) (map[prCiKey]ciOutcomeEntry, errors.Error) { + if len(repoShortNames) == 0 { + return map[prCiKey]ciOutcomeEntry{}, nil + } + + var rows []struct { + PullRequestNumber int64 `gorm:"column:pull_request_number"` + Repository string `gorm:"column:repository"` + TestName string `gorm:"column:test_name"` + Status string `gorm:"column:status"` + } + + err := db.All(&rows, + dal.Select("j.pull_request_number, j.repository, tc.name AS test_name, tc.status"), + dal.From("ci_test_jobs j"), + dal.Join("JOIN ci_test_cases tc ON j.connection_id = tc.connection_id AND j.job_id = tc.job_id"), + dal.Where("j.trigger_type = 'pull_request' AND j.pull_request_number > 0 AND j.repository IN ? AND j.finished_at >= ?", repoShortNames, time.Now().AddDate(0, -3, 0)), + ) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to load CI test case outcomes") + } + + outcomes := make(map[prCiKey]ciOutcomeEntry) + for _, r := range rows { + key := prCiKey{ + PullRequestNumber: strconv.FormatInt(r.PullRequestNumber, 10), + Repository: r.Repository, + } + if _, exists := outcomes[key]; !exists { + outcomes[key] = ciOutcomeEntry{} + } + if r.Status != "failed" { + continue + } + // Check if this failed test is flaky. + flakyKey := prCiKey{PullRequestNumber: r.TestName, Repository: r.Repository} + if flakyTests[flakyKey] { + continue + } + entry := outcomes[key] + entry.HadNonFlakyFailure = true + outcomes[key] = entry + } + return outcomes, nil +} + +// ciJobRow is one row from the ci_test_jobs table. +type ciJobRow struct { + PullRequestNumber int64 `gorm:"column:pull_request_number"` + Repository string `gorm:"column:repository"` + JobId string `gorm:"column:job_id"` + JobName string `gorm:"column:job_name"` + Result string `gorm:"column:result"` +} + +// loadCiOutcomesByJobResult queries ci_test_jobs.result for PR-triggered jobs +// and returns a map indicating whether each PR had a non-flaky job-level failure. +// When excludeInfraFailures is true, individual job runs that failed but have no +// failed test cases are considered infrastructure failures and excluded. +// Works without ci_test_cases being populated (unless excludeInfraFailures is enabled). +func loadCiOutcomesByJobResult(db dal.Dal, repoShortNames []string, flakyJobs map[string]bool, excludeInfraFailures bool) (map[prCiKey]ciOutcomeEntry, errors.Error) { + if len(repoShortNames) == 0 { + return map[prCiKey]ciOutcomeEntry{}, nil + } + + var rows []ciJobRow + err := db.All(&rows, + dal.Select("pull_request_number, repository, job_id, job_name, result"), + dal.From("ci_test_jobs"), + dal.Where("trigger_type = 'pull_request' AND pull_request_number > 0 AND repository IN ? AND finished_at >= ?", repoShortNames, time.Now().AddDate(0, -3, 0)), + ) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to load CI job outcomes") + } + + // When infra filtering is enabled, pre-load individual job run IDs that have + // at least one failed test case. This allows us to distinguish infra failures + // (job failed but no tests failed) from real test failures on a per-run basis. + var jobRunsWithTestFailures map[string]bool + if excludeInfraFailures { + jobRunsWithTestFailures, err = buildJobRunsWithTestFailures(db, repoShortNames) + if err != nil { + return nil, err + } + } + + outcomes := make(map[prCiKey]ciOutcomeEntry) + for _, r := range rows { + key := prCiKey{ + PullRequestNumber: strconv.FormatInt(r.PullRequestNumber, 10), + Repository: r.Repository, + } + if _, exists := outcomes[key]; !exists { + outcomes[key] = ciOutcomeEntry{} + } + if r.Result == "SUCCESS" { + continue + } + // Check if this job is known to be flaky on non-PR runs. + flakyKey := r.JobName + "|" + r.Repository + if flakyJobs[flakyKey] { + continue + } + // Check if this specific job run is an infrastructure failure + // (failed but no test cases actually failed in this run). + if excludeInfraFailures && !jobRunsWithTestFailures[r.JobId] { + continue + } + entry := outcomes[key] + entry.HadNonFlakyFailure = true + outcomes[key] = entry + } + return outcomes, nil +} + +// buildJobRunsWithTestFailures returns a set of job_id values for PR-triggered +// CI job runs that have at least one failed test case. Used to identify individual +// infrastructure failures — a specific job run that failed but had no test failures. +func buildJobRunsWithTestFailures(db dal.Dal, repoShortNames []string) (map[string]bool, errors.Error) { + var rows []struct { + JobId string `gorm:"column:job_id"` + } + + err := db.All(&rows, + dal.Select("DISTINCT j.job_id"), + dal.From("ci_test_jobs j"), + dal.Join("JOIN ci_test_cases tc ON j.connection_id = tc.connection_id AND j.job_id = tc.job_id"), + dal.Where("j.trigger_type = 'pull_request' AND j.pull_request_number > 0 AND j.repository IN ? AND tc.status = 'failed' AND j.finished_at >= ?", repoShortNames, time.Now().AddDate(0, -3, 0)), + ) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to build job runs with test failures set") + } + + result := make(map[string]bool, len(rows)) + for _, r := range rows { + result[r.JobId] = true + } + return result, nil +} + +// uniqueRepoShortNames returns the distinct repo short names from the summaries. +func uniqueRepoShortNames(summaries []prAiSummary) []string { + seen := make(map[string]bool) + result := make([]string, 0) + for _, s := range summaries { + if s.RepoShortName != "" && !seen[s.RepoShortName] { + seen[s.RepoShortName] = true + result = append(result, s.RepoShortName) + } + } + return result +} + +// calculateOutcome determines the confusion-matrix label for a prediction. +func calculateOutcome(wasFlaggedRisky, actualFailure bool) string { + if wasFlaggedRisky && actualFailure { + return models.PredictionTP + } + if wasFlaggedRisky && !actualFailure { + return models.PredictionFP + } + if !wasFlaggedRisky && actualFailure { + return models.PredictionFN + } + return models.PredictionTN +} + +// generatePredictionId creates a deterministic ID for a prediction. +func generatePredictionId(prId, aiTool, ciFailureSource string) string { + hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%s", prId, aiTool, ciFailureSource))) + return "aipred:" + hex.EncodeToString(hash[:16]) +} + +// repoShortNameFrom extracts the repository short name (the part after the last "/") +// from a full "org/repo" name. This avoids MySQL-specific SUBSTRING_INDEX in SQL. +func repoShortNameFrom(fullName string) string { + if i := strings.LastIndex(fullName, "/"); i >= 0 { + return fullName[i+1:] + } + return fullName +} + +// savePredictionsBatch upserts a batch of predictions. +func savePredictionsBatch(db dal.Dal, batch []*models.AiFailurePrediction) errors.Error { + for _, p := range batch { + if err := db.CreateOrUpdate(p); err != nil { + return errors.Default.Wrap(err, "failed to save failure prediction") + } + } + return nil +} diff --git a/backend/plugins/aireview/tasks/calculate_failure_predictions_test.go b/backend/plugins/aireview/tasks/calculate_failure_predictions_test.go new file mode 100644 index 00000000000..0816671eb2d --- /dev/null +++ b/backend/plugins/aireview/tasks/calculate_failure_predictions_test.go @@ -0,0 +1,501 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + "time" + + "github.com/apache/devlake/core/errors" + mockdal "github.com/apache/devlake/mocks/core/dal" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestCalculateOutcome(t *testing.T) { + cases := []struct { + flagged bool + failed bool + want string + }{ + {true, true, models.PredictionTP}, + {true, false, models.PredictionFP}, + {false, true, models.PredictionFN}, + {false, false, models.PredictionTN}, + } + for _, tc := range cases { + got := calculateOutcome(tc.flagged, tc.failed) + if got != tc.want { + t.Errorf("calculateOutcome(flagged=%v, failed=%v) = %q, want %q", + tc.flagged, tc.failed, got, tc.want) + } + } +} + +// TestNoCiDataProducesNoPredictions verifies the core invariant: a PR that has +// no matching entry in the ciOutcomes map must not produce any prediction. +// +// This is the unit-level guard for the bug where missing CI data was treated as +// "CI passed", turning every flagged PR into a False Positive. +func TestNoCiDataProducesNoPredictions(t *testing.T) { + // Simulate the inner loop logic directly: an empty CI outcomes map (no + // TestRegistry configured) paired with one high-risk AI-reviewed PR. + ciOutcomes := map[prCiKey]ciOutcomeEntry{} // no CI data for any repo + + summaries := []prAiSummary{ + { + PullRequestId: "github:GithubPullRequest:1:9001", + PullRequestKey: "901", + RepoId: "github:GithubRepo:1:999", + RepoShortName: "no-ci-repo", + AiTool: "CodeRabbit", + MaxRiskScore: 80, // above any reasonable warning threshold + }, + } + + written := 0 + for i := range summaries { + ps := &summaries[i] + ciKey := prCiKey{PullRequestNumber: ps.PullRequestKey, Repository: ps.RepoShortName} + _, hasCiData := ciOutcomes[ciKey] + if !hasCiData { + continue // must skip — this is what we're testing + } + written++ + } + + if written != 0 { + t.Errorf("expected 0 predictions written for repo with no CI data, got %d", written) + } +} + +// TestGeneratePredictionId verifies that two identical inputs produce the same +// ID and that differing inputs produce different IDs. +func TestGeneratePredictionId(t *testing.T) { + id1 := generatePredictionId("pr:1", "CodeRabbit", "job_result") + id2 := generatePredictionId("pr:1", "CodeRabbit", "job_result") + id3 := generatePredictionId("pr:1", "CodeRabbit", "test_cases") + id4 := generatePredictionId("pr:2", "CodeRabbit", "job_result") + + if id1 != id2 { + t.Errorf("same inputs produced different IDs: %q vs %q", id1, id2) + } + if id1 == id3 { + t.Errorf("different ci_source should produce different IDs, both got %q", id1) + } + if id1 == id4 { + t.Errorf("different PR ID should produce different IDs, both got %q", id1) + } + if len(id1) == 0 { + t.Error("generatePredictionId returned empty string") + } +} + +func TestRepoShortNameFrom(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"org/repo format", "openshift/console", "console"}, + {"nested path", "org/sub/repo", "repo"}, + {"no slash returns full name", "myrepo", "myrepo"}, + {"trailing slash", "org/", ""}, + {"empty string", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := repoShortNameFrom(tt.input) + if got != tt.want { + t.Errorf("repoShortNameFrom(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestUniqueRepoShortNames(t *testing.T) { + t.Run("deduplicates and preserves order", func(t *testing.T) { + summaries := []prAiSummary{ + {RepoShortName: "repo-a"}, + {RepoShortName: "repo-b"}, + {RepoShortName: "repo-a"}, + {RepoShortName: "repo-c"}, + {RepoShortName: "repo-b"}, + } + got := uniqueRepoShortNames(summaries) + want := []string{"repo-a", "repo-b", "repo-c"} + if len(got) != len(want) { + t.Fatalf("uniqueRepoShortNames() len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("uniqueRepoShortNames()[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + + t.Run("empty input returns empty", func(t *testing.T) { + got := uniqueRepoShortNames(nil) + if len(got) != 0 { + t.Errorf("expected empty, got %v", got) + } + }) + + t.Run("single entry", func(t *testing.T) { + summaries := []prAiSummary{{RepoShortName: "only"}} + got := uniqueRepoShortNames(summaries) + if len(got) != 1 || got[0] != "only" { + t.Errorf("expected [only], got %v", got) + } + }) +} + +func TestSavePredictionsBatch(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + batch := []*models.AiFailurePrediction{ + {Id: "p1", PullRequestId: "pr-1"}, + {Id: "p2", PullRequestId: "pr-2"}, + } + err := savePredictionsBatch(mockDal, batch) + assert.Nil(t, err) + mockDal.AssertNumberOfCalls(t, "CreateOrUpdate", 2) + }) + + t.Run("empty batch", func(t *testing.T) { + mockDal := new(mockdal.Dal) + err := savePredictionsBatch(mockDal, []*models.AiFailurePrediction{}) + assert.Nil(t, err) + }) + + t.Run("error on save", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + batch := []*models.AiFailurePrediction{{Id: "p1"}} + err := savePredictionsBatch(mockDal, batch) + assert.NotNil(t, err) + }) +} + +func TestBuildFlakyTestSet(t *testing.T) { + t.Run("success with results", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]struct { + Name string `gorm:"column:name"` + Repository string `gorm:"column:repository"` + }) + *dst = []struct { + Name string `gorm:"column:name"` + Repository string `gorm:"column:repository"` + }{ + {Name: "TestFlaky1", Repository: "repo-a"}, + {Name: "TestFlaky2", Repository: "repo-b"}, + } + }).Return(nil) + + result, err := buildFlakyTestSet(mockDal) + assert.Nil(t, err) + assert.Len(t, result, 2) + assert.True(t, result[prCiKey{PullRequestNumber: "TestFlaky1", Repository: "repo-a"}]) + assert.True(t, result[prCiKey{PullRequestNumber: "TestFlaky2", Repository: "repo-b"}]) + }) + + t.Run("empty results", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Return(nil) + + result, err := buildFlakyTestSet(mockDal) + assert.Nil(t, err) + assert.Empty(t, result) + }) + + t.Run("error returns wrapped error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + result, err := buildFlakyTestSet(mockDal) + assert.NotNil(t, err) + assert.Nil(t, result) + }) +} + +func TestBuildFlakyJobSet(t *testing.T) { + t.Run("success with results", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]struct { + JobName string `gorm:"column:job_name"` + Repository string `gorm:"column:repository"` + }) + *dst = []struct { + JobName string `gorm:"column:job_name"` + Repository string `gorm:"column:repository"` + }{ + {JobName: "e2e-test", Repository: "repo-a"}, + } + }).Return(nil) + + result, err := buildFlakyJobSet(mockDal) + assert.Nil(t, err) + assert.Len(t, result, 1) + assert.True(t, result["e2e-test|repo-a"]) + }) + + t.Run("error returns wrapped error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + result, err := buildFlakyJobSet(mockDal) + assert.NotNil(t, err) + assert.Nil(t, result) + }) +} + +func TestLoadAiReviewPrSummaries(t *testing.T) { + t.Run("single repo mode", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]struct { + PullRequestId string `gorm:"column:pull_request_id"` + PullRequestKey string `gorm:"column:pull_request_key"` + RepoId string `gorm:"column:repo_id"` + RepoName string `gorm:"column:repo_name"` + AiTool string `gorm:"column:ai_tool"` + MaxRiskScore int `gorm:"column:max_risk_score"` + CreatedDate time.Time `gorm:"column:created_date"` + PrTitle string `gorm:"column:pr_title"` + PrUrl string `gorm:"column:pr_url"` + PrAuthor string `gorm:"column:pr_author"` + PrCreatedAt time.Time `gorm:"column:pr_created_at"` + Additions int `gorm:"column:additions"` + Deletions int `gorm:"column:deletions"` + }) + *dst = []struct { + PullRequestId string `gorm:"column:pull_request_id"` + PullRequestKey string `gorm:"column:pull_request_key"` + RepoId string `gorm:"column:repo_id"` + RepoName string `gorm:"column:repo_name"` + AiTool string `gorm:"column:ai_tool"` + MaxRiskScore int `gorm:"column:max_risk_score"` + CreatedDate time.Time `gorm:"column:created_date"` + PrTitle string `gorm:"column:pr_title"` + PrUrl string `gorm:"column:pr_url"` + PrAuthor string `gorm:"column:pr_author"` + PrCreatedAt time.Time `gorm:"column:pr_created_at"` + Additions int `gorm:"column:additions"` + Deletions int `gorm:"column:deletions"` + }{ + { + PullRequestId: "pr-1", + PullRequestKey: "42", + RepoId: "repo-1", + RepoName: "org/my-repo", + AiTool: "CodeRabbit", + MaxRiskScore: 80, + }, + } + }).Return(nil) + + result, err := loadAiReviewPrSummaries(mockDal, "repo-1", "") + assert.Nil(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "pr-1", result[0].PullRequestId) + assert.Equal(t, "my-repo", result[0].RepoShortName) + assert.Equal(t, "CodeRabbit", result[0].AiTool) + }) + + t.Run("error returns wrapped error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + result, err := loadAiReviewPrSummaries(mockDal, "repo-1", "") + assert.NotNil(t, err) + assert.Nil(t, result) + }) + + t.Run("empty results", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Return(nil) + + result, err := loadAiReviewPrSummaries(mockDal, "repo-1", "") + assert.Nil(t, err) + assert.Empty(t, result) + }) +} + +func TestLoadCiOutcomesByTestCases(t *testing.T) { + t.Run("empty repoShortNames", func(t *testing.T) { + mockDal := new(mockdal.Dal) + result, err := loadCiOutcomesByTestCases(mockDal, []string{}, nil) + assert.Nil(t, err) + assert.Empty(t, result) + }) + + t.Run("success with rows", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]struct { + PullRequestNumber int64 `gorm:"column:pull_request_number"` + Repository string `gorm:"column:repository"` + TestName string `gorm:"column:test_name"` + Status string `gorm:"column:status"` + }) + *dst = []struct { + PullRequestNumber int64 `gorm:"column:pull_request_number"` + Repository string `gorm:"column:repository"` + TestName string `gorm:"column:test_name"` + Status string `gorm:"column:status"` + }{ + {PullRequestNumber: 42, Repository: "repo-a", TestName: "TestPassing", Status: "passed"}, + {PullRequestNumber: 42, Repository: "repo-a", TestName: "TestRealFailure", Status: "failed"}, + {PullRequestNumber: 42, Repository: "repo-a", TestName: "FlakyTest", Status: "failed"}, + } + }).Return(nil) + + flakyTests := map[prCiKey]bool{ + {PullRequestNumber: "FlakyTest", Repository: "repo-a"}: true, + } + + result, err := loadCiOutcomesByTestCases(mockDal, []string{"repo-a"}, flakyTests) + assert.Nil(t, err) + assert.Len(t, result, 1) + key := prCiKey{PullRequestNumber: "42", Repository: "repo-a"} + assert.True(t, result[key].HadNonFlakyFailure) + }) + + t.Run("error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + result, err := loadCiOutcomesByTestCases(mockDal, []string{"repo-a"}, nil) + assert.NotNil(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "CI test case outcomes") + }) +} + +func TestLoadCiOutcomesByJobResult(t *testing.T) { + t.Run("empty repoShortNames", func(t *testing.T) { + mockDal := new(mockdal.Dal) + result, err := loadCiOutcomesByJobResult(mockDal, []string{}, nil, false) + assert.Nil(t, err) + assert.Empty(t, result) + }) + + t.Run("success without infra filter", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]ciJobRow) + *dst = []ciJobRow{ + {PullRequestNumber: 10, Repository: "repo-a", JobId: "j1", JobName: "build", Result: "SUCCESS"}, + {PullRequestNumber: 10, Repository: "repo-a", JobId: "j2", JobName: "test", Result: "FAILURE"}, + } + }).Return(nil) + + result, err := loadCiOutcomesByJobResult(mockDal, []string{"repo-a"}, nil, false) + assert.Nil(t, err) + assert.Len(t, result, 1) + key := prCiKey{PullRequestNumber: "10", Repository: "repo-a"} + assert.True(t, result[key].HadNonFlakyFailure) + }) + + t.Run("success with infra filter", func(t *testing.T) { + mockDal := new(mockdal.Dal) + + // First call: load job rows + mockDal.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]ciJobRow) + *dst = []ciJobRow{ + {PullRequestNumber: 10, Repository: "repo-a", JobId: "j1", JobName: "build", Result: "FAILURE"}, + {PullRequestNumber: 10, Repository: "repo-a", JobId: "j2", JobName: "test", Result: "FAILURE"}, + } + }).Return(nil).Once() + + // Second call: buildJobRunsWithTestFailures — only j2 has test failures + mockDal.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]struct { + JobId string `gorm:"column:job_id"` + }) + *dst = []struct { + JobId string `gorm:"column:job_id"` + }{ + {JobId: "j2"}, + } + }).Return(nil).Once() + + result, err := loadCiOutcomesByJobResult(mockDal, []string{"repo-a"}, nil, true) + assert.Nil(t, err) + assert.Len(t, result, 1) + key := prCiKey{PullRequestNumber: "10", Repository: "repo-a"} + // j1 was infra failure (no test failures), j2 had test failures + assert.True(t, result[key].HadNonFlakyFailure) + }) + + t.Run("error on query", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + result, err := loadCiOutcomesByJobResult(mockDal, []string{"repo-a"}, nil, false) + assert.NotNil(t, err) + assert.Nil(t, result) + }) +} + +func TestBuildJobRunsWithTestFailures(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]struct { + JobId string `gorm:"column:job_id"` + }) + *dst = []struct { + JobId string `gorm:"column:job_id"` + }{ + {JobId: "job-1"}, + {JobId: "job-2"}, + } + }).Return(nil) + + result, err := buildJobRunsWithTestFailures(mockDal, []string{"repo-a"}) + assert.Nil(t, err) + assert.Len(t, result, 2) + assert.True(t, result["job-1"]) + assert.True(t, result["job-2"]) + }) + + t.Run("error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + result, err := buildJobRunsWithTestFailures(mockDal, []string{"repo-a"}) + assert.NotNil(t, err) + assert.Nil(t, result) + }) +} diff --git a/backend/plugins/aireview/tasks/calculate_prediction_metrics.go b/backend/plugins/aireview/tasks/calculate_prediction_metrics.go new file mode 100644 index 00000000000..66753f167ec --- /dev/null +++ b/backend/plugins/aireview/tasks/calculate_prediction_metrics.go @@ -0,0 +1,338 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "math" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +var CalculatePredictionMetricsMeta = plugin.SubTaskMeta{ + Name: "calculatePredictionMetrics", + EntryPoint: CalculatePredictionMetrics, + EnabledByDefault: true, + Description: "Calculate aggregated AI prediction metrics (precision, recall, AUC) from CI outcomes", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW}, + Dependencies: []*plugin.SubTaskMeta{&CalculateFailurePredictionsMeta}, +} + +// aucThresholds are the risk_score cut-points used when computing PR-AUC and ROC-AUC. +// They match the sensitivity levels used in the Grafana dashboard. +var aucThresholds = []int{0, 10, 20, 50, 80, 100} + +// predictionPoint is the minimal data needed for AUC computation. +type predictionPoint struct { + RiskScore int + HadCiFailure bool +} + +// CalculatePredictionMetrics aggregates prediction data into period metrics. +func CalculatePredictionMetrics(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + warningThreshold := data.Options.ScopeConfig.WarningThreshold + if warningThreshold == 0 { + warningThreshold = 50 + } + + logger.Info("Calculating prediction metrics for repo: %s", data.Options.RepoId) + + // Get distinct (repo_id, ai_tool, ci_failure_source) triplets that have completed predictions. + // Supports both single-repo mode and project mode (repoId empty). + var toolRows []struct { + RepoId string `gorm:"column:repo_id"` + AiTool string `gorm:"column:ai_tool"` + CiFailureSource string `gorm:"column:ci_failure_source"` + } + toolQuery := []dal.Clause{ + dal.Select("DISTINCT repo_id, ai_tool, ci_failure_source"), + dal.From(&models.AiFailurePrediction{}), + } + if data.Options.RepoId != "" { + toolQuery = append(toolQuery, dal.Where("repo_id = ? AND prediction_outcome != ''", data.Options.RepoId)) + } else { + toolQuery = append(toolQuery, dal.Where("prediction_outcome != ''")) + } + if err := db.All(&toolRows, toolQuery...); err != nil { + return errors.Default.Wrap(err, "failed to get repo/tool/source triplets") + } + + now := time.Now() + periods := []struct { + name string + start time.Time + end time.Time + }{ + {"daily", now.AddDate(0, 0, -1), now}, + {"weekly", now.AddDate(0, 0, -7), now}, + {"monthly", now.AddDate(0, -1, 0), now}, + {"rolling_60d", now.AddDate(0, 0, -60), now}, + } + + for _, tr := range toolRows { + if tr.AiTool == "" || tr.RepoId == "" { + continue + } + + // Load all prediction points for this (repo, tool, source) triplet for AUC. + allPoints, err := loadPredictionPoints(db, tr.RepoId, tr.AiTool, tr.CiFailureSource, time.Time{}, now) + if err != nil { + logger.Warn(err, "Failed to load prediction points for %s/%s/%s", tr.RepoId, tr.AiTool, tr.CiFailureSource) + continue + } + + for _, period := range periods { + periodPoints, err := loadPredictionPoints(db, tr.RepoId, tr.AiTool, tr.CiFailureSource, period.start, period.end) + if err != nil { + logger.Warn(err, "Failed to load period prediction points for %s/%s/%s/%s", tr.RepoId, tr.AiTool, tr.CiFailureSource, period.name) + continue + } + if len(periodPoints) == 0 { + continue + } + + aucPoints := periodPoints + if len(periodPoints) < 5 { + aucPoints = allPoints + } + + metrics := computeMetrics(tr.RepoId, tr.AiTool, tr.CiFailureSource, period.name, period.start, period.end, periodPoints, aucPoints, warningThreshold) + + if err := db.CreateOrUpdate(metrics); err != nil { + return errors.Default.Wrap(err, "failed to save prediction metrics") + } + } + } + + logger.Info("Completed prediction metrics calculation") + return nil +} + +// loadPredictionPoints fetches risk_score + had_ci_failure for all completed +// predictions for a given (repo, tool, ci_failure_source). When start is zero, +// no time filter is applied (all-time). +func loadPredictionPoints(db dal.Dal, repoId, aiTool, ciFailureSource string, start, end time.Time) ([]predictionPoint, errors.Error) { + var rows []struct { + RiskScore int `gorm:"column:risk_score"` + HadCiFailure bool `gorm:"column:had_ci_failure"` + } + + var err errors.Error + if start.IsZero() { + err = db.All(&rows, + dal.Select("risk_score, had_ci_failure"), + dal.From(&models.AiFailurePrediction{}), + dal.Where("repo_id = ? AND ai_tool = ? AND ci_failure_source = ? AND prediction_outcome != ''", repoId, aiTool, ciFailureSource), + ) + } else { + err = db.All(&rows, + dal.Select("risk_score, had_ci_failure"), + dal.From(&models.AiFailurePrediction{}), + dal.Where("repo_id = ? AND ai_tool = ? AND ci_failure_source = ? AND prediction_outcome != '' AND created_at BETWEEN ? AND ?", repoId, aiTool, ciFailureSource, start, end), + ) + } + if err != nil { + return nil, errors.Default.Wrap(err, "failed to query prediction points") + } + + points := make([]predictionPoint, len(rows)) + for i, r := range rows { + points[i] = predictionPoint{RiskScore: r.RiskScore, HadCiFailure: r.HadCiFailure} + } + return points, nil +} + +// computeMetrics builds an AiPredictionMetrics record from prediction points. +func computeMetrics(repoId, aiTool, ciFailureSource, periodType string, periodStart, periodEnd time.Time, + periodPoints, aucPoints []predictionPoint, warningThreshold int) *models.AiPredictionMetrics { + + // Confusion matrix at warning_threshold. + var tp, fp, fn, tn int + for _, p := range periodPoints { + flagged := p.RiskScore >= warningThreshold + switch { + case flagged && p.HadCiFailure: + tp++ + case flagged && !p.HadCiFailure: + fp++ + case !flagged && p.HadCiFailure: + fn++ + default: + tn++ + } + } + + ftpF := float64(tp) + ffpF := float64(fp) + ffnF := float64(fn) + ftnF := float64(tn) + total := ftpF + ffpF + ffnF + ftnF + + precision, recall, accuracy, f1, specificity, fprPct := 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + + if ftpF+ffpF > 0 { + precision = ftpF / (ftpF + ffpF) + } + if ftpF+ffnF > 0 { + recall = ftpF / (ftpF + ffnF) + } + if total > 0 { + accuracy = (ftpF + ftnF) / total + } + if precision+recall > 0 { + f1 = 2 * (precision * recall) / (precision + recall) + } + if ftnF+ffpF > 0 { + specificity = ftnF / (ftnF + ffpF) + fprPct = ffpF / (ftnF + ffpF) * 100 + } + + // AUC metrics from the broader point set. + prAuc, rocAuc := computeAucs(aucPoints) + + // Sample sizes. + flaggedPrs := tp + fp + failedPrs := tp + fn + + return &models.AiPredictionMetrics{ + Id: generateMetricsId(repoId, aiTool, ciFailureSource, periodType, periodStart), + RepoId: repoId, + AiTool: aiTool, + CiFailureSource: ciFailureSource, + PeriodStart: periodStart, + PeriodEnd: periodEnd, + PeriodType: periodType, + TruePositives: tp, + FalsePositives: fp, + FalseNegatives: fn, + TrueNegatives: tn, + Precision: precision, + Recall: recall, + Accuracy: accuracy, + F1Score: f1, + Specificity: specificity, + FprPct: fprPct, + PrAuc: prAuc, + RocAuc: rocAuc, + TotalPrs: len(periodPoints), + FlaggedPrs: flaggedPrs, + FailedPrs: failedPrs, + ObservedPrs: len(periodPoints), + WarningThreshold: warningThreshold, + RecommendedAutonomyLevel: determineAutonomyLevel(precision, recall), + CalculatedAt: time.Now(), + } +} + +// curvePoint holds precision-recall and ROC coordinates for one threshold. +type curvePoint struct { + threshold int + precision float64 + recall float64 + fpr float64 + tpr float64 +} + +// computeAucs computes PR-AUC and ROC-AUC using a trapezoidal rule over +// aucThresholds. Returns (0, 0) when there are fewer than 2 points. +func computeAucs(points []predictionPoint) (prAuc, rocAuc float64) { + if len(points) < 2 { + return 0, 0 + } + + curve := make([]curvePoint, len(aucThresholds)) + for i, t := range aucThresholds { + var tp, fp, fn, tn int + for _, p := range points { + flagged := p.RiskScore >= t + switch { + case flagged && p.HadCiFailure: + tp++ + case flagged && !p.HadCiFailure: + fp++ + case !flagged && p.HadCiFailure: + fn++ + default: + tn++ + } + } + + prec := 1.0 // by convention when nothing is flagged + if tp+fp > 0 { + prec = float64(tp) / float64(tp+fp) + } + rec := 0.0 + if tp+fn > 0 { + rec = float64(tp) / float64(tp+fn) + } + tpr := rec + fpr := 0.0 + if fp+tn > 0 { + fpr = float64(fp) / float64(fp+tn) + } + + curve[i] = curvePoint{threshold: t, precision: prec, recall: rec, fpr: fpr, tpr: tpr} + } + + // PR-AUC: trapezoidal over recall axis (thresholds ascending → recall descending). + // Sum |Δrecall| × average precision between consecutive points. + for i := 1; i < len(curve); i++ { + prev, curr := curve[i-1], curve[i] + deltaRecall := math.Abs(curr.recall - prev.recall) + prAuc += deltaRecall * (curr.precision + prev.precision) / 2 + } + + // ROC-AUC: trapezoidal over FPR axis (thresholds descending → FPR increasing). + // Reverse the curve so FPR goes 0 → 1. + for i := len(curve) - 2; i >= 0; i-- { + curr, next := curve[i+1], curve[i] + deltaFPR := next.fpr - curr.fpr + if deltaFPR > 0 { + rocAuc += deltaFPR * (next.tpr + curr.tpr) / 2 + } + } + + return prAuc, rocAuc +} + +// determineAutonomyLevel recommends AI autonomy based on precision and recall. +func determineAutonomyLevel(precision, recall float64) string { + if precision >= 0.80 && recall >= 0.70 { + return models.AutonomyAutoBlock + } + if precision >= 0.60 && recall >= 0.50 { + return models.AutonomyMandatoryReview + } + return models.AutonomyAdvisoryOnly +} + +// generateMetricsId creates a deterministic ID for a metrics record. +func generateMetricsId(repoId, aiTool, ciFailureSource, periodType string, periodStart time.Time) string { + hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%s:%s:%s", repoId, aiTool, ciFailureSource, periodType, periodStart.Format("2006-01-02")))) + return "aimetrics:" + hex.EncodeToString(hash[:16]) +} diff --git a/backend/plugins/aireview/tasks/calculate_prediction_metrics_test.go b/backend/plugins/aireview/tasks/calculate_prediction_metrics_test.go new file mode 100644 index 00000000000..684647e29f2 --- /dev/null +++ b/backend/plugins/aireview/tasks/calculate_prediction_metrics_test.go @@ -0,0 +1,300 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "strings" + "testing" + "time" + + "github.com/apache/devlake/core/errors" + mockdal "github.com/apache/devlake/mocks/core/dal" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestDetermineAutonomyLevel(t *testing.T) { + tests := []struct { + name string + precision float64 + recall float64 + want string + }{ + {"auto_block: high precision and recall", 0.85, 0.75, models.AutonomyAutoBlock}, + {"auto_block: exact boundary", 0.80, 0.70, models.AutonomyAutoBlock}, + {"mandatory_review: medium precision and recall", 0.65, 0.55, models.AutonomyMandatoryReview}, + {"mandatory_review: exact boundary", 0.60, 0.50, models.AutonomyMandatoryReview}, + {"mandatory_review: high precision low recall", 0.90, 0.55, models.AutonomyMandatoryReview}, + {"advisory_only: low precision", 0.50, 0.80, models.AutonomyAdvisoryOnly}, + {"advisory_only: low recall", 0.70, 0.40, models.AutonomyAdvisoryOnly}, + {"advisory_only: both low", 0.30, 0.30, models.AutonomyAdvisoryOnly}, + {"advisory_only: zero values", 0.0, 0.0, models.AutonomyAdvisoryOnly}, + {"auto_block: perfect scores", 1.0, 1.0, models.AutonomyAutoBlock}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, determineAutonomyLevel(tt.precision, tt.recall)) + }) + } +} + +func TestGenerateMetricsId(t *testing.T) { + ts := time.Date(2024, 6, 15, 0, 0, 0, 0, time.UTC) + + id1 := generateMetricsId("repo1", "CodeRabbit", "test_cases", "weekly", ts) + id2 := generateMetricsId("repo1", "CodeRabbit", "test_cases", "weekly", ts) + id3 := generateMetricsId("repo1", "CodeRabbit", "test_cases", "monthly", ts) + id4 := generateMetricsId("repo2", "CodeRabbit", "test_cases", "weekly", ts) + id5 := generateMetricsId("repo1", "Qodo", "test_cases", "weekly", ts) + + assert.Equal(t, id1, id2, "same inputs must produce same ID") + assert.NotEqual(t, id1, id3, "different period type must produce different ID") + assert.NotEqual(t, id1, id4, "different repo must produce different ID") + assert.NotEqual(t, id1, id5, "different tool must produce different ID") + assert.True(t, strings.HasPrefix(id1, "aimetrics:")) +} + +func TestComputeAucs(t *testing.T) { + t.Run("fewer than 2 points returns zeros", func(t *testing.T) { + prAuc, rocAuc := computeAucs([]predictionPoint{ + {RiskScore: 80, HadCiFailure: true}, + }) + assert.Equal(t, 0.0, prAuc) + assert.Equal(t, 0.0, rocAuc) + }) + + t.Run("empty points returns zeros", func(t *testing.T) { + prAuc, rocAuc := computeAucs(nil) + assert.Equal(t, 0.0, prAuc) + assert.Equal(t, 0.0, rocAuc) + }) + + t.Run("perfect classifier", func(t *testing.T) { + points := []predictionPoint{ + {RiskScore: 90, HadCiFailure: true}, + {RiskScore: 85, HadCiFailure: true}, + {RiskScore: 10, HadCiFailure: false}, + {RiskScore: 5, HadCiFailure: false}, + } + prAuc, rocAuc := computeAucs(points) + assert.Greater(t, prAuc, 0.0, "PR-AUC should be positive for a good classifier") + assert.Greater(t, rocAuc, 0.0, "ROC-AUC should be positive for a good classifier") + }) + + t.Run("random classifier has lower AUC", func(t *testing.T) { + goodPoints := []predictionPoint{ + {RiskScore: 90, HadCiFailure: true}, + {RiskScore: 85, HadCiFailure: true}, + {RiskScore: 10, HadCiFailure: false}, + {RiskScore: 5, HadCiFailure: false}, + } + randomPoints := []predictionPoint{ + {RiskScore: 90, HadCiFailure: false}, + {RiskScore: 85, HadCiFailure: true}, + {RiskScore: 10, HadCiFailure: true}, + {RiskScore: 5, HadCiFailure: false}, + } + goodPr, goodRoc := computeAucs(goodPoints) + randPr, randRoc := computeAucs(randomPoints) + + assert.Greater(t, goodPr, randPr, "good classifier should have higher PR-AUC") + assert.Greater(t, goodRoc, randRoc, "good classifier should have higher ROC-AUC") + }) + + t.Run("all same risk score", func(t *testing.T) { + points := []predictionPoint{ + {RiskScore: 50, HadCiFailure: true}, + {RiskScore: 50, HadCiFailure: false}, + {RiskScore: 50, HadCiFailure: true}, + } + prAuc, rocAuc := computeAucs(points) + assert.GreaterOrEqual(t, prAuc, 0.0) + assert.GreaterOrEqual(t, rocAuc, 0.0) + }) +} + +func TestComputeMetrics(t *testing.T) { + now := time.Now() + weekAgo := now.AddDate(0, 0, -7) + + t.Run("all true positives", func(t *testing.T) { + points := []predictionPoint{ + {RiskScore: 80, HadCiFailure: true}, + {RiskScore: 90, HadCiFailure: true}, + {RiskScore: 70, HadCiFailure: true}, + } + m := computeMetrics("repo1", "CodeRabbit", "test_cases", "weekly", weekAgo, now, points, points, 50) + + assert.Equal(t, 3, m.TruePositives) + assert.Equal(t, 0, m.FalsePositives) + assert.Equal(t, 0, m.FalseNegatives) + assert.Equal(t, 0, m.TrueNegatives) + assert.Equal(t, 1.0, m.Precision) + assert.Equal(t, 1.0, m.Recall) + assert.Equal(t, 1.0, m.Accuracy) + assert.Equal(t, 1.0, m.F1Score) + assert.Equal(t, "repo1", m.RepoId) + assert.Equal(t, "CodeRabbit", m.AiTool) + assert.Equal(t, "weekly", m.PeriodType) + assert.Equal(t, 50, m.WarningThreshold) + }) + + t.Run("all true negatives", func(t *testing.T) { + points := []predictionPoint{ + {RiskScore: 10, HadCiFailure: false}, + {RiskScore: 20, HadCiFailure: false}, + } + m := computeMetrics("repo1", "CodeRabbit", "test_cases", "daily", weekAgo, now, points, points, 50) + + assert.Equal(t, 0, m.TruePositives) + assert.Equal(t, 0, m.FalsePositives) + assert.Equal(t, 0, m.FalseNegatives) + assert.Equal(t, 2, m.TrueNegatives) + assert.Equal(t, 0.0, m.Precision) + assert.Equal(t, 0.0, m.Recall) + assert.Equal(t, 1.0, m.Accuracy) + assert.Equal(t, 1.0, m.Specificity) + }) + + t.Run("mixed confusion matrix", func(t *testing.T) { + points := []predictionPoint{ + {RiskScore: 80, HadCiFailure: true}, // TP + {RiskScore: 70, HadCiFailure: false}, // FP + {RiskScore: 20, HadCiFailure: true}, // FN + {RiskScore: 10, HadCiFailure: false}, // TN + } + m := computeMetrics("repo1", "CodeRabbit", "job_result", "monthly", weekAgo, now, points, points, 50) + + assert.Equal(t, 1, m.TruePositives) + assert.Equal(t, 1, m.FalsePositives) + assert.Equal(t, 1, m.FalseNegatives) + assert.Equal(t, 1, m.TrueNegatives) + assert.InDelta(t, 0.5, m.Precision, 0.001) + assert.InDelta(t, 0.5, m.Recall, 0.001) + assert.InDelta(t, 0.5, m.Accuracy, 0.001) + assert.InDelta(t, 0.5, m.F1Score, 0.001) + assert.InDelta(t, 0.5, m.Specificity, 0.001) + assert.InDelta(t, 50.0, m.FprPct, 0.001) + assert.Equal(t, 4, m.TotalPrs) + assert.Equal(t, 2, m.FlaggedPrs) + assert.Equal(t, 2, m.FailedPrs) + }) + + t.Run("zero division safety with empty points", func(t *testing.T) { + points := []predictionPoint{} + m := computeMetrics("repo1", "CodeRabbit", "test_cases", "daily", weekAgo, now, points, points, 50) + + assert.Equal(t, 0.0, m.Precision) + assert.Equal(t, 0.0, m.Recall) + assert.Equal(t, 0.0, m.Accuracy) + assert.Equal(t, 0.0, m.F1Score) + assert.Equal(t, 0, m.TotalPrs) + }) + + t.Run("autonomy level propagated correctly", func(t *testing.T) { + highPrecisionPoints := []predictionPoint{ + {RiskScore: 80, HadCiFailure: true}, + {RiskScore: 90, HadCiFailure: true}, + {RiskScore: 85, HadCiFailure: true}, + {RiskScore: 95, HadCiFailure: true}, + {RiskScore: 75, HadCiFailure: true}, + {RiskScore: 10, HadCiFailure: false}, + {RiskScore: 5, HadCiFailure: false}, + } + m := computeMetrics("repo1", "CodeRabbit", "test_cases", "weekly", weekAgo, now, highPrecisionPoints, highPrecisionPoints, 50) + assert.Equal(t, models.AutonomyAutoBlock, m.RecommendedAutonomyLevel) + }) + + t.Run("metrics ID is deterministic", func(t *testing.T) { + points := []predictionPoint{{RiskScore: 50, HadCiFailure: true}} + m1 := computeMetrics("repo1", "CodeRabbit", "test_cases", "weekly", weekAgo, now, points, points, 50) + m2 := computeMetrics("repo1", "CodeRabbit", "test_cases", "weekly", weekAgo, now, points, points, 50) + assert.Equal(t, m1.Id, m2.Id) + assert.True(t, strings.HasPrefix(m1.Id, "aimetrics:")) + }) + + t.Run("ci_failure_source preserved", func(t *testing.T) { + points := []predictionPoint{{RiskScore: 50, HadCiFailure: true}} + m := computeMetrics("repo1", "CodeRabbit", "job_result", "daily", weekAgo, now, points, points, 50) + assert.Equal(t, "job_result", m.CiFailureSource) + }) +} + +func TestLoadPredictionPoints(t *testing.T) { + t.Run("all-time (start is zero)", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]struct { + RiskScore int `gorm:"column:risk_score"` + HadCiFailure bool `gorm:"column:had_ci_failure"` + }) + *dst = []struct { + RiskScore int `gorm:"column:risk_score"` + HadCiFailure bool `gorm:"column:had_ci_failure"` + }{ + {RiskScore: 80, HadCiFailure: true}, + {RiskScore: 20, HadCiFailure: false}, + {RiskScore: 60, HadCiFailure: true}, + } + }).Return(nil) + + points, err := loadPredictionPoints(mockDal, "repo-1", "CodeRabbit", "test_cases", time.Time{}, time.Now()) + assert.Nil(t, err) + assert.Len(t, points, 3) + assert.Equal(t, 80, points[0].RiskScore) + assert.True(t, points[0].HadCiFailure) + assert.Equal(t, 20, points[1].RiskScore) + assert.False(t, points[1].HadCiFailure) + }) + + t.Run("with time range", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]struct { + RiskScore int `gorm:"column:risk_score"` + HadCiFailure bool `gorm:"column:had_ci_failure"` + }) + *dst = []struct { + RiskScore int `gorm:"column:risk_score"` + HadCiFailure bool `gorm:"column:had_ci_failure"` + }{ + {RiskScore: 50, HadCiFailure: false}, + } + }).Return(nil) + + now := time.Now() + start := now.AddDate(0, 0, -7) + points, err := loadPredictionPoints(mockDal, "repo-1", "CodeRabbit", "job_result", start, now) + assert.Nil(t, err) + assert.Len(t, points, 1) + assert.Equal(t, 50, points[0].RiskScore) + assert.False(t, points[0].HadCiFailure) + }) + + t.Run("error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + points, err := loadPredictionPoints(mockDal, "repo-1", "CodeRabbit", "test_cases", time.Time{}, time.Now()) + assert.NotNil(t, err) + assert.Nil(t, points) + assert.Contains(t, err.Error(), "prediction points") + }) +} diff --git a/backend/plugins/aireview/tasks/convert_ai_commits.go b/backend/plugins/aireview/tasks/convert_ai_commits.go new file mode 100644 index 00000000000..f26d0cf7a6b --- /dev/null +++ b/backend/plugins/aireview/tasks/convert_ai_commits.go @@ -0,0 +1,109 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer" + domainCode "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +var ConvertAiCommitsMeta = plugin.SubTaskMeta{ + Name: "convertAiCommits", + EntryPoint: ConvertAiCommits, + EnabledByDefault: true, + Description: "Convert tool-layer AI commits into project-scoped domain table ai_commits", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&ExtractAiCommitsMeta}, +} + +// ConvertAiCommits reads _tool_aireview_commits scoped to the current project +// and writes project-stamped rows into the ai_commits domain table. +// Only runs in project mode; no-ops silently in single-repo mode. +func ConvertAiCommits(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + projectName := data.Options.ProjectName + if projectName == "" { + logger.Info("convertAiCommits: skipping — no projectName set (single-repo mode)") + return nil + } + + if err := db.Delete(&domainCode.AiCommit{}, dal.Where("project_name = ?", projectName)); err != nil { + return errors.Default.Wrap(err, "failed to delete existing ai_commits for project") + } + + cursor, err := db.Cursor( + dal.From(&models.AiCommit{}), + dal.Join("JOIN project_mapping pm ON _tool_aireview_commits.repo_id = pm.row_id AND pm.`table` = 'repos'"), + dal.Where("pm.project_name = ?", projectName), + ) + if err != nil { + return errors.Default.Wrap(err, "failed to cursor ai commits") + } + defer cursor.Close() + + batch := make([]*domainCode.AiCommit, 0, 100) + for cursor.Next() { + var src models.AiCommit + if fetchErr := db.Fetch(cursor, &src); fetchErr != nil { + return errors.Default.Wrap(fetchErr, "failed to fetch ai commit row") + } + + batch = append(batch, &domainCode.AiCommit{ + DomainEntity: domainlayer.DomainEntity{ + Id: generateAiDomainId("ac", projectName, src.Id), + }, + ProjectName: projectName, + CommitSha: src.CommitSha, + RepoId: src.RepoId, + AiTool: src.AiTool, + AuthorName: src.AuthorName, + AuthoredDate: src.AuthoredDate, + }) + + if len(batch) >= 100 { + if saveErr := saveDomainAiCommitBatch(db, batch); saveErr != nil { + return saveErr + } + batch = batch[:0] + } + } + if len(batch) > 0 { + if saveErr := saveDomainAiCommitBatch(db, batch); saveErr != nil { + return saveErr + } + } + + logger.Info("convertAiCommits: done for project %s", projectName) + return nil +} + +func saveDomainAiCommitBatch(db dal.Dal, batch []*domainCode.AiCommit) errors.Error { + for _, c := range batch { + if err := db.CreateOrUpdate(c); err != nil { + return errors.Default.Wrap(err, "failed to save domain ai commit") + } + } + return nil +} diff --git a/backend/plugins/aireview/tasks/convert_ai_commits_test.go b/backend/plugins/aireview/tasks/convert_ai_commits_test.go new file mode 100644 index 00000000000..2dd814b41ba --- /dev/null +++ b/backend/plugins/aireview/tasks/convert_ai_commits_test.go @@ -0,0 +1,114 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/apache/devlake/core/errors" + domainCode "github.com/apache/devlake/core/models/domainlayer/code" + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + mockplugin "github.com/apache/devlake/mocks/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestConvertAiCommits_NoProjectName(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDalI := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + data := &AiReviewTaskData{ + Options: &AiReviewOptions{ProjectName: ""}, + } + + mockCtx.On("GetDal").Return(mockDalI) + mockCtx.On("GetLogger").Return(mockLogger) + mockCtx.On("GetData").Return(data) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + err := ConvertAiCommits(mockCtx) + assert.Nil(t, err) +} + +func TestConvertAiCommits_WithProject(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDalI := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockRows := new(mockdal.Rows) + + data := &AiReviewTaskData{ + Options: &AiReviewOptions{ProjectName: "my-project"}, + } + + mockCtx.On("GetDal").Return(mockDalI) + mockCtx.On("GetLogger").Return(mockLogger) + mockCtx.On("GetData").Return(data) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + mockDalI.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDalI.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDalI.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*models.AiCommit) + *dst = models.AiCommit{ + Id: "src-1", + CommitSha: "abc123", + RepoId: "repo-1", + AiTool: models.AiToolCursor, + } + }).Return(nil) + + // Generated Dal.CreateOrUpdate mock always records (entity, clauses), even + // when production code calls CreateOrUpdate(c) with no extra clauses. + mockDalI.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertAiCommits(mockCtx) + assert.Nil(t, err) + mockDalI.AssertCalled(t, "CreateOrUpdate", mock.Anything, mock.Anything) +} + +func TestSaveDomainAiCommitBatch(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + err := saveDomainAiCommitBatch(mockDal, []*domainCode.AiCommit{ + {ProjectName: "p1"}, + }) + assert.Nil(t, err) + }) + + t.Run("empty batch", func(t *testing.T) { + mockDal := new(mockdal.Dal) + err := saveDomainAiCommitBatch(mockDal, []*domainCode.AiCommit{}) + assert.Nil(t, err) + }) + + t.Run("error on save", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + err := saveDomainAiCommitBatch(mockDal, []*domainCode.AiCommit{{ProjectName: "p1"}}) + assert.NotNil(t, err) + }) +} diff --git a/backend/plugins/aireview/tasks/convert_ai_reviews.go b/backend/plugins/aireview/tasks/convert_ai_reviews.go new file mode 100644 index 00000000000..c23daef9f42 --- /dev/null +++ b/backend/plugins/aireview/tasks/convert_ai_reviews.go @@ -0,0 +1,131 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + domainCode "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +var ConvertAiReviewsMeta = plugin.SubTaskMeta{ + Name: "convertAiReviews", + EntryPoint: ConvertAiReviews, + EnabledByDefault: true, + Description: "Convert tool-layer AI reviews into project-scoped domain table ai_reviews", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW}, + Dependencies: []*plugin.SubTaskMeta{&ExtractAiReviewsMeta}, +} + +// ConvertAiReviews reads _tool_aireview_reviews scoped to the current project +// and writes project-stamped rows into the ai_reviews domain table. +// Only runs in project mode; no-ops silently in single-repo mode. +func ConvertAiReviews(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + projectName := data.Options.ProjectName + if projectName == "" { + logger.Info("convertAiReviews: skipping — no projectName set (single-repo mode)") + return nil + } + + // Clear existing domain records for this project so re-runs are idempotent. + if err := db.Delete(&domainCode.AiReview{}, dal.Where("project_name = ?", projectName)); err != nil { + return errors.Default.Wrap(err, "failed to delete existing ai_reviews for project") + } + + cursor, err := db.Cursor( + dal.From(&models.AiReview{}), + dal.Join("JOIN project_mapping pm ON _tool_aireview_reviews.repo_id = pm.row_id AND pm.`table` = 'repos'"), + dal.Where("pm.project_name = ? AND _tool_aireview_reviews.body NOT LIKE '%Review skipped%'", projectName), + ) + if err != nil { + return errors.Default.Wrap(err, "failed to cursor ai reviews") + } + defer cursor.Close() + + batch := make([]*domainCode.AiReview, 0, 100) + for cursor.Next() { + var src models.AiReview + if fetchErr := db.Fetch(cursor, &src); fetchErr != nil { + return errors.Default.Wrap(fetchErr, "failed to fetch ai review row") + } + + batch = append(batch, &domainCode.AiReview{ + DomainEntity: domainlayer.DomainEntity{ + Id: generateAiDomainId("ar", projectName, src.Id), + }, + ProjectName: projectName, + PullRequestId: src.PullRequestId, + RepoId: src.RepoId, + AiTool: src.AiTool, + CreatedDate: src.CreatedDate, + RiskLevel: src.RiskLevel, + RiskScore: src.RiskScore, + IssuesFound: src.IssuesFound, + SuggestionsCount: src.SuggestionsCount, + PreMergeChecksPassed: src.PreMergeChecksPassed, + PreMergeChecksFailed: src.PreMergeChecksFailed, + ReactionsTotalCount: src.ReactionsTotalCount, + ReactionsThumbsUp: src.ReactionsThumbsUp, + ReactionsThumbsDown: src.ReactionsThumbsDown, + ReviewState: src.ReviewState, + SourceUrl: src.SourceUrl, + }) + + if len(batch) >= 100 { + if saveErr := saveAiReviewBatch(db, batch); saveErr != nil { + return saveErr + } + batch = batch[:0] + } + } + if len(batch) > 0 { + if saveErr := saveAiReviewBatch(db, batch); saveErr != nil { + return saveErr + } + } + + logger.Info("convertAiReviews: done for project %s", projectName) + return nil +} + +func saveAiReviewBatch(db dal.Dal, batch []*domainCode.AiReview) errors.Error { + for _, r := range batch { + if err := db.CreateOrUpdate(r); err != nil { + return errors.Default.Wrap(err, "failed to save domain ai review") + } + } + return nil +} + +// generateAiDomainId creates a stable, project-scoped domain ID from a prefix, +// project name, and the source tool-layer record ID. +func generateAiDomainId(prefix, projectName, sourceId string) string { + hash := sha256.Sum256(fmt.Appendf(nil, "%s:%s:%s", prefix, projectName, sourceId)) + return prefix + ":" + hex.EncodeToString(hash[:16]) +} diff --git a/backend/plugins/aireview/tasks/convert_ai_reviews_test.go b/backend/plugins/aireview/tasks/convert_ai_reviews_test.go new file mode 100644 index 00000000000..1a32222c2ef --- /dev/null +++ b/backend/plugins/aireview/tasks/convert_ai_reviews_test.go @@ -0,0 +1,156 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "strings" + "testing" + + "github.com/apache/devlake/core/errors" + domainCode "github.com/apache/devlake/core/models/domainlayer/code" + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + mockplugin "github.com/apache/devlake/mocks/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestGenerateAiDomainId(t *testing.T) { + t.Run("deterministic output", func(t *testing.T) { + id1 := generateAiDomainId("ar", "my-project", "src-123") + id2 := generateAiDomainId("ar", "my-project", "src-123") + assert.Equal(t, id1, id2) + }) + + t.Run("different prefix produces different ID", func(t *testing.T) { + id1 := generateAiDomainId("ar", "proj", "src-1") + id2 := generateAiDomainId("fp", "proj", "src-1") + assert.NotEqual(t, id1, id2) + }) + + t.Run("different project produces different ID", func(t *testing.T) { + id1 := generateAiDomainId("ar", "project-a", "src-1") + id2 := generateAiDomainId("ar", "project-b", "src-1") + assert.NotEqual(t, id1, id2) + }) + + t.Run("different source produces different ID", func(t *testing.T) { + id1 := generateAiDomainId("ar", "proj", "src-1") + id2 := generateAiDomainId("ar", "proj", "src-2") + assert.NotEqual(t, id1, id2) + }) + + t.Run("prefix appears in output", func(t *testing.T) { + id := generateAiDomainId("ar", "proj", "src-1") + assert.True(t, strings.HasPrefix(id, "ar:")) + }) + + t.Run("non-empty output", func(t *testing.T) { + id := generateAiDomainId("x", "y", "z") + assert.NotEmpty(t, id) + }) +} + +func TestSaveAiReviewBatch(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + batch := []*domainCode.AiReview{ + {ProjectName: "proj1"}, + {ProjectName: "proj2"}, + } + err := saveAiReviewBatch(mockDal, batch) + assert.Nil(t, err) + mockDal.AssertNumberOfCalls(t, "CreateOrUpdate", 2) + }) + + t.Run("empty batch", func(t *testing.T) { + mockDal := new(mockdal.Dal) + err := saveAiReviewBatch(mockDal, []*domainCode.AiReview{}) + assert.Nil(t, err) + }) + + t.Run("error on save", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + batch := []*domainCode.AiReview{{ProjectName: "p1"}} + err := saveAiReviewBatch(mockDal, batch) + assert.NotNil(t, err) + }) +} + +func TestConvertAiReviews_NoProjectName(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDalI := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + data := &AiReviewTaskData{ + Options: &AiReviewOptions{ProjectName: ""}, + } + + mockCtx.On("GetDal").Return(mockDalI) + mockCtx.On("GetLogger").Return(mockLogger) + mockCtx.On("GetData").Return(data) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + err := ConvertAiReviews(mockCtx) + assert.Nil(t, err) +} + +func TestConvertAiReviews_WithProject(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDalI := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockRows := new(mockdal.Rows) + + data := &AiReviewTaskData{ + Options: &AiReviewOptions{ProjectName: "my-project"}, + } + + mockCtx.On("GetDal").Return(mockDalI) + mockCtx.On("GetLogger").Return(mockLogger) + mockCtx.On("GetData").Return(data) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + mockDalI.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDalI.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDalI.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*models.AiReview) + *dst = models.AiReview{ + Id: "src-1", + PullRequestId: "pr-1", + RepoId: "repo-1", + AiTool: "CodeRabbit", + RiskLevel: "high", + } + }).Return(nil) + + mockDalI.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertAiReviews(mockCtx) + assert.Nil(t, err) + mockDalI.AssertCalled(t, "CreateOrUpdate", mock.Anything, mock.Anything) +} diff --git a/backend/plugins/aireview/tasks/convert_failure_predictions.go b/backend/plugins/aireview/tasks/convert_failure_predictions.go new file mode 100644 index 00000000000..b2b700859f9 --- /dev/null +++ b/backend/plugins/aireview/tasks/convert_failure_predictions.go @@ -0,0 +1,122 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + domainCode "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +var ConvertFailurePredictionsMeta = plugin.SubTaskMeta{ + Name: "convertFailurePredictions", + EntryPoint: ConvertFailurePredictions, + EnabledByDefault: true, + Description: "Convert tool-layer AI failure predictions into project-scoped domain table ai_failure_predictions", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW}, + Dependencies: []*plugin.SubTaskMeta{&CalculateFailurePredictionsMeta}, +} + +// ConvertFailurePredictions reads _tool_aireview_failure_predictions scoped to +// the current project and writes project-stamped rows into ai_failure_predictions. +// Only runs in project mode; no-ops silently in single-repo mode. +func ConvertFailurePredictions(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + projectName := data.Options.ProjectName + if projectName == "" { + logger.Info("convertFailurePredictions: skipping — no projectName set (single-repo mode)") + return nil + } + + if err := db.Delete(&domainCode.AiFailurePrediction{}, dal.Where("project_name = ?", projectName)); err != nil { + return errors.Default.Wrap(err, "failed to delete existing ai_failure_predictions for project") + } + + // _tool_aireview_failure_predictions stores repo_id which matches project_mapping.row_id. + cursor, err := db.Cursor( + dal.From(&models.AiFailurePrediction{}), + dal.Join("JOIN project_mapping pm ON _tool_aireview_failure_predictions.repo_id = pm.row_id AND pm.`table` = 'repos'"), + dal.Where("pm.project_name = ?", projectName), + ) + if err != nil { + return errors.Default.Wrap(err, "failed to cursor failure predictions") + } + defer cursor.Close() + + batch := make([]*domainCode.AiFailurePrediction, 0, 100) + for cursor.Next() { + var src models.AiFailurePrediction + if fetchErr := db.Fetch(cursor, &src); fetchErr != nil { + return errors.Default.Wrap(fetchErr, "failed to fetch failure prediction row") + } + + batch = append(batch, &domainCode.AiFailurePrediction{ + DomainEntity: domainlayer.DomainEntity{ + Id: generateAiDomainId("afp", projectName, src.Id), + }, + ProjectName: projectName, + PullRequestId: src.PullRequestId, + PullRequestKey: src.PullRequestKey, + RepoId: src.RepoId, + RepoName: src.RepoName, + AiTool: src.AiTool, + CiFailureSource: src.CiFailureSource, + PrTitle: src.PrTitle, + PrUrl: src.PrUrl, + PrAuthor: src.PrAuthor, + PrCreatedAt: src.PrCreatedAt, + Additions: src.Additions, + Deletions: src.Deletions, + WasFlaggedRisky: src.WasFlaggedRisky, + RiskScore: src.RiskScore, + FlaggedAt: src.FlaggedAt, + HadCiFailure: src.HadCiFailure, + PredictionOutcome: src.PredictionOutcome, + }) + + if len(batch) >= 100 { + if saveErr := saveFailurePredictionBatch(db, batch); saveErr != nil { + return saveErr + } + batch = batch[:0] + } + } + if len(batch) > 0 { + if saveErr := saveFailurePredictionBatch(db, batch); saveErr != nil { + return saveErr + } + } + + logger.Info("convertFailurePredictions: done for project %s", projectName) + return nil +} + +func saveFailurePredictionBatch(db dal.Dal, batch []*domainCode.AiFailurePrediction) errors.Error { + for _, p := range batch { + if err := db.CreateOrUpdate(p); err != nil { + return errors.Default.Wrap(err, "failed to save domain failure prediction") + } + } + return nil +} diff --git a/backend/plugins/aireview/tasks/convert_failure_predictions_test.go b/backend/plugins/aireview/tasks/convert_failure_predictions_test.go new file mode 100644 index 00000000000..faa9e7ee06a --- /dev/null +++ b/backend/plugins/aireview/tasks/convert_failure_predictions_test.go @@ -0,0 +1,119 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/apache/devlake/core/errors" + domainCode "github.com/apache/devlake/core/models/domainlayer/code" + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + mockplugin "github.com/apache/devlake/mocks/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestSaveFailurePredictionBatch(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + batch := []*domainCode.AiFailurePrediction{ + {ProjectName: "proj1"}, + {ProjectName: "proj2"}, + } + err := saveFailurePredictionBatch(mockDal, batch) + assert.Nil(t, err) + mockDal.AssertNumberOfCalls(t, "CreateOrUpdate", 2) + }) + + t.Run("empty batch", func(t *testing.T) { + mockDal := new(mockdal.Dal) + err := saveFailurePredictionBatch(mockDal, []*domainCode.AiFailurePrediction{}) + assert.Nil(t, err) + }) + + t.Run("error on save", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + batch := []*domainCode.AiFailurePrediction{{ProjectName: "p1"}} + err := saveFailurePredictionBatch(mockDal, batch) + assert.NotNil(t, err) + }) +} + +func TestConvertFailurePredictions_NoProjectName(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDalI := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + data := &AiReviewTaskData{ + Options: &AiReviewOptions{ProjectName: ""}, + } + + mockCtx.On("GetDal").Return(mockDalI) + mockCtx.On("GetLogger").Return(mockLogger) + mockCtx.On("GetData").Return(data) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + err := ConvertFailurePredictions(mockCtx) + assert.Nil(t, err) +} + +func TestConvertFailurePredictions_WithProject(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDalI := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockRows := new(mockdal.Rows) + + data := &AiReviewTaskData{ + Options: &AiReviewOptions{ProjectName: "my-project"}, + } + + mockCtx.On("GetDal").Return(mockDalI) + mockCtx.On("GetLogger").Return(mockLogger) + mockCtx.On("GetData").Return(data) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + mockDalI.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDalI.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDalI.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*models.AiFailurePrediction) + *dst = models.AiFailurePrediction{ + Id: "pred-1", + PullRequestId: "pr-1", + RepoId: "repo-1", + AiTool: "CodeRabbit", + RiskScore: 80, + } + }).Return(nil) + + mockDalI.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertFailurePredictions(mockCtx) + assert.Nil(t, err) + mockDalI.AssertCalled(t, "CreateOrUpdate", mock.Anything, mock.Anything) +} diff --git a/backend/plugins/aireview/tasks/convert_prediction_metrics.go b/backend/plugins/aireview/tasks/convert_prediction_metrics.go new file mode 100644 index 00000000000..1d3fedf2b31 --- /dev/null +++ b/backend/plugins/aireview/tasks/convert_prediction_metrics.go @@ -0,0 +1,129 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + domainCode "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +var ConvertPredictionMetricsMeta = plugin.SubTaskMeta{ + Name: "convertPredictionMetrics", + EntryPoint: ConvertPredictionMetrics, + EnabledByDefault: true, + Description: "Convert tool-layer prediction metrics into project-scoped domain table ai_prediction_metrics", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW}, + Dependencies: []*plugin.SubTaskMeta{&CalculatePredictionMetricsMeta}, +} + +// ConvertPredictionMetrics reads _tool_aireview_prediction_metrics scoped to +// the current project and writes project-stamped rows into ai_prediction_metrics. +// Only runs in project mode; no-ops silently in single-repo mode. +func ConvertPredictionMetrics(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + projectName := data.Options.ProjectName + if projectName == "" { + logger.Info("convertPredictionMetrics: skipping — no projectName set (single-repo mode)") + return nil + } + + if err := db.Delete(&domainCode.AiPredictionMetrics{}, dal.Where("project_name = ?", projectName)); err != nil { + return errors.Default.Wrap(err, "failed to delete existing ai_prediction_metrics for project") + } + + // _tool_aireview_prediction_metrics is keyed by repo_id, which matches project_mapping.row_id. + cursor, err := db.Cursor( + dal.From(&models.AiPredictionMetrics{}), + dal.Join("JOIN project_mapping pm ON _tool_aireview_prediction_metrics.repo_id = pm.row_id AND pm.`table` = 'repos'"), + dal.Where("pm.project_name = ?", projectName), + ) + if err != nil { + return errors.Default.Wrap(err, "failed to cursor prediction metrics") + } + defer cursor.Close() + + batch := make([]*domainCode.AiPredictionMetrics, 0, 100) + for cursor.Next() { + var src models.AiPredictionMetrics + if fetchErr := db.Fetch(cursor, &src); fetchErr != nil { + return errors.Default.Wrap(fetchErr, "failed to fetch prediction metrics row") + } + + batch = append(batch, &domainCode.AiPredictionMetrics{ + DomainEntity: domainlayer.DomainEntity{ + Id: generateAiDomainId("apm", projectName, src.Id), + }, + ProjectName: projectName, + RepoId: src.RepoId, + AiTool: src.AiTool, + CiFailureSource: src.CiFailureSource, + PeriodStart: src.PeriodStart, + PeriodEnd: src.PeriodEnd, + PeriodType: src.PeriodType, + TruePositives: src.TruePositives, + FalsePositives: src.FalsePositives, + FalseNegatives: src.FalseNegatives, + TrueNegatives: src.TrueNegatives, + Precision: src.Precision, + Recall: src.Recall, + Accuracy: src.Accuracy, + F1Score: src.F1Score, + Specificity: src.Specificity, + FprPct: src.FprPct, + PrAuc: src.PrAuc, + RocAuc: src.RocAuc, + WarningThreshold: src.WarningThreshold, + TotalPrs: src.TotalPrs, + FlaggedPrs: src.FlaggedPrs, + FailedPrs: src.FailedPrs, + RecommendedAutonomyLevel: src.RecommendedAutonomyLevel, + CalculatedAt: src.CalculatedAt, + }) + + if len(batch) >= 100 { + if saveErr := savePredictionMetricsBatch(db, batch); saveErr != nil { + return saveErr + } + batch = batch[:0] + } + } + if len(batch) > 0 { + if saveErr := savePredictionMetricsBatch(db, batch); saveErr != nil { + return saveErr + } + } + + logger.Info("convertPredictionMetrics: done for project %s", projectName) + return nil +} + +func savePredictionMetricsBatch(db dal.Dal, batch []*domainCode.AiPredictionMetrics) errors.Error { + for _, m := range batch { + if err := db.CreateOrUpdate(m); err != nil { + return errors.Default.Wrap(err, "failed to save domain prediction metrics") + } + } + return nil +} diff --git a/backend/plugins/aireview/tasks/convert_prediction_metrics_test.go b/backend/plugins/aireview/tasks/convert_prediction_metrics_test.go new file mode 100644 index 00000000000..57168f0b848 --- /dev/null +++ b/backend/plugins/aireview/tasks/convert_prediction_metrics_test.go @@ -0,0 +1,121 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/apache/devlake/core/errors" + domainCode "github.com/apache/devlake/core/models/domainlayer/code" + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + mockplugin "github.com/apache/devlake/mocks/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestSavePredictionMetricsBatch(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + batch := []*domainCode.AiPredictionMetrics{ + {ProjectName: "proj1"}, + {ProjectName: "proj2"}, + } + err := savePredictionMetricsBatch(mockDal, batch) + assert.Nil(t, err) + mockDal.AssertNumberOfCalls(t, "CreateOrUpdate", 2) + }) + + t.Run("empty batch", func(t *testing.T) { + mockDal := new(mockdal.Dal) + err := savePredictionMetricsBatch(mockDal, []*domainCode.AiPredictionMetrics{}) + assert.Nil(t, err) + }) + + t.Run("error on save", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + batch := []*domainCode.AiPredictionMetrics{{ProjectName: "p1"}} + err := savePredictionMetricsBatch(mockDal, batch) + assert.NotNil(t, err) + }) +} + +func TestConvertPredictionMetrics_NoProjectName(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDalI := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + data := &AiReviewTaskData{ + Options: &AiReviewOptions{ProjectName: ""}, + } + + mockCtx.On("GetDal").Return(mockDalI) + mockCtx.On("GetLogger").Return(mockLogger) + mockCtx.On("GetData").Return(data) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + err := ConvertPredictionMetrics(mockCtx) + assert.Nil(t, err) +} + +func TestConvertPredictionMetrics_WithProject(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDalI := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockRows := new(mockdal.Rows) + + data := &AiReviewTaskData{ + Options: &AiReviewOptions{ProjectName: "my-project"}, + } + + mockCtx.On("GetDal").Return(mockDalI) + mockCtx.On("GetLogger").Return(mockLogger) + mockCtx.On("GetData").Return(data) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + mockDalI.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDalI.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDalI.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*models.AiPredictionMetrics) + *dst = models.AiPredictionMetrics{ + Id: "metrics-1", + RepoId: "repo-1", + AiTool: "CodeRabbit", + CiFailureSource: "job_result", + PeriodType: "weekly", + TruePositives: 5, + FalsePositives: 2, + } + }).Return(nil) + + mockDalI.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertPredictionMetrics(mockCtx) + assert.Nil(t, err) + mockDalI.AssertCalled(t, "CreateOrUpdate", mock.Anything, mock.Anything) +} diff --git a/backend/plugins/aireview/tasks/detect_ai_commit.go b/backend/plugins/aireview/tasks/detect_ai_commit.go new file mode 100644 index 00000000000..854e1d32997 --- /dev/null +++ b/backend/plugins/aireview/tasks/detect_ai_commit.go @@ -0,0 +1,74 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "regexp" + + "github.com/apache/devlake/plugins/aireview/models" +) + +// trailerSuffixLen is how many bytes of the commit message to inspect. +// Co-Authored-By / Assisted-by / Made-with trailers live at the end. +const trailerSuffixLen = 2048 + +var ( + cursorTrailerRe = regexp.MustCompile(`(?i)(?:co-authored-by:|assisted-by:|made-with:).*(?:cursor(?:\.com)?)`) + claudeTrailerRe = regexp.MustCompile(`(?i)(?:co-authored-by:|assisted-by:|made-with:).*(?:claude|anthropic\.com)`) + copilotTrailerRe = regexp.MustCompile(`(?i)(?:co-authored-by:|assisted-by:|made-with:).*copilot`) + codeRabbitTrailerRe = regexp.MustCompile(`(?i)(?:co-authored-by:|assisted-by:|made-with:).*coderabbit`) + codeRabbitAuthorRe = regexp.MustCompile(`(?i)coderabbit`) + assistedByRe = regexp.MustCompile(`(?i)assisted-by:`) + madeWithRe = regexp.MustCompile(`(?i)made-with:`) +) + +// commitTrailer returns the suffix of message that typically contains git trailers. +func commitTrailer(message string) string { + if len(message) <= trailerSuffixLen { + return message + } + return message[len(message)-trailerSuffixLen:] +} + +// detectAiCommit reports whether a commit is AI-assisted and which tool authored it. +func detectAiCommit(message, authorName string, extraPattern *regexp.Regexp) (string, bool) { + trailer := commitTrailer(message) + + if cursorTrailerRe.MatchString(trailer) { + return models.AiToolCursor, true + } + if claudeTrailerRe.MatchString(trailer) { + return models.AiToolClaude, true + } + if copilotTrailerRe.MatchString(trailer) { + return models.AiToolCopilot, true + } + if codeRabbitTrailerRe.MatchString(trailer) || codeRabbitAuthorRe.MatchString(authorName) { + return models.AiToolCodeRabbit, true + } + if assistedByRe.MatchString(trailer) { + return models.AiToolAssistedByUnknown, true + } + if madeWithRe.MatchString(trailer) { + return models.AiToolMadeWithUnknown, true + } + if extraPattern != nil && extraPattern.MatchString(trailer) { + return models.AiToolOther, true + } + return "", false +} diff --git a/backend/plugins/aireview/tasks/detect_ai_commit_test.go b/backend/plugins/aireview/tasks/detect_ai_commit_test.go new file mode 100644 index 00000000000..fdbdac14f56 --- /dev/null +++ b/backend/plugins/aireview/tasks/detect_ai_commit_test.go @@ -0,0 +1,174 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "regexp" + "strings" + "testing" + + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" +) + +func TestDetectAiCommit(t *testing.T) { + extra := regexp.MustCompile(`(?i)(?:co-authored-by|assisted-by|made-with):.*(?:\bai\b|chatgpt|openai)`) + + tests := []struct { + name string + message string + authorName string + extra *regexp.Regexp + wantTool string + wantAI bool + }{ + { + name: "Co-Authored-By Cursor", + message: "Fix nil check\n\nCo-Authored-By: Cursor ", + wantTool: models.AiToolCursor, + wantAI: true, + }, + { + name: "co-authored-by cursor.com case variant", + message: "refactor\n\nco-authored-by: Cursor Agent ", + wantTool: models.AiToolCursor, + wantAI: true, + }, + { + name: "Assisted-by Cursor", + message: "update config\n\nAssisted-by: Cursor", + wantTool: models.AiToolCursor, + wantAI: true, + }, + { + name: "Made-with Cursor", + message: "docs\n\nMade-with: Cursor", + wantTool: models.AiToolCursor, + wantAI: true, + }, + { + name: "Co-Authored-By Claude", + message: "Add tests\n\nCo-Authored-By: Claude ", + wantTool: models.AiToolClaude, + wantAI: true, + }, + { + name: "anthropic.com trailer", + message: "chore\n\nCo-authored-by: Claude ", + wantTool: models.AiToolClaude, + wantAI: true, + }, + { + name: "Assisted-by Claude", + message: "feat\n\nAssisted-by: Claude", + wantTool: models.AiToolClaude, + wantAI: true, + }, + { + name: "Co-Authored-By Copilot", + message: "lint\n\nCo-Authored-By: Copilot ", + wantTool: models.AiToolCopilot, + wantAI: true, + }, + { + name: "Co-Authored-By CodeRabbit", + message: "Apply suggestion\n\nCo-authored-by: coderabbitai[bot] ", + wantTool: models.AiToolCodeRabbit, + wantAI: true, + }, + { + name: "CodeRabbit author without trailer", + message: "Apply suggestions from code review", + authorName: "coderabbitai[bot]", + wantTool: models.AiToolCodeRabbit, + wantAI: true, + }, + { + name: "CodeRabbit author mixed case", + message: "Apply suggestions from code review", + authorName: "CodeRabbitAI[bot]", + wantTool: models.AiToolCodeRabbit, + wantAI: true, + }, + { + name: "Cursor takes precedence over Claude in the same message", + message: "both\n\nCo-Authored-By: Cursor \nCo-Authored-By: Claude ", + wantTool: models.AiToolCursor, + wantAI: true, + }, + { + name: "unknown Assisted-by", + message: "tweak\n\nAssisted-by: SomeOtherTool", + wantTool: models.AiToolAssistedByUnknown, + wantAI: true, + }, + { + name: "unknown Made-with", + message: "tweak\n\nMade-with: SomeOtherTool", + wantTool: models.AiToolMadeWithUnknown, + wantAI: true, + }, + { + name: "extra pattern chatgpt trailer", + message: "wip\n\nCo-Authored-By: ChatGPT ", + extra: extra, + wantTool: models.AiToolOther, + wantAI: true, + }, + { + name: "human commit mentioning Claude in the body is not AI", + message: "Document how we evaluated Claude vs Copilot for reviews", + wantTool: "", + wantAI: false, + }, + { + name: "empty message human author", + message: "", + authorName: "Jane Doe", + wantTool: "", + wantAI: false, + }, + { + name: "trailer after a long body is still detected", + message: strings.Repeat("x", 3000) + "\n\nCo-Authored-By: Claude ", + wantTool: models.AiToolClaude, + wantAI: true, + }, + { + name: "nil extra pattern does not match generic AI", + message: "wip\n\nCo-Authored-By: ChatGPT ", + extra: nil, + wantTool: "", + wantAI: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTool, gotAI := detectAiCommit(tt.message, tt.authorName, tt.extra) + assert.Equal(t, tt.wantTool, gotTool) + assert.Equal(t, tt.wantAI, gotAI) + }) + } +} + +func TestCommitTrailer(t *testing.T) { + assert.Equal(t, "short", commitTrailer("short")) + long := strings.Repeat("a", trailerSuffixLen+10) + assert.Equal(t, trailerSuffixLen, len(commitTrailer(long))) +} diff --git a/backend/plugins/aireview/tasks/enrich_github_review_reactions.go b/backend/plugins/aireview/tasks/enrich_github_review_reactions.go new file mode 100644 index 00000000000..5e23bf5901c --- /dev/null +++ b/backend/plugins/aireview/tasks/enrich_github_review_reactions.go @@ -0,0 +1,189 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "strings" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +// reactions holds the parsed GitHub reactions JSON. +// GitLab notes do not embed reactions inline, so this only applies to GitHub raw data. +type reactions struct { + TotalCount int `json:"total_count"` + ThumbsUp int `json:"+1"` + ThumbsDown int `json:"-1"` +} + +var EnrichGithubReviewReactionsMeta = plugin.SubTaskMeta{ + Name: "enrichGithubReviewReactions", + EntryPoint: EnrichGithubReviewReactions, + EnabledByDefault: true, + Description: "Enrich AI reviews with developer reaction data from GitHub raw API tables", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW}, + Dependencies: []*plugin.SubTaskMeta{&ExtractAiReviewsMeta}, +} + +// reviewRawLink holds the mapping between a review and its raw data source +type reviewRawLink struct { + Id string `gorm:"column:id"` + RawDataTable string `gorm:"column:_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id"` +} + +// EnrichGithubReviewReactions enriches AI reviews with reaction counts from raw GitHub data +func EnrichGithubReviewReactions(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + logger.Info("Starting review reaction enrichment") + + // Step 1: Query reviews joined with pull_request_comments to get raw data links + var clauses []dal.Clause + if data.Options.ProjectName != "" { + clauses = []dal.Clause{ + dal.Select("ar.id, prc._raw_data_table, prc._raw_data_id"), + dal.From("_tool_aireview_reviews ar"), + dal.Join("JOIN pull_request_comments prc ON ar.review_id = prc.id"), + dal.Join("JOIN pull_requests pr ON prc.pull_request_id = pr.id"), + dal.Join("JOIN project_mapping pm ON pr.base_repo_id = pm.row_id"), + dal.Where("pm.project_name = ? AND pm.`table` = ? AND prc._raw_data_table != ''", data.Options.ProjectName, "repos"), + } + } else { + clauses = []dal.Clause{ + dal.Select("ar.id, prc._raw_data_table, prc._raw_data_id"), + dal.From("_tool_aireview_reviews ar"), + dal.Join("JOIN pull_request_comments prc ON ar.review_id = prc.id"), + dal.Join("JOIN pull_requests pr ON prc.pull_request_id = pr.id"), + dal.Where("pr.base_repo_id = ? AND prc._raw_data_table != ''", data.Options.RepoId), + } + } + + cursor, err := db.Cursor(clauses...) + if err != nil { + return errors.Default.Wrap(err, "failed to query review-to-raw-data links") + } + defer cursor.Close() + + // Group links by raw data table name for batched queries + tableLinks := make(map[string][]reviewRawLink) + for cursor.Next() { + var link reviewRawLink + if err := db.Fetch(cursor, &link); err != nil { + return errors.Default.Wrap(err, "failed to fetch review raw link") + } + tableLinks[link.RawDataTable] = append(tableLinks[link.RawDataTable], link) + } + + if len(tableLinks) == 0 { + logger.Info("No reviews with raw data links found, skipping reaction enrichment") + return nil + } + + totalEnriched := 0 + totalWithReactions := 0 + + // Step 2: For each raw table, batch-query reaction data + // GitLab notes don't embed reactions inline (GitLab uses separate award_emoji API), + // so skip GitLab raw tables as they won't have $.reactions in the JSON + for rawTable, links := range tableLinks { + if strings.Contains(rawTable, "gitlab") { + logger.Info("Skipping %d reviews from GitLab raw table %s (reactions not embedded in GitLab notes)", len(links), rawTable) + continue + } + logger.Info("Processing %d reviews from raw table: %s", len(links), rawTable) + + // Build lookup: raw_data_id -> review_id + rawIdToReviewId := make(map[uint64]string) + rawIds := make([]uint64, 0, len(links)) + for _, link := range links { + rawIdToReviewId[link.RawDataId] = link.Id + rawIds = append(rawIds, link.RawDataId) + } + + // Process in batches of 500 + batchSize := 500 + for i := 0; i < len(rawIds); i += batchSize { + end := i + batchSize + if end > len(rawIds) { + end = len(rawIds) + } + batch := rawIds[i:end] + + // Query raw table for reaction data as JSON string + // Note: data column is stored as binary, must convert to utf8mb4 for JSON_EXTRACT + // We extract $.reactions as a full JSON object and parse in Go to avoid + // MySQL JSON path issues with special-character keys like "+1" and "-1" + rows, err := db.Cursor( + dal.Select("id, JSON_EXTRACT(CONVERT(data USING utf8mb4), '$.reactions') as reactions_json"), + dal.From(rawTable), + dal.Where("id IN (?)", batch), + ) + if err != nil { + logger.Warn(err, "failed to query reactions from %s, skipping", rawTable) + continue + } + + for rows.Next() { + var id uint64 + var reactionsJSON *string + if scanErr := rows.Scan(&id, &reactionsJSON); scanErr != nil { + logger.Warn(errors.Default.WrapRaw(scanErr), "failed to scan reaction row") + continue + } + + reviewId, ok := rawIdToReviewId[id] + if !ok { + continue + } + + var r reactions + if reactionsJSON != nil { + if parseErr := json.Unmarshal([]byte(*reactionsJSON), &r); parseErr != nil { + logger.Warn(errors.Default.WrapRaw(parseErr), "failed to parse reactions JSON for raw id %d", id) + continue + } + } + + // Update the review with reaction data + updateErr := db.Exec( + "UPDATE _tool_aireview_reviews SET reactions_total_count = ?, reactions_thumbs_up = ?, reactions_thumbs_down = ? WHERE id = ?", + r.TotalCount, r.ThumbsUp, r.ThumbsDown, reviewId, + ) + if updateErr != nil { + logger.Warn(updateErr, "failed to update reactions for review %s", reviewId) + continue + } + + totalEnriched++ + if r.TotalCount > 0 { + totalWithReactions++ + } + } + rows.Close() + } + } + + logger.Info("Reaction enrichment complete: %d reviews enriched, %d had reactions", totalEnriched, totalWithReactions) + return nil +} diff --git a/backend/plugins/aireview/tasks/enrich_gitlab_review_reactions.go b/backend/plugins/aireview/tasks/enrich_gitlab_review_reactions.go new file mode 100644 index 00000000000..ae4919feddf --- /dev/null +++ b/backend/plugins/aireview/tasks/enrich_gitlab_review_reactions.go @@ -0,0 +1,280 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var EnrichGitlabReviewReactionsMeta = plugin.SubTaskMeta{ + Name: "enrichGitlabReviewReactions", + EntryPoint: EnrichGitlabReviewReactions, + EnabledByDefault: true, + Description: "Enrich AI reviews with reaction data from GitLab award emoji API", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW}, + Dependencies: []*plugin.SubTaskMeta{&ExtractAiReviewsMeta}, +} + +// gitlabConn holds the minimal fields needed to call the GitLab API. +// Token uses the encdec serializer so GORM decrypts it automatically. +type gitlabConn struct { + ID uint64 `gorm:"primaryKey;column:id"` + Endpoint string `gorm:"column:endpoint"` + Token string `gorm:"column:token;serializer:encdec"` +} + +func (gitlabConn) TableName() string { return "_tool_gitlab_connections" } + +// gitlabNoteInfo holds the GitLab-specific data needed to call the award emoji API +type gitlabNoteInfo struct { + ReviewId string `gorm:"column:review_id"` + NoteGitlabId int `gorm:"column:note_gitlab_id"` + ConnectionId uint64 `gorm:"column:connection_id"` + MergeRequestIid int `gorm:"column:merge_request_iid"` + ProjectId int `gorm:"column:project_id"` +} + +// awardEmoji represents a single GitLab award emoji from the API response +type awardEmoji struct { + Name string `json:"name"` +} + +// EnrichGitlabReviewReactions fetches award emojis from the GitLab API +// for AI review notes and updates the reaction counts on each review. +func EnrichGitlabReviewReactions(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + logger.Info("Starting GitLab review reaction enrichment via award emoji API") + + // Step 1: Get GitLab reviews with their domain comment IDs + var reviewClauses []dal.Clause + if data.Options.ProjectName != "" { + reviewClauses = []dal.Clause{ + dal.Select("ar.id as review_id, ar.review_id as domain_comment_id"), + dal.From("_tool_aireview_reviews ar"), + dal.Join("JOIN pull_requests pr ON ar.pull_request_id = pr.id"), + dal.Join("JOIN project_mapping pm ON pr.base_repo_id = pm.row_id"), + dal.Where("ar.source_platform = ? AND pm.project_name = ? AND pm.`table` = ?", "gitlab", data.Options.ProjectName, "repos"), + } + } else { + reviewClauses = []dal.Clause{ + dal.Select("ar.id as review_id, ar.review_id as domain_comment_id"), + dal.From("_tool_aireview_reviews ar"), + dal.Where("ar.source_platform = ? AND ar.repo_id = ?", "gitlab", data.Options.RepoId), + } + } + + type reviewRow struct { + ReviewId string `gorm:"column:review_id"` + DomainCommentId string `gorm:"column:domain_comment_id"` + } + + cursor, err := db.Cursor(reviewClauses...) + if err != nil { + return errors.Default.Wrap(err, "failed to query GitLab reviews") + } + defer cursor.Close() + + // Parse domain comment IDs to extract connection_id and note gitlab_id + // Format: "gitlab:GitlabMrComment:CONNECTION_ID:GITLAB_ID" + type noteKey struct { + ConnectionId uint64 + GitlabId int + } + noteToReviewId := make(map[noteKey]string) + + for cursor.Next() { + var row reviewRow + if fetchErr := db.Fetch(cursor, &row); fetchErr != nil { + return errors.Default.Wrap(fetchErr, "failed to fetch GitLab review") + } + connId, gitlabId, parseErr := parseGitlabDomainId(row.DomainCommentId) + if parseErr != nil { + logger.Warn(nil, "skipping review with unparseable domain ID: %s", row.DomainCommentId) + continue + } + noteToReviewId[noteKey{connId, gitlabId}] = row.ReviewId + } + + if len(noteToReviewId) == 0 { + logger.Info("No GitLab reviews found, skipping award emoji enrichment") + return nil + } + + // Group by connection_id + connectionNotes := make(map[uint64][]noteKey) + for nk := range noteToReviewId { + connectionNotes[nk.ConnectionId] = append(connectionNotes[nk.ConnectionId], nk) + } + + logger.Info("Found %d GitLab reviews across %d connections", len(noteToReviewId), len(connectionNotes)) + + httpClient := &http.Client{Timeout: 30 * time.Second} + totalEnriched := 0 + totalWithReactions := 0 + + // Step 2: For each connection, load credentials and resolve note details + for connId, notes := range connectionNotes { + // Load GitLab connection (token is auto-decrypted by GORM serializer) + var conn gitlabConn + connErr := db.First(&conn, dal.Where("id = ?", connId)) + if connErr != nil { + logger.Warn(connErr, "failed to load GitLab connection %d, skipping %d reviews", connId, len(notes)) + continue + } + + // Collect note gitlab_ids for batch query + gitlabIds := make([]int, 0, len(notes)) + for _, nk := range notes { + gitlabIds = append(gitlabIds, nk.GitlabId) + } + + // Query _tool_gitlab_mr_comments joined with _tool_gitlab_merge_requests + // to get merge_request_iid and project_id for each note + infoCursor, infoErr := db.Cursor( + dal.Select("gmc.gitlab_id as note_gitlab_id, gmc.connection_id, gmc.merge_request_iid, gmr.project_id"), + dal.From("_tool_gitlab_mr_comments gmc"), + dal.Join("JOIN _tool_gitlab_merge_requests gmr ON gmc.connection_id = gmr.connection_id AND gmc.merge_request_id = gmr.gitlab_id"), + dal.Where("gmc.connection_id = ? AND gmc.gitlab_id IN (?)", connId, gitlabIds), + ) + if infoErr != nil { + logger.Warn(infoErr, "failed to query GitLab note details for connection %d, skipping", connId) + continue + } + defer infoCursor.Close() + + // Collect note info + var noteInfos []gitlabNoteInfo + for infoCursor.Next() { + var info gitlabNoteInfo + if fetchErr := db.Fetch(infoCursor, &info); fetchErr != nil { + logger.Warn(errors.Default.Wrap(fetchErr, ""), "failed to fetch note info") + continue + } + info.ReviewId = noteToReviewId[noteKey{connId, info.NoteGitlabId}] + noteInfos = append(noteInfos, info) + } + + endpoint := strings.TrimRight(conn.Endpoint, "/") + + // Step 3: Call award emoji API for each note + for _, info := range noteInfos { + emojis, fetchErr := fetchAwardEmojis( + taskCtx.GetContext(), httpClient, endpoint, conn.Token, + info.ProjectId, info.MergeRequestIid, info.NoteGitlabId, + ) + if fetchErr != nil { + logger.Warn(nil, "failed to fetch award emojis for note %d: %s", info.NoteGitlabId, fetchErr) + continue + } + + thumbsUp, thumbsDown, total := countReactionEmojis(emojis) + + updateErr := db.Exec( + "UPDATE _tool_aireview_reviews SET reactions_total_count = ?, reactions_thumbs_up = ?, reactions_thumbs_down = ? WHERE id = ?", + total, thumbsUp, thumbsDown, info.ReviewId, + ) + if updateErr != nil { + logger.Warn(updateErr, "failed to update reactions for review %s", info.ReviewId) + continue + } + + totalEnriched++ + if total > 0 { + totalWithReactions++ + } + } + } + + logger.Info("GitLab reaction enrichment complete: %d reviews enriched, %d had reactions", totalEnriched, totalWithReactions) + return nil +} + +// parseGitlabDomainId extracts connection_id and gitlab_id from a domain layer ID. +// Format: "gitlab:GitlabMrComment:CONNECTION_ID:GITLAB_ID" +func parseGitlabDomainId(domainId string) (uint64, int, error) { + parts := strings.Split(domainId, ":") + if len(parts) < 4 { + return 0, 0, fmt.Errorf("expected at least 4 parts, got %d", len(parts)) + } + connId, err := strconv.ParseUint(parts[2], 10, 64) + if err != nil { + return 0, 0, fmt.Errorf("invalid connection ID %q: %w", parts[2], err) + } + gitlabId, err := strconv.Atoi(parts[3]) + if err != nil { + return 0, 0, fmt.Errorf("invalid gitlab ID %q: %w", parts[3], err) + } + return connId, gitlabId, nil +} + +// fetchAwardEmojis calls the GitLab API to get award emojis for a MR note. +func fetchAwardEmojis(ctx context.Context, client *http.Client, endpoint, token string, projectId, mrIid, noteId int) ([]awardEmoji, error) { + url := fmt.Sprintf("%s/projects/%d/merge_requests/%d/notes/%d/award_emoji?per_page=100", + endpoint, projectId, mrIid, noteId) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Private-Token", token) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return nil, fmt.Errorf("GitLab API returned %d: %s", resp.StatusCode, string(body)) + } + + var emojis []awardEmoji + if err := json.NewDecoder(resp.Body).Decode(&emojis); err != nil { + return nil, fmt.Errorf("failed to decode award emoji response: %w", err) + } + return emojis, nil +} + +// countReactionEmojis counts thumbsup and thumbsdown emojis from a list of award emojis. +func countReactionEmojis(emojis []awardEmoji) (thumbsUp, thumbsDown, total int) { + total = len(emojis) + for _, e := range emojis { + switch e.Name { + case "thumbsup": + thumbsUp++ + case "thumbsdown": + thumbsDown++ + } + } + return +} diff --git a/backend/plugins/aireview/tasks/enrich_gitlab_review_reactions_test.go b/backend/plugins/aireview/tasks/enrich_gitlab_review_reactions_test.go new file mode 100644 index 00000000000..775b17e6df2 --- /dev/null +++ b/backend/plugins/aireview/tasks/enrich_gitlab_review_reactions_test.go @@ -0,0 +1,203 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseGitlabDomainId(t *testing.T) { + tests := []struct { + name string + domainId string + wantConn uint64 + wantGitlab int + wantErr bool + }{ + { + name: "valid GitLab MR comment ID", + domainId: "gitlab:GitlabMrComment:1:123456", + wantConn: 1, + wantGitlab: 123456, + }, + { + name: "valid with large IDs", + domainId: "gitlab:GitlabMrComment:42:999999999", + wantConn: 42, + wantGitlab: 999999999, + }, + { + name: "too few parts", + domainId: "gitlab:GitlabMrComment", + wantErr: true, + }, + { + name: "invalid connection ID", + domainId: "gitlab:GitlabMrComment:abc:123", + wantErr: true, + }, + { + name: "invalid gitlab ID", + domainId: "gitlab:GitlabMrComment:1:xyz", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + connId, gitlabId, err := parseGitlabDomainId(tt.domainId) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantConn, connId) + assert.Equal(t, tt.wantGitlab, gitlabId) + }) + } +} + +func TestCountReactionEmojis(t *testing.T) { + tests := []struct { + name string + emojis []awardEmoji + wantThumbsUp int + wantThumbsDown int + wantTotal int + }{ + { + name: "empty", + emojis: nil, + wantThumbsUp: 0, + wantThumbsDown: 0, + wantTotal: 0, + }, + { + name: "mixed reactions", + emojis: []awardEmoji{ + {Name: "thumbsup"}, + {Name: "thumbsup"}, + {Name: "thumbsdown"}, + {Name: "heart"}, + {Name: "rocket"}, + }, + wantThumbsUp: 2, + wantThumbsDown: 1, + wantTotal: 5, + }, + { + name: "only thumbsup", + emojis: []awardEmoji{ + {Name: "thumbsup"}, + {Name: "thumbsup"}, + }, + wantThumbsUp: 2, + wantThumbsDown: 0, + wantTotal: 2, + }, + { + name: "no thumbs reactions", + emojis: []awardEmoji{ + {Name: "heart"}, + {Name: "eyes"}, + }, + wantThumbsUp: 0, + wantThumbsDown: 0, + wantTotal: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + up, down, total := countReactionEmojis(tt.emojis) + assert.Equal(t, tt.wantThumbsUp, up) + assert.Equal(t, tt.wantThumbsDown, down) + assert.Equal(t, tt.wantTotal, total) + }) + } +} + +func TestFetchAwardEmojis(t *testing.T) { + // Mock GitLab API server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify auth header + if r.Header.Get("Private-Token") != "test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Verify URL path + expectedPath := "/projects/100/merge_requests/42/notes/999/award_emoji" + if r.URL.Path != expectedPath { + t.Errorf("unexpected path: got %s, want %s", r.URL.Path, expectedPath) + w.WriteHeader(http.StatusNotFound) + return + } + + emojis := []awardEmoji{ + {Name: "thumbsup"}, + {Name: "thumbsdown"}, + {Name: "thumbsup"}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(emojis) + })) + defer server.Close() + + client := server.Client() + emojis, err := fetchAwardEmojis(context.Background(), client, server.URL, "test-token", 100, 42, 999) + assert.NoError(t, err) + assert.Len(t, emojis, 3) + + up, down, total := countReactionEmojis(emojis) + assert.Equal(t, 2, up) + assert.Equal(t, 1, down) + assert.Equal(t, 3, total) +} + +func TestFetchAwardEmojis_Unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"401 Unauthorized"}`)) + })) + defer server.Close() + + client := server.Client() + _, err := fetchAwardEmojis(context.Background(), client, server.URL, "bad-token", 100, 42, 999) + assert.Error(t, err) + assert.Contains(t, err.Error(), "401") +} + +func TestFetchAwardEmojis_EmptyResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("[]")) + })) + defer server.Close() + + client := server.Client() + emojis, err := fetchAwardEmojis(context.Background(), client, server.URL, "token", 100, 42, 999) + assert.NoError(t, err) + assert.Empty(t, emojis) +} diff --git a/backend/plugins/aireview/tasks/extract_ai_commits.go b/backend/plugins/aireview/tasks/extract_ai_commits.go new file mode 100644 index 00000000000..53501c4a7da --- /dev/null +++ b/backend/plugins/aireview/tasks/extract_ai_commits.go @@ -0,0 +1,239 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +var ExtractAiCommitsMeta = plugin.SubTaskMeta{ + Name: "extractAiCommits", + EntryPoint: ExtractAiCommits, + EnabledByDefault: true, + Description: "Classify AI-assisted commits from commit messages and store hits in _tool_aireview_commits", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, +} + +type aiCommitScanRow struct { + Sha string `gorm:"column:sha"` + RepoId string `gorm:"column:repo_id"` + CommitMessage []byte `gorm:"column:commit_message"` + GithubMessage []byte `gorm:"column:github_message"` + CommitAuthor string `gorm:"column:commit_author"` + GithubAuthor string `gorm:"column:github_author"` + PrcAuthor string `gorm:"column:prc_author"` + CommitDate *time.Time `gorm:"column:commit_date"` + GithubDate *time.Time `gorm:"column:github_date"` + PrcDate *time.Time `gorm:"column:prc_date"` +} + +// ExtractAiCommits scans commits for the current project/repo and writes +// sparse _tool_aireview_commits rows for AI-positive commits only. +func ExtractAiCommits(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + repoIds, err := resolveAiCommitRepoIds(db, data) + if err != nil { + return err + } + if len(repoIds) == 0 { + logger.Info("extractAiCommits: no repos to scan") + return nil + } + + if delErr := db.Delete(&models.AiCommit{}, dal.Where("repo_id IN ?", repoIds)); delErr != nil { + return errors.Default.Wrap(delErr, "failed to delete existing ai commits") + } + + githubSelect := "NULL AS github_message, NULL AS github_author, NULL AS github_date" + githubJoin := "" + if db.HasTable("_tool_github_commits") { + githubSelect = "tc.message AS github_message, tc.author_name AS github_author, tc.authored_date AS github_date" + githubJoin = "LEFT JOIN _tool_github_commits tc ON combined.sha = tc.sha" + } + + clauses := []dal.Clause{ + dal.Select(`combined.sha AS sha, combined.repo_id AS repo_id, + c.message AS commit_message, ` + githubSelect + `, + c.author_name AS commit_author, + prc_data.commit_author_name AS prc_author, + c.authored_date AS commit_date, + prc_data.commit_authored_date AS prc_date`), + dal.From(`( + SELECT rc.commit_sha AS sha, rc.repo_id AS repo_id + FROM repo_commits rc + WHERE rc.repo_id IN ? + UNION + SELECT prc.commit_sha, pr.base_repo_id + FROM pull_request_commits prc + INNER JOIN pull_requests pr ON prc.pull_request_id = pr.id + WHERE pr.base_repo_id IN ? + UNION + SELECT pr.merge_commit_sha, pr.base_repo_id + FROM pull_requests pr + WHERE pr.base_repo_id IN ? + AND pr.status = ? + AND pr.merge_commit_sha IS NOT NULL + AND pr.merge_commit_sha != ? + ) combined`, repoIds, repoIds, repoIds, "MERGED", ""), + dal.Join("LEFT JOIN commits c ON combined.sha = c.sha"), + dal.Join(`LEFT JOIN ( + SELECT prc.commit_sha, + pr.base_repo_id AS repo_id, + MIN(prc.commit_author_name) AS commit_author_name, + MIN(prc.commit_authored_date) AS commit_authored_date + FROM pull_request_commits prc + INNER JOIN pull_requests pr ON prc.pull_request_id = pr.id + WHERE pr.base_repo_id IN ? + GROUP BY prc.commit_sha, pr.base_repo_id + ) prc_data ON combined.sha = prc_data.commit_sha AND combined.repo_id = prc_data.repo_id`, repoIds), + } + if githubJoin != "" { + clauses = append(clauses, dal.Join(githubJoin)) + } + + cursor, err := db.Cursor(clauses...) + if err != nil { + return errors.Default.Wrap(err, "failed to query commits for AI classification") + } + defer cursor.Close() + + batchSize := 100 + batch := make([]*models.AiCommit, 0, batchSize) + seen := make(map[string]bool) + found := 0 + + for cursor.Next() { + var row aiCommitScanRow + if fetchErr := db.Fetch(cursor, &row); fetchErr != nil { + return errors.Default.Wrap(fetchErr, "failed to fetch commit row") + } + if row.Sha == "" || row.RepoId == "" { + continue + } + + id := generateAiCommitId(row.RepoId, row.Sha) + if seen[id] { + continue + } + + message := firstNonEmptyBytes(row.CommitMessage, row.GithubMessage) + author := firstNonEmpty(row.CommitAuthor, row.GithubAuthor, row.PrcAuthor) + tool, isAI := detectAiCommit(message, author, data.AiCommitPatternsRegex) + if !isAI { + continue + } + + seen[id] = true + authoredDate := firstNonNilTime(row.CommitDate, row.GithubDate, row.PrcDate) + batch = append(batch, &models.AiCommit{ + Id: id, + CommitSha: row.Sha, + RepoId: row.RepoId, + AiTool: tool, + AuthorName: author, + AuthoredDate: authoredDate, + }) + found++ + + if len(batch) >= batchSize { + if saveErr := saveAiCommitBatch(db, batch); saveErr != nil { + return saveErr + } + batch = batch[:0] + } + } + + if len(batch) > 0 { + if saveErr := saveAiCommitBatch(db, batch); saveErr != nil { + return saveErr + } + } + + logger.Info("Completed AI commit extraction: %d AI-assisted commits found", found) + return nil +} + +func resolveAiCommitRepoIds(db dal.Dal, data *AiReviewTaskData) ([]string, errors.Error) { + if data.Options.ProjectName != "" { + var repoIds []string + err := db.Pluck("row_id", &repoIds, + dal.From("project_mapping"), + dal.Where("project_name = ? AND `table` = ?", data.Options.ProjectName, "repos"), + ) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to load project repos") + } + return repoIds, nil + } + if data.Options.RepoId != "" { + return []string{data.Options.RepoId}, nil + } + return nil, nil +} + +func generateAiCommitId(repoId, sha string) string { + hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s", repoId, sha))) + return "aireview-commit:" + hex.EncodeToString(hash[:16]) +} + +func saveAiCommitBatch(db dal.Dal, batch []*models.AiCommit) errors.Error { + for _, c := range batch { + if err := db.CreateOrUpdate(c); err != nil { + return errors.Default.Wrap(err, "failed to save AI commit") + } + } + return nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func firstNonEmptyBytes(values ...[]byte) string { + for _, v := range values { + if len(v) > 0 { + return string(v) + } + } + return "" +} + +func firstNonNilTime(values ...*time.Time) time.Time { + for _, v := range values { + if v != nil && !v.IsZero() { + return *v + } + } + return time.Time{} +} diff --git a/backend/plugins/aireview/tasks/extract_ai_commits_test.go b/backend/plugins/aireview/tasks/extract_ai_commits_test.go new file mode 100644 index 00000000000..99222a8ef3e --- /dev/null +++ b/backend/plugins/aireview/tasks/extract_ai_commits_test.go @@ -0,0 +1,45 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateAiCommitId(t *testing.T) { + id1 := generateAiCommitId("repo-1", "abc123") + id2 := generateAiCommitId("repo-1", "abc123") + assert.Equal(t, id1, id2) + assert.True(t, len(id1) > 0) + assert.Contains(t, id1, "aireview-commit:") + + id3 := generateAiCommitId("repo-2", "abc123") + assert.NotEqual(t, id1, id3) +} + +func TestFirstNonEmpty(t *testing.T) { + assert.Equal(t, "a", firstNonEmpty("", "a", "b")) + assert.Equal(t, "", firstNonEmpty("", "")) +} + +func TestFirstNonEmptyBytes(t *testing.T) { + assert.Equal(t, "hi", firstNonEmptyBytes(nil, []byte("hi"))) + assert.Equal(t, "", firstNonEmptyBytes(nil, []byte{})) +} diff --git a/backend/plugins/aireview/tasks/extract_ai_review_findings.go b/backend/plugins/aireview/tasks/extract_ai_review_findings.go new file mode 100644 index 00000000000..1255d3737d5 --- /dev/null +++ b/backend/plugins/aireview/tasks/extract_ai_review_findings.go @@ -0,0 +1,349 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strings" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +var ExtractAiReviewFindingsMeta = plugin.SubTaskMeta{ + Name: "extractAiReviewFindings", + EntryPoint: ExtractAiReviewFindings, + EnabledByDefault: true, + Description: "Extract individual findings from AI reviews", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW}, + Dependencies: []*plugin.SubTaskMeta{&ExtractAiReviewsMeta}, +} + +// ExtractAiReviewFindings parses AI reviews and extracts individual findings +func ExtractAiReviewFindings(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + logger.Info("Extracting findings from AI reviews for repo: %s", data.Options.RepoId) + + // Query AI reviews + cursor, err := db.Cursor( + dal.From(&models.AiReview{}), + dal.Where("repo_id = ?", data.Options.RepoId), + ) + if err != nil { + return errors.Default.Wrap(err, "failed to query AI reviews") + } + defer cursor.Close() + + totalFindings := 0 + batchSize := 100 + batch := make([]*models.AiReviewFinding, 0, batchSize) + + for cursor.Next() { + var review models.AiReview + if err := db.Fetch(cursor, &review); err != nil { + return errors.Default.Wrap(err, "failed to fetch AI review") + } + + // Parse findings from review body + findings := parseFindings(&review) + totalFindings += len(findings) + + for _, finding := range findings { + batch = append(batch, finding) + if len(batch) >= batchSize { + if err := saveFindingsBatch(db, batch); err != nil { + return err + } + batch = make([]*models.AiReviewFinding, 0, batchSize) + } + } + } + + // Save remaining batch + if len(batch) > 0 { + if err := saveFindingsBatch(db, batch); err != nil { + return err + } + } + + logger.Info("Completed finding extraction: %d findings found", totalFindings) + return nil +} + +// parseFindings extracts individual findings from an AI review +func parseFindings(review *models.AiReview) []*models.AiReviewFinding { + var findings []*models.AiReviewFinding + body := normalizeBody(review.Body) + + // Parse CodeRabbit-style findings (file blocks, etc.) + if review.AiTool == models.AiToolCodeRabbit { + findings = append(findings, parseCodeRabbitFindings(review, body)...) + } + + // Parse ```suggestion blocks — GitHub-native feature used by all AI tools + findings = append(findings, parseSuggestionBlocks(review, body)...) + + // Parse generic inline comment findings + findings = append(findings, parseGenericFindings(review, body)...) + + return findings +} + +// parseCodeRabbitFindings extracts findings from CodeRabbit format +func parseCodeRabbitFindings(review *models.AiReview, body string) []*models.AiReviewFinding { + var findings []*models.AiReviewFinding + + // Pattern for file-level findings + // Example: "📁 file.go\n- Issue description" + filePattern := regexp.MustCompile(`(?m)(?:📁|File:)\s*([^\n]+)\n((?:[-*•]\s*[^\n]+\n?)+)`) + fileMatches := filePattern.FindAllStringSubmatch(body, -1) + + for _, match := range fileMatches { + if len(match) < 3 { + continue + } + filePath := strings.TrimSpace(match[1]) + issuesBlock := match[2] + + // Parse individual issues within the file block + issuePattern := regexp.MustCompile(`(?m)[-*•]\s*(.+)`) + issueMatches := issuePattern.FindAllStringSubmatch(issuesBlock, -1) + + for idx, issue := range issueMatches { + if len(issue) < 2 { + continue + } + description := strings.TrimSpace(issue[1]) + + finding := &models.AiReviewFinding{ + Id: generateFindingId(review.Id, filePath, idx), + AiReviewId: review.Id, + PullRequestId: review.PullRequestId, + RepoId: review.RepoId, + AiTool: review.AiTool, + FilePath: filePath, + Description: description, + Category: detectFindingCategory(description), + Severity: detectFindingSeverity(description), + Type: detectFindingType(description), + Title: truncateTitle(description), + CreatedDate: review.CreatedDate, + } + findings = append(findings, finding) + } + } + + return findings +} + +// parseSuggestionBlocks extracts ```suggestion code blocks from any AI review comment. +// This is a GitHub-native feature used by CodeRabbit, Gemini Code Assist, Qodo, and others. +func parseSuggestionBlocks(review *models.AiReview, body string) []*models.AiReviewFinding { + var findings []*models.AiReviewFinding + + suggestionPattern := regexp.MustCompile("(?s)```suggestion\\s*\\n(.+?)```") + suggestionMatches := suggestionPattern.FindAllStringSubmatch(body, -1) + + for idx, match := range suggestionMatches { + if len(match) < 2 { + continue + } + suggestedCode := strings.TrimSpace(match[1]) + + finding := &models.AiReviewFinding{ + Id: generateFindingId(review.Id, "suggestion", idx), + AiReviewId: review.Id, + PullRequestId: review.PullRequestId, + RepoId: review.RepoId, + AiTool: review.AiTool, + SuggestedCode: suggestedCode, + SuggestionApplied: detectSuggestionApplied(body, idx), + Category: models.FindingCategoryBestPractice, + Severity: models.FindingSeverityInfo, + Type: models.FindingTypeSuggestion, + Title: "Code suggestion", + Description: "AI-suggested code change", + CreatedDate: review.CreatedDate, + } + findings = append(findings, finding) + } + + return findings +} + +// parseGenericFindings extracts findings from generic comment format +func parseGenericFindings(review *models.AiReview, body string) []*models.AiReviewFinding { + var findings []*models.AiReviewFinding + + // Bullet point patterns + bulletPattern := regexp.MustCompile(`(?m)^[-*•]\s+(.+)$`) + bulletMatches := bulletPattern.FindAllStringSubmatch(body, -1) + + for idx, match := range bulletMatches { + if len(match) < 2 { + continue + } + description := strings.TrimSpace(match[1]) + + // Skip if too short or likely a header + if len(description) < 20 { + continue + } + + // Detect file path in the line + filePath := "" + filePattern := regexp.MustCompile(`\b([\w/.-]+\.(?:go|ts|js|py|java|rs|cpp|c|h))\b`) + if fileMatch := filePattern.FindString(description); fileMatch != "" { + filePath = fileMatch + } + + finding := &models.AiReviewFinding{ + Id: generateFindingId(review.Id, "bullet", idx), + AiReviewId: review.Id, + PullRequestId: review.PullRequestId, + RepoId: review.RepoId, + AiTool: review.AiTool, + FilePath: filePath, + Description: description, + Category: detectFindingCategory(description), + Severity: detectFindingSeverity(description), + Type: detectFindingType(description), + Title: truncateTitle(description), + CreatedDate: review.CreatedDate, + } + findings = append(findings, finding) + } + + return findings +} + +// generateFindingId creates a deterministic ID for a finding +func generateFindingId(reviewId, context string, index int) string { + hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", reviewId, context, index))) + return "aifinding:" + hex.EncodeToString(hash[:16]) +} + +// detectFindingCategory determines the category of a finding +func detectFindingCategory(text string) string { + text = strings.ToLower(text) + + categoryPatterns := map[string]*regexp.Regexp{ + models.FindingCategorySecurity: regexp.MustCompile(`(?i)(security|vulnerab|xss|sql.?inject|auth|creds|secret|token|password)`), + models.FindingCategoryPerformance: regexp.MustCompile(`(?i)(performance|slow|optimi|efficien|memory|leak|cache|latency)`), + models.FindingCategoryBug: regexp.MustCompile(`(?i)(bug|error|crash|fail|broken|undefined|null.?pointer|exception)`), + models.FindingCategoryStyle: regexp.MustCompile(`(?i)(style|format|indent|naming|convention|lint)`), + models.FindingCategoryDocumentation: regexp.MustCompile(`(?i)(doc|comment|readme|describe|explain)`), + models.FindingCategoryMaintainability: regexp.MustCompile(`(?i)(maintain|refactor|complex|duplicate|dry|solid|clean)`), + } + + for category, pattern := range categoryPatterns { + if pattern.MatchString(text) { + return category + } + } + + return models.FindingCategoryBestPractice +} + +// detectFindingSeverity determines the severity of a finding +func detectFindingSeverity(text string) string { + text = strings.ToLower(text) + + if regexp.MustCompile(`(?i)(critical|severe|security|vulnerab|crash|data.?loss)`).MatchString(text) { + return models.FindingSeverityCritical + } + if regexp.MustCompile(`(?i)(error|bug|fail|broken|must|required)`).MatchString(text) { + return models.FindingSeverityError + } + if regexp.MustCompile(`(?i)(warning|should|recommend|consider)`).MatchString(text) { + return models.FindingSeverityWarning + } + + return models.FindingSeverityInfo +} + +// detectFindingType determines the type of a finding +func detectFindingType(text string) string { + text = strings.ToLower(text) + + if regexp.MustCompile(`(?i)(suggest|recommend|consider|could|might)`).MatchString(text) { + return models.FindingTypeSuggestion + } + if regexp.MustCompile(`(?i)(issue|bug|error|problem|fail)`).MatchString(text) { + return models.FindingTypeIssue + } + + return models.FindingTypeComment +} + +// detectSuggestionApplied checks whether a suggestion block within a comment +// has been marked as applied/resolved by the AI tool. +// The index parameter identifies which suggestion block in the body we're checking. +func detectSuggestionApplied(body string, _ int) bool { + // Check for global acceptance markers in the comment body. + // When the entire comment has acceptance signals, individual suggestions + // within it are considered applied. + appliedRe := regexp.MustCompile(`(?i)(?:applied suggestion|suggestion applied|✅\s*Resolved|suggestion\s+(?:was\s+)?(?:applied|committed)\s+in\s+commit)`) + return appliedRe.MatchString(body) +} + +// normalizeBody strips JSON quoting and converts escaped newlines to real newlines. +// Comment bodies from the domain table may be stored with JSON encoding +// (surrounding quotes, escaped newlines as \\n). +func normalizeBody(body string) string { + // Strip surrounding JSON quotes + if len(body) >= 2 && body[0] == '"' && body[len(body)-1] == '"' { + body = body[1 : len(body)-1] + } + // Convert escaped newlines/quotes + body = strings.ReplaceAll(body, "\\n", "\n") + body = strings.ReplaceAll(body, "\\r", "") + body = strings.ReplaceAll(body, "\\\"", "\"") + return body +} + +// truncateTitle creates a short title from description +func truncateTitle(description string) string { + // Get first sentence or 80 chars + if idx := strings.IndexAny(description, ".!?\n"); idx > 0 && idx < 80 { + return description[:idx] + } + if len(description) > 80 { + return description[:77] + "..." + } + return description +} + +// saveFindingsBatch saves a batch of findings to the database +func saveFindingsBatch(db dal.Dal, batch []*models.AiReviewFinding) errors.Error { + for _, finding := range batch { + err := db.CreateOrUpdate(finding) + if err != nil { + return errors.Default.Wrap(err, "failed to save AI review finding") + } + } + return nil +} diff --git a/backend/plugins/aireview/tasks/extract_ai_review_findings_test.go b/backend/plugins/aireview/tasks/extract_ai_review_findings_test.go new file mode 100644 index 00000000000..7b11fdb5511 --- /dev/null +++ b/backend/plugins/aireview/tasks/extract_ai_review_findings_test.go @@ -0,0 +1,399 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "strings" + "testing" + + "github.com/apache/devlake/core/errors" + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + mockplugin "github.com/apache/devlake/mocks/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestNormalizeBody(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"plain text unchanged", "hello world", "hello world"}, + {"strips JSON quotes", `"hello world"`, "hello world"}, + {"converts escaped newlines", `line1\nline2`, "line1\nline2"}, + {"removes escaped carriage returns", `line1\r\nline2`, "line1\nline2"}, + {"converts escaped quotes", `say \"hello\"`, `say "hello"`}, + {"empty string", "", ""}, + {"single quote char", `"`, `"`}, + {"combined escaping", `"first\\nsecond\nthird"`, "first\\\nsecond\nthird"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, normalizeBody(tt.input)) + }) + } +} + +func TestTruncateTitle(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"short text returned as-is", "fix bug", "fix bug"}, + {"first sentence extracted", "Fix the bug. Then refactor.", "Fix the bug"}, + {"exclamation as sentence end", "Security issue found! Check now.", "Security issue found"}, + {"question mark as sentence end", "Is this correct? Let me check.", "Is this correct"}, + {"long text truncated with ellipsis", + "This is a very long description that goes well beyond the eighty character limit and should be truncated properly", + "This is a very long description that goes well beyond the eighty character li..."}, + {"exactly 80 chars", strings.Repeat("a", 80), strings.Repeat("a", 80)}, + {"79 chars no truncation", strings.Repeat("a", 79), strings.Repeat("a", 79)}, + {"empty string", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, truncateTitle(tt.input)) + }) + } +} + +func TestGenerateFindingId(t *testing.T) { + id1 := generateFindingId("review-1", "file.go", 0) + id2 := generateFindingId("review-1", "file.go", 0) + id3 := generateFindingId("review-1", "file.go", 1) + id4 := generateFindingId("review-2", "file.go", 0) + + assert.Equal(t, id1, id2, "same inputs must produce same ID") + assert.NotEqual(t, id1, id3, "different index must produce different ID") + assert.NotEqual(t, id1, id4, "different review must produce different ID") + assert.True(t, strings.HasPrefix(id1, "aifinding:"), "must have aifinding: prefix") + assert.NotEmpty(t, id1) +} + +func TestDetectFindingCategory(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"security keyword", "This has a SQL injection vulnerability", models.FindingCategorySecurity}, + {"password keyword", "Hardcoded password detected", models.FindingCategorySecurity}, + {"performance keyword", "This could cause a memory leak", models.FindingCategoryPerformance}, + {"optimization keyword", "Consider optimizing this query", models.FindingCategoryPerformance}, + {"bug keyword", "This will crash on nil input", models.FindingCategoryBug}, + {"error keyword", "Error handling is missing", models.FindingCategoryBug}, + {"style keyword", "Naming convention violation", models.FindingCategoryStyle}, + {"lint keyword", "Lint formatting issue", models.FindingCategoryStyle}, + {"documentation keyword", "Missing doc comment", models.FindingCategoryDocumentation}, + {"maintainability keyword", "Consider refactoring this method", models.FindingCategoryMaintainability}, + {"duplicate keyword", "Duplicate code detected", models.FindingCategoryMaintainability}, + {"default to best_practice", "Consider using a constant here", models.FindingCategoryBestPractice}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, detectFindingCategory(tt.input)) + }) + } +} + +func TestDetectFindingSeverity(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"critical keyword", "Critical security vulnerability found", models.FindingSeverityCritical}, + {"data loss keyword", "This could cause data loss", models.FindingSeverityCritical}, + {"error keyword", "This is a bug that will fail", models.FindingSeverityError}, + {"must keyword", "Must handle this case", models.FindingSeverityError}, + {"warning keyword", "You should validate input", models.FindingSeverityWarning}, + {"consider keyword", "Consider adding a check", models.FindingSeverityWarning}, + {"default to info", "Nice formatting here", models.FindingSeverityInfo}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, detectFindingSeverity(tt.input)) + }) + } +} + +func TestDetectFindingType(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"suggest keyword", "I suggest using a map here", models.FindingTypeSuggestion}, + {"consider keyword", "Consider refactoring this", models.FindingTypeSuggestion}, + {"issue keyword", "There is an issue with this logic", models.FindingTypeIssue}, + {"bug keyword", "This is a bug in the error handling", models.FindingTypeIssue}, + {"default to comment", "The code looks clean here", models.FindingTypeComment}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, detectFindingType(tt.input)) + }) + } +} + +func TestDetectSuggestionApplied(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + {"applied suggestion marker", "The applied suggestion looks good", true}, + {"resolved marker", "✅ Resolved this issue", true}, + {"applied in commit", "suggestion applied in commit abc123", true}, + {"no marker", "This is a regular comment", false}, + {"empty body", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, detectSuggestionApplied(tt.body, 0)) + }) + } +} + +func TestParseSuggestionBlocks(t *testing.T) { + t.Run("single suggestion block", func(t *testing.T) { + review := &models.AiReview{ + Id: "r1", + PullRequestId: "pr1", + RepoId: "repo1", + AiTool: models.AiToolCodeRabbit, + } + body := "Some text\n```suggestion\nfunc foo() {}\n```\nMore text" + + findings := parseSuggestionBlocks(review, body) + + assert.Len(t, findings, 1) + assert.Equal(t, "func foo() {}", findings[0].SuggestedCode) + assert.Equal(t, models.FindingTypeSuggestion, findings[0].Type) + assert.Equal(t, "pr1", findings[0].PullRequestId) + assert.Equal(t, "repo1", findings[0].RepoId) + }) + + t.Run("multiple suggestion blocks", func(t *testing.T) { + review := &models.AiReview{Id: "r2", AiTool: models.AiToolQodo} + body := "```suggestion\nline1\n```\ntext\n```suggestion\nline2\n```" + + findings := parseSuggestionBlocks(review, body) + assert.Len(t, findings, 2) + assert.Equal(t, "line1", findings[0].SuggestedCode) + assert.Equal(t, "line2", findings[1].SuggestedCode) + }) + + t.Run("no suggestion blocks", func(t *testing.T) { + review := &models.AiReview{Id: "r3", AiTool: models.AiToolGemini} + findings := parseSuggestionBlocks(review, "Just regular text with ```code``` blocks") + assert.Empty(t, findings) + }) +} + +func TestParseCodeRabbitFindings(t *testing.T) { + t.Run("file block with issues", func(t *testing.T) { + review := &models.AiReview{ + Id: "r1", + PullRequestId: "pr1", + RepoId: "repo1", + AiTool: models.AiToolCodeRabbit, + } + body := "📁 src/main.go\n- Missing error handling in this function\n- Consider adding a nil check for safety\n" + + findings := parseCodeRabbitFindings(review, body) + + assert.Len(t, findings, 2) + assert.Equal(t, "src/main.go", findings[0].FilePath) + assert.Contains(t, findings[0].Description, "Missing error handling") + assert.Contains(t, findings[1].Description, "nil check") + }) + + t.Run("File: prefix format", func(t *testing.T) { + review := &models.AiReview{Id: "r2", AiTool: models.AiToolCodeRabbit} + body := "File: pkg/utils.go\n* Unused import detected\n" + + findings := parseCodeRabbitFindings(review, body) + assert.Len(t, findings, 1) + assert.Equal(t, "pkg/utils.go", findings[0].FilePath) + }) + + t.Run("no file blocks", func(t *testing.T) { + review := &models.AiReview{Id: "r3", AiTool: models.AiToolCodeRabbit} + findings := parseCodeRabbitFindings(review, "Just a summary comment") + assert.Empty(t, findings) + }) +} + +func TestParseGenericFindings(t *testing.T) { + t.Run("bullet points with file paths", func(t *testing.T) { + review := &models.AiReview{ + Id: "r1", + AiTool: models.AiToolQodo, + } + body := "- The function in src/handler.go should validate input before processing\n- Consider adding tests for the edge case scenario here\n" + + findings := parseGenericFindings(review, body) + + assert.GreaterOrEqual(t, len(findings), 1) + found := false + for _, f := range findings { + if f.FilePath == "src/handler.go" { + found = true + } + } + assert.True(t, found, "should extract file path from bullet") + }) + + t.Run("short bullets are skipped", func(t *testing.T) { + review := &models.AiReview{Id: "r2", AiTool: models.AiToolGemini} + body := "- Short\n- Also short\n" + findings := parseGenericFindings(review, body) + assert.Empty(t, findings) + }) + + t.Run("asterisk bullets", func(t *testing.T) { + review := &models.AiReview{Id: "r3", AiTool: models.AiToolGemini} + body := "* This is a long enough bullet point to be considered a real finding\n" + findings := parseGenericFindings(review, body) + assert.Len(t, findings, 1) + }) +} + +func TestParseFindings(t *testing.T) { + t.Run("CodeRabbit review dispatches to CodeRabbit parser", func(t *testing.T) { + review := &models.AiReview{ + Id: "r1", + AiTool: models.AiToolCodeRabbit, + Body: "📁 main.go\n- Security issue with input validation in the handler function\n", + } + findings := parseFindings(review) + assert.NotEmpty(t, findings) + }) + + t.Run("non-CodeRabbit review uses generic parser", func(t *testing.T) { + review := &models.AiReview{ + Id: "r2", + AiTool: models.AiToolQodo, + Body: "- Consider refactoring this function for better maintainability\n", + } + findings := parseFindings(review) + assert.NotEmpty(t, findings) + }) + + t.Run("suggestion blocks extracted from any tool", func(t *testing.T) { + review := &models.AiReview{ + Id: "r3", + AiTool: models.AiToolGemini, + Body: "```suggestion\nreturn nil, fmt.Errorf(\"invalid: %w\", err)\n```", + } + findings := parseFindings(review) + hasSuggestion := false + for _, f := range findings { + if f.Type == models.FindingTypeSuggestion { + hasSuggestion = true + } + } + assert.True(t, hasSuggestion, "should find suggestion block") + }) + + t.Run("empty body returns no findings", func(t *testing.T) { + review := &models.AiReview{Id: "r4", AiTool: models.AiToolCodeRabbit, Body: ""} + findings := parseFindings(review) + assert.Empty(t, findings) + }) +} + +func TestSaveFindingsBatch(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + batch := []*models.AiReviewFinding{ + {Id: "f1", Description: "finding 1"}, + {Id: "f2", Description: "finding 2"}, + } + err := saveFindingsBatch(mockDal, batch) + assert.Nil(t, err) + mockDal.AssertNumberOfCalls(t, "CreateOrUpdate", 2) + }) + + t.Run("empty batch", func(t *testing.T) { + mockDal := new(mockdal.Dal) + err := saveFindingsBatch(mockDal, []*models.AiReviewFinding{}) + assert.Nil(t, err) + }) + + t.Run("error on save", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything). + Return(nil).Once() + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + batch := []*models.AiReviewFinding{ + {Id: "f1"}, + {Id: "f2"}, + } + err := saveFindingsBatch(mockDal, batch) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "db error") + }) +} + +func TestExtractAiReviewFindings(t *testing.T) { + t.Run("processes one review with findings", func(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDl := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockRows := new(mockdal.Rows) + + data := &AiReviewTaskData{ + Options: &AiReviewOptions{RepoId: "repo-1"}, + } + + mockCtx.On("GetData").Return(data) + mockCtx.On("GetDal").Return(mockDl) + mockCtx.On("GetLogger").Return(mockLogger) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + mockDl.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDl.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*models.AiReview) + *dst = models.AiReview{ + Id: "review-1", + RepoId: "repo-1", + AiTool: "CodeRabbit", + Body: "- This has a security vulnerability in the auth handler that needs fixing\n", + } + }).Return(nil) + + mockDl.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ExtractAiReviewFindings(mockCtx) + assert.Nil(t, err) + }) +} diff --git a/backend/plugins/aireview/tasks/extract_ai_reviews.go b/backend/plugins/aireview/tasks/extract_ai_reviews.go new file mode 100644 index 00000000000..96e90ba4f31 --- /dev/null +++ b/backend/plugins/aireview/tasks/extract_ai_reviews.go @@ -0,0 +1,800 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +var ExtractAiReviewsMeta = plugin.SubTaskMeta{ + Name: "extractAiReviews", + EntryPoint: ExtractAiReviews, + EnabledByDefault: true, + Description: "Extract AI-generated reviews from pull request comments", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW}, +} + +// ExtractAiReviews identifies and extracts AI-generated reviews from PR comments +func ExtractAiReviews(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + // Build query based on mode (projectName or repoId) + var clauses []dal.Clause + + if data.Options.ProjectName != "" { + logger.Info("Starting AI review extraction for project: %s", data.Options.ProjectName) + // Project mode: join with project_mappings to get all repos in project + clauses = []dal.Clause{ + dal.Select("prc.*, pr.base_repo_id, pr.status as pr_status, pr.merged_date, pr.url as pr_url, a.user_name as account_username"), + dal.From("pull_request_comments prc"), + dal.Join("LEFT JOIN pull_requests pr ON prc.pull_request_id = pr.id"), + dal.Join("LEFT JOIN accounts a ON prc.account_id = a.id"), + dal.Join("LEFT JOIN project_mapping pm ON pr.base_repo_id = pm.row_id"), + dal.Where("pm.project_name = ? AND pm.`table` = ?", data.Options.ProjectName, "repos"), + } + } else { + logger.Info("Starting AI review extraction for repo: %s", data.Options.RepoId) + // Single repo mode + clauses = []dal.Clause{ + dal.Select("prc.*, pr.base_repo_id, pr.status as pr_status, pr.merged_date, pr.url as pr_url, a.user_name as account_username"), + dal.From("pull_request_comments prc"), + dal.Join("LEFT JOIN pull_requests pr ON prc.pull_request_id = pr.id"), + dal.Join("LEFT JOIN accounts a ON prc.account_id = a.id"), + dal.Where("pr.base_repo_id = ?", data.Options.RepoId), + } + } + + cursor, err := db.Cursor(clauses...) + if err != nil { + return errors.Default.Wrap(err, "failed to query pull request comments") + } + defer cursor.Close() + + // Track processed reviews to avoid duplicates + processedReviews := make(map[string]bool) + batchSize := 100 + batch := make([]*models.AiReview, 0, batchSize) + + for cursor.Next() { + var comment struct { + code.PullRequestComment + BaseRepoId string `gorm:"column:base_repo_id"` + PrStatus string `gorm:"column:pr_status"` + MergedDate *time.Time `gorm:"column:merged_date"` + PrUrl string `gorm:"column:pr_url"` + AccountUsername string `gorm:"column:account_username"` + } + + if err := db.Fetch(cursor, &comment); err != nil { + return errors.Default.Wrap(err, "failed to fetch comment") + } + + // Use the resolved username from accounts table for reliable tool detection. + // Fall back to domain account_id if accounts table entry is missing. + username := comment.AccountUsername + if username == "" { + username = comment.AccountId + } + + // Check if this is an AI-generated review + aiTool, isAiReview := detectAiTool(data, username, comment.Body) + if !isAiReview { + continue + } + + // Generate unique ID for this review + reviewId := generateReviewId(comment.PullRequestId, comment.Id, aiTool) + if processedReviews[reviewId] { + continue + } + processedReviews[reviewId] = true + + // Parse the review content for metrics + reviewMetrics := parseReviewMetrics(comment.Body) + + // Detect risk level + riskLevel, riskScore := detectRiskLevel(data, comment.Body) + + // Determine repo ID (from query result in project mode, from options in repo mode) + repoId := comment.BaseRepoId + if repoId == "" { + repoId = data.Options.RepoId + } + + // Create AI review record + aiReview := &models.AiReview{ + Id: reviewId, + PullRequestId: comment.PullRequestId, + RepoId: repoId, + AiTool: aiTool, + AiToolUser: username, + ReviewId: comment.Id, + Body: comment.Body, + Summary: extractSummary(comment.Body), + CreatedDate: comment.CreatedDate, + RiskLevel: riskLevel, + RiskScore: riskScore, + RiskConfidence: reviewMetrics.Confidence, + IssuesFound: reviewMetrics.IssuesFound, + SuggestionsCount: reviewMetrics.SuggestionsCount, + FilesReviewed: reviewMetrics.FilesReviewed, + LinesReviewed: reviewMetrics.LinesReviewed, + EffortComplexity: reviewMetrics.Complexity, + EffortRating: reviewMetrics.EffortRating, + EffortMinutes: reviewMetrics.EffortMinutes, + SuggestionsAccepted: reviewMetrics.SuggestionsAccepted, + PreMergeChecksPassed: reviewMetrics.PreMergeChecksPassed, + PreMergeChecksFailed: reviewMetrics.PreMergeChecksFailed, + PreMergeChecksInconclusive: reviewMetrics.PreMergeChecksInconclusive, + ReviewState: detectReviewState(comment.Body, comment.Status), + SourcePlatform: detectSourcePlatform(comment.PullRequestId), + SourceUrl: buildCommentUrl(comment.PrUrl, comment.Id), + } + + batch = append(batch, aiReview) + + if len(batch) >= batchSize { + if err := saveBatch(db, batch); err != nil { + return err + } + batch = make([]*models.AiReview, 0, batchSize) + } + } + + // Save remaining batch + if len(batch) > 0 { + if err := saveBatch(db, batch); err != nil { + return err + } + } + + logger.Info("Completed AI review extraction: %d reviews found", len(processedReviews)) + return nil +} + +// detectAiTool checks if the comment is from an AI review tool +func detectAiTool(data *AiReviewTaskData, accountId, body string) (string, bool) { + // Check CodeRabbit + if data.Options.ScopeConfig.CodeRabbitEnabled { + if data.CodeRabbitUsernameRegex != nil && data.CodeRabbitUsernameRegex.MatchString(accountId) { + return models.AiToolCodeRabbit, true + } + if data.CodeRabbitPatternRegex != nil && data.CodeRabbitPatternRegex.MatchString(body) { + return models.AiToolCodeRabbit, true + } + } + + // Check Cursor Bugbot + if data.Options.ScopeConfig.CursorBugbotEnabled { + if data.CursorBugbotUsernameRegex != nil && data.CursorBugbotUsernameRegex.MatchString(accountId) { + return models.AiToolCursorBugbot, true + } + if data.CursorBugbotPatternRegex != nil && data.CursorBugbotPatternRegex.MatchString(body) { + return models.AiToolCursorBugbot, true + } + } + + // Check Qodo (formerly Codium) + if data.Options.ScopeConfig.QodoEnabled { + if data.QodoUsernameRegex != nil && data.QodoUsernameRegex.MatchString(accountId) { + return models.AiToolQodo, true + } + if data.QodoPatternRegex != nil && data.QodoPatternRegex.MatchString(body) { + return models.AiToolQodo, true + } + } + + // Check Gemini Code Assist + if data.Options.ScopeConfig.GeminiEnabled { + if data.GeminiUsernameRegex != nil && data.GeminiUsernameRegex.MatchString(accountId) { + return models.AiToolGemini, true + } + if data.GeminiPatternRegex != nil && data.GeminiPatternRegex.MatchString(body) { + return models.AiToolGemini, true + } + } + + return "", false +} + +// generateReviewId creates a deterministic ID for an AI review +func generateReviewId(prId, commentId, aiTool string) string { + hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%s", prId, commentId, aiTool))) + return "aireview:" + hex.EncodeToString(hash[:16]) +} + +// ReviewMetrics holds parsed metrics from review content +type ReviewMetrics struct { + IssuesFound int + SuggestionsCount int + SuggestionsAccepted int + FilesReviewed int + LinesReviewed int + Complexity string + EffortRating int // 1-5 scale numeric rating + EffortMinutes int + Confidence int + PreMergeChecksPassed int + PreMergeChecksFailed int + PreMergeChecksInconclusive int +} + +// parseReviewMetrics extracts metrics from review body +func parseReviewMetrics(body string) ReviewMetrics { + metrics := ReviewMetrics{ + Confidence: 70, // Default confidence + } + + // Parse CodeRabbit numeric effort rating (e.g., "🎯 3 (Moderate)" or "🎯 3") + // This format appears in CodeRabbit reviews + effortRatingRe := regexp.MustCompile(`🎯\s*(\d)(?:\s*\([^)]+\))?`) + if match := effortRatingRe.FindStringSubmatch(body); len(match) > 1 { + if val, err := strconv.Atoi(match[1]); err == nil && val >= 1 && val <= 5 { + metrics.EffortRating = val + } + } + + // Parse Qodo effort rating (e.g., "Estimated effort to review: 3" or with emoji dots) + if metrics.EffortRating == 0 { + qodoEffortRe := regexp.MustCompile(`(?i)estimated effort[^:]*:\s*(\d)`) + if match := qodoEffortRe.FindStringSubmatch(body); len(match) > 1 { + if val, err := strconv.Atoi(match[1]); err == nil && val >= 1 && val <= 5 { + metrics.EffortRating = val + } + } + } + + // Parse effort/complexity (CodeRabbit format) + complexityRe := regexp.MustCompile(`(?i)(simple|moderate|complex|trivial)`) + if match := complexityRe.FindString(body); match != "" { + metrics.Complexity = strings.ToLower(match) + switch metrics.Complexity { + case "trivial", "simple": + metrics.EffortMinutes = 5 + case "moderate": + metrics.EffortMinutes = 15 + case "complex": + metrics.EffortMinutes = 30 + } + } + + // Infer complexity from effort rating if not explicitly stated + if metrics.Complexity == "" && metrics.EffortRating > 0 { + switch { + case metrics.EffortRating <= 2: + metrics.Complexity = "simple" + case metrics.EffortRating <= 3: + metrics.Complexity = "moderate" + default: + metrics.Complexity = "complex" + } + } + + // Parse time estimate (e.g., "~12 minutes" or "⏱️ ~20 minutes") + timeRe := regexp.MustCompile(`(?:⏱️\s*)?~?(\d+)\s*minutes?`) + if match := timeRe.FindStringSubmatch(body); len(match) > 1 { + if val, err := strconv.Atoi(match[1]); err == nil { + metrics.EffortMinutes = val + } + } + + // Parse pre-merge check results (CodeRabbit format: "2 passed, 1 inconclusive") + // Also handles: "✅ 2 checks passed" or "❌ 1 check failed" + parsePreMergeChecks(body, &metrics) + + // Parse suggestion acceptance signals from AI tool comment bodies. + // AI tools update their comments to reflect which suggestions were applied: + // - CodeRabbit: "✅ Resolved" markers in tracking tables, resolved thread counts + // - Qodo: checkbox rows marked with ✅ in suggestion tables + parseSuggestionAcceptance(body, &metrics) + + // Count issue patterns + issuePatterns := []string{ + `(?i)\b(bug|error|issue|problem|warning)\b`, + `(?i)❌`, + `(?i)⚠️`, + } + for _, pattern := range issuePatterns { + re := regexp.MustCompile(pattern) + metrics.IssuesFound += len(re.FindAllString(body, -1)) + } + + // Count suggestions + suggestionRe := regexp.MustCompile(`(?i)(suggest|recommend|consider|should|could)`) + metrics.SuggestionsCount = len(suggestionRe.FindAllString(body, -1)) + + // Count file references + fileRe := regexp.MustCompile(`\b[\w/]+\.(go|ts|js|py|java|rs|cpp|c|h)\b`) + files := make(map[string]bool) + for _, match := range fileRe.FindAllString(body, -1) { + files[match] = true + } + metrics.FilesReviewed = len(files) + + // Parse lines changed (e.g., "+50 −36") + linesRe := regexp.MustCompile(`\+(\d+)\s*[−-](\d+)`) + if match := linesRe.FindStringSubmatch(body); len(match) > 2 { + added, err1 := strconv.Atoi(match[1]) + removed, err2 := strconv.Atoi(match[2]) + if err1 == nil && err2 == nil { + metrics.LinesReviewed = added + removed + } + } + + return metrics +} + +// parsePreMergeChecks extracts pre-merge check results from CodeRabbit format +// Handles formats like: "2 passed, 1 inconclusive" or "✅ 2 checks passed" +func parsePreMergeChecks(body string, metrics *ReviewMetrics) { + // CodeRabbit format: "N passed" or "✅ N checks passed" or "N checks passed" + passedRe := regexp.MustCompile(`(?i)(?:✅\s*)?(\d+)\s*(?:checks?\s+)?passed`) + if match := passedRe.FindStringSubmatch(body); len(match) > 1 { + if val, err := strconv.Atoi(match[1]); err == nil { + metrics.PreMergeChecksPassed = val + } + } + + // CodeRabbit format: "N failed" or "❌ N checks failed" or "N checks failed" + failedRe := regexp.MustCompile(`(?i)(?:❌\s*)?(\d+)\s*(?:checks?\s+)?failed`) + if match := failedRe.FindStringSubmatch(body); len(match) > 1 { + if val, err := strconv.Atoi(match[1]); err == nil { + metrics.PreMergeChecksFailed = val + } + } + + // CodeRabbit format: "N inconclusive" + inconclusiveRe := regexp.MustCompile(`(?i)(\d+)\s*(?:checks?\s+)?inconclusive`) + if match := inconclusiveRe.FindStringSubmatch(body); len(match) > 1 { + if val, err := strconv.Atoi(match[1]); err == nil { + metrics.PreMergeChecksInconclusive = val + } + } +} + +// parseSuggestionAcceptance counts accepted/applied suggestions from AI tool comment bodies. +// +// AI review tools update their own comments to reflect suggestion outcomes: +// +// CodeRabbit patterns: +// - Tracking table rows with "✅ Resolved" or "✅" status markers +// - "N suggestions applied" or "N/M suggestions accepted" +// - "Applied suggestion" in resolution tracking +// +// Qodo patterns: +// - Suggestion table rows with "✅" checkbox markers (applied) +// - "[x]" checked checkboxes in suggestion lists (applied via Apply button) +// - "N suggestions implemented" summary lines +func parseSuggestionAcceptance(body string, metrics *ReviewMetrics) { + accepted := 0 + + // Pattern 1: Explicit "N suggestions applied/accepted/implemented" summary lines + // Matches: "3 suggestions applied", "2/5 suggestions accepted", "1 suggestion implemented" + summaryRe := regexp.MustCompile(`(?i)(\d+)(?:/\d+)?\s+suggestions?\s+(?:applied|accepted|implemented)`) + if match := summaryRe.FindStringSubmatch(body); len(match) > 1 { + if val, err := strconv.Atoi(match[1]); err == nil { + accepted += val + } + } + + // Pattern 2: "Applied suggestion" markers (CodeRabbit tracking tables) + // Matches individual "Applied suggestion" or "Suggestion applied" entries + appliedRe := regexp.MustCompile(`(?i)(?:applied suggestion|suggestion applied|✅\s*Resolved)`) + accepted += len(appliedRe.FindAllString(body, -1)) + + // Pattern 3: Checked checkboxes in suggestion lists (Qodo "Apply" checkboxes) + // Matches: "- [x] **suggestion title**" but NOT "- [ ] **suggestion title**" + // These appear in Qodo's persistent suggestion table when a developer clicks Apply. + // Note: comment bodies from CSV/DB may store newlines as literal "\n" (escaped), + // so we match both real newlines and the "- " prefix mid-line. + checkedBoxRe := regexp.MustCompile(`(?m)(?:^|\\n)[\s-]*\[x\]\s+`) + uncheckedBoxRe := regexp.MustCompile(`(?m)(?:^|\\n)[\s-]*\[ \]\s+`) + checkedCount := len(checkedBoxRe.FindAllString(body, -1)) + uncheckedCount := len(uncheckedBoxRe.FindAllString(body, -1)) + // Only count checkboxes as accepted suggestions if there's a mix of checked/unchecked + // (pure checked lists are likely self-review checkboxes, not suggestion tracking) + if checkedCount > 0 && uncheckedCount > 0 { + accepted += checkedCount + } + + // Pattern 4: CodeRabbit "committed suggestion" references + // When a user clicks "Commit suggestion" on GitHub, the thread may contain + // "Suggestion was applied in commit " or similar + committedRe := regexp.MustCompile(`(?i)suggestion\s+(?:was\s+)?(?:applied|committed)\s+in\s+commit`) + accepted += len(committedRe.FindAllString(body, -1)) + + metrics.SuggestionsAccepted = accepted +} + +// extractSummary extracts a clean markdown summary from the review body +func extractSummary(body string) string { + // First, convert HTML to markdown + cleaned := htmlToMarkdown(body) + + // Try to extract specific sections based on AI tool format + var summaryParts []string + + // Qodo format: Extract key sections + if strings.Contains(cleaned, "PR Reviewer Guide") || strings.Contains(cleaned, "Estimated effort") { + // Extract effort estimate + effortRe := regexp.MustCompile(`(?i)\*\*Estimated effort[^*]*\*\*[:\s]*(\d+)`) + if match := effortRe.FindStringSubmatch(cleaned); len(match) > 1 { + summaryParts = append(summaryParts, "Effort: "+match[1]+"/5") + } + + // Extract security status + if strings.Contains(cleaned, "No security concerns") { + summaryParts = append(summaryParts, "Security: OK") + } else if strings.Contains(cleaned, "security") { + summaryParts = append(summaryParts, "Security: Review needed") + } + + // Extract focus area titles (bold items after "Recommended focus areas") + focusRe := regexp.MustCompile(`\*\*([^*]+)\*\*`) + focusMatches := focusRe.FindAllStringSubmatch(cleaned, -1) + for _, match := range focusMatches { + title := strings.TrimSpace(match[1]) + // Skip generic headers + if title != "" && !strings.Contains(strings.ToLower(title), "estimated effort") && + !strings.Contains(strings.ToLower(title), "security") && + !strings.Contains(strings.ToLower(title), "ticket") && + !strings.Contains(strings.ToLower(title), "recommended focus") && + len(title) < 100 { + summaryParts = append(summaryParts, title) + if len(summaryParts) >= 4 { + break + } + } + } + } + + // Gemini Code Assist format: PR-level summary comment + // Format: "## Summary of Changes\n\nHello @user, I'm Gemini Code Assist...\n\nThis pull request [summary]...\n\n### Highlights\n* ..." + if len(summaryParts) == 0 && strings.Contains(cleaned, "Summary of Changes") && strings.Contains(cleaned, "Gemini Code Assist") { + // Extract the first substantive paragraph after the greeting line + paragraphs := regexp.MustCompile(`\n\s*\n`).Split(cleaned, -1) + for _, p := range paragraphs { + p = strings.TrimSpace(p) + // Skip headers, greeting line, and short lines + if strings.HasPrefix(p, "#") || strings.Contains(p, "Gemini Code Assist") || len(p) < 30 { + continue + } + summaryParts = append(summaryParts, p) + break + } + // Also extract Highlights items + highlightsRe := regexp.MustCompile(`(?m)^\*\s+\*\*([^*]+)\*\*`) + for _, match := range highlightsRe.FindAllStringSubmatch(cleaned, 3) { + summaryParts = append(summaryParts, match[1]) + } + } + + // Gemini Code Assist format: Inline review comment with priority badge + // Format: "![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg)\n\n[review text]" + // May have multiple badges on one line: "![security-high](...) ![high](...)\n\n[review text]" + if len(summaryParts) == 0 && strings.Contains(cleaned, "gstatic.com/codereviewagent") { + // Strip all image badge references (may appear multiple times on a line) + badgeRe := regexp.MustCompile(`!\[[^\]]*\]\([^)]*gstatic\.com/codereviewagent[^)]*\)\s*`) + stripped := strings.TrimSpace(badgeRe.ReplaceAllString(cleaned, "")) + if len(stripped) > 20 { + summaryParts = append(summaryParts, stripped) + } + } + + // CodeRabbit format: Look for "Walkthrough" section + if len(summaryParts) == 0 && strings.Contains(cleaned, "Walkthrough") { + walkRe := regexp.MustCompile(`(?is)Walkthrough\s*\n+(.+?)(\n\n|$)`) + if match := walkRe.FindStringSubmatch(cleaned); len(match) > 1 { + summaryParts = append(summaryParts, strings.TrimSpace(match[1])) + } + } + + // CodeRabbit: Look for potential issue + if strings.Contains(cleaned, "Potential issue") { + // Find the main recommendation after "Potential issue" + issueRe := regexp.MustCompile(`(?is)Potential issue.*?\*\*([^*]+)\*\*`) + if match := issueRe.FindStringSubmatch(cleaned); len(match) > 1 { + summaryParts = append(summaryParts, "Issue: "+strings.TrimSpace(match[1])) + } + } + + // Generic: Look for summary/overview sections + if len(summaryParts) == 0 { + // Match "Summary:" or "Overview:" followed by content, but not "Summary of Changes" (Gemini heading) + summaryRe := regexp.MustCompile(`(?i)(summary|overview)[:\s]+(?:of\s+changes\s*)?(.{10,300})`) + if match := summaryRe.FindStringSubmatch(cleaned); len(match) > 2 { + text := strings.TrimSpace(match[2]) + // Skip if it's just a Gemini heading remainder + if !strings.HasPrefix(strings.ToLower(text), "of changes") { + summaryParts = append(summaryParts, text) + } + } + } + + // Fallback: first meaningful paragraph + if len(summaryParts) == 0 { + paragraphs := regexp.MustCompile(`\n\s*\n`).Split(cleaned, -1) + for _, p := range paragraphs { + p = strings.TrimSpace(p) + // Skip headers, short lines, URLs, and metadata + if len(p) > 30 && len(p) < 500 && + !strings.HasPrefix(p, "#") && + !strings.HasPrefix(p, "http") && + !strings.HasPrefix(p, "[") && + !strings.HasPrefix(p, "![") && + !strings.Contains(p, "```") { + summaryParts = append(summaryParts, p) + break + } + } + } + + // Build summary + summary := strings.Join(summaryParts, " | ") + + // Final fallback + if summary == "" { + // Take first 300 chars, avoiding URLs + for _, line := range strings.Split(cleaned, "\n") { + line = strings.TrimSpace(line) + if len(line) > 20 && !strings.HasPrefix(line, "http") && !strings.HasPrefix(line, "[") { + summary = line + break + } + } + } + + // Truncate if too long + if len(summary) > 500 { + if idx := strings.LastIndex(summary[:500], ". "); idx > 200 { + summary = summary[:idx+1] + } else if idx := strings.LastIndex(summary[:500], " | "); idx > 100 { + summary = summary[:idx] + } else { + summary = summary[:497] + "..." + } + } + + return strings.TrimSpace(summary) +} + +// htmlToMarkdown converts HTML elements to markdown equivalents while preserving structure +func htmlToMarkdown(body string) string { + // Strip surrounding JSON quotes if present (bodies may be stored with JSON quoting) + if len(body) >= 2 && body[0] == '"' && body[len(body)-1] == '"' { + body = body[1 : len(body)-1] + } + + // Replace escaped newlines and quotes (from JSON encoding) + body = strings.ReplaceAll(body, "\\n", "\n") + body = strings.ReplaceAll(body, "\\r", "") + body = strings.ReplaceAll(body, "\\\"", "\"") + + // Remove HTML comments first + commentRe := regexp.MustCompile(``) + body = commentRe.ReplaceAllString(body, "") + + // Handle nested tags: text -> extract text first + // Convert links with nested content - capture everything between and + nestedLinkRe := regexp.MustCompile(`(?is)]*>(.*?)`) + body = nestedLinkRe.ReplaceAllStringFunc(body, func(match string) string { + parts := nestedLinkRe.FindStringSubmatch(match) + if len(parts) < 3 { + return match + } + url := parts[1] + text := parts[2] + // Strip any HTML tags from the link text + text = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(text, "") + text = strings.TrimSpace(text) + if text == "" { + return "" + } + return "[" + text + "](" + url + ")" + }) + + // Convert and to **bold** + strongRe := regexp.MustCompile(`(?is)<(?:strong|b)>(.*?)`) + body = strongRe.ReplaceAllString(body, "**$1**") + + // Convert and to *italic* + emRe := regexp.MustCompile(`(?is)<(?:em|i)>(.*?)`) + body = emRe.ReplaceAllString(body, "*$1*") + + // Convert to `code` + codeRe := regexp.MustCompile(`(?is)(.*?)`) + body = codeRe.ReplaceAllString(body, "`$1`") + + // Convert
and
to newlines + brRe := regexp.MustCompile(`(?i)`) + body = brRe.ReplaceAllString(body, "\n") + + // Extract content from
...content
+ // Handle summary with nested HTML + detailsRe := regexp.MustCompile(`(?is)
\s*(.*?)\s*(.*?)\s*
`) + body = detailsRe.ReplaceAllStringFunc(body, func(match string) string { + parts := detailsRe.FindStringSubmatch(match) + if len(parts) < 3 { + return match + } + title := parts[1] + content := parts[2] + // Clean up the title - remove HTML tags but keep markdown + title = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(title, "") + title = strings.TrimSpace(title) + content = strings.TrimSpace(content) + if title == "" { + return content + } + return "\n**" + title + "**\n" + content + "\n" + }) + + // Remove remaining tags + summaryTagRe := regexp.MustCompile(`(?i)`) + body = summaryTagRe.ReplaceAllString(body, "") + + // Convert simple tables: extract cell content + tdRe := regexp.MustCompile(`(?is)]*>(.*?)`) + body = tdRe.ReplaceAllStringFunc(body, func(match string) string { + content := tdRe.FindStringSubmatch(match) + if len(content) > 1 { + return strings.TrimSpace(content[1]) + "\n" + } + return "" + }) + + // Remove table structure tags + tableTagsRe := regexp.MustCompile(`(?i)]*>`) + body = tableTagsRe.ReplaceAllString(body, "\n") + + // Remove any remaining HTML tags + htmlRe := regexp.MustCompile(`<[^>]+>`) + body = htmlRe.ReplaceAllString(body, "") + + // Clean up HTML entities + body = strings.ReplaceAll(body, " ", " ") + body = strings.ReplaceAll(body, "<", "<") + body = strings.ReplaceAll(body, ">", ">") + body = strings.ReplaceAll(body, "&", "&") + body = strings.ReplaceAll(body, """, "\"") + + // Collapse multiple blank lines + blankLinesRe := regexp.MustCompile(`\n{3,}`) + body = blankLinesRe.ReplaceAllString(body, "\n\n") + + // Clean up lines - remove empty ones but preserve structure + lines := strings.Split(body, "\n") + var cleanedLines []string + prevEmpty := false + for _, line := range lines { + line = strings.TrimRight(line, " \t") + if line == "" { + if !prevEmpty { + cleanedLines = append(cleanedLines, "") + prevEmpty = true + } + } else { + cleanedLines = append(cleanedLines, line) + prevEmpty = false + } + } + + return strings.TrimSpace(strings.Join(cleanedLines, "\n")) +} + +// detectRiskLevel analyzes the review body for risk indicators +func detectRiskLevel(data *AiReviewTaskData, body string) (string, int) { + // Check patterns in order of severity + if data.RiskHighPatternRegex != nil && data.RiskHighPatternRegex.MatchString(body) { + return models.RiskLevelHigh, 80 + } + if data.RiskMediumPatternRegex != nil && data.RiskMediumPatternRegex.MatchString(body) { + return models.RiskLevelMedium, 50 + } + if data.RiskLowPatternRegex != nil && data.RiskLowPatternRegex.MatchString(body) { + return models.RiskLevelLow, 20 + } + + // Default to low risk if no patterns match + return models.RiskLevelLow, 10 +} + +// detectReviewState determines the review outcome +func detectReviewState(body, status string) string { + body = strings.ToLower(body) + + if strings.Contains(body, "approved") || strings.Contains(body, "lgtm") { + return models.ReviewStateApproved + } + if strings.Contains(body, "changes requested") || strings.Contains(body, "request changes") { + return models.ReviewStateChangesRequested + } + + // Check status field + switch strings.ToUpper(status) { + case "APPROVED": + return models.ReviewStateApproved + case "CHANGES_REQUESTED": + return models.ReviewStateChangesRequested + } + + return models.ReviewStateCommented +} + +// detectSourcePlatform determines if the PR is from GitHub or GitLab +func detectSourcePlatform(prId string) string { + if strings.HasPrefix(prId, "github:") { + return "github" + } + if strings.HasPrefix(prId, "gitlab:") { + return "gitlab" + } + return "unknown" +} + +// buildCommentUrl constructs a direct URL to the comment +// commentId format: "github:GithubPrComment:1:123456789" or "gitlab:GitlabMrComment:1:123456" +func buildCommentUrl(prUrl, commentId string) string { + if prUrl == "" { + return "" + } + + // Extract the numeric comment ID from the DevLake ID + parts := strings.Split(commentId, ":") + if len(parts) < 4 { + return prUrl + } + numericId := parts[len(parts)-1] + + // GitHub format: {pr_url}#issuecomment-{id} + if strings.Contains(commentId, "github") || strings.Contains(commentId, "Github") { + return prUrl + "#issuecomment-" + numericId + } + + // GitLab format: {mr_url}#note_{id} + if strings.Contains(commentId, "gitlab") || strings.Contains(commentId, "Gitlab") { + return prUrl + "#note_" + numericId + } + + return prUrl +} + +// saveBatch saves a batch of AI reviews to the database +func saveBatch(db dal.Dal, batch []*models.AiReview) errors.Error { + for _, review := range batch { + err := db.CreateOrUpdate(review) + if err != nil { + return errors.Default.Wrap(err, "failed to save AI review") + } + } + return nil +} diff --git a/backend/plugins/aireview/tasks/extract_ai_reviews_save_batch_test.go b/backend/plugins/aireview/tasks/extract_ai_reviews_save_batch_test.go new file mode 100644 index 00000000000..00504978e0e --- /dev/null +++ b/backend/plugins/aireview/tasks/extract_ai_reviews_save_batch_test.go @@ -0,0 +1,59 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/apache/devlake/core/errors" + mockdal "github.com/apache/devlake/mocks/core/dal" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestSaveBatch(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + batch := []*models.AiReview{ + {Id: "r1", PullRequestId: "pr-1"}, + {Id: "r2", PullRequestId: "pr-2"}, + } + err := saveBatch(mockDal, batch) + assert.Nil(t, err) + mockDal.AssertNumberOfCalls(t, "CreateOrUpdate", 2) + }) + + t.Run("error on save", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything). + Return(errors.Default.New("db error")) + + batch := []*models.AiReview{{Id: "r1"}} + err := saveBatch(mockDal, batch) + assert.NotNil(t, err) + }) + + t.Run("empty batch", func(t *testing.T) { + mockDal := new(mockdal.Dal) + err := saveBatch(mockDal, []*models.AiReview{}) + assert.Nil(t, err) + }) +} diff --git a/backend/plugins/aireview/tasks/extract_ai_reviews_test.go b/backend/plugins/aireview/tasks/extract_ai_reviews_test.go new file mode 100644 index 00000000000..2e585ed1a07 --- /dev/null +++ b/backend/plugins/aireview/tasks/extract_ai_reviews_test.go @@ -0,0 +1,946 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "regexp" + "testing" + + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" +) + +func TestDetectAiTool_CodeRabbit(t *testing.T) { + tests := []struct { + name string + accountId string + body string + wantTool string + wantIsAi bool + }{ + { + name: "CodeRabbit by username", + accountId: "coderabbitai", + body: "Some review comment", + wantTool: models.AiToolCodeRabbit, + wantIsAi: true, + }, + { + name: "CodeRabbit by pattern in body", + accountId: "someuser", + body: "## Summary by CodeRabbit\n\nThis PR adds...", + wantTool: models.AiToolCodeRabbit, + wantIsAi: true, + }, + { + name: "CodeRabbit walkthrough pattern", + accountId: "bot", + body: "## Walkthrough\nThe changes introduce...", + wantTool: models.AiToolCodeRabbit, + wantIsAi: true, + }, + { + name: "Not an AI review", + accountId: "developer", + body: "LGTM, looks good to me!", + wantTool: "", + wantIsAi: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + // Compile patterns + err := CompilePatterns(taskData) + assert.NoError(t, err) + + gotTool, gotIsAi := detectAiTool(taskData, tt.accountId, tt.body) + assert.Equal(t, tt.wantTool, gotTool) + assert.Equal(t, tt.wantIsAi, gotIsAi) + }) + } +} + +func TestParseReviewMetrics(t *testing.T) { + tests := []struct { + name string + body string + wantComplexity string + wantEffort int + wantIssuesMin int + wantSuggestions int + }{ + { + name: "Simple review with time estimate", + body: "This is a simple change that takes ~12 minutes to review.", + wantComplexity: "simple", + wantEffort: 12, + wantIssuesMin: 0, + wantSuggestions: 0, + }, + { + name: "Complex review with issues", + body: "This is a complex change. Found a bug in the auth logic. Also there's an error in validation.", + wantComplexity: "complex", + wantEffort: 30, + wantIssuesMin: 2, + wantSuggestions: 0, + }, + { + name: "Review with suggestions", + body: "I suggest refactoring this. You should consider using a map. I would recommend adding tests.", + wantComplexity: "", + wantEffort: 0, + wantIssuesMin: 0, + wantSuggestions: 3, + }, + { + name: "Review with line changes", + body: "Changes: +50 −36 lines modified", + wantComplexity: "", + wantEffort: 0, + wantIssuesMin: 0, + wantSuggestions: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metrics := parseReviewMetrics(tt.body) + + if tt.wantComplexity != "" { + assert.Equal(t, tt.wantComplexity, metrics.Complexity) + } + if tt.wantEffort > 0 { + assert.Equal(t, tt.wantEffort, metrics.EffortMinutes) + } + assert.GreaterOrEqual(t, metrics.IssuesFound, tt.wantIssuesMin) + assert.GreaterOrEqual(t, metrics.SuggestionsCount, tt.wantSuggestions) + }) + } +} + +func TestDetectRiskLevel(t *testing.T) { + tests := []struct { + name string + body string + wantLevel string + wantScore int + }{ + { + name: "High risk - security", + body: "CRITICAL: This introduces a security vulnerability", + wantLevel: models.RiskLevelHigh, + wantScore: 80, + }, + { + name: "Medium risk - warning", + body: "Warning: This change may have moderate impact", + wantLevel: models.RiskLevelMedium, + wantScore: 50, + }, + { + name: "Low risk - minor", + body: "Minor suggestion: consider renaming this variable", + wantLevel: models.RiskLevelLow, + wantScore: 20, + }, + { + name: "Default to low", + body: "Looks good, no issues found", + wantLevel: models.RiskLevelLow, + wantScore: 10, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + err := CompilePatterns(taskData) + assert.NoError(t, err) + + gotLevel, gotScore := detectRiskLevel(taskData, tt.body) + assert.Equal(t, tt.wantLevel, gotLevel) + assert.Equal(t, tt.wantScore, gotScore) + }) + } +} + +func TestDetectReviewState(t *testing.T) { + tests := []struct { + name string + body string + status string + wantState string + }{ + { + name: "Approved in body", + body: "LGTM, approved!", + status: "", + wantState: models.ReviewStateApproved, + }, + { + name: "Changes requested", + body: "Please make changes requested above", + status: "", + wantState: models.ReviewStateChangesRequested, + }, + { + name: "Approved via status", + body: "Some comment", + status: "APPROVED", + wantState: models.ReviewStateApproved, + }, + { + name: "Default to commented", + body: "Here are some thoughts", + status: "", + wantState: models.ReviewStateCommented, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotState := detectReviewState(tt.body, tt.status) + assert.Equal(t, tt.wantState, gotState) + }) + } +} + +func TestExtractSummary(t *testing.T) { + tests := []struct { + name string + body string + wantContain string + wantNotContain string + }{ + { + name: "Extract summary section", + body: "## Summary\nThis PR adds a new feature.\n\nDetails follow...", + wantContain: "This PR adds a new feature", + }, + { + name: "Extract walkthrough section", + body: "## Walkthrough\nThe changes introduce new handlers.\n\n## Details", + wantContain: "changes introduce new handlers", + }, + { + name: "Truncate long body without summary", + body: "This is a very long comment that goes on and on with more content.", + wantContain: "This is a very long comment", + }, + { + name: "Clean HTML tags", + body: "
Some content here that is long enough to be extracted as a summary paragraph
", + wantContain: "Some content here", + wantNotContain: "", + }, + { + name: "Preserve markdown quote blocks", + body: "> This is a quote\n>Another line\n\nActual content here is important", + wantContain: "> This is a quote", + }, + { + name: "Handle escaped newlines", + body: "First line\\nSecond line\\nThird line with content", + wantContain: "Second line", + }, + { + name: "Extract Qodo focus areas", + body: "## PR Reviewer Guide\n\n**Estimated effort to review**: 3\n\n**Recommended focus areas for review**:\n\n**Over-broad replace** - The sed replacements may break production.", + wantContain: "Over-broad replace", + }, + { + name: "Convert details/summary to markdown", + body: "Main content
HiddenSecret stuff
More content", + wantContain: "**Hidden**", + wantNotContain: "
", + }, + { + name: "Preserve markdown links", + body: "Check [this link](https://example.com) for details.", + wantContain: "[this link]", + }, + { + name: "Handle real Qodo format", + body: "## PR Reviewer Guide\n\n
\n\n\n
\n\n**Ticket compliance**\n\n
Estimated effort to review: 2
", + wantContain: "Effort: 2/5", + wantNotContain: "", + }, + { + name: "Gemini PR-level summary extracts content after greeting", + body: "## Summary of Changes\n\nHello @user, I'm Gemini Code Assist[^1]! Here is my review.\n\nThis pull request refactors the authentication module to use JWT tokens instead of session cookies, improving scalability.\n\n### Highlights\n\n* **Token rotation**: Added automatic token refresh logic\n* **Session cleanup**: Removed legacy session store", + wantContain: "This pull request refactors the authentication module", + wantNotContain: "of Changes", + }, + { + name: "Gemini inline review strips priority badge", + body: "![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg)\n\nThe error returned by `db.Save()` is not checked here, which could silently drop write failures.", + wantContain: "error returned by", + wantNotContain: "gstatic.com", + }, + { + name: "Gemini inline review with JSON-quoted body", + body: "\"![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg)\\n\\nThis test uses the `eth4` interface, which is not created in the test setup.\"", + wantContain: "This test uses the", + wantNotContain: "gstatic.com", + }, + { + name: "Gemini inline review with multiple badges", + body: "\"![security-high](https://www.gstatic.com/codereviewagent/security-high-priority.svg) ![high](https://www.gstatic.com/codereviewagent/high-priority.svg)\\n\\nA command injection vulnerability exists here.\"", + wantContain: "command injection vulnerability", + wantNotContain: "gstatic.com", + }, + { + name: "Gemini PR summary extracts highlights", + body: "## Summary of Changes\n\nHello @dev, I'm Gemini Code Assist[^1]!\n\nThis PR adds retry logic for failed API calls to improve resilience.\n\n### Highlights\n\n* **Exponential backoff**: Implements configurable retry delays\n* **Circuit breaker**: Prevents cascading failures", + wantContain: "Exponential backoff", + }, + { + name: "Generic summary regex does not match Gemini heading", + body: "## Summary of Changes\n\nSome unrelated content that is long enough to be a paragraph for extraction purposes.", + wantContain: "Some unrelated content", + wantNotContain: "of Changes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractSummary(tt.body) + assert.Contains(t, got, tt.wantContain) + if tt.wantNotContain != "" { + assert.NotContains(t, got, tt.wantNotContain) + } + }) + } +} + +func TestHtmlToMarkdown(t *testing.T) { + tests := []struct { + name string + input string + wantContain string + wantNotContain string + }{ + { + name: "Convert strong to bold markdown", + input: "Bold text", + wantContain: "**Bold**", + wantNotContain: "", + }, + { + name: "Handle escaped newlines", + input: "Line1\\nLine2\\nLine3", + wantContain: "Line1\nLine2\nLine3", + }, + { + name: "Convert details/summary to markdown", + input: "Before
TitleHidden content
After", + wantContain: "**Title**", + wantNotContain: "
", + }, + { + name: "Preserve markdown quote blocks", + input: "> Quoted text\n> More quoted", + wantContain: "> Quoted text", + }, + { + name: "Preserve bold markers", + input: "This is **bold** and __also bold__", + wantContain: "**bold**", + }, + { + name: "Preserve markdown headers", + input: "## This is a header\n\nParagraph text", + wantContain: "## This is a header", + }, + { + name: "Preserve markdown links", + input: "See [documentation](https://docs.example.com) here", + wantContain: "[documentation](https://docs.example.com)", + }, + { + name: "Convert HTML links to markdown", + input: "See documentation here", + wantContain: "[documentation](https://docs.example.com)", + }, + { + name: "Remove HTML tables but keep content", + input: "
Cell content
", + wantContain: "Cell content", + wantNotContain: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := htmlToMarkdown(tt.input) + assert.Contains(t, got, tt.wantContain) + if tt.wantNotContain != "" { + assert.NotContains(t, got, tt.wantNotContain) + } + }) + } +} + +func TestGenerateReviewId(t *testing.T) { + // Test deterministic ID generation + id1 := generateReviewId("pr-123", "comment-456", "coderabbit") + id2 := generateReviewId("pr-123", "comment-456", "coderabbit") + id3 := generateReviewId("pr-123", "comment-789", "coderabbit") + + assert.Equal(t, id1, id2, "Same inputs should produce same ID") + assert.NotEqual(t, id1, id3, "Different inputs should produce different IDs") + assert.True(t, len(id1) > 0, "ID should not be empty") + assert.Contains(t, id1, "aireview:", "ID should have correct prefix") +} + +func TestDetectSourcePlatform(t *testing.T) { + tests := []struct { + name string + prId string + wantPlat string + }{ + { + name: "GitHub PR", + prId: "github:GithubPullRequest:1:12345", + wantPlat: "github", + }, + { + name: "GitLab MR", + prId: "gitlab:GitlabMergeRequest:1:67890", + wantPlat: "gitlab", + }, + { + name: "Unknown platform", + prId: "bitbucket:PullRequest:1:11111", + wantPlat: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectSourcePlatform(tt.prId) + assert.Equal(t, tt.wantPlat, got) + }) + } +} + +func TestCompilePatterns(t *testing.T) { + t.Run("Default config compiles successfully", func(t *testing.T) { + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + err := CompilePatterns(taskData) + assert.NoError(t, err) + assert.NotNil(t, taskData.CodeRabbitUsernameRegex) + assert.NotNil(t, taskData.CodeRabbitPatternRegex) + assert.NotNil(t, taskData.RiskHighPatternRegex) + }) + + t.Run("Invalid regex returns error", func(t *testing.T) { + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: &models.AiReviewScopeConfig{ + CodeRabbitEnabled: true, + CodeRabbitPattern: "[invalid(regex", + }, + }, + } + err := CompilePatterns(taskData) + assert.Error(t, err) + }) + + t.Run("Nil config uses defaults", func(t *testing.T) { + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: nil, + }, + } + err := CompilePatterns(taskData) + assert.NoError(t, err) + assert.NotNil(t, taskData.Options.ScopeConfig) + }) +} + +func TestCodeRabbitPatternMatching(t *testing.T) { + pattern := regexp.MustCompile(`(?i)(coderabbit|walkthrough|summary by coderabbit)`) + + tests := []struct { + name string + input string + want bool + }{ + {"CodeRabbit mention", "This is a CodeRabbit review", true}, + {"Walkthrough section", "## Walkthrough", true}, + {"Summary by CodeRabbit", "Summary by CodeRabbit:", true}, + {"Case insensitive", "CODERABBIT", true}, + {"No match", "Regular comment", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pattern.MatchString(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDetectAiTool_Qodo(t *testing.T) { + tests := []struct { + name string + accountId string + body string + wantTool string + wantIsAi bool + }{ + { + name: "Qodo by username", + accountId: "qodo-merge", + body: "Some review comment", + wantTool: models.AiToolQodo, + wantIsAi: true, + }, + { + name: "Qodo by username with prefix", + accountId: "rhdh-qodo-merge", + body: "Some review comment", + wantTool: models.AiToolQodo, + wantIsAi: true, + }, + { + name: "Qodo by PR Reviewer Guide pattern", + accountId: "somebot", + body: "## PR Reviewer Guide 🔍\n\n⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪", + wantTool: models.AiToolQodo, + wantIsAi: true, + }, + { + name: "Qodo by effort estimate pattern", + accountId: "bot", + body: "⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪", + wantTool: models.AiToolQodo, + wantIsAi: true, + }, + { + name: "Qodo mentioned in body", + accountId: "someuser", + body: "This is a Qodo review with security findings", + wantTool: models.AiToolQodo, + wantIsAi: true, + }, + { + name: "Not Qodo - regular comment", + accountId: "developer", + body: "LGTM, looks good!", + wantTool: "", + wantIsAi: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + // Compile patterns + err := CompilePatterns(taskData) + assert.NoError(t, err) + + gotTool, gotIsAi := detectAiTool(taskData, tt.accountId, tt.body) + assert.Equal(t, tt.wantTool, gotTool) + assert.Equal(t, tt.wantIsAi, gotIsAi) + }) + } +} + +func TestDetectAiTool_Gemini(t *testing.T) { + tests := []struct { + name string + accountId string + body string + wantTool string + wantIsAi bool + }{ + { + name: "Gemini by GitHub username", + accountId: "gemini-code-assist", + body: "Some review comment", + wantTool: models.AiToolGemini, + wantIsAi: true, + }, + { + name: "Gemini by GitLab username", + accountId: "gemini-code-assist-bot", + body: "Some review comment with code assist feedback", + wantTool: models.AiToolGemini, + wantIsAi: true, + }, + { + name: "Gemini by body pattern - codereviewagent", + accountId: "somebot", + body: "Review by codereviewagent: this looks good", + wantTool: models.AiToolGemini, + wantIsAi: true, + }, + { + name: "Gemini by body pattern - gstatic reference", + accountId: "somebot", + body: "![icon](https://www.gstatic.com/codereviewagent/icon.png) Review summary", + wantTool: models.AiToolGemini, + wantIsAi: true, + }, + { + name: "Gemini by body pattern - greeting phrase", + accountId: "bot", + body: "Hello @user, I'm Gemini Code Assist! Here is my review of this pull request.", + wantTool: models.AiToolGemini, + wantIsAi: true, + }, + { + name: "Not Gemini - regular comment", + accountId: "developer", + body: "LGTM, looks good!", + wantTool: "", + wantIsAi: false, + }, + { + name: "Not Gemini - human mentioning Gemini Code Assist", + accountId: "developer", + body: "Gemini Code Assist has reviewed this merge request and I agree with its findings.", + wantTool: "", + wantIsAi: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: models.GetDefaultScopeConfig(), + }, + } + err := CompilePatterns(taskData) + assert.NoError(t, err) + + gotTool, gotIsAi := detectAiTool(taskData, tt.accountId, tt.body) + assert.Equal(t, tt.wantTool, gotTool) + assert.Equal(t, tt.wantIsAi, gotIsAi) + }) + } +} + +func TestBuildCommentUrl(t *testing.T) { + tests := []struct { + name string + prUrl string + commentId string + want string + }{ + { + name: "GitHub comment", + prUrl: "https://github.com/owner/repo/pull/123", + commentId: "github:GithubPrComment:1:456789", + want: "https://github.com/owner/repo/pull/123#issuecomment-456789", + }, + { + name: "GitLab comment", + prUrl: "https://gitlab.com/owner/repo/-/merge_requests/123", + commentId: "gitlab:GitlabMrComment:1:456789", + want: "https://gitlab.com/owner/repo/-/merge_requests/123#note_456789", + }, + { + name: "Empty PR URL", + prUrl: "", + commentId: "github:GithubPrComment:1:456789", + want: "", + }, + { + name: "Malformed comment ID", + prUrl: "https://github.com/owner/repo/pull/123", + commentId: "invalid", + want: "https://github.com/owner/repo/pull/123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildCommentUrl(tt.prUrl, tt.commentId) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestQodoPatternMatching(t *testing.T) { + pattern := regexp.MustCompile(`(?i)(qodo|pr reviewer guide|estimated effort to review)`) + + tests := []struct { + name string + input string + want bool + }{ + {"Qodo mention", "This is a Qodo review", true}, + {"PR Reviewer Guide header", "## PR Reviewer Guide 🔍", true}, + {"Effort estimate", "⏱️ Estimated effort to review: 3", true}, + {"Case insensitive", "QODO", true}, + {"No match", "Regular comment", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pattern.MatchString(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseReviewMetrics_EffortRating(t *testing.T) { + tests := []struct { + name string + body string + wantRating int + wantComplexity string + }{ + { + name: "CodeRabbit effort rating with complexity", + body: "🎯 3 (Moderate) | ⏱️ ~20 minutes", + wantRating: 3, + wantComplexity: "moderate", + }, + { + name: "CodeRabbit effort rating only", + body: "Review effort: 🎯 4", + wantRating: 4, + wantComplexity: "complex", // inferred from rating >= 4 + }, + { + name: "CodeRabbit simple rating", + body: "🎯 1 (Simple)", + wantRating: 1, + wantComplexity: "simple", + }, + { + name: "Qodo effort estimate format", + body: "⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪", + wantRating: 3, + wantComplexity: "moderate", // inferred from rating 3 + }, + { + name: "Qodo table format", + body: "", + wantRating: 2, + wantComplexity: "simple", // inferred from rating <= 2 + }, + { + name: "No effort rating", + body: "This is a regular review comment", + wantRating: 0, + wantComplexity: "", + }, + { + name: "Effort rating 5 (complex)", + body: "🎯 5 (Very Complex)", + wantRating: 5, + wantComplexity: "complex", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metrics := parseReviewMetrics(tt.body) + assert.Equal(t, tt.wantRating, metrics.EffortRating) + if tt.wantComplexity != "" { + assert.Equal(t, tt.wantComplexity, metrics.Complexity) + } + }) + } +} + +func TestParseReviewMetrics_PreMergeChecks(t *testing.T) { + tests := []struct { + name string + body string + wantPassed int + wantFailed int + wantInconclusive int + }{ + { + name: "CodeRabbit pre-merge checks all present", + body: "Pre-merge checks: 2 passed, 1 failed, 1 inconclusive", + wantPassed: 2, + wantFailed: 1, + wantInconclusive: 1, + }, + { + name: "CodeRabbit with emojis", + body: "✅ 3 checks passed | ❌ 1 check failed", + wantPassed: 3, + wantFailed: 1, + wantInconclusive: 0, + }, + { + name: "Only passed checks", + body: "All checks passed: 5 passed", + wantPassed: 5, + wantFailed: 0, + wantInconclusive: 0, + }, + { + name: "Inconclusive only", + body: "Status: 2 inconclusive", + wantPassed: 0, + wantFailed: 0, + wantInconclusive: 2, + }, + { + name: "No check information", + body: "This is a regular review", + wantPassed: 0, + wantFailed: 0, + wantInconclusive: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metrics := parseReviewMetrics(tt.body) + assert.Equal(t, tt.wantPassed, metrics.PreMergeChecksPassed) + assert.Equal(t, tt.wantFailed, metrics.PreMergeChecksFailed) + assert.Equal(t, tt.wantInconclusive, metrics.PreMergeChecksInconclusive) + }) + } +} + +func TestParseSuggestionAcceptance(t *testing.T) { + tests := []struct { + name string + body string + wantAccepted int + }{ + { + name: "No acceptance markers", + body: "Summary by CodeRabbit. Simple review with no suggestions applied.", + wantAccepted: 0, + }, + { + name: "CodeRabbit resolved markers in tracking table", + body: `| Thread | File | Action | Status | +|--------|------|--------|--------| +| 1 | api/handler.go:15 | Committable | ✅ Resolved | +| 2 | api/handler.go:42 | Committable | ✅ Resolved | +| 3 | api/middleware.go:8 | Suggestion | Pending |`, + wantAccepted: 2, + }, + { + name: "Explicit suggestions applied summary", + body: "3 suggestions applied out of 5 total.", + wantAccepted: 3, + }, + { + name: "CodeRabbit resolved markers plus summary", + body: `Summary by CodeRabbit. +| 1 | handler.go | ✅ Resolved | +| 2 | middleware.go | ✅ Resolved | +2 suggestions applied.`, + wantAccepted: 4, + }, + { + name: "Qodo checked checkboxes mixed with unchecked", + body: `**Suggestions** +- [x] **Use constants for config keys** - Replace magic strings +- [ ] **Add validation** - Validate port range +- [x] **Extract defaults** - Move defaults to a separate block +- [ ] **Add docs** - Document config options`, + wantAccepted: 2, + }, + { + name: "All checkboxes checked - likely self-review, not suggestions", + body: `**Self-review checklist** +- [x] I reviewed the code changes +- [x] I ran the tests +- [x] I updated the docs`, + wantAccepted: 0, + }, + { + name: "Suggestion committed in specific commit", + body: "Suggestion was applied in commit abc1234.", + wantAccepted: 1, + }, + { + name: "Applied suggestion marker", + body: "Applied suggestion: use constants instead of magic strings.", + wantAccepted: 1, + }, + { + name: "Suggestion accepted (alternate wording)", + body: "1 suggestion accepted by the developer.", + wantAccepted: 1, + }, + { + name: "Multiple suggestions implemented", + body: "5 suggestions implemented in the latest push.", + wantAccepted: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metrics := ReviewMetrics{} + parseSuggestionAcceptance(tt.body, &metrics) + assert.Equal(t, tt.wantAccepted, metrics.SuggestionsAccepted) + }) + } +} + +func TestParseReviewMetrics_CodeRabbitRealWorld(t *testing.T) { + // Test with realistic CodeRabbit output format + realCodeRabbitBody := `## Summary by CodeRabbit + +🎯 3 (Moderate) | ⏱️ ~20 minutes + +**Pre-merge checks**: 2 passed, 1 inconclusive + +### Walkthrough +The changes introduce new handlers for authentication. + +### Changes +- Added JWT validation +- Updated middleware configuration +` + + metrics := parseReviewMetrics(realCodeRabbitBody) + + assert.Equal(t, 3, metrics.EffortRating) + assert.Equal(t, 20, metrics.EffortMinutes) + assert.Equal(t, "moderate", metrics.Complexity) + assert.Equal(t, 2, metrics.PreMergeChecksPassed) + assert.Equal(t, 1, metrics.PreMergeChecksInconclusive) +} diff --git a/backend/plugins/aireview/tasks/fetch_missing_ci_jobs.go b/backend/plugins/aireview/tasks/fetch_missing_ci_jobs.go new file mode 100644 index 00000000000..e44d514cc0f --- /dev/null +++ b/backend/plugins/aireview/tasks/fetch_missing_ci_jobs.go @@ -0,0 +1,318 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/log" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/pkg/gcshelper" +) + +var FetchMissingCiJobsMeta = plugin.SubTaskMeta{ + Name: "fetchMissingCiJobs", + EntryPoint: FetchMissingCiJobs, + EnabledByDefault: false, + Description: "Backfill CI job results from GCS for PRs that have AI reviews but no CI outcome data", + DomainTypes: []string{plugin.DOMAIN_TYPE_CICD, plugin.DOMAIN_TYPE_CODE_REVIEW}, + Dependencies: []*plugin.SubTaskMeta{&ExtractAiReviewsMeta}, +} + +// ciBackfillJobRow writes the minimal subset of ci_test_jobs columns required +// by calculateFailurePredictions. Using a local struct avoids importing the +// testregistry plugin (which would create a circular dependency). +// connection_id=0 is the sentinel used for aireview-backfilled rows. +type ciBackfillJobRow struct { + ConnectionId int64 `gorm:"primaryKey;column:connection_id"` + JobId string `gorm:"primaryKey;column:job_id"` + JobName string `gorm:"column:job_name"` + Repository string `gorm:"column:repository"` + PullRequestNumber *int64 `gorm:"column:pull_request_number"` + TriggerType string `gorm:"column:trigger_type"` + Result string `gorm:"column:result"` + FinishedAt *time.Time `gorm:"column:finished_at"` + DurationSec *float64 `gorm:"column:duration_sec"` +} + +func (ciBackfillJobRow) TableName() string { return "ci_test_jobs" } + +// missingPR holds the minimum information needed to query GCS for a single PR. +type missingPR struct { + PullRequestKey string + OrgName string + RepoShortName string + RepoFullName string +} + +// FetchMissingCiJobs queries for PRs that have AI reviews but no associated +// CI job rows, then fetches their build results directly from the Openshift CI +// GCS bucket. This is much faster than scanning all GCS directories because we +// look up only the PR numbers we already know about. +func FetchMissingCiJobs(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + cfg := data.Options.ScopeConfig + if cfg == nil || cfg.CiBackfillDays <= 0 { + logger.Debug("CI backfill disabled (ciBackfillDays=0), skipping fetchMissingCiJobs") + return nil + } + + cutoff := time.Now().AddDate(0, 0, -cfg.CiBackfillDays) + + // ---- 1. Find PRs with AI reviews but no CI job rows ---- + // aireview is a metric plugin — it has no per-repo scope, so we query all + // repos that have AI reviews (or just the one repo if RepoId is set). + repoId := data.Options.RepoId + var repoIds []string + if repoId != "" { + repoIds = []string{repoId} + } else { + var repoRows []struct { + RepoId string `gorm:"column:repo_id"` + } + if dbErr := db.All(&repoRows, dal.Select("DISTINCT repo_id"), dal.From("_tool_aireview_reviews")); dbErr != nil { + return errors.Default.Wrap(dbErr, "querying distinct repo IDs from aireview reviews") + } + for _, r := range repoRows { + repoIds = append(repoIds, r.RepoId) + } + } + if len(repoIds) == 0 { + logger.Info("No repos with AI reviews found, nothing to backfill") + return nil + } + + var missing []missingPR + for _, rid := range repoIds { + prs, findErr := findMissingPRs(db, rid, cutoff) + if findErr != nil { + return errors.Default.Wrap(findErr, "querying missing CI PRs for repo "+rid) + } + missing = append(missing, prs...) + } + if len(missing) == 0 { + logger.Info("No PRs missing CI data, nothing to backfill") + return nil + } + logger.Info("Found %d PRs with AI reviews but no CI data, fetching from GCS", len(missing)) + + // ---- 2. Set up GCS store (real or injected fake for tests) ---- + var store gcshelper.HistoryStore + if data.GcsStoreOverride != nil { + store = data.GcsStoreOverride + } else { + ctx := context.Background() + bucket, gcsErr := gcshelper.New(ctx, gcshelper.OpenshiftCIBucketName) + if gcsErr != nil { + return errors.Default.Wrap(gcsErr, "creating GCS client") + } + defer bucket.Close() + store = bucket + } + + // ---- 3. Fetch each PR from GCS ---- + var rows []ciBackfillJobRow + ctx := context.Background() + + taskCtx.SetProgress(0, len(missing)) + for i, pr := range missing { + prNum, convErr := strconv.ParseInt(pr.PullRequestKey, 10, 64) + if convErr != nil { + logger.Warn(nil, "Skipping non-numeric PR key %q", pr.PullRequestKey) + taskCtx.IncProgress(1) + continue + } + + fetched, fetchErr := fetchPRBuilds(ctx, store, pr, prNum, cutoff, logger) + if fetchErr != nil { + logger.Warn(errors.Default.WrapRaw(fetchErr), + "Failed to fetch GCS builds for %s/%s PR %d", + pr.OrgName, pr.RepoShortName, prNum) + } + rows = append(rows, fetched...) + taskCtx.SetProgress(i+1, len(missing)) + } + + if len(rows) == 0 { + logger.Info("No CI job rows fetched from GCS") + return nil + } + + // ---- 4. Upsert into ci_test_jobs ---- + if err := db.CreateOrUpdate(rows); err != nil { + return errors.Default.Wrap(err, "upserting ci_test_jobs from GCS backfill") + } + logger.Info("Backfilled %d CI job rows from GCS", len(rows)) + return nil +} + +// missingPRRow is used as the query target for findMissingPRs. +type missingPRRow struct { + PullRequestKey string `gorm:"column:pull_request_key"` + RepoName string `gorm:"column:repo_name"` +} + +func (missingPRRow) TableName() string { return "_tool_aireview_reviews" } + +// findMissingPRs returns PRs in the given repo that have at least one non-skipped +// AI review but no matching row in ci_test_jobs for a pull_request trigger. +func findMissingPRs(db dal.Dal, repoId string, cutoff time.Time) ([]missingPR, error) { + // Pre-compute the short repo name (part after the last "/") to avoid + // MySQL-specific SUBSTRING_INDEX inside the SQL subquery. + var repoRows []struct { + Name string `gorm:"column:name"` + } + if dbErr := db.All(&repoRows, dal.Select("name"), dal.From("repos"), dal.Where("id = ?", repoId), dal.Limit(1)); dbErr != nil { + return nil, fmt.Errorf("querying repo name for %s: %w", repoId, dbErr) + } + if len(repoRows) == 0 { + return nil, fmt.Errorf("repo %s not found", repoId) + } + repoShort := repoRows[0].Name + if i := strings.LastIndex(repoShort, "/"); i >= 0 { + repoShort = repoShort[i+1:] + } + + var rawRows []missingPRRow + err := db.All(&rawRows, + dal.Select("DISTINCT pr.pull_request_key, r.name AS repo_name"), + dal.From("_tool_aireview_reviews ar"), + dal.Join("JOIN pull_requests pr ON ar.pull_request_id = pr.id"), + dal.Join("JOIN repos r ON ar.repo_id = r.id"), + dal.Where(`ar.repo_id = ? + AND ar.body NOT LIKE '%Review skipped%' + AND pr.created_date >= ? + AND NOT EXISTS ( + SELECT 1 FROM ci_test_jobs j + WHERE CAST(j.pull_request_number AS CHAR) = pr.pull_request_key + AND j.repository = ? + AND j.trigger_type = 'pull_request' + )`, repoId, cutoff, repoShort), + ) + if err != nil { + return nil, fmt.Errorf("querying missing PRs: %w", err) + } + + result := make([]missingPR, 0, len(rawRows)) + for _, r := range rawRows { + parts := strings.SplitN(r.RepoName, "/", 2) + org, repo := "", r.RepoName + if len(parts) == 2 { + org, repo = parts[0], parts[1] + } + result = append(result, missingPR{ + PullRequestKey: r.PullRequestKey, + OrgName: org, + RepoShortName: repo, + RepoFullName: r.RepoName, + }) + } + return result, nil +} + +// fetchPRBuilds queries GCS for all build results for a single PR number and +// returns them as ciBackfillJobRow records ready for upsert. +func fetchPRBuilds( + ctx context.Context, + store gcshelper.HistoryStore, + pr missingPR, + prNum int64, + cutoff time.Time, + logger log.Logger, +) ([]ciBackfillJobRow, error) { + // GCS path: pr-logs/pull/{org}_{repo}/{pr_number}/ + prPrefix := fmt.Sprintf("pr-logs/pull/%s_%s/%d/", pr.OrgName, pr.RepoShortName, prNum) + + jobPrefixes, err := store.ListSubdirectories(ctx, prPrefix) + if err != nil { + return nil, fmt.Errorf("listing jobs at %s: %w", prPrefix, err) + } + + var rows []ciBackfillJobRow + for _, jobPrefix := range jobPrefixes { + jobName := gcshelper.LastSegment(jobPrefix) + + buildPrefixes, err := store.ListSubdirectories(ctx, jobPrefix) + if err != nil { + logger.Warn(errors.Default.WrapRaw(err), "Listing builds at %s", jobPrefix) + continue + } + + gcshelper.SortBuildIDsDescending(buildPrefixes) + + for _, buildPrefix := range buildPrefixes { + buildID := gcshelper.LastSegment(buildPrefix) + + finishedData, err := store.ReadFile(ctx, buildPrefix+"finished.json") + if err != nil { + // Not finished yet — skip silently. + continue + } + finished, err := gcshelper.ParseFinishedJSON(finishedData) + if err != nil { + logger.Warn(errors.Default.WrapRaw(err), "Parsing finished.json at %s", buildPrefix) + continue + } + + finishedAt := time.Unix(finished.Timestamp, 0).UTC() + if finishedAt.Before(cutoff) { + // Build IDs are monotonically increasing — stop scanning older builds. + break + } + + var startedAt *time.Time + if started, startErr := gcshelper.ReadStartedJSON(ctx, store, buildPrefix); startErr == nil { + t := time.Unix(started.Timestamp, 0).UTC() + startedAt = &t + } + + result := gcshelper.MapProwResult(finished.Result, finished.Passed) + + var durationSec *float64 + if startedAt != nil && !finishedAt.IsZero() { + d := finishedAt.Sub(*startedAt).Seconds() + durationSec = &d + } + + prNumCopy := prNum + jobId := fmt.Sprintf("prow:%s_%s:%d:%s:%s", pr.OrgName, pr.RepoShortName, prNum, jobName, buildID) + + rows = append(rows, ciBackfillJobRow{ + ConnectionId: 0, // sentinel: backfilled by aireview + JobId: jobId, + JobName: jobName, + Repository: pr.RepoShortName, + TriggerType: "pull_request", + PullRequestNumber: &prNumCopy, + Result: result, + FinishedAt: &finishedAt, + DurationSec: durationSec, + }) + } + } + return rows, nil +} diff --git a/backend/plugins/aireview/tasks/fetch_missing_ci_jobs_test.go b/backend/plugins/aireview/tasks/fetch_missing_ci_jobs_test.go new file mode 100644 index 00000000000..e1a0bb4082a --- /dev/null +++ b/backend/plugins/aireview/tasks/fetch_missing_ci_jobs_test.go @@ -0,0 +1,416 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/log" + "github.com/apache/devlake/pkg/gcshelper" + mockdal "github.com/apache/devlake/mocks/core/dal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// fakeHistoryStore implements gcshelper.HistoryStore for unit tests. +type fakeHistoryStore struct { + // subdirs maps a prefix to its immediate child prefixes. + subdirs map[string][]string + // files maps a GCS object path to its content. + files map[string][]byte +} + +func (f *fakeHistoryStore) ListSubdirectories(_ context.Context, prefix string) ([]string, error) { + return f.subdirs[prefix], nil +} + +func (f *fakeHistoryStore) ReadFile(_ context.Context, path string) ([]byte, error) { + data, ok := f.files[path] + if !ok { + return nil, fmt.Errorf("not found: %s", path) + } + return data, nil +} + +// Compile-time check that fakeHistoryStore implements the interface. +var _ gcshelper.HistoryStore = (*fakeHistoryStore)(nil) + +// TestFetchPRBuilds_HappyPath verifies that fetchPRBuilds correctly parses +// finished.json / started.json and returns one row per build. +func TestFetchPRBuilds_HappyPath(t *testing.T) { + now := time.Now() + startedTS := now.Add(-10 * time.Minute).Unix() + finishedTS := now.Add(-5 * time.Minute).Unix() + + org, repo, prNum := "myorg", "myrepo", int64(42) + jobName := "pull-ci-myorg-myrepo-main-unit-tests" + buildID := "9999999" + + prPrefix := fmt.Sprintf("pr-logs/pull/%s_%s/%d/", org, repo, prNum) + jobPrefix := prPrefix + jobName + "/" + buildPrefix := jobPrefix + buildID + "/" + + store := &fakeHistoryStore{ + subdirs: map[string][]string{ + prPrefix: {jobPrefix}, + jobPrefix: {buildPrefix}, + }, + files: map[string][]byte{ + buildPrefix + "finished.json": fmt.Appendf(nil, + `{"timestamp":%d,"passed":true,"result":"SUCCESS","revision":"abc123"}`, finishedTS, + ), + buildPrefix + "started.json": fmt.Appendf(nil, + `{"timestamp":%d,"commit":"abc123"}`, startedTS, + ), + }, + } + + pr := missingPR{ + PullRequestKey: "42", + OrgName: org, + RepoShortName: repo, + RepoFullName: org + "/" + repo, + } + cutoff := now.Add(-7 * 24 * time.Hour) // 7 days ago + + rows, err := fetchPRBuilds(context.Background(), store, pr, prNum, cutoff, &nopLogger{}) + if err != nil { + t.Fatalf("fetchPRBuilds returned error: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + + row := rows[0] + if row.Result != "success" { + t.Errorf("expected result=success, got %q", row.Result) + } + if row.JobName != jobName { + t.Errorf("expected jobName=%q, got %q", jobName, row.JobName) + } + if *row.PullRequestNumber != prNum { + t.Errorf("expected pullRequestNumber=%d, got %d", prNum, *row.PullRequestNumber) + } + if row.TriggerType != "pull_request" { + t.Errorf("expected trigger_type=pull_request, got %q", row.TriggerType) + } + if row.ConnectionId != 0 { + t.Errorf("expected connection_id=0 sentinel, got %d", row.ConnectionId) + } + if row.DurationSec == nil { + t.Error("expected DurationSec to be populated") + } else { + wantDuration := float64(finishedTS-startedTS) - 1 // allow 1s tolerance + if *row.DurationSec < wantDuration { + t.Errorf("DurationSec=%f seems too small", *row.DurationSec) + } + } +} + +// TestFetchPRBuilds_CutoffRespected verifies that builds older than the cutoff +// are not returned. +func TestFetchPRBuilds_CutoffRespected(t *testing.T) { + now := time.Now() + oldFinishedTS := now.Add(-200 * 24 * time.Hour).Unix() // 200 days ago + + org, repo, prNum := "myorg", "myrepo", int64(7) + jobName := "pull-ci-myorg-myrepo-main-e2e" + buildID := "1000000" + + prPrefix := fmt.Sprintf("pr-logs/pull/%s_%s/%d/", org, repo, prNum) + jobPrefix := prPrefix + jobName + "/" + buildPrefix := jobPrefix + buildID + "/" + + store := &fakeHistoryStore{ + subdirs: map[string][]string{ + prPrefix: {jobPrefix}, + jobPrefix: {buildPrefix}, + }, + files: map[string][]byte{ + buildPrefix + "finished.json": fmt.Appendf(nil, + `{"timestamp":%d,"passed":false,"result":"FAILURE"}`, oldFinishedTS, + ), + }, + } + + pr := missingPR{ + PullRequestKey: "7", + OrgName: org, + RepoShortName: repo, + RepoFullName: org + "/" + repo, + } + cutoff := now.Add(-90 * 24 * time.Hour) // 90 days + + rows, err := fetchPRBuilds(context.Background(), store, pr, prNum, cutoff, &nopLogger{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0 rows (build is older than cutoff), got %d", len(rows)) + } +} + +// TestFetchPRBuilds_FailedBuild verifies that failed builds are persisted with +// result=failure. +func TestFetchPRBuilds_FailedBuild(t *testing.T) { + now := time.Now() + finishedTS := now.Add(-2 * time.Hour).Unix() + + org, repo, prNum := "myorg", "myrepo", int64(99) + jobName := "pull-ci-myorg-myrepo-main-integration" + buildID := "8888888" + + prPrefix := fmt.Sprintf("pr-logs/pull/%s_%s/%d/", org, repo, prNum) + jobPrefix := prPrefix + jobName + "/" + buildPrefix := jobPrefix + buildID + "/" + + store := &fakeHistoryStore{ + subdirs: map[string][]string{ + prPrefix: {jobPrefix}, + jobPrefix: {buildPrefix}, + }, + files: map[string][]byte{ + buildPrefix + "finished.json": fmt.Appendf(nil, + `{"timestamp":%d,"passed":false,"result":"FAILURE"}`, finishedTS, + ), + }, + } + + pr := missingPR{ + PullRequestKey: "99", + OrgName: org, + RepoShortName: repo, + RepoFullName: org + "/" + repo, + } + cutoff := now.Add(-30 * 24 * time.Hour) + + rows, err := fetchPRBuilds(context.Background(), store, pr, prNum, cutoff, &nopLogger{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0].Result != "failure" { + t.Errorf("expected result=failure, got %q", rows[0].Result) + } +} + +// TestFetchPRBuilds_NoPRDirectoryInGCS verifies graceful handling when a PR +// has no GCS directory (no builds were run). +func TestFetchPRBuilds_NoPRDirectoryInGCS(t *testing.T) { + store := &fakeHistoryStore{ + subdirs: map[string][]string{}, + files: map[string][]byte{}, + } + + pr := missingPR{ + PullRequestKey: "1", + OrgName: "myorg", + RepoShortName: "myrepo", + RepoFullName: "myorg/myrepo", + } + + rows, err := fetchPRBuilds(context.Background(), store, pr, 1, time.Now().Add(-30*24*time.Hour), &nopLogger{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rows) != 0 { + t.Errorf("expected 0 rows, got %d", len(rows)) + } +} + +// TestFetchPRBuilds_MultipleJobs verifies that multiple job names under one PR +// each produce their own rows. +func TestFetchPRBuilds_MultipleJobs(t *testing.T) { + now := time.Now() + finishedTS := now.Add(-1 * time.Hour).Unix() + + org, repo, prNum := "myorg", "myrepo", int64(5) + job1 := "pull-ci-myorg-myrepo-main-unit" + job2 := "pull-ci-myorg-myrepo-main-e2e" + buildID := "5555555" + + prPrefix := fmt.Sprintf("pr-logs/pull/%s_%s/%d/", org, repo, prNum) + job1Prefix := prPrefix + job1 + "/" + job2Prefix := prPrefix + job2 + "/" + build1Prefix := job1Prefix + buildID + "/" + build2Prefix := job2Prefix + buildID + "/" + + store := &fakeHistoryStore{ + subdirs: map[string][]string{ + prPrefix: {job1Prefix, job2Prefix}, + job1Prefix: {build1Prefix}, + job2Prefix: {build2Prefix}, + }, + files: map[string][]byte{ + build1Prefix + "finished.json": fmt.Appendf(nil, `{"timestamp":%d,"passed":true,"result":"SUCCESS"}`, finishedTS), + build2Prefix + "finished.json": fmt.Appendf(nil, `{"timestamp":%d,"passed":false,"result":"FAILURE"}`, finishedTS), + }, + } + + pr := missingPR{ + PullRequestKey: "5", + OrgName: org, + RepoShortName: repo, + RepoFullName: org + "/" + repo, + } + + rows, err := fetchPRBuilds(context.Background(), store, pr, prNum, now.Add(-30*24*time.Hour), &nopLogger{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected 2 rows (one per job), got %d", len(rows)) + } + + results := map[string]string{} + for _, r := range rows { + results[r.JobName] = r.Result + } + if results[job1] != "success" { + t.Errorf("job1 result: got %q, want success", results[job1]) + } + if results[job2] != "failure" { + t.Errorf("job2 result: got %q, want failure", results[job2]) + } +} + +// nopLogger is a no-op log.Logger implementation for unit tests. +type nopLogger struct{} + +func (*nopLogger) IsLevelEnabled(_ log.LogLevel) bool { return false } +func (*nopLogger) Printf(_ string, _ ...any) {} +func (*nopLogger) Log(_ log.LogLevel, _ string, _ ...any) {} +func (*nopLogger) Debug(_ string, _ ...any) {} +func (*nopLogger) Info(_ string, _ ...any) {} +func (*nopLogger) Warn(_ error, _ string, _ ...any) {} +func (*nopLogger) Error(_ error, _ string, _ ...any) {} +func (*nopLogger) Nested(_ string) log.Logger { return &nopLogger{} } +func (*nopLogger) GetConfig() *log.LoggerConfig { return &log.LoggerConfig{} } +func (*nopLogger) SetStream(_ *log.LoggerStreamConfig) {} + +func TestFindMissingPRs(t *testing.T) { + cutoff := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + t.Run("success with org/repo name", func(t *testing.T) { + mockDal := new(mockdal.Dal) + callCount := 0 + mockDal.On("All", mock.Anything, mock.Anything).Return(nil).Run(func(args mock.Arguments) { + callCount++ + if callCount == 1 { + // First call: repo name query — anonymous struct + dst := args.Get(0).(*[]struct { + Name string `gorm:"column:name"` + }) + *dst = []struct { + Name string `gorm:"column:name"` + }{{Name: "org/my-repo"}} + } else { + // Second call: missing PRs query + dst := args.Get(0).(*[]missingPRRow) + *dst = []missingPRRow{ + {PullRequestKey: "42", RepoName: "org/my-repo"}, + } + } + }) + + result, err := findMissingPRs(mockDal, "repo-1", cutoff) + assert.Nil(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "42", result[0].PullRequestKey) + assert.Equal(t, "org", result[0].OrgName) + assert.Equal(t, "my-repo", result[0].RepoShortName) + assert.Equal(t, "org/my-repo", result[0].RepoFullName) + }) + + t.Run("repo name without slash", func(t *testing.T) { + mockDal := new(mockdal.Dal) + callCount := 0 + mockDal.On("All", mock.Anything, mock.Anything).Return(nil).Run(func(args mock.Arguments) { + callCount++ + if callCount == 1 { + dst := args.Get(0).(*[]struct { + Name string `gorm:"column:name"` + }) + *dst = []struct { + Name string `gorm:"column:name"` + }{{Name: "simple-repo"}} + } else { + dst := args.Get(0).(*[]missingPRRow) + *dst = []missingPRRow{ + {PullRequestKey: "10", RepoName: "simple-repo"}, + } + } + }) + + result, err := findMissingPRs(mockDal, "repo-2", cutoff) + assert.Nil(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "", result[0].OrgName) + assert.Equal(t, "simple-repo", result[0].RepoShortName) + }) + + t.Run("repo name query error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Return(errors.Default.New("db error")) + + result, err := findMissingPRs(mockDal, "repo-1", cutoff) + assert.Nil(t, result) + assert.NotNil(t, err) + assert.Contains(t, fmt.Sprint(err), "db error") + }) + + t.Run("repo not found", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("All", mock.Anything, mock.Anything).Return(nil).Run(func(args mock.Arguments) { + // Leave the destination slice empty (zero rows) + }) + + result, err := findMissingPRs(mockDal, "nonexistent-repo", cutoff) + assert.Nil(t, result) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("missing PRs query error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + callCount := 0 + mockDal.On("All", mock.Anything, mock.Anything).Return(nil).Run(func(args mock.Arguments) { + callCount++ + if callCount == 1 { + dst := args.Get(0).(*[]struct { + Name string `gorm:"column:name"` + }) + *dst = []struct { + Name string `gorm:"column:name"` + }{{Name: "org/my-repo"}} + } + }).Once() + mockDal.On("All", mock.Anything, mock.Anything).Return(errors.Default.New("query error")).Once() + + result, err := findMissingPRs(mockDal, "repo-1", cutoff) + assert.Nil(t, result) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "query error") + }) +} diff --git a/backend/plugins/aireview/tasks/match_suggestion_diffs.go b/backend/plugins/aireview/tasks/match_suggestion_diffs.go new file mode 100644 index 00000000000..846c20f2263 --- /dev/null +++ b/backend/plugins/aireview/tasks/match_suggestion_diffs.go @@ -0,0 +1,757 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "regexp" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/log" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aireview/models" +) + +var MatchSuggestionDiffsMeta = plugin.SubTaskMeta{ + Name: "matchSuggestionDiffs", + EntryPoint: MatchSuggestionDiffs, + EnabledByDefault: true, + Description: "Match AI suggestion findings against PR commit diffs to detect acceptance", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE_REVIEW}, + Dependencies: []*plugin.SubTaskMeta{&ExtractAiReviewFindingsMeta}, +} + +// prCommit holds commit info for a PR +type prCommit struct { + CommitSha string `gorm:"column:commit_sha"` + AuthoredDate time.Time `gorm:"column:commit_authored_date"` + Message string `gorm:"column:message"` + AuthorName string `gorm:"column:author_name"` +} + +// commitFileChange holds a file changed in a commit +type commitFileChange struct { + CommitSha string `gorm:"column:commit_sha"` + FilePath string `gorm:"column:file_path"` + Additions int `gorm:"column:additions"` + Deletions int `gorm:"column:deletions"` +} + +// suggestionFinding holds a finding with its context for matching +type suggestionFinding struct { + models.AiReviewFinding + ReviewCreatedDate time.Time `gorm:"column:review_created_date"` + AiToolUser string `gorm:"column:ai_tool_user"` +} + +// commitFilePatch holds a file's patch content from a commit +type commitFilePatch struct { + CommitSha string + FilePath string + Patch string +} + +// matchResult holds the result of matching a suggestion against commits +type matchResult struct { + FindingId string + Matched bool + Method string + Score float64 + LinesMatched int + LinesTotal int + CommitSha string + MatchedFile string +} + +// MatchSuggestionDiffs compares AI suggestions against PR commit diffs to detect acceptance. +// +// It uses three matching strategies (in priority order): +// 1. Commit message matching: detects GitHub "Apply suggestion" button clicks +// 2. File path + temporal proximity: same file modified shortly after suggestion +// 3. Resolves file paths from raw data when the domain table lacks them +func MatchSuggestionDiffs(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AiReviewTaskData) + + repoId := data.Options.RepoId + logger.Info("Starting diff-based suggestion matching for repo: %s", repoId) + + // Step 1: Resolve file paths from raw data for findings that lack them + enriched, err := enrichFindingFilePaths(db, data) + if err != nil { + logger.Warn(err, "file path enrichment had errors, continuing with available data") + } + logger.Info("Enriched %d findings with file paths from raw data", enriched) + + // Step 2: Get all suggestion findings for this repo + findings, loadErr := loadSuggestionFindings(db, repoId) + if loadErr != nil { + return errors.Default.Wrap(loadErr, "failed to load suggestion findings") + } + if len(findings) == 0 { + logger.Info("No suggestion findings to match, skipping") + return nil + } + logger.Info("Found %d suggestion findings to match against commits", len(findings)) + + // Step 3: Group findings by PR + findingsByPR := make(map[string][]suggestionFinding) + for _, f := range findings { + findingsByPR[f.PullRequestId] = append(findingsByPR[f.PullRequestId], f) + } + + // Step 4: For each PR, load commits, patches, and match + totalMatched := 0 + reviewAcceptedCounts := make(map[string]int) // ai_review_id -> count of diff-accepted + reviewAcceptedScores := make(map[string][]float64) // ai_review_id -> list of match scores + + for prId, prFindings := range findingsByPR { + // Get commits for this PR + commits, loadErr := loadPRCommits(db, prId) + if loadErr != nil { + logger.Warn(loadErr, "failed to load commits for PR %s, skipping", prId) + continue + } + if len(commits) == 0 { + continue + } + + // Get file changes for these commits + commitShas := make([]string, len(commits)) + for i, c := range commits { + commitShas[i] = c.CommitSha + } + fileChanges, loadErr := loadCommitFiles(db, commitShas) + if loadErr != nil { + logger.Warn(loadErr, "failed to load commit files for PR %s, skipping", prId) + continue + } + + // Try to load commit patches from raw data for line-by-line comparison + patches := loadCommitPatches(db, commitShas, logger) + + // If commit_files is sparse, supplement from raw patch data + if len(fileChanges) < len(patches) { + existingKeys := make(map[string]bool) + for _, fc := range fileChanges { + existingKeys[fc.CommitSha+":"+fc.FilePath] = true + } + for _, p := range patches { + key := p.CommitSha + ":" + p.FilePath + if !existingKeys[key] { + fileChanges = append(fileChanges, commitFileChange{ + CommitSha: p.CommitSha, + FilePath: p.FilePath, + }) + existingKeys[key] = true + } + } + } + + // Match each finding + for _, finding := range prFindings { + result := matchFinding(finding, commits, fileChanges, patches) + if result.Matched { + totalMatched++ + reviewAcceptedCounts[finding.AiReviewId]++ + reviewAcceptedScores[finding.AiReviewId] = append(reviewAcceptedScores[finding.AiReviewId], result.Score) + + updateErr := db.UpdateColumns( + &models.AiReviewFinding{}, + []dal.DalSet{ + {ColumnName: "suggestion_diff_matched", Value: true}, + {ColumnName: "suggestion_match_method", Value: result.Method}, + {ColumnName: "suggestion_match_score", Value: result.Score}, + {ColumnName: "suggestion_lines_matched", Value: result.LinesMatched}, + {ColumnName: "suggestion_lines_total", Value: result.LinesTotal}, + {ColumnName: "matched_commit_sha", Value: result.CommitSha}, + {ColumnName: "matched_file_path", Value: result.MatchedFile}, + }, + dal.Where("id = ?", result.FindingId), + ) + if updateErr != nil { + logger.Warn(updateErr, "failed to update finding %s", result.FindingId) + } + } + } + } + + // Step 5: Update review-level diff-accepted counts and average percentages + for reviewId, count := range reviewAcceptedCounts { + avgPct := 0.0 + scores := reviewAcceptedScores[reviewId] + if len(scores) > 0 { + sum := 0.0 + for _, s := range scores { + sum += s + } + avgPct = sum / float64(len(scores)) + } + + updateErr := db.UpdateColumns( + &models.AiReview{}, + []dal.DalSet{ + {ColumnName: "suggestions_diff_accepted", Value: count}, + {ColumnName: "suggestions_diff_accept_pct", Value: avgPct}, + }, + dal.Where("id = ?", reviewId), + ) + if updateErr != nil { + logger.Warn(updateErr, "failed to update diff accepted count for review %s", reviewId) + } + } + + logger.Info("Diff-based suggestion matching complete: %d/%d findings matched", totalMatched, len(findings)) + return nil +} + +// loadSuggestionFindings gets all suggestion-type findings for the repo +func loadSuggestionFindings(db dal.Dal, repoId string) ([]suggestionFinding, error) { + cursor, err := db.Cursor( + dal.Select("f.*, ar.created_date as review_created_date, ar.ai_tool_user"), + dal.From("_tool_aireview_findings f"), + dal.Join("JOIN _tool_aireview_reviews ar ON f.ai_review_id = ar.id"), + dal.Where("f.repo_id = ? AND f.type = ?", repoId, models.FindingTypeSuggestion), + ) + if err != nil { + return nil, err + } + defer cursor.Close() + + var findings []suggestionFinding + for cursor.Next() { + var f suggestionFinding + if scanErr := db.Fetch(cursor, &f); scanErr != nil { + return nil, scanErr + } + findings = append(findings, f) + } + return findings, nil +} + +// loadPRCommits gets all commits for a PR, joined with commit metadata +func loadPRCommits(db dal.Dal, prId string) ([]prCommit, error) { + cursor, err := db.Cursor( + dal.Select("prc.commit_sha, prc.commit_authored_date, c.message, c.author_name"), + dal.From("pull_request_commits prc"), + dal.Join("LEFT JOIN commits c ON prc.commit_sha = c.sha"), + dal.Where("prc.pull_request_id = ?", prId), + dal.Orderby("prc.commit_authored_date ASC"), + ) + if err != nil { + return nil, err + } + defer cursor.Close() + + var commits []prCommit + for cursor.Next() { + var c prCommit + if scanErr := db.Fetch(cursor, &c); scanErr != nil { + return nil, scanErr + } + commits = append(commits, c) + } + return commits, nil +} + +// loadCommitFiles gets file changes for a set of commits +func loadCommitFiles(db dal.Dal, commitShas []string) ([]commitFileChange, error) { + if len(commitShas) == 0 { + return nil, nil + } + cursor, err := db.Cursor( + dal.Select("commit_sha, file_path, additions, deletions"), + dal.From("commit_files"), + dal.Where("commit_sha IN (?)", commitShas), + ) + if err != nil { + return nil, err + } + defer cursor.Close() + + var files []commitFileChange + for cursor.Next() { + var f commitFileChange + if scanErr := db.Fetch(cursor, &f); scanErr != nil { + return nil, scanErr + } + files = append(files, f) + } + return files, nil +} + +// matchFinding attempts to match a suggestion finding against PR commits. +// Returns the best match found across all strategies. +func matchFinding(finding suggestionFinding, commits []prCommit, fileChanges []commitFileChange, patches []commitFilePatch) matchResult { + result := matchResult{FindingId: finding.Id} + + // Get the file path for this finding + filePath := finding.MatchedFilePath + if filePath == "" { + filePath = finding.FilePath + } + + // Build file change index: commitSha -> []filePath + commitFiles := make(map[string][]commitFileChange) + for _, fc := range fileChanges { + commitFiles[fc.CommitSha] = append(commitFiles[fc.CommitSha], fc) + } + + // Build patch index: "commitSha:filePath" -> patch + patchIndex := make(map[string]string) + for _, p := range patches { + patchIndex[p.CommitSha+":"+p.FilePath] = p.Patch + } + + // Parse suggested lines (non-trivial only) + suggestedLines := nonTrivialLines(finding.SuggestedCode) + + // Filter to commits after the suggestion was made + suggestionTime := finding.ReviewCreatedDate + if suggestionTime.IsZero() { + suggestionTime = finding.CreatedDate + } + + // Strategy 1: Check for "Apply suggestion" commit messages + // The GitHub "Apply suggestion" button applies the exact code, so 100%. + for _, commit := range commits { + if commit.AuthoredDate.Before(suggestionTime) { + continue + } + if isApplySuggestionCommit(commit.Message, finding.AiToolUser) { + if filePath != "" { + for _, fc := range commitFiles[commit.CommitSha] { + if filePathsMatch(fc.FilePath, filePath) { + result.Matched = true + result.Method = "diff_commit_msg" + result.Score = 100.0 + result.LinesMatched = len(suggestedLines) + result.LinesTotal = len(suggestedLines) + result.CommitSha = commit.CommitSha + result.MatchedFile = fc.FilePath + return result + } + } + } + // Without file match, still strong signal — assume full apply + if len(commitFiles[commit.CommitSha]) == 1 { + result.Matched = true + result.Method = "diff_commit_msg" + result.Score = 100.0 + result.LinesMatched = len(suggestedLines) + result.LinesTotal = len(suggestedLines) + result.CommitSha = commit.CommitSha + result.MatchedFile = commitFiles[commit.CommitSha][0].FilePath + return result + } + } + } + + // Strategy 2: Line-level comparison against commit patches + if filePath != "" && len(suggestedLines) > 0 { + for _, commit := range commits { + if commit.AuthoredDate.Before(suggestionTime) { + continue + } + for _, fc := range commitFiles[commit.CommitSha] { + if !filePathsMatch(fc.FilePath, filePath) { + continue + } + + // Try to find the patch for this commit+file + patch := patchIndex[commit.CommitSha+":"+fc.FilePath] + if patch == "" { + // Try with normalized path + for key, p := range patchIndex { + parts := strings.SplitN(key, ":", 2) + if len(parts) == 2 && parts[0] == commit.CommitSha && filePathsMatch(parts[1], filePath) { + patch = p + break + } + } + } + + if patch != "" { + addedLines := extractAddedLines(patch) + matched, total := countMatchingLines(suggestedLines, addedLines) + if total > 0 { + pct := float64(matched) / float64(total) * 100.0 + if pct > result.Score { + result.Matched = pct > 0 + result.Method = "diff_line_pct" + result.Score = pct + result.LinesMatched = matched + result.LinesTotal = total + result.CommitSha = commit.CommitSha + result.MatchedFile = fc.FilePath + } + } + } + } + } + // If we found a line-level match, return it + if result.Matched { + return result + } + } + + // Strategy 3: File path + temporal proximity (fallback when no patches available) + if filePath == "" { + return result + } + + for _, commit := range commits { + if commit.AuthoredDate.Before(suggestionTime) { + continue + } + + timeDelta := commit.AuthoredDate.Sub(suggestionTime) + for _, fc := range commitFiles[commit.CommitSha] { + if !filePathsMatch(fc.FilePath, filePath) { + continue + } + + score := calculateTemporalScore(timeDelta) + if score > result.Score { + result.Matched = true + result.Method = "diff_file_temporal" + result.Score = score + result.LinesTotal = len(suggestedLines) + result.CommitSha = commit.CommitSha + result.MatchedFile = fc.FilePath + } + } + } + + return result +} + +// applySuggestionRe matches GitHub "Apply suggestion" commit messages and co-author lines +var applySuggestionRe = regexp.MustCompile(`(?i)(?:apply\s+suggest|co-authored-by:.*(?:coderabbit|qodo|gemini|cursor|copilot|bugbot))`) + +// isApplySuggestionCommit checks if a commit message indicates a suggestion was applied +func isApplySuggestionCommit(message, aiToolUser string) bool { + if message == "" { + return false + } + if applySuggestionRe.MatchString(message) { + return true + } + // Check for the specific AI tool user in co-authored-by + if aiToolUser != "" && strings.Contains(strings.ToLower(message), strings.ToLower(aiToolUser)) { + return true + } + return false +} + +// filePathsMatch checks if two file paths refer to the same file. +// Handles cases where one path has a prefix (e.g., "a/pkg/foo.go" vs "pkg/foo.go") +func filePathsMatch(path1, path2 string) bool { + if path1 == path2 { + return true + } + // Normalize: strip leading a/ or b/ (git diff prefixes) + p1 := strings.TrimPrefix(strings.TrimPrefix(path1, "a/"), "b/") + p2 := strings.TrimPrefix(strings.TrimPrefix(path2, "a/"), "b/") + if p1 == p2 { + return true + } + // Check suffix match (one might have a longer prefix) + return strings.HasSuffix(p1, "/"+p2) || strings.HasSuffix(p2, "/"+p1) +} + +// calculateTemporalScore returns a confidence score based on how soon after the suggestion +// the commit was made. Shorter time = higher confidence. +// Returns a float64 used as a fallback score when patch data is not available. +func calculateTemporalScore(delta time.Duration) float64 { + switch { + case delta < 30*time.Minute: + return 75.0 // Very likely applied + case delta < 2*time.Hour: + return 60.0 // Probably applied + case delta < 24*time.Hour: + return 45.0 // Possibly applied + case delta < 72*time.Hour: + return 30.0 // Weak signal + default: + return 0.0 // Too far apart + } +} + +// nonTrivialLines splits text into lines and filters out trivial ones +// (empty, whitespace-only, single braces/brackets/parens). +func nonTrivialLines(text string) []string { + var result []string + for _, line := range strings.Split(text, "\n") { + trimmed := strings.TrimSpace(line) + if isTrivialLine(trimmed) { + continue + } + result = append(result, trimmed) + } + return result +} + +// isTrivialLine returns true for lines that carry no meaningful code content. +func isTrivialLine(trimmed string) bool { + if trimmed == "" { + return true + } + // Single punctuation characters that are just structural + trivialTokens := map[string]bool{ + "{": true, "}": true, + "(": true, ")": true, + "[": true, "]": true, + "},": true, "],": true, "];": true, ");": true, + "};": true, "*/": true, "/*": true, + } + return trivialTokens[trimmed] +} + +// extractAddedLines parses a unified diff patch and returns only the added lines +// (lines starting with '+' but not '+++'), with trivial lines filtered out. +func extractAddedLines(patch string) []string { + var added []string + for _, line := range strings.Split(patch, "\n") { + if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + trimmed := strings.TrimSpace(line[1:]) // strip leading '+' then trim + if !isTrivialLine(trimmed) { + added = append(added, trimmed) + } + } + } + return added +} + +// countMatchingLines counts how many of the suggested lines appear in the added lines. +// Uses normalized (trimmed) comparison to handle indentation differences. +// Returns (matched, total) where total is len(suggested). +func countMatchingLines(suggested, added []string) (int, int) { + if len(suggested) == 0 { + return 0, 0 + } + + // Build a set from added lines for O(1) lookup + addedSet := make(map[string]int, len(added)) + for _, line := range added { + addedSet[line]++ + } + + matched := 0 + for _, line := range suggested { + if addedSet[line] > 0 { + matched++ + addedSet[line]-- // consume the match to avoid double-counting + } + } + return matched, len(suggested) +} + +// loadCommitPatches loads file-level patches from raw GitHub API commit data. +// Falls back gracefully if raw tables are unavailable. +func loadCommitPatches(db dal.Dal, commitShas []string, logger log.Logger) []commitFilePatch { + if len(commitShas) == 0 { + return nil + } + + // Try _raw_github_api_commit_stats which stores per-file patches + var patches []commitFilePatch + batchSize := 100 + for i := 0; i < len(commitShas); i += batchSize { + end := i + batchSize + if end > len(commitShas) { + end = len(commitShas) + } + batch := commitShas[i:end] + + rows, err := db.Cursor( + dal.Select("JSON_UNQUOTE(JSON_EXTRACT(CONVERT(data USING utf8mb4), '$.sha')) as commit_sha, "+ + "JSON_EXTRACT(CONVERT(data USING utf8mb4), '$.files') as files_json"), + dal.From("_raw_github_api_commit_stats"), + dal.Where("JSON_UNQUOTE(JSON_EXTRACT(CONVERT(data USING utf8mb4), '$.sha')) IN (?) AND JSON_LENGTH(JSON_EXTRACT(CONVERT(data USING utf8mb4), '$.files')) > 0", batch), + ) + if err != nil { + logger.Info("Raw commit patches not available (table may not exist): %s", err.Error()) + return nil + } + + for rows.Next() { + var sha *string + var filesJSON *string + if scanErr := rows.Scan(&sha, &filesJSON); scanErr != nil || sha == nil || filesJSON == nil { + continue + } + cleanSha := strings.Trim(*sha, "\"") + for _, pf := range parseFilesJSON(*filesJSON) { + patches = append(patches, commitFilePatch{ + CommitSha: cleanSha, + FilePath: pf.Filename, + Patch: pf.Patch, + }) + } + } + rows.Close() + } + + logger.Info("Loaded %d file patches from raw commit data", len(patches)) + return patches +} + +// ghCommitFile mirrors the relevant fields from the GitHub API commit files array. +type ghCommitFile struct { + Filename string `json:"filename"` + Patch string `json:"patch"` +} + +// parseFilesJSON extracts filename and patch pairs from the GitHub API files JSON array. +func parseFilesJSON(filesJSON string) []ghCommitFile { + var files []ghCommitFile + if err := json.Unmarshal([]byte(filesJSON), &files); err != nil { + return nil + } + // Filter to files that have a patch (binary files don't) + result := make([]ghCommitFile, 0, len(files)) + for _, f := range files { + if f.Patch != "" { + result = append(result, f) + } + } + return result +} + +// enrichFindingFilePaths resolves file paths from raw data for findings that lack them. +// It follows the same raw-data-access pattern as EnrichGithubReviewReactions. +func enrichFindingFilePaths(db dal.Dal, data *AiReviewTaskData) (int, errors.Error) { + repoId := data.Options.RepoId + + // Find findings that need file path enrichment: have a review linked to a comment + // with raw data, and currently have no file path + var clauses []dal.Clause + if data.Options.ProjectName != "" { + clauses = []dal.Clause{ + dal.Select("f.id as finding_id, prc._raw_data_table, prc._raw_data_id"), + dal.From("_tool_aireview_findings f"), + dal.Join("JOIN _tool_aireview_reviews ar ON f.ai_review_id = ar.id"), + dal.Join("JOIN pull_request_comments prc ON ar.review_id = prc.id"), + dal.Join("JOIN pull_requests pr ON prc.pull_request_id = pr.id"), + dal.Join("JOIN project_mapping pm ON pr.base_repo_id = pm.row_id"), + dal.Where("pm.project_name = ? AND pm.`table` = ? AND f.file_path = '' AND prc._raw_data_table != ''", + data.Options.ProjectName, "repos"), + } + } else { + clauses = []dal.Clause{ + dal.Select("f.id as finding_id, prc._raw_data_table, prc._raw_data_id"), + dal.From("_tool_aireview_findings f"), + dal.Join("JOIN _tool_aireview_reviews ar ON f.ai_review_id = ar.id"), + dal.Join("JOIN pull_request_comments prc ON ar.review_id = prc.id"), + dal.Join("JOIN pull_requests pr ON prc.pull_request_id = pr.id"), + dal.Where("pr.base_repo_id = ? AND (f.file_path = '' OR f.file_path IS NULL) AND prc._raw_data_table != ''", repoId), + } + } + + type findingRawLink struct { + FindingId string `gorm:"column:finding_id"` + RawDataTable string `gorm:"column:_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id"` + } + + cursor, err := db.Cursor(clauses...) + if err != nil { + return 0, errors.Default.Wrap(err, "failed to query findings for file path enrichment") + } + defer cursor.Close() + + // Group by raw table + tableLinks := make(map[string][]findingRawLink) + for cursor.Next() { + var link findingRawLink + if fetchErr := db.Fetch(cursor, &link); fetchErr != nil { + return 0, errors.Default.Wrap(fetchErr, "failed to fetch finding raw link") + } + tableLinks[link.RawDataTable] = append(tableLinks[link.RawDataTable], link) + } + + enriched := 0 + for rawTable, links := range tableLinks { + rawIdToFindingId := make(map[uint64]string) + rawIds := make([]uint64, 0, len(links)) + for _, link := range links { + rawIdToFindingId[link.RawDataId] = link.FindingId + rawIds = append(rawIds, link.RawDataId) + } + + // Query raw table for file path + // GitHub: $.path, GitLab: $.position.new_path + pathExpr := "JSON_EXTRACT(CONVERT(data USING utf8mb4), '$.path')" + if strings.Contains(rawTable, "gitlab") { + pathExpr = "COALESCE(JSON_EXTRACT(CONVERT(data USING utf8mb4), '$.position.new_path'), JSON_EXTRACT(CONVERT(data USING utf8mb4), '$.position.old_path'))" + } + + batchSize := 500 + for i := 0; i < len(rawIds); i += batchSize { + end := i + batchSize + if end > len(rawIds) { + end = len(rawIds) + } + batch := rawIds[i:end] + + rows, queryErr := db.Cursor( + dal.Select("id, "+pathExpr+" as file_path"), + dal.From(rawTable), + dal.Where("id IN (?)", batch), + ) + if queryErr != nil { + continue + } + + for rows.Next() { + var id uint64 + var filePath *string + if scanErr := rows.Scan(&id, &filePath); scanErr != nil { + continue + } + + findingId, ok := rawIdToFindingId[id] + if !ok || filePath == nil || *filePath == "" { + continue + } + + // JSON_EXTRACT returns quoted strings, strip quotes + cleanPath := strings.Trim(*filePath, "\"") + if cleanPath == "" || cleanPath == "null" { + continue + } + + updateErr := db.UpdateColumn( + &models.AiReviewFinding{}, "matched_file_path", cleanPath, + dal.Where("id = ?", findingId), + ) + if updateErr == nil { + enriched++ + } + } + rows.Close() + } + } + + return enriched, nil +} diff --git a/backend/plugins/aireview/tasks/match_suggestion_diffs_test.go b/backend/plugins/aireview/tasks/match_suggestion_diffs_test.go new file mode 100644 index 00000000000..764374c3e6a --- /dev/null +++ b/backend/plugins/aireview/tasks/match_suggestion_diffs_test.go @@ -0,0 +1,613 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/apache/devlake/core/errors" + mockdal "github.com/apache/devlake/mocks/core/dal" + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestIsApplySuggestionCommit(t *testing.T) { + tests := []struct { + name string + message string + aiToolUser string + want bool + }{ + { + name: "GitHub Apply suggestion button", + message: "Apply suggestions from code review\n\nCo-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>", + aiToolUser: "coderabbitai[bot]", + want: true, + }, + { + name: "Short Apply suggestion message", + message: "Apply suggestion", + aiToolUser: "", + want: true, + }, + { + name: "Co-authored-by CodeRabbit", + message: "Fix nil check\n\nCo-authored-by: coderabbitai[bot] ", + aiToolUser: "", + want: true, + }, + { + name: "Co-authored-by Qodo", + message: "Add validation\n\nCo-authored-by: qodo-merge-pro[bot] ", + aiToolUser: "", + want: true, + }, + { + name: "Regular commit, no suggestion", + message: "Fix bug in handler\n\nAdded nil check to prevent panic.", + aiToolUser: "coderabbitai[bot]", + want: false, + }, + { + name: "Empty message", + message: "", + aiToolUser: "coderabbitai[bot]", + want: false, + }, + { + name: "Commit mentioning AI tool user", + message: "Applied fix suggested by coderabbitai[bot]", + aiToolUser: "coderabbitai[bot]", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isApplySuggestionCommit(tt.message, tt.aiToolUser) + if got != tt.want { + t.Errorf("isApplySuggestionCommit(%q, %q) = %v, want %v", tt.message, tt.aiToolUser, got, tt.want) + } + }) + } +} + +func TestFilePathsMatch(t *testing.T) { + tests := []struct { + name string + path1 string + path2 string + want bool + }{ + {"exact match", "pkg/server/server.go", "pkg/server/server.go", true}, + {"with a/ prefix", "a/pkg/server/server.go", "pkg/server/server.go", true}, + {"with b/ prefix", "b/pkg/server/server.go", "pkg/server/server.go", true}, + {"both prefixed", "a/pkg/server/server.go", "b/pkg/server/server.go", true}, + {"suffix match", "src/pkg/server/server.go", "pkg/server/server.go", true}, + {"different files", "pkg/server/server.go", "pkg/server/config.go", false}, + {"different dirs", "pkg/server/server.go", "cmd/server/server.go", false}, + {"empty paths", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filePathsMatch(tt.path1, tt.path2) + if got != tt.want { + t.Errorf("filePathsMatch(%q, %q) = %v, want %v", tt.path1, tt.path2, got, tt.want) + } + }) + } +} + +func TestCalculateTemporalScore(t *testing.T) { + tests := []struct { + name string + delta time.Duration + want float64 + }{ + {"10 minutes", 10 * time.Minute, 75.0}, + {"1 hour", 1 * time.Hour, 60.0}, + {"12 hours", 12 * time.Hour, 45.0}, + {"48 hours", 48 * time.Hour, 30.0}, + {"1 week", 7 * 24 * time.Hour, 0.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := calculateTemporalScore(tt.delta) + if got != tt.want { + t.Errorf("calculateTemporalScore(%v) = %f, want %f", tt.delta, got, tt.want) + } + }) + } +} + +func TestNonTrivialLines(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {"code with empty lines", "foo()\n\nbar()\n", 2}, + {"just braces", "{\n}\n", 0}, + {"mixed", "if x > 0 {\n return x\n}\n", 2}, + {"whitespace only", " \n\t\n \n", 0}, + {"single brace variants", "}\n},\n];\n);\n", 0}, + {"real code", "log.Println(\"hello\")\nreturn nil\n", 2}, + {"empty", "", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := nonTrivialLines(tt.input) + if len(got) != tt.want { + t.Errorf("nonTrivialLines(%q) = %d lines, want %d (got: %v)", tt.input, len(got), tt.want, got) + } + }) + } +} + +func TestExtractAddedLines(t *testing.T) { + patch := `@@ -1,5 +1,7 @@ + func foo() { +- return nil ++ if x > 0 { ++ return x ++ } ++ return 0 + } +` + got := extractAddedLines(patch) + // Should get: "if x > 0 {" (not trivial because has content), "return x", "return 0" + // "}" is trivial and excluded + want := 3 + if len(got) != want { + t.Errorf("extractAddedLines() = %d lines, want %d (got: %v)", len(got), want, got) + } +} + +func TestCountMatchingLines(t *testing.T) { + tests := []struct { + name string + suggested []string + added []string + wantMatched int + wantTotal int + }{ + { + name: "full match", + suggested: []string{"return x", "return 0"}, + added: []string{"return x", "return 0", "log.Println()"}, + wantMatched: 2, + wantTotal: 2, + }, + { + name: "partial match", + suggested: []string{"return x", "return y", "return z"}, + added: []string{"return x", "return w"}, + wantMatched: 1, + wantTotal: 3, + }, + { + name: "no match", + suggested: []string{"foo()"}, + added: []string{"bar()"}, + wantMatched: 0, + wantTotal: 1, + }, + { + name: "empty suggestion", + suggested: []string{}, + added: []string{"foo()"}, + wantMatched: 0, + wantTotal: 0, + }, + { + name: "duplicate lines — no double counting", + suggested: []string{"return nil", "return nil"}, + added: []string{"return nil"}, + wantMatched: 1, + wantTotal: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matched, total := countMatchingLines(tt.suggested, tt.added) + if matched != tt.wantMatched || total != tt.wantTotal { + t.Errorf("countMatchingLines() = (%d, %d), want (%d, %d)", matched, total, tt.wantMatched, tt.wantTotal) + } + }) + } +} + +func TestIsTrivialLine(t *testing.T) { + // isTrivialLine expects already-trimmed input (nonTrivialLines does TrimSpace before calling) + trivial := []string{"", "{", "}", "},", "];", ");", "};", "*/", "/*", "(", ")", "[", "]", "],"} + for _, s := range trivial { + if !isTrivialLine(s) { + t.Errorf("isTrivialLine(%q) = false, want true", s) + } + } + + nonTrivial := []string{"return nil", "if x > 0 {", "// comment", "x := 1"} + for _, s := range nonTrivial { + if isTrivialLine(s) { + t.Errorf("isTrivialLine(%q) = true, want false", s) + } + } +} + +func TestMatchFinding(t *testing.T) { + baseTime := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + finding suggestionFinding + commits []prCommit + fileChanges []commitFileChange + patches []commitFilePatch + wantMatched bool + wantMethod string + wantMinScore float64 + }{ + { + name: "Apply suggestion commit with matching file", + finding: suggestionFinding{ + AiReviewFinding: models.AiReviewFinding{ + Id: "f1", + FilePath: "pkg/server/server.go", + Type: models.FindingTypeSuggestion, + SuggestedCode: "return nil", + }, + ReviewCreatedDate: baseTime, + AiToolUser: "coderabbitai[bot]", + }, + commits: []prCommit{ + {CommitSha: "abc123", AuthoredDate: baseTime.Add(20 * time.Minute), Message: "Apply suggestion from code review\n\nCo-authored-by: coderabbitai[bot] "}, + }, + fileChanges: []commitFileChange{ + {CommitSha: "abc123", FilePath: "pkg/server/server.go", Additions: 3, Deletions: 1}, + }, + wantMatched: true, + wantMethod: "diff_commit_msg", + wantMinScore: 100.0, + }, + { + name: "Line-level match with patch data", + finding: suggestionFinding{ + AiReviewFinding: models.AiReviewFinding{ + Id: "f2", + FilePath: "pkg/config/config.go", + MatchedFilePath: "pkg/config/config.go", + Type: models.FindingTypeSuggestion, + SuggestedCode: "if cfg == nil {\n return ErrNilConfig\n}\n", + }, + ReviewCreatedDate: baseTime, + }, + commits: []prCommit{ + {CommitSha: "def456", AuthoredDate: baseTime.Add(15 * time.Minute), Message: "Fix config validation"}, + }, + fileChanges: []commitFileChange{ + {CommitSha: "def456", FilePath: "pkg/config/config.go", Additions: 5, Deletions: 2}, + }, + patches: []commitFilePatch{ + {CommitSha: "def456", FilePath: "pkg/config/config.go", Patch: "@@ -10,3 +10,5 @@\n func Load() {\n+if cfg == nil {\n+ return ErrNilConfig\n+}\n return cfg\n"}, + }, + wantMatched: true, + wantMethod: "diff_line_pct", + wantMinScore: 100.0, + }, + { + name: "Partial line match — 50%", + finding: suggestionFinding{ + AiReviewFinding: models.AiReviewFinding{ + Id: "f2b", + FilePath: "pkg/config/config.go", + MatchedFilePath: "pkg/config/config.go", + Type: models.FindingTypeSuggestion, + SuggestedCode: "if cfg == nil {\n return ErrNilConfig\n}\nlog.Warn(\"checked\")\n", + }, + ReviewCreatedDate: baseTime, + }, + commits: []prCommit{ + {CommitSha: "def456", AuthoredDate: baseTime.Add(15 * time.Minute), Message: "Fix config validation"}, + }, + fileChanges: []commitFileChange{ + {CommitSha: "def456", FilePath: "pkg/config/config.go", Additions: 3, Deletions: 1}, + }, + patches: []commitFilePatch{ + {CommitSha: "def456", FilePath: "pkg/config/config.go", Patch: "@@ -10,3 +10,5 @@\n+if cfg == nil {\n+ return ErrNilConfig\n+}\n"}, + }, + wantMatched: true, + wantMethod: "diff_line_pct", + wantMinScore: 50.0, + }, + { + name: "File modified much later — temporal fallback", + finding: suggestionFinding{ + AiReviewFinding: models.AiReviewFinding{ + Id: "f3", + FilePath: "pkg/handler.go", + Type: models.FindingTypeSuggestion, + SuggestedCode: "return err", + }, + ReviewCreatedDate: baseTime, + }, + commits: []prCommit{ + {CommitSha: "ghi789", AuthoredDate: baseTime.Add(48 * time.Hour), Message: "Refactor handler"}, + }, + fileChanges: []commitFileChange{ + {CommitSha: "ghi789", FilePath: "pkg/handler.go", Additions: 20, Deletions: 10}, + }, + // No patches — falls back to temporal + wantMatched: true, + wantMethod: "diff_file_temporal", + wantMinScore: 25.0, + }, + { + name: "No matching file in commits", + finding: suggestionFinding{ + AiReviewFinding: models.AiReviewFinding{ + Id: "f4", + FilePath: "pkg/server/server.go", + Type: models.FindingTypeSuggestion, + SuggestedCode: "return nil", + }, + ReviewCreatedDate: baseTime, + }, + commits: []prCommit{ + {CommitSha: "jkl012", AuthoredDate: baseTime.Add(10 * time.Minute), Message: "Fix tests"}, + }, + fileChanges: []commitFileChange{ + {CommitSha: "jkl012", FilePath: "pkg/server/server_test.go", Additions: 10, Deletions: 5}, + }, + wantMatched: false, + }, + { + name: "Commit before suggestion — should not match", + finding: suggestionFinding{ + AiReviewFinding: models.AiReviewFinding{ + Id: "f5", + FilePath: "pkg/server/server.go", + Type: models.FindingTypeSuggestion, + SuggestedCode: "return nil", + }, + ReviewCreatedDate: baseTime, + }, + commits: []prCommit{ + {CommitSha: "mno345", AuthoredDate: baseTime.Add(-1 * time.Hour), Message: "Initial commit"}, + }, + fileChanges: []commitFileChange{ + {CommitSha: "mno345", FilePath: "pkg/server/server.go", Additions: 100, Deletions: 0}, + }, + wantMatched: false, + }, + { + name: "No file path — only commit message can match", + finding: suggestionFinding{ + AiReviewFinding: models.AiReviewFinding{ + Id: "f6", + Type: models.FindingTypeSuggestion, + SuggestedCode: "x := 1", + }, + ReviewCreatedDate: baseTime, + AiToolUser: "coderabbitai[bot]", + }, + commits: []prCommit{ + {CommitSha: "pqr678", AuthoredDate: baseTime.Add(5 * time.Minute), Message: "Apply suggestion\n\nCo-authored-by: coderabbitai[bot] "}, + }, + fileChanges: []commitFileChange{ + {CommitSha: "pqr678", FilePath: "pkg/handler.go", Additions: 2, Deletions: 1}, + }, + wantMatched: true, + wantMethod: "diff_commit_msg", + wantMinScore: 100.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchFinding(tt.finding, tt.commits, tt.fileChanges, tt.patches) + if result.Matched != tt.wantMatched { + t.Errorf("matchFinding() matched = %v, want %v", result.Matched, tt.wantMatched) + } + if tt.wantMatched { + if result.Method != tt.wantMethod { + t.Errorf("matchFinding() method = %q, want %q", result.Method, tt.wantMethod) + } + if result.Score < tt.wantMinScore { + t.Errorf("matchFinding() score = %f, want >= %f", result.Score, tt.wantMinScore) + } + } + }) + } +} + +func TestParseFilesJSON(t *testing.T) { + input := `[{"sha":"abc","filename":"pkg/foo.go","status":"modified","additions":2,"deletions":1,"changes":3,"patch":"@@ -1,3 +1,4 @@\n func foo() {\n+ return nil\n }"},{"sha":"abc","filename":"pkg/bar.go","status":"modified","additions":1,"deletions":0,"changes":1,"patch":"@@ -5,2 +5,3 @@\n+import \"fmt\""},{"sha":"abc","filename":"pkg/bin","status":"added"}]` + + files := parseFilesJSON(input) + if len(files) != 2 { + t.Fatalf("parseFilesJSON() returned %d files, want 2 (binary file should be excluded)", len(files)) + } + if files[0].Filename != "pkg/foo.go" { + t.Errorf("files[0].Filename = %q, want %q", files[0].Filename, "pkg/foo.go") + } + if files[1].Filename != "pkg/bar.go" { + t.Errorf("files[1].Filename = %q, want %q", files[1].Filename, "pkg/bar.go") + } + if len(files[0].Patch) == 0 { + t.Error("files[0].Patch is empty") + } + // Verify JSON unescaping works — \n in patch should become real newlines + if !strings.Contains(files[0].Patch, "\n") { + t.Errorf("files[0].Patch should contain real newlines, got: %q", files[0].Patch) + } +} + +func TestLoadSuggestionFindings(t *testing.T) { + t.Run("success with one row", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockRows := new(mockdal.Rows) + + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false).Once() + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*suggestionFinding) + dst.AiReviewFinding = models.AiReviewFinding{ + Id: "finding-1", + FilePath: "pkg/server.go", + RepoId: "repo-1", + Type: models.FindingTypeSuggestion, + } + dst.ReviewCreatedDate = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + dst.AiToolUser = "coderabbitai[bot]" + }).Return(nil) + mockRows.On("Close").Return(nil) + + findings, err := loadSuggestionFindings(mockDal, "repo-1") + assert.Nil(t, err) + assert.Len(t, findings, 1) + assert.Equal(t, "finding-1", findings[0].Id) + assert.Equal(t, "coderabbitai[bot]", findings[0].AiToolUser) + mockDal.AssertExpectations(t) + mockRows.AssertExpectations(t) + }) + + t.Run("cursor error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Cursor", mock.Anything).Return(nil, errors.Default.New("db error")) + + findings, err := loadSuggestionFindings(mockDal, "repo-1") + assert.Nil(t, findings) + assert.NotNil(t, err) + assert.Contains(t, fmt.Sprint(err), "db error") + }) + + t.Run("fetch error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockRows := new(mockdal.Rows) + + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockDal.On("Fetch", mockRows, mock.Anything).Return(errors.Default.New("scan error")) + mockRows.On("Close").Return(nil) + + findings, err := loadSuggestionFindings(mockDal, "repo-1") + assert.Nil(t, findings) + assert.NotNil(t, err) + assert.Contains(t, fmt.Sprint(err), "scan error") + }) +} + +func TestLoadPRCommits(t *testing.T) { + t.Run("success with one row", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockRows := new(mockdal.Rows) + + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false).Once() + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*prCommit) + dst.CommitSha = "abc123" + dst.AuthoredDate = time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) + dst.Message = "Fix handler" + dst.AuthorName = "dev" + }).Return(nil) + mockRows.On("Close").Return(nil) + + commits, err := loadPRCommits(mockDal, "pr-1") + assert.Nil(t, err) + assert.Len(t, commits, 1) + assert.Equal(t, "abc123", commits[0].CommitSha) + assert.Equal(t, "Fix handler", commits[0].Message) + mockDal.AssertExpectations(t) + }) + + t.Run("cursor error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Cursor", mock.Anything).Return(nil, errors.Default.New("db error")) + + commits, err := loadPRCommits(mockDal, "pr-1") + assert.Nil(t, commits) + assert.NotNil(t, err) + }) + + t.Run("fetch error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockRows := new(mockdal.Rows) + + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockDal.On("Fetch", mockRows, mock.Anything).Return(errors.Default.New("scan error")) + mockRows.On("Close").Return(nil) + + commits, err := loadPRCommits(mockDal, "pr-1") + assert.Nil(t, commits) + assert.NotNil(t, err) + }) +} + +func TestLoadCommitFiles(t *testing.T) { + t.Run("empty commitShas returns nil", func(t *testing.T) { + mockDal := new(mockdal.Dal) + files, err := loadCommitFiles(mockDal, []string{}) + assert.Nil(t, err) + assert.Nil(t, files) + }) + + t.Run("success with one row", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockRows := new(mockdal.Rows) + + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false).Once() + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*commitFileChange) + dst.CommitSha = "abc123" + dst.FilePath = "pkg/handler.go" + dst.Additions = 5 + dst.Deletions = 2 + }).Return(nil) + mockRows.On("Close").Return(nil) + + files, err := loadCommitFiles(mockDal, []string{"abc123"}) + assert.Nil(t, err) + assert.Len(t, files, 1) + assert.Equal(t, "pkg/handler.go", files[0].FilePath) + assert.Equal(t, 5, files[0].Additions) + mockDal.AssertExpectations(t) + }) + + t.Run("cursor error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Cursor", mock.Anything).Return(nil, errors.Default.New("db error")) + + files, err := loadCommitFiles(mockDal, []string{"abc123"}) + assert.Nil(t, files) + assert.NotNil(t, err) + }) +} diff --git a/backend/plugins/aireview/tasks/task_data.go b/backend/plugins/aireview/tasks/task_data.go new file mode 100644 index 00000000000..634208fdafe --- /dev/null +++ b/backend/plugins/aireview/tasks/task_data.go @@ -0,0 +1,204 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "regexp" + + "github.com/apache/devlake/core/errors" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/pkg/gcshelper" + "github.com/apache/devlake/plugins/aireview/models" +) + +// AiReviewOptions contains the configuration options for the plugin +type AiReviewOptions struct { + // Project name (for project metric mode - analyzes all repos in project) + ProjectName string `json:"projectName"` + + // Repository to analyze (domain layer ID) - optional if projectName is provided + RepoId string `json:"repoId"` + + // Scope config ID reference + ScopeConfigId uint64 `json:"scopeConfigId"` + + // Inline scope config + ScopeConfig *models.AiReviewScopeConfig `json:"scopeConfig"` + + // Source platform filter (optional: github, gitlab, or empty for all) + SourcePlatform string `json:"sourcePlatform"` + + // Time filter + TimeAfter string `json:"timeAfter"` +} + +// AiReviewTaskData contains shared data for subtasks +type AiReviewTaskData struct { + Options *AiReviewOptions + + // GcsStoreOverride allows tests to inject a fake GCS store instead of + // opening a real connection to the public Openshift CI bucket. + // If nil, FetchMissingCiJobs opens a real GCS client. + GcsStoreOverride gcshelper.HistoryStore + + // Compiled regex patterns + CodeRabbitUsernameRegex *regexp.Regexp + CodeRabbitPatternRegex *regexp.Regexp + CursorBugbotUsernameRegex *regexp.Regexp + CursorBugbotPatternRegex *regexp.Regexp + QodoUsernameRegex *regexp.Regexp + QodoPatternRegex *regexp.Regexp + GeminiUsernameRegex *regexp.Regexp + GeminiPatternRegex *regexp.Regexp + AiCommitPatternsRegex *regexp.Regexp + AiPrLabelPatternRegex *regexp.Regexp + RiskHighPatternRegex *regexp.Regexp + RiskMediumPatternRegex *regexp.Regexp + RiskLowPatternRegex *regexp.Regexp + BugLinkPatternRegex *regexp.Regexp +} + +// DecodeTaskOptions decodes and validates task options +func DecodeTaskOptions(options map[string]interface{}) (*AiReviewOptions, errors.Error) { + var op AiReviewOptions + if err := helper.Decode(options, &op, nil); err != nil { + return nil, errors.BadInput.Wrap(err, "failed to decode aireview options") + } + return &op, nil +} + +// ValidateTaskOptions validates the task options +func ValidateTaskOptions(op *AiReviewOptions) errors.Error { + if op.RepoId == "" && op.ProjectName == "" { + return errors.BadInput.New("either repoId or projectName is required") + } + return nil +} + +// CompilePatterns compiles all regex patterns from scope config +func CompilePatterns(taskData *AiReviewTaskData) errors.Error { + config := taskData.Options.ScopeConfig + if config == nil { + config = models.GetDefaultScopeConfig() + taskData.Options.ScopeConfig = config + } + + var err error + + // CodeRabbit patterns + if config.CodeRabbitEnabled && config.CodeRabbitUsername != "" { + taskData.CodeRabbitUsernameRegex, err = regexp.Compile("(?i)" + regexp.QuoteMeta(config.CodeRabbitUsername)) + if err != nil { + return errors.BadInput.Wrap(err, "invalid codeRabbitUsername pattern") + } + } + if config.CodeRabbitEnabled && config.CodeRabbitPattern != "" { + taskData.CodeRabbitPatternRegex, err = regexp.Compile(config.CodeRabbitPattern) + if err != nil { + return errors.BadInput.Wrap(err, "invalid codeRabbitPattern") + } + } + + // Cursor Bugbot patterns + if config.CursorBugbotEnabled && config.CursorBugbotUsername != "" { + taskData.CursorBugbotUsernameRegex, err = regexp.Compile("(?i)" + regexp.QuoteMeta(config.CursorBugbotUsername)) + if err != nil { + return errors.BadInput.Wrap(err, "invalid cursorBugbotUsername pattern") + } + } + if config.CursorBugbotEnabled && config.CursorBugbotPattern != "" { + taskData.CursorBugbotPatternRegex, err = regexp.Compile(config.CursorBugbotPattern) + if err != nil { + return errors.BadInput.Wrap(err, "invalid cursorBugbotPattern") + } + } + + // Qodo patterns + if config.QodoEnabled && config.QodoUsername != "" { + taskData.QodoUsernameRegex, err = regexp.Compile("(?i)" + regexp.QuoteMeta(config.QodoUsername)) + if err != nil { + return errors.BadInput.Wrap(err, "invalid qodoUsername pattern") + } + } + if config.QodoEnabled && config.QodoPattern != "" { + taskData.QodoPatternRegex, err = regexp.Compile(config.QodoPattern) + if err != nil { + return errors.BadInput.Wrap(err, "invalid qodoPattern") + } + } + + // Gemini patterns + if config.GeminiEnabled && config.GeminiUsername != "" { + taskData.GeminiUsernameRegex, err = regexp.Compile("(?i)" + regexp.QuoteMeta(config.GeminiUsername)) + if err != nil { + return errors.BadInput.Wrap(err, "invalid geminiUsername pattern") + } + } + if config.GeminiEnabled && config.GeminiPattern != "" { + taskData.GeminiPatternRegex, err = regexp.Compile(config.GeminiPattern) + if err != nil { + return errors.BadInput.Wrap(err, "invalid geminiPattern") + } + } + + // Risk patterns + if config.RiskHighPattern != "" { + taskData.RiskHighPatternRegex, err = regexp.Compile(config.RiskHighPattern) + if err != nil { + return errors.BadInput.Wrap(err, "invalid riskHighPattern") + } + } + if config.RiskMediumPattern != "" { + taskData.RiskMediumPatternRegex, err = regexp.Compile(config.RiskMediumPattern) + if err != nil { + return errors.BadInput.Wrap(err, "invalid riskMediumPattern") + } + } + if config.RiskLowPattern != "" { + taskData.RiskLowPatternRegex, err = regexp.Compile(config.RiskLowPattern) + if err != nil { + return errors.BadInput.Wrap(err, "invalid riskLowPattern") + } + } + + // Bug link pattern + if config.BugLinkPattern != "" { + taskData.BugLinkPatternRegex, err = regexp.Compile(config.BugLinkPattern) + if err != nil { + return errors.BadInput.Wrap(err, "invalid bugLinkPattern") + } + } + + // AI PR label pattern + if config.AiPrLabelPattern != "" { + taskData.AiPrLabelPatternRegex, err = regexp.Compile(config.AiPrLabelPattern) + if err != nil { + return errors.BadInput.Wrap(err, "invalid aiPrLabelPattern") + } + } + + // Extra catch-all regex for AI-assisted commit messages (single pattern, not comma-split) + if config.AiCommitPatterns != "" { + taskData.AiCommitPatternsRegex, err = regexp.Compile(config.AiCommitPatterns) + if err != nil { + return errors.BadInput.Wrap(err, "invalid aiCommitPatterns") + } + } + + return nil +} diff --git a/backend/plugins/aireview/tasks/task_data_test.go b/backend/plugins/aireview/tasks/task_data_test.go new file mode 100644 index 00000000000..f649e015cfa --- /dev/null +++ b/backend/plugins/aireview/tasks/task_data_test.go @@ -0,0 +1,296 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/apache/devlake/plugins/aireview/models" + "github.com/stretchr/testify/assert" +) + +func TestDecodeTaskOptions(t *testing.T) { + t.Run("valid options with repoId", func(t *testing.T) { + opts := map[string]any{ + "repoId": "github:GithubRepo:1:100", + } + result, err := DecodeTaskOptions(opts) + assert.Nil(t, err) + assert.Equal(t, "github:GithubRepo:1:100", result.RepoId) + }) + + t.Run("valid options with projectName", func(t *testing.T) { + opts := map[string]any{ + "projectName": "my-project", + } + result, err := DecodeTaskOptions(opts) + assert.Nil(t, err) + assert.Equal(t, "my-project", result.ProjectName) + }) + + t.Run("valid options with both fields", func(t *testing.T) { + opts := map[string]any{ + "repoId": "github:GithubRepo:1:100", + "projectName": "my-project", + } + result, err := DecodeTaskOptions(opts) + assert.Nil(t, err) + assert.Equal(t, "github:GithubRepo:1:100", result.RepoId) + assert.Equal(t, "my-project", result.ProjectName) + }) + + t.Run("empty map produces empty options", func(t *testing.T) { + opts := map[string]any{} + result, err := DecodeTaskOptions(opts) + assert.Nil(t, err) + assert.Empty(t, result.RepoId) + assert.Empty(t, result.ProjectName) + }) + + t.Run("optional fields decoded", func(t *testing.T) { + opts := map[string]any{ + "repoId": "repo1", + "sourcePlatform": "github", + "timeAfter": "2024-01-01", + } + result, err := DecodeTaskOptions(opts) + assert.Nil(t, err) + assert.Equal(t, "github", result.SourcePlatform) + assert.Equal(t, "2024-01-01", result.TimeAfter) + }) +} + +func TestValidateTaskOptions(t *testing.T) { + t.Run("repoId only is valid", func(t *testing.T) { + err := ValidateTaskOptions(&AiReviewOptions{RepoId: "repo1"}) + assert.Nil(t, err) + }) + + t.Run("projectName only is valid", func(t *testing.T) { + err := ValidateTaskOptions(&AiReviewOptions{ProjectName: "proj1"}) + assert.Nil(t, err) + }) + + t.Run("both set is valid", func(t *testing.T) { + err := ValidateTaskOptions(&AiReviewOptions{RepoId: "repo1", ProjectName: "proj1"}) + assert.Nil(t, err) + }) + + t.Run("neither set returns error", func(t *testing.T) { + err := ValidateTaskOptions(&AiReviewOptions{}) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "repoId") + }) +} + +func TestCompilePatterns_AllToolsEnabled(t *testing.T) { + config := models.GetDefaultScopeConfig() + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.Nil(t, err) + + assert.NotNil(t, taskData.CodeRabbitUsernameRegex) + assert.NotNil(t, taskData.CodeRabbitPatternRegex) + assert.NotNil(t, taskData.QodoUsernameRegex) + assert.NotNil(t, taskData.QodoPatternRegex) + assert.NotNil(t, taskData.GeminiUsernameRegex) + assert.NotNil(t, taskData.GeminiPatternRegex) + assert.NotNil(t, taskData.RiskHighPatternRegex) + assert.NotNil(t, taskData.RiskMediumPatternRegex) + assert.NotNil(t, taskData.RiskLowPatternRegex) + assert.NotNil(t, taskData.AiCommitPatternsRegex) +} + +func TestCompilePatterns_NilConfigUsesDefaults(t *testing.T) { + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: nil, + }, + } + err := CompilePatterns(taskData) + assert.Nil(t, err) + assert.NotNil(t, taskData.Options.ScopeConfig, "nil config should be replaced with defaults") + assert.NotNil(t, taskData.CodeRabbitPatternRegex) +} + +func TestCompilePatterns_InvalidRegexReturnsError(t *testing.T) { + config := models.GetDefaultScopeConfig() + config.CodeRabbitPattern = "[invalid" + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "codeRabbitPattern") +} + +func TestCompilePatterns_DisabledToolSkipsCompilation(t *testing.T) { + config := models.GetDefaultScopeConfig() + config.QodoEnabled = false + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.Nil(t, err) + assert.Nil(t, taskData.QodoUsernameRegex, "disabled tool should not compile username regex") + assert.Nil(t, taskData.QodoPatternRegex, "disabled tool should not compile pattern regex") +} + +func TestCompilePatterns_RiskPatterns(t *testing.T) { + config := models.GetDefaultScopeConfig() + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.Nil(t, err) + + assert.True(t, taskData.RiskHighPatternRegex.MatchString("security vulnerability found")) + assert.True(t, taskData.RiskMediumPatternRegex.MatchString("warning: possible issue")) + assert.True(t, taskData.RiskLowPatternRegex.MatchString("minor style fix")) +} + +func TestCompilePatterns_CursorBugbotEnabled(t *testing.T) { + config := models.GetDefaultScopeConfig() + config.CursorBugbotEnabled = true + config.CursorBugbotUsername = "cursor-bugbot" + config.CursorBugbotPattern = `(?i)(cursor|bugbot)` + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.Nil(t, err) + assert.NotNil(t, taskData.CursorBugbotUsernameRegex) + assert.NotNil(t, taskData.CursorBugbotPatternRegex) + assert.True(t, taskData.CursorBugbotPatternRegex.MatchString("cursor review")) +} + +func TestCompilePatterns_InvalidQodoPattern(t *testing.T) { + config := models.GetDefaultScopeConfig() + config.QodoPattern = "[invalid" + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "qodoPattern") +} + +func TestCompilePatterns_InvalidGeminiPattern(t *testing.T) { + config := models.GetDefaultScopeConfig() + config.GeminiPattern = "[invalid" + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "geminiPattern") +} + +func TestCompilePatterns_InvalidRiskMediumPattern(t *testing.T) { + config := models.GetDefaultScopeConfig() + config.RiskMediumPattern = "[invalid" + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "riskMediumPattern") +} + +func TestCompilePatterns_InvalidBugLinkPattern(t *testing.T) { + config := models.GetDefaultScopeConfig() + config.BugLinkPattern = "[invalid" + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "bugLinkPattern") +} + +func TestCompilePatterns_InvalidAiPrLabelPattern(t *testing.T) { + config := models.GetDefaultScopeConfig() + config.AiPrLabelPattern = "[invalid" + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "aiPrLabelPattern") +} + +func TestCompilePatterns_AiCommitPatterns(t *testing.T) { + config := models.GetDefaultScopeConfig() + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.Nil(t, err) + assert.NotNil(t, taskData.AiCommitPatternsRegex) + assert.True(t, taskData.AiCommitPatternsRegex.MatchString("Co-Authored-By: ChatGPT ")) +} + +func TestCompilePatterns_InvalidAiCommitPatterns(t *testing.T) { + config := models.GetDefaultScopeConfig() + config.AiCommitPatterns = "[invalid" + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "aiCommitPatterns") +} + +func TestCompilePatterns_EmptyAiCommitPatternsSkips(t *testing.T) { + config := models.GetDefaultScopeConfig() + config.AiCommitPatterns = "" + taskData := &AiReviewTaskData{ + Options: &AiReviewOptions{ + ScopeConfig: config, + }, + } + err := CompilePatterns(taskData) + assert.Nil(t, err) + assert.Nil(t, taskData.AiCommitPatternsRegex) +} diff --git a/backend/plugins/aisdlc/AGENTS.md b/backend/plugins/aisdlc/AGENTS.md new file mode 100644 index 00000000000..ec471448412 --- /dev/null +++ b/backend/plugins/aisdlc/AGENTS.md @@ -0,0 +1,57 @@ +# aisdlc Plugin — Agent Context + +Data-source plugin that collects AISDLC station artifacts from GitLab repositories and stores shared observations. The first parsers cover RFE Creator (`rfe-assess-data`) and Strategy Creator (`strat-pipeline-data`). + +## Build & Test + +```bash +cd backend +go test ./plugins/aisdlc/... -v +golangci-lint run ./plugins/aisdlc/... +``` + +Single-file verification: `go vet ./plugins/aisdlc/...` + +## Layout + +- `impl/impl.go` — plugin interfaces +- `models/` — connection, scope, scope config, observations, and `migrationscripts/register.go` +- `tasks/` — `collectFiles` → `extractObservations` → `linkWorkItems` +- `tasks/parse/` — pure parsers and identity rules +- `tasks/testdata/` — small fixtures taken from the two GitLab repos +- `api/` — connections, scopes, scope configs, remote scopes, blueprints + +## History mode + +Both repositories keep immutable timestamped batch directories on the branch tip. Collect that tree. Do not walk git history. + +`RHAIRFE/current` and `RHAISTRAT/current` are symlinks to the latest batch. Skip any path segment named `current` so the batch is stored once, under its timestamp directory. + +Observation identity is `source_id + blob sha + path + field selector + observation_kind`. A later commit that does not change the blob updates `collected_at` and does not add a row. `collected_at` is the time `collectFiles` last saw the blob in the tree. Extraction copies that time, so a raw file that has left the tree keeps its previous stamp. + +## Field mapping + +Configured on the scope, never inferred from the repo name: `tenant_id`, `source_id`, `station_id`, `parser` (`rfe_assess` or `strat_pipeline`). + +`rfe_assess` reads `*.result.md` only. `scores.csv` repeats those totals and is ignored. Each file is one `assessment` observation for the Jira RFE key in the filename. The batch folder is a timestamp with an unspecified timezone, not a CI execution, so no execution row is created. Attempt, pipeline id, and consumed revision stay unknown. + +`strat_pipeline` reads `pipeline-data.json` when the batch has one. Each `strategies[]` entry is one `processing_result` observation. A score on that entry stays on the same observation. `source_rfe` is an input link (`source_work_item`), not a causal handoff. The file's `timestamp` identifies a batch execution whose id is the batch folder. `run_id: "artifacts"` is not unique and is not used as the execution id. Pipeline id and attempt stay unknown. Timezone-free timestamps are not normalized. + +Batches with no `pipeline-data.json` use `strat-tasks/*.md` front matter. Those files do not identify a run, so they do not create an execution. `jira_key: null` leaves the subject unconfirmed. + +`summary.json`, reviews, originals, reports, and `claude-otel.jsonl` are not collected. + +## Conventions + +- Connection stores `gitlab_connection_id` only. The token is read from `_tool_gitlab_connections`. +- Do not import `plugins/gitlab`. +- Do not insert `issues` or `issue_relationships`. `linkWorkItems` sets `domain_issue_id` only when `issues.issue_key` matches exactly one row. +- Project-scoped tool rows set `_raw_data_params` from the scope (`connectionId` and `fullName`) so deleting a scope removes them. Work items are shared by the connection and stay unstamped. +- Apache 2.0 license header on every new `.go` file. + +## Don'ts + +- Don't add a model to `GetTablesInfo()` without a migration script. +- Don't treat a folder name as an attempt. +- Don't convert a date-only value to midnight UTC. +- Don't store GitLab credentials on the aisdlc connection. diff --git a/backend/plugins/aisdlc/aisdlc.go b/backend/plugins/aisdlc/aisdlc.go new file mode 100644 index 00000000000..4e2b8926629 --- /dev/null +++ b/backend/plugins/aisdlc/aisdlc.go @@ -0,0 +1,40 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/apache/devlake/core/runner" + "github.com/apache/devlake/plugins/aisdlc/impl" + "github.com/spf13/cobra" +) + +var PluginEntry impl.Aisdlc + +func main() { + cmd := &cobra.Command{Use: "aisdlc"} + connectionId := cmd.Flags().Uint64P("connectionId", "c", 0, "connection ID") + fullName := cmd.Flags().StringP("fullName", "f", "", "GitLab project path") + + cmd.Run = func(_ *cobra.Command, args []string) { + runner.DirectRun(cmd, args, PluginEntry, map[string]any{ + "connectionId": *connectionId, + "fullName": *fullName, + }, "") + } + runner.RunCmd(cmd) +} diff --git a/backend/plugins/aisdlc/api/blueprint_v200.go b/backend/plugins/aisdlc/api/blueprint_v200.go new file mode 100644 index 00000000000..2c49ec647fe --- /dev/null +++ b/backend/plugins/aisdlc/api/blueprint_v200.go @@ -0,0 +1,94 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/models/domainlayer/code" + "github.com/apache/devlake/core/plugin" + helperapi "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/helpers/srvhelper" + "github.com/apache/devlake/plugins/aisdlc/models" + "github.com/apache/devlake/plugins/aisdlc/tasks" +) + +func MakeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + connection, err := dsHelper.ConnSrv.FindByPk(connectionId) + if err != nil { + return nil, nil, err + } + scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes) + if err != nil { + return nil, nil, err + } + plan, err := makePipelinePlan(subtaskMetas, scopeDetails, connection) + if err != nil { + return nil, nil, err + } + return plan, makeScopes(scopeDetails), nil +} + +func makePipelinePlan( + subtaskMetas []plugin.SubTaskMeta, + scopeDetails []*srvhelper.ScopeDetail[models.AisdlcScope, models.AisdlcScopeConfig], + connection *models.AisdlcConnection, +) (coreModels.PipelinePlan, errors.Error) { + plan := make(coreModels.PipelinePlan, len(scopeDetails)) + for i, scopeDetail := range scopeDetails { + scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig + entities := []string{plugin.DOMAIN_TYPE_CODE} + if scopeConfig != nil && len(scopeConfig.Entities) > 0 { + entities = scopeConfig.Entities + } + task, err := helperapi.MakePipelinePlanTask( + "aisdlc", + subtaskMetas, + entities, + tasks.AisdlcOptions{ + ConnectionId: connection.ID, + FullName: scope.FullName, + ScopeConfig: scopeConfig, + }, + ) + if err != nil { + return nil, err + } + plan[i] = coreModels.PipelineStage{task} + } + return plan, nil +} + +func makeScopes( + scopeDetails []*srvhelper.ScopeDetail[models.AisdlcScope, models.AisdlcScopeConfig], +) []plugin.Scope { + scopes := make([]plugin.Scope, 0, len(scopeDetails)) + for _, scopeDetail := range scopeDetails { + scope := scopeDetail.Scope + scopes = append(scopes, &code.Repo{ + DomainEntity: domainlayer.DomainEntity{Id: scope.FullName}, + Name: scope.ScopeName(), + }) + } + return scopes +} diff --git a/backend/plugins/aisdlc/api/connection.go b/backend/plugins/aisdlc/api/connection.go new file mode 100644 index 00000000000..2fe4264fd76 --- /dev/null +++ b/backend/plugins/aisdlc/api/connection.go @@ -0,0 +1,86 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + gocontext "context" + "fmt" + "net/http" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/aisdlc/models" + "github.com/apache/devlake/plugins/aisdlc/tasks" +) + +func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Post(input) +} + +func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Patch(input) +} + +func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Delete(input) +} + +func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetAll(input) +} + +func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetDetail(input) +} + +func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var conn models.AisdlcConnection + if err := api.Decode(input.Body, &conn, nil); err != nil { + return nil, errors.BadInput.Wrap(err, "failed to decode connection") + } + return testConnection(gocontext.Background(), &conn) +} + +func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection, err := dsHelper.ConnApi.GetMergedConnection(input) + if err != nil { + return nil, errors.Convert(err) + } + return testConnection(gocontext.Background(), connection) +} + +func testConnection(ctx gocontext.Context, conn *models.AisdlcConnection) (*plugin.ApiResourceOutput, errors.Error) { + if conn.GitlabConnectionId == 0 { + return nil, errors.BadInput.New("gitlabConnectionId is required") + } + glConn, err := tasks.LoadGitlabConnection(basicRes.GetDal(), conn.GitlabConnectionId) + if err != nil { + return nil, err + } + if _, clientErr := tasks.NewGitlabClient(ctx, glConn.Endpoint, glConn.Token); clientErr != nil { + return nil, errors.Default.Wrap(clientErr, "gitlab connection test failed") + } + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "success": true, + "message": fmt.Sprintf("GitLab connection %d is usable", conn.GitlabConnectionId), + }, + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/aisdlc/api/init.go b/backend/plugins/aisdlc/api/init.go new file mode 100644 index 00000000000..c59c97ec235 --- /dev/null +++ b/backend/plugins/aisdlc/api/init.go @@ -0,0 +1,45 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/aisdlc/models" +) + +var basicRes context.BasicRes + +var dsHelper *api.DsHelper[models.AisdlcConnection, models.AisdlcScope, models.AisdlcScopeConfig] + +func Init(br context.BasicRes, p plugin.PluginMeta) { + basicRes = br + dsHelper = api.NewDataSourceHelper[ + models.AisdlcConnection, models.AisdlcScope, models.AisdlcScopeConfig, + ]( + br, + p.Name(), + []string{"full_name"}, + func(c models.AisdlcConnection) models.AisdlcConnection { + return c.Sanitize() + }, + nil, + nil, + ) +} diff --git a/backend/plugins/aisdlc/api/remote_api.go b/backend/plugins/aisdlc/api/remote_api.go new file mode 100644 index 00000000000..2a123f9ea24 --- /dev/null +++ b/backend/plugins/aisdlc/api/remote_api.go @@ -0,0 +1,75 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + gocontext "context" + "net/http" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helperapi "github.com/apache/devlake/helpers/pluginhelper/api" + dsmodels "github.com/apache/devlake/helpers/pluginhelper/api/models" + "github.com/apache/devlake/plugins/aisdlc/models" + "github.com/apache/devlake/plugins/aisdlc/tasks" +) + +func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection, err := dsHelper.ConnApi.ModelApiHelper.FindByPk(input) + if err != nil { + return nil, err + } + glConn, err := tasks.LoadGitlabConnection(basicRes.GetDal(), connection.GitlabConnectionId) + if err != nil { + return nil, err + } + client, clientErr := tasks.NewGitlabClient(gocontext.Background(), glConn.Endpoint, glConn.Token) + if clientErr != nil { + return nil, errors.Default.Wrap(clientErr, "gitlab client") + } + search := input.Query.Get("search") + page := input.Query.Get("pageToken") + projects, next, listErr := client.ListProjects(gocontext.Background(), search, page) + if listErr != nil { + return nil, errors.Default.Wrap(listErr, "list gitlab projects") + } + children := make([]dsmodels.DsRemoteApiScopeListEntry[models.AisdlcScope], 0, len(projects)) + for _, project := range projects { + if project.PathWithNamespace == "" { + continue + } + scopeData := &models.AisdlcScope{ + FullName: project.PathWithNamespace, + Name: project.Name, + } + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.AisdlcScope]{ + Type: helperapi.RAS_ENTRY_TYPE_SCOPE, + Id: scopeData.ScopeId(), + Name: scopeData.ScopeName(), + FullName: scopeData.ScopeFullName(), + Data: scopeData, + }) + } + return &plugin.ApiResourceOutput{ + Body: map[string]any{ + "children": children, + "nextPageToken": next, + }, + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/aisdlc/api/scope.go b/backend/plugins/aisdlc/api/scope.go new file mode 100644 index 00000000000..87590d990b9 --- /dev/null +++ b/backend/plugins/aisdlc/api/scope.go @@ -0,0 +1,57 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +func GetScopeDispatcher(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + scopeIdWithSuffix := strings.TrimLeft(input.Params["scopeId"], "/") + if scopeId, found := strings.CutSuffix(scopeIdWithSuffix, "/latest-sync-state"); found { + input.Params["scopeId"] = scopeId + return GetScopeLatestSyncState(input) + } + return GetScope(input) +} + +func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.GetScopeDetail(input) +} + +func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.Patch(input) +} + +func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.Delete(input) +} + +func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetPage(input) +} + +func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.PutMultiple(input) +} diff --git a/backend/plugins/aisdlc/api/scope_config.go b/backend/plugins/aisdlc/api/scope_config.go new file mode 100644 index 00000000000..f868c2491b9 --- /dev/null +++ b/backend/plugins/aisdlc/api/scope_config.go @@ -0,0 +1,47 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +func CreateScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Post(input) +} + +func UpdateScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Patch(input) +} + +func GetScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetDetail(input) +} + +func DeleteScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Delete(input) +} + +func GetScopeConfigList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetAll(input) +} + +func GetProjectsByScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetProjectsByScopeConfig(input) +} diff --git a/backend/plugins/aisdlc/api/scope_state_api.go b/backend/plugins/aisdlc/api/scope_state_api.go new file mode 100644 index 00000000000..49aa39efa4f --- /dev/null +++ b/backend/plugins/aisdlc/api/scope_state_api.go @@ -0,0 +1,30 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +func GetScopeLatestSyncState(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.GetScopeLatestSyncState(input) +} diff --git a/backend/plugins/aisdlc/impl/impl.go b/backend/plugins/aisdlc/impl/impl.go new file mode 100644 index 00000000000..6d8207531c1 --- /dev/null +++ b/backend/plugins/aisdlc/impl/impl.go @@ -0,0 +1,199 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import ( + "fmt" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/plugin" + pluginhelper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/aisdlc/api" + "github.com/apache/devlake/plugins/aisdlc/models" + "github.com/apache/devlake/plugins/aisdlc/models/migrationscripts" + "github.com/apache/devlake/plugins/aisdlc/tasks" + "github.com/apache/devlake/plugins/aisdlc/tasks/parse" +) + +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginApi + plugin.PluginModel + plugin.PluginMigration + plugin.PluginTask + plugin.DataSourcePluginBlueprintV200 +} = (*Aisdlc)(nil) + +// Aisdlc collects station artifacts from GitLab. +type Aisdlc struct{} + +func (p Aisdlc) Description() string { + return "Collect AISDLC station artifacts from GitLab and store shared observations" +} + +func (p Aisdlc) Name() string { + return "aisdlc" +} + +func (p Aisdlc) Init(basicRes context.BasicRes) errors.Error { + api.Init(basicRes, p) + return nil +} + +func (p Aisdlc) RootPkgPath() string { + return "github.com/apache/devlake/plugins/aisdlc" +} + +func (p Aisdlc) GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + &models.AisdlcConnection{}, + &models.AisdlcScope{}, + &models.AisdlcScopeConfig{}, + &models.AisdlcRawFile{}, + &models.AisdlcCheckpoint{}, + &models.AisdlcExecution{}, + &models.AisdlcWorkItem{}, + &models.AisdlcObservation{}, + &models.AisdlcEvidence{}, + &models.AisdlcItemLink{}, + &models.AisdlcUnknown{}, + &models.AisdlcTime{}, + } +} + +func (p Aisdlc) SubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{ + tasks.CollectFilesMeta, + tasks.ExtractObservationsMeta, + tasks.LinkWorkItemsMeta, + } +} + +func (p Aisdlc) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]any) (any, errors.Error) { + var op tasks.AisdlcOptions + if err := pluginhelper.Decode(options, &op, nil); err != nil { + return nil, err + } + if op.ConnectionId == 0 { + return nil, errors.BadInput.New("connectionId is required") + } + if op.FullName == "" { + return nil, errors.BadInput.New("fullName is required") + } + connectionHelper := pluginhelper.NewConnectionHelper(taskCtx, nil, p.Name()) + connection := &models.AisdlcConnection{} + if err := connectionHelper.FirstById(connection, op.ConnectionId); err != nil { + return nil, err + } + if op.ScopeConfig == nil { + scope := &models.AisdlcScope{} + if err := taskCtx.GetDal().First(scope, dal.Where("connection_id = ? AND full_name = ?", op.ConnectionId, op.FullName)); err != nil { + return nil, errors.Default.Wrap(err, "scope not found") + } + if scope.ScopeConfigId == 0 { + return nil, errors.BadInput.New("scope has no scope config") + } + scopeConfig := &models.AisdlcScopeConfig{} + if err := taskCtx.GetDal().First(scopeConfig, dal.Where("id = ?", scope.ScopeConfigId)); err != nil { + return nil, errors.Default.Wrap(err, "scope config not found") + } + op.ScopeConfig = scopeConfig + } + if err := validateScopeConfig(op.ScopeConfig); err != nil { + return nil, err + } + return &tasks.AisdlcTaskData{ + Options: &op, + Connection: connection, + }, nil +} + +func validateScopeConfig(cfg *models.AisdlcScopeConfig) errors.Error { + if cfg == nil { + return errors.BadInput.New("scope config is required") + } + if cfg.TenantId == "" || cfg.SourceId == "" || cfg.StationId == "" { + return errors.BadInput.New("tenantId, sourceId, and stationId are required") + } + if cfg.Parser != parse.ParserRFE && cfg.Parser != parse.ParserStrat { + return errors.BadInput.New(fmt.Sprintf("parser must be %s or %s", parse.ParserRFE, parse.ParserStrat)) + } + if cfg.DisplayName == "" { + cfg.DisplayName = cfg.StationId + } + return nil +} + +func (p Aisdlc) MakeDataSourcePipelinePlanV200( + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, bpScopes) +} + +func (p Aisdlc) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +func (p Aisdlc) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{ + "test": { + "POST": api.TestConnection, + }, + "connections": { + "POST": api.PostConnections, + "GET": api.ListConnections, + }, + "connections/:connectionId": { + "GET": api.GetConnection, + "PATCH": api.PatchConnection, + "DELETE": api.DeleteConnection, + }, + "connections/:connectionId/test": { + "POST": api.TestExistingConnection, + }, + "connections/:connectionId/remote-scopes": { + "GET": api.RemoteScopes, + }, + "connections/:connectionId/scopes/*scopeId": { + "GET": api.GetScopeDispatcher, + "PATCH": api.PatchScope, + "DELETE": api.DeleteScope, + }, + "connections/:connectionId/scopes": { + "GET": api.GetScopeList, + "PUT": api.PutScopes, + }, + "connections/:connectionId/scope-configs": { + "POST": api.CreateScopeConfig, + "GET": api.GetScopeConfigList, + }, + "connections/:connectionId/scope-configs/:scopeConfigId": { + "PATCH": api.UpdateScopeConfig, + "GET": api.GetScopeConfig, + "DELETE": api.DeleteScopeConfig, + }, + "scope-config/:scopeConfigId/projects": { + "GET": api.GetProjectsByScopeConfig, + }, + } +} diff --git a/backend/plugins/aisdlc/models/connection.go b/backend/plugins/aisdlc/models/connection.go new file mode 100644 index 00000000000..3030a5b0c13 --- /dev/null +++ b/backend/plugins/aisdlc/models/connection.go @@ -0,0 +1,36 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +// AisdlcConnection delegates GitLab credentials to an existing GitLab connection. +type AisdlcConnection struct { + helper.BaseConnection `mapstructure:",squash"` + GitlabConnectionId uint64 `mapstructure:"gitlabConnectionId" json:"gitlabConnectionId" gorm:"column:gitlab_connection_id;not null"` +} + +func (AisdlcConnection) TableName() string { + return "_tool_aisdlc_connections" +} + +func (c AisdlcConnection) Sanitize() AisdlcConnection { + return c +} diff --git a/backend/plugins/aisdlc/models/migrationscripts/20261026_add_nopk_columns.go b/backend/plugins/aisdlc/models/migrationscripts/20261026_add_nopk_columns.go new file mode 100644 index 00000000000..00176f06e35 --- /dev/null +++ b/backend/plugins/aisdlc/models/migrationscripts/20261026_add_nopk_columns.go @@ -0,0 +1,95 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "fmt" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addNoPKColumns)(nil) + +type addNoPKColumns struct{} + +// Tables whose init snapshot embedded an unexported struct. GORM dropped +// created_at, updated_at, and the _raw_data_* columns. Databases that already +// ran 20261020000001 keep that shape until this script adds the columns. +var aisdlcNoPKTables = []string{ + "_tool_aisdlc_scopes", + "_tool_aisdlc_raw_files", + "_tool_aisdlc_checkpoints", + "_tool_aisdlc_executions", + "_tool_aisdlc_work_items", + "_tool_aisdlc_observations", + "_tool_aisdlc_evidence", + "_tool_aisdlc_item_links", + "_tool_aisdlc_unknowns", + "_tool_aisdlc_times", +} + +func (*addNoPKColumns) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + timeType := "timestamp NULL" + idType := "bigint NULL" + if db.Dialect() == "mysql" { + timeType = "datetime(3) NULL" + idType = "bigint unsigned NULL" + } + columns := []struct { + name string + sql string + }{ + {"created_at", timeType}, + {"updated_at", timeType}, + {"_raw_data_params", "varchar(255) NULL"}, + {"_raw_data_table", "varchar(255) NULL"}, + {"_raw_data_id", idType}, + {"_raw_data_remark", "varchar(255) NULL"}, + } + for _, table := range aisdlcNoPKTables { + addedParams := false + for _, column := range columns { + if db.HasColumn(table, column.name) { + continue + } + stmt := fmt.Sprintf("ALTER TABLE `%s` ADD COLUMN `%s` %s", table, column.name, column.sql) + if err := db.Exec(stmt); err != nil { + return errors.Default.Wrap(err, "add "+table+"."+column.name) + } + if column.name == "_raw_data_params" { + addedParams = true + } + } + if addedParams { + index := fmt.Sprintf("idx_%s_raw_data_params", table) + _ = db.Exec(fmt.Sprintf("CREATE INDEX `%s` ON `%s` (`_raw_data_params`)", index, table)) + } + } + return nil +} + +func (*addNoPKColumns) Version() uint64 { + return 20261026000001 +} + +func (*addNoPKColumns) Name() string { + return "add aisdlc columns skipped by the init snapshot" +} diff --git a/backend/plugins/aisdlc/models/migrationscripts/init_schema.go b/backend/plugins/aisdlc/models/migrationscripts/init_schema.go new file mode 100644 index 00000000000..54d70cb54fa --- /dev/null +++ b/backend/plugins/aisdlc/models/migrationscripts/init_schema.go @@ -0,0 +1,273 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/migrationhelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +var _ plugin.MigrationScript = (*initSchema)(nil) + +type initSchema struct{} + +// Snapshot of the tool tables at version 20261020000001. +// These types stay here so a later model change cannot rewrite this migration. + +// AisdlcNoPK20261020 is exported so GORM migrates its fields. An unexported +// embed is ignored, and created_at plus the _raw_data_* columns are never created. +type AisdlcNoPK20261020 struct { + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` +} + +type aisdlcConnection20261020 struct { + helper.BaseConnection `mapstructure:",squash"` + GitlabConnectionId uint64 `gorm:"column:gitlab_connection_id;not null"` +} + +func (aisdlcConnection20261020) TableName() string { + return "_tool_aisdlc_connections" +} + +type aisdlcScope20261020 struct { + AisdlcNoPK20261020 + ConnectionId uint64 `json:"connectionId" gorm:"primaryKey"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty"` + FullName string `gorm:"primaryKey;type:varchar(255)"` + Name string `gorm:"type:varchar(255)"` +} + +func (aisdlcScope20261020) TableName() string { + return "_tool_aisdlc_scopes" +} + +type aisdlcScopeConfig20261020 struct { + ID uint64 `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Entities []string `gorm:"type:json;serializer:json" json:"entities"` + ConnectionId uint64 `json:"connectionId" gorm:"index"` + Name string `json:"name" gorm:"type:varchar(255);uniqueIndex"` + TenantId string `gorm:"type:varchar(255)"` + SourceId string `gorm:"type:varchar(255)"` + StationId string `gorm:"type:varchar(255)"` + DisplayName string `gorm:"type:varchar(255)"` + Parser string `gorm:"type:varchar(64)"` + Branch string `gorm:"type:varchar(255)"` + PathPrefix string `gorm:"type:varchar(1024)"` +} + +func (aisdlcScopeConfig20261020) TableName() string { + return "_tool_aisdlc_scope_configs" +} + +type aisdlcRawFile20261020 struct { + AisdlcNoPK20261020 + Id string `gorm:"primaryKey;type:varchar(64)"` + ConnectionId uint64 `gorm:"primaryKey"` + ProjectPath string `gorm:"type:varchar(255);index"` + FilePath string `gorm:"type:varchar(1024)"` + CommitSha string `gorm:"type:varchar(64)"` + BlobId string `gorm:"type:varchar(64)"` + Body string `gorm:"type:longtext"` + CollectedAt time.Time +} + +func (aisdlcRawFile20261020) TableName() string { + return "_tool_aisdlc_raw_files" +} + +type aisdlcCheckpoint20261020 struct { + AisdlcNoPK20261020 + Id string `gorm:"primaryKey;type:varchar(64)"` + ConnectionId uint64 `gorm:"primaryKey"` + ProjectPath string `gorm:"type:varchar(255)"` + ExaminedRevision string `gorm:"type:varchar(64)"` + Branch string `gorm:"type:varchar(255)"` + FileCount int + ErrorMessage string `gorm:"type:text"` + CollectedAt time.Time +} + +func (aisdlcCheckpoint20261020) TableName() string { + return "_tool_aisdlc_checkpoints" +} + +type aisdlcExecution20261020 struct { + AisdlcNoPK20261020 + Id string `gorm:"primaryKey;type:varchar(64)"` + ConnectionId uint64 `gorm:"primaryKey"` + SourceId string `gorm:"type:varchar(255)"` + StationId string `gorm:"type:varchar(255)"` + SourceRunId string `gorm:"type:varchar(255)"` + RunIdType string `gorm:"type:varchar(64)"` + PipelineIdStatus string `gorm:"type:varchar(32)"` + ProjectPath string `gorm:"type:varchar(255)"` + BatchPath string `gorm:"type:varchar(1024)"` +} + +func (aisdlcExecution20261020) TableName() string { + return "_tool_aisdlc_executions" +} + +type aisdlcWorkItem20261020 struct { + AisdlcNoPK20261020 + Id string `gorm:"primaryKey;type:varchar(64)"` + ConnectionId uint64 `gorm:"primaryKey"` + SourceSystem string `gorm:"type:varchar(32)"` + SourceInstance string `gorm:"type:varchar(255)"` + EntityKind string `gorm:"type:varchar(32)"` + ItemKey string `gorm:"type:varchar(255);index"` + IdStatus string `gorm:"type:varchar(32)"` + DomainIssueId string `gorm:"type:varchar(255)"` + MatchStatus string `gorm:"type:varchar(32)"` +} + +func (aisdlcWorkItem20261020) TableName() string { + return "_tool_aisdlc_work_items" +} + +type aisdlcObservation20261020 struct { + AisdlcNoPK20261020 + Id string `gorm:"primaryKey;type:varchar(64)"` + ConnectionId uint64 `gorm:"primaryKey"` + SchemaVersion string `gorm:"type:varchar(16)"` + TenantId string `gorm:"type:varchar(255)"` + SourceId string `gorm:"type:varchar(255)"` + StationId string `gorm:"type:varchar(255)"` + ObservationKind string `gorm:"type:varchar(64);index"` + WorkItemId string `gorm:"type:varchar(64);index"` + ExecutionId string `gorm:"type:varchar(64)"` + AttemptStatus string `gorm:"type:varchar(32)"` + RawResult string `gorm:"type:varchar(255)"` + AssessmentTotal *float64 + AssessmentScale *float64 + RawVerdict string `gorm:"type:varchar(64)"` + ProjectPath string `gorm:"type:varchar(255)"` + FilePath string `gorm:"type:varchar(1024)"` + BlobId string `gorm:"type:varchar(64)"` + CommitSha string `gorm:"type:varchar(64)"` + FieldSelector string `gorm:"type:varchar(255)"` + AdapterId string `gorm:"type:varchar(64)"` + AdapterVersion string `gorm:"type:varchar(32)"` + MappingVersion string `gorm:"type:varchar(32)"` + Completeness string `gorm:"type:varchar(64)"` + Extensions string `gorm:"type:text"` + CollectedAt time.Time +} + +func (aisdlcObservation20261020) TableName() string { + return "_tool_aisdlc_observations" +} + +type aisdlcEvidence20261020 struct { + AisdlcNoPK20261020 + Id string `gorm:"primaryKey;type:varchar(128)"` + ConnectionId uint64 `gorm:"primaryKey"` + ObservationId string `gorm:"type:varchar(64);index"` + SourceId string `gorm:"type:varchar(255)"` + URI string `gorm:"type:text"` + Revision string `gorm:"type:varchar(64)"` + FieldSelector string `gorm:"type:varchar(255)"` +} + +func (aisdlcEvidence20261020) TableName() string { + return "_tool_aisdlc_evidence" +} + +type aisdlcItemLink20261020 struct { + AisdlcNoPK20261020 + Id string `gorm:"primaryKey;type:varchar(128)"` + ConnectionId uint64 `gorm:"primaryKey"` + ObservationId string `gorm:"type:varchar(64);index"` + Role string `gorm:"type:varchar(32)"` + Relation string `gorm:"type:varchar(64)"` + WorkItemId string `gorm:"type:varchar(64)"` + EvidenceStatus string `gorm:"type:varchar(32)"` +} + +func (aisdlcItemLink20261020) TableName() string { + return "_tool_aisdlc_item_links" +} + +type aisdlcUnknown20261020 struct { + AisdlcNoPK20261020 + Id string `gorm:"primaryKey;type:varchar(128)"` + ConnectionId uint64 `gorm:"primaryKey"` + ObservationId string `gorm:"type:varchar(64);index"` + Field string `gorm:"type:varchar(255)"` + Reason string `gorm:"type:text"` + EvidenceNeeded string `gorm:"type:text"` +} + +func (aisdlcUnknown20261020) TableName() string { + return "_tool_aisdlc_unknowns" +} + +type aisdlcTime20261020 struct { + AisdlcNoPK20261020 + Id string `gorm:"primaryKey;type:varchar(128)"` + ConnectionId uint64 `gorm:"primaryKey"` + ObservationId string `gorm:"type:varchar(64);index"` + TimeType string `gorm:"column:time_type;type:varchar(64)"` + RawValue string `gorm:"type:varchar(255)"` + Normalized *string `gorm:"type:varchar(255)"` + Precision string `gorm:"type:varchar(32)"` + TimezoneStatus string `gorm:"type:varchar(32)"` + AppliesTo string `gorm:"type:varchar(32)"` +} + +func (aisdlcTime20261020) TableName() string { + return "_tool_aisdlc_times" +} + +func (script *initSchema) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &aisdlcConnection20261020{}, + &aisdlcScope20261020{}, + &aisdlcScopeConfig20261020{}, + &aisdlcRawFile20261020{}, + &aisdlcCheckpoint20261020{}, + &aisdlcExecution20261020{}, + &aisdlcWorkItem20261020{}, + &aisdlcObservation20261020{}, + &aisdlcEvidence20261020{}, + &aisdlcItemLink20261020{}, + &aisdlcUnknown20261020{}, + &aisdlcTime20261020{}, + ) +} + +func (script *initSchema) Version() uint64 { + return 20261020000001 +} + +func (script *initSchema) Name() string { + return "aisdlc init schema" +} diff --git a/backend/plugins/aisdlc/models/migrationscripts/register.go b/backend/plugins/aisdlc/models/migrationscripts/register.go new file mode 100644 index 00000000000..f074871eee7 --- /dev/null +++ b/backend/plugins/aisdlc/models/migrationscripts/register.go @@ -0,0 +1,29 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/plugin" +) + +func All() []plugin.MigrationScript { + return []plugin.MigrationScript{ + &initSchema{}, + &addNoPKColumns{}, + } +} diff --git a/backend/plugins/aisdlc/models/records.go b/backend/plugins/aisdlc/models/records.go new file mode 100644 index 00000000000..c13c5a8ddca --- /dev/null +++ b/backend/plugins/aisdlc/models/records.go @@ -0,0 +1,176 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +// AisdlcRawFile is one artifact blob. It is not a station observation. +type AisdlcRawFile struct { + common.NoPKModel + Id string `gorm:"primaryKey;type:varchar(64)"` + ConnectionId uint64 `gorm:"primaryKey"` + ProjectPath string `gorm:"type:varchar(255);index"` + FilePath string `gorm:"type:varchar(1024)"` + CommitSha string `gorm:"type:varchar(64)"` + BlobId string `gorm:"type:varchar(64)"` + Body string `gorm:"type:longtext"` + CollectedAt time.Time +} + +func (AisdlcRawFile) TableName() string { return "_tool_aisdlc_raw_files" } + +// AisdlcCheckpoint records collection progress separately from station results. +type AisdlcCheckpoint struct { + common.NoPKModel + Id string `gorm:"primaryKey;type:varchar(64)"` + ConnectionId uint64 `gorm:"primaryKey"` + ProjectPath string `gorm:"type:varchar(255)"` + ExaminedRevision string `gorm:"type:varchar(64)"` + Branch string `gorm:"type:varchar(255)"` + FileCount int + ErrorMessage string `gorm:"type:text"` + CollectedAt time.Time +} + +func (AisdlcCheckpoint) TableName() string { return "_tool_aisdlc_checkpoints" } + +// AisdlcExecution is a batch the source file itself identified. +type AisdlcExecution struct { + common.NoPKModel + Id string `gorm:"primaryKey;type:varchar(64)"` + ConnectionId uint64 `gorm:"primaryKey"` + SourceId string `gorm:"type:varchar(255)"` + StationId string `gorm:"type:varchar(255)"` + SourceRunId string `gorm:"type:varchar(255)"` + RunIdType string `gorm:"type:varchar(64)"` + PipelineIdStatus string `gorm:"type:varchar(32)"` + ProjectPath string `gorm:"type:varchar(255)"` + BatchPath string `gorm:"type:varchar(1024)"` +} + +func (AisdlcExecution) TableName() string { return "_tool_aisdlc_executions" } + +// AisdlcWorkItem is a source-qualified reference. DomainIssueId is set only +// when exactly one existing issues row matches the key. +type AisdlcWorkItem struct { + common.NoPKModel + Id string `gorm:"primaryKey;type:varchar(64)"` + ConnectionId uint64 `gorm:"primaryKey"` + SourceSystem string `gorm:"type:varchar(32)"` + SourceInstance string `gorm:"type:varchar(255)"` + EntityKind string `gorm:"type:varchar(32)"` + ItemKey string `gorm:"type:varchar(255);index"` + IdStatus string `gorm:"type:varchar(32)"` + DomainIssueId string `gorm:"type:varchar(255)"` + MatchStatus string `gorm:"type:varchar(32)"` +} + +func (AisdlcWorkItem) TableName() string { return "_tool_aisdlc_work_items" } + +// AisdlcObservation is one source-supported result about one work item. +type AisdlcObservation struct { + common.NoPKModel + Id string `gorm:"primaryKey;type:varchar(64)"` + ConnectionId uint64 `gorm:"primaryKey"` + SchemaVersion string `gorm:"type:varchar(16)"` + TenantId string `gorm:"type:varchar(255)"` + SourceId string `gorm:"type:varchar(255)"` + StationId string `gorm:"type:varchar(255)"` + ObservationKind string `gorm:"type:varchar(64);index"` + WorkItemId string `gorm:"type:varchar(64);index"` + ExecutionId string `gorm:"type:varchar(64)"` + AttemptStatus string `gorm:"type:varchar(32)"` + RawResult string `gorm:"type:varchar(255)"` + AssessmentTotal *float64 + AssessmentScale *float64 + RawVerdict string `gorm:"type:varchar(64)"` + ProjectPath string `gorm:"type:varchar(255)"` + FilePath string `gorm:"type:varchar(1024)"` + BlobId string `gorm:"type:varchar(64)"` + CommitSha string `gorm:"type:varchar(64)"` + FieldSelector string `gorm:"type:varchar(255)"` + AdapterId string `gorm:"type:varchar(64)"` + AdapterVersion string `gorm:"type:varchar(32)"` + MappingVersion string `gorm:"type:varchar(32)"` + Completeness string `gorm:"type:varchar(64)"` + Extensions string `gorm:"type:text"` + CollectedAt time.Time +} + +func (AisdlcObservation) TableName() string { return "_tool_aisdlc_observations" } + +// AisdlcEvidence points at the Git blob entry that supports an observation. +type AisdlcEvidence struct { + common.NoPKModel + Id string `gorm:"primaryKey;type:varchar(128)"` + ConnectionId uint64 `gorm:"primaryKey"` + ObservationId string `gorm:"type:varchar(64);index"` + SourceId string `gorm:"type:varchar(255)"` + URI string `gorm:"type:text"` + Revision string `gorm:"type:varchar(64)"` + FieldSelector string `gorm:"type:varchar(255)"` +} + +func (AisdlcEvidence) TableName() string { return "_tool_aisdlc_evidence" } + +// AisdlcItemLink is an evidenced input or output reference. +type AisdlcItemLink struct { + common.NoPKModel + Id string `gorm:"primaryKey;type:varchar(128)"` + ConnectionId uint64 `gorm:"primaryKey"` + ObservationId string `gorm:"type:varchar(64);index"` + Role string `gorm:"type:varchar(32)"` + Relation string `gorm:"type:varchar(64)"` + WorkItemId string `gorm:"type:varchar(64)"` + EvidenceStatus string `gorm:"type:varchar(32)"` +} + +func (AisdlcItemLink) TableName() string { return "_tool_aisdlc_item_links" } + +// AisdlcUnknown records a missing value and what would resolve it. +type AisdlcUnknown struct { + common.NoPKModel + Id string `gorm:"primaryKey;type:varchar(128)"` + ConnectionId uint64 `gorm:"primaryKey"` + ObservationId string `gorm:"type:varchar(64);index"` + Field string `gorm:"type:varchar(255)"` + Reason string `gorm:"type:text"` + EvidenceNeeded string `gorm:"type:text"` +} + +func (AisdlcUnknown) TableName() string { return "_tool_aisdlc_unknowns" } + +// AisdlcTime keeps the source timestamp's precision and timezone status. +type AisdlcTime struct { + common.NoPKModel + Id string `gorm:"primaryKey;type:varchar(128)"` + ConnectionId uint64 `gorm:"primaryKey"` + ObservationId string `gorm:"type:varchar(64);index"` + TimeType string `gorm:"column:time_type;type:varchar(64)"` + RawValue string `gorm:"type:varchar(255)"` + Normalized *string `gorm:"type:varchar(255)"` + Precision string `gorm:"type:varchar(32)"` + TimezoneStatus string `gorm:"type:varchar(32)"` + AppliesTo string `gorm:"type:varchar(32)"` +} + +func (AisdlcTime) TableName() string { return "_tool_aisdlc_times" } diff --git a/backend/plugins/aisdlc/models/scope.go b/backend/plugins/aisdlc/models/scope.go new file mode 100644 index 00000000000..e30fc06a250 --- /dev/null +++ b/backend/plugins/aisdlc/models/scope.go @@ -0,0 +1,74 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "encoding/json" + + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" +) + +// AisdlcScope is one GitLab project path, such as redhat/rhel-ai/agentic-ci/rfe-assess-data. +type AisdlcScope struct { + common.Scope `mapstructure:",squash"` + FullName string `gorm:"primaryKey;type:varchar(255)" json:"fullName" mapstructure:"fullName" validate:"required"` + Name string `gorm:"type:varchar(255)" json:"name" mapstructure:"name"` + Id string `gorm:"-" json:"id" mapstructure:"-"` +} + +func (s AisdlcScope) MarshalJSON() ([]byte, error) { + type Alias AisdlcScope + alias := Alias(s) + alias.Id = s.FullName + return json.Marshal(alias) +} + +func (AisdlcScope) TableName() string { + return "_tool_aisdlc_scopes" +} + +func (s AisdlcScope) ScopeId() string { + return s.FullName +} + +func (s AisdlcScope) ScopeName() string { + if s.Name != "" { + return s.Name + } + return s.FullName +} + +func (s AisdlcScope) ScopeFullName() string { + return s.FullName +} + +func (s AisdlcScope) ScopeParams() interface{} { + return &AisdlcApiParams{ + ConnectionId: s.ConnectionId, + FullName: s.FullName, + } +} + +// AisdlcApiParams identifies a scope inside a pipeline. +type AisdlcApiParams struct { + ConnectionId uint64 `json:"connectionId"` + FullName string `json:"fullName"` +} + +var _ plugin.ToolLayerScope = (*AisdlcScope)(nil) diff --git a/backend/plugins/aisdlc/models/scope_config.go b/backend/plugins/aisdlc/models/scope_config.go new file mode 100644 index 00000000000..eeedc069450 --- /dev/null +++ b/backend/plugins/aisdlc/models/scope_config.go @@ -0,0 +1,39 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// AisdlcScopeConfig holds the station registration for one scope. +// These values are configured. They are not inferred from repository names. +type AisdlcScopeConfig struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` + TenantId string `json:"tenantId" mapstructure:"tenantId" gorm:"type:varchar(255)"` + SourceId string `json:"sourceId" mapstructure:"sourceId" gorm:"type:varchar(255)"` + StationId string `json:"stationId" mapstructure:"stationId" gorm:"type:varchar(255)"` + DisplayName string `json:"displayName" mapstructure:"displayName" gorm:"type:varchar(255)"` + Parser string `json:"parser" mapstructure:"parser" gorm:"type:varchar(64)"` + Branch string `json:"branch" mapstructure:"branch" gorm:"type:varchar(255)"` + PathPrefix string `json:"pathPrefix" mapstructure:"pathPrefix" gorm:"type:varchar(1024)"` +} + +func (AisdlcScopeConfig) TableName() string { + return "_tool_aisdlc_scope_configs" +} diff --git a/backend/plugins/aisdlc/tasks/collect.go b/backend/plugins/aisdlc/tasks/collect.go new file mode 100644 index 00000000000..d667cf1af07 --- /dev/null +++ b/backend/plugins/aisdlc/tasks/collect.go @@ -0,0 +1,175 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aisdlc/models" + "github.com/apache/devlake/plugins/aisdlc/tasks/parse" +) + +var CollectFilesMeta = plugin.SubTaskMeta{ + Name: "collectFiles", + EntryPoint: CollectFiles, + EnabledByDefault: true, + Description: "Read station artifacts from a GitLab project", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, +} + +// CollectFiles lists and stores artifact blobs. The checkpoint is separate from observations. +func CollectFiles(taskCtx plugin.SubTaskContext) errors.Error { + ctx := taskCtx.GetContext() + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AisdlcTaskData) + cfg := data.Options.ScopeConfig + projectPath := data.Options.FullName + + glConn, err := LoadGitlabConnection(db, data.Connection.GitlabConnectionId) + if err != nil { + return err + } + client, clientErr := NewGitlabClient(ctx, glConn.Endpoint, glConn.Token) + if clientErr != nil { + return errors.Default.Wrap(clientErr, "gitlab client") + } + branch := cfg.Branch + if branch == "" { + project, projectErr := client.GetProject(ctx, projectPath) + if projectErr != nil { + return errors.Default.Wrap(projectErr, "gitlab project") + } + branch = project.DefaultBranch + } + if branch == "" { + branch = "main" + } + commit, commitErr := client.BranchCommit(ctx, projectPath, branch) + if commitErr != nil { + return errors.Default.Wrap(commitErr, "gitlab branch commit") + } + // List and download the same commit. A branch name can move between the two calls. + entries, listErr := client.ListBlobs(ctx, projectPath, commit, cfg.PathPrefix, cfg.Parser) + if listErr != nil { + _ = saveCheckpoint(db, data.Connection.ID, projectPath, commit, branch, 0, listErr.Error()) + return errors.Default.Wrap(listErr, "gitlab tree") + } + + collectedAt := time.Now().UTC() + var failures []string + for _, entry := range entries { + rawID := parse.ID("raw", fmt.Sprintf("%d", data.Connection.ID), projectPath, entry.Path, entry.ID) + existing := &models.AisdlcRawFile{} + findErr := db.First(existing, dal.Where("id = ? AND connection_id = ?", rawID, data.Connection.ID)) + if findErr == nil && existing.BlobId == entry.ID && existing.Body != "" { + existing.CommitSha = commit + existing.CollectedAt = collectedAt + existing.RawDataParams = scopeRawParams(data.Connection.ID, projectPath) + if updateErr := db.Update(existing); updateErr != nil { + return updateErr + } + continue + } + body, rawErr := client.GetRaw(ctx, projectPath, commit, entry.Path) + if rawErr != nil { + logger.Warn(rawErr, "failed to download %s", entry.Path) + failures = append(failures, entry.Path+": "+rawErr.Error()) + continue + } + row := &models.AisdlcRawFile{ + NoPKModel: scopeStamp(data.Connection.ID, projectPath), + Id: rawID, + ConnectionId: data.Connection.ID, + ProjectPath: projectPath, + FilePath: entry.Path, + CommitSha: commit, + BlobId: entry.ID, + Body: body, + CollectedAt: collectedAt, + } + if saveErr := db.CreateOrUpdate(row); saveErr != nil { + return saveErr + } + } + message := strings.Join(failures, "\n") + if saveErr := saveCheckpoint(db, data.Connection.ID, projectPath, commit, branch, len(entries), message); saveErr != nil { + return saveErr + } + if len(failures) > 0 { + return errors.Default.New(fmt.Sprintf("%d download failure(s): %s", len(failures), message)) + } + return nil +} + +// LoadGitlabConnection reads token and endpoint from the GitLab plugin's connection table. +func LoadGitlabConnection(db dal.Dal, id uint64) (*GitlabConnLookup, errors.Error) { + var glConn GitlabConnLookup + if err := db.First(&glConn, dal.Where("id = ?", id)); err != nil { + return nil, errors.Default.Wrap(err, fmt.Sprintf("gitlab connection %d not found", id)) + } + if glConn.Token == "" { + return nil, errors.BadInput.New("referenced gitlab connection has no token") + } + if glConn.Endpoint == "" { + glConn.Endpoint = "https://gitlab.com/api/v4" + } + return &glConn, nil +} + +func saveCheckpoint(db dal.Dal, connectionID uint64, projectPath, commit, branch string, fileCount int, message string) errors.Error { + row := &models.AisdlcCheckpoint{ + NoPKModel: scopeStamp(connectionID, projectPath), + Id: checkpointID(connectionID, projectPath), + ConnectionId: connectionID, + ProjectPath: projectPath, + ExaminedRevision: commit, + Branch: branch, + FileCount: fileCount, + ErrorMessage: message, + CollectedAt: time.Now().UTC(), + } + return db.CreateOrUpdate(row) +} + +func checkpointID(connectionID uint64, projectPath string) string { + return parse.ID("checkpoint", fmt.Sprintf("%d", connectionID), projectPath) +} + +// appendCheckpointError records a later failure without clearing the revision collect stored. +func appendCheckpointError(db dal.Dal, connectionID uint64, projectPath, message string) errors.Error { + row := &models.AisdlcCheckpoint{} + err := db.First(row, dal.Where("id = ? AND connection_id = ?", checkpointID(connectionID, projectPath), connectionID)) + if err != nil { + if db.IsErrorNotFound(err) { + return nil + } + return err + } + if row.ErrorMessage != "" { + row.ErrorMessage += "\n" + } + row.ErrorMessage += message + row.RawDataParams = scopeRawParams(connectionID, projectPath) + return db.Update(row) +} diff --git a/backend/plugins/aisdlc/tasks/extract.go b/backend/plugins/aisdlc/tasks/extract.go new file mode 100644 index 00000000000..25441f7a084 --- /dev/null +++ b/backend/plugins/aisdlc/tasks/extract.go @@ -0,0 +1,269 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "strings" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aisdlc/models" + "github.com/apache/devlake/plugins/aisdlc/tasks/parse" +) + +var ExtractObservationsMeta = plugin.SubTaskMeta{ + Name: "extractObservations", + EntryPoint: ExtractObservations, + EnabledByDefault: true, + Description: "Convert station artifacts into observations", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&CollectFilesMeta}, +} + +// ExtractObservations parses stored blobs into observations. +// collected_at is copied from the raw file, which collect sets when it last saw the blob. +func ExtractObservations(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + data := taskCtx.GetData().(*AisdlcTaskData) + cfg := data.Options.ScopeConfig + projectPath := data.Options.FullName + + var raws []models.AisdlcRawFile + if err := db.All(&raws, dal.Where("connection_id = ? AND project_path = ?", data.Connection.ID, projectPath)); err != nil { + return err + } + files := filesFromRaw(raws, cfg.PathPrefix) + webBase := "" + if glConn, glErr := LoadGitlabConnection(db, data.Connection.GitlabConnectionId); glErr == nil { + webBase = WebBase(glConn.Endpoint) + } + set := parse.Parse(parse.Config{ + TenantID: cfg.TenantId, + SourceID: cfg.SourceId, + StationID: cfg.StationId, + DisplayName: cfg.DisplayName, + Parser: cfg.Parser, + ProjectPath: projectPath, + WebBase: webBase, + }, files) + if err := saveDraft(db, data.Connection.ID, projectPath, cfg, set); err != nil { + return err + } + if len(set.FileErrors) == 0 { + return nil + } + message := strings.Join(set.FileErrors, "\n") + for _, item := range set.FileErrors { + logger.Warn(nil, "parse %s", item) + } + if err := appendCheckpointError(db, data.Connection.ID, projectPath, message); err != nil { + return err + } + return errors.Default.New(fmt.Sprintf("%d file(s) failed to parse: %s", len(set.FileErrors), message)) +} + +// filesFromRaw keeps each blob's collection time. A file that is no longer in the +// tree is still here, and its collected_at stays at the last time collect saw it. +func filesFromRaw(raws []models.AisdlcRawFile, prefix string) []parse.File { + prefix = strings.Trim(prefix, "/") + files := make([]parse.File, 0, len(raws)) + for _, raw := range raws { + if prefix != "" && raw.FilePath != prefix && !strings.HasPrefix(raw.FilePath, prefix+"/") { + continue + } + files = append(files, parse.File{ + Path: raw.FilePath, + BlobID: raw.BlobId, + CommitSHA: raw.CommitSha, + Body: raw.Body, + CollectedAt: raw.CollectedAt, + }) + } + return files +} + +func saveDraft(db dal.Dal, connectionID uint64, projectPath string, cfg *models.AisdlcScopeConfig, set parse.DraftSet) errors.Error { + params := scopeStamp(connectionID, projectPath) + for _, exec := range set.Executions { + row := &models.AisdlcExecution{ + NoPKModel: params, + Id: exec.ID, + ConnectionId: connectionID, + SourceId: exec.SourceID, + StationId: exec.StationID, + SourceRunId: exec.SourceRunID, + RunIdType: exec.RunIDType, + PipelineIdStatus: exec.PipelineIDStatus, + ProjectPath: projectPath, + BatchPath: exec.BatchPath, + } + if err := db.CreateOrUpdate(row); err != nil { + return err + } + } + // Work items are shared by every scope on this connection. Leaving + // _raw_data_params empty keeps scope delete from removing a key another scope still uses. + for _, item := range set.WorkItems { + existing := &models.AisdlcWorkItem{} + err := db.First(existing, dal.Where("id = ? AND connection_id = ?", item.ID, connectionID)) + matchStatus := item.MatchStatus + domainID := item.DomainIssueID + if err == nil && existing.MatchStatus == parse.MatchUnique { + matchStatus = existing.MatchStatus + domainID = existing.DomainIssueId + } + row := &models.AisdlcWorkItem{ + Id: item.ID, + ConnectionId: connectionID, + SourceSystem: item.SourceSystem, + SourceInstance: item.SourceInstance, + EntityKind: item.EntityKind, + ItemKey: item.ItemKey, + IdStatus: item.IdStatus, + DomainIssueId: domainID, + MatchStatus: matchStatus, + } + if err := db.CreateOrUpdate(row); err != nil { + return err + } + } + for _, obs := range set.Observations { + row := &models.AisdlcObservation{ + NoPKModel: params, + Id: obs.ID, + ConnectionId: connectionID, + SchemaVersion: parse.SchemaVersion, + TenantId: cfg.TenantId, + SourceId: cfg.SourceId, + StationId: cfg.StationId, + ObservationKind: obs.Kind, + WorkItemId: obs.WorkItemID, + ExecutionId: obs.ExecutionID, + AttemptStatus: obs.AttemptStatus, + RawResult: obs.RawResult, + AssessmentTotal: obs.AssessmentTotal, + AssessmentScale: obs.AssessmentScale, + RawVerdict: obs.RawVerdict, + ProjectPath: projectPath, + FilePath: obs.Path, + BlobId: obs.BlobID, + CommitSha: obs.CommitSHA, + FieldSelector: obs.FieldSelector, + AdapterId: obs.AdapterID, + AdapterVersion: parse.AdapterVersion, + MappingVersion: parse.MappingVersion, + Completeness: obs.Completeness, + Extensions: obs.Extensions, + CollectedAt: obs.CollectedAt, + } + if err := db.CreateOrUpdate(row); err != nil { + return err + } + if err := replaceChildren(db, connectionID, projectPath, obs); err != nil { + return err + } + } + return nil +} + +func replaceChildren(db dal.Dal, connectionID uint64, projectPath string, obs parse.Observation) errors.Error { + where := dal.Where("connection_id = ? AND observation_id = ?", connectionID, obs.ID) + if err := db.Delete(&models.AisdlcEvidence{}, where); err != nil { + return err + } + if err := db.Delete(&models.AisdlcItemLink{}, where); err != nil { + return err + } + if err := db.Delete(&models.AisdlcUnknown{}, where); err != nil { + return err + } + if err := db.Delete(&models.AisdlcTime{}, where); err != nil { + return err + } + params := scopeStamp(connectionID, projectPath) + for _, ev := range obs.Evidence { + row := &models.AisdlcEvidence{ + NoPKModel: params, + Id: ev.ID, + ConnectionId: connectionID, + ObservationId: obs.ID, + SourceId: ev.SourceID, + URI: ev.URI, + Revision: ev.Revision, + FieldSelector: ev.FieldSelector, + } + if err := db.CreateOrUpdate(row); err != nil { + return err + } + } + for _, link := range obs.Links { + row := &models.AisdlcItemLink{ + NoPKModel: params, + Id: link.ID, + ConnectionId: connectionID, + ObservationId: obs.ID, + Role: link.Role, + Relation: link.Relation, + WorkItemId: link.WorkItemID, + EvidenceStatus: link.EvidenceStatus, + } + if err := db.CreateOrUpdate(row); err != nil { + return err + } + } + for _, unknown := range obs.Unknowns { + row := &models.AisdlcUnknown{ + NoPKModel: params, + Id: unknown.ID, + ConnectionId: connectionID, + ObservationId: obs.ID, + Field: unknown.Field, + Reason: unknown.Reason, + EvidenceNeeded: unknown.EvidenceNeeded, + } + if err := db.CreateOrUpdate(row); err != nil { + return err + } + } + for _, stamp := range obs.Times { + var normalized *string + if stamp.Normalized != "" { + value := stamp.Normalized + normalized = &value + } + row := &models.AisdlcTime{ + NoPKModel: params, + Id: parse.ID(obs.ID, "time", stamp.Type, stamp.Raw), + ConnectionId: connectionID, + ObservationId: obs.ID, + TimeType: stamp.Type, + RawValue: stamp.Raw, + Normalized: normalized, + Precision: stamp.Precision, + TimezoneStatus: stamp.TimezoneStatus, + AppliesTo: stamp.AppliesTo, + } + if err := db.CreateOrUpdate(row); err != nil { + return err + } + } + return nil +} diff --git a/backend/plugins/aisdlc/tasks/extract_test.go b/backend/plugins/aisdlc/tasks/extract_test.go new file mode 100644 index 00000000000..40c09dec75f --- /dev/null +++ b/backend/plugins/aisdlc/tasks/extract_test.go @@ -0,0 +1,58 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "strings" + "testing" + "time" + + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aisdlc/models" +) + +func TestFilesFromRawKeepsCollectionTime(t *testing.T) { + seen := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) + later := seen.Add(24 * time.Hour) + raws := []models.AisdlcRawFile{ + {FilePath: "RHAIRFE/20260719-041307/RHAIRFE-1.result.md", BlobId: "kept", CollectedAt: seen}, + {FilePath: "other/RHAIRFE-2.result.md", BlobId: "skipped", CollectedAt: later}, + } + files := filesFromRaw(raws, "RHAIRFE") + if len(files) != 1 { + t.Fatalf("files = %+v", files) + } + if files[0].BlobID != "kept" || !files[0].CollectedAt.Equal(seen) { + t.Fatalf("file = %+v", files[0]) + } +} + +func TestScopeRawParamsMatchScopeDeleteKey(t *testing.T) { + const connectionID = uint64(7) + const fullName = "redhat/rhel-ai/agentic-ci/rfe-assess-data" + scope := models.AisdlcScope{FullName: fullName} + scope.ConnectionId = connectionID + got := scopeRawParams(connectionID, fullName) + want := plugin.MarshalScopeParams(scope.ScopeParams()) + if got != want { + t.Fatalf("scope params = %s, delete key = %s", got, want) + } + if !strings.Contains(got, fullName) || !strings.Contains(got, `"connectionId":7`) { + t.Fatalf("params = %s", got) + } +} diff --git a/backend/plugins/aisdlc/tasks/gitlab_client.go b/backend/plugins/aisdlc/tasks/gitlab_client.go new file mode 100644 index 00000000000..d59de376ffa --- /dev/null +++ b/backend/plugins/aisdlc/tasks/gitlab_client.go @@ -0,0 +1,313 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/apache/devlake/plugins/aisdlc/tasks/parse" +) + +const ( + maxBodyBytes = 8 << 20 + maxTreePages = 2000 +) + +// GitlabProject is a project the token can see. +type GitlabProject struct { + Name string `json:"name"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` +} + +type gitlabTreeEntry struct { + ID string `json:"id"` + Path string `json:"path"` + Type string `json:"type"` + Mode string `json:"mode"` +} + +type gitlabFileMeta struct { + BlobID string `json:"blob_id"` + FilePath string `json:"file_path"` +} + +type gitlabCommit struct { + ID string `json:"id"` +} + +// GitlabClient calls the GitLab API with either a bearer token or a private token. +type GitlabClient struct { + Base string + HTTP *http.Client + Header http.Header +} + +// NewGitlabClient probes bearer authentication and falls back to PRIVATE-TOKEN. +func NewGitlabClient(ctx context.Context, endpoint, token string) (*GitlabClient, error) { + if token == "" { + return nil, fmt.Errorf("gitlab token is empty") + } + client := &GitlabClient{ + Base: strings.TrimRight(endpoint, "/"), + HTTP: &http.Client{Timeout: 60 * time.Second}, + } + if client.Base == "" { + return nil, fmt.Errorf("gitlab endpoint is empty") + } + bearer := http.Header{} + bearer.Set("Authorization", "Bearer "+token) + status, err := client.probe(ctx, bearer) + if err != nil { + return nil, err + } + if status == http.StatusOK { + client.Header = bearer + return client, nil + } + private := http.Header{} + private.Set("PRIVATE-TOKEN", token) + status, err = client.probe(ctx, private) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("gitlab authentication failed with status %d", status) + } + client.Header = private + return client, nil +} + +func (c *GitlabClient) probe(ctx context.Context, header http.Header) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+"/user", nil) + if err != nil { + return 0, err + } + req.Header = header.Clone() + resp, err := c.HTTP.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + return resp.StatusCode, nil +} + +// WebBase strips the API prefix so evidence links open in the GitLab UI. +func WebBase(endpoint string) string { + base := strings.TrimRight(endpoint, "/") + base = strings.TrimSuffix(base, "/api/v4") + return strings.TrimRight(base, "/") +} + +func encodeGitlabPath(p string) string { + parts := strings.Split(p, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return strings.Join(parts, "%2F") +} + +func (c *GitlabClient) get(ctx context.Context, path string, query url.Values) (*http.Response, error) { + u := c.Base + path + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header = c.Header.Clone() + return c.HTTP.Do(req) +} + +func readJSON(resp *http.Response, dest any) error { + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("gitlab status %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + if err := json.Unmarshal(body, dest); err != nil { + return fmt.Errorf("decode gitlab response: %w", err) + } + return nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +// NextPage reads GitLab's X-Next-Page header. +func NextPage(header http.Header) string { + return header.Get("X-Next-Page") +} + +// GetProject loads one project by path. +func (c *GitlabClient) GetProject(ctx context.Context, projectPath string) (*GitlabProject, error) { + resp, err := c.get(ctx, "/projects/"+encodeGitlabPath(projectPath), nil) + if err != nil { + return nil, err + } + var project GitlabProject + if err := readJSON(resp, &project); err != nil { + return nil, err + } + return &project, nil +} + +// BranchCommit returns the commit sha at ref. +func (c *GitlabClient) BranchCommit(ctx context.Context, projectPath, ref string) (string, error) { + resp, err := c.get(ctx, "/projects/"+encodeGitlabPath(projectPath)+"/repository/commits/"+encodeGitlabPath(ref), nil) + if err != nil { + return "", err + } + var commit gitlabCommit + if err := readJSON(resp, &commit); err != nil { + return "", err + } + if commit.ID == "" { + return "", fmt.Errorf("gitlab commit response has no id") + } + return commit.ID, nil +} + +// ListProjects returns projects visible to the token. +func (c *GitlabClient) ListProjects(ctx context.Context, search, page string) ([]GitlabProject, string, error) { + query := url.Values{} + query.Set("membership", "true") + query.Set("simple", "true") + query.Set("per_page", "50") + if search != "" { + query.Set("search", search) + } + if page != "" { + query.Set("page", page) + } + resp, err := c.get(ctx, "/projects", query) + if err != nil { + return nil, "", err + } + next := NextPage(resp.Header) + var projects []GitlabProject + if err := readJSON(resp, &projects); err != nil { + return nil, "", err + } + return projects, next, nil +} + +// ListBlobs returns parser-owned blobs under prefix. +// ref is a commit SHA when CollectFiles calls this, so the tree matches GetRaw. +func (c *GitlabClient) ListBlobs(ctx context.Context, projectPath, ref, prefix, parser string) ([]parse.TreeEntry, error) { + prefix = strings.Trim(prefix, "/") + if prefix != "" && strings.Contains(pathBase(prefix), ".") { + entry, err := c.fileEntry(ctx, projectPath, ref, prefix) + if err != nil { + return nil, err + } + return parse.SelectPaths(parser, prefix, []parse.TreeEntry{entry}), nil + } + page := "1" + var entries []parse.TreeEntry + for i := 0; i < maxTreePages && page != ""; i++ { + query := url.Values{} + query.Set("ref", ref) + query.Set("per_page", "100") + query.Set("page", page) + query.Set("recursive", "true") + if prefix != "" { + query.Set("path", prefix) + } + resp, err := c.get(ctx, "/projects/"+encodeGitlabPath(projectPath)+"/repository/tree", query) + if err != nil { + return nil, err + } + next := NextPage(resp.Header) + var batch []gitlabTreeEntry + if err := readJSON(resp, &batch); err != nil { + return nil, err + } + if len(batch) == 0 { + break + } + for _, item := range batch { + entries = append(entries, parse.TreeEntry{ID: item.ID, Path: item.Path, Type: item.Type, Mode: item.Mode}) + } + if next == "" || next == page { + break + } + page = next + } + return parse.SelectPaths(parser, prefix, entries), nil +} + +func (c *GitlabClient) fileEntry(ctx context.Context, projectPath, ref, filePath string) (parse.TreeEntry, error) { + query := url.Values{} + query.Set("ref", ref) + resp, err := c.get(ctx, "/projects/"+encodeGitlabPath(projectPath)+"/repository/files/"+encodeGitlabPath(filePath), query) + if err != nil { + return parse.TreeEntry{}, err + } + var meta gitlabFileMeta + if err := readJSON(resp, &meta); err != nil { + return parse.TreeEntry{}, err + } + pathValue := meta.FilePath + if pathValue == "" { + pathValue = filePath + } + return parse.TreeEntry{ID: meta.BlobID, Path: pathValue, Type: "blob", Mode: "100644"}, nil +} + +// GetRaw downloads a file body at ref. +func (c *GitlabClient) GetRaw(ctx context.Context, projectPath, ref, filePath string) (string, error) { + query := url.Values{} + query.Set("ref", ref) + resp, err := c.get(ctx, "/projects/"+encodeGitlabPath(projectPath)+"/repository/files/"+encodeGitlabPath(filePath)+"/raw", query) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("gitlab raw status %d for %s", resp.StatusCode, filePath) + } + return string(body), nil +} + +func pathBase(p string) string { + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[i+1:] + } + return p +} diff --git a/backend/plugins/aisdlc/tasks/gitlab_client_test.go b/backend/plugins/aisdlc/tasks/gitlab_client_test.go new file mode 100644 index 00000000000..8998b3ab81c --- /dev/null +++ b/backend/plugins/aisdlc/tasks/gitlab_client_test.go @@ -0,0 +1,89 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/apache/devlake/plugins/aisdlc/tasks/parse" +) + +func TestListBlobsPaginatesAndFilters(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user" { + w.WriteHeader(http.StatusOK) + return + } + if strings.Contains(r.URL.Path, "/repository/tree") && r.URL.Query().Get("ref") != "a1b2c3d4" { + t.Errorf("tree ref = %q", r.URL.Query().Get("ref")) + } + if r.URL.Query().Get("page") == "1" { + w.Header().Set("X-Next-Page", "2") + _, _ = w.Write([]byte(`[ + {"id":"b1","path":"RHAIRFE/20260719-041307/RHAIRFE-1.result.md","type":"blob","mode":"100644"}, + {"id":"b2","path":"RHAIRFE/20260719-041307/scores.csv","type":"blob","mode":"100644"} + ]`)) + return + } + w.Header().Set("X-Next-Page", "") + _, _ = w.Write([]byte(`[ + {"id":"b3","path":"RHAIRFE/current/RHAIRFE-2.result.md","type":"blob","mode":"120000"}, + {"id":"b4","path":"RHAIRFE/20261020-041115/RHAIRFE-2.result.md","type":"blob","mode":"100644"} + ]`)) + })) + defer server.Close() + + client, err := NewGitlabClient(context.Background(), server.URL, "token") + if err != nil { + t.Fatal(err) + } + entries, err := client.ListBlobs(context.Background(), "group/repo", "a1b2c3d4", "", parse.ParserRFE) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("entries = %+v", entries) + } +} + +func TestNewGitlabClientFallsBackToPrivateToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.Header.Get("PRIVATE-TOKEN") == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + client, err := NewGitlabClient(context.Background(), server.URL, "private") + if err != nil { + t.Fatal(err) + } + if client.Header.Get("PRIVATE-TOKEN") != "private" { + t.Fatalf("header = %v", client.Header) + } +} diff --git a/backend/plugins/aisdlc/tasks/link.go b/backend/plugins/aisdlc/tasks/link.go new file mode 100644 index 00000000000..aca165e6160 --- /dev/null +++ b/backend/plugins/aisdlc/tasks/link.go @@ -0,0 +1,73 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aisdlc/models" + "github.com/apache/devlake/plugins/aisdlc/tasks/parse" +) + +var LinkWorkItemsMeta = plugin.SubTaskMeta{ + Name: "linkWorkItems", + EntryPoint: LinkWorkItems, + EnabledByDefault: true, + Description: "Match work-item keys to existing issues when the match is unique", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&ExtractObservationsMeta}, +} + +type issueRow struct { + ID string `gorm:"column:id"` + IssueKey string `gorm:"column:issue_key"` +} + +// LinkWorkItems sets domain_issue_id only when issues.issue_key matches one row. +// It does not insert issues. +func LinkWorkItems(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + data := taskCtx.GetData().(*AisdlcTaskData) + var items []models.AisdlcWorkItem + if err := db.All(&items, dal.Where("connection_id = ?", data.Connection.ID)); err != nil { + return err + } + for i := range items { + item := &items[i] + if item.SourceSystem != parse.JiraSystem || item.ItemKey == "" { + continue + } + var rows []issueRow + if err := db.All(&rows, dal.From("issues"), dal.Where("issue_key = ?", item.ItemKey)); err != nil { + return err + } + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + draft := parse.WorkItem{ID: item.Id, MatchStatus: item.MatchStatus, DomainIssueID: item.DomainIssueId} + parse.ApplyIssueMatch(&draft, ids) + item.MatchStatus = draft.MatchStatus + item.DomainIssueId = draft.DomainIssueID + if err := db.Update(item); err != nil { + return err + } + } + return nil +} diff --git a/backend/plugins/aisdlc/tasks/parse/id.go b/backend/plugins/aisdlc/tasks/parse/id.go new file mode 100644 index 00000000000..2412c5097ae --- /dev/null +++ b/backend/plugins/aisdlc/tasks/parse/id.go @@ -0,0 +1,57 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package parse + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +const ( + ParserRFE = "rfe_assess" + ParserStrat = "strat_pipeline" + + SchemaVersion = "0.1" + MappingVersion = "draft-0.1" + AdapterVersion = "0.1" + + KindAssessment = "assessment" + KindProcessing = "processing_result" + + AttemptUnknown = "unknown" + MatchUnresolved = "unresolved" + MatchUnique = "confirmed_unique" + MatchAmbiguous = "ambiguous" + JiraSystem = "jira" + JiraInstance = "redhat.atlassian.net" + GenericRunID = "artifacts" + IdStatusKeyOnly = "key_only" + IdStatusUnconfirmed = "unconfirmed" +) + +// ID is the stable identity of a collected record. +func ID(parts ...string) string { + sum := sha256.Sum256([]byte(strings.Join(parts, "\n"))) + return hex.EncodeToString(sum[:]) +} + +// ObservationID identifies one converted record. The blob sha is the file revision. +func ObservationID(sourceID, blobID, path, selector, kind string) string { + return ID(sourceID, blobID, path, selector, kind) +} diff --git a/backend/plugins/aisdlc/tasks/parse/parse.go b/backend/plugins/aisdlc/tasks/parse/parse.go new file mode 100644 index 00000000000..4f922b964af --- /dev/null +++ b/backend/plugins/aisdlc/tasks/parse/parse.go @@ -0,0 +1,481 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package parse + +import ( + "encoding/json" + "fmt" + "net/url" + "path" + "regexp" + "strconv" + "strings" +) + +var ( + jiraKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9]+-\d+$`) + rfeFilePattern = regexp.MustCompile(`(RHAIRFE-\d+)\.result\.md$`) + totalPattern = regexp.MustCompile(`(?m)\|\s*\*{0,2}Total\*{0,2}\s*\|\s*\*{0,2}(\d+)\s*/\s*(\d+)\*{0,2}\s*\|\s*\*{0,2}([A-Za-z]+)\*{0,2}`) + titlePattern = regexp.MustCompile(`(?m)^TITLE:\s*(.+?)\s*$`) + rowPattern = regexp.MustCompile(`(?m)\|\s*([^|\n]+?)\s*\|\s*(\d+)\s*/\s*(\d+)\s*\|`) +) + +type pipelineFile struct { + GeneratedAt string `json:"generated_at"` + Timestamp string `json:"timestamp"` + RunID string `json:"run_id"` + DryRun bool `json:"dry_run"` + Strategies []strategyEntry `json:"strategies"` +} + +type strategyEntry struct { + StratID string `json:"strat_id"` + Title string `json:"title"` + SourceRFE string `json:"source_rfe"` + Status string `json:"status"` + Recommendation string `json:"recommendation"` + Scores *scoreBlock `json:"scores"` +} + +type scoreBlock struct { + Total *float64 `json:"total"` + Feasibility *float64 `json:"feasibility"` + Testability *float64 `json:"testability"` + Scope *float64 `json:"scope"` + Architecture *float64 `json:"architecture"` +} + +type built struct { + observations []Observation + workItems []WorkItem + execution *Execution +} + +// Parse converts collected files into observations. Re-parsing the same blobs +// yields the same observation ids. Each observation keeps file.CollectedAt. +func Parse(cfg Config, files []File) DraftSet { + set := DraftSet{} + items := map[string]WorkItem{} + execs := map[string]Execution{} + for _, file := range files { + if !Matches(cfg.Parser, file.Path) { + continue + } + var result built + var err error + switch cfg.Parser { + case ParserRFE: + result, err = parseRFE(cfg, file) + case ParserStrat: + if isPipelineData(file.Path) { + result, err = parsePipeline(cfg, file) + } else { + result, err = parseStratTask(cfg, file) + } + default: + err = fmt.Errorf("unknown parser %q", cfg.Parser) + } + if err != nil { + set.FileErrors = append(set.FileErrors, file.Path+": "+err.Error()) + continue + } + set.Observations = append(set.Observations, result.observations...) + for _, item := range result.workItems { + items[item.ID] = item + } + if result.execution != nil { + execs[result.execution.ID] = *result.execution + } + } + for _, item := range items { + set.WorkItems = append(set.WorkItems, item) + } + for _, exec := range execs { + set.Executions = append(set.Executions, exec) + } + return set +} + +func parseRFE(cfg Config, file File) (built, error) { + match := rfeFilePattern.FindStringSubmatch(file.Path) + if match == nil { + return built{}, fmt.Errorf("result file name has no RFE key") + } + key := match[1] + subject := jiraWorkItem(key, "rfe") + selector := "result" + obsID := ObservationID(cfg.SourceID, file.BlobID, file.Path, selector, KindAssessment) + total, scale, verdict := parseTotal(file.Body) + ext := map[string]any{"title": parseTitle(file.Body), "criteria": parseCriteria(file.Body)} + rawExt, err := json.Marshal(ext) + if err != nil { + return built{}, err + } + obs := baseObservation(cfg, file, obsID, KindAssessment, subject, "", "git-rfe-v0.1", "file_assessment", selector) + obs.Extensions = string(rawExt) + if total != nil { + obs.AssessmentTotal = total + obs.AssessmentScale = scale + obs.RawVerdict = verdict + } + obs.Evidence = []Evidence{evidence(cfg, file, obsID, selector)} + obs.Unknowns = standardUnknowns(obsID, false) + if total == nil { + obs.Unknowns = append(obs.Unknowns, unknown(obsID, "assessments[0].total", "the result file has no total score", "a Total row in the result table")) + } + if folder := batchFolderName(file.Path); folder != "" { + entry := ClassifyTime(folder) + entry.Type = "batch_folder" + entry.AppliesTo = "batch" + obs.Times = []TimeEntry{entry} + } + return built{observations: []Observation{obs}, workItems: []WorkItem{subject}}, nil +} + +func parsePipeline(cfg Config, file File) (built, error) { + var doc pipelineFile + if err := json.Unmarshal([]byte(file.Body), &doc); err != nil { + return built{}, fmt.Errorf("pipeline-data.json: %w", err) + } + result := built{} + var executionID string + if doc.Timestamp != "" || doc.GeneratedAt != "" { + folder := batchFolderName(file.Path) + runID := folder + if runID == "" { + runID = doc.Timestamp + } + exec := Execution{ + ID: ID(cfg.SourceID, "execution", BatchDir(file.Path)), + SourceID: cfg.SourceID, + StationID: cfg.StationID, + SourceRunID: runID, + RunIDType: "batch_folder", + PipelineIDStatus: "unknown", + BatchPath: BatchDir(file.Path), + } + result.execution = &exec + executionID = exec.ID + } + seen := map[string]WorkItem{} + for _, strategy := range doc.Strategies { + if strategy.StratID == "" { + result.observations = append(result.observations, Observation{}) + continue + } + subject := jiraWorkItem(strategy.StratID, "strategy") + seen[subject.ID] = subject + selector := "/strategies/" + strategy.StratID + obsID := ObservationID(cfg.SourceID, file.BlobID, file.Path, selector, KindProcessing) + rawResult := strategy.Recommendation + if rawResult == "" { + rawResult = strategy.Status + } + ext := map[string]any{ + "title": strategy.Title, + "status": strategy.Status, + "recommendation": strategy.Recommendation, + "reported_run_id": doc.RunID, + "dry_run": doc.DryRun, + } + if strategy.Scores != nil { + ext["criteria"] = map[string]any{ + "feasibility": strategy.Scores.Feasibility, + "testability": strategy.Scores.Testability, + "scope": strategy.Scores.Scope, + "architecture": strategy.Scores.Architecture, + } + } + rawExt, err := json.Marshal(ext) + if err != nil { + return built{}, err + } + obs := baseObservation(cfg, file, obsID, KindProcessing, subject, executionID, "git-strat-v0.1", "batch_membership_known", selector) + obs.RawResult = rawResult + obs.Extensions = string(rawExt) + obs.Evidence = []Evidence{evidence(cfg, file, obsID, selector)} + obs.Unknowns = standardUnknowns(obsID, executionID != "") + if strategy.Recommendation == "" { + obs.Unknowns = append(obs.Unknowns, unknown(obsID, "processing_result.recommendation", "the strategy entry has no recommendation", "a recommendation field on the strategy")) + } + if doc.RunID == "" || doc.RunID == GenericRunID { + obs.Unknowns = append(obs.Unknowns, unknown(obsID, "run_ref.reported_run_id", "source run_id does not identify this batch", "a producer pipeline id")) + } + if strategy.Scores != nil && strategy.Scores.Total != nil { + total := *strategy.Scores.Total + obs.AssessmentTotal = &total + } + if strategy.SourceRFE != "" { + linked := jiraWorkItem(strategy.SourceRFE, "rfe") + seen[linked.ID] = linked + obs.Links = []Link{{ + ID: ID(obsID, "link", linked.ID), + ObservationID: obsID, + Role: "input", + Relation: "source_work_item", + WorkItemID: linked.ID, + EvidenceStatus: "recorded", + }} + } + stamp := doc.Timestamp + if stamp == "" { + stamp = doc.GeneratedAt + } + if stamp != "" { + entry := ClassifyTime(stamp) + entry.Type = "source_generated" + entry.AppliesTo = "batch" + obs.Times = append(obs.Times, entry) + } + result.observations = append(result.observations, obs) + } + // Drop the placeholder used when strat_id is empty. + kept := result.observations[:0] + for _, obs := range result.observations { + if obs.ID != "" { + kept = append(kept, obs) + } + } + result.observations = kept + for _, item := range seen { + result.workItems = append(result.workItems, item) + } + return result, nil +} + +func parseStratTask(cfg Config, file File) (built, error) { + fields := frontMatter(file.Body) + if len(fields) == 0 { + return built{}, fmt.Errorf("strat task has no front matter") + } + stratID := fields["strat_id"] + if stratID == "" { + return built{}, fmt.Errorf("strat task has no strat_id") + } + subject := workItemFromTask(stratID, fields["jira_key"]) + selector := "frontmatter" + obsID := ObservationID(cfg.SourceID, file.BlobID, file.Path, selector, KindProcessing) + ext, err := json.Marshal(map[string]any{ + "title": fields["title"], + "status": fields["status"], + "strat_id": stratID, + "jira_key": fields["jira_key"], + }) + if err != nil { + return built{}, err + } + obs := baseObservation(cfg, file, obsID, KindProcessing, subject, "", "git-strat-v0.1", "front_matter_only", selector) + obs.RawResult = fields["status"] + obs.Extensions = string(ext) + obs.Evidence = []Evidence{evidence(cfg, file, obsID, selector)} + obs.Unknowns = standardUnknowns(obsID, false) + if fields["jira_key"] == "" { + obs.Unknowns = append(obs.Unknowns, unknown(obsID, "subject_ref", "front matter jira_key is null", "a Jira key on the strategy task")) + } + items := []WorkItem{subject} + if fields["source_rfe"] != "" { + linked := jiraWorkItem(fields["source_rfe"], "rfe") + items = append(items, linked) + obs.Links = []Link{{ + ID: ID(obsID, "link", linked.ID), + ObservationID: obsID, + Role: "input", + Relation: "source_work_item", + WorkItemID: linked.ID, + EvidenceStatus: "recorded", + }} + } + return built{observations: []Observation{obs}, workItems: items}, nil +} + +func baseObservation(cfg Config, file File, id, kind string, subject WorkItem, executionID, adapter, completeness, selector string) Observation { + return Observation{ + ID: id, + Kind: kind, + WorkItemID: subject.ID, + SubjectKey: subject.ItemKey, + ExecutionID: executionID, + Path: file.Path, + BlobID: file.BlobID, + CommitSHA: file.CommitSHA, + FieldSelector: selector, + AdapterID: adapter, + Completeness: completeness, + CollectedAt: file.CollectedAt, + AttemptStatus: AttemptUnknown, + } +} + +func jiraWorkItem(key, kind string) WorkItem { + return WorkItem{ + ID: ID(JiraSystem, JiraInstance, kind, key), + SourceSystem: JiraSystem, + SourceInstance: JiraInstance, + EntityKind: kind, + ItemKey: key, + IdStatus: IdStatusKeyOnly, + MatchStatus: MatchUnresolved, + } +} + +func workItemFromTask(stratID, jiraKey string) WorkItem { + if jiraKeyPattern.MatchString(jiraKey) { + return jiraWorkItem(jiraKey, "strategy") + } + return WorkItem{ + ID: ID("station", "strat-task", stratID), + SourceSystem: "station", + EntityKind: "strategy", + ItemKey: stratID, + IdStatus: IdStatusUnconfirmed, + MatchStatus: MatchUnresolved, + } +} + +func evidence(cfg Config, file File, obsID, selector string) Evidence { + return Evidence{ + ID: ID(obsID, "evidence"), + ObservationID: obsID, + SourceID: cfg.SourceID, + URI: blobURI(cfg, file), + Revision: file.BlobID, + FieldSelector: selector, + } +} + +func blobURI(cfg Config, file File) string { + base := strings.TrimRight(cfg.WebBase, "/") + if base == "" { + base = "https://gitlab.com" + } + ref := file.CommitSHA + if ref == "" { + ref = file.BlobID + } + return base + "/" + cfg.ProjectPath + "/-/blob/" + url.PathEscape(ref) + "/" + pathEscape(file.Path) +} + +func pathEscape(p string) string { + parts := strings.Split(p, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return path.Join(parts...) +} + +func standardUnknowns(obsID string, hasExecution bool) []Unknown { + unknowns := []Unknown{ + unknown(obsID, "attempt_ref", "no per-item execution id was found", "a producer item-level execution id"), + unknown(obsID, "run_ref.pipeline_id", "GitLab pipeline id is not in the artifact", "a GitLab CI pipeline or job id"), + unknown(obsID, "inputs[0].consumed_revision", "the exact input revision consumed by this run is not recorded", "an input manifest or snapshot hash"), + } + if !hasExecution { + unknowns = append(unknowns, unknown(obsID, "run_ref", "the artifact does not identify a producer run", "a run id recorded by the producer")) + } + return unknowns +} + +func unknown(obsID, field, reason, needed string) Unknown { + return Unknown{ + ID: ID(obsID, "unknown", field), + ObservationID: obsID, + Field: field, + Reason: reason, + EvidenceNeeded: needed, + } +} + +func parseTitle(body string) string { + match := titlePattern.FindStringSubmatch(body) + if match == nil { + return "" + } + return match[1] +} + +func parseTotal(body string) (total *float64, scale *float64, verdict string) { + match := totalPattern.FindStringSubmatch(body) + if match == nil { + return nil, nil, "" + } + totalVal, err1 := strconv.ParseFloat(match[1], 64) + scaleVal, err2 := strconv.ParseFloat(match[2], 64) + if err1 != nil || err2 != nil { + return nil, nil, "" + } + return &totalVal, &scaleVal, match[3] +} + +func parseCriteria(body string) []map[string]any { + rows := rowPattern.FindAllStringSubmatch(body, -1) + criteria := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + name := strings.Trim(row[1], "* ") + if strings.EqualFold(name, "total") || strings.EqualFold(name, "criterion") { + continue + } + score, err1 := strconv.Atoi(row[2]) + scale, err2 := strconv.Atoi(row[3]) + if err1 != nil || err2 != nil { + continue + } + criteria = append(criteria, map[string]any{"name": name, "score": score, "scale": scale}) + } + return criteria +} + +func frontMatter(body string) map[string]string { + lines := strings.Split(body, "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return nil + } + fields := map[string]string{} + var key string + for _, line := range lines[1:] { + if strings.TrimSpace(line) == "---" { + break + } + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + if key != "" { + fields[key] = strings.TrimSpace(fields[key] + " " + strings.TrimSpace(line)) + } + continue + } + name, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + key = strings.TrimSpace(name) + fields[key] = strings.Trim(strings.TrimSpace(value), `"`) + } + for name, value := range fields { + if value == "null" || value == "~" { + fields[name] = "" + } + } + return fields +} + +func batchFolderName(filePath string) string { + dir := BatchDir(filePath) + name := path.Base(dir) + if folderPattern.MatchString(name) { + return name + } + return "" +} diff --git a/backend/plugins/aisdlc/tasks/parse/parse_test.go b/backend/plugins/aisdlc/tasks/parse/parse_test.go new file mode 100644 index 00000000000..5007019917e --- /dev/null +++ b/backend/plugins/aisdlc/tasks/parse/parse_test.go @@ -0,0 +1,266 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package parse + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestClassifyTimeKeepsDateOnly(t *testing.T) { + entry := ClassifyTime("2026-07-21") + if entry.Precision != "date" || entry.TimezoneStatus != "unspecified" || entry.Normalized != "" { + t.Fatalf("date-only time = %+v", entry) + } + if entry.Normalized == "2026-07-21T00:00:00Z" { + t.Fatal("date-only value was converted to midnight UTC") + } +} + +func TestClassifyTimeKeepsTimezoneFree(t *testing.T) { + entry := ClassifyTime("2026-07-25T01:55:16.703430") + if entry.Precision != "second" || entry.Normalized != "" || entry.TimezoneStatus != "unspecified" { + t.Fatalf("timezone-free time = %+v", entry) + } + folder := ClassifyTime("20260719-041307") + if folder.Precision != "second" || folder.Normalized != "" { + t.Fatalf("folder time = %+v", folder) + } + zulu := ClassifyTime("2026-07-21T15:37:59Z") + if zulu.Normalized != "2026-07-21T15:37:59Z" || zulu.TimezoneStatus != "utc" { + t.Fatalf("zulu time = %+v", zulu) + } +} + +func TestSelectPaths(t *testing.T) { + entries := []TreeEntry{ + {Path: "RHAIRFE/current/RHAIRFE-1.result.md", Type: "blob"}, + {Path: "RHAIRFE/20260719-041307/scores.csv", Type: "blob"}, + {Path: "RHAIRFE/20260719-041307/RHAIRFE-2402.result.md", Type: "blob"}, + {Path: "RHAIRFE/20260719-041307", Type: "tree"}, + {Path: "RHAISTRAT/current/pipeline-data.json", Type: "blob", Mode: "120000"}, + {Path: "RHAISTRAT/20260725-015518/pipeline-data.json", Type: "blob"}, + {Path: "RHAISTRAT/20260725-015518/strat-tasks/RHAISTRAT-1.md", Type: "blob"}, + {Path: "RHAISTRAT/20260414-180837/strat-tasks/STRAT-1397.md", Type: "blob"}, + {Path: "RHAISTRAT/summary.json", Type: "blob"}, + } + rfe := SelectPaths(ParserRFE, "", entries) + if len(rfe) != 1 || rfe[0].Path != "RHAIRFE/20260719-041307/RHAIRFE-2402.result.md" { + t.Fatalf("rfe paths = %+v", rfe) + } + strat := SelectPaths(ParserStrat, "RHAISTRAT", entries) + if len(strat) != 2 { + t.Fatalf("strat paths = %+v", strat) + } + for _, entry := range strat { + if entry.Path == "RHAISTRAT/20260725-015518/strat-tasks/RHAISTRAT-1.md" { + t.Fatal("strat-tasks kept beside pipeline-data.json") + } + } +} + +func TestParseRFEFixture(t *testing.T) { + file := readFixture(t, "rfe/RHAIRFE/20260719-041307/RHAIRFE-2402.result.md") + file.BlobID = "blob-1" + file.CommitSHA = "commit-1" + cfg := testConfig(ParserRFE) + firstAt := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC) + secondAt := firstAt.Add(time.Hour) + file.CollectedAt = firstAt + first := Parse(cfg, []File{file}) + file.CollectedAt = secondAt + second := Parse(cfg, []File{file}) + if len(first.FileErrors) != 0 { + t.Fatal(first.FileErrors) + } + if len(first.Observations) != 1 || len(first.Executions) != 0 { + t.Fatalf("observations=%d executions=%d", len(first.Observations), len(first.Executions)) + } + obs := first.Observations[0] + if obs.Kind != KindAssessment || obs.SubjectKey != "RHAIRFE-2402" { + t.Fatalf("observation = %+v", obs) + } + if obs.AssessmentTotal == nil || *obs.AssessmentTotal != 10 || obs.AssessmentScale == nil || *obs.AssessmentScale != 10 || obs.RawVerdict != "PASS" { + t.Fatalf("assessment = total %v scale %v verdict %s", obs.AssessmentTotal, obs.AssessmentScale, obs.RawVerdict) + } + if obs.AttemptStatus != AttemptUnknown || !hasUnknown(obs, "attempt_ref") || !hasUnknown(obs, "run_ref") { + t.Fatal("expected attempt and run to stay unknown") + } + if len(obs.Times) != 1 || obs.Times[0].Normalized != "" || obs.Times[0].Precision != "second" { + t.Fatalf("times = %+v", obs.Times) + } + if len(obs.Evidence) != 1 || obs.Evidence[0].Revision != "blob-1" { + t.Fatal("missing evidence") + } + store := NewStore() + store.Apply(first) + store.Apply(second) + if len(store.Observations) != 1 { + t.Fatalf("second parse added a row: %d", len(store.Observations)) + } + if store.Observations[obs.ID].CollectedAt != secondAt { + t.Fatal("collected_at was not updated") + } + if second.Observations[0].ID != obs.ID { + t.Fatal("observation id changed for the same blob") + } +} + +func TestParseStrategyFixture(t *testing.T) { + file := readFixture(t, "strat/RHAISTRAT/20260725-015518/pipeline-data.json") + file.BlobID = "blob-strat" + file.CommitSHA = "commit-strat" + cfg := testConfig(ParserStrat) + set := Parse(cfg, []File{file}) + if len(set.FileErrors) != 0 { + t.Fatal(set.FileErrors) + } + if len(set.Observations) != 2 { + t.Fatalf("observations = %d", len(set.Observations)) + } + if len(set.Executions) != 1 || set.Executions[0].SourceRunID != "20260725-015518" || set.Executions[0].SourceRunID == "artifacts" { + t.Fatalf("execution = %+v", set.Executions) + } + var withScore, without *Observation + for i := range set.Observations { + obs := &set.Observations[i] + if obs.ExecutionID == "" || obs.AttemptStatus != AttemptUnknown || !hasUnknown(*obs, "run_ref.pipeline_id") { + t.Fatalf("observation missing execution or unknowns: %+v", obs) + } + switch obs.SubjectKey { + case "RHAISTRAT-2371": + withScore = obs + case "RHAISTRAT-2370": + without = obs + } + } + if withScore == nil || without == nil { + t.Fatal("missing strategy observations") + } + if withScore.RawResult != "approve" || withScore.AssessmentTotal == nil || *withScore.AssessmentTotal != 6 { + t.Fatalf("scored strategy = %+v", withScore) + } + if len(withScore.Links) != 1 || withScore.Links[0].Relation != "source_work_item" { + t.Fatalf("links = %+v", withScore.Links) + } + linked := false + for _, item := range set.WorkItems { + if item.ItemKey == "RHAIRFE-2892" && item.EntityKind == "rfe" { + linked = item.ID == withScore.Links[0].WorkItemID + } + } + if !linked { + t.Fatal("source_rfe was not stored as a work item link") + } + if without.AssessmentTotal != nil || without.RawResult != "Draft" { + t.Fatalf("unscored strategy = %+v", without) + } + if withScore.Times[0].Normalized != "" { + t.Fatalf("strategy timestamp was normalized: %+v", withScore.Times[0]) + } +} + +func TestParseStratTaskWithoutRun(t *testing.T) { + file := readFixture(t, "strat/RHAISTRAT/20260414-180837/strat-tasks/STRAT-1397.md") + file.BlobID = "blob-task" + cfg := testConfig(ParserStrat) + set := Parse(cfg, []File{file}) + if len(set.FileErrors) != 0 || len(set.Observations) != 1 || len(set.Executions) != 0 { + t.Fatalf("errors=%v observations=%d executions=%d", set.FileErrors, len(set.Observations), len(set.Executions)) + } + obs := set.Observations[0] + if obs.SubjectKey != "STRAT-1397" || !hasUnknown(obs, "subject_ref") || !hasUnknown(obs, "run_ref") { + t.Fatalf("task observation = %+v unknowns=%+v", obs, obs.Unknowns) + } + if len(obs.Links) != 1 || obs.Links[0].Relation != "source_work_item" { + t.Fatal("missing source_rfe link") + } +} + +func TestApplyIssueMatch(t *testing.T) { + tests := []struct { + name string + ids []string + status string + domain string + }{ + {"none", nil, MatchUnresolved, ""}, + {"one", []string{"jira:JiraIssue:1:10"}, MatchUnique, "jira:JiraIssue:1:10"}, + {"many", []string{"a", "b"}, MatchAmbiguous, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item := WorkItem{ID: "item"} + ApplyIssueMatch(&item, tt.ids) + if item.MatchStatus != tt.status || item.DomainIssueID != tt.domain { + t.Fatalf("match = %s %s", item.MatchStatus, item.DomainIssueID) + } + }) + } +} + +func testConfig(parser string) Config { + return Config{ + TenantID: "rhai", + SourceID: "fixture", + StationID: "station", + DisplayName: "Station", + Parser: parser, + ProjectPath: "redhat/rhel-ai/agentic-ci/fixture", + WebBase: "https://gitlab.com", + } +} + +func readFixture(t *testing.T, rel string) File { + t.Helper() + body, err := os.ReadFile(filepath.Join("..", "testdata", rel)) + if err != nil { + t.Fatal(err) + } + return File{Path: relPath(rel), Body: string(body)} +} + +func relPath(rel string) string { + // Fixture directories include the parser root. The GitLab path starts at RHAIRFE or RHAISTRAT. + for _, root := range []string{"RHAIRFE/", "RHAISTRAT/"} { + if i := indexOf(rel, root); i >= 0 { + return rel[i:] + } + } + return rel +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +func hasUnknown(obs Observation, field string) bool { + for _, unknown := range obs.Unknowns { + if unknown.Field == field { + return true + } + } + return false +} diff --git a/backend/plugins/aisdlc/tasks/parse/select.go b/backend/plugins/aisdlc/tasks/parse/select.go new file mode 100644 index 00000000000..4f14edbc7f3 --- /dev/null +++ b/backend/plugins/aisdlc/tasks/parse/select.go @@ -0,0 +1,118 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package parse + +import "strings" + +// TreeEntry is one GitLab repository tree item. +type TreeEntry struct { + ID string + Path string + Type string + Mode string +} + +func hasSegment(path, segment string) bool { + for _, part := range strings.Split(path, "/") { + if part == segment { + return true + } + } + return false +} + +func underPrefix(path, prefix string) bool { + prefix = strings.Trim(prefix, "/") + if prefix == "" { + return true + } + return path == prefix || strings.HasPrefix(path, prefix+"/") +} + +func skippedPath(path string) bool { + return hasSegment(path, "current") +} + +func isPipelineData(path string) bool { + return strings.HasSuffix(path, "/pipeline-data.json") +} + +func isStratTask(path string) bool { + return strings.Contains(path, "/strat-tasks/") && strings.HasSuffix(path, ".md") +} + +// Matches reports whether this parser owns the path. +func Matches(parser, path string) bool { + if path == "" || skippedPath(path) { + return false + } + switch parser { + case ParserRFE: + return strings.HasSuffix(path, ".result.md") + case ParserStrat: + return isPipelineData(path) || isStratTask(path) + default: + return false + } +} + +// BatchDir is the timestamped directory that contains a strategy artifact. +func BatchDir(path string) string { + if i := strings.Index(path, "/strat-tasks/"); i >= 0 { + return path[:i] + } + if i := strings.LastIndex(path, "/"); i >= 0 { + return path[:i] + } + return "" +} + +// SelectPaths keeps parser-owned blobs under prefix and drops strat-tasks +// in a batch that also has pipeline-data.json. +func SelectPaths(parser, prefix string, entries []TreeEntry) []TreeEntry { + selected := make([]TreeEntry, 0, len(entries)) + for _, entry := range entries { + if entry.Type != "" && entry.Type != "blob" { + continue + } + if entry.Mode == "120000" { + continue + } + if !underPrefix(entry.Path, prefix) || !Matches(parser, entry.Path) { + continue + } + selected = append(selected, entry) + } + if parser != ParserStrat { + return selected + } + batches := map[string]bool{} + for _, entry := range selected { + if isPipelineData(entry.Path) { + batches[BatchDir(entry.Path)] = true + } + } + filtered := make([]TreeEntry, 0, len(selected)) + for _, entry := range selected { + if isStratTask(entry.Path) && batches[BatchDir(entry.Path)] { + continue + } + filtered = append(filtered, entry) + } + return filtered +} diff --git a/backend/plugins/aisdlc/tasks/parse/store.go b/backend/plugins/aisdlc/tasks/parse/store.go new file mode 100644 index 00000000000..db44cf73c11 --- /dev/null +++ b/backend/plugins/aisdlc/tasks/parse/store.go @@ -0,0 +1,68 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package parse + +// Store is the in-memory shape of an idempotent upsert. +type Store struct { + Observations map[string]Observation + WorkItems map[string]WorkItem + Executions map[string]Execution +} + +// NewStore returns an empty store. +func NewStore() *Store { + return &Store{ + Observations: map[string]Observation{}, + WorkItems: map[string]WorkItem{}, + Executions: map[string]Execution{}, + } +} + +// Apply upserts a draft set. A second apply of the same identities replaces +// rows and does not add observations. +func (s *Store) Apply(set DraftSet) { + for _, item := range set.WorkItems { + if existing, ok := s.WorkItems[item.ID]; ok && existing.MatchStatus == MatchUnique { + item.MatchStatus = existing.MatchStatus + item.DomainIssueID = existing.DomainIssueID + } + s.WorkItems[item.ID] = item + } + for _, exec := range set.Executions { + s.Executions[exec.ID] = exec + } + for _, obs := range set.Observations { + s.Observations[obs.ID] = obs + } +} + +// ApplyIssueMatch sets the match status from how many existing issue rows share the key. +// It never creates an issue. Zero and many matches stay unresolved. +func ApplyIssueMatch(item *WorkItem, issueIDs []string) { + switch len(issueIDs) { + case 1: + item.MatchStatus = MatchUnique + item.DomainIssueID = issueIDs[0] + case 0: + item.MatchStatus = MatchUnresolved + item.DomainIssueID = "" + default: + item.MatchStatus = MatchAmbiguous + item.DomainIssueID = "" + } +} diff --git a/backend/plugins/aisdlc/tasks/parse/time.go b/backend/plugins/aisdlc/tasks/parse/time.go new file mode 100644 index 00000000000..1bb20a10d37 --- /dev/null +++ b/backend/plugins/aisdlc/tasks/parse/time.go @@ -0,0 +1,69 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package parse + +import ( + "regexp" + "strings" +) + +var ( + dateOnlyPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + folderPattern = regexp.MustCompile(`^\d{8}-\d{6}$`) + zuluPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$`) + offsetPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:?\d{2}$`) + bareTimePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$`) +) + +// TimeEntry is one source timestamp with its precision left intact. +type TimeEntry struct { + Type string + Raw string + Normalized string + Precision string + TimezoneStatus string + AppliesTo string +} + +// ClassifyTime preserves date-only and timezone-free values. +// A date-only value is not converted to midnight UTC. +func ClassifyTime(raw string) TimeEntry { + raw = strings.TrimSpace(raw) + entry := TimeEntry{Raw: raw, TimezoneStatus: "unspecified"} + switch { + case raw == "": + entry.Precision = "unknown" + case dateOnlyPattern.MatchString(raw): + entry.Precision = "date" + case folderPattern.MatchString(raw): + entry.Precision = "second" + case zuluPattern.MatchString(raw): + entry.Precision = "second" + entry.TimezoneStatus = "utc" + entry.Normalized = raw + case offsetPattern.MatchString(raw): + entry.Precision = "second" + entry.TimezoneStatus = "known" + entry.Normalized = raw + case bareTimePattern.MatchString(raw): + entry.Precision = "second" + default: + entry.Precision = "unknown" + } + return entry +} diff --git a/backend/plugins/aisdlc/tasks/parse/types.go b/backend/plugins/aisdlc/tasks/parse/types.go new file mode 100644 index 00000000000..9b4f015e367 --- /dev/null +++ b/backend/plugins/aisdlc/tasks/parse/types.go @@ -0,0 +1,127 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package parse + +import "time" + +// Config is the station registration supplied by scope configuration. +type Config struct { + TenantID string + SourceID string + StationID string + DisplayName string + Parser string + ProjectPath string + WebBase string +} + +// File is one collected artifact. +// CollectedAt is when collect last saw this blob in the tree, not when it was parsed. +type File struct { + Path string + BlobID string + CommitSHA string + Body string + CollectedAt time.Time +} + +// WorkItem is a source-qualified reference. +type WorkItem struct { + ID string + SourceSystem string + SourceInstance string + EntityKind string + ItemKey string + IdStatus string + MatchStatus string + DomainIssueID string +} + +// Execution is a batch the source file identified. +type Execution struct { + ID string + SourceID string + StationID string + SourceRunID string + RunIDType string + PipelineIDStatus string + BatchPath string +} + +// Evidence points at the blob entry behind a claim. +type Evidence struct { + ID string + ObservationID string + SourceID string + URI string + Revision string + FieldSelector string +} + +// Link is an evidenced item relationship. It is not a causal handoff. +type Link struct { + ID string + ObservationID string + Role string + Relation string + WorkItemID string + EvidenceStatus string +} + +// Unknown explains a missing field. +type Unknown struct { + ID string + ObservationID string + Field string + Reason string + EvidenceNeeded string +} + +// Observation is one converted record. +type Observation struct { + ID string + Kind string + WorkItemID string + SubjectKey string + ExecutionID string + RawResult string + AssessmentTotal *float64 + AssessmentScale *float64 + RawVerdict string + Path string + BlobID string + CommitSHA string + FieldSelector string + AdapterID string + Completeness string + Extensions string + CollectedAt time.Time + AttemptStatus string + Evidence []Evidence + Links []Link + Unknowns []Unknown + Times []TimeEntry +} + +// DraftSet is the parser output for one collection. +type DraftSet struct { + Observations []Observation + WorkItems []WorkItem + Executions []Execution + FileErrors []string +} diff --git a/backend/plugins/aisdlc/tasks/task_data.go b/backend/plugins/aisdlc/tasks/task_data.go new file mode 100644 index 00000000000..de5ab8ebcc6 --- /dev/null +++ b/backend/plugins/aisdlc/tasks/task_data.go @@ -0,0 +1,59 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/aisdlc/models" +) + +// AisdlcOptions identifies one scope inside a pipeline. +type AisdlcOptions struct { + ConnectionId uint64 `json:"connectionId"` + FullName string `json:"fullName"` + ScopeConfig *models.AisdlcScopeConfig `json:"scopeConfig"` +} + +// AisdlcTaskData is the data shared by subtasks. +type AisdlcTaskData struct { + Options *AisdlcOptions + Connection *models.AisdlcConnection +} + +// GitlabConnLookup reads a GitLab connection without importing the GitLab plugin. +type GitlabConnLookup struct { + ID uint64 `gorm:"primaryKey;column:id"` + Endpoint string `gorm:"column:endpoint"` + Token string `gorm:"column:token;serializer:encdec"` +} + +func (GitlabConnLookup) TableName() string { return "_tool_gitlab_connections" } + +// scopeRawParams is the key DevLake uses to delete one scope's tool rows. +func scopeRawParams(connectionID uint64, fullName string) string { + scope := models.AisdlcScope{FullName: fullName} + scope.ConnectionId = connectionID + return plugin.MarshalScopeParams(scope.ScopeParams()) +} + +func scopeStamp(connectionID uint64, fullName string) common.NoPKModel { + return common.NoPKModel{ + RawDataOrigin: common.RawDataOrigin{RawDataParams: scopeRawParams(connectionID, fullName)}, + } +} diff --git a/backend/plugins/aisdlc/tasks/testdata/rfe/RHAIRFE/20260719-041307/RHAIRFE-2402.result.md b/backend/plugins/aisdlc/tasks/testdata/rfe/RHAIRFE/20260719-041307/RHAIRFE-2402.result.md new file mode 100644 index 00000000000..39c85785222 --- /dev/null +++ b/backend/plugins/aisdlc/tasks/testdata/rfe/RHAIRFE/20260719-041307/RHAIRFE-2402.result.md @@ -0,0 +1,16 @@ +TITLE: Auto ML Test Data Support + +| Criterion | Score | Notes | +|-----------|-------|-------| +| WHAT | 2/2 | Clearly describes the need for advanced Auto ML users to provide their own test datasets instead of relying on automated train/test splits. The problem statement explains the gap (auto-split may not match production scenarios, forcing users to export and test outside the workflow). Acceptance criteria are specific and observable: upload test data, train without auto-split, evaluate with user data, show which dataset was used in UI. | +| WHY | 2/2 | Names two specific customer accounts: Verizon and Credit Mutuel. Also cites competitive parity with watsonx.ai Auto ML capabilities and references "advanced enterprise customers" requiring production validation workflows. Named customer accounts satisfy the threshold for a score of 2. | +| Open to HOW | 2/2 | All acceptance criteria describe user-facing outcomes and observable behaviors. No internal architecture is prescribed -- no database schemas, no specific implementation approaches, no mandated technologies beyond the existing Auto ML platform vocabulary. Engineering retains full freedom to determine how test data upload, storage, and evaluation are implemented. | +| Not a task | 2/2 | This is a clear business need: enabling advanced users to validate models against production-representative data before deployment to build confidence and reduce deployment risk. It is not a housekeeping chore, tech debt item, or implementation task. | +| Right-sized | 2/2 | All four acceptance criteria are tightly interdependent: providing test data is useless without evaluation using it; evaluation metrics on user data require the data to be provided; the UI indicator requires the test data capability to exist. None can ship independently and provide standalone value. This maps cleanly to a single strategy feature: "user-provided test data support in Auto ML." | +| **Total** | **10/10** | **PASS** | + +### Verdict +A well-structured RFE that clearly articulates a specific customer need with named account evidence, leaves implementation to engineering, and is tightly scoped to a single coherent feature. + +### Feedback +This RFE is strong across all criteria. Minor improvements that could further strengthen it: (1) quantifying the revenue or deal impact associated with the named customers (e.g., deal size at risk), and (2) specifying any constraints around supported test data formats or size limits if those are known customer requirements. These are polish items rather than structural gaps. diff --git a/backend/plugins/aisdlc/tasks/testdata/strat/RHAISTRAT/20260414-180837/strat-tasks/STRAT-1397.md b/backend/plugins/aisdlc/tasks/testdata/strat/RHAISTRAT/20260414-180837/strat-tasks/STRAT-1397.md new file mode 100644 index 00000000000..f417efd1d19 --- /dev/null +++ b/backend/plugins/aisdlc/tasks/testdata/strat/RHAISTRAT/20260414-180837/strat-tasks/STRAT-1397.md @@ -0,0 +1,10 @@ +--- +strat_id: STRAT-1397 +title: Kubeflow Automated pipeLines Engine integration into RHOAI +source_rfe: RHAIRFE-1397 +jira_key: null +priority: Major +status: Refined +--- + +Trimmed fixture body. diff --git a/backend/plugins/aisdlc/tasks/testdata/strat/RHAISTRAT/20260725-015518/pipeline-data.json b/backend/plugins/aisdlc/tasks/testdata/strat/RHAISTRAT/20260725-015518/pipeline-data.json new file mode 100644 index 00000000000..2acdc9a8dee --- /dev/null +++ b/backend/plugins/aisdlc/tasks/testdata/strat/RHAISTRAT/20260725-015518/pipeline-data.json @@ -0,0 +1,44 @@ +{ + "generated_at": "2026-07-25T01:55:16.703430", + "dry_run": false, + "run_id": "artifacts", + "timestamp": "2026-07-25T01:55:16.703430", + "strategies": [ + { + "strat_id": "RHAISTRAT-2370", + "title": "Validated Kimi K3 Model for Red Hat AI and AI Hub", + "source_rfe": "RHAIRFE-2887", + "priority": "Major", + "status": "Draft", + "size": null, + "recommendation": "", + "needs_attention": false, + "scores": null, + "labels": [ + "strat-creator-auto-created" + ] + }, + { + "strat_id": "RHAISTRAT-2371", + "title": "Agentic SDLC: auto-create observability instrumentation tickets (metrics, traces, logs) for component RFE/STRATs", + "source_rfe": "RHAIRFE-2892", + "priority": "Major", + "status": "Refined", + "size": "M", + "recommendation": "approve", + "needs_attention": false, + "scores": { + "feasibility": 1, + "testability": 1, + "scope": 2, + "architecture": 2, + "total": 6 + }, + "labels": [ + "strat-creator-auto-created", + "strat-creator-auto-refined", + "strat-creator-rubric-pass" + ] + } + ] +} diff --git a/backend/plugins/codecov/AGENTS.md b/backend/plugins/codecov/AGENTS.md new file mode 100644 index 00000000000..5722a8e6ac7 --- /dev/null +++ b/backend/plugins/codecov/AGENTS.md @@ -0,0 +1,62 @@ +# codecov Plugin — Agent Context + +Data-source plugin that collects code coverage metrics from Codecov's API (flags, commits, coverages, comparisons, trends) and converts them into DevLake's domain layer for Grafana dashboards. + +## Build & Test + +```bash +cd backend +go test ./plugins/codecov/... -v # unit tests +golangci-lint run ./plugins/codecov/... # lint +``` + +Single-file verification: `go vet ./plugins/codecov/...` + +Playwright e2e (requires `CODECOV_TOKEN`): + +```bash +cd e2e +export CODECOV_TOKEN=... # Codecov API token for konflux-ci org +npm run test:codecov +``` + +Exercises the full plugin lifecycle (connection → scope → blueprint → pipeline with all 13 subtasks). Runs in CI via `.github/workflows/test-e2e-coverage.yml` to drive Go coverage under the `e2e-go` flag. + +## Layout + +- `impl/impl.go` — plugin interfaces (PluginSource, DataSourcePluginBlueprintV200) +- `models/` — tool-layer models (connection, repo, flag, commit, coverage, comparison, trend) + `migrationscripts/register.go` +- `tasks/` — collector → extractor → converter pipeline for each entity +- `tasks/helpers.go` — shared utilities (`ParseFullName`) +- `api/` — REST endpoints (connections, scopes, scope-configs, remote-scopes, blueprints) +- `docs/` — user-facing documentation + +## Conventions + +- This is a **data-source plugin**: has Connection, Scope (repo), ScopeConfig models +- Implements `DataSourcePluginBlueprintV200` for blueprint-driven collection +- Subtask execution order: flags → commits → coverage data → converters (see `SubTaskMetas()`) +- API rate limit: 5000 req/hour hardcoded in `PrepareTaskData()` +- `FullName` format: `"owner/repo"` — parsed via `tasks.ParseFullName()` +- Branch auto-detection: `PrepareTaskData()` fetches default branch from Codecov API + +## Don'ts + +- Don't add models to `GetTablesInfo()` without a migration script in `migrationscripts/register.go` +- Don't import from other plugins (plugins must be independent) +- Don't skip the Apache 2.0 license header on new files +- Don't hardcode branch names — use the auto-detected branch from `PrepareTaskData()` + +## Pattern References + +| Change Type | Example File | +|---|---| +| Add new API collector | `tasks/commits_collector.go` + matching extractor/converter | +| Add migration | `models/migrationscripts/20260316000000_add_line_counts_to_commit_coverages.go` | +| Add model field | model file + migration + update `GetTablesInfo()` | +| Add API endpoint | `api/connection_api.go`, register in `impl/impl.go:ApiResources()` | +| Add converter | `tasks/coverage_converter.go`, register in `impl/impl.go:SubTaskMetas()` | + +## Skills + +- **PR Definition of Done**: see [skills/pr-definition-of-done/SKILL.md](skills/pr-definition-of-done/SKILL.md) diff --git a/backend/plugins/codecov/api/blueprint_v200.go b/backend/plugins/codecov/api/blueprint_v200.go new file mode 100644 index 00000000000..2e8c37b07b8 --- /dev/null +++ b/backend/plugins/codecov/api/blueprint_v200.go @@ -0,0 +1,111 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/helpers/srvhelper" + "github.com/apache/devlake/plugins/codecov/models" + "github.com/apache/devlake/plugins/codecov/tasks" +) + +// MakeDataSourcePipelinePlanV200 creates a pipeline plan for Codecov +func MakeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + // load connection, scope and scopeConfig from the db + connection, err := dsHelper.ConnSrv.FindByPk(connectionId) + if err != nil { + return nil, nil, err + } + scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes) + if err != nil { + // Provide a more helpful error message if scope is not found + if lakeErr := errors.AsLakeErrorType(err); lakeErr != nil && lakeErr.As(errors.NotFound) != nil { + return nil, nil, errors.BadInput.Wrap(err, "One or more scopes were not found in the database. Please save the scopes to the connection first before adding them to a project.") + } + return nil, nil, err + } + + plan, err := makePipelinePlanV200(subtaskMetas, scopeDetails, connection) + if err != nil { + return nil, nil, err + } + + // Return scopes for project mapping + scopes, err := makeScopesV200(scopeDetails, connection) + if err != nil { + return nil, nil, err + } + + return plan, scopes, nil +} + +func makePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + scopeDetails []*srvhelper.ScopeDetail[models.CodecovRepo, models.CodecovScopeConfig], + connection *models.CodecovConnection, +) (coreModels.PipelinePlan, errors.Error) { + plan := make(coreModels.PipelinePlan, len(scopeDetails)) + for i, scopeDetail := range scopeDetails { + stage := plan[i] + if stage == nil { + stage = coreModels.PipelineStage{} + } + + scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig + // construct task options for codecov + task, err := helper.MakePipelinePlanTask( + "codecov", + subtaskMetas, + scopeConfig.Entities, + tasks.CodecovOptions{ + ConnectionId: connection.ID, + FullName: scope.FullName, + ScopeConfig: scopeConfig, + }, + ) + if err != nil { + return nil, err + } + stage = append(stage, task) + plan[i] = stage + } + + return plan, nil +} + +func makeScopesV200( + scopeDetails []*srvhelper.ScopeDetail[models.CodecovRepo, models.CodecovScopeConfig], + connection *models.CodecovConnection, +) ([]plugin.Scope, errors.Error) { + scopes := make([]plugin.Scope, 0, len(scopeDetails)) + + for _, scopeDetail := range scopeDetails { + scope := scopeDetail.Scope + // Return the CodecovRepo itself as it implements plugin.Scope + scopes = append(scopes, scope) + } + + return scopes, nil +} diff --git a/backend/plugins/codecov/api/connection_api.go b/backend/plugins/codecov/api/connection_api.go new file mode 100644 index 00000000000..267c07dcefd --- /dev/null +++ b/backend/plugins/codecov/api/connection_api.go @@ -0,0 +1,178 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "context" + "fmt" + "net/http" + + "github.com/mitchellh/mapstructure" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" + "github.com/apache/devlake/server/api/shared" +) + +type CodecovTestConnResponse struct { + shared.ApiBody + Organization string `json:"organization"` +} + +// TestConnection test codecov connection +// @Summary test codecov connection +// @Description Test codecov Connection +// @Tags plugins/codecov +// @Param body body models.CodecovConn true "json body" +// @Success 200 {object} CodecovTestConnResponse +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/codecov/test [POST] +func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + // process input + var conn models.CodecovConn + e := mapstructure.Decode(input.Body, &conn) + if e != nil { + return nil, errors.Convert(e) + } + testConnectionResult, err := testConnection(context.TODO(), conn) + if err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + return &plugin.ApiResourceOutput{Body: testConnectionResult, Status: http.StatusOK}, nil +} + +// @Summary create codecov connection +// @Description Create codecov connection +// @Tags plugins/codecov +// @Param body body models.CodecovConnection true "json body" +// @Success 200 {object} models.CodecovConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/codecov/connections [POST] +func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Post(input) +} + +// @Summary patch codecov connection +// @Description Patch codecov connection +// @Tags plugins/codecov +// @Param body body models.CodecovConnection true "json body" +// @Success 200 {object} models.CodecovConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/codecov/connections/{connectionId} [PATCH] +func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Patch(input) +} + +// @Summary delete a codecov connection +// @Description Delete a codecov connection +// @Tags plugins/codecov +// @Success 200 {object} models.CodecovConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 409 {object} srvhelper.DsRefs "References exist to this connection" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/codecov/connections/{connectionId} [DELETE] +func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Delete(input) +} + +// @Summary get all codecov connections +// @Description Get all codecov connections +// @Tags plugins/codecov +// @Success 200 {object} []models.CodecovConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/codecov/connections [GET] +func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetAll(input) +} + +// @Summary get codecov connection detail +// @Description Get codecov connection detail +// @Tags plugins/codecov +// @Success 200 {object} models.CodecovConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/codecov/connections/{connectionId} [GET] +func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetDetail(input) +} + +// @Summary test existing codecov connection +// @Description Test existing codecov connection +// @Tags plugins/codecov +// @Success 200 {object} CodecovTestConnResponse +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/test [POST] +func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection, err := dsHelper.ConnApi.GetMergedConnection(input) + if err != nil { + return nil, errors.Convert(err) + } + testConnectionResult, testConnectionErr := testConnection(context.TODO(), connection.CodecovConn) + if testConnectionErr != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, testConnectionErr) + } + return &plugin.ApiResourceOutput{Body: testConnectionResult, Status: http.StatusOK}, nil +} + +func testConnection(ctx context.Context, conn models.CodecovConn) (*CodecovTestConnResponse, errors.Error) { + if vld != nil { + if err := vld.Struct(conn); err != nil { + return nil, errors.Convert(err) + } + } + + if err := conn.ValidateService(); err != nil { + return nil, err + } + + apiClient, err := api.NewApiClientFromConnection(ctx, basicRes, &conn) + if err != nil { + return nil, err + } + + service := conn.ServiceOrDefault() + testUrl := fmt.Sprintf("/api/v2/%s/%s/users", service, conn.Organization) + res, err := apiClient.Get(testUrl, nil, nil) + if err != nil { + return nil, errors.BadInput.Wrap(err, "verify token failed") + } + if res.StatusCode == http.StatusUnauthorized { + return nil, errors.HttpStatus(http.StatusBadRequest).New("StatusUnauthorized error when testing connection") + } + if res.StatusCode == http.StatusNotFound { + return nil, errors.HttpStatus(http.StatusBadRequest).New(fmt.Sprintf("Organization '%s' not found or token does not have access", conn.Organization)) + } + if res.StatusCode != http.StatusOK { + return nil, errors.HttpStatus(res.StatusCode).New("unexpected status code while testing connection") + } + + return &CodecovTestConnResponse{ + ApiBody: shared.ApiBody{ + Success: true, + Message: "success", + }, + Organization: conn.Organization, + }, nil +} diff --git a/backend/plugins/codecov/api/init.go b/backend/plugins/codecov/api/init.go new file mode 100644 index 00000000000..d40df73ab25 --- /dev/null +++ b/backend/plugins/codecov/api/init.go @@ -0,0 +1,54 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" + "github.com/go-playground/validator/v10" +) + +var vld *validator.Validate + +var basicRes context.BasicRes +var dsHelper *api.DsHelper[models.CodecovConnection, models.CodecovRepo, models.CodecovScopeConfig] +var raProxy *api.DsRemoteApiProxyHelper[models.CodecovConnection] +var raScopeList *api.DsRemoteApiScopeListHelper[models.CodecovConnection, models.CodecovRepo, CodecovRemotePagination] + +func Init(br context.BasicRes, p plugin.PluginMeta) { + basicRes = br + vld = validator.New() + dsHelper = api.NewDataSourceHelper[ + models.CodecovConnection, + models.CodecovRepo, + models.CodecovScopeConfig, + ]( + br, + p.Name(), + []string{"full_name"}, + func(c models.CodecovConnection) models.CodecovConnection { + return c.Sanitize() + }, + nil, + nil, + ) + raProxy = api.NewDsRemoteApiProxyHelper[models.CodecovConnection](dsHelper.ConnApi.ModelApiHelper) + raScopeList = api.NewDsRemoteApiScopeListHelper[models.CodecovConnection, models.CodecovRepo, CodecovRemotePagination](raProxy, listCodecovRemoteScopes) +} diff --git a/backend/plugins/codecov/api/remote_api.go b/backend/plugins/codecov/api/remote_api.go new file mode 100644 index 00000000000..3793f40c291 --- /dev/null +++ b/backend/plugins/codecov/api/remote_api.go @@ -0,0 +1,346 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + gocontext "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + dsmodels "github.com/apache/devlake/helpers/pluginhelper/api/models" + "github.com/apache/devlake/plugins/codecov/models" +) + +type CodecovRemotePagination struct { + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +type codecovRepo struct { + Name string `json:"name"` + Service string `json:"service"` + Language string `json:"language"` + Active bool `json:"active"` + ActivatedAt string `json:"activated_at"` + Updatestamp string `json:"updatestamp"` + Private bool `json:"private"` + Branch string `json:"branch"` + Repository struct { + Name string `json:"name"` + } `json:"repository"` +} + +type codecovReposResponse struct { + Results []codecovRepo `json:"results"` + Next *string `json:"next,omitempty"` +} + +func listCodecovRemoteScopes( + connection *models.CodecovConnection, + apiClient plugin.ApiClient, + groupId string, + page CodecovRemotePagination, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.CodecovRepo], + nextPage *CodecovRemotePagination, + err errors.Error, +) { + if page.Page == 0 { + page.Page = 1 + } + if page.PerPage == 0 { + page.PerPage = 100 + } + + owner := connection.Organization + if groupId != "" { + owner = groupId + } + + query := url.Values{ + "page": []string{fmt.Sprintf("%v", page.Page)}, + "page_size": []string{fmt.Sprintf("%v", page.PerPage)}, + } + + service := connection.ServiceOrDefault() + reposUrl := fmt.Sprintf("/api/v2/%s/%s/repos/", service, owner) + reposBody, err := apiClient.Get(reposUrl, query, nil) + if err != nil { + return nil, nil, err + } + + if reposBody.StatusCode == http.StatusNotFound { + _ = reposBody.Body.Close() + return nil, nil, errors.HttpStatus(http.StatusNotFound).New(fmt.Sprintf("Organization or owner '%s' not found", owner)) + } + + if reposBody.StatusCode != http.StatusOK { + _ = reposBody.Body.Close() + return nil, nil, errors.HttpStatus(reposBody.StatusCode).New("unexpected status code while fetching repositories") + } + + var reposResponse codecovReposResponse + err = api.UnmarshalResponse(reposBody, &reposResponse) + if err != nil { + return nil, nil, err + } + + for _, repo := range reposResponse.Results { + // Get repo name - it might be in repo.Name or repo.Repository.Name + repoName := repo.Name + if repoName == "" && repo.Repository.Name != "" { + repoName = repo.Repository.Name + } + if repoName == "" { + continue + } + + fullName := fmt.Sprintf("%s/%s", owner, repoName) + codecovId := fmt.Sprintf("%s/%s", owner, repoName) + + branch := repo.Branch + if branch == "" { + branch = "main" + } + + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.CodecovRepo]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + ParentId: nil, + Id: codecovId, + Name: repoName, + FullName: fullName, + Data: &models.CodecovRepo{ + CodecovId: codecovId, + Name: repoName, + FullName: fullName, + Service: repo.Service, + Language: repo.Language, + Active: repo.Active, + ActivatedAt: repo.ActivatedAt, + Updatestamp: repo.Updatestamp, + Private: repo.Private, + Branch: branch, + }, + }) + } + + // Check if there's a next page + if reposResponse.Next != nil && *reposResponse.Next != "" { + nextPage = &CodecovRemotePagination{ + Page: page.Page + 1, + PerPage: page.PerPage, + } + } + + return children, nextPage, nil +} + +// RemoteScopes list all available scopes on the remote server +// @Summary list all available scopes on the remote server +// @Description list all available scopes on the remote server +// @Accept application/json +// @Param connectionId path int false "connection ID" +// @Param groupId query string false "group ID" +// @Param pageToken query string false "page Token" +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Success 200 {object} dsmodels.DsRemoteApiScopeList[models.CodecovRepo] +// @Tags plugins/codecov +// @Router /plugins/codecov/connections/{connectionId}/remote-scopes [GET] +func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raScopeList.Get(input) +} + +// SearchRemoteScopes searches scopes on the remote server. +// Bypasses the standard search helper because Codecov's repos endpoint +// is scoped to an organization (needs connection.Organization), and the +// framework callback only receives apiClient + params. +// @Summary searches scopes on the remote server +// @Description searches scopes on the remote server +// @Accept application/json +// @Param connectionId path int false "connection ID" +// @Param search query string false "search" +// @Param page query int false "page number" +// @Param pageSize query int false "page size per page" +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Success 200 {object} dsmodels.DsRemoteApiScopeList[models.CodecovRepo] "the parentIds are always null" +// @Tags plugins/codecov +// @Router /plugins/codecov/connections/{connectionId}/search-remote-scopes [GET] +func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection, err := dsHelper.ConnApi.ModelApiHelper.FindByPk(input) + if err != nil { + return nil, err + } + + apiClient, err := api.NewApiClientFromConnection(gocontext.TODO(), basicRes, connection) + if err != nil { + return nil, err + } + + search := input.Query.Get("search") + if search == "" { + return &plugin.ApiResourceOutput{ + Body: map[string]interface{}{ + "children": []dsmodels.DsRemoteApiScopeListEntry[models.CodecovRepo]{}, + "page": 1, + "pageSize": 50, + }, + }, nil + } + + page := 1 + pageSize := 50 + if v, e := strconv.Atoi(input.Query.Get("page")); e == nil && v > 0 { + page = v + } + if v, e := strconv.Atoi(input.Query.Get("pageSize")); e == nil && v > 0 { + pageSize = v + } + + service := connection.ServiceOrDefault() + org := connection.Organization + + // For GitLab services, search supports subgroup paths: + // "subgroup" → list repos under org:subgroup + // "subgroup/repo" → search "repo" under org:subgroup + // "sub/nested/repo" → search "repo" under org:sub:nested + // If no "/" is present, also try the search as a plain repo name search. + var children []dsmodels.DsRemoteApiScopeListEntry[models.CodecovRepo] + isGitLabService := strings.HasPrefix(service, "gitlab") + + if isGitLabService && strings.Contains(search, "/") { + // Split on last "/" — left is subgroup path, right is repo search + lastSlash := strings.LastIndex(search, "/") + subgroupPath := search[:lastSlash] + repoSearch := search[lastSlash+1:] + subgroupOwner := org + ":" + strings.ReplaceAll(subgroupPath, "/", ":") + + children, err = searchReposForOwner(apiClient, service, subgroupOwner, repoSearch, page, pageSize) + if err != nil { + return nil, err + } + } else { + // Standard search: search repos at the org level + children, err = searchReposForOwner(apiClient, service, org, search, page, pageSize) + if err != nil { + return nil, err + } + + // For GitLab: if no results, try interpreting search as a subgroup name + if isGitLabService && len(children) == 0 { + subgroupOwner := org + ":" + strings.ReplaceAll(search, "/", ":") + subgroupChildren, subErr := searchReposForOwner(apiClient, service, subgroupOwner, "", page, pageSize) + if subErr == nil && len(subgroupChildren) > 0 { + children = subgroupChildren + } + } + } + + return &plugin.ApiResourceOutput{ + Body: map[string]interface{}{ + "children": children, + "page": page, + "pageSize": pageSize, + }, + }, nil +} + +// searchReposForOwner queries the Codecov API for repos belonging to owner, +// optionally filtered by a search term. +func searchReposForOwner( + apiClient plugin.ApiClient, + service, owner, search string, + page, pageSize int, +) ([]dsmodels.DsRemoteApiScopeListEntry[models.CodecovRepo], errors.Error) { + query := url.Values{ + "page": []string{fmt.Sprintf("%v", page)}, + "page_size": []string{fmt.Sprintf("%v", pageSize)}, + } + if search != "" { + query.Set("search", search) + } + + reposUrl := fmt.Sprintf("/api/v2/%s/%s/repos/", service, owner) + res, err := apiClient.Get(reposUrl, query, nil) + if err != nil { + return nil, err + } + + if res.StatusCode == http.StatusNotFound { + _ = res.Body.Close() + return []dsmodels.DsRemoteApiScopeListEntry[models.CodecovRepo]{}, nil + } + if res.StatusCode != http.StatusOK { + _ = res.Body.Close() + return nil, errors.HttpStatus(res.StatusCode).New("unexpected status code while searching repositories") + } + + var reposResponse codecovReposResponse + err = api.UnmarshalResponse(res, &reposResponse) + if err != nil { + return nil, err + } + + children := make([]dsmodels.DsRemoteApiScopeListEntry[models.CodecovRepo], 0, len(reposResponse.Results)) + for _, repo := range reposResponse.Results { + repoName := repo.Name + if repoName == "" && repo.Repository.Name != "" { + repoName = repo.Repository.Name + } + if repoName == "" { + continue + } + + fullName := fmt.Sprintf("%s/%s", owner, repoName) + codecovId := fullName + + branch := repo.Branch + if branch == "" { + branch = "main" + } + + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.CodecovRepo]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + ParentId: nil, + Id: codecovId, + Name: repoName, + FullName: fullName, + Data: &models.CodecovRepo{ + CodecovId: codecovId, + Name: repoName, + FullName: fullName, + Service: repo.Service, + Language: repo.Language, + Active: repo.Active, + ActivatedAt: repo.ActivatedAt, + Updatestamp: repo.Updatestamp, + Private: repo.Private, + Branch: branch, + }, + }) + } + + return children, nil +} diff --git a/backend/plugins/codecov/api/scope_api.go b/backend/plugins/codecov/api/scope_api.go new file mode 100644 index 00000000000..054e25de496 --- /dev/null +++ b/backend/plugins/codecov/api/scope_api.go @@ -0,0 +1,156 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/url" + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +type PutScopesReqBody api.PutScopesReqBody[models.CodecovRepo] +type ScopeDetail api.ScopeDetail[models.CodecovRepo, models.CodecovScopeConfig] + +// PutScopes create or update codecov repo +// @Summary create or update codecov repo +// @Description Create or update codecov repo +// @Tags plugins/codecov +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param scope body PutScopesReqBody true "json" +// @Success 200 {object} []models.CodecovRepo +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scopes [PUT] +func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.PutMultiple(input) +} + +// PatchScope patch to codecov repo +// @Summary patch to codecov repo +// @Description patch to codecov repo +// @Tags plugins/codecov +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Param scope body models.CodecovRepo true "json" +// @Success 200 {object} models.CodecovRepo +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scopes/{scopeId} [PATCH] +func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + scopeId := strings.TrimLeft(input.Params["scopeId"], "/") + // URL decode the scopeId in case it's encoded (e.g., %2F for /) + if decoded, err := url.QueryUnescape(scopeId); err == nil { + scopeId = decoded + } + input.Params["scopeId"] = scopeId + return dsHelper.ScopeApi.Patch(input) +} + +// GetScopes get Codecov repos +// @Summary get Codecov repos +// @Description get Codecov repos +// @Tags plugins/codecov +// @Param connectionId path int true "connection ID" +// @Param searchTerm query string false "search term for scope name" +// @Param pageSize query int false "page size, default 50" +// @Param page query int false "page size, default 1" +// @Param blueprints query bool false "also return blueprints using these scopes as part of the payload" +// @Success 200 {object} []ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scopes [GET] +func GetScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetPage(input) +} + +// GetScopeDispatcher handles scope routes with potential suffixes like /latest-sync-state +// @Summary get scope dispatcher +// @Description Handles scope routes with suffixes +// @Tags plugins/codecov +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Success 200 {object} ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scopes/{scopeId} [GET] +func GetScopeDispatcher(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + scopeIdWithSuffix := strings.TrimLeft(input.Params["scopeId"], "/") + // URL decode the scopeId in case it's encoded (e.g., %2F for /) + if decoded, err := url.QueryUnescape(scopeIdWithSuffix); err == nil { + scopeIdWithSuffix = decoded + } + if strings.HasSuffix(scopeIdWithSuffix, "/latest-sync-state") { + // Remove the /latest-sync-state suffix and trim any leading slashes + scopeId := strings.TrimSuffix(scopeIdWithSuffix, "/latest-sync-state") + scopeId = strings.TrimLeft(scopeId, "/") + input.Params["scopeId"] = scopeId + return GetScopeLatestSyncState(input) + } + // Trim leading slash for normal scope retrieval + input.Params["scopeId"] = strings.TrimLeft(scopeIdWithSuffix, "/") + return GetScope(input) +} + +// GetScope get one Codecov repo +// @Summary get one Codecov repo +// @Description get one Codecov repo +// @Tags plugins/codecov +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Param blueprints query bool false "also return blueprints using these scopes as part of the payload" +// @Success 200 {object} ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scopes/{scopeId} [GET] +func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + scopeId := strings.TrimLeft(input.Params["scopeId"], "/") + // URL decode the scopeId in case it's encoded (e.g., %2F for /) + if decoded, err := url.QueryUnescape(scopeId); err == nil { + scopeId = decoded + } + input.Params["scopeId"] = scopeId + return dsHelper.ScopeApi.GetScopeDetail(input) +} + +// DeleteScope delete plugin data associated with the scope and optionally the scope itself +// @Summary delete plugin data associated with the scope and optionally the scope itself +// @Description delete data associated with plugin scope +// @Tags plugins/codecov +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Param delete_data_only query bool false "Only delete the scope data, not the scope itself" +// @Success 200 {object} models.CodecovRepo +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 409 {object} srvhelper.DsRefs "References exist to this scope" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scopes/{scopeId} [DELETE] +func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + scopeId := strings.TrimLeft(input.Params["scopeId"], "/") + // URL decode the scopeId in case it's encoded (e.g., %2F for /) + if decoded, err := url.QueryUnescape(scopeId); err == nil { + scopeId = decoded + } + input.Params["scopeId"] = scopeId + return dsHelper.ScopeApi.Delete(input) +} diff --git a/backend/plugins/codecov/api/scope_config_api.go b/backend/plugins/codecov/api/scope_config_api.go new file mode 100644 index 00000000000..67957dc7429 --- /dev/null +++ b/backend/plugins/codecov/api/scope_config_api.go @@ -0,0 +1,99 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +// PostScopeConfig create scope config +// @Summary create scope config +// @Description create scope config +// @Tags plugins/codecov +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param scopeConfig body models.CodecovScopeConfig true "scope config" +// @Success 200 {object} models.CodecovScopeConfig +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scope-configs [POST] +func PostScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Post(input) +} + +// GetScopeConfigList get all scope configs +// @Summary get all scope configs +// @Description get all scope configs +// @Tags plugins/codecov +// @Param connectionId path int true "connection ID" +// @Param pageSize query int false "page size, default 50" +// @Param page query int false "page size, default 1" +// @Success 200 {object} []models.CodecovScopeConfig +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scope-configs [GET] +func GetScopeConfigList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetAll(input) +} + +// GetScopeConfig get one scope config +// @Summary get one scope config +// @Description get one scope config +// @Tags plugins/codecov +// @Param connectionId path int true "connection ID" +// @Param id path int true "scope config ID" +// @Success 200 {object} models.CodecovScopeConfig +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scope-configs/{id} [GET] +func GetScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetDetail(input) +} + +// PatchScopeConfig patch scope config +// @Summary patch scope config +// @Description patch scope config +// @Tags plugins/codecov +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param id path int true "scope config ID" +// @Param scopeConfig body models.CodecovScopeConfig true "scope config" +// @Success 200 {object} models.CodecovScopeConfig +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scope-configs/{id} [PATCH] +func PatchScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Patch(input) +} + +// DeleteScopeConfig delete scope config +// @Summary delete scope config +// @Description delete scope config +// @Tags plugins/codecov +// @Param connectionId path int true "connection ID" +// @Param id path int true "scope config ID" +// @Success 200 {object} models.CodecovScopeConfig +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 409 {object} srvhelper.DsRefs "References exist to this scope config" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scope-configs/{id} [DELETE] +func DeleteScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Delete(input) +} + diff --git a/backend/plugins/codecov/api/scope_state_api.go b/backend/plugins/codecov/api/scope_state_api.go new file mode 100644 index 00000000000..e4d96f07b14 --- /dev/null +++ b/backend/plugins/codecov/api/scope_state_api.go @@ -0,0 +1,47 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/url" + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +// GetScopeLatestSyncState get the latest sync state of a scope +// @Summary get the latest sync state of a scope +// @Description get the latest sync state of a scope +// @Tags plugins/codecov +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Success 200 {object} []models.LatestSyncState +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/codecov/connections/{connectionId}/scopes/{scopeId}/latest-sync-state [GET] +func GetScopeLatestSyncState(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + scopeId := strings.TrimLeft(input.Params["scopeId"], "/") + // URL decode the scopeId in case it's encoded (e.g., %2F for /) + if decoded, err := url.QueryUnescape(scopeId); err == nil { + scopeId = decoded + } + input.Params["scopeId"] = scopeId + return dsHelper.ScopeApi.GetScopeLatestSyncState(input) +} + diff --git a/backend/plugins/codecov/codecov.go b/backend/plugins/codecov/codecov.go new file mode 100644 index 00000000000..fc8171c0248 --- /dev/null +++ b/backend/plugins/codecov/codecov.go @@ -0,0 +1,26 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main // must be main for plugin entry point + +import ( + "github.com/apache/devlake/plugins/codecov/impl" +) + +// PluginEntry is a variable exported for Framework to search and load +var PluginEntry impl.Codecov //nolint + diff --git a/backend/plugins/codecov/docs/METRICS_REFERENCE.md b/backend/plugins/codecov/docs/METRICS_REFERENCE.md new file mode 100644 index 00000000000..74993ac0736 --- /dev/null +++ b/backend/plugins/codecov/docs/METRICS_REFERENCE.md @@ -0,0 +1,263 @@ +# Codecov Metrics Reference + +This guide explains all the metrics collected by the Codecov plugin in simple, non-technical terms. + +## Coverage Metrics + +### Overall Coverage Percentage +**What it is**: The percentage of your entire codebase that is tested. + +**Example**: 85% means 85 out of every 100 lines of code are covered by tests. + +**Why it matters**: Higher coverage generally means more of your code is tested, reducing the risk of bugs. + +**Good to know**: +- 100% coverage doesn't mean perfect code - tests can still be wrong +- Focus on covering important, critical code paths +- Industry standards vary, but 70-80% is often considered good + +### Patch Coverage +**What it is**: The percentage of **new or modified code** that is tested in a specific commit. + +**Example**: If you change 100 lines and 80 are tested, patch coverage is 80%. + +**Why it matters**: Ensures new code changes include tests, preventing coverage from declining over time. + +**Good to know**: +- Patch coverage can be null/empty if no files changed or API data is unavailable +- High patch coverage helps maintain overall coverage as code grows +- Many teams require minimum patch coverage (e.g., 80%) before merging code + +### Modified Coverage +**What it is**: Similar to patch coverage - coverage for code that was changed in a commit. + +**Example**: If you modify 50 lines and 45 are tested, modified coverage is 90%. + +**Why it matters**: Helps ensure code changes don't reduce overall test coverage. + +## Line Metrics + +### Total Lines +**What it is**: The total number of lines of code in your repository. + +**Example**: 29,333 lines means your codebase has 29,333 lines of code. + +**Why it matters**: Helps understand the size of your codebase and track growth over time. + +**Good to know**: +- Includes all code files (not comments or blank lines in some calculations) +- Grows as you add features +- Larger codebases need more tests to maintain coverage + +### Lines Covered +**What it is**: The number of lines that are executed by your tests. + +**Example**: 24,901 lines covered means 24,901 lines of code are tested. + +**Why it matters**: Shows the absolute number of lines with test coverage. + +**Good to know**: +- Higher is generally better +- Should grow as you add tests +- Compare with total lines to understand coverage percentage + +### Lines Missed (Uncovered) +**What it is**: The number of lines that are NOT executed by your tests. + +**Example**: 4,399 lines missed means 4,399 lines have no test coverage. + +**Why it matters**: Identifies areas of your code that need tests. + +**Good to know**: +- Lower is generally better +- Focus on testing critical paths first +- Some code (like error handlers) may be hard to test + +### Lines Total (in Modified Code) +**What it is**: Total number of lines in code that was changed in a commit. + +**Example**: If you modify 200 lines, lines total is 200. + +**Why it matters**: Helps understand the scope of changes in each commit. + +## Execution Metrics + +### Hits +**What it is**: The number of times a line of code was executed during test runs. + +**Example**: A line with 100 hits was run 100 times during tests. + +**Why it matters**: Shows how thoroughly code is tested - more hits often means more thorough testing. + +**Good to know**: +- Higher hits don't always mean better tests +- Some code should be tested multiple times with different inputs +- Very high hits might indicate inefficient tests + +### Misses +**What it is**: The number of times a line of code was NOT executed during test runs. + +**Example**: A line with 5 misses was not executed 5 times (but might have been executed other times). + +**Why it matters**: Identifies code paths that are rarely or never tested. + +**Good to know**: +- Zero misses means the line is always executed in tests +- High misses might indicate untested error paths or edge cases + +### Partials +**What it is**: Lines that are partially covered (some conditions tested, others not). + +**Example**: An if/else statement where the "if" is tested but the "else" is not. + +**Why it matters**: Identifies code that needs more complete test coverage. + +**Good to know**: +- Partials indicate incomplete test coverage +- Should aim to reduce partials by testing all code paths + +## Method Metrics + +### Methods Covered +**What it is**: The number of functions/methods that are tested. + +**Example**: 5,790 methods covered means 5,790 functions have tests. + +**Why it matters**: Ensures individual functions are tested, not just lines of code. + +**Good to know**: +- Methods are functions or procedures in your code +- Higher is generally better +- Should track alongside line coverage + +### Methods Total +**What it is**: The total number of functions/methods in your code. + +**Example**: 6,000 methods total means your code has 6,000 functions. + +**Why it matters**: Helps understand how many functions need testing. + +**Good to know**: +- Compare with methods covered to see percentage +- Large number of untested methods indicates areas needing attention + +## Change Metrics + +### Files Changed +**What it is**: The number of files modified in a commit. + +**Example**: 5 files changed means 5 files were modified in that commit. + +**Why it matters**: Helps understand the scope of changes - more files often means bigger changes. + +**Good to know**: +- Larger changes may need more tests +- Many small file changes might indicate refactoring +- Track alongside coverage to ensure changes include tests + +## Trend Metrics + +### Coverage Trend +**What it is**: Whether coverage is improving, declining, or staying stable over time. + +**Example**: "Improving (2.5% change)" means coverage increased by 2.5 percentage points. + +**Why it matters**: Shows if your testing efforts are effective and coverage is improving. + +**Good to know**: +- Improving: Coverage is going up - good! +- Declining: Coverage is going down - may need attention +- Stable: Coverage is staying the same - maintain current testing practices + +### Running Average (Patch Coverage Trend) +**What it is**: A cumulative average calculated from the first day to each day. + +**Example**: +- Day 1: 80% → Average: 80% +- Day 2: 90% → Average: (80% + 90%) / 2 = 85% +- Day 3: 85% → Average: (80% + 90% + 85%) / 3 = 85% + +**Why it matters**: Shows overall trend direction - is patch coverage generally improving or declining? + +**Good to know**: +- Smooth line that continues even on days with no data +- Rising line = improving coverage over time +- Falling line = declining coverage over time +- Flat line = stable coverage + +## Flag-Based Metrics + +### Coverage by Flag (Test Type) +**What it is**: Coverage percentage for a specific type of test (e.g., "unit tests", "e2e tests"). + +**Example**: +- Unit Tests: 85% coverage +- E2E Tests: 60% coverage + +**Why it matters**: Different test types cover different aspects - unit tests test individual functions, e2e tests test entire workflows. + +**Good to know**: +- Each flag can have different coverage levels +- Compare flags to see which test types need more coverage +- Some flags may have higher coverage than others (this is normal) + +## How Metrics Work Together + +### Coverage Calculation +``` +Coverage % = (Lines Covered / Lines Total) × 100 +``` + +**Example**: +- 24,901 lines covered +- 29,333 lines total +- Coverage = (24,901 / 29,333) × 100 = 84.89% + +### Patch Coverage vs Overall Coverage +- **Overall Coverage**: All code in repository +- **Patch Coverage**: Only new/modified code in a commit +- **Goal**: Keep patch coverage high to maintain or improve overall coverage + +### Understanding Trends +- **Improving Coverage**: Lines covered growing faster than lines total +- **Declining Coverage**: Lines total growing faster than lines covered +- **Stable Coverage**: Both growing at similar rates + +## Best Practices + +1. **Focus on Patch Coverage**: Ensure new code has high coverage to maintain overall coverage +2. **Monitor Trends**: Watch for declining coverage trends and address early +3. **Set Goals**: Establish minimum coverage thresholds (e.g., 80% overall, 90% patch) +4. **Review Regularly**: Check metrics weekly or monthly +5. **Combine Metrics**: Use multiple metrics together for a complete picture + +## Common Scenarios + +### Scenario 1: High Overall, Low Patch +**Situation**: Overall coverage is 90%, but patch coverage is 50% + +**Meaning**: Existing code is well-tested, but new code isn't being tested adequately + +**Action**: Focus on adding tests for new code changes + +### Scenario 2: Improving Trend +**Situation**: Coverage trend shows "Improving (5% change)" + +**Meaning**: Coverage increased by 5 percentage points in the time period + +**Action**: Continue current testing practices + +### Scenario 3: Declining Trend +**Situation**: Coverage trend shows "Declining (3% change)" + +**Meaning**: Coverage decreased by 3 percentage points + +**Action**: Review recent commits, ensure new code includes tests, consider increasing patch coverage requirements + +### Scenario 4: High Misses +**Situation**: Many lines have high "misses" count + +**Meaning**: These lines are rarely or never executed in tests + +**Action**: Add tests to cover these code paths, especially for critical functionality + diff --git a/backend/plugins/codecov/docs/QUICK_START.md b/backend/plugins/codecov/docs/QUICK_START.md new file mode 100644 index 00000000000..43e69b976b8 --- /dev/null +++ b/backend/plugins/codecov/docs/QUICK_START.md @@ -0,0 +1,70 @@ +# Codecov Plugin - Quick Start Guide + +## 5-Minute Setup + +### 1. Get Your Codecov API Token +- Log in to [Codecov](https://codecov.io) +- Go to Settings → API +- Generate a new API token +- Copy the token (you'll need it in step 2) + +### 2. Add Connection in DevLake +1. Open DevLake → **Data Connections** +2. Click **Add Connection** → Select **Codecov** +3. Paste your API token +4. Click **Test Connection** to verify +5. Click **Save** + +### 3. Add Repositories +1. After saving, click **Add Repositories** +2. Search for your repository +3. Select it and click **Add** + +### 4. Create Blueprint +1. Go to **Blueprints** → **Create Blueprint** +2. Select your Codecov connection +3. Choose repositories to track +4. Set frequency (recommended: Daily) +5. Click **Save and Run** + +### 5. View Dashboard +1. Wait a few minutes for data collection +2. Go to **Dashboards** → **Codecov** +3. Select your project and repository +4. View coverage metrics and trends + +## What You'll See + +### Key Numbers +- **Overall Coverage**: Percentage of code covered by tests +- **Total Lines**: Total lines of code in repository +- **Lines Covered**: Lines with test coverage +- **Uncovered Lines**: Lines without test coverage +- **Patch Coverage**: Coverage for new/modified code + +### Charts +- **Coverage Trend**: How coverage changes over time +- **Patch Coverage Trend**: Running average of patch coverage +- **Lines Trend**: How total and covered lines change + +## Common Questions + +**Q: How long does data collection take?** +A: First collection takes 5-15 minutes depending on repository size. Subsequent collections are faster. + +**Q: Why is patch coverage sometimes 0% or null?** +A: This happens when no files changed in a commit, or when Codecov API data is unavailable for that commit. + +**Q: Can I track multiple test types?** +A: Yes! Use the "Test Name" filter to switch between different flags (e.g., "unittests", "e2e-tests"). + +**Q: How do I generate a report?** +A: Use the executive summary report generator (see main README for details). + +## Next Steps + +- Read the [full documentation](README.md) for detailed information +- Explore the dashboard filters to analyze different time periods +- Set up regular reports for your team +- Monitor coverage trends to maintain code quality + diff --git a/backend/plugins/codecov/docs/README.md b/backend/plugins/codecov/docs/README.md new file mode 100644 index 00000000000..eb467da4905 --- /dev/null +++ b/backend/plugins/codecov/docs/README.md @@ -0,0 +1,235 @@ +# Codecov Plugin Documentation + +## Overview + +The Codecov plugin integrates with [Codecov](https://codecov.io), a code coverage analysis service, to collect and analyze test coverage data from your repositories. This plugin helps you understand how much of your code is tested and track coverage trends over time. + +## What is Code Coverage? + +**Code coverage** measures how much of your source code is executed by your tests. It's expressed as a percentage: + +- **100% coverage** means every line of code is tested +- **50% coverage** means half of your code is tested +- **0% coverage** means no code is tested + +Higher coverage generally indicates better test quality, but it's not the only factor in code quality. + +## Key Concepts + +### Coverage Types + +The plugin collects several types of coverage metrics: + +1. **Overall Coverage**: The percentage of all code lines that are covered by tests +2. **Patch Coverage**: The percentage of new or modified code lines that are covered by tests (also called "diff coverage") +3. **Flag-Based Coverage**: Coverage for specific test suites or test types (e.g., "unit tests", "e2e tests") + +### Flags (Test Names) + +In Codecov, **flags** are labels that categorize different types of tests: + +- **Unit Tests**: Tests for individual functions or components +- **E2E Tests**: End-to-end tests that test entire workflows +- **Integration Tests**: Tests that verify how different parts work together + +Each flag can have its own coverage metrics, allowing you to track coverage separately for different test types. + +### Commits and Coverage + +The plugin tracks coverage for each commit in your repository, allowing you to: +- See how coverage changes over time +- Identify commits that increased or decreased coverage +- Track coverage trends for specific branches + +## What Data is Collected? + +The plugin collects the following information: + +### Repository Information + +- Repository name and identifier +- Branch information +- Language and service type + +### Coverage Metrics (per commit and flag) + +- **Coverage Percentage**: Overall percentage of code covered +- **Lines Covered**: Number of code lines that are tested +- **Lines Total**: Total number of code lines +- **Lines Missed**: Number of code lines not tested +- **Hits**: Number of times code was executed during tests +- **Misses**: Number of times code was not executed +- **Methods Covered/Total**: Number of methods (functions) covered vs total + +### Patch Coverage Metrics + +- **Patch Coverage**: Coverage percentage for new or modified code +- **Modified Coverage**: Coverage for code changed in a commit +- **Files Changed**: Number of files modified in a commit +- **Lines Covered/Missed in Modified Code**: Coverage details for changed code + +### Trends + +- Daily coverage trends over time +- Coverage changes between commits +- Running averages of patch coverage + +## Setup Instructions + +### Prerequisites + +1. A Codecov account with access to your repositories +2. A Codecov API token (obtained from your Codecov account settings) +3. Your repository must be connected to Codecov and have coverage reports being generated + +### Step 1: Create a Connection + +1. In DevLake, go to **Data Connections** +2. Click **Add Connection** and select **Codecov** +3. Enter your Codecov API token +4. Test the connection to verify it works +5. Save the connection + +### Step 2: Add Repositories + +1. After creating the connection, click **Add Repositories** +2. Search for and select the repositories you want to track +3. The plugin will automatically discover available repositories from your Codecov account + +### Step 3: Create a Blueprint + +1. Go to **Blueprints** in DevLake +2. Create a new blueprint +3. Select your Codecov connection +4. Choose the repositories to include +5. Set the data collection frequency (e.g., daily, weekly) +6. Save and run the blueprint + +### Step 4: View Your Data + +Once data collection starts, you can: +- View dashboards in Grafana (see below) +- Generate executive summary reports +- Query the data directly from the database + +## Available Dashboards + +### Codecov Dashboard + +The main Codecov dashboard provides: + +#### Key Metrics Panels +- **Overall Coverage**: Current coverage percentage for the selected test type +- **Total Lines**: Total lines of code in the repository +- **Lines Covered**: Number of lines with test coverage +- **Uncovered Lines**: Number of lines without test coverage +- **Patch Coverage**: Average patch coverage for the selected time period + +#### Trend Charts +- **Overall Coverage Trend**: How overall coverage changes over time +- **Patch Coverage Trend**: Running average of patch coverage (shows cumulative average from first day to each day) +- **Lines Coverage Trend**: How lines covered and total lines change over time + +#### Filters +- **Project**: Filter by DevLake project +- **Repository**: Select specific repository +- **Test Name (Flag)**: Filter by test type (e.g., "unittests", "e2e-tests") +- **Time Range**: Select date range (default: last 30 days) + +### Understanding the Patch Coverage Trend + +The Patch Coverage Trend chart shows a **running average**, which means: +- It calculates the average patch coverage from the first day to each subsequent day +- If there's no data for a day, it continues the line using the last known average +- This gives you a smooth trend line that shows overall patch coverage improvement or decline + +**Example**: If you have patch coverage of 80% on day 1, 90% on day 2, and 85% on day 3: +- Day 1 average: 80% +- Day 2 average: (80% + 90%) / 2 = 85% +- Day 3 average: (80% + 90% + 85%) / 3 = 85% + +## Executive Summary Reports + +The plugin can generate PDF executive summary reports that provide high-level coverage metrics for leadership. These reports include: + +- Overall project coverage percentage +- Coverage by test type +- Total repositories and lines of code +- Coverage trends (improving, declining, or stable) +- Average patch coverage +- Visual charts and graphs + +Reports are based on configurable time periods (e.g., last 7, 24, or 30 days). + +## Data Tables + +The plugin stores data in the following database tables: + +- **`_tool_codecov_repos`**: Repository information +- **`_tool_codecov_flags`**: Test flags (test types) for each repository +- **`_tool_codecov_commits`**: Commit metadata +- **`_tool_codecov_coverages`**: Coverage metrics per commit and flag +- **`_tool_codecov_comparisons`**: Patch coverage and comparison data +- **`_tool_codecov_commit_coverages`**: Overall commit-level coverage (without flags) + +## Common Use Cases + +### 1. Track Coverage Trends +Monitor how code coverage changes over time to ensure it's improving or staying stable. + +### 2. Identify Coverage Gaps +Find areas of your codebase that lack test coverage and prioritize adding tests. + +### 3. Compare Test Types +Compare coverage between different test types (unit tests vs. e2e tests) to understand where testing efforts should focus. + +### 4. Monitor Patch Coverage +Ensure that new code changes include adequate test coverage by tracking patch coverage metrics. + +### 5. Generate Reports for Leadership +Create executive summaries showing overall project health and coverage trends. + +## Best Practices + +1. **Set Coverage Goals**: Establish minimum coverage thresholds (e.g., 80%) and track progress +2. **Monitor Patch Coverage**: Ensure new code maintains or improves overall coverage +3. **Review Trends Regularly**: Check coverage trends weekly or monthly to catch declining coverage early +4. **Use Flags Appropriately**: Organize tests with meaningful flag names to track different test types separately +5. **Combine with Other Metrics**: Use coverage data alongside other quality metrics for a complete picture + +## Troubleshooting + +### No Data Appearing + +- **Check Connection**: Verify your Codecov API token is valid +- **Verify Repository Setup**: Ensure the repository is connected to Codecov and generating coverage reports +- **Check Blueprint Status**: Make sure the blueprint ran successfully +- **Review Time Range**: Ensure your selected time range includes dates when coverage data exists + +### Coverage Numbers Don't Match Codecov + +- **Flag Filtering**: Make sure you're comparing the same flags/test types +- **Time Range**: Coverage may vary by time period - ensure you're comparing the same dates +- **Branch Selection**: Verify you're looking at the same branch (main/master) + +### Missing Patch Coverage Data + +- **API Availability**: Patch coverage requires the Codecov Compare API - verify your API token has access +- **Parent Commit**: Patch coverage compares to parent commit - ensure parent commits exist +- **Null Values**: Some commits may not have patch coverage if no files changed or API data is unavailable + +## Support and Resources + +- **Codecov Documentation**: [https://docs.codecov.com](https://docs.codecov.com) +- **DevLake Documentation**: [https://devlake.apache.org/docs](https://devlake.apache.org/docs) +- **Issues**: Report issues on the DevLake GitHub repository + +## Glossary + +- **Coverage**: The percentage of code executed by tests +- **Flag**: A label for categorizing different test types in Codecov +- **Patch Coverage**: Coverage percentage for new or modified code in a commit +- **Running Average**: A cumulative average calculated from the first value to each subsequent value +- **Commit**: A snapshot of code changes in a repository +- **Branch**: A parallel version of code in a repository (e.g., "main", "develop") + diff --git a/backend/plugins/codecov/impl/impl.go b/backend/plugins/codecov/impl/impl.go new file mode 100644 index 00000000000..372cd37578a --- /dev/null +++ b/backend/plugins/codecov/impl/impl.go @@ -0,0 +1,270 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import ( + "fmt" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/api" + "github.com/apache/devlake/plugins/codecov/models" + "github.com/apache/devlake/plugins/codecov/models/migrationscripts" + "github.com/apache/devlake/plugins/codecov/tasks" +) + +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginApi + plugin.PluginModel + plugin.PluginSource + plugin.DataSourcePluginBlueprintV200 +} = (*Codecov)(nil) + +type Codecov struct{} + +func (p Codecov) Connection() dal.Tabler { + return &models.CodecovConnection{} +} + +func (p Codecov) Scope() plugin.ToolLayerScope { + return &models.CodecovRepo{} +} + +func (p Codecov) ScopeConfig() dal.Tabler { + return &models.CodecovScopeConfig{} +} + +func (p Codecov) Init(basicRes context.BasicRes) errors.Error { + api.Init(basicRes, p) + return nil +} + +func (p Codecov) GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + &models.CodecovConnection{}, + &models.CodecovRepo{}, + &models.CodecovScopeConfig{}, + &models.CodecovFlag{}, + &models.CodecovCommit{}, + &models.CodecovCoverage{}, + &models.CodecovCoverageTrend{}, + &models.CodecovCommitCoverage{}, + &models.CodecovRepoConfig{}, + } +} + +func (p Codecov) Description() string { + return "To collect and enrich data from Codecov" +} + +func (p Codecov) Name() string { + return "codecov" +} + +func (p Codecov) RootPkgPath() string { + return "github.com/apache/devlake/plugins/codecov" +} + +func (p Codecov) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +func (p Codecov) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{ + "test": { + "POST": api.TestConnection, + }, + "connections": { + "POST": api.PostConnections, + "GET": api.ListConnections, + }, + "connections/:connectionId": { + "GET": api.GetConnection, + "PATCH": api.PatchConnection, + "DELETE": api.DeleteConnection, + }, + "connections/:connectionId/test": { + "POST": api.TestExistingConnection, + }, + "connections/:connectionId/scopes": { + "GET": api.GetScopes, + "PUT": api.PutScopes, + }, + "connections/:connectionId/scopes/*scopeId": { + // Behind 'GetScopeDispatcher', there are two paths so far: + // GetScopeLatestSyncState "connections/:connectionId/scopes/:scopeId/latest-sync-state" + // GetScope "connections/:connectionId/scopes/:scopeId" + // Because there may be slash in scopeId (codecovId like "owner/repo"), so we handle it manually. + "GET": api.GetScopeDispatcher, + "PATCH": api.PatchScope, + "DELETE": api.DeleteScope, + }, + "connections/:connectionId/scope-configs": { + "POST": api.PostScopeConfig, + "GET": api.GetScopeConfigList, + }, + "connections/:connectionId/scope-configs/:id": { + "GET": api.GetScopeConfig, + "PATCH": api.PatchScopeConfig, + "DELETE": api.DeleteScopeConfig, + }, + "connections/:connectionId/remote-scopes": { + "GET": api.RemoteScopes, + }, + "connections/:connectionId/search-remote-scopes": { + "GET": api.SearchRemoteScopes, + }, + } +} + +func (p Codecov) SubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{ + // Step 1: Collect and convert flags first (needed for flag-based coverage collection) + tasks.CollectFlagsMeta, + tasks.ConvertFlagsMeta, + // Step 2: Collect commits + tasks.CollectCommitsMeta, + tasks.ExtractCommitsMeta, + // Step 3: Collect coverage data (depends on flags and commits) + tasks.CollectCommitTotalsMeta, + tasks.CollectCommitCoverageMeta, + tasks.CollectComparisonMeta, + tasks.CollectFlagCoverageTrendMeta, + // Step 4: Collect repo config (codecov.yml) from the code host + tasks.CollectRepoConfigMeta, + // Step 5: Convert coverage data + tasks.ConvertComparisonMeta, + tasks.ConvertCoverageMeta, + tasks.ConvertCommitCoverageMeta, + tasks.ConvertCoverageTrendMeta, + } +} + +func (p Codecov) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) { + op, err := tasks.DecodeAndValidateTaskOptions(options) + if err != nil { + return nil, err + } + + db := taskCtx.GetDal() + connection := &models.CodecovConnection{} + err = db.First(connection, dal.Where("id = ?", op.ConnectionId)) + if err != nil { + return nil, errors.BadInput.Wrap(err, "unable to get Codecov connection by the given connection ID") + } + + // Create synchronize API client (PrepareApiClient on connection will set headers) + apiClient, err := helper.NewApiClientFromConnection(taskCtx.GetContext(), taskCtx, connection) + if err != nil { + return nil, err + } + + // Create async API client with rate limiter optimized for Codecov + // Note: Go's http.Client already has connection pooling via http.Transport + rateLimiter := &helper.ApiRateLimitCalculator{ + UserRateLimitPerHour: 5000, // Codecov's rate limit (adjust based on your plan) + } + + asyncApiClient, err := helper.CreateAsyncApiClient( + taskCtx, + apiClient, + rateLimiter, + ) + if err != nil { + return nil, err + } + + taskCtx.GetLogger().Info("[Codecov] API client initialized with rate limiter (5000 req/hour)") + + service := connection.ServiceOrDefault() + if validErr := connection.ValidateService(); validErr != nil { + return nil, validErr + } + + // Load the CodecovRepo scope to get branch and other metadata + repo := &models.CodecovRepo{} + err = db.First(repo, dal.Where("connection_id = ? AND codecov_id = ?", op.ConnectionId, op.FullName)) + if err != nil { + taskCtx.GetLogger().Warn(err, "unable to load CodecovRepo scope for %s, branch will default to 'main'", op.FullName) + repo = nil + } + + // Auto-detect the default branch from the Codecov API + owner, repoName, parseErr := tasks.ParseFullName(op.FullName) + if parseErr != nil { + taskCtx.GetLogger().Warn(parseErr, "[Codecov] Failed to parse fullName '%s', branch detection skipped", op.FullName) + } else { + repoUrl := fmt.Sprintf("/%s/", tasks.RepoAPIPrefix(service, owner, repoName)) + res, apiErr := apiClient.Get(repoUrl, nil, nil) + if apiErr != nil { + taskCtx.GetLogger().Warn(apiErr, "[Codecov] Failed to fetch repo detail for %s, using stored branch", op.FullName) + } else if res.StatusCode != 200 { + taskCtx.GetLogger().Warn(nil, "[Codecov] Repo detail API returned status %d for %s, using stored branch", res.StatusCode, op.FullName) + } else { + var repoDetail struct { + Branch string `json:"branch"` + } + if unmarshalErr := helper.UnmarshalResponse(res, &repoDetail); unmarshalErr != nil { + taskCtx.GetLogger().Warn(unmarshalErr, "[Codecov] Failed to parse repo detail response for %s", op.FullName) + } else if repoDetail.Branch != "" { + if repo != nil && repo.Branch != repoDetail.Branch { + taskCtx.GetLogger().Info("[Codecov] Default branch updated: %s -> %s for %s", repo.Branch, repoDetail.Branch, op.FullName) + repo.Branch = repoDetail.Branch + if updateErr := db.Update(repo); updateErr != nil { + taskCtx.GetLogger().Warn(updateErr, "[Codecov] Failed to persist branch update for %s", op.FullName) + } + } else if repo != nil && repo.Branch == "" { + repo.Branch = repoDetail.Branch + if updateErr := db.Update(repo); updateErr != nil { + taskCtx.GetLogger().Warn(updateErr, "[Codecov] Failed to persist branch update for %s", op.FullName) + } + } else if repo == nil { + // Scope record not found in DB but API returned branch info; + // create a minimal in-memory repo so collectors can use the detected branch + repo = &models.CodecovRepo{ + CodecovId: op.FullName, + FullName: op.FullName, + Name: repoName, + Branch: repoDetail.Branch, + } + taskCtx.GetLogger().Info("[Codecov] No scope record found, using API-detected branch '%s' for %s", repoDetail.Branch, op.FullName) + } + } + } + } + + return &tasks.CodecovTaskData{ + Options: op, + ApiClient: asyncApiClient, + Repo: repo, + Service: service, + }, nil +} + +func (p Codecov) MakeDataSourcePipelinePlanV200( + connectionId uint64, + scopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, scopes) +} diff --git a/backend/plugins/codecov/models/commit.go b/backend/plugins/codecov/models/commit.go new file mode 100644 index 00000000000..6669a681cce --- /dev/null +++ b/backend/plugins/codecov/models/commit.go @@ -0,0 +1,40 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +type CodecovCommit struct { + common.NoPKModel // Includes CreatedAt, UpdatedAt, and RawDataOrigin + ConnectionId uint64 `gorm:"primaryKey;type:bigint" json:"connectionId"` + RepoId string `gorm:"primaryKey;type:varchar(200);index" json:"repoId"` + CommitSha string `gorm:"primaryKey;type:varchar(64);index" json:"commitSha"` + Branch string `gorm:"type:varchar(100)" json:"branch"` + CommitTimestamp *time.Time `gorm:"index" json:"commitTimestamp"` + Message string `gorm:"type:text" json:"message"` + Author string `gorm:"type:varchar(255)" json:"author"` + ParentSha string `gorm:"type:varchar(64)" json:"parentSha"` // Parent commit SHA for comparisons +} + +func (CodecovCommit) TableName() string { + return "_tool_codecov_commits" +} diff --git a/backend/plugins/codecov/models/commit_coverage.go b/backend/plugins/codecov/models/commit_coverage.go new file mode 100644 index 00000000000..3a01620e181 --- /dev/null +++ b/backend/plugins/codecov/models/commit_coverage.go @@ -0,0 +1,48 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +type CodecovCommitCoverage struct { + common.NoPKModel // Includes CreatedAt, UpdatedAt, and RawDataOrigin + ConnectionId uint64 `gorm:"primaryKey;type:bigint" json:"connectionId"` + RepoId string `gorm:"primaryKey;type:varchar(200);index" json:"repoId"` + CommitSha string `gorm:"primaryKey;type:varchar(64)" json:"commitSha"` + Branch string `gorm:"type:varchar(100)" json:"branch"` + CommitTimestamp *time.Time `gorm:"index" json:"commitTimestamp"` + OverallCoverage float64 `json:"overallCoverage"` + ModifiedCoverage float64 `json:"modifiedCoverage"` + FilesChanged int `json:"filesChanged"` + LinesCovered int `json:"linesCovered"` + LinesTotal int `json:"linesTotal"` + LinesMissed int `json:"linesMissed"` + Hits int `json:"hits"` + Partials int `json:"partials"` + Misses int `json:"misses"` + MethodsCovered int `json:"methodsCovered"` + MethodsTotal int `json:"methodsTotal"` +} + +func (CodecovCommitCoverage) TableName() string { + return "_tool_codecov_commit_coverages" +} diff --git a/backend/plugins/codecov/models/connection.go b/backend/plugins/codecov/models/connection.go new file mode 100644 index 00000000000..8e1e2489059 --- /dev/null +++ b/backend/plugins/codecov/models/connection.go @@ -0,0 +1,171 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "fmt" + "net/http" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +// CodecovAccessToken supports token-based authentication +type CodecovAccessToken struct { + helper.AccessToken `mapstructure:",squash"` +} + +// SetupAuthentication sets up the HTTP Request Authentication +func (conn *CodecovAccessToken) SetupAuthentication(req *http.Request) errors.Error { + if conn.Token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", conn.Token)) + } + return nil +} + +// ValidCodecovServices lists the service values accepted by the Codecov API v2. +var ValidCodecovServices = []string{ + "github", "gitlab", "bitbucket", + "github_enterprise", "gitlab_enterprise", "bitbucket_server", +} + +// CodecovConn holds the essential information to connect to the Codecov API +type CodecovConn struct { + helper.RestConnection `mapstructure:",squash"` + CodecovAccessToken `mapstructure:",squash"` + Organization string `mapstructure:"organization" json:"organization" gorm:"type:varchar(255)" validate:"required"` + Service string `mapstructure:"service" json:"service" gorm:"type:varchar(100);default:github"` +} + +// ServiceOrDefault returns the connection's Service, falling back to "github". +func (conn *CodecovConn) ServiceOrDefault() string { + if conn.Service == "" { + return "github" + } + return conn.Service +} + +// ValidateService checks that Service is one of the accepted values. +func (conn *CodecovConn) ValidateService() errors.Error { + s := conn.ServiceOrDefault() + for _, v := range ValidCodecovServices { + if v == s { + return nil + } + } + return errors.BadInput.New(fmt.Sprintf("invalid service %q, expected one of %v", s, ValidCodecovServices)) +} + +// PrepareApiClient configures the HTTP client headers for optimal performance +// Similar to Python pycodecov's ClientSession setup +func (conn *CodecovConn) PrepareApiClient(apiClient plugin.ApiClient) errors.Error { + // Set default headers for better compatibility and performance + // These help with connection keep-alive and proper API interaction + apiClient.SetHeaders(map[string]string{ + "Accept": "application/json", + "User-Agent": "DevLake/1.0 (Codecov Plugin)", + "Connection": "keep-alive", // Request persistent connections + "Content-Type": "application/json", + }) + + return nil +} + +// CodecovConnection holds CodecovConn plus ID/Name for database storage +type CodecovConnection struct { + helper.BaseConnection `mapstructure:",squash"` + CodecovConn `mapstructure:",squash"` +} + +func (connection CodecovConnection) TableName() string { + return "_tool_codecov_connections" +} + +func (connection *CodecovConnection) MergeFromRequest(target *CodecovConnection, body map[string]interface{}) error { + modifiedConnection := CodecovConnection{} + if err := helper.DecodeMapStruct(body, &modifiedConnection, true); err != nil { + return err + } + return connection.Merge(target, &modifiedConnection, body) +} + +func (connection *CodecovConnection) Merge(existed, modified *CodecovConnection, body map[string]interface{}) error { + existedTokenStr := existed.Token + existed.Name = modified.Name + existed.Organization = modified.Organization + if _, ok := body["service"]; ok { + existed.Service = modified.Service + } + existed.Proxy = modified.Proxy + existed.Endpoint = modified.Endpoint + existed.RateLimitPerHour = modified.RateLimitPerHour + + // handle token + if existedTokenStr == "" { + if modified.Token != "" { + existed.Token = modified.Token + } + } else { + if modified.Token == "" { + // delete token + existed.Token = modified.Token + } else { + // update token + sanitizeToken := existed.Sanitize().Token + if sanitizeToken == modified.Token { + // change nothing, restore it + existed.Token = existedTokenStr + } else { + // has changed, replace it with the new token + existed.Token = modified.Token + } + } + } + + return nil +} + +func (connection CodecovConnection) Sanitize() CodecovConnection { + connection.CodecovConn = connection.CodecovConn.Sanitize() + return connection +} + +func (conn *CodecovConn) Sanitize() CodecovConn { + conn.SanitizeToken() + return *conn +} + +func (conn *CodecovConn) SanitizeToken() CodecovConn { + if conn.Token == "" { + return *conn + } + // Codecov tokens are typically UUIDs or similar, mask most of it + if len(conn.Token) > 8 { + showPrefixLen := 4 + hiddenLen := len(conn.Token) - showPrefixLen - 4 + secret := "" + for i := 0; i < hiddenLen; i++ { + secret += "*" + } + conn.Token = conn.Token[:showPrefixLen] + secret + conn.Token[len(conn.Token)-4:] + } else { + conn.Token = "****" + } + return *conn +} diff --git a/backend/plugins/codecov/models/coverage.go b/backend/plugins/codecov/models/coverage.go new file mode 100644 index 00000000000..495b3fdcd08 --- /dev/null +++ b/backend/plugins/codecov/models/coverage.go @@ -0,0 +1,48 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +type CodecovCoverage struct { + common.NoPKModel // Includes CreatedAt, UpdatedAt, and RawDataOrigin + ConnectionId uint64 `gorm:"primaryKey;type:bigint" json:"connectionId"` + RepoId string `gorm:"primaryKey;type:varchar(200);index" json:"repoId"` + FlagName string `gorm:"primaryKey;type:varchar(100);index" json:"flagName"` + Branch string `gorm:"primaryKey;type:varchar(100)" json:"branch"` + CommitSha string `gorm:"primaryKey;type:varchar(64)" json:"commitSha"` + CommitTimestamp *time.Time `gorm:"index" json:"commitTimestamp"` + CoveragePercentage float64 `json:"coveragePercentage"` + ModifiedCoverage float64 `json:"modifiedCoverage"` // Modified/patch coverage for this flag + LinesCovered int `json:"linesCovered"` + LinesTotal int `json:"linesTotal"` + LinesMissed int `json:"linesMissed"` + Hits int `json:"hits"` + Partials int `json:"partials"` + Misses int `json:"misses"` + MethodsCovered int `json:"methodsCovered"` + MethodsTotal int `json:"methodsTotal"` +} + +func (CodecovCoverage) TableName() string { + return "_tool_codecov_coverages" +} diff --git a/backend/plugins/codecov/models/coverage_trend.go b/backend/plugins/codecov/models/coverage_trend.go new file mode 100644 index 00000000000..67d02183272 --- /dev/null +++ b/backend/plugins/codecov/models/coverage_trend.go @@ -0,0 +1,42 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +type CodecovCoverageTrend struct { + common.NoPKModel // Includes CreatedAt, UpdatedAt, and RawDataOrigin + ConnectionId uint64 `gorm:"primaryKey;type:bigint" json:"connectionId"` + RepoId string `gorm:"primaryKey;type:varchar(200);index" json:"repoId"` + FlagName string `gorm:"primaryKey;type:varchar(100);index" json:"flagName"` + Branch string `gorm:"primaryKey;type:varchar(100)" json:"branch"` + Date time.Time `gorm:"primaryKey;type:date" json:"date"` + CoveragePercentage float64 `json:"coveragePercentage"` + LinesCovered int `json:"linesCovered"` + LinesTotal int `json:"linesTotal"` + MethodsCovered int `json:"methodsCovered"` + MethodsTotal int `json:"methodsTotal"` +} + +func (CodecovCoverageTrend) TableName() string { + return "_tool_codecov_coverage_trends" +} diff --git a/backend/plugins/codecov/models/flag.go b/backend/plugins/codecov/models/flag.go new file mode 100644 index 00000000000..bba644c5dd8 --- /dev/null +++ b/backend/plugins/codecov/models/flag.go @@ -0,0 +1,37 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +type CodecovFlag struct { + common.NoPKModel // Includes CreatedAt, UpdatedAt, and RawDataOrigin + ConnectionId uint64 `gorm:"primaryKey;type:bigint" json:"connectionId"` + RepoId string `gorm:"primaryKey;type:varchar(200)" json:"repoId"` + FlagName string `gorm:"primaryKey;type:varchar(100)" json:"flagName"` + Carryforward bool `json:"carryforward"` + Deleted bool `json:"deleted"` + Yaml string `gorm:"type:text" json:"yaml"` + Coverage *float64 `json:"coverage"` // Latest overall coverage for this flag +} + +func (CodecovFlag) TableName() string { + return "_tool_codecov_flags" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251111164338_add_init_tables.go b/backend/plugins/codecov/models/migrationscripts/20251111164338_add_init_tables.go new file mode 100644 index 00000000000..cd14fa46c83 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251111164338_add_init_tables.go @@ -0,0 +1,105 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +// codecovConnection20251111 is a frozen snapshot of models.CodecovConnection as of 2025-11-11. +// Do not update this struct — create a new migration instead. +type codecovConnection20251111 struct { + helper.BaseConnection `mapstructure:",squash"` + helper.RestConnection `mapstructure:",squash"` + helper.AccessToken `mapstructure:",squash"` + Organization string `gorm:"type:varchar(255)"` +} + +func (codecovConnection20251111) TableName() string { + return "_tool_codecov_connections" +} + +// codecovRepo20251111 is a frozen snapshot of models.CodecovRepo as of 2025-11-11. +// Do not update this struct — create a new migration instead. +type codecovRepo20251111 struct { + // common.Scope fields (inlined) + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + ConnectionId uint64 `json:"connectionId" gorm:"primaryKey"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty"` + // CodecovRepo-specific fields + CodecovId string `gorm:"primaryKey;type:varchar(255)"` + Name string `gorm:"type:varchar(255)"` + FullName string `gorm:"type:varchar(255)"` + Service string `gorm:"type:varchar(255)"` + Language string `gorm:"type:varchar(255)"` + Active bool + ActivatedAt string `gorm:"type:varchar(255)"` + Updatestamp string `gorm:"type:varchar(255)"` + Private bool + Branch string `gorm:"type:varchar(255)"` +} + +func (codecovRepo20251111) TableName() string { + return "_tool_codecov_repos" +} + +// codecovScopeConfig20251111 is a frozen snapshot of models.CodecovScopeConfig as of 2025-11-11. +// Do not update this struct — create a new migration instead. +type codecovScopeConfig20251111 struct { + // common.ScopeConfig fields (inlined) + ID uint64 `gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Entities []string `gorm:"type:json;serializer:json" json:"entities"` + ConnectionId uint64 `json:"connectionId" gorm:"index"` + Name string `gorm:"type:varchar(255);uniqueIndex" json:"name"` +} + +func (codecovScopeConfig20251111) TableName() string { + return "_tool_codecov_scope_configs" +} + +type addInitTables struct{} + +func (u *addInitTables) Up(basicRes context.BasicRes) errors.Error { + err := migrationhelper.AutoMigrateTables( + basicRes, + &codecovConnection20251111{}, + &codecovRepo20251111{}, + &codecovScopeConfig20251111{}, + ) + return err +} + +func (*addInitTables) Version() uint64 { + return 20251111164338 +} + +func (*addInitTables) Name() string { + return "Codecov init schemas" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251112000000_add_coverage_tables.go b/backend/plugins/codecov/models/migrationscripts/20251112000000_add_coverage_tables.go new file mode 100644 index 00000000000..18fa8f44863 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251112000000_add_coverage_tables.go @@ -0,0 +1,198 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// codecovFlag20251112 is a frozen snapshot of models.CodecovFlag as of 2025-11-12. +// Do not update this struct — create a new migration instead. +type codecovFlag20251112 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200)"` + FlagName string `gorm:"primaryKey;type:varchar(100)"` + // fields + Carryforward bool + Deleted bool + Yaml string `gorm:"type:text"` + Coverage *float64 +} + +func (codecovFlag20251112) TableName() string { + return "_tool_codecov_flags" +} + +// codecovCommit20251112 is a frozen snapshot of models.CodecovCommit as of 2025-11-12. +// Do not update this struct — create a new migration instead. +type codecovCommit20251112 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + CommitSha string `gorm:"primaryKey;type:varchar(64);index"` + // fields + Branch string `gorm:"type:varchar(100)"` + CommitTimestamp *time.Time `gorm:"index"` + Message string `gorm:"type:text"` + Author string `gorm:"type:varchar(255)"` + ParentSha string `gorm:"type:varchar(64)"` +} + +func (codecovCommit20251112) TableName() string { + return "_tool_codecov_commits" +} + +// codecovCoverage20251112 is a frozen snapshot of models.CodecovCoverage as of 2025-11-12. +// At this time the model used common.Model (with ID) + common.RawDataOrigin. +// Do not update this struct — create a new migration instead. +type codecovCoverage20251112 struct { + // common.Model fields (as used at time of migration) + ID uint64 `gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // common.RawDataOrigin fields + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + FlagName string `gorm:"primaryKey;type:varchar(100);index"` + Branch string `gorm:"primaryKey;type:varchar(100)"` + CommitSha string `gorm:"primaryKey;type:varchar(64)"` + CommitTimestamp *time.Time `gorm:"index"` + // coverage metrics + CoveragePercentage float64 + ModifiedCoverage float64 + LinesCovered int + LinesTotal int + LinesMissed int + Hits int + Partials int + Misses int + MethodsCovered int + MethodsTotal int +} + +func (codecovCoverage20251112) TableName() string { + return "_tool_codecov_coverages" +} + +// codecovCoverageTrend20251112 is a frozen snapshot of models.CodecovCoverageTrend as of 2025-11-12. +// Do not update this struct — create a new migration instead. +type codecovCoverageTrend20251112 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + FlagName string `gorm:"primaryKey;type:varchar(100);index"` + Branch string `gorm:"primaryKey;type:varchar(100)"` + Date time.Time `gorm:"primaryKey;type:date"` + // fields + CoveragePercentage float64 + LinesCovered int + LinesTotal int + MethodsCovered int + MethodsTotal int +} + +func (codecovCoverageTrend20251112) TableName() string { + return "_tool_codecov_coverage_trends" +} + +// codecovCommitCoverage20251112 is a frozen snapshot of models.CodecovCommitCoverage as of 2025-11-12. +// Do not update this struct — create a new migration instead. +type codecovCommitCoverage20251112 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + CommitSha string `gorm:"primaryKey;type:varchar(64)"` + Branch string `gorm:"type:varchar(100)"` + CommitTimestamp *time.Time `gorm:"index"` + // fields + OverallCoverage float64 + ModifiedCoverage float64 + FilesChanged int + LinesCovered int + LinesTotal int + LinesMissed int + Hits int + Partials int + Misses int + MethodsCovered int + MethodsTotal int +} + +func (codecovCommitCoverage20251112) TableName() string { + return "_tool_codecov_commit_coverages" +} + +type addCoverageTables struct{} + +func (u *addCoverageTables) Up(basicRes context.BasicRes) errors.Error { + err := migrationhelper.AutoMigrateTables( + basicRes, + &codecovFlag20251112{}, + &codecovCommit20251112{}, + &codecovCoverage20251112{}, + &codecovCoverageTrend20251112{}, + &codecovCommitCoverage20251112{}, + ) + return err +} + +func (*addCoverageTables) Version() uint64 { + return 20251112000000 +} + +func (*addCoverageTables) Name() string { + return "Codecov add coverage tables" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251114000000_add_raw_data_origin_to_commits.go b/backend/plugins/codecov/models/migrationscripts/20251114000000_add_raw_data_origin_to_commits.go new file mode 100644 index 00000000000..d32d7f5993b --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251114000000_add_raw_data_origin_to_commits.go @@ -0,0 +1,69 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// codecovCommit20251114 is a frozen snapshot of models.CodecovCommit as of 2025-11-14. +// Do not update this struct — create a new migration instead. +type codecovCommit20251114 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + CommitSha string `gorm:"primaryKey;type:varchar(64);index"` + // fields + Branch string `gorm:"type:varchar(100)"` + CommitTimestamp *time.Time `gorm:"index"` + Message string `gorm:"type:text"` + Author string `gorm:"type:varchar(255)"` + ParentSha string `gorm:"type:varchar(64)"` +} + +func (codecovCommit20251114) TableName() string { + return "_tool_codecov_commits" +} + +type addRawDataOriginToCommits struct{} + +func (u *addRawDataOriginToCommits) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &codecovCommit20251114{}, + ) +} + +func (*addRawDataOriginToCommits) Version() uint64 { + return 20251114000000 +} + +func (*addRawDataOriginToCommits) Name() string { + return "Codecov add RawDataOrigin columns to commits table" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251115000000_add_raw_data_origin_to_flags.go b/backend/plugins/codecov/models/migrationscripts/20251115000000_add_raw_data_origin_to_flags.go new file mode 100644 index 00000000000..1b570d05eed --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251115000000_add_raw_data_origin_to_flags.go @@ -0,0 +1,68 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// codecovFlag20251115 is a frozen snapshot of models.CodecovFlag as of 2025-11-15. +// Do not update this struct — create a new migration instead. +type codecovFlag20251115 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200)"` + FlagName string `gorm:"primaryKey;type:varchar(100)"` + // fields + Carryforward bool + Deleted bool + Yaml string `gorm:"type:text"` + Coverage *float64 +} + +func (codecovFlag20251115) TableName() string { + return "_tool_codecov_flags" +} + +type addRawDataOriginToFlags struct{} + +func (u *addRawDataOriginToFlags) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &codecovFlag20251115{}, + ) +} + +func (*addRawDataOriginToFlags) Version() uint64 { + return 20251115000000 +} + +func (*addRawDataOriginToFlags) Name() string { + return "Codecov add RawDataOrigin to flags table" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251116000000_add_comparison_table.go b/backend/plugins/codecov/models/migrationscripts/20251116000000_add_comparison_table.go new file mode 100644 index 00000000000..46a256dc2bf --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251116000000_add_comparison_table.go @@ -0,0 +1,74 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// comparisonData20251116 is a frozen snapshot of tasks.ComparisonData as of 2025-11-16. +// Do not update this struct — create a new migration instead. +type comparisonData20251116 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + CommitSha string `gorm:"primaryKey;type:varchar(64);index"` + FlagName string `gorm:"primaryKey;type:varchar(100);index"` + // fields + ParentSha string `gorm:"type:varchar(64)"` + ModifiedCoverage float64 `gorm:"type:double"` + FilesChanged int `gorm:"type:int"` + MethodsCovered int `gorm:"type:int"` + MethodsTotal int `gorm:"type:int"` + LinesCovered int `gorm:"type:int"` + LinesTotal int `gorm:"type:int"` + LinesMissed int `gorm:"type:int"` + Patch *float64 `gorm:"type:double"` +} + +func (comparisonData20251116) TableName() string { + return "_tool_codecov_comparisons" +} + +type addComparisonTable struct{} + +func (u *addComparisonTable) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &comparisonData20251116{}, + ) +} + +func (*addComparisonTable) Version() uint64 { + return 20251116000000 +} + +func (*addComparisonTable) Name() string { + return "Codecov add comparison table" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251117000000_add_modified_coverage_to_coverages.go b/backend/plugins/codecov/models/migrationscripts/20251117000000_add_modified_coverage_to_coverages.go new file mode 100644 index 00000000000..aa2e649486a --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251117000000_add_modified_coverage_to_coverages.go @@ -0,0 +1,80 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// codecovCoverage20251117 is a frozen snapshot of models.CodecovCoverage as of 2025-11-17. +// At this time the model used common.Model (with ID) + common.RawDataOrigin. +// Do not update this struct — create a new migration instead. +type codecovCoverage20251117 struct { + // common.Model fields (as used at time of migration) + ID uint64 `gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // common.RawDataOrigin fields + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + FlagName string `gorm:"primaryKey;type:varchar(100);index"` + Branch string `gorm:"primaryKey;type:varchar(100)"` + CommitSha string `gorm:"primaryKey;type:varchar(64)"` + CommitTimestamp *time.Time `gorm:"index"` + // coverage metrics + CoveragePercentage float64 + ModifiedCoverage float64 + LinesCovered int + LinesTotal int + LinesMissed int + Hits int + Partials int + Misses int + MethodsCovered int + MethodsTotal int +} + +func (codecovCoverage20251117) TableName() string { + return "_tool_codecov_coverages" +} + +type addModifiedCoverageToCoverages struct{} + +func (u *addModifiedCoverageToCoverages) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &codecovCoverage20251117{}, + ) +} + +func (*addModifiedCoverageToCoverages) Version() uint64 { + return 20251117000000 +} + +func (*addModifiedCoverageToCoverages) Name() string { + return "Codecov add ModifiedCoverage to coverages table" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251118000000_add_raw_data_origin_to_coverage_tables.go b/backend/plugins/codecov/models/migrationscripts/20251118000000_add_raw_data_origin_to_coverage_tables.go new file mode 100644 index 00000000000..7088b5ced31 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251118000000_add_raw_data_origin_to_coverage_tables.go @@ -0,0 +1,144 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// codecovCoverage20251118 is a frozen snapshot of models.CodecovCoverage as of 2025-11-18. +// At this time the model used common.Model (with ID) + common.RawDataOrigin. +// Do not update this struct — create a new migration instead. +type codecovCoverage20251118 struct { + // common.Model fields (as used at time of migration) + ID uint64 `gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // common.RawDataOrigin fields + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + FlagName string `gorm:"primaryKey;type:varchar(100);index"` + Branch string `gorm:"primaryKey;type:varchar(100)"` + CommitSha string `gorm:"primaryKey;type:varchar(64)"` + CommitTimestamp *time.Time `gorm:"index"` + // coverage metrics + CoveragePercentage float64 + ModifiedCoverage float64 + LinesCovered int + LinesTotal int + LinesMissed int + Hits int + Partials int + Misses int + MethodsCovered int + MethodsTotal int +} + +func (codecovCoverage20251118) TableName() string { + return "_tool_codecov_coverages" +} + +// codecovCoverageTrend20251118 is a frozen snapshot of models.CodecovCoverageTrend as of 2025-11-18. +// Do not update this struct — create a new migration instead. +type codecovCoverageTrend20251118 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + FlagName string `gorm:"primaryKey;type:varchar(100);index"` + Branch string `gorm:"primaryKey;type:varchar(100)"` + Date time.Time `gorm:"primaryKey;type:date"` + // fields + CoveragePercentage float64 + LinesCovered int + LinesTotal int + MethodsCovered int + MethodsTotal int +} + +func (codecovCoverageTrend20251118) TableName() string { + return "_tool_codecov_coverage_trends" +} + +// codecovCommitCoverage20251118 is a frozen snapshot of models.CodecovCommitCoverage as of 2025-11-18. +// Do not update this struct — create a new migration instead. +type codecovCommitCoverage20251118 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + CommitSha string `gorm:"primaryKey;type:varchar(64)"` + Branch string `gorm:"type:varchar(100)"` + CommitTimestamp *time.Time `gorm:"index"` + // fields + OverallCoverage float64 + ModifiedCoverage float64 + FilesChanged int + LinesCovered int + LinesTotal int + LinesMissed int + Hits int + Partials int + Misses int + MethodsCovered int + MethodsTotal int +} + +func (codecovCommitCoverage20251118) TableName() string { + return "_tool_codecov_commit_coverages" +} + +type addRawDataOriginToCoverageTables struct{} + +func (u *addRawDataOriginToCoverageTables) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &codecovCoverage20251118{}, + &codecovCoverageTrend20251118{}, + &codecovCommitCoverage20251118{}, + ) +} + +func (*addRawDataOriginToCoverageTables) Version() uint64 { + return 20251118000000 +} + +func (*addRawDataOriginToCoverageTables) Name() string { + return "Codecov add RawDataOrigin to coverage tables" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251119000000_add_raw_data_origin_to_comparisons.go b/backend/plugins/codecov/models/migrationscripts/20251119000000_add_raw_data_origin_to_comparisons.go new file mode 100644 index 00000000000..b2e6d97615b --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251119000000_add_raw_data_origin_to_comparisons.go @@ -0,0 +1,74 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// comparisonData20251119 is a frozen snapshot of tasks.ComparisonData as of 2025-11-19. +// Do not update this struct — create a new migration instead. +type comparisonData20251119 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + CommitSha string `gorm:"primaryKey;type:varchar(64);index"` + FlagName string `gorm:"primaryKey;type:varchar(100);index"` + // fields + ParentSha string `gorm:"type:varchar(64)"` + ModifiedCoverage float64 `gorm:"type:double"` + FilesChanged int `gorm:"type:int"` + MethodsCovered int `gorm:"type:int"` + MethodsTotal int `gorm:"type:int"` + LinesCovered int `gorm:"type:int"` + LinesTotal int `gorm:"type:int"` + LinesMissed int `gorm:"type:int"` + Patch *float64 `gorm:"type:double"` +} + +func (comparisonData20251119) TableName() string { + return "_tool_codecov_comparisons" +} + +type addRawDataOriginToComparisons struct{} + +func (u *addRawDataOriginToComparisons) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &comparisonData20251119{}, + ) +} + +func (*addRawDataOriginToComparisons) Version() uint64 { + return 20251119000000 +} + +func (*addRawDataOriginToComparisons) Name() string { + return "Codecov add RawDataOrigin to comparisons table" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251120000000_add_modified_lines_to_comparisons.go b/backend/plugins/codecov/models/migrationscripts/20251120000000_add_modified_lines_to_comparisons.go new file mode 100644 index 00000000000..18bd5d03d04 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251120000000_add_modified_lines_to_comparisons.go @@ -0,0 +1,74 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// comparisonData20251120 is a frozen snapshot of tasks.ComparisonData as of 2025-11-20. +// Do not update this struct — create a new migration instead. +type comparisonData20251120 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + CommitSha string `gorm:"primaryKey;type:varchar(64);index"` + FlagName string `gorm:"primaryKey;type:varchar(100);index"` + // fields + ParentSha string `gorm:"type:varchar(64)"` + ModifiedCoverage float64 `gorm:"type:double"` + FilesChanged int `gorm:"type:int"` + MethodsCovered int `gorm:"type:int"` + MethodsTotal int `gorm:"type:int"` + LinesCovered int `gorm:"type:int"` + LinesTotal int `gorm:"type:int"` + LinesMissed int `gorm:"type:int"` + Patch *float64 `gorm:"type:double"` +} + +func (comparisonData20251120) TableName() string { + return "_tool_codecov_comparisons" +} + +type addModifiedLinesToComparisons struct{} + +func (u *addModifiedLinesToComparisons) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &comparisonData20251120{}, + ) +} + +func (*addModifiedLinesToComparisons) Version() uint64 { + return 20251120000000 +} + +func (*addModifiedLinesToComparisons) Name() string { + return "Codecov add modified lines to comparisons table" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251122000000_add_patch_to_coverages.go b/backend/plugins/codecov/models/migrationscripts/20251122000000_add_patch_to_coverages.go new file mode 100644 index 00000000000..165b44db641 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251122000000_add_patch_to_coverages.go @@ -0,0 +1,79 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// codecovCoverage20251122 is a frozen snapshot of models.CodecovCoverage as of 2025-11-22. +// Do not update this struct — create a new migration instead. +type codecovCoverage20251122 struct { + // common.Model fields (as used at time of migration) + ID uint64 `gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // common.RawDataOrigin fields + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary key fields + ConnectionId uint64 `gorm:"primaryKey;type:bigint" json:"connectionId"` + RepoId string `gorm:"primaryKey;type:varchar(200);index" json:"repoId"` + FlagName string `gorm:"primaryKey;type:varchar(100);index" json:"flagName"` + Branch string `gorm:"primaryKey;type:varchar(100)" json:"branch"` + CommitSha string `gorm:"primaryKey;type:varchar(64)" json:"commitSha"` + CommitTimestamp *time.Time `gorm:"index" json:"commitTimestamp"` + // coverage metrics + CoveragePercentage float64 `json:"coveragePercentage"` + ModifiedCoverage float64 `json:"modifiedCoverage"` + LinesCovered int `json:"linesCovered"` + LinesTotal int `json:"linesTotal"` + LinesMissed int `json:"linesMissed"` + Hits int `json:"hits"` + Partials int `json:"partials"` + Misses int `json:"misses"` + MethodsCovered int `json:"methodsCovered"` + MethodsTotal int `json:"methodsTotal"` +} + +func (codecovCoverage20251122) TableName() string { + return "_tool_codecov_coverages" +} + +type addPatchToCoverages struct{} + +func (u *addPatchToCoverages) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &codecovCoverage20251122{}, + ) +} + +func (*addPatchToCoverages) Version() uint64 { + return 20251122000000 +} + +func (*addPatchToCoverages) Name() string { + return "Codecov add Patch to coverages table" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251123000000_add_patch_to_comparisons.go b/backend/plugins/codecov/models/migrationscripts/20251123000000_add_patch_to_comparisons.go new file mode 100644 index 00000000000..106564f369f --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251123000000_add_patch_to_comparisons.go @@ -0,0 +1,74 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// comparisonData20251123 is a frozen snapshot of tasks.ComparisonData as of 2025-11-23. +// Do not update this struct — create a new migration instead. +type comparisonData20251123 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + CommitSha string `gorm:"primaryKey;type:varchar(64);index"` + FlagName string `gorm:"primaryKey;type:varchar(100);index"` + // fields + ParentSha string `gorm:"type:varchar(64)"` + ModifiedCoverage float64 `gorm:"type:double"` + FilesChanged int `gorm:"type:int"` + MethodsCovered int `gorm:"type:int"` + MethodsTotal int `gorm:"type:int"` + LinesCovered int `gorm:"type:int"` + LinesTotal int `gorm:"type:int"` + LinesMissed int `gorm:"type:int"` + Patch *float64 `gorm:"type:double"` +} + +func (comparisonData20251123) TableName() string { + return "_tool_codecov_comparisons" +} + +type addPatchToComparisons struct{} + +func (u *addPatchToComparisons) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &comparisonData20251123{}, + ) +} + +func (*addPatchToComparisons) Version() uint64 { + return 20251123000000 +} + +func (*addPatchToComparisons) Name() string { + return "Codecov add Patch to comparisons table" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20251206100000_add_coverage_to_flags.go b/backend/plugins/codecov/models/migrationscripts/20251206100000_add_coverage_to_flags.go new file mode 100644 index 00000000000..5ca995541c4 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20251206100000_add_coverage_to_flags.go @@ -0,0 +1,49 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*addCoverageToFlags)(nil) + +type addCoverageToFlags struct{} + +type codecovFlag20251206 struct { + Coverage *float64 +} + +func (codecovFlag20251206) TableName() string { + return "_tool_codecov_flags" +} + +func (script *addCoverageToFlags) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables(basicRes, &codecovFlag20251206{}) +} + +func (*addCoverageToFlags) Version() uint64 { + return 20251206100000 +} + +func (*addCoverageToFlags) Name() string { + return "Codecov add coverage column to flags table" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20260316000000_add_line_counts_to_commit_coverages.go b/backend/plugins/codecov/models/migrationscripts/20260316000000_add_line_counts_to_commit_coverages.go new file mode 100644 index 00000000000..cabfda6bf77 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20260316000000_add_line_counts_to_commit_coverages.go @@ -0,0 +1,54 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*addLineCountsToCommitCoverages)(nil) + +type addLineCountsToCommitCoverages struct{} + +type commitCoverage20260316 struct { + LinesCovered int + LinesTotal int + LinesMissed int + Hits int + Partials int + Misses int +} + +func (commitCoverage20260316) TableName() string { + return "_tool_codecov_commit_coverages" +} + +func (script *addLineCountsToCommitCoverages) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables(basicRes, &commitCoverage20260316{}) +} + +func (*addLineCountsToCommitCoverages) Version() uint64 { + return 20260316000000 +} + +func (*addLineCountsToCommitCoverages) Name() string { + return "Codecov add line count columns to commit_coverages table" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20260615000000_fix_commits_dedup_and_drop_id.go b/backend/plugins/codecov/models/migrationscripts/20260615000000_fix_commits_dedup_and_drop_id.go new file mode 100644 index 00000000000..e95f7f77c15 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20260615000000_fix_commits_dedup_and_drop_id.go @@ -0,0 +1,114 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// codecovCommit20260615 is a frozen snapshot of models.CodecovCommit as of 2026-06-15. +// Do not update this struct — create a new migration instead. +type codecovCommit20260615 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + CommitSha string `gorm:"primaryKey;type:varchar(64);index"` + // fields + Branch string `gorm:"type:varchar(100)"` + CommitTimestamp *time.Time `gorm:"index"` + Message string `gorm:"type:text"` + Author string `gorm:"type:varchar(255)"` + ParentSha string `gorm:"type:varchar(64)"` +} + +func (codecovCommit20260615) TableName() string { + return "_tool_codecov_commits" +} + +type fixCommitsDedupAndDropId struct{} + +// Up deduplicates _tool_codecov_commits and ensures the primary key is +// (connection_id, repo_id, commit_sha) without an auto_increment id column. +// +// In production the table has an id AUTO_INCREMENT column that prevents +// ON DUPLICATE KEY UPDATE from firing. In dev databases the id column may +// not exist. This migration handles both cases. +func (u *fixCommitsDedupAndDropId) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + logger := basicRes.GetLogger() + + // Check if the id column exists by trying to select it + idExists := true + err := db.Exec(`SELECT id FROM _tool_codecov_commits LIMIT 0`) + if err != nil { + idExists = false + logger.Info("_tool_codecov_commits has no id column, skipping id-based dedup") + } + + if idExists { + logger.Info("_tool_codecov_commits has id column — deduplicating and dropping it") + + err = db.Exec(` + DELETE t1 FROM _tool_codecov_commits t1 + INNER JOIN _tool_codecov_commits t2 + ON t1.connection_id = t2.connection_id + AND t1.repo_id = t2.repo_id + AND t1.commit_sha = t2.commit_sha + AND t1.id < t2.id + `) + if err != nil { + return err + } + + err = db.Exec(`ALTER TABLE _tool_codecov_commits MODIFY id BIGINT UNSIGNED NOT NULL`) + if err != nil { + return err + } + err = db.Exec(`ALTER TABLE _tool_codecov_commits DROP PRIMARY KEY`) + if err != nil { + return err + } + err = db.Exec(`ALTER TABLE _tool_codecov_commits DROP COLUMN id`) + if err != nil { + return err + } + } + + // Use AutoMigrate to ensure the table matches the current model definition, + // which has PK on (connection_id, repo_id, commit_sha) and no id column. + return migrationhelper.AutoMigrateTables(basicRes, &codecovCommit20260615{}) +} + +func (*fixCommitsDedupAndDropId) Version() uint64 { + return 20260615000000 +} + +func (*fixCommitsDedupAndDropId) Name() string { + return "Codecov fix commits table: deduplicate rows and drop auto_increment id to enable upserts" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20260616000000_add_repo_config_table.go b/backend/plugins/codecov/models/migrationscripts/20260616000000_add_repo_config_table.go new file mode 100644 index 00000000000..d86f65cf424 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20260616000000_add_repo_config_table.go @@ -0,0 +1,70 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// codecovRepoConfig20260616 is a frozen snapshot of models.CodecovRepoConfig as of 2026-06-16. +// Do not update this struct — create a new migration instead. +type codecovRepoConfig20260616 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200)"` + // fields + ConfigSource string `gorm:"type:varchar(50)"` + RawYaml string `gorm:"type:text"` + ProjectTarget *float64 + ProjectTargetAuto bool + ProjectThreshold *float64 + ProjectInformational bool + PatchTarget *float64 + PatchTargetAuto bool + PatchThreshold *float64 + PatchInformational bool +} + +func (codecovRepoConfig20260616) TableName() string { + return "_tool_codecov_repo_configs" +} + +type addRepoConfigTable struct{} + +func (u *addRepoConfigTable) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables(basicRes, &codecovRepoConfig20260616{}) +} + +func (*addRepoConfigTable) Version() uint64 { + return 20260616000000 +} + +func (*addRepoConfigTable) Name() string { + return "Codecov add repo config table for coverage thresholds" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20260622000000_dedup_raw_tables.go b/backend/plugins/codecov/models/migrationscripts/20260622000000_dedup_raw_tables.go new file mode 100644 index 00000000000..322b0ac05d4 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20260622000000_dedup_raw_tables.go @@ -0,0 +1,119 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "fmt" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" +) + +type dedupRawTables struct{} + +// dedupOneTable deduplicates a raw table by keeping only one row per unique +// (params, input) combination (the one with the highest id). It uses a +// create-new / atomic-rename strategy to avoid long-running deletes and +// heavy row-level locks on production. +func dedupOneTable(basicRes context.BasicRes, table string) errors.Error { + db := basicRes.GetDal() + logger := basicRes.GetLogger() + + if !db.HasTable(table) { + logger.Info("[dedup-raw] table %s does not exist, skipping", table) + return nil + } + + newTable := table + "_dedup" + oldTable := table + "_old" + + // Clean up any leftover temp tables from a previous interrupted run. + // These DROPs use IF EXISTS so the only real failure mode is permissions/locks. + if dropErr := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS `%s`", newTable)); dropErr != nil { + logger.Warn(dropErr, "[dedup-raw] failed to drop leftover %s", newTable) + } + if dropErr := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS `%s`", oldTable)); dropErr != nil { + logger.Warn(dropErr, "[dedup-raw] failed to drop leftover %s", oldTable) + } + + logger.Info("[dedup-raw] deduplicating %s — creating clean copy", table) + + // 1. Create a new table with the same schema (no data) + err := db.Exec(fmt.Sprintf("CREATE TABLE `%s` LIKE `%s`", newTable, table)) + if err != nil { + return errors.Default.Wrap(err, fmt.Sprintf("failed to create %s", newTable)) + } + + // 2. Copy only unique rows — keep the latest (MAX id) per (params, input) + err = db.Exec(fmt.Sprintf(` + INSERT INTO %s + SELECT t.* FROM %s t + INNER JOIN ( + SELECT MAX(id) AS max_id + FROM %s + GROUP BY params, CAST(input AS CHAR) + ) keep ON t.id = keep.max_id + `, newTable, table, table)) + if err != nil { + if cleanupErr := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS `%s`", newTable)); cleanupErr != nil { + logger.Warn(cleanupErr, "[dedup-raw] also failed to clean up %s after INSERT error", newTable) + } + return errors.Default.Wrap(err, fmt.Sprintf("failed to copy unique rows into %s", newTable)) + } + + // 3. Atomic rename: original → backup, new → original + err = db.Exec(fmt.Sprintf("RENAME TABLE `%s` TO `%s`, `%s` TO `%s`", + table, oldTable, newTable, table)) + if err != nil { + if cleanupErr := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS `%s`", newTable)); cleanupErr != nil { + logger.Warn(cleanupErr, "[dedup-raw] also failed to clean up %s after RENAME error", newTable) + } + return errors.Default.Wrap(err, fmt.Sprintf("failed to rename tables for %s", table)) + } + + // 4. Drop the old bloated table to reclaim disk space + err = db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS `%s`", oldTable)) + if err != nil { + logger.Warn(err, "[dedup-raw] could not drop %s — clean up manually", oldTable) + } + + logger.Info("[dedup-raw] %s: dedup complete", table) + return nil +} + +func (u *dedupRawTables) Up(basicRes context.BasicRes) errors.Error { + tables := []string{ + "_raw_codecov_api_commit_coverages", + "_raw_codecov_api_comparisons", + "_raw_codecov_api_commit_totals", + } + for _, t := range tables { + if err := dedupOneTable(basicRes, t); err != nil { + return err + } + } + return nil +} + +func (*dedupRawTables) Version() uint64 { + return 20260622000000 +} + +func (*dedupRawTables) Name() string { + return "Codecov deduplicate raw API tables to fix converter performance" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20260804000000_add_service_to_connections.go b/backend/plugins/codecov/models/migrationscripts/20260804000000_add_service_to_connections.go new file mode 100644 index 00000000000..16223dec735 --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20260804000000_add_service_to_connections.go @@ -0,0 +1,46 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" +) + +type addServiceToConnections struct{} + +func (*addServiceToConnections) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + return db.AutoMigrate(&addServiceToConnections20260804{}) +} + +func (*addServiceToConnections) Version() uint64 { + return 20260804000000 +} + +func (*addServiceToConnections) Name() string { + return "Codecov add service field to connections for multi-service support" +} + +type addServiceToConnections20260804 struct { + Service string `gorm:"column:service;type:varchar(100);default:github"` +} + +func (addServiceToConnections20260804) TableName() string { + return "_tool_codecov_connections" +} diff --git a/backend/plugins/codecov/models/migrationscripts/20261007000000_fix_commits_primary_key.go b/backend/plugins/codecov/models/migrationscripts/20261007000000_fix_commits_primary_key.go new file mode 100644 index 00000000000..a61a7e68afb --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/20261007000000_fix_commits_primary_key.go @@ -0,0 +1,137 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" +) + +type fixCommitsPrimaryKey struct{} + +// Up deduplicates _tool_codecov_commits and adds the missing composite +// primary key (connection_id, repo_id, commit_sha). +// +// Migration 20260615 dropped the old auto_increment id and called +// AutoMigrateTables, but MySQL's AutoMigrate does not reliably add a +// composite PK to an existing table. The result: no PK, so +// CreateOrUpdate always inserts, causing ~32x row duplication. +func (*fixCommitsPrimaryKey) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + logger := basicRes.GetLogger() + + // 1. Clean up leftover tables from a previous interrupted run (before any checks) + if dropErr := db.Exec(`DROP TABLE IF EXISTS _tool_codecov_commits_dedup`); dropErr != nil { + logger.Warn(dropErr, "[fix-commits-pk] failed to drop leftover _dedup table") + } + if dropErr := db.Exec(`DROP TABLE IF EXISTS _tool_codecov_commits_old`); dropErr != nil { + logger.Warn(dropErr, "[fix-commits-pk] failed to drop leftover _old table") + } + + // 2. Check if PK already exists (idempotent) + rows, err := db.RawCursor(` + SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = '_tool_codecov_commits' + AND CONSTRAINT_TYPE = 'PRIMARY KEY' + `) + if err == nil && rows != nil { + defer rows.Close() + if rows.Next() { + var pkCount int + if scanErr := rows.Scan(&pkCount); scanErr == nil && pkCount > 0 { + logger.Info("[fix-commits-pk] PK already exists, skipping") + return nil + } + } + } + + // 3. Deduplicate — keep exactly one row per (connection_id, repo_id, commit_sha). + // Uses ROW_NUMBER with a deterministic tie-breaker (updated_at DESC, created_at DESC) + // to handle duplicates that share the same updated_at timestamp. + logger.Info("[fix-commits-pk] Deduplicating _tool_codecov_commits") + + err = db.Exec(`CREATE TABLE _tool_codecov_commits_dedup LIKE _tool_codecov_commits`) + if err != nil { + return errors.Default.Wrap(err, "failed to create dedup table") + } + + err = db.Exec(` + INSERT INTO _tool_codecov_commits_dedup + (created_at, updated_at, _raw_data_params, _raw_data_table, + _raw_data_id, _raw_data_remark, connection_id, repo_id, + commit_sha, branch, commit_timestamp, message, author, parent_sha) + SELECT t.created_at, t.updated_at, t._raw_data_params, t._raw_data_table, + t._raw_data_id, t._raw_data_remark, t.connection_id, t.repo_id, + t.commit_sha, t.branch, t.commit_timestamp, t.message, t.author, t.parent_sha + FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY connection_id, repo_id, commit_sha + ORDER BY updated_at DESC, created_at DESC + ) AS rn + FROM _tool_codecov_commits + ) t WHERE t.rn = 1 + `) + if err != nil { + if dropErr := db.Exec(`DROP TABLE IF EXISTS _tool_codecov_commits_dedup`); dropErr != nil { + logger.Warn(dropErr, "[fix-commits-pk] also failed to clean up _dedup table after INSERT error") + } + return errors.Default.Wrap(err, "failed to copy unique rows") + } + + // 4. Add PK on the replacement BEFORE swapping — validates uniqueness + // and avoids exposing a keyless table if ALTER fails. + err = db.Exec(` + ALTER TABLE _tool_codecov_commits_dedup + ADD PRIMARY KEY (connection_id, repo_id, commit_sha) + `) + if err != nil { + if dropErr := db.Exec(`DROP TABLE IF EXISTS _tool_codecov_commits_dedup`); dropErr != nil { + logger.Warn(dropErr, "[fix-commits-pk] also failed to clean up _dedup table after PK error") + } + return errors.Default.Wrap(err, "failed to add primary key on dedup table") + } + + // 5. Atomic rename: swap dedup into service + err = db.Exec(` + RENAME TABLE _tool_codecov_commits TO _tool_codecov_commits_old, + _tool_codecov_commits_dedup TO _tool_codecov_commits + `) + if err != nil { + if dropErr := db.Exec(`DROP TABLE IF EXISTS _tool_codecov_commits_dedup`); dropErr != nil { + logger.Warn(dropErr, "[fix-commits-pk] also failed to clean up _dedup table after RENAME error") + } + return errors.Default.Wrap(err, "failed to rename tables") + } + + // 6. Drop old bloated table + if dropErr := db.Exec(`DROP TABLE IF EXISTS _tool_codecov_commits_old`); dropErr != nil { + logger.Warn(dropErr, "[fix-commits-pk] could not drop _old table — clean up manually") + } + + logger.Info("[fix-commits-pk] Done — duplicates removed, PK added") + return nil +} + +func (*fixCommitsPrimaryKey) Version() uint64 { + return 20261007000000 +} + +func (*fixCommitsPrimaryKey) Name() string { + return "Codecov fix missing primary key on commits table and remove duplicates" +} diff --git a/backend/plugins/codecov/models/migrationscripts/register.go b/backend/plugins/codecov/models/migrationscripts/register.go new file mode 100644 index 00000000000..186ffa1838c --- /dev/null +++ b/backend/plugins/codecov/models/migrationscripts/register.go @@ -0,0 +1,46 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/plugin" +) + +// All return all the migration scripts +func All() []plugin.MigrationScript { + return []plugin.MigrationScript{ + new(addInitTables), + new(addCoverageTables), + new(addRawDataOriginToCommits), + new(addRawDataOriginToFlags), + new(addComparisonTable), + new(addModifiedCoverageToCoverages), + new(addRawDataOriginToCoverageTables), + new(addRawDataOriginToComparisons), + new(addModifiedLinesToComparisons), + new(addPatchToCoverages), + new(addPatchToComparisons), + new(addCoverageToFlags), + new(addLineCountsToCommitCoverages), + new(fixCommitsDedupAndDropId), + new(addRepoConfigTable), + new(dedupRawTables), + new(addServiceToConnections), + new(fixCommitsPrimaryKey), + } +} diff --git a/backend/plugins/codecov/models/repo.go b/backend/plugins/codecov/models/repo.go new file mode 100644 index 00000000000..2089de4b8ff --- /dev/null +++ b/backend/plugins/codecov/models/repo.go @@ -0,0 +1,82 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "encoding/json" + + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.ToolLayerScope = (*CodecovRepo)(nil) + +type CodecovRepo struct { + common.Scope `mapstructure:",squash" gorm:"embedded"` + CodecovId string `json:"codecovId" gorm:"primaryKey;type:varchar(255)" validate:"required" mapstructure:"codecovId"` + Name string `json:"name" gorm:"type:varchar(255)" mapstructure:"name,omitempty"` + FullName string `json:"fullName" gorm:"type:varchar(255)" mapstructure:"fullName,omitempty"` + Service string `json:"service" gorm:"type:varchar(255)" mapstructure:"service,omitempty"` + Language string `json:"language" gorm:"type:varchar(255)" mapstructure:"language,omitempty"` + Active bool `json:"active" mapstructure:"active,omitempty"` + ActivatedAt string `json:"activatedAt" gorm:"type:varchar(255)" mapstructure:"activatedAt,omitempty"` + Updatestamp string `json:"updatestamp" gorm:"type:varchar(255)" mapstructure:"updatestamp,omitempty"` + Private bool `json:"private" mapstructure:"private,omitempty"` + Branch string `json:"branch" gorm:"type:varchar(255)" mapstructure:"branch,omitempty"` + Id string `gorm:"-" json:"id" mapstructure:"-"` // Computed field: same as CodecovId, for frontend compatibility +} + +// MarshalJSON customizes JSON serialization to populate Id field from CodecovId +func (r CodecovRepo) MarshalJSON() ([]byte, error) { + // Create an alias type to avoid infinite recursion + type Alias CodecovRepo + + // Populate Id from CodecovId for frontend compatibility + alias := Alias(r) + alias.Id = r.CodecovId + + return json.Marshal(alias) +} + +func (r CodecovRepo) ScopeId() string { + return r.CodecovId +} + +func (r CodecovRepo) ScopeName() string { + return r.Name +} + +func (r CodecovRepo) ScopeFullName() string { + return r.FullName +} + +func (r CodecovRepo) ScopeParams() interface{} { + return &CodecovApiParams{ + ConnectionId: r.ConnectionId, + Name: r.FullName, + } +} + +func (CodecovRepo) TableName() string { + return "_tool_codecov_repos" +} + +type CodecovApiParams struct { + ConnectionId uint64 + Name string +} diff --git a/backend/plugins/codecov/models/repo_config.go b/backend/plugins/codecov/models/repo_config.go new file mode 100644 index 00000000000..f41a6d05bfa --- /dev/null +++ b/backend/plugins/codecov/models/repo_config.go @@ -0,0 +1,42 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +type CodecovRepoConfig struct { + common.NoPKModel + ConnectionId uint64 `gorm:"primaryKey;type:bigint" json:"connectionId"` + RepoId string `gorm:"primaryKey;type:varchar(200)" json:"repoId"` + ConfigSource string `gorm:"type:varchar(50)" json:"configSource"` + RawYaml string `gorm:"type:text" json:"rawYaml"` + ProjectTarget *float64 `json:"projectTarget"` + ProjectTargetAuto bool `json:"projectTargetAuto"` + ProjectThreshold *float64 `json:"projectThreshold"` + ProjectInformational bool `json:"projectInformational"` + PatchTarget *float64 `json:"patchTarget"` + PatchTargetAuto bool `json:"patchTargetAuto"` + PatchThreshold *float64 `json:"patchThreshold"` + PatchInformational bool `json:"patchInformational"` +} + +func (CodecovRepoConfig) TableName() string { + return "_tool_codecov_repo_configs" +} diff --git a/backend/plugins/codecov/models/scope_config.go b/backend/plugins/codecov/models/scope_config.go new file mode 100644 index 00000000000..c73861c60a7 --- /dev/null +++ b/backend/plugins/codecov/models/scope_config.go @@ -0,0 +1,39 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.ToolLayerScopeConfig = (*CodecovScopeConfig)(nil) + +type CodecovScopeConfig struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` +} + +// GetConnectionId implements plugin.ToolLayerScopeConfig. +func (sc CodecovScopeConfig) GetConnectionId() uint64 { + return sc.ConnectionId +} + +func (CodecovScopeConfig) TableName() string { + return "_tool_codecov_scope_configs" +} + diff --git a/backend/plugins/codecov/skills/pr-definition-of-done/SKILL.md b/backend/plugins/codecov/skills/pr-definition-of-done/SKILL.md new file mode 100644 index 00000000000..f1da097b805 --- /dev/null +++ b/backend/plugins/codecov/skills/pr-definition-of-done/SKILL.md @@ -0,0 +1,41 @@ +--- +description: >- + Checklist for merge-ready PRs touching the codecov plugin. + Use when preparing, reviewing, or finalizing a PR for codecov. +--- + +# PR Definition of Done — codecov Plugin + +## Required for All PRs + +- [ ] Changes scoped to `backend/plugins/codecov/` +- [ ] `go vet ./plugins/codecov/...` passes +- [ ] `go test ./plugins/codecov/... -v` passes +- [ ] `golangci-lint run ./plugins/codecov/...` passes (CI only runs lint on main, so check locally) +- [ ] No `//nolint` without justification comment +- [ ] Apache 2.0 license header on every new `.go` file + +## Schema / Model Changes + +- [ ] Migration script in `models/migrationscripts/` named `YYYYMMDD_description.go` +- [ ] Migration registered in `migrationscripts/register.go:All()` +- [ ] New models added to `GetTablesInfo()` in `impl/impl.go` +- [ ] Table name follows `_tool_codecov_` convention + +## New Subtasks + +- [ ] Registered in `impl/impl.go:SubTaskMetas()` in correct execution order +- [ ] Collectors before extractors before converters + +## Plugin-Specific + +- [ ] `FullName` format validated as `"owner/repo"` (use `tasks.ParseFullName`) +- [ ] Rate limiter unchanged at 5000 req/hour unless Codecov plan warrants it +- [ ] Branch auto-detection in `PrepareTaskData()` not broken + +## CI Behavior + +- **Unit tests**: Run on PRs via `make unit-test-go` (excludes `e2e/` and `models/` packages) +- **E2E tests**: Playwright spec at `e2e/codecov-flow.spec.ts` runs in `test-e2e-coverage` workflow (requires `CODECOV_TOKEN` secret) +- **Lint**: Runs only on push to main/tags, not on PRs +- Upstream CI also runs `plugins/table_info_test.go` — fails if `GetTablesInfo()` is incomplete diff --git a/backend/plugins/codecov/tasks/commit_coverage_collector.go b/backend/plugins/codecov/tasks/commit_coverage_collector.go new file mode 100644 index 00000000000..54bb873060a --- /dev/null +++ b/backend/plugins/codecov/tasks/commit_coverage_collector.go @@ -0,0 +1,194 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +type CommitFlagInput struct { + CommitSha string `json:"commit_sha"` + FlagName string `json:"flag_name"` +} + +var CollectCommitCoverageMeta = plugin.SubTaskMeta{ + Name: "CollectCommitCoverage", + EntryPoint: CollectCommitCoverage, + EnabledByDefault: true, + Description: "Collect coverage per flag per commit from Codecov API", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&ConvertFlagsMeta}, + DependencyTables: []string{models.CodecovCommit{}.TableName(), models.CodecovFlag{}.TableName()}, +} + +func CollectCommitCoverage(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + + // Extract owner and repo from FullName + owner, repo, err := ParseFullName(data.Options.FullName) + if err != nil { + return err + } + + // Use sync policy time range, default to last 90 days + var startDate time.Time + syncPolicy := taskCtx.TaskContext().SyncPolicy() + if syncPolicy != nil && syncPolicy.TimeAfter != nil { + startDate = *syncPolicy.TimeAfter + logger.Info("[Codecov] CommitCoverage: Using sync policy from %s", startDate.Format("2006-01-02")) + } else { + startDate = time.Now().AddDate(0, 0, -90) + logger.Info("[Codecov] CommitCoverage: Using default 90 days from %s", startDate.Format("2006-01-02")) + } + + // Get distinct commits filtered by sync policy. + // The commits table may lack a PK (pre-migration), causing duplicate rows. + // Only commit_sha is needed downstream for the API iterator. + var commits []models.CodecovCommit + err = db.All(&commits, + dal.Select("DISTINCT connection_id, repo_id, commit_sha"), + dal.Where("connection_id = ? AND repo_id = ? AND commit_timestamp >= ?", data.Options.ConnectionId, data.Options.FullName, startDate), + ) + if err != nil { + return err + } + + // Get all flags + var flags []models.CodecovFlag + err = db.All(&flags, dal.Where("connection_id = ? AND repo_id = ?", data.Options.ConnectionId, data.Options.FullName)) + if err != nil { + return err + } + + // Get existing coverage records to skip already collected data (OPTIMIZATION) + var existingCoverages []models.CodecovCoverage + err = db.All(&existingCoverages, dal.Where("connection_id = ? AND repo_id = ?", data.Options.ConnectionId, data.Options.FullName)) + if err != nil { + return err + } + + // Build a set of already collected commit+flag combinations. + // Zero-data rows younger than 7 days are NOT marked as collected — they get + // re-fetched in case the coverage upload arrived after the previous collection. + retryWindow := time.Now().Add(-7 * 24 * time.Hour) + collectedSet := make(map[string]bool) + for _, cov := range existingCoverages { + key := fmt.Sprintf("%s|%s", cov.CommitSha, cov.FlagName) + if cov.LinesTotal > 0 { + collectedSet[key] = true + } else if cov.CommitTimestamp != nil && cov.CommitTimestamp.Before(retryWindow) { + collectedSet[key] = true + } + } + + // Build iterator with only NEW commit × flag combinations + iterator := helper.NewQueueIterator() + skippedCount := 0 + addedCount := 0 + for _, commit := range commits { + for _, flag := range flags { + // Skip empty flag names - only collect per-flag coverage + if flag.FlagName == "" { + continue + } + key := fmt.Sprintf("%s|%s", commit.CommitSha, flag.FlagName) + if !collectedSet[key] { + iterator.Push(&CommitFlagInput{ + CommitSha: commit.CommitSha, + FlagName: flag.FlagName, + }) + addedCount++ + } else { + skippedCount++ + } + } + } + + logger.Info("[Codecov] CommitCoverage: Skipped %d already collected, collecting %d new", skippedCount, addedCount) + + // If nothing new to collect, return early + if addedCount == 0 { + logger.Info("[Codecov] CommitCoverage: All data already collected, skipping API calls") + return nil + } + + collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_COMMIT_COVERAGES_TABLE, + }, + Incremental: true, // ALWAYS preserve historical data + ApiClient: data.ApiClient, + Input: iterator, + UrlTemplate: fmt.Sprintf("%s/totals/", RepoAPIPrefix(data.Service, owner, repo)), + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*CommitFlagInput) + query := url.Values{} + query.Set("sha", input.CommitSha) + if input.FlagName != "" { + query.Set("flag", input.FlagName) + } + return query, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + // Safety check: if status is 404 or 500+, return empty array to skip + if res.StatusCode == http.StatusNotFound || res.StatusCode >= http.StatusInternalServerError { + return []json.RawMessage{}, nil + } + var body json.RawMessage + err := helper.UnmarshalResponse(res, &body) + if err != nil { + return nil, err + } + // The response contains coverage data for the specific flag (or overall if no flag) + return []json.RawMessage{body}, nil + }, + AfterResponse: func(res *http.Response) errors.Error { + if res.StatusCode == http.StatusUnauthorized { + return errors.Unauthorized.New("authentication failed, please check your AccessToken") + } + // Skip 404 (no coverage) and 500 (server error) without retrying + if res.StatusCode == http.StatusNotFound || res.StatusCode >= http.StatusInternalServerError { + return helper.ErrIgnoreAndContinue + } + return nil + }, + }) + + if err != nil { + return err + } + + return collector.Execute() +} diff --git a/backend/plugins/codecov/tasks/commit_coverage_converter.go b/backend/plugins/codecov/tasks/commit_coverage_converter.go new file mode 100644 index 00000000000..bb02783a3ab --- /dev/null +++ b/backend/plugins/codecov/tasks/commit_coverage_converter.go @@ -0,0 +1,160 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +var ConvertCommitCoverageMeta = plugin.SubTaskMeta{ + Name: "ConvertCommitCoverage", + EntryPoint: ConvertCommitCoverage, + EnabledByDefault: true, + Description: "Convert overall and modified coverage per commit from raw data", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&ConvertComparisonMeta}, + DependencyTables: []string{RAW_COMMIT_TOTALS_TABLE, RAW_COMPARISONS_TABLE}, +} + +func ConvertCommitCoverage(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + db := taskCtx.GetDal() + + // Pre-load all commits for this repo into memory to avoid N+1 queries + var commits []models.CodecovCommit + err := db.All(&commits, dal.Where("connection_id = ? AND repo_id = ?", data.Options.ConnectionId, data.Options.FullName)) + if err != nil { + return errors.Default.Wrap(err, "failed to pre-load commits for commit coverage conversion") + } + commitMap := make(map[string]*models.CodecovCommit, len(commits)) + for i := range commits { + commitMap[commits[i].CommitSha] = &commits[i] + } + taskCtx.GetLogger().Info("[ConvertCommitCoverage] pre-loaded %d commits into memory", len(commitMap)) + + // Pre-load comparison data with empty flag (overall comparisons) for this repo + var comparisons []ComparisonData + err = db.All(&comparisons, dal.Where("connection_id = ? AND repo_id = ? AND flag_name = ?", data.Options.ConnectionId, data.Options.FullName, "")) + if err != nil { + return errors.Default.Wrap(err, "failed to pre-load comparisons for commit coverage conversion") + } + comparisonMap := make(map[string]*ComparisonData, len(comparisons)) + for i := range comparisons { + comparisonMap[comparisons[i].CommitSha] = &comparisons[i] + } + taskCtx.GetLogger().Info("[ConvertCommitCoverage] pre-loaded %d comparisons into memory", len(comparisonMap)) + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_COMMIT_TOTALS_TABLE, + }, + Extract: func(resData *helper.RawData) ([]interface{}, errors.Error) { + var input CommitInput + err := errors.Convert(json.Unmarshal(resData.Input, &input)) + if err != nil { + return nil, err + } + + var totals struct { + Commitid string `json:"commitid"` + Totals struct { + Files int `json:"files"` + Lines int `json:"lines"` + Hits int `json:"hits"` + Misses int `json:"misses"` + Partials int `json:"partials"` + Coverage float64 `json:"coverage"` + Branches int `json:"branches"` + Methods int `json:"methods"` + Messages int `json:"messages"` + Sessions int `json:"sessions"` + Complexity float64 `json:"complexity"` + } `json:"totals"` + } + err = errors.Convert(json.Unmarshal(resData.Data, &totals)) + if err != nil { + return nil, err + } + + commitSha := input.CommitSha + if commitSha == "" && totals.Commitid != "" { + commitSha = totals.Commitid + } + if commitSha == "" { + return nil, nil + } + + commit, ok := commitMap[commitSha] + if !ok { + return nil, nil + } + + var modifiedCoverage float64 + var filesChanged int + var methodsCovered, methodsTotal int + + if comp, ok := comparisonMap[commitSha]; ok { + modifiedCoverage = comp.ModifiedCoverage + filesChanged = comp.FilesChanged + methodsCovered = comp.MethodsCovered + methodsTotal = comp.MethodsTotal + } else { + methodsCovered = totals.Totals.Methods + methodsTotal = totals.Totals.Methods + } + + return []interface{}{&models.CodecovCommitCoverage{ + NoPKModel: common.NoPKModel{}, + ConnectionId: data.Options.ConnectionId, + RepoId: data.Options.FullName, + CommitSha: commitSha, + Branch: commit.Branch, + CommitTimestamp: commit.CommitTimestamp, + OverallCoverage: totals.Totals.Coverage, + ModifiedCoverage: modifiedCoverage, + FilesChanged: filesChanged, + LinesCovered: totals.Totals.Hits, + LinesTotal: totals.Totals.Lines, + LinesMissed: totals.Totals.Misses, + Hits: totals.Totals.Hits, + Partials: totals.Totals.Partials, + Misses: totals.Totals.Misses, + MethodsCovered: methodsCovered, + MethodsTotal: methodsTotal, + }}, nil + }, + }) + + if err != nil { + return err + } + + return extractor.Execute() +} diff --git a/backend/plugins/codecov/tasks/commit_totals_collector.go b/backend/plugins/codecov/tasks/commit_totals_collector.go new file mode 100644 index 00000000000..7065321a9aa --- /dev/null +++ b/backend/plugins/codecov/tasks/commit_totals_collector.go @@ -0,0 +1,160 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +type CommitInput struct { + CommitSha string `json:"commit_sha"` +} + +var CollectCommitTotalsMeta = plugin.SubTaskMeta{ + Name: "CollectCommitTotals", + EntryPoint: CollectCommitTotals, + EnabledByDefault: true, + Description: "Collect commit totals (overall coverage) from Codecov API", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + DependencyTables: []string{models.CodecovCommit{}.TableName()}, +} + +func CollectCommitTotals(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + + // Extract owner and repo from FullName + owner, repo, err := ParseFullName(data.Options.FullName) + if err != nil { + return err + } + + // Use sync policy time range, default to last 90 days + var startDate time.Time + syncPolicy := taskCtx.TaskContext().SyncPolicy() + if syncPolicy != nil && syncPolicy.TimeAfter != nil { + startDate = *syncPolicy.TimeAfter + logger.Info("[Codecov] CommitTotals: Using sync policy from %s", startDate.Format("2006-01-02")) + } else { + startDate = time.Now().AddDate(0, 0, -90) + logger.Info("[Codecov] CommitTotals: Using default 90 days from %s", startDate.Format("2006-01-02")) + } + + // Get existing commit coverages to skip already collected data (OPTIMIZATION) + var existingCoverages []models.CodecovCommitCoverage + err = db.All(&existingCoverages, dal.Where("connection_id = ? AND repo_id = ?", data.Options.ConnectionId, data.Options.FullName)) + if err != nil { + return err + } + + // Build a set of already collected commit SHAs + collectedSet := make(map[string]bool) + for _, cov := range existingCoverages { + collectedSet[cov.CommitSha] = true + } + + // Get commits filtered by sync policy + var allCommits []models.CodecovCommit + err = db.All(&allCommits, dal.Where("connection_id = ? AND repo_id = ? AND commit_timestamp >= ?", data.Options.ConnectionId, data.Options.FullName, startDate)) + if err != nil { + return err + } + + // Build iterator with only NEW commits + iterator := helper.NewQueueIterator() + skippedCount := 0 + addedCount := 0 + for _, commit := range allCommits { + if !collectedSet[commit.CommitSha] { + iterator.Push(&CommitInput{ + CommitSha: commit.CommitSha, + }) + addedCount++ + } else { + skippedCount++ + } + } + + logger.Info("[Codecov] CommitTotals: Skipped %d already collected, collecting %d new", skippedCount, addedCount) + + // If nothing new to collect, return early + if addedCount == 0 { + logger.Info("[Codecov] CommitTotals: All data already collected, skipping API calls") + return nil + } + + collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_COMMIT_TOTALS_TABLE, + }, + Incremental: true, // ALWAYS preserve historical data + ApiClient: data.ApiClient, + Input: iterator, + UrlTemplate: fmt.Sprintf("%s/totals/", RepoAPIPrefix(data.Service, owner, repo)), + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*CommitInput) + query := url.Values{} + query.Set("sha", input.CommitSha) + return query, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + // Safety check: if status is 404 or 500+, return empty array to skip + if res.StatusCode == http.StatusNotFound || res.StatusCode >= http.StatusInternalServerError { + return []json.RawMessage{}, nil + } + var body json.RawMessage + err := helper.UnmarshalResponse(res, &body) + if err != nil { + return nil, err + } + return []json.RawMessage{body}, nil + }, + AfterResponse: func(res *http.Response) errors.Error { + if res.StatusCode == http.StatusUnauthorized { + return errors.Unauthorized.New("authentication failed, please check your AccessToken") + } + // Skip 404 (no coverage) and 500 (server error) without retrying + if res.StatusCode == http.StatusNotFound || res.StatusCode >= http.StatusInternalServerError { + return helper.ErrIgnoreAndContinue + } + return nil + }, + }) + + if err != nil { + return err + } + + return collector.Execute() +} diff --git a/backend/plugins/codecov/tasks/commits_collector.go b/backend/plugins/codecov/tasks/commits_collector.go new file mode 100644 index 00000000000..5216880fc30 --- /dev/null +++ b/backend/plugins/codecov/tasks/commits_collector.go @@ -0,0 +1,168 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +var CollectCommitsMeta = plugin.SubTaskMeta{ + Name: "CollectCommits", + EntryPoint: CollectCommits, + EnabledByDefault: true, + Description: "Collect commits data from Codecov API for main/master branch", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, +} + +// CommitResponse represents a single commit from the Codecov API +type CommitResponse struct { + CommitID string `json:"commitid"` + Branch string `json:"branch"` + Message string `json:"message"` + Parent string `json:"parent"` + Timestamp string `json:"timestamp"` + Author struct { + Name string `json:"name"` + } `json:"author"` +} + +func CollectCommits(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + logger := taskCtx.GetLogger() + db := taskCtx.GetDal() + + // Extract owner and repo from FullName + owner, repo, err := ParseFullName(data.Options.FullName) + if err != nil { + return err + } + + // Use branch from repo scope, falling back to "main" + branch := "main" + if data.Repo != nil && data.Repo.Branch != "" { + branch = data.Repo.Branch + } + + // Use sync policy time range, default to last 90 days + var cutoffDate time.Time + syncPolicy := taskCtx.TaskContext().SyncPolicy() + if syncPolicy != nil && syncPolicy.TimeAfter != nil { + // Truncate to start of day to include all commits from the cutoff day + t := *syncPolicy.TimeAfter + cutoffDate = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + logger.Info("[Codecov] Collecting commits for %s/%s branch=%s from %s (sync policy, inclusive)", owner, repo, branch, cutoffDate.Format("2006-01-02")) + } else { + // Default to last 90 days if no sync policy + now := time.Now() + cutoffDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -90) + logger.Info("[Codecov] Collecting commits for %s/%s branch=%s from %s (default 90 days, inclusive)", owner, repo, branch, cutoffDate.Format("2006-01-02")) + } + + // Manual pagination with early termination + // Codecov API doesn't support start_date filter, so we paginate until we hit old commits + apiClient := data.ApiClient + pageSize := 100 + page := 1 + totalCollected := 0 + stopPagination := false + + for !stopPagination { + // Build the request URL + reqUrl := fmt.Sprintf("%s/commits?branch=%s&page=%d&page_size=%d", + RepoAPIPrefix(data.Service, owner, repo), branch, page, pageSize) + + res, err := apiClient.Get(reqUrl, nil, nil) + if err != nil { + return errors.Default.Wrap(err, fmt.Sprintf("failed to fetch commits page %d", page)) + } + + var response struct { + Count int `json:"count"` + Next *string `json:"next"` + Results []CommitResponse `json:"results"` + TotalPages int `json:"total_pages"` + } + + if err := helper.UnmarshalResponse(res, &response); err != nil { + return errors.Default.Wrap(err, "failed to parse commits response") + } + + if len(response.Results) == 0 { + logger.Info("[Codecov] No more commits found on page %d", page) + break + } + + // Process each commit + for _, commit := range response.Results { + // Parse timestamp + var commitTimestamp *time.Time + if commit.Timestamp != "" { + parsed, parseErr := time.Parse(time.RFC3339, commit.Timestamp) + if parseErr == nil { + commitTimestamp = &parsed + + // Check if this commit is older than our cutoff date + if parsed.Before(cutoffDate) { + logger.Info("[Codecov] Reached commit older than cutoff (%s < %s), stopping pagination", + parsed.Format("2006-01-02"), cutoffDate.Format("2006-01-02")) + stopPagination = true + break + } + } + } + + // Save commit to database + codecovCommit := &models.CodecovCommit{ + NoPKModel: common.NoPKModel{}, + ConnectionId: data.Options.ConnectionId, + RepoId: data.Options.FullName, + CommitSha: commit.CommitID, + Branch: commit.Branch, + CommitTimestamp: commitTimestamp, + Message: commit.Message, + Author: commit.Author.Name, + ParentSha: commit.Parent, + } + + // Upsert: PK is (connection_id, repo_id, commit_sha) so ON CONFLICT fires on duplicates + if err := db.CreateOrUpdate(codecovCommit); err != nil { + logger.Warn(err, "failed to save commit %s", commit.CommitID) + } else { + totalCollected++ + } + } + + // Check if there are more pages + if response.Next == nil || stopPagination { + break + } + + page++ + logger.Info("[Codecov] Processed page %d, collected %d commits so far", page-1, totalCollected) + } + + logger.Info("[Codecov] Finished collecting commits: %d total commits within sync policy window", totalCollected) + return nil +} diff --git a/backend/plugins/codecov/tasks/commits_extractor.go b/backend/plugins/codecov/tasks/commits_extractor.go new file mode 100644 index 00000000000..36a116d9462 --- /dev/null +++ b/backend/plugins/codecov/tasks/commits_extractor.go @@ -0,0 +1,99 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +// RAW_COMMITS_TABLE is defined in commits_collector.go + +var ExtractCommitsMeta = plugin.SubTaskMeta{ + Name: "ExtractCommits", + EntryPoint: ExtractCommits, + EnabledByDefault: true, + Description: "Extract commits from raw data", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + DependencyTables: []string{RAW_COMMITS_TABLE}, +} + +func ExtractCommits(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_COMMITS_TABLE, + }, + Extract: func(resData *helper.RawData) ([]interface{}, errors.Error) { + var commit struct { + Commitid string `json:"commitid"` + Branch string `json:"branch"` + Message string `json:"message"` + Parent string `json:"parent"` // Parent commit SHA for comparisons + Author struct { + Name string `json:"name"` + } `json:"author"` + Timestamp string `json:"timestamp"` + } + err := errors.Convert(json.Unmarshal(resData.Data, &commit)) + if err != nil { + return nil, err + } + + var commitTimestamp *time.Time + if commit.Timestamp != "" { + parsed, err := time.Parse(time.RFC3339, commit.Timestamp) + if err == nil { + commitTimestamp = &parsed + } + } + + codecovCommit := &models.CodecovCommit{ + NoPKModel: common.NoPKModel{}, + ConnectionId: data.Options.ConnectionId, + RepoId: data.Options.FullName, + CommitSha: commit.Commitid, + Branch: commit.Branch, + CommitTimestamp: commitTimestamp, + Message: commit.Message, + Author: commit.Author.Name, + ParentSha: commit.Parent, // Store parent SHA for comparison API calls + } + + return []interface{}{codecovCommit}, nil + }, + }) + + if err != nil { + return err + } + + return extractor.Execute() +} diff --git a/backend/plugins/codecov/tasks/comparison_collector.go b/backend/plugins/codecov/tasks/comparison_collector.go new file mode 100644 index 00000000000..34613283983 --- /dev/null +++ b/backend/plugins/codecov/tasks/comparison_collector.go @@ -0,0 +1,212 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +type ComparisonInput struct { + CommitSha string `json:"commit_sha"` + ParentSha string `json:"parent_sha"` + FlagName string `json:"flag_name"` +} + +var CollectComparisonMeta = plugin.SubTaskMeta{ + Name: "CollectComparison", + EntryPoint: CollectComparison, + EnabledByDefault: true, + Description: "Collect comparison data (modified/patch coverage) per flag from Codecov API", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&ConvertFlagsMeta}, + DependencyTables: []string{models.CodecovCommit{}.TableName(), models.CodecovFlag{}.TableName()}, +} + +func CollectComparison(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + + // Extract owner and repo from FullName + owner, repo, err := ParseFullName(data.Options.FullName) + if err != nil { + return err + } + + // Use sync policy time range, default to last 90 days + var startDate time.Time + syncPolicy := taskCtx.TaskContext().SyncPolicy() + if syncPolicy != nil && syncPolicy.TimeAfter != nil { + startDate = *syncPolicy.TimeAfter + logger.Info("[Codecov] Comparison: Using sync policy from %s", startDate.Format("2006-01-02")) + } else { + startDate = time.Now().AddDate(0, 0, -90) + logger.Info("[Codecov] Comparison: Using default 90 days from %s", startDate.Format("2006-01-02")) + } + + // Get commits ordered newest-first so the most recent (most valuable) data is collected first; + // if the task is killed or times out, older data is what gets skipped rather than recent data + var commits []models.CodecovCommit + err = db.All(&commits, dal.Where("connection_id = ? AND repo_id = ? AND commit_timestamp >= ?", data.Options.ConnectionId, data.Options.FullName, startDate), dal.Orderby("commit_timestamp DESC")) + if err != nil { + return err + } + + // Get all flags + var flags []models.CodecovFlag + err = db.All(&flags, dal.Where("connection_id = ? AND repo_id = ?", data.Options.ConnectionId, data.Options.FullName)) + if err != nil { + return err + } + + // Build skip-list from the raw API table (_raw_codecov_api_comparisons) instead of + // the tool table (_tool_codecov_comparisons). The raw table is populated by the collector + // before the converter runs, so it reflects all previously fetched API responses. + paramsJSON, jsonErr := json.Marshal(CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }) + if jsonErr != nil { + return errors.Default.Wrap(jsonErr, "failed to marshal params for skip-list query") + } + + collectedSet := make(map[string]bool) + rawTable := "_raw_codecov_api_comparisons" + if db.HasTable(rawTable) { + var rawInputs []struct { + Input json.RawMessage `json:"input"` + } + err = db.All(&rawInputs, + dal.From(rawTable), + dal.Where("params = ?", string(paramsJSON)), + dal.Select("input"), + ) + if err != nil { + return err + } + for _, row := range rawInputs { + var input ComparisonInput + if unmarshalErr := json.Unmarshal(row.Input, &input); unmarshalErr == nil { + key := fmt.Sprintf("%s|%s|%s", input.CommitSha, input.ParentSha, input.FlagName) + collectedSet[key] = true + } + } + } + logger.Info("[Codecov] Comparison: Loaded %d existing raw API responses into skip-list", len(collectedSet)) + + // Build comparison pairs for each commit × flag combination + // Use the actual ParentSha from the commit (from Codecov API) instead of assuming sequential order + // Patch coverage IS flag-specific - each flag shows how well that test type covers new code + // Only collect NEW comparisons that don't exist in the database + iterator := helper.NewQueueIterator() + skippedCount := 0 + addedCount := 0 + for _, commit := range commits { + // Skip commits without a parent (first commit in repo) + if commit.ParentSha == "" { + continue + } + + // Collect comparison for each flag (flag-specific patch coverage) + for _, flag := range flags { + key := fmt.Sprintf("%s|%s|%s", commit.CommitSha, commit.ParentSha, flag.FlagName) + if !collectedSet[key] { + iterator.Push(&ComparisonInput{ + CommitSha: commit.CommitSha, + ParentSha: commit.ParentSha, // Use actual parent from Codecov API + FlagName: flag.FlagName, // Flag-specific patch coverage + }) + addedCount++ + } else { + skippedCount++ + } + } + } + + logger.Info("[Codecov] Comparison: Skipped %d already collected, collecting %d new", skippedCount, addedCount) + + // If nothing new to collect, return early + if addedCount == 0 { + logger.Info("[Codecov] Comparison: All data already collected, skipping API calls") + return nil + } + + collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_COMPARISONS_TABLE, + }, + Incremental: true, // ALWAYS preserve historical data + ApiClient: data.ApiClient, + Input: iterator, + UrlTemplate: fmt.Sprintf("%s/compare", RepoAPIPrefix(data.Service, owner, repo)), + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*ComparisonInput) + query := url.Values{} + query.Set("base", input.ParentSha) + query.Set("head", input.CommitSha) + // Send flag parameter for flag-specific patch coverage + if input.FlagName != "" { + query.Set("flag", input.FlagName) + } + return query, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + // Safety check: if status is 404 or 500+, return empty array to skip + if res.StatusCode == http.StatusNotFound || res.StatusCode >= http.StatusInternalServerError { + return []json.RawMessage{}, nil + } + var body json.RawMessage + err := helper.UnmarshalResponse(res, &body) + if err != nil { + return nil, err + } + return []json.RawMessage{body}, nil + }, + AfterResponse: func(res *http.Response) errors.Error { + if res.StatusCode == http.StatusUnauthorized { + return errors.Unauthorized.New("authentication failed, please check your AccessToken") + } + // Skip 404 (no coverage) and 500 (server error) without retrying + if res.StatusCode == http.StatusNotFound || res.StatusCode >= http.StatusInternalServerError { + return helper.ErrIgnoreAndContinue + } + return nil + }, + }) + + if err != nil { + return err + } + + return collector.Execute() +} diff --git a/backend/plugins/codecov/tasks/comparison_converter.go b/backend/plugins/codecov/tasks/comparison_converter.go new file mode 100644 index 00000000000..699254041ba --- /dev/null +++ b/backend/plugins/codecov/tasks/comparison_converter.go @@ -0,0 +1,161 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +// ComparisonData stores comparison results for linking to commit coverage per flag +type ComparisonData struct { + common.NoPKModel // Includes CreatedAt, UpdatedAt, and RawDataOrigin + ConnectionId uint64 `gorm:"primaryKey;type:bigint"` + RepoId string `gorm:"primaryKey;type:varchar(200);index"` + CommitSha string `gorm:"primaryKey;type:varchar(64);index"` + FlagName string `gorm:"primaryKey;type:varchar(100);index"` + ParentSha string `gorm:"type:varchar(64)"` + ModifiedCoverage float64 `gorm:"type:double"` + FilesChanged int `gorm:"type:int"` + MethodsCovered int `gorm:"type:int"` + MethodsTotal int `gorm:"type:int"` + LinesCovered int `gorm:"type:int"` // Lines covered in modified code + LinesTotal int `gorm:"type:int"` // Total lines in modified code + LinesMissed int `gorm:"type:int"` // Lines missed in modified code + Patch *float64 `gorm:"type:double"` // Patch coverage from compare API (can be null) +} + +func (ComparisonData) TableName() string { + return "_tool_codecov_comparisons" +} + +var ConvertComparisonMeta = plugin.SubTaskMeta{ + Name: "ConvertComparison", + EntryPoint: ConvertComparison, + EnabledByDefault: true, + Description: "Convert comparison data (modified/patch coverage) from raw data", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&ExtractCommitsMeta}, + DependencyTables: []string{RAW_COMPARISONS_TABLE}, +} + +func ConvertComparison(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_COMPARISONS_TABLE, + }, + Extract: func(resData *helper.RawData) ([]interface{}, errors.Error) { + // Read input to get commit and parent SHA + var input ComparisonInput + err := errors.Convert(json.Unmarshal(resData.Input, &input)) + if err != nil { + return nil, err + } + + // Parse comparison response + var comparison struct { + BaseCommitid string `json:"base_commitid"` + HeadCommitid string `json:"head_commitid"` + Diff struct { + Files []struct { + Name string `json:"name"` + } `json:"files"` + Totals struct { + Files int `json:"files"` + Lines int `json:"lines"` + Hits int `json:"hits"` + Misses int `json:"misses"` + Partials int `json:"partials"` + Coverage float64 `json:"coverage"` + Branches int `json:"branches"` + Methods int `json:"methods"` + Messages int `json:"messages"` + Sessions int `json:"sessions"` + Complexity float64 `json:"complexity"` + } `json:"totals"` + } `json:"diff"` + Totals struct { + Patch *struct { + Files int `json:"files"` + Lines int `json:"lines"` + Hits int `json:"hits"` + Misses int `json:"misses"` + Coverage *float64 `json:"coverage"` + } `json:"patch"` + } `json:"totals"` + } + err = errors.Convert(json.Unmarshal(resData.Data, &comparison)) + if err != nil { + return nil, err + } + + // Extract patch coverage and line counts from totals.patch + // Only store patch coverage when there are actual coverable lines changed + // (lines > 0). A commit can have files > 0 but lines = 0 if the changed + // files contain no coverable code (e.g., only comments or config within + // a source file). In that case patch coverage is N/A, not 0%. + var patchCoverage *float64 + var patchFiles, patchLines, patchHits, patchMisses int + if comparison.Totals.Patch != nil { + patchFiles = comparison.Totals.Patch.Files + patchLines = comparison.Totals.Patch.Lines + patchHits = comparison.Totals.Patch.Hits + patchMisses = comparison.Totals.Patch.Misses + if comparison.Totals.Patch.Coverage != nil && patchLines > 0 { + patchCoverage = comparison.Totals.Patch.Coverage + } + } + + comparisonData := &ComparisonData{ + NoPKModel: common.NoPKModel{}, + ConnectionId: data.Options.ConnectionId, + RepoId: data.Options.FullName, + CommitSha: input.CommitSha, + FlagName: input.FlagName, + ParentSha: input.ParentSha, + ModifiedCoverage: comparison.Diff.Totals.Coverage, + FilesChanged: patchFiles, + MethodsCovered: comparison.Diff.Totals.Methods, + MethodsTotal: comparison.Diff.Totals.Methods, + LinesCovered: patchHits, + LinesTotal: patchLines, + LinesMissed: patchMisses, + Patch: patchCoverage, + } + + return []interface{}{comparisonData}, nil + }, + }) + + if err != nil { + return err + } + + return extractor.Execute() +} diff --git a/backend/plugins/codecov/tasks/constants.go b/backend/plugins/codecov/tasks/constants.go new file mode 100644 index 00000000000..8da57bf0374 --- /dev/null +++ b/backend/plugins/codecov/tasks/constants.go @@ -0,0 +1,27 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +const ( + RAW_FLAGS_TABLE = "codecov_api_flags" + RAW_COMMITS_TABLE = "codecov_api_commits" + RAW_COMMIT_TOTALS_TABLE = "codecov_api_commit_totals" + RAW_COMMIT_COVERAGES_TABLE = "codecov_api_commit_coverages" + RAW_COMPARISONS_TABLE = "codecov_api_comparisons" + RAW_FLAG_COVERAGE_TRENDS_TABLE = "codecov_api_flag_coverage_trends" +) diff --git a/backend/plugins/codecov/tasks/coverage_converter.go b/backend/plugins/codecov/tasks/coverage_converter.go new file mode 100644 index 00000000000..f58a4a986d1 --- /dev/null +++ b/backend/plugins/codecov/tasks/coverage_converter.go @@ -0,0 +1,206 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +var ConvertCoverageMeta = plugin.SubTaskMeta{ + Name: "ConvertCoverage", + EntryPoint: ConvertCoverage, + EnabledByDefault: true, + Description: "Convert coverage data per flag per commit from raw data", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&ConvertComparisonMeta}, + DependencyTables: []string{RAW_COMMIT_COVERAGES_TABLE}, +} + +func ConvertCoverage(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + db := taskCtx.GetDal() + + // Pre-load all commits for this repo into memory to avoid N+1 queries + var commits []models.CodecovCommit + err := db.All(&commits, dal.Where("connection_id = ? AND repo_id = ?", data.Options.ConnectionId, data.Options.FullName)) + if err != nil { + return errors.Default.Wrap(err, "failed to pre-load commits for coverage conversion") + } + commitMap := make(map[string]*models.CodecovCommit, len(commits)) + for i := range commits { + commitMap[commits[i].CommitSha] = &commits[i] + } + taskCtx.GetLogger().Info("[ConvertCoverage] pre-loaded %d commits into memory", len(commitMap)) + + // Pre-load all comparison data for this repo into memory + var comparisons []ComparisonData + err = db.All(&comparisons, dal.Where("connection_id = ? AND repo_id = ?", data.Options.ConnectionId, data.Options.FullName)) + if err != nil { + return errors.Default.Wrap(err, "failed to pre-load comparisons for coverage conversion") + } + comparisonMap := make(map[string]*ComparisonData, len(comparisons)) + for i := range comparisons { + key := comparisons[i].CommitSha + "|" + comparisons[i].FlagName + comparisonMap[key] = &comparisons[i] + } + taskCtx.GetLogger().Info("[ConvertCoverage] pre-loaded %d comparisons into memory", len(comparisonMap)) + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_COMMIT_COVERAGES_TABLE, + }, + Extract: func(resData *helper.RawData) ([]interface{}, errors.Error) { + var input CommitFlagInput + err := errors.Convert(json.Unmarshal(resData.Input, &input)) + if err != nil { + return nil, err + } + + var totals struct { + Commitid string `json:"commitid"` + Totals struct { + Files int `json:"files"` + Lines int `json:"lines"` + Hits int `json:"hits"` + Misses int `json:"misses"` + Partials int `json:"partials"` + Coverage float64 `json:"coverage"` + Branches int `json:"branches"` + Methods int `json:"methods"` + Messages int `json:"messages"` + Sessions int `json:"sessions"` + Complexity float64 `json:"complexity"` + } `json:"totals"` + Flags map[string]struct { + Files int `json:"files"` + Lines int `json:"lines"` + Hits int `json:"hits"` + Misses int `json:"misses"` + Partials int `json:"partials"` + Coverage float64 `json:"coverage"` + Branches int `json:"branches"` + Methods int `json:"methods"` + Messages int `json:"messages"` + Sessions int `json:"sessions"` + Complexity float64 `json:"complexity"` + } `json:"flags"` + } + err = errors.Convert(json.Unmarshal(resData.Data, &totals)) + if err != nil { + return nil, err + } + + commitSha := input.CommitSha + if totals.Commitid != "" { + commitSha = totals.Commitid + } + + commit, ok := commitMap[commitSha] + if !ok { + return nil, nil + } + + flagName := input.FlagName + if flagName == "" { + return nil, nil + } + + var coveragePercentage float64 + var linesCovered, linesTotal, linesMissed int + var hits, partials, misses int + var methodsCovered, methodsTotal int + + if flagName != "" && totals.Flags != nil { + if flagTotals, ok := totals.Flags[flagName]; ok { + coveragePercentage = flagTotals.Coverage + linesCovered = flagTotals.Hits + linesTotal = flagTotals.Lines + linesMissed = flagTotals.Misses + hits = flagTotals.Hits + partials = flagTotals.Partials + misses = flagTotals.Misses + methodsCovered = flagTotals.Methods + methodsTotal = flagTotals.Methods + } else { + coveragePercentage = totals.Totals.Coverage + linesCovered = totals.Totals.Hits + linesTotal = totals.Totals.Lines + linesMissed = totals.Totals.Misses + hits = totals.Totals.Hits + partials = totals.Totals.Partials + misses = totals.Totals.Misses + methodsCovered = totals.Totals.Methods + methodsTotal = totals.Totals.Methods + } + } else { + coveragePercentage = totals.Totals.Coverage + linesCovered = totals.Totals.Hits + linesTotal = totals.Totals.Lines + linesMissed = totals.Totals.Misses + hits = totals.Totals.Hits + partials = totals.Totals.Partials + misses = totals.Totals.Misses + methodsCovered = totals.Totals.Methods + methodsTotal = totals.Totals.Methods + } + + var modifiedCoverage float64 + if comp, ok := comparisonMap[commitSha+"|"+flagName]; ok { + modifiedCoverage = comp.ModifiedCoverage + } + + return []interface{}{&models.CodecovCoverage{ + NoPKModel: common.NoPKModel{}, + ConnectionId: data.Options.ConnectionId, + RepoId: data.Options.FullName, + FlagName: flagName, + Branch: commit.Branch, + CommitSha: commitSha, + CommitTimestamp: commit.CommitTimestamp, + CoveragePercentage: coveragePercentage, + ModifiedCoverage: modifiedCoverage, + LinesCovered: linesCovered, + LinesTotal: linesTotal, + LinesMissed: linesMissed, + Hits: hits, + Partials: partials, + Misses: misses, + MethodsCovered: methodsCovered, + MethodsTotal: methodsTotal, + }}, nil + }, + }) + + if err != nil { + return err + } + + return extractor.Execute() +} diff --git a/backend/plugins/codecov/tasks/coverage_trend_converter.go b/backend/plugins/codecov/tasks/coverage_trend_converter.go new file mode 100644 index 00000000000..c2058101671 --- /dev/null +++ b/backend/plugins/codecov/tasks/coverage_trend_converter.go @@ -0,0 +1,113 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +var ConvertCoverageTrendMeta = plugin.SubTaskMeta{ + Name: "ConvertCoverageTrend", + EntryPoint: ConvertCoverageTrend, + EnabledByDefault: true, + Description: "Convert coverage trend data per flag from raw data", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + DependencyTables: []string{RAW_FLAG_COVERAGE_TRENDS_TABLE}, +} + +func ConvertCoverageTrend(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_FLAG_COVERAGE_TRENDS_TABLE, + }, + Extract: func(resData *helper.RawData) ([]interface{}, errors.Error) { + // Read input to get flag name + var input struct { + FlagName string `json:"flag_name"` + } + if err := errors.Convert(json.Unmarshal(resData.Input, &input)); err != nil { + return nil, err + } + + // Parse individual trend point from results array + // Each raw data record is a single trend point: {"timestamp": "...", "min": ..., "max": ..., "avg": ...} + var point struct { + Timestamp string `json:"timestamp"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Avg float64 `json:"avg"` + } + err := errors.Convert(json.Unmarshal(resData.Data, &point)) + if err != nil { + return nil, err + } + + // Extract branch from scope config or default to main + branch := "main" + if data.Options.ScopeConfig != nil { + // Could add branch config here if needed + } + + // Parse timestamp like "2025-12-02T00:00:00Z" + date, parseErr := time.Parse(time.RFC3339, point.Timestamp) + if parseErr != nil { + // Try alternative format + date, parseErr = time.Parse("2006-01-02", point.Timestamp[:10]) + if parseErr != nil { + return nil, nil // Skip invalid dates + } + } + + codecovTrend := &models.CodecovCoverageTrend{ + NoPKModel: common.NoPKModel{}, + ConnectionId: data.Options.ConnectionId, + RepoId: data.Options.FullName, + FlagName: input.FlagName, + Branch: branch, + Date: date, + CoveragePercentage: point.Avg, // Use average coverage + LinesCovered: 0, // Not provided by this endpoint + LinesTotal: 0, // Not provided by this endpoint + MethodsCovered: 0, // Not provided by this endpoint + MethodsTotal: 0, // Not provided by this endpoint + } + + return []interface{}{codecovTrend}, nil + }, + }) + + if err != nil { + return err + } + + return extractor.Execute() +} diff --git a/backend/plugins/codecov/tasks/extractors_test.go b/backend/plugins/codecov/tasks/extractors_test.go new file mode 100644 index 00000000000..7310ef68046 --- /dev/null +++ b/backend/plugins/codecov/tasks/extractors_test.go @@ -0,0 +1,1640 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "encoding/json" + "reflect" + "testing" + "time" + + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + mockplugin "github.com/apache/devlake/mocks/core/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +func setupCodecovMocks(t *testing.T) (*mockplugin.SubTaskContext, *mockdal.Dal, *mocklog.Logger) { + t.Helper() + + mockCtx := new(mockplugin.SubTaskContext) + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + nestedLogger := new(mocklog.Logger) + + data := &CodecovTaskData{ + Options: &CodecovOptions{ + ConnectionId: 1, + FullName: "owner/repo", + }, + } + + mockCtx.On("GetData").Return(data) + mockCtx.On("GetDal").Return(mockDal) + mockCtx.On("GetLogger").Return(mockLogger) + mockCtx.On("GetName").Return("test") + mockCtx.On("GetContext").Return(context.Background()) + mockCtx.On("SetProgress", mock.Anything, mock.Anything).Maybe() + mockCtx.On("IncProgress", mock.Anything).Maybe() + mockCtx.On("NestedLogger", mock.Anything).Return(mockCtx).Maybe() + mockCtx.On("ReplaceLogger", mock.Anything).Return(mockCtx).Maybe() + mockCtx.On("GetConfig", mock.Anything).Return("").Maybe() + mockCtx.On("GetConfigReader").Return(nil).Maybe() + + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + mockLogger.On("Nested", mock.Anything).Return(nestedLogger).Maybe() + + nestedLogger.On("Info", mock.Anything, mock.Anything).Maybe() + nestedLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + nestedLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + nestedLogger.On("Nested", mock.Anything).Return(nestedLogger).Maybe() + + return mockCtx, mockDal, mockLogger +} + +func TestConvertFlags_NoTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockDal.On("HasTable", "_raw_codecov_api_flags").Return(false) + + err := ConvertFlags(mockCtx) + assert.Nil(t, err) +} + +func TestConvertComparison_NoTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockDal.On("HasTable", "_raw_codecov_api_comparisons").Return(false) + + err := ConvertComparison(mockCtx) + assert.Nil(t, err) +} + +func TestExtractCommits_NoTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockDal.On("HasTable", "_raw_codecov_api_commits").Return(false) + + err := ExtractCommits(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverageTrend_NoTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockDal.On("HasTable", "_raw_codecov_api_flag_coverage_trends").Return(false) + + err := ConvertCoverageTrend(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverage_NoTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + mockDal.On("HasTable", "_raw_codecov_api_commit_coverages").Return(false) + + err := ConvertCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCommitCoverage_NoTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + mockDal.On("HasTable", "_raw_codecov_api_commit_totals").Return(false) + + err := ConvertCommitCoverage(mockCtx) + assert.Nil(t, err) +} + +// setupConverterPreloadMocks adds All() mock expectations for the pre-loaded +// commit and comparison maps used by ConvertCoverage and ConvertCommitCoverage. +func setupConverterPreloadMocks(mockDal *mockdal.Dal, commits []models.CodecovCommit, comparisons []ComparisonData) { + mockDal.On("All", mock.AnythingOfType("*[]models.CodecovCommit"), mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]models.CodecovCommit) + *dst = commits + }).Return(nil) + mockDal.On("All", mock.AnythingOfType("*[]tasks.ComparisonData"), mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(0).(*[]ComparisonData) + *dst = comparisons + }).Return(nil) +} + +func setupEmptyTableMocks(mockDal *mockdal.Dal, tableName string) { + mockRows := new(mockdal.Rows) + mockDal.On("HasTable", "_raw_"+tableName).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(0), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) +} + +func TestConvertFlags_EmptyTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupEmptyTableMocks(mockDal, RAW_FLAGS_TABLE) + + err := ConvertFlags(mockCtx) + assert.Nil(t, err) +} + +func TestExtractCommits_EmptyTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupEmptyTableMocks(mockDal, RAW_COMMITS_TABLE) + + err := ExtractCommits(mockCtx) + assert.Nil(t, err) +} + +func TestConvertComparison_EmptyTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupEmptyTableMocks(mockDal, RAW_COMPARISONS_TABLE) + + err := ConvertComparison(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverageTrend_EmptyTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupEmptyTableMocks(mockDal, RAW_FLAG_COVERAGE_TRENDS_TABLE) + + err := ConvertCoverageTrend(mockCtx) + assert.Nil(t, err) +} + +func TestConvertFlags_WithOneRow(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_FLAGS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + flagJSON, _ := json.Marshal(map[string]any{ + "flag_name": "unit-tests", + "coverage": 85.5, + "carryforward": false, + "deleted": false, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: flagJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertFlags(mockCtx) + assert.Nil(t, err) +} + +func TestExtractCommits_WithOneRow(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMITS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + commitJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123def", + "branch": "main", + "message": "feat: add tests", + "parent": "parent123", + "timestamp": "2024-06-15T10:00:00Z", + "author": map[string]string{ + "name": "developer1", + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: commitJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ExtractCommits(mockCtx) + assert.Nil(t, err) +} + +func TestConvertComparison_WithOneRow(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMPARISONS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(ComparisonInput{ + CommitSha: "abc123", + ParentSha: "parent456", + FlagName: "unit-tests", + }) + dataJSON, _ := json.Marshal(map[string]any{ + "base_commitid": "parent456", + "head_commitid": "abc123", + "diff": map[string]any{ + "files": []map[string]string{{"name": "main.go"}}, + "totals": map[string]any{ + "files": 1, + "lines": 100, + "hits": 80, + "misses": 15, + "partials": 5, + "coverage": 80.0, + "methods": 10, + }, + }, + "totals": map[string]any{ + "patch": map[string]any{ + "files": 1, + "lines": 50, + "coverage": 75.0, + }, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertComparison(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverageTrend_WithOneRow(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_FLAG_COVERAGE_TRENDS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(map[string]string{"flag_name": "unit-tests"}) + dataJSON, _ := json.Marshal(map[string]any{ + "timestamp": "2024-06-15T00:00:00Z", + "min": 80.0, + "max": 90.0, + "avg": 85.5, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + {Name: "Branch", Type: reflect.TypeOf("")}, + {Name: "Date", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverageTrend(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverage_WithOneRow(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_COVERAGES_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitFlagInput{ + CommitSha: "abc123", + FlagName: "unit-tests", + }) + dataJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123", + "totals": map[string]any{ + "files": 10, + "lines": 500, + "hits": 400, + "misses": 80, + "partials": 20, + "coverage": 80.0, + "methods": 50, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + {Name: "Branch", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }).Maybe() + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil).Maybe() + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil).Maybe() + + err := ConvertCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCommitCoverage_WithOneRow(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_TOTALS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitInput{CommitSha: "abc123"}) + dataJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123", + "totals": map[string]any{ + "files": 10, + "lines": 500, + "hits": 400, + "misses": 80, + "partials": 20, + "coverage": 80.0, + "methods": 50, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }).Maybe() + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil).Maybe() + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil).Maybe() + + err := ConvertCommitCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestComparisonDataTableName(t *testing.T) { + cd := ComparisonData{} + assert.Equal(t, "_tool_codecov_comparisons", cd.TableName()) +} + +func TestConvertCoverage_EmptyTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + setupEmptyTableMocks(mockDal, RAW_COMMIT_COVERAGES_TABLE) + err := ConvertCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCommitCoverage_EmptyTable(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + setupEmptyTableMocks(mockDal, RAW_COMMIT_TOTALS_TABLE) + err := ConvertCommitCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverageTrend_DateFallback(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_FLAG_COVERAGE_TRENDS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(map[string]string{"flag_name": "unit-tests"}) + dataJSON, _ := json.Marshal(map[string]any{ + "timestamp": "2024-06-15", + "min": 80.0, + "max": 90.0, + "avg": 85.5, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + {Name: "Branch", Type: reflect.TypeOf("")}, + {Name: "Date", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverageTrend(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverageTrend_InvalidDate(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_FLAG_COVERAGE_TRENDS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(map[string]string{"flag_name": "unit-tests"}) + dataJSON, _ := json.Marshal(map[string]any{ + "timestamp": "not-a-date", + "min": 80.0, + "max": 90.0, + "avg": 85.5, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + {Name: "Branch", Type: reflect.TypeOf("")}, + {Name: "Date", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverageTrend(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverage_CommitFound(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + ts := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + setupConverterPreloadMocks(mockDal, []models.CodecovCommit{ + {ConnectionId: 1, RepoId: "owner/repo", CommitSha: "abc123", Branch: "main", CommitTimestamp: &ts}, + }, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_COVERAGES_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitFlagInput{ + CommitSha: "abc123", + FlagName: "unit-tests", + }) + dataJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123", + "totals": map[string]any{ + "files": 10, "lines": 500, "hits": 400, + "misses": 80, "partials": 20, "coverage": 80.0, "methods": 50, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + {Name: "Branch", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverage_EmptyFlagName(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + ts := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + setupConverterPreloadMocks(mockDal, []models.CodecovCommit{ + {ConnectionId: 1, RepoId: "owner/repo", CommitSha: "abc123", Branch: "main", CommitTimestamp: &ts}, + }, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_COVERAGES_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitFlagInput{CommitSha: "abc123", FlagName: ""}) + dataJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123", + "totals": map[string]any{ + "coverage": 80.0, "hits": 400, "lines": 500, + "misses": 80, "partials": 20, "methods": 50, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + {Name: "Branch", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverage_FlagInMap(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + ts := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + setupConverterPreloadMocks(mockDal, []models.CodecovCommit{ + {ConnectionId: 1, RepoId: "owner/repo", CommitSha: "abc123", Branch: "main", CommitTimestamp: &ts}, + }, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_COVERAGES_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitFlagInput{CommitSha: "abc123", FlagName: "unit-tests"}) + dataJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123", + "totals": map[string]any{ + "coverage": 80.0, "hits": 400, "lines": 500, + "misses": 80, "partials": 20, "methods": 50, + }, + "flags": map[string]any{ + "unit-tests": map[string]any{ + "coverage": 90.0, "hits": 450, "lines": 500, + "misses": 30, "partials": 20, "methods": 60, + }, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + {Name: "Branch", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCoverage_ComparisonFound(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + ts := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + setupConverterPreloadMocks(mockDal, []models.CodecovCommit{ + {ConnectionId: 1, RepoId: "owner/repo", CommitSha: "abc123", Branch: "main", CommitTimestamp: &ts}, + }, []ComparisonData{ + {ConnectionId: 1, RepoId: "owner/repo", CommitSha: "abc123", FlagName: "unit-tests", ModifiedCoverage: 92.5}, + }) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_COVERAGES_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitFlagInput{CommitSha: "abc123", FlagName: "unit-tests"}) + dataJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123", + "totals": map[string]any{ + "coverage": 80.0, "hits": 400, "lines": 500, + "misses": 80, "partials": 20, "methods": 50, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + {Name: "Branch", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCommitCoverage_CommitFound(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + ts := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + setupConverterPreloadMocks(mockDal, []models.CodecovCommit{ + {ConnectionId: 1, RepoId: "owner/repo", CommitSha: "abc123", Branch: "main", CommitTimestamp: &ts}, + }, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_TOTALS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitInput{CommitSha: "abc123"}) + dataJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123", + "totals": map[string]any{ + "files": 10, "lines": 500, "hits": 400, + "misses": 80, "partials": 20, "coverage": 80.0, "methods": 50, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCommitCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCommitCoverage_CommitAndComparison(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + ts := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + setupConverterPreloadMocks(mockDal, []models.CodecovCommit{ + {ConnectionId: 1, RepoId: "owner/repo", CommitSha: "abc123", Branch: "main", CommitTimestamp: &ts}, + }, []ComparisonData{ + {ConnectionId: 1, RepoId: "owner/repo", CommitSha: "abc123", FlagName: "", ModifiedCoverage: 75.5, FilesChanged: 3, MethodsCovered: 20, MethodsTotal: 25}, + }) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_TOTALS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitInput{CommitSha: "abc123"}) + dataJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123", + "totals": map[string]any{ + "files": 10, "lines": 500, "hits": 400, + "misses": 80, "partials": 20, "coverage": 80.0, "methods": 50, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCommitCoverage(mockCtx) + assert.Nil(t, err) +} + +func TestConvertCommitCoverage_EmptyCommitSha(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_TOTALS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitInput{CommitSha: ""}) + dataJSON, _ := json.Marshal(map[string]any{ + "totals": map[string]any{ + "coverage": 80.0, "hits": 400, "lines": 500, + "misses": 80, "partials": 20, "methods": 50, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCommitCoverage(mockCtx) + assert.Nil(t, err) +} + +// --- ConvertComparison branch coverage tests --- + +func TestConvertComparison_InvalidInputJSON(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMPARISONS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: []byte(`{"valid":"json"}`), + Input: []byte(`<<>>`), + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertComparison(mockCtx) + assert.NotNil(t, err) +} + +func TestConvertComparison_InvalidDataJSON(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMPARISONS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(ComparisonInput{ + CommitSha: "abc123", + ParentSha: "parent456", + FlagName: "unit-tests", + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: []byte(`<<>>`), + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertComparison(mockCtx) + assert.NotNil(t, err) +} + +func TestConvertComparison_PatchNil(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMPARISONS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(ComparisonInput{ + CommitSha: "abc123", + ParentSha: "parent456", + FlagName: "unit-tests", + }) + // No "totals.patch" at all -> Patch pointer stays nil + dataJSON, _ := json.Marshal(map[string]any{ + "base_commitid": "parent456", + "head_commitid": "abc123", + "diff": map[string]any{ + "files": []map[string]string{}, + "totals": map[string]any{"coverage": 0.0}, + }, + "totals": map[string]any{}, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertComparison(mockCtx) + assert.Nil(t, err) +} + +func TestConvertComparison_PatchCoverageNil(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMPARISONS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(ComparisonInput{ + CommitSha: "abc123", + ParentSha: "parent456", + FlagName: "unit-tests", + }) + // Patch exists but coverage is null -> Coverage pointer stays nil + dataJSON, _ := json.Marshal(map[string]any{ + "base_commitid": "parent456", + "head_commitid": "abc123", + "diff": map[string]any{ + "files": []map[string]string{}, + "totals": map[string]any{"coverage": 0.0}, + }, + "totals": map[string]any{ + "patch": map[string]any{ + "files": 1, + "lines": 10, + "coverage": nil, + }, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertComparison(mockCtx) + assert.Nil(t, err) +} + +func TestConvertComparison_PatchZeroFilesAndLines(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMPARISONS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(ComparisonInput{ + CommitSha: "abc123", + ParentSha: "parent456", + FlagName: "unit-tests", + }) + // Patch exists, Coverage is non-nil, but Files=0 and Lines=0 + // -> patchCoverage should remain nil (treated as no actual changes) + cov := 0.0 + patchData := map[string]any{ + "files": 0, + "lines": 0, + "coverage": cov, + } + dataJSON, _ := json.Marshal(map[string]any{ + "base_commitid": "parent456", + "head_commitid": "abc123", + "diff": map[string]any{ + "files": []map[string]string{}, + "totals": map[string]any{"coverage": 0.0}, + }, + "totals": map[string]any{ + "patch": patchData, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertComparison(mockCtx) + assert.Nil(t, err) +} + +// --- ConvertFlags branch coverage tests --- + +func TestConvertFlags_InvalidJSON(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_FLAGS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: []byte(`<<>>`), + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertFlags(mockCtx) + assert.NotNil(t, err) +} + +func TestConvertFlags_EmptyFlagName(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_FLAGS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + flagJSON, _ := json.Marshal(map[string]any{ + "flag_name": "", + "coverage": 85.5, + "carryforward": false, + "deleted": false, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: flagJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertFlags(mockCtx) + assert.Nil(t, err) +} + +// --- ExtractCommits branch coverage tests --- + +func TestExtractCommits_InvalidJSON(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMITS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: []byte(`<<>>`), + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ExtractCommits(mockCtx) + assert.NotNil(t, err) +} + +func TestExtractCommits_EmptyTimestamp(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMITS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + commitJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123def", + "branch": "main", + "message": "feat: add tests", + "parent": "parent123", + "timestamp": "", + "author": map[string]string{ + "name": "developer1", + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: commitJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ExtractCommits(mockCtx) + assert.Nil(t, err) +} + +func TestExtractCommits_InvalidTimestamp(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMITS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + commitJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123def", + "branch": "main", + "message": "feat: add tests", + "parent": "parent123", + "timestamp": "not-a-valid-timestamp", + "author": map[string]string{ + "name": "developer1", + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: commitJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ExtractCommits(mockCtx) + assert.Nil(t, err) +} + +// --- ConvertCoverageTrend branch coverage tests --- + +func TestConvertCoverageTrend_InvalidInputJSON(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_FLAG_COVERAGE_TRENDS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: []byte(`{"timestamp":"2024-06-15T00:00:00Z","min":80.0,"max":90.0,"avg":85.5}`), + Input: []byte(`<<>>`), + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverageTrend(mockCtx) + assert.NotNil(t, err) +} + +func TestConvertCoverageTrend_InvalidDataJSON(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_FLAG_COVERAGE_TRENDS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(map[string]string{"flag_name": "unit-tests"}) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: []byte(`<<>>`), + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverageTrend(mockCtx) + assert.NotNil(t, err) +} + +// --- ConvertCoverage branch coverage tests --- + +func TestConvertCoverage_InvalidInputJSON(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_COVERAGES_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: []byte(`{"commitid":"abc123","totals":{"coverage":80.0}}`), + Input: []byte(`<<>>`), + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverage(mockCtx) + assert.NotNil(t, err) +} + +func TestConvertCoverage_InvalidDataJSON(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_COVERAGES_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitFlagInput{CommitSha: "abc123", FlagName: "unit-tests"}) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: []byte(`<<>>`), + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverage(mockCtx) + assert.NotNil(t, err) +} + +func TestConvertCoverage_FlagNotInMap(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + ts := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + setupConverterPreloadMocks(mockDal, []models.CodecovCommit{ + {ConnectionId: 1, RepoId: "owner/repo", CommitSha: "abc123", Branch: "main", CommitTimestamp: &ts}, + }, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_COVERAGES_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitFlagInput{CommitSha: "abc123", FlagName: "unit-tests"}) + dataJSON, _ := json.Marshal(map[string]any{ + "commitid": "abc123", + "totals": map[string]any{ + "coverage": 80.0, "hits": 400, "lines": 500, + "misses": 80, "partials": 20, "methods": 50, + }, + "flags": map[string]any{ + "integration-tests": map[string]any{ + "coverage": 70.0, "hits": 350, "lines": 500, + "misses": 120, "partials": 30, "methods": 40, + }, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "FlagName", Type: reflect.TypeOf("")}, + {Name: "Branch", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCoverage(mockCtx) + assert.Nil(t, err) +} + +// --- ConvertCommitCoverage branch coverage tests --- + +func TestConvertCommitCoverage_InvalidInputJSON(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_TOTALS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: []byte(`{"commitid":"abc123","totals":{"coverage":80.0}}`), + Input: []byte(`<<>>`), + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCommitCoverage(mockCtx) + assert.NotNil(t, err) +} + +func TestConvertCommitCoverage_InvalidDataJSON(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + setupConverterPreloadMocks(mockDal, nil, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_TOTALS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitInput{CommitSha: "abc123"}) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: []byte(`<<>>`), + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCommitCoverage(mockCtx) + assert.NotNil(t, err) +} + +func TestConvertCommitCoverage_FallbackToTotalsCommitid(t *testing.T) { + mockCtx, mockDal, _ := setupCodecovMocks(t) + ts := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + setupConverterPreloadMocks(mockDal, []models.CodecovCommit{ + {ConnectionId: 1, RepoId: "owner/repo", CommitSha: "fallback123", Branch: "develop", CommitTimestamp: &ts}, + }, nil) + mockRows := new(mockdal.Rows) + + mockDal.On("HasTable", "_raw_"+RAW_COMMIT_TOTALS_TABLE).Return(true) + mockDal.On("Count", mock.Anything).Return(int64(1), nil) + mockDal.On("Cursor", mock.Anything).Return(mockRows, nil) + mockRows.On("Next").Return(true).Once() + mockRows.On("Next").Return(false) + mockRows.On("Close").Return(nil) + + inputJSON, _ := json.Marshal(CommitInput{CommitSha: ""}) + dataJSON, _ := json.Marshal(map[string]any{ + "commitid": "fallback123", + "totals": map[string]any{ + "files": 5, "lines": 200, "hits": 150, + "misses": 40, "partials": 10, "coverage": 75.0, "methods": 20, + }, + }) + + mockDal.On("Fetch", mockRows, mock.Anything).Run(func(args mock.Arguments) { + dst := args.Get(1).(*helper.RawData) + *dst = helper.RawData{ + ID: 1, + Params: `{"ConnectionId":1,"Name":"owner/repo"}`, + Data: dataJSON, + Input: inputJSON, + } + }).Return(nil) + + mockDal.On("GetPrimaryKeyFields", mock.Anything).Return([]reflect.StructField{ + {Name: "ConnectionId", Type: reflect.TypeOf(uint64(0))}, + {Name: "RepoId", Type: reflect.TypeOf("")}, + {Name: "CommitSha", Type: reflect.TypeOf("")}, + }) + mockDal.On("Delete", mock.Anything, mock.Anything).Return(nil) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + err := ConvertCommitCoverage(mockCtx) + assert.Nil(t, err) +} diff --git a/backend/plugins/codecov/tasks/flag_coverage_trend_collector.go b/backend/plugins/codecov/tasks/flag_coverage_trend_collector.go new file mode 100644 index 00000000000..ea838e3febf --- /dev/null +++ b/backend/plugins/codecov/tasks/flag_coverage_trend_collector.go @@ -0,0 +1,166 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "reflect" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +type FlagInput struct { + FlagName string `json:"flag_name"` +} + +var CollectFlagCoverageTrendMeta = plugin.SubTaskMeta{ + Name: "CollectFlagCoverageTrend", + EntryPoint: CollectFlagCoverageTrend, + EnabledByDefault: true, + Description: "Collect historical coverage trend per flag from Codecov API", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + Dependencies: []*plugin.SubTaskMeta{&ConvertFlagsMeta}, + DependencyTables: []string{models.CodecovFlag{}.TableName()}, +} + +func CollectFlagCoverageTrend(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + + // Log sync policy time range + syncPolicy := taskCtx.TaskContext().SyncPolicy() + if syncPolicy != nil && syncPolicy.TimeAfter != nil { + logger.Info("[Codecov] CollectFlagCoverageTrend: Using sync policy time range from %s", syncPolicy.TimeAfter.Format("2006-01-02")) + } else { + logger.Info("[Codecov] CollectFlagCoverageTrend: No sync policy, using default 90 days") + } + + // Extract owner and repo from FullName + owner, repo, err := ParseFullName(data.Options.FullName) + if err != nil { + return err + } + + // Create iterator from collected flags, skipping empty names and names with + // control characters (newlines, tabs, etc.) that would produce invalid URLs. + // Some repos have misconfigured flag names like "ARM64-3.10\n3.11". + clauses := []dal.Clause{ + dal.Select("flag_name AS flag_name"), + dal.From(&models.CodecovFlag{}), + dal.Where("connection_id = ? AND repo_id = ? AND flag_name != '' AND flag_name NOT REGEXP '[[:cntrl:]]'", + data.Options.ConnectionId, data.Options.FullName), + } + + cursor, err := db.Cursor(clauses...) + if err != nil { + return err + } + defer cursor.Close() + + iterator, err := helper.NewDalCursorIterator(db, cursor, reflect.TypeOf(FlagInput{})) + if err != nil { + return err + } + + collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_FLAG_COVERAGE_TRENDS_TABLE, + }, + Incremental: true, // ALWAYS preserve historical data + ApiClient: data.ApiClient, + Input: iterator, + PageSize: 100, // Max results per page + // Use the correct per-flag coverage endpoint: /flags/{flag_name}/coverage (NO trailing slash!) + // See: https://docs.codecov.com/reference/repos_flags_coverage_list + UrlTemplate: fmt.Sprintf("%s/flags/{{ .Input.FlagName }}/coverage", RepoAPIPrefix(data.Service, owner, repo)), + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + query := url.Values{} + query.Set("interval", "1d") // Daily trend data + query.Set("page", fmt.Sprintf("%d", reqData.Pager.Page)) + + // Use sync policy time range, default to last 90 days + endDate := time.Now() + var startDate time.Time + syncPolicy := taskCtx.TaskContext().SyncPolicy() + if syncPolicy != nil && syncPolicy.TimeAfter != nil { + startDate = *syncPolicy.TimeAfter + } else { + // Default to last 90 days if no sync policy + startDate = endDate.AddDate(0, 0, -90) + } + query.Set("start_date", startDate.Format("2006-01-02")) + query.Set("end_date", endDate.Format("2006-01-02")) + return query, nil + }, + GetTotalPages: func(res *http.Response, args *helper.ApiCollectorArgs) (int, errors.Error) { + var response struct { + TotalPages int `json:"total_pages"` + } + err := helper.UnmarshalResponse(res, &response) + if err != nil { + return 0, err + } + logger.Info("[Codecov] Coverage trend has %d pages to collect", response.TotalPages) + return response.TotalPages, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + // Safety check: if status is 404 or 500+, return empty array to skip + if res.StatusCode == http.StatusNotFound || res.StatusCode >= http.StatusInternalServerError { + return []json.RawMessage{}, nil + } + var response struct { + Results []json.RawMessage `json:"results"` + } + err := helper.UnmarshalResponse(res, &response) + if err != nil { + return nil, err + } + return response.Results, nil + }, + AfterResponse: func(res *http.Response) errors.Error { + if res.StatusCode == http.StatusUnauthorized { + return errors.Unauthorized.New("authentication failed, please check your AccessToken") + } + // Skip 404 (no coverage) and 500 (server error) without retrying + if res.StatusCode == http.StatusNotFound || res.StatusCode >= http.StatusInternalServerError { + return helper.ErrIgnoreAndContinue + } + return nil + }, + }) + + if err != nil { + return err + } + + return collector.Execute() +} diff --git a/backend/plugins/codecov/tasks/flags_collector.go b/backend/plugins/codecov/tasks/flags_collector.go new file mode 100644 index 00000000000..7517f5725ad --- /dev/null +++ b/backend/plugins/codecov/tasks/flags_collector.go @@ -0,0 +1,77 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +var CollectFlagsMeta = plugin.SubTaskMeta{ + Name: "CollectFlags", + EntryPoint: CollectFlags, + EnabledByDefault: true, + Description: "Collect flags data from Codecov API", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, +} + +func CollectFlags(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + + // Extract owner and repo from FullName (format: "owner/repo") + owner, repo, err := ParseFullName(data.Options.FullName) + if err != nil { + return err + } + + collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_FLAGS_TABLE, + }, + Incremental: true, // ALWAYS preserve historical data + ApiClient: data.ApiClient, + UrlTemplate: fmt.Sprintf("%s/flags", RepoAPIPrefix(data.Service, owner, repo)), + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + var response struct { + Results []json.RawMessage `json:"results"` + } + err := helper.UnmarshalResponse(res, &response) + if err != nil { + return nil, err + } + return response.Results, nil + }, + }) + + if err != nil { + return err + } + + return collector.Execute() +} + diff --git a/backend/plugins/codecov/tasks/flags_converter.go b/backend/plugins/codecov/tasks/flags_converter.go new file mode 100644 index 00000000000..aabdd7f6c46 --- /dev/null +++ b/backend/plugins/codecov/tasks/flags_converter.go @@ -0,0 +1,89 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +var ConvertFlagsMeta = plugin.SubTaskMeta{ + Name: "ConvertFlags", + EntryPoint: ConvertFlags, + EnabledByDefault: true, + Description: "Convert flags from raw data", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, + DependencyTables: []string{RAW_FLAGS_TABLE}, +} + +func ConvertFlags(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: CodecovApiParams{ + ConnectionId: data.Options.ConnectionId, + Name: data.Options.FullName, + }, + Table: RAW_FLAGS_TABLE, + }, + Extract: func(resData *helper.RawData) ([]interface{}, errors.Error) { + var flag struct { + FlagName string `json:"flag_name"` + Coverage *float64 `json:"coverage"` + Carryforward bool `json:"carryforward"` + Deleted bool `json:"deleted"` + Yaml string `json:"yaml"` + } + err := errors.Convert(json.Unmarshal(resData.Data, &flag)) + if err != nil { + return nil, err + } + + // Skip empty flag names + if flag.FlagName == "" { + return nil, nil + } + + codecovFlag := &models.CodecovFlag{ + NoPKModel: common.NoPKModel{}, + ConnectionId: data.Options.ConnectionId, + RepoId: data.Options.FullName, + FlagName: flag.FlagName, + Coverage: flag.Coverage, + Carryforward: flag.Carryforward, + Deleted: flag.Deleted, + Yaml: flag.Yaml, + } + + return []interface{}{codecovFlag}, nil + }, + }) + + if err != nil { + return err + } + + return extractor.Execute() +} diff --git a/backend/plugins/codecov/tasks/helpers.go b/backend/plugins/codecov/tasks/helpers.go new file mode 100644 index 00000000000..e4eab477a10 --- /dev/null +++ b/backend/plugins/codecov/tasks/helpers.go @@ -0,0 +1,41 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "strings" + + "github.com/apache/devlake/core/errors" +) + +// ParseFullName splits a "owner/repo" string into its components. +func ParseFullName(fullName string) (owner, repo string, err errors.Error) { + parts := strings.Split(fullName, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", errors.BadInput.New("invalid fullName format, expected 'owner/repo'") + } + return parts[0], parts[1], nil +} + +// RepoAPIPrefix builds the Codecov API v2 path prefix for a repo: +// +// api/v2/{service}/{owner}/repos/{repo} +func RepoAPIPrefix(service, owner, repo string) string { + return fmt.Sprintf("api/v2/%s/%s/repos/%s", service, owner, repo) +} diff --git a/backend/plugins/codecov/tasks/helpers_test.go b/backend/plugins/codecov/tasks/helpers_test.go new file mode 100644 index 00000000000..9f1857860bd --- /dev/null +++ b/backend/plugins/codecov/tasks/helpers_test.go @@ -0,0 +1,56 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" +) + +func TestParseFullName(t *testing.T) { + tests := []struct { + name string + input string + wantOwner string + wantRepo string + wantErr bool + }{ + {"standard github format", "ansible/ansible-ui", "ansible", "ansible-ui", false}, + {"org with hyphens", "konflux-ci/build-service", "konflux-ci", "build-service", false}, + {"simple names", "owner/repo", "owner", "repo", false}, + {"empty string", "", "", "", true}, + {"no slash", "noslash", "", "", true}, + {"too many slashes", "a/b/c", "", "", true}, + {"only slash", "/", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo, err := ParseFullName(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseFullName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if owner != tt.wantOwner { + t.Errorf("ParseFullName(%q) owner = %q, want %q", tt.input, owner, tt.wantOwner) + } + if repo != tt.wantRepo { + t.Errorf("ParseFullName(%q) repo = %q, want %q", tt.input, repo, tt.wantRepo) + } + }) + } +} diff --git a/backend/plugins/codecov/tasks/repo_config_collector.go b/backend/plugins/codecov/tasks/repo_config_collector.go new file mode 100644 index 00000000000..4ae48006e17 --- /dev/null +++ b/backend/plugins/codecov/tasks/repo_config_collector.go @@ -0,0 +1,321 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" + "gopkg.in/yaml.v3" +) + +var CollectRepoConfigMeta = plugin.SubTaskMeta{ + Name: "CollectRepoConfig", + EntryPoint: CollectRepoConfig, + EnabledByDefault: true, + Description: "Fetch codecov.yml via Codecov GraphQL and parse coverage thresholds", + DomainTypes: []string{plugin.DOMAIN_TYPE_CODE}, +} + +const maxConfigSize = 1 << 20 // 1 MiB + +// maxGraphQLResponseSize allows JSON wrapper overhead around the yaml payload. +const maxGraphQLResponseSize = maxConfigSize + (64 << 10) + +const repoYamlGraphQLQuery = `query GetRepoSettings($name: String!, $repo: String!) { + owner(username: $name) { + repository(name: $repo) { + ... on Repository { + yaml + } + } + } +}` + +type graphqlRepoYamlResponse struct { + Data struct { + Owner struct { + Repository struct { + Yaml *string `json:"yaml"` + } `json:"repository"` + } `json:"owner"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// codecovYaml represents the relevant parts of a codecov.yml file +type codecovYaml struct { + Coverage struct { + Status struct { + Project map[string]statusConfig `yaml:"project"` + Patch map[string]statusConfig `yaml:"patch"` + } `yaml:"status"` + } `yaml:"coverage"` +} + +type statusConfig struct { + Target interface{} `yaml:"target"` + Threshold interface{} `yaml:"threshold"` + Informational interface{} `yaml:"informational"` +} + +func serviceShortCode(service string) (string, error) { + switch service { + case "github": + return "gh", nil + case "github_enterprise": + return "ghe", nil + case "gitlab": + return "gl", nil + case "gitlab_enterprise": + return "gle", nil + case "bitbucket": + return "bb", nil + case "bitbucket_server": + return "bbs", nil + default: + return "", fmt.Errorf("unsupported service %q", service) + } +} + +func fetchRepoYaml(apiClient *helper.ApiAsyncClient, serviceShort, owner, repo string) (string, errors.Error) { + path := fmt.Sprintf("/graphql/%s", serviceShort) + body := map[string]interface{}{ + "query": repoYamlGraphQLQuery, + "variables": map[string]string{ + "name": owner, + "repo": repo, + }, + } + + res, err := apiClient.Post(path, nil, body, nil) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return "", errors.HttpStatus(res.StatusCode).New( + fmt.Sprintf("GraphQL request failed with HTTP %d", res.StatusCode), + ) + } + + limited := io.LimitReader(res.Body, maxGraphQLResponseSize+1) + resBody, readErr := io.ReadAll(limited) + if readErr != nil { + return "", errors.Default.Wrap(readErr, "error reading GraphQL response body") + } + if len(resBody) == 0 { + return "", helper.ErrEmptyResponse + } + if len(resBody) > maxGraphQLResponseSize { + return "", errors.Default.New(fmt.Sprintf("GraphQL response exceeds size limit (%d bytes)", maxGraphQLResponseSize)) + } + + var result graphqlRepoYamlResponse + if err := errors.Convert(json.Unmarshal(resBody, &result)); err != nil { + return "", errors.HttpStatus(res.StatusCode).Wrap(err, "error decoding GraphQL response") + } + + if len(result.Errors) > 0 { + msgs := make([]string, len(result.Errors)) + for i, gqlErr := range result.Errors { + msgs[i] = gqlErr.Message + } + return "", errors.Default.New(fmt.Sprintf("GraphQL errors: %s", strings.Join(msgs, "; "))) + } + + if result.Data.Owner.Repository.Yaml == nil { + return "", nil + } + + rawYaml := *result.Data.Owner.Repository.Yaml + if rawYaml == "" { + return "", nil + } + + if len(rawYaml) > maxConfigSize { + return "", errors.Default.New(fmt.Sprintf("repo yaml exceeds size limit (%d bytes)", maxConfigSize)) + } + + return rawYaml, nil +} + +func CollectRepoConfig(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*CodecovTaskData) + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + + owner, repo, err := ParseFullName(data.Options.FullName) + if err != nil { + return err + } + + if data.Repo == nil { + logger.Warn(nil, "[Codecov] CollectRepoConfig: No repo data available for %s, skipping", data.Options.FullName) + return nil + } + + serviceShort, mapErr := serviceShortCode(data.Service) + if mapErr != nil { + logger.Warn(nil, "[Codecov] CollectRepoConfig: unsupported service %q for %s/%s, skipping", data.Service, owner, repo) + return nil + } + + logger.Info("[Codecov] CollectRepoConfig: Fetching codecov config for %s/%s (service=%s)", owner, repo, data.Service) + + rawYaml, fetchErr := fetchRepoYaml(data.ApiClient, serviceShort, owner, repo) + if fetchErr != nil { + return errors.Default.Wrap(fetchErr, fmt.Sprintf("failed to fetch repo yaml via GraphQL for %s/%s", owner, repo)) + } + + if rawYaml == "" { + logger.Info("[Codecov] CollectRepoConfig: No codecov config found for %s/%s", owner, repo) + return nil + } + + logger.Info("[Codecov] CollectRepoConfig: Found repo yaml for %s/%s (%d bytes)", owner, repo, len(rawYaml)) + + config := parseCodecovYaml(rawYaml) + + repoConfig := &models.CodecovRepoConfig{ + NoPKModel: common.NoPKModel{}, + ConnectionId: data.Options.ConnectionId, + RepoId: data.Options.FullName, + ConfigSource: "codecov-graphql", + RawYaml: rawYaml, + ProjectTarget: config.projectTarget, + ProjectTargetAuto: config.projectTargetAuto, + ProjectThreshold: config.projectThreshold, + ProjectInformational: config.projectInformational, + PatchTarget: config.patchTarget, + PatchTargetAuto: config.patchTargetAuto, + PatchThreshold: config.patchThreshold, + PatchInformational: config.patchInformational, + } + + if err := db.CreateOrUpdate(repoConfig); err != nil { + return errors.Default.Wrap(err, "failed to save repo config") + } + + logger.Info("[Codecov] CollectRepoConfig: Saved config for %s/%s (patch_target=%v, project_target=%v)", + owner, repo, config.patchTarget, config.projectTarget) + return nil +} + +type parsedConfig struct { + projectTarget *float64 + projectTargetAuto bool + projectThreshold *float64 + projectInformational bool + patchTarget *float64 + patchTargetAuto bool + patchThreshold *float64 + patchInformational bool +} + +func parseCodecovYaml(raw string) parsedConfig { + var cfg codecovYaml + if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil { + return parsedConfig{} + } + + result := parsedConfig{} + + if def, ok := cfg.Coverage.Status.Project["default"]; ok { + result.projectTarget, result.projectTargetAuto = parseTarget(def.Target) + result.projectThreshold = parsePercentage(def.Threshold) + result.projectInformational = parseBool(def.Informational) + } + + if def, ok := cfg.Coverage.Status.Patch["default"]; ok { + result.patchTarget, result.patchTargetAuto = parseTarget(def.Target) + result.patchThreshold = parsePercentage(def.Threshold) + result.patchInformational = parseBool(def.Informational) + } + + return result +} + +// parseTarget handles target values: "auto", "80%", 80, 80.0 +func parseTarget(v interface{}) (target *float64, isAuto bool) { + if v == nil { + return nil, false + } + switch val := v.(type) { + case string: + val = strings.TrimSpace(val) + if strings.EqualFold(val, "auto") { + return nil, true + } + val = strings.TrimSuffix(val, "%") + if f, err := strconv.ParseFloat(val, 64); err == nil { + return &f, false + } + case int: + f := float64(val) + return &f, false + case float64: + return &val, false + } + return nil, false +} + +// parsePercentage handles threshold values: "1%", 1, 1.0 +func parsePercentage(v interface{}) *float64 { + if v == nil { + return nil + } + switch val := v.(type) { + case string: + val = strings.TrimSpace(strings.TrimSuffix(val, "%")) + if f, err := strconv.ParseFloat(val, 64); err == nil { + return &f + } + case int: + f := float64(val) + return &f + case float64: + return &val + } + return nil +} + +func parseBool(v interface{}) bool { + if v == nil { + return false + } + switch val := v.(type) { + case bool: + return val + case string: + return strings.EqualFold(val, "true") + } + return false +} diff --git a/backend/plugins/codecov/tasks/repo_config_collector_test.go b/backend/plugins/codecov/tasks/repo_config_collector_test.go new file mode 100644 index 00000000000..038b2b9c56c --- /dev/null +++ b/backend/plugins/codecov/tasks/repo_config_collector_test.go @@ -0,0 +1,693 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + mockplugin "github.com/apache/devlake/mocks/core/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/apache/devlake/core/errors" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +// --- serviceShortCode --- + +func TestServiceShortCode_AllServices(t *testing.T) { + cases := map[string]string{ + "github": "gh", + "github_enterprise": "ghe", + "gitlab": "gl", + "gitlab_enterprise": "gle", + "bitbucket": "bb", + "bitbucket_server": "bbs", + } + for service, want := range cases { + got, err := serviceShortCode(service) + require.NoError(t, err) + assert.Equal(t, want, got, "service %q", service) + } +} + +func TestServiceShortCode_Unknown(t *testing.T) { + _, err := serviceShortCode("unknown") + assert.Error(t, err) +} + +// --- fetchRepoYaml --- + +func newTestGraphQLApiClient(t *testing.T, handler http.HandlerFunc) *helper.ApiAsyncClient { + t.Helper() + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + + apiClient := &helper.ApiClient{} + apiClient.Setup(ts.URL, nil, 10*time.Second) + return &helper.ApiAsyncClient{ApiClient: apiClient} +} + +func TestFetchRepoYaml_Success(t *testing.T) { + yamlContent := "coverage:\n status:\n patch:\n default:\n target: 80%\n" + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/graphql/gh", r.URL.Path) + + var reqBody map[string]interface{} + require.NoError(t, json.NewDecoder(r.Body).Decode(&reqBody)) + assert.Contains(t, reqBody["query"], "GetRepoSettings") + + vars, ok := reqBody["variables"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "konflux-ci", vars["name"]) + assert.Equal(t, "build-service", vars["repo"]) + + w.WriteHeader(http.StatusOK) + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "owner": map[string]interface{}{ + "repository": map[string]interface{}{ + "yaml": yamlContent, + }, + }, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + }) + + got, err := fetchRepoYaml(apiClient, "gh", "konflux-ci", "build-service") + assert.NoError(t, err) + assert.Contains(t, got, "target: 80%") +} + +func TestFetchRepoYaml_NullYaml(t *testing.T) { + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"owner":{"repository":{"yaml":null}}}}`)) + }) + + got, err := fetchRepoYaml(apiClient, "gh", "owner", "repo") + assert.NoError(t, err) + assert.Empty(t, got) +} + +func TestFetchRepoYaml_HTTP401(t *testing.T) { + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"detail":"Unauthorized"}`)) + }) + + got, err := fetchRepoYaml(apiClient, "gh", "owner", "repo") + assert.Error(t, err) + assert.Empty(t, got) +} + +func TestFetchRepoYaml_HTTP404(t *testing.T) { + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"Not found"}`)) + }) + + got, err := fetchRepoYaml(apiClient, "gh", "owner", "repo") + assert.Error(t, err) + assert.Empty(t, got) +} + +func TestFetchRepoYaml_ResponseTooLarge(t *testing.T) { + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(make([]byte, maxGraphQLResponseSize+1)) + }) + + got, err := fetchRepoYaml(apiClient, "gh", "owner", "repo") + assert.Error(t, err) + assert.Empty(t, got) + assert.Contains(t, err.Error(), "exceeds size limit") +} + +func TestFetchRepoYaml_GraphQLErrors(t *testing.T) { + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":null,"errors":[{"message":"Repository not found"}]}`)) + }) + + got, err := fetchRepoYaml(apiClient, "gh", "owner", "repo") + assert.Error(t, err) + assert.Empty(t, got) + assert.Contains(t, err.Error(), "Repository not found") +} + +func TestFetchRepoYaml_EmptyResponseBody(t *testing.T) { + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + got, err := fetchRepoYaml(apiClient, "gh", "owner", "repo") + assert.Error(t, err) + assert.Empty(t, got) +} + +func TestFetchRepoYaml_InvalidJSON(t *testing.T) { + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`not-json`)) + }) + + got, err := fetchRepoYaml(apiClient, "gh", "owner", "repo") + assert.Error(t, err) + assert.Empty(t, got) + assert.Contains(t, err.Error(), "error decoding GraphQL response") +} + +func TestFetchRepoYaml_EmptyYamlString(t *testing.T) { + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"owner":{"repository":{"yaml":""}}}}`)) + }) + + got, err := fetchRepoYaml(apiClient, "gh", "owner", "repo") + assert.NoError(t, err) + assert.Empty(t, got) +} + +func TestFetchRepoYaml_YamlExceedsSizeLimit(t *testing.T) { + oversizedYaml := strings.Repeat("x", maxConfigSize+1) + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "owner": map[string]interface{}{ + "repository": map[string]interface{}{ + "yaml": oversizedYaml, + }, + }, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + }) + + got, err := fetchRepoYaml(apiClient, "gh", "owner", "repo") + assert.Error(t, err) + assert.Empty(t, got) + assert.Contains(t, err.Error(), "repo yaml exceeds size limit") +} + +func TestFetchRepoYaml_PostError(t *testing.T) { + apiClient := &helper.ApiClient{} + apiClient.Setup("http://127.0.0.1:1", nil, 50*time.Millisecond) + client := &helper.ApiAsyncClient{ApiClient: apiClient} + + got, err := fetchRepoYaml(client, "gh", "owner", "repo") + assert.Error(t, err) + assert.Empty(t, got) +} + +// --- CollectRepoConfig (integration) --- + +func TestCollectRepoConfig_SavesParsedConfig(t *testing.T) { + yamlContent := "coverage:\n status:\n patch:\n default:\n target: 80%\n" + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "owner": map[string]interface{}{ + "repository": map[string]interface{}{ + "yaml": yamlContent, + }, + }, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + }) + + mockCtx := new(mockplugin.SubTaskContext) + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + mockCtx.On("GetData").Return(&CodecovTaskData{ + Options: &CodecovOptions{ + ConnectionId: 1, + FullName: "konflux-ci/build-service", + }, + ApiClient: apiClient, + Service: "github", + Repo: &models.CodecovRepo{Branch: "main"}, + }) + mockCtx.On("GetDal").Return(mockDal) + mockCtx.On("GetLogger").Return(mockLogger) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + + mockDal.On("CreateOrUpdate", mock.MatchedBy(func(entity interface{}) bool { + cfg, ok := entity.(*models.CodecovRepoConfig) + if !ok { + return false + } + return cfg.ConnectionId == 1 && + cfg.RepoId == "konflux-ci/build-service" && + cfg.ConfigSource == "codecov-graphql" && + cfg.PatchTarget != nil && *cfg.PatchTarget == 80.0 + }), mock.Anything).Return(nil).Once() + + err := CollectRepoConfig(mockCtx) + assert.NoError(t, err) + mockDal.AssertExpectations(t) +} + +func TestCollectRepoConfig_SkipsWhenRepoMissing(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + mockCtx.On("GetData").Return(&CodecovTaskData{ + Options: &CodecovOptions{ + ConnectionId: 1, + FullName: "konflux-ci/build-service", + }, + Service: "github", + Repo: nil, + }) + mockCtx.On("GetDal").Return(mockDal) + mockCtx.On("GetLogger").Return(mockLogger) + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Once() + + err := CollectRepoConfig(mockCtx) + assert.NoError(t, err) + mockDal.AssertNotCalled(t, "CreateOrUpdate", mock.Anything, mock.Anything) +} + +func TestCollectRepoConfig_ReturnsFetchError(t *testing.T) { + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"detail":"Unauthorized"}`)) + }) + + mockCtx := new(mockplugin.SubTaskContext) + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + mockCtx.On("GetData").Return(&CodecovTaskData{ + Options: &CodecovOptions{ + ConnectionId: 1, + FullName: "konflux-ci/build-service", + }, + Service: "github", + ApiClient: apiClient, + Repo: &models.CodecovRepo{}, + }) + mockCtx.On("GetDal").Return(mockDal) + mockCtx.On("GetLogger").Return(mockLogger) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + err := CollectRepoConfig(mockCtx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch repo yaml via GraphQL") + mockDal.AssertNotCalled(t, "CreateOrUpdate", mock.Anything, mock.Anything) +} + +func TestCollectRepoConfig_InvalidFullName(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + mockCtx.On("GetData").Return(&CodecovTaskData{ + Options: &CodecovOptions{ + ConnectionId: 1, + FullName: "invalid-full-name-without-slash", + }, + Service: "github", + Repo: &models.CodecovRepo{}, + }) + mockCtx.On("GetDal").Return(mockDal) + mockCtx.On("GetLogger").Return(mockLogger) + + err := CollectRepoConfig(mockCtx) + assert.Error(t, err) + mockDal.AssertNotCalled(t, "CreateOrUpdate", mock.Anything, mock.Anything) +} + +func TestCollectRepoConfig_SkipsUnsupportedService(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + mockCtx.On("GetData").Return(&CodecovTaskData{ + Options: &CodecovOptions{ + ConnectionId: 1, + FullName: "konflux-ci/build-service", + }, + Service: "unknown-provider", + Repo: &models.CodecovRepo{}, + }) + mockCtx.On("GetDal").Return(mockDal) + mockCtx.On("GetLogger").Return(mockLogger) + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + + err := CollectRepoConfig(mockCtx) + assert.NoError(t, err) + mockDal.AssertNotCalled(t, "CreateOrUpdate", mock.Anything, mock.Anything) +} + +func TestCollectRepoConfig_NoConfigFound(t *testing.T) { + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"owner":{"repository":{"yaml":null}}}}`)) + }) + + mockCtx := new(mockplugin.SubTaskContext) + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + mockCtx.On("GetData").Return(&CodecovTaskData{ + Options: &CodecovOptions{ + ConnectionId: 1, + FullName: "konflux-ci/build-service", + }, + Service: "github", + ApiClient: apiClient, + Repo: &models.CodecovRepo{}, + }) + mockCtx.On("GetDal").Return(mockDal) + mockCtx.On("GetLogger").Return(mockLogger) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + err := CollectRepoConfig(mockCtx) + assert.NoError(t, err) + mockDal.AssertNotCalled(t, "CreateOrUpdate", mock.Anything, mock.Anything) +} + +func TestCollectRepoConfig_CreateOrUpdateError(t *testing.T) { + yamlContent := "coverage:\n status:\n patch:\n default:\n target: 80%\n" + apiClient := newTestGraphQLApiClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + resp := map[string]interface{}{ + "data": map[string]interface{}{ + "owner": map[string]interface{}{ + "repository": map[string]interface{}{ + "yaml": yamlContent, + }, + }, + }, + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + }) + + mockCtx := new(mockplugin.SubTaskContext) + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + mockCtx.On("GetData").Return(&CodecovTaskData{ + Options: &CodecovOptions{ + ConnectionId: 1, + FullName: "konflux-ci/build-service", + }, + Service: "github", + ApiClient: apiClient, + Repo: &models.CodecovRepo{}, + }) + mockCtx.On("GetDal").Return(mockDal) + mockCtx.On("GetLogger").Return(mockLogger) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything). + Return(errors.Default.New("db unavailable")).Once() + + err := CollectRepoConfig(mockCtx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to save repo config") +} + +// --- parseCodecovYaml --- + +func TestParseCodecovYaml_FullConfig(t *testing.T) { + raw := ` +coverage: + status: + project: + default: + target: 80% + threshold: 1% + informational: false + patch: + default: + target: 90% + threshold: 5% + informational: true +` + cfg := parseCodecovYaml(raw) + + assert.NotNil(t, cfg.projectTarget) + assert.Equal(t, 80.0, *cfg.projectTarget) + assert.False(t, cfg.projectTargetAuto) + assert.NotNil(t, cfg.projectThreshold) + assert.Equal(t, 1.0, *cfg.projectThreshold) + assert.False(t, cfg.projectInformational) + + assert.NotNil(t, cfg.patchTarget) + assert.Equal(t, 90.0, *cfg.patchTarget) + assert.False(t, cfg.patchTargetAuto) + assert.NotNil(t, cfg.patchThreshold) + assert.Equal(t, 5.0, *cfg.patchThreshold) + assert.True(t, cfg.patchInformational) +} + +func TestParseCodecovYaml_AutoTarget(t *testing.T) { + raw := ` +coverage: + status: + project: + default: + target: auto + patch: + default: + target: auto +` + cfg := parseCodecovYaml(raw) + + assert.Nil(t, cfg.projectTarget) + assert.True(t, cfg.projectTargetAuto) + assert.Nil(t, cfg.patchTarget) + assert.True(t, cfg.patchTargetAuto) +} + +func TestParseCodecovYaml_NumericTargets(t *testing.T) { + raw := ` +coverage: + status: + project: + default: + target: 75 + threshold: 2 + patch: + default: + target: 85.5 + threshold: 0.5 +` + cfg := parseCodecovYaml(raw) + + assert.NotNil(t, cfg.projectTarget) + assert.Equal(t, 75.0, *cfg.projectTarget) + assert.NotNil(t, cfg.projectThreshold) + assert.Equal(t, 2.0, *cfg.projectThreshold) + + assert.NotNil(t, cfg.patchTarget) + assert.Equal(t, 85.5, *cfg.patchTarget) + assert.NotNil(t, cfg.patchThreshold) + assert.Equal(t, 0.5, *cfg.patchThreshold) +} + +func TestParseCodecovYaml_Empty(t *testing.T) { + cfg := parseCodecovYaml("") + assert.Nil(t, cfg.projectTarget) + assert.Nil(t, cfg.patchTarget) + assert.False(t, cfg.projectTargetAuto) + assert.False(t, cfg.patchTargetAuto) +} + +func TestParseCodecovYaml_InvalidYaml(t *testing.T) { + cfg := parseCodecovYaml("{{{{not yaml") + assert.Nil(t, cfg.projectTarget) + assert.Nil(t, cfg.patchTarget) +} + +func TestParseCodecovYaml_NoDefault(t *testing.T) { + raw := ` +coverage: + status: + project: + custom-status: + target: 50% + patch: + custom-status: + target: 60% +` + cfg := parseCodecovYaml(raw) + assert.Nil(t, cfg.projectTarget) + assert.Nil(t, cfg.patchTarget) +} + +func TestParseCodecovYaml_OnlyPatch(t *testing.T) { + raw := ` +coverage: + status: + patch: + default: + target: 70% + informational: true +` + cfg := parseCodecovYaml(raw) + assert.Nil(t, cfg.projectTarget) + assert.NotNil(t, cfg.patchTarget) + assert.Equal(t, 70.0, *cfg.patchTarget) + assert.True(t, cfg.patchInformational) + assert.False(t, cfg.projectInformational) +} + +// --- parseTarget --- + +func TestParseTarget_Nil(t *testing.T) { + target, auto := parseTarget(nil) + assert.Nil(t, target) + assert.False(t, auto) +} + +func TestParseTarget_Auto(t *testing.T) { + target, auto := parseTarget("auto") + assert.Nil(t, target) + assert.True(t, auto) +} + +func TestParseTarget_AutoUpperCase(t *testing.T) { + target, auto := parseTarget("AUTO") + assert.Nil(t, target) + assert.True(t, auto) +} + +func TestParseTarget_StringPercent(t *testing.T) { + target, auto := parseTarget("80%") + assert.NotNil(t, target) + assert.Equal(t, 80.0, *target) + assert.False(t, auto) +} + +func TestParseTarget_StringNumber(t *testing.T) { + target, auto := parseTarget("75.5") + assert.NotNil(t, target) + assert.Equal(t, 75.5, *target) + assert.False(t, auto) +} + +func TestParseTarget_Int(t *testing.T) { + target, auto := parseTarget(80) + assert.NotNil(t, target) + assert.Equal(t, 80.0, *target) + assert.False(t, auto) +} + +func TestParseTarget_Float(t *testing.T) { + target, auto := parseTarget(85.5) + assert.NotNil(t, target) + assert.Equal(t, 85.5, *target) + assert.False(t, auto) +} + +func TestParseTarget_InvalidString(t *testing.T) { + target, auto := parseTarget("not-a-number") + assert.Nil(t, target) + assert.False(t, auto) +} + +func TestParseTarget_StringWithSpaces(t *testing.T) { + target, auto := parseTarget(" 90% ") + assert.NotNil(t, target) + assert.Equal(t, 90.0, *target) + assert.False(t, auto) +} + +// --- parsePercentage --- + +func TestParsePercentage_Nil(t *testing.T) { + assert.Nil(t, parsePercentage(nil)) +} + +func TestParsePercentage_StringPercent(t *testing.T) { + result := parsePercentage("1%") + assert.NotNil(t, result) + assert.Equal(t, 1.0, *result) +} + +func TestParsePercentage_StringNumber(t *testing.T) { + result := parsePercentage("5.5") + assert.NotNil(t, result) + assert.Equal(t, 5.5, *result) +} + +func TestParsePercentage_Int(t *testing.T) { + result := parsePercentage(3) + assert.NotNil(t, result) + assert.Equal(t, 3.0, *result) +} + +func TestParsePercentage_Float(t *testing.T) { + result := parsePercentage(2.5) + assert.NotNil(t, result) + assert.Equal(t, 2.5, *result) +} + +func TestParsePercentage_InvalidString(t *testing.T) { + assert.Nil(t, parsePercentage("abc")) +} + +// --- parseBool --- + +func TestParseBool_Nil(t *testing.T) { + assert.False(t, parseBool(nil)) +} + +func TestParseBool_True(t *testing.T) { + assert.True(t, parseBool(true)) +} + +func TestParseBool_False(t *testing.T) { + assert.False(t, parseBool(false)) +} + +func TestParseBool_StringTrue(t *testing.T) { + assert.True(t, parseBool("true")) + assert.True(t, parseBool("TRUE")) + assert.True(t, parseBool("True")) +} + +func TestParseBool_StringFalse(t *testing.T) { + assert.False(t, parseBool("false")) + assert.False(t, parseBool("something")) +} + +func TestParseBool_OtherType(t *testing.T) { + assert.False(t, parseBool(42)) +} diff --git a/backend/plugins/codecov/tasks/task_data.go b/backend/plugins/codecov/tasks/task_data.go new file mode 100644 index 00000000000..82f1ad1d680 --- /dev/null +++ b/backend/plugins/codecov/tasks/task_data.go @@ -0,0 +1,72 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "github.com/apache/devlake/core/errors" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/codecov/models" +) + +type CodecovOptions struct { + ConnectionId uint64 `json:"connectionId" mapstructure:"connectionId,omitempty"` + ScopeConfigId uint64 `json:"scopeConfigId" mapstructure:"scopeConfigId,omitempty"` + FullName string `json:"fullName" mapstructure:"fullName,omitempty"` + ScopeConfig *models.CodecovScopeConfig `mapstructure:"scopeConfig,omitempty" json:"scopeConfig"` +} + +type CodecovTaskData struct { + Options *CodecovOptions + ApiClient *helper.ApiAsyncClient + Repo *models.CodecovRepo + Service string // Codecov API service segment (github, gitlab, etc.) +} + +// CodecovApiParams matches the models.CodecovApiParams +type CodecovApiParams models.CodecovApiParams + +func DecodeAndValidateTaskOptions(options map[string]interface{}) (*CodecovOptions, errors.Error) { + op, err := DecodeTaskOptions(options) + if err != nil { + return nil, err + } + err = ValidateTaskOptions(op) + if err != nil { + return nil, err + } + return op, nil +} + +func DecodeTaskOptions(options map[string]interface{}) (*CodecovOptions, errors.Error) { + var op CodecovOptions + err := helper.Decode(options, &op, nil) + if err != nil { + return nil, err + } + return &op, nil +} + +func ValidateTaskOptions(op *CodecovOptions) errors.Error { + if op.ConnectionId == 0 { + return errors.BadInput.New("connectionId is invalid") + } + if op.FullName == "" { + return errors.BadInput.New("fullName is required") + } + return nil +} diff --git a/backend/plugins/codecov/tasks/task_data_test.go b/backend/plugins/codecov/tasks/task_data_test.go new file mode 100644 index 00000000000..370c2e9bfeb --- /dev/null +++ b/backend/plugins/codecov/tasks/task_data_test.go @@ -0,0 +1,137 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateTaskOptions(t *testing.T) { + t.Run("valid options", func(t *testing.T) { + err := ValidateTaskOptions(&CodecovOptions{ + ConnectionId: 1, + FullName: "org/repo", + }) + assert.Nil(t, err) + }) + + t.Run("zero connectionId returns error", func(t *testing.T) { + err := ValidateTaskOptions(&CodecovOptions{ + ConnectionId: 0, + FullName: "org/repo", + }) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "connectionId") + }) + + t.Run("empty fullName returns error", func(t *testing.T) { + err := ValidateTaskOptions(&CodecovOptions{ + ConnectionId: 1, + FullName: "", + }) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "fullName") + }) + + t.Run("both invalid returns connectionId error first", func(t *testing.T) { + err := ValidateTaskOptions(&CodecovOptions{ + ConnectionId: 0, + FullName: "", + }) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "connectionId") + }) +} + +func TestDecodeTaskOptions(t *testing.T) { + t.Run("valid map decodes correctly", func(t *testing.T) { + opts := map[string]any{ + "connectionId": uint64(42), + "fullName": "owner/repo", + } + result, err := DecodeTaskOptions(opts) + assert.Nil(t, err) + assert.Equal(t, uint64(42), result.ConnectionId) + assert.Equal(t, "owner/repo", result.FullName) + }) + + t.Run("empty map produces zero-value options", func(t *testing.T) { + result, err := DecodeTaskOptions(map[string]any{}) + assert.Nil(t, err) + assert.Equal(t, uint64(0), result.ConnectionId) + assert.Empty(t, result.FullName) + }) + + t.Run("scopeConfigId decoded", func(t *testing.T) { + opts := map[string]any{ + "connectionId": uint64(1), + "fullName": "org/repo", + "scopeConfigId": uint64(99), + } + result, err := DecodeTaskOptions(opts) + assert.Nil(t, err) + assert.Equal(t, uint64(99), result.ScopeConfigId) + }) +} + +func TestDecodeAndValidateTaskOptions(t *testing.T) { + t.Run("valid input succeeds", func(t *testing.T) { + opts := map[string]any{ + "connectionId": uint64(1), + "fullName": "owner/repo", + } + result, err := DecodeAndValidateTaskOptions(opts) + assert.Nil(t, err) + assert.Equal(t, uint64(1), result.ConnectionId) + assert.Equal(t, "owner/repo", result.FullName) + }) + + t.Run("missing fullName fails validation", func(t *testing.T) { + opts := map[string]any{ + "connectionId": uint64(1), + } + result, err := DecodeAndValidateTaskOptions(opts) + assert.NotNil(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "fullName") + }) + + t.Run("decode error returns error", func(t *testing.T) { + // Pass a value that mapstructure cannot decode into uint64 + opts := map[string]any{ + "connectionId": map[string]any{"nested": "object"}, + "fullName": "owner/repo", + } + result, err := DecodeAndValidateTaskOptions(opts) + assert.NotNil(t, err) + assert.Nil(t, result) + }) +} + +func TestDecodeTaskOptions_Error(t *testing.T) { + // Pass a value that mapstructure cannot decode into uint64 + opts := map[string]any{ + "connectionId": map[string]any{"nested": "object"}, + "fullName": "owner/repo", + } + result, err := DecodeTaskOptions(opts) + assert.NotNil(t, err) + assert.Nil(t, result) +} diff --git a/backend/plugins/gitextractor/impl/impl.go b/backend/plugins/gitextractor/impl/impl.go index d0f763489af..bf80f0b897d 100644 --- a/backend/plugins/gitextractor/impl/impl.go +++ b/backend/plugins/gitextractor/impl/impl.go @@ -118,9 +118,14 @@ func (p GitExtractor) PrepareTaskData(taskCtx plugin.TaskContext, options map[st loadBool(&op.UseGoGit, "UseGoGit", false) loadBool(&op.SkipCommitStat, "SKIP_COMMIT_STAT", false) loadBool(&op.SkipCommitFiles, "SKIP_COMMIT_FILES", true) + if cfg.GetBool("FORCE_FULL_GIT_HISTORY") { + op.ForceFullClone = true + } log.Info("UseGoGit: %v", *op.UseGoGit) log.Info("SkipCommitStat: %v", *op.SkipCommitStat) log.Info("SkipCommitFiles: %v", *op.SkipCommitFiles) + log.Info("NoShallowClone: %v", op.NoShallowClone) + log.Info("ForceFullClone: %v", op.ForceFullClone) taskData := &parser.GitExtractorTaskData{ Options: &op, diff --git a/backend/plugins/gitextractor/parser/clone_gitcli.go b/backend/plugins/gitextractor/parser/clone_gitcli.go index 82fc9d57afb..dc16152291a 100644 --- a/backend/plugins/gitextractor/parser/clone_gitcli.go +++ b/backend/plugins/gitextractor/parser/clone_gitcli.go @@ -50,6 +50,7 @@ type CloneRepoConfig struct { SkipCommitStat *bool SkipCommitFiles *bool NoShallowClone bool + ForceFullClone bool } type GitcliCloner struct { @@ -76,6 +77,7 @@ func NewGitcliCloner(ctx plugin.SubTaskContext, localDir string) (*GitcliCloner, SkipCommitStat: taskData.Options.SkipCommitStat, SkipCommitFiles: taskData.Options.SkipCommitFiles, NoShallowClone: taskData.Options.NoShallowClone, + ForceFullClone: taskData.Options.ForceFullClone, }, })) @@ -191,25 +193,45 @@ func (g *GitcliCloner) IsIncremental() bool { return false } +type cloneStrategy int + +const ( + strategyFull cloneStrategy = iota + strategyDoubleClone // full clone to temp dir, shallow from local (for remotes without --shallow-since) + strategyShallow +) + +func chooseCloneStrategy(since *time.Time, forceFullClone, noShallowClone bool) cloneStrategy { + if since == nil || forceFullClone { + return strategyFull + } + if noShallowClone { + return strategyDoubleClone + } + return strategyShallow +} + func (g *GitcliCloner) CloneRepo() errors.Error { - if g.since == nil { - // full sync + strategy := chooseCloneStrategy(g.since, g.taskData.Options.ForceFullClone, g.taskData.Options.NoShallowClone) + switch strategy { + case strategyFull: if err := g.fullClone(); err != nil { return err } - } else { - if g.taskData.Options.NoShallowClone { - // data source does not support shallow clone - // 1. perform a full clone to accommodate - // 2. perform a local shallow clone to reduce the libgit2 memory usage - if err := g.doubleClone(); err != nil { - return err - } - } else { - // data source support shallow clone - if err := g.shallowClone(); err != nil { - return err - } + if g.since != nil { + g.success = true + } + case strategyDoubleClone: + if err := g.doubleClone(); err != nil { + return err + } + if err := g.deepen(); err != nil { + return err + } + g.success = true + case strategyShallow: + if err := g.shallowClone(); err != nil { + return err } if err := g.deepen(); err != nil { return err @@ -351,16 +373,14 @@ func (g *GitcliCloner) doubleClone() errors.Error { return errors.Convert(e) } defer os.RemoveAll(intermediaryDir) // CWE-459: ensure cleanup on all exit paths - // step 1: full clone into a intermediary dir backup := g.localDir g.localDir = intermediaryDir if err := g.fullClone(); err != nil { return err } g.localDir = backup - // step 2: perform shallow clone against the intermediary dir backup = g.remoteUrl - g.remoteUrl = fmt.Sprintf("file://%s", intermediaryDir) // the file:// prefix is required for shallow clone to work + g.remoteUrl = fmt.Sprintf("file://%s", intermediaryDir) if err := g.shallowClone(); err != nil { return err } diff --git a/backend/plugins/gitextractor/parser/clone_gitcli_test.go b/backend/plugins/gitextractor/parser/clone_gitcli_test.go index 790980f64f6..285020860bd 100644 --- a/backend/plugins/gitextractor/parser/clone_gitcli_test.go +++ b/backend/plugins/gitextractor/parser/clone_gitcli_test.go @@ -184,3 +184,80 @@ func TestNewestShallowCommitTimeIgnoresMissingCommits(t *testing.T) { require.NoError(t, err) assert.True(t, newest.IsZero()) } + +func TestChooseCloneStrategy(t *testing.T) { + now := time.Now() + tests := []struct { + name string + since *time.Time + forceFullClone bool + noShallowClone bool + want cloneStrategy + }{ + { + name: "full sync (since=nil)", + since: nil, + forceFullClone: false, + noShallowClone: false, + want: strategyFull, + }, + { + name: "full sync takes precedence over noShallowClone", + since: nil, + forceFullClone: false, + noShallowClone: true, + want: strategyFull, + }, + { + name: "forceFullClone overrides incremental to full", + since: &now, + forceFullClone: true, + noShallowClone: false, + want: strategyFull, + }, + { + name: "forceFullClone takes precedence over noShallowClone", + since: &now, + forceFullClone: true, + noShallowClone: true, + want: strategyFull, + }, + { + name: "forceFullClone with nil since is still full", + since: nil, + forceFullClone: true, + noShallowClone: false, + want: strategyFull, + }, + { + name: "forceFullClone with nil since takes precedence over noShallowClone", + since: nil, + forceFullClone: true, + noShallowClone: true, + want: strategyFull, + }, + { + name: "noShallowClone on incremental uses doubleClone", + since: &now, + forceFullClone: false, + noShallowClone: true, + want: strategyDoubleClone, + }, + { + name: "default incremental uses shallow", + since: &now, + forceFullClone: false, + noShallowClone: false, + want: strategyShallow, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := chooseCloneStrategy(tt.since, tt.forceFullClone, tt.noShallowClone) + if got != tt.want { + t.Errorf("chooseCloneStrategy() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/backend/plugins/gitextractor/parser/taskdata.go b/backend/plugins/gitextractor/parser/taskdata.go index bdfdbd2ae1b..41272448749 100644 --- a/backend/plugins/gitextractor/parser/taskdata.go +++ b/backend/plugins/gitextractor/parser/taskdata.go @@ -45,6 +45,7 @@ type GitExtractorOptions struct { SkipCommitStat *bool `json:"skipCommitStat" mapstructure:"skipCommitStat" comment:"skip all commit stat including added/deleted lines and commit files as well"` SkipCommitFiles *bool `json:"skipCommitFiles" mapstructure:"skipCommitFiles"` NoShallowClone bool `json:"noShallowClone" mapstructure:"noShallowClone"` + ForceFullClone bool `json:"forceFullClone" mapstructure:"forceFullClone"` ConnectionId uint64 `json:"connectionId" mapstructure:"connectionId,omitempty"` PluginName string `json:"pluginName" mapstructure:"pluginName,omitempty"` // Configured by upstream plugin (e.g., GitLab) to exclude file extensions from commit stats diff --git a/backend/plugins/github/tasks/account_convertor.go b/backend/plugins/github/tasks/account_convertor.go index 9a85acf5bc7..f05a0249b24 100644 --- a/backend/plugins/github/tasks/account_convertor.go +++ b/backend/plugins/github/tasks/account_convertor.go @@ -147,8 +147,6 @@ func ConvertAccounts(taskCtx plugin.SubTaskContext) errors.Error { orgStr = orgStr[:255] } } - isBot := githubUser.Type == "Bot" || strings.HasSuffix(githubUser.Login, "[bot]") - domainUser := &crossdomain.Account{ DomainEntity: domainlayer.DomainEntity{Id: accountIdGen.Generate(data.Options.ConnectionId, githubUser.Id)}, Email: githubUser.Email, @@ -156,7 +154,7 @@ func ConvertAccounts(taskCtx plugin.SubTaskContext) errors.Error { UserName: githubUser.Login, AvatarUrl: githubUser.AvatarUrl, Organization: orgStr, - IsBot: isBot, + IsBot: isBotAccount(githubUser.Login, githubUser.Type), } return []interface{}{ domainUser, @@ -169,3 +167,23 @@ func ConvertAccounts(taskCtx plugin.SubTaskContext) errors.Error { return converter.Execute() } + +// isBotAccount reports whether a GitHub account identifies a bot, based on +// the API-reported account type or well-known login conventions ([bot] +// suffix for GitHub Apps, -bot/-robot suffixes, or exact logins +// copilot/dependabot/github-actions/codecov-commenter). +func isBotAccount(login string, accountType string) bool { + if accountType == "Bot" { + return true + } + lowerLogin := strings.ToLower(login) + if strings.HasSuffix(lowerLogin, "[bot]") || + strings.HasSuffix(lowerLogin, "-bot") || + strings.HasSuffix(lowerLogin, "-robot") { + return true + } + return lowerLogin == "copilot" || + lowerLogin == "dependabot" || + lowerLogin == "github-actions" || + lowerLogin == "codecov-commenter" +} diff --git a/backend/plugins/github/tasks/account_convertor_test.go b/backend/plugins/github/tasks/account_convertor_test.go new file mode 100644 index 00000000000..53be7e3e5a1 --- /dev/null +++ b/backend/plugins/github/tasks/account_convertor_test.go @@ -0,0 +1,82 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsBotAccount(t *testing.T) { + tests := []struct { + name string + login string + accountType string + want bool + }{ + // API-reported type takes precedence over any login pattern. + {"api type marks bot even without suffix", "some-github-app", "Bot", true}, + + // [bot] suffix - GitHub Apps. + {"github app bot suffix", "renovate[bot]", "", true}, + {"github-actions bot suffix", "github-actions[bot]", "", true}, + {"codecov bot suffix", "codecov-commenter[bot]", "", true}, + {"coderabbit bot suffix", "coderabbit[bot]", "", true}, + {"cursor bot suffix", "cursor[bot]", "", true}, + {"claude bot suffix", "claude[bot]", "", true}, + {"copilot reviewer bot suffix", "copilot-pull-request-reviewer[bot]", "", true}, + + // Known bot logins referenced by DPROD-1262. + {"triage bot login", "konflux-ci-triage[bot]", "", true}, + {"review bot login", "konflux-ci-review[bot]", "", true}, + {"coder bot login", "konflux-ci-coder[bot]", "", true}, + {"fullsend bot login", "konflux-ci-fullsend[bot]", "", true}, + + // Hyphenated -bot/-robot suffixes (no brackets). + {"hyphenated bot suffix", "openshift-bot", "", true}, + {"hyphenated robot suffix", "openshift-cherrypick-robot", "", true}, + {"redhat renovate bot suffix", "redhat-renovate-bot", "", true}, + {"snyk bot suffix", "snyk-bot", "", true}, + + // Standalone names without a suffix marker. + {"standalone copilot login", "copilot", "", true}, + {"dependabot without brackets", "dependabot", "", true}, + {"github-actions without brackets", "github-actions", "", true}, + {"codecov commenter bot, real GitHub API type is User", "codecov-commenter", "User", true}, + + // Negative cases: must not false-positive on substrings, only + // on the known suffixes/markers above. + {"normal human user", "octocat", "User", false}, + {"organization account is not a bot", "my-org", "Organization", false}, + {"surname containing bot as a substring", "abbott", "User", false}, + {"login containing bot mid-string", "robotics-fan", "User", false}, + {"copilot substring does not mark bot", "copilot-trainer", "User", false}, + {"dependabot substring does not mark bot", "dependabot-helper", "User", false}, + {"github-actions substring does not mark bot", "github-actions-user", "User", false}, + {"codecov-commenter substring does not mark bot", "codecov-commenter-fan", "User", false}, + {"empty type and no bot marker", "some-human-login", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isBotAccount(tt.login, tt.accountType) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/backend/plugins/github_graphql/tasks/pr_collector.go b/backend/plugins/github_graphql/tasks/pr_collector.go index 760f9c2786e..9eed2b3ec4b 100644 --- a/backend/plugins/github_graphql/tasks/pr_collector.go +++ b/backend/plugins/github_graphql/tasks/pr_collector.go @@ -109,11 +109,28 @@ type ReviewRequestNode struct { RequestedReviewer RequestedReviewer `graphql:"requestedReviewer"` } +// RequestedReviewer represents the GitHub RequestedReviewer union type. +// In GitHub's schema, RequestedReviewer = User | Team | Mannequin (no Bot). type RequestedReviewer struct { User User `graphql:"... on User"` Team Team `graphql:"... on Team"` } +// GetId returns the database ID from the User fragment. +func (r *RequestedReviewer) GetId() int { + return r.User.Id +} + +// GetLogin returns the login from the User fragment. +func (r *RequestedReviewer) GetLogin() string { + return r.User.Login +} + +// GetName returns the name from the User fragment. +func (r *RequestedReviewer) GetName() string { + return r.User.Name +} + type User struct { Id int `graphql:"databaseId"` Login string `graphql:"login"` @@ -201,6 +218,7 @@ func CollectPrs(taskCtx plugin.SubTaskContext) errors.Error { "owner": graphql.String(ownerName[0]), "name": graphql.String(ownerName[1]), } + return query, variables, nil }, GetPageInfo: func(iQuery interface{}, args *api.GraphqlCollectorArgs) (*api.GraphqlQueryPageInfo, error) { diff --git a/backend/plugins/gitlab/tasks/account_convertor.go b/backend/plugins/gitlab/tasks/account_convertor.go index 8af3f7036e4..20e3cfabf63 100644 --- a/backend/plugins/gitlab/tasks/account_convertor.go +++ b/backend/plugins/gitlab/tasks/account_convertor.go @@ -19,6 +19,8 @@ package tasks import ( "reflect" + "regexp" + "strings" "github.com/apache/devlake/core/dal" "github.com/apache/devlake/core/errors" @@ -30,6 +32,39 @@ import ( gitlabModels "github.com/apache/devlake/plugins/gitlab/models" ) +// gitlabBotTokenPattern matches GitLab project/group access token usernames, +// e.g. "project_78877_bot_ec63972b..." or "group_80889_bot_73ed0037...". +var gitlabBotTokenPattern = regexp.MustCompile(`^(project|group)_[0-9]+_bot`) + +// isGitlabBotAccount reports whether a GitLab account identifies a bot. +// GitLab accounts have no API-reported type field, so detection relies on +// username conventions (access token prefixes, -bot/-robot suffixes) and +// the account's display name containing "Service Account". +func isGitlabBotAccount(username string, fullName string) bool { + lowerUsername := strings.ToLower(username) + if gitlabBotTokenPattern.MatchString(lowerUsername) { + return true + } + if strings.HasSuffix(lowerUsername, "-bot") || strings.HasSuffix(lowerUsername, "-robot") { + return true + } + return strings.Contains(strings.ToLower(fullName), "service account") +} + +// buildGitlabDomainAccount converts a tool-layer GitlabAccount row into a +// domain Account, flagging bot identities via isGitlabBotAccount. +func buildGitlabDomainAccount(id string, gitlabAccount *gitlabModels.GitlabAccount) *crossdomain.Account { + return &crossdomain.Account{ + DomainEntity: domainlayer.DomainEntity{Id: id}, + UserName: gitlabAccount.Username, + FullName: gitlabAccount.Name, + Email: gitlabAccount.Email, + AvatarUrl: gitlabAccount.AvatarUrl, + CreatedDate: gitlabAccount.CreatedUserAt, + IsBot: isGitlabBotAccount(gitlabAccount.Username, gitlabAccount.Name), + } +} + func init() { RegisterSubtaskMeta(&ConvertAccountsMeta) } @@ -67,14 +102,7 @@ func ConvertAccounts(taskCtx plugin.SubTaskContext) errors.Error { }, Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { GitlabAccount := inputRow.(*gitlabModels.GitlabAccount) - domainUser := &crossdomain.Account{ - DomainEntity: domainlayer.DomainEntity{Id: accountIdGen.Generate(data.Options.ConnectionId, GitlabAccount.GitlabId)}, - UserName: GitlabAccount.Username, - FullName: GitlabAccount.Name, - Email: GitlabAccount.Email, - AvatarUrl: GitlabAccount.AvatarUrl, - CreatedDate: GitlabAccount.CreatedUserAt, - } + domainUser := buildGitlabDomainAccount(accountIdGen.Generate(data.Options.ConnectionId, GitlabAccount.GitlabId), GitlabAccount) return []interface{}{ domainUser, diff --git a/backend/plugins/gitlab/tasks/account_convertor_test.go b/backend/plugins/gitlab/tasks/account_convertor_test.go new file mode 100644 index 00000000000..8ec43165431 --- /dev/null +++ b/backend/plugins/gitlab/tasks/account_convertor_test.go @@ -0,0 +1,85 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/apache/devlake/plugins/gitlab/models" +) + +func TestIsGitlabBotAccount(t *testing.T) { + tests := []struct { + name string + username string + fullName string + want bool + }{ + // Project/group access tokens: ^(project|group)_[0-9]+_bot... + {"project access token", "project_78877_bot_ec63972b1234567890abcdef", "", true}, + {"group access token", "group_80889_bot_73ed0037abcdef123456", "", true}, + + // Hyphenated -bot/-robot suffixes. + {"hyphenated bot suffix", "pnc-stage-bot", "", true}, + {"hyphenated bot suffix variant", "snyk-broker-bot", "", true}, + {"hyphenated robot suffix", "openshift-cherrypick-robot", "", true}, + + // Name contains "Service Account" (case-insensitive), independent of username shape. + {"service account name marks bot", "renovate-app", "Renovate Service Account", true}, + {"service account name case-insensitive", "ci-runner", "SERVICE ACCOUNT for CI", true}, + {"known prod example: username and name both signal bot", "rh-renovate-bot", "renovate service account", true}, + + // Negative cases: must not false-positive on substrings or near-miss shapes. + {"normal human user", "jsmith", "John Smith", false}, + {"username containing bot mid-string", "robotics-team-lead", "", false}, + {"name containing bot substring without service account phrase", "abbott", "Abbott Contractor", false}, + {"project id present but no bot marker", "project_78877_admin", "", false}, + {"bot as a separate segment, not a suffix or token prefix", "team_bot_helper", "", false}, + {"underscore before bot without project/group token prefix", "user_78877_bot", "", false}, + {"empty name and no bot marker", "alice", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isGitlabBotAccount(tt.username, tt.fullName) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBuildGitlabDomainAccount_SetsIsBot(t *testing.T) { + tests := []struct { + name string + account *models.GitlabAccount + wantIsBot bool + }{ + {"human user", &models.GitlabAccount{Username: "jsmith", Name: "John Smith"}, false}, + {"project token bot", &models.GitlabAccount{Username: "project_123_bot_abc"}, true}, + {"service account name", &models.GitlabAccount{Username: "renovate-app", Name: "Renovate Service Account"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildGitlabDomainAccount("gitlab:GitlabAccount:1:1", tt.account) + assert.Equal(t, tt.wantIsBot, got.IsBot) + assert.Equal(t, tt.account.Username, got.UserName) + }) + } +} diff --git a/backend/plugins/jira/impl/impl.go b/backend/plugins/jira/impl/impl.go index c66a7061c62..757df740e93 100644 --- a/backend/plugins/jira/impl/impl.go +++ b/backend/plugins/jira/impl/impl.go @@ -122,6 +122,8 @@ func (p Jira) SubTaskMetas() []plugin.SubTaskMeta { tasks.CollectIssuesMeta, tasks.ExtractIssuesMeta, + tasks.CollectParentIssuesMeta, + tasks.ConvertIssueLabelsMeta, tasks.CollectIssueCommentsMeta, @@ -144,6 +146,7 @@ func (p Jira) SubTaskMetas() []plugin.SubTaskMeta { tasks.CollectEpicsMeta, tasks.ExtractEpicsMeta, + tasks.CleanupStaleBoardIssuesMeta, tasks.CollectAccountsMeta, diff --git a/backend/plugins/jira/tasks/parent_issue_collector.go b/backend/plugins/jira/tasks/parent_issue_collector.go new file mode 100644 index 00000000000..98ec04f901f --- /dev/null +++ b/backend/plugins/jira/tasks/parent_issue_collector.go @@ -0,0 +1,279 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/jira/models" + "github.com/apache/devlake/plugins/jira/tasks/apiv2models" +) + +var _ plugin.SubTaskEntryPoint = CollectParentIssues + +var CollectParentIssuesMeta = plugin.SubTaskMeta{ + Name: "collectParentIssues", + EntryPoint: CollectParentIssues, + EnabledByDefault: true, + Description: "collect parent issues (Features, Outcomes) referenced by epic_key but not in current collection", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +// CollectParentIssues collects parent issues that are referenced in epic_key field +// but were not collected due to JQL filter restrictions (e.g., Features in a different project) +func CollectParentIssues(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*JiraTaskData) + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + connectionId := data.Options.ConnectionId + boardId := data.Options.BoardId + + logger.Info("collecting parent issues for connection_id=%d, board_id=%d", connectionId, data.Options.BoardId) + + // Collect parent issues iteratively (they may have their own parents) + maxIterations := 10 // Prevent infinite loops + totalCollected := 0 + + for iteration := 0; iteration < maxIterations; iteration++ { + // Find all unique epic_key values that reference issues not in our collection + var epicKeys []struct { + EpicKey string + } + err := db.All(&epicKeys, + dal.Select("DISTINCT epic_key"), + dal.From(&models.JiraIssue{}), + dal.Where("connection_id = ? AND epic_key IS NOT NULL AND epic_key != '' AND issue_id IN (SELECT issue_id FROM _tool_jira_board_issues WHERE connection_id = ? AND board_id = ?)", connectionId, connectionId, boardId), + ) + if err != nil { + return errors.Default.Wrap(err, "failed to query epic_key values") + } + + if len(epicKeys) == 0 { + logger.Info("no parent keys found, skipping parent issue collection") + break + } + + // Convert to string slice + var parentKeys []string + for _, ek := range epicKeys { + parentKeys = append(parentKeys, ek.EpicKey) + } + + // Filter out keys that are already collected + var existingIssues []struct { + IssueKey string + } + err = db.All(&existingIssues, + dal.Select("DISTINCT issue_key"), + dal.From(&models.JiraIssue{}), + dal.Where("connection_id = ? AND issue_key IN ?", connectionId, parentKeys), + ) + if err != nil { + return errors.Default.Wrap(err, "failed to query existing issue keys") + } + + existingKeyMap := make(map[string]bool) + for _, issue := range existingIssues { + existingKeyMap[issue.IssueKey] = true + } + + var keysToCollect []string + for _, key := range parentKeys { + if !existingKeyMap[key] { + keysToCollect = append(keysToCollect, key) + } + } + + if len(keysToCollect) == 0 { + logger.Info("iteration %d: all parent issues already collected", iteration+1) + break + } + + logger.Info("iteration %d: collecting %d missing parent issues: %v", iteration+1, len(keysToCollect), keysToCollect) + + // Collect each parent issue directly + for _, issueKey := range keysToCollect { + err = collectAndExtractSingleIssue(taskCtx, data, db, issueKey) + if err != nil { + // Log but don't fail - the issue might not exist or we might not have permission + logger.Warn(err, "failed to collect parent issue %s, skipping", issueKey) + continue + } + totalCollected++ + } + } + + logger.Info("collected %d parent issues in total", totalCollected) + return nil +} + +// collectAndExtractSingleIssue collects a single issue by key and extracts it +func collectAndExtractSingleIssue(taskCtx plugin.SubTaskContext, data *JiraTaskData, db dal.Dal, issueKey string) errors.Error { + logger := taskCtx.GetLogger() + + // Fetch the issue from Jira API + path := fmt.Sprintf("api/2/issue/%s", issueKey) + query := url.Values{} + query.Set("expand", "changelog") + + resp, err := data.ApiClient.Get(path, query, nil) + if err != nil { + return errors.Default.Wrap(err, fmt.Sprintf("failed to fetch issue %s", issueKey)) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + logger.Info("issue %s not found, skipping", issueKey) + return nil + } + + if resp.StatusCode != http.StatusOK { + return errors.Default.New(fmt.Sprintf("unexpected status code %d for issue %s", resp.StatusCode, issueKey)) + } + + blob, err := errors.Convert01(io.ReadAll(resp.Body)) + if err != nil { + return errors.Default.Wrap(err, "failed to read response body") + } + + // Parse and extract the issue + var issue apiv2models.Issue + err = errors.Convert(json.Unmarshal(blob, &issue)) + if err != nil { + return errors.Default.Wrap(err, "failed to parse issue JSON") + } + + err = issue.SetAllFields(blob) + if err != nil { + return err + } + + if issue.Fields.Created == nil { + logger.Info("issue %s has no created date, skipping", issueKey) + return nil + } + + // Get type mappings + mappings, err := getTypeMappings(data, db) + if err != nil { + return err + } + + userFieldMap, err := getUserFieldMap(db, data.Options.ConnectionId, logger) + if err != nil { + return err + } + + // Extract entities + _, jiraIssue, comments, worklogs, changelogs, changelogItems, users := issue.ExtractEntities(data.Options.ConnectionId, userFieldMap) + + // Extract epic key from custom field if configured + if data.Options.ScopeConfig != nil && data.Options.ScopeConfig.EpicKeyField != "" { + unknownEpicKey := issue.Fields.AllFields[data.Options.ScopeConfig.EpicKeyField] + switch ek := unknownEpicKey.(type) { + case string: + jiraIssue.EpicKey = ek + case map[string]interface{}: + if key, ok := ek["key"].(string); ok { + jiraIssue.EpicKey = key + } + } + } + + // Set resolution date and lead time + if jiraIssue.ResolutionDate != nil { + temp := uint(jiraIssue.ResolutionDate.Unix()-jiraIssue.Created.Unix()) / 60 + jiraIssue.LeadTimeMinutes = &temp + } + + // Set type mappings + jiraIssue.Type = mappings.TypeIdMappings[jiraIssue.Type] + jiraIssue.StdType = mappings.StdTypeMappings[jiraIssue.Type] + if jiraIssue.StdType == "" { + jiraIssue.StdType = strings.ToUpper(jiraIssue.Type) + } + jiraIssue.StdStatus = getStdStatus(jiraIssue.StatusKey) + if value, ok := mappings.StandardStatusMappings[jiraIssue.Type][jiraIssue.StatusKey]; ok { + jiraIssue.StdStatus = value.StandardStatus + } + + // Save the issue + err = db.CreateOrUpdate(jiraIssue) + if err != nil { + return errors.Default.Wrap(err, fmt.Sprintf("failed to save issue %s", issueKey)) + } + + // Save comments + for _, comment := range comments { + err = db.CreateOrUpdate(comment) + if err != nil { + logger.Warn(err, "failed to save comment for issue %s", issueKey) + } + } + + // Save worklogs + for _, worklog := range worklogs { + err = db.CreateOrUpdate(worklog) + if err != nil { + logger.Warn(err, "failed to save worklog for issue %s", issueKey) + } + } + + // Save changelogs + var issueUpdated *time.Time + if len(changelogs) < 100 { + issueUpdated = &jiraIssue.Updated + } + for _, changelog := range changelogs { + changelog.IssueUpdated = issueUpdated + err = db.CreateOrUpdate(changelog) + if err != nil { + logger.Warn(err, "failed to save changelog for issue %s", issueKey) + } + } + + // Save changelog items + for _, changelogItem := range changelogItems { + err = db.CreateOrUpdate(changelogItem) + if err != nil { + logger.Warn(err, "failed to save changelog item for issue %s", issueKey) + } + } + + // Save users + for _, user := range users { + if user.AccountId != "" { + err = db.CreateOrUpdate(user) + if err != nil { + logger.Warn(err, "failed to save user for issue %s", issueKey) + } + } + } + + logger.Info("successfully collected and extracted parent issue %s (%s)", issueKey, jiraIssue.Type) + return nil +} diff --git a/backend/plugins/jira/tasks/stale_board_issue_cleaner.go b/backend/plugins/jira/tasks/stale_board_issue_cleaner.go new file mode 100644 index 00000000000..1a080ead947 --- /dev/null +++ b/backend/plugins/jira/tasks/stale_board_issue_cleaner.go @@ -0,0 +1,221 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/jira/models" +) + +const staleBoardIssueCheckBatchSize = 100 + +var _ plugin.SubTaskEntryPoint = CleanupStaleBoardIssues + +var CleanupStaleBoardIssuesMeta = plugin.SubTaskMeta{ + Name: "cleanupStaleBoardIssues", + EntryPoint: CleanupStaleBoardIssues, + EnabledByDefault: true, + Description: "remove board associations for issues no longer returned by the board API (e.g. moved to a different board/team)", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +// CleanupStaleBoardIssues batch-checks all issues in _tool_jira_board_issues against the +// board API (100 per request via issue IN (...) JQL) and removes any that are no longer +// returned. Covers both open and closed issues that moved to a different board or team. +func CleanupStaleBoardIssues(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*JiraTaskData) + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + connectionId := data.Options.ConnectionId + boardId := data.Options.BoardId + + logger.Info("cleaning up stale board issues for connection_id=%d, board_id=%d", connectionId, boardId) + + issueIdGen := didgen.NewDomainIdGenerator(&models.JiraIssue{}) + boardIdGen := didgen.NewDomainIdGenerator(&models.JiraBoard{}) + + var allBoardIssues []struct { + IssueKey string + IssueId uint64 + } + if err := db.All(&allBoardIssues, + dal.Select("ji.issue_key, ji.issue_id"), + dal.From("_tool_jira_board_issues tbi"), + dal.Join("JOIN _tool_jira_issues ji ON ji.issue_id = tbi.issue_id AND ji.connection_id = tbi.connection_id"), + dal.Where("tbi.connection_id = ? AND tbi.board_id = ?", connectionId, boardId), + ); err != nil { + return errors.Default.Wrap(err, "failed to query board issues") + } + + logger.Info("batch-checking %d board issues against the board API", len(allBoardIssues)) + + onBoard, err := fetchBoardMembership(data, boardId, allBoardIssues) + if err != nil { + return err + } + if onBoard == nil { + logger.Warn(nil, "board %d returned 404 from the board issues API, skipping stale cleanup to preserve existing associations", boardId) + return nil + } + + removed := 0 + for _, bi := range allBoardIssues { + if onBoard[bi.IssueKey] { + continue + } + + logger.Info("issue %s is no longer on board %d, updating state and removing association", bi.IssueKey, boardId) + + // Re-fetch from Jira to update _tool_jira_issues with current status/team/resolution + if updateErr := collectAndExtractSingleIssue(taskCtx, data, db, bi.IssueKey); updateErr != nil { + logger.Warn(updateErr, "failed to update issue state for %s, will still remove board association", bi.IssueKey) + } + + if delErr := removeBoardAssociation(db, issueIdGen, boardIdGen, connectionId, boardId, bi.IssueId); delErr != nil { + logger.Warn(delErr, "failed to remove stale association for %s, will retry next run", bi.IssueKey) + continue + } + + removed++ + } + + logger.Info("removed %d stale board associations out of %d checked", removed, len(allBoardIssues)) + return nil +} + +func removeBoardAssociation(db dal.Dal, issueIdGen, boardIdGen *didgen.DomainIdGenerator, connectionId, boardId, issueId uint64) errors.Error { + domainIssueId := issueIdGen.Generate(connectionId, issueId) + domainBoardId := boardIdGen.Generate(connectionId, boardId) + + tx := db.Begin() + txErr := tx.Delete( + &models.JiraBoardIssue{}, + dal.Where("connection_id = ? AND board_id = ? AND issue_id = ?", connectionId, boardId, issueId), + ) + if txErr == nil { + txErr = tx.Delete( + &ticket.BoardIssue{}, + dal.Where("board_id = ? AND issue_id = ?", domainBoardId, domainIssueId), + ) + } + if txErr != nil { + _ = tx.Rollback() + return txErr + } + return tx.Commit() +} + +// fetchBoardMembership batch-checks which issue keys are still on the board using +// issue IN (...) JQL — 100 issues per API call instead of one call per issue. +// Returns (nil, nil) when the board is not found (404), signalling the caller to skip cleanup. +// Returns an error when a page is empty while total still claims more results, so the +// caller does not treat a partial/empty map as "not on board". +func fetchBoardMembership(data *JiraTaskData, boardId uint64, issues []struct { + IssueKey string + IssueId uint64 +}) (map[string]bool, errors.Error) { + onBoard := make(map[string]bool, len(issues)) + + for i := 0; i < len(issues); i += staleBoardIssueCheckBatchSize { + end := i + staleBoardIssueCheckBatchSize + if end > len(issues) { + end = len(issues) + } + batch := issues[i:end] + + keys := make([]string, 0, len(batch)) + for _, bi := range batch { + keys = append(keys, bi.IssueKey) + } + + path := fmt.Sprintf("agile/1.0/board/%d/issue", boardId) + jql := fmt.Sprintf("issue IN (%s)", strings.Join(keys, ",")) + + pageSize := 0 + for startAt := 0; ; startAt += pageSize { + query := url.Values{} + query.Set("jql", jql) + query.Set("startAt", fmt.Sprintf("%d", startAt)) + query.Set("maxResults", fmt.Sprintf("%d", staleBoardIssueCheckBatchSize)) + query.Set("fields", "id,key") + + resp, err := data.ApiClient.Get(path, query, nil) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to batch-check board membership") + } + + if resp.StatusCode == http.StatusNotFound { + _ = resp.Body.Close() + return nil, nil + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, errors.Default.New(fmt.Sprintf("unexpected status %d from board issues API", resp.StatusCode)) + } + + blob, readErr := errors.Convert01(io.ReadAll(resp.Body)) + _ = resp.Body.Close() + if readErr != nil { + return nil, errors.Default.Wrap(readErr, "failed to read board response") + } + + var result struct { + Total int `json:"total"` + Issues []struct { + Key string `json:"key"` + } `json:"issues"` + } + if jsonErr := errors.Convert(json.Unmarshal(blob, &result)); jsonErr != nil { + return nil, errors.Default.Wrap(jsonErr, "failed to parse board response") + } + + if len(result.Issues) == 0 { + if result.Total > startAt { + return nil, errors.Default.New(fmt.Sprintf( + "incomplete board membership response for board %d: total=%d but issues=[] at startAt=%d", + boardId, result.Total, startAt, + )) + } + break + } + + for _, issue := range result.Issues { + onBoard[issue.Key] = true + } + + pageSize = len(result.Issues) + if startAt+pageSize >= result.Total { + break + } + } + } + + return onBoard, nil +} diff --git a/backend/plugins/jira/tasks/stale_board_issue_cleaner_test.go b/backend/plugins/jira/tasks/stale_board_issue_cleaner_test.go new file mode 100644 index 00000000000..1eb2d93c2d2 --- /dev/null +++ b/backend/plugins/jira/tasks/stale_board_issue_cleaner_test.go @@ -0,0 +1,686 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + helperapi "github.com/apache/devlake/helpers/pluginhelper/api" + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + mockplugin "github.com/apache/devlake/mocks/core/plugin" + "github.com/apache/devlake/plugins/jira/models" +) + +// buildTestTaskData creates a minimal JiraTaskData pointing ApiClient at the given server URL. +func buildTestTaskData(serverURL string, connectionId, boardId uint64) *JiraTaskData { + apiClient := &helperapi.ApiClient{} + apiClient.Setup(serverURL, nil, 10*time.Second) + return &JiraTaskData{ + Options: &JiraOptions{ + ConnectionId: connectionId, + BoardId: boardId, + }, + ApiClient: &helperapi.ApiAsyncClient{ApiClient: apiClient}, + } +} + +// boardResponse is the JSON shape returned by agile/1.0/board/:id/issue. +type boardResponse struct { + Total int `json:"total"` + Issues []boardRespIssue `json:"issues"` +} + +type boardRespIssue struct { + Key string `json:"key"` +} + +func TestFetchBoardMembership_EmptyIssues(t *testing.T) { + // No issues to check → no HTTP calls needed, should return an empty map. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("unexpected HTTP call with empty issue list") + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + onBoard, err := fetchBoardMembership(data, 42, nil) + assert.NoError(t, err) + assert.NotNil(t, onBoard) + assert.Empty(t, onBoard) +} + +func TestFetchBoardMembership_AllOnBoard(t *testing.T) { + issues := []struct { + IssueKey string + IssueId uint64 + }{ + {"PROJ-1", 1}, + {"PROJ-2", 2}, + {"PROJ-3", 3}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := boardResponse{ + Total: 3, + Issues: []boardRespIssue{ + {Key: "PROJ-1"}, + {Key: "PROJ-2"}, + {Key: "PROJ-3"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + onBoard, err := fetchBoardMembership(data, 42, issues) + assert.NoError(t, err) + assert.Equal(t, map[string]bool{ + "PROJ-1": true, + "PROJ-2": true, + "PROJ-3": true, + }, onBoard) +} + +func TestFetchBoardMembership_SomeNotOnBoard(t *testing.T) { + issues := []struct { + IssueKey string + IssueId uint64 + }{ + {"PROJ-1", 1}, + {"PROJ-2", 2}, // will be missing from API response + {"PROJ-3", 3}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only PROJ-1 and PROJ-3 are on the board. + resp := boardResponse{ + Total: 2, + Issues: []boardRespIssue{{Key: "PROJ-1"}, {Key: "PROJ-3"}}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + onBoard, err := fetchBoardMembership(data, 42, issues) + assert.NoError(t, err) + assert.Equal(t, map[string]bool{ + "PROJ-1": true, + "PROJ-3": true, + }, onBoard) +} + +func TestFetchBoardMembership_404ReturnsNil(t *testing.T) { + issues := []struct { + IssueKey string + IssueId uint64 + }{ + {"PROJ-1", 1}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + onBoard, err := fetchBoardMembership(data, 42, issues) + assert.NoError(t, err) + assert.Nil(t, onBoard) +} + +func TestFetchBoardMembership_NonOKStatusReturnsError(t *testing.T) { + issues := []struct { + IssueKey string + IssueId uint64 + }{ + {"PROJ-1", 1}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + _, err := fetchBoardMembership(data, 42, issues) + assert.Error(t, err) +} + +func TestFetchBoardMembership_PaginationFetchesAllPages(t *testing.T) { + // API returns 3 issues total but only 2 per page (short of maxResults=100). + // Next startAt must be 2, not 100 — otherwise PROJ-3 is skipped and treated as off-board. + issues := []struct { + IssueKey string + IssueId uint64 + }{ + {"PROJ-1", 1}, + {"PROJ-2", 2}, + {"PROJ-3", 3}, + } + + var startAts []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startAt := r.URL.Query().Get("startAt") + startAts = append(startAts, startAt) + var resp boardResponse + if startAt == "0" || startAt == "" { + resp = boardResponse{ + Total: 3, + Issues: []boardRespIssue{{Key: "PROJ-1"}, {Key: "PROJ-2"}}, + } + } else if startAt == "2" { + resp = boardResponse{ + Total: 3, + Issues: []boardRespIssue{{Key: "PROJ-3"}}, + } + } else { + http.Error(w, "unexpected startAt: "+startAt, http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + onBoard, err := fetchBoardMembership(data, 42, issues) + assert.NoError(t, err) + assert.Equal(t, []string{"0", "2"}, startAts) + assert.Equal(t, map[string]bool{ + "PROJ-1": true, + "PROJ-2": true, + "PROJ-3": true, + }, onBoard) +} + +func TestFetchBoardMembership_ShortPageDoesNotSkipIssues(t *testing.T) { + // Jira often caps a page below maxResults (e.g. 50 of 80). Stepping startAt by 100 + // would skip keys 50–79 and delete their board associations. + issues := make([]struct { + IssueKey string + IssueId uint64 + }, 80) + for i := range issues { + issues[i] = struct { + IssueKey string + IssueId uint64 + }{fmt.Sprintf("PROJ-%d", i+1), uint64(i + 1)} + } + + var startAts []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startAt := r.URL.Query().Get("startAt") + startAts = append(startAts, startAt) + var from, to int + switch startAt { + case "0", "": + from, to = 0, 50 + case "50": + from, to = 50, 80 + default: + http.Error(w, "skipped issues; startAt="+startAt, http.StatusBadRequest) + return + } + respIssues := make([]boardRespIssue, 0, to-from) + for i := from; i < to; i++ { + respIssues = append(respIssues, boardRespIssue{Key: fmt.Sprintf("PROJ-%d", i+1)}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(boardResponse{Total: 80, Issues: respIssues}) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + onBoard, err := fetchBoardMembership(data, 42, issues) + assert.NoError(t, err) + assert.Equal(t, []string{"0", "50"}, startAts) + assert.Len(t, onBoard, 80) + assert.True(t, onBoard["PROJ-51"]) + assert.True(t, onBoard["PROJ-80"]) +} + +func TestFetchBoardMembership_EmptyPageWithZeroTotalMeansNoneOnBoard(t *testing.T) { + issues := []struct { + IssueKey string + IssueId uint64 + }{ + {"PROJ-1", 1}, + {"PROJ-2", 2}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := boardResponse{Total: 0, Issues: []boardRespIssue{}} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + onBoard, err := fetchBoardMembership(data, 42, issues) + assert.NoError(t, err) + assert.NotNil(t, onBoard) + assert.Empty(t, onBoard) +} + +func TestFetchBoardMembership_EmptyPageWithTotalReturnsError(t *testing.T) { + // Permission filtering / inconsistent API: total>0 but issues=[]. + // Must error (not return an empty map) so cleanup does not delete valid associations. + issues := []struct { + IssueKey string + IssueId uint64 + }{ + {"PROJ-1", 1}, + {"PROJ-2", 2}, + } + + requestCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + resp := boardResponse{ + Total: 5, + Issues: []boardRespIssue{}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + onBoard, err := fetchBoardMembership(data, 42, issues) + assert.Error(t, err) + assert.Nil(t, onBoard) + assert.Equal(t, 1, requestCount) + assert.Contains(t, err.Error(), "incomplete board membership") +} + +func TestFetchBoardMembership_EmptyLaterPageDoesNotReturnPartialMap(t *testing.T) { + // Full first page (100) with total still higher; next page (startAt=100) is empty. + issues := make([]struct { + IssueKey string + IssueId uint64 + }, 1) + issues[0] = struct { + IssueKey string + IssueId uint64 + }{"PROJ-1", 1} + + page1 := make([]boardRespIssue, staleBoardIssueCheckBatchSize) + for i := range page1 { + page1[i] = boardRespIssue{Key: fmt.Sprintf("PROJ-%d", i+1)} + } + + requestCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startAt := r.URL.Query().Get("startAt") + requestCount++ + var resp boardResponse + if startAt == "0" || startAt == "" { + resp = boardResponse{Total: 150, Issues: page1} + } else { + resp = boardResponse{Total: 150, Issues: []boardRespIssue{}} + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + onBoard, err := fetchBoardMembership(data, 42, issues) + assert.Error(t, err) + assert.Nil(t, onBoard) + assert.Equal(t, 2, requestCount) +} + +func TestFetchBoardMembership_BatchesBigIssueListIntoMultipleAPIRequests(t *testing.T) { + // Build 150 issues — more than staleBoardIssueCheckBatchSize (100). + // Should result in 2 separate API batch requests with different JQL sets. + issues := make([]struct { + IssueKey string + IssueId uint64 + }, 150) + for i := range issues { + issues[i] = struct { + IssueKey string + IssueId uint64 + }{fmt.Sprintf("PROJ-%d", i+1), uint64(i + 1)} + } + + jqlBatches := []string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jql := r.URL.Query().Get("jql") + if r.URL.Query().Get("startAt") == "0" || r.URL.Query().Get("startAt") == "" { + jqlBatches = append(jqlBatches, jql) + } + // Return all requested keys as "on board" so pagination terminates immediately. + parts := strings.TrimPrefix(jql, "issue IN (") + parts = strings.TrimSuffix(parts, ")") + keys := strings.Split(parts, ",") + respIssues := make([]boardRespIssue, 0, len(keys)) + for _, k := range keys { + respIssues = append(respIssues, boardRespIssue{Key: k}) + } + resp := boardResponse{Total: len(respIssues), Issues: respIssues} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + onBoard, err := fetchBoardMembership(data, 42, issues) + assert.NoError(t, err) + assert.Len(t, jqlBatches, 2) + assert.Contains(t, jqlBatches[0], "PROJ-1") + assert.Contains(t, jqlBatches[0], "PROJ-100") + assert.NotContains(t, jqlBatches[0], "PROJ-101") + assert.Contains(t, jqlBatches[1], "PROJ-101") + assert.Contains(t, jqlBatches[1], "PROJ-150") + assert.Len(t, onBoard, 150) +} + +type jiraPluginStub struct{} + +func (jiraPluginStub) Name() string { return "jira" } +func (jiraPluginStub) RootPkgPath() string { + return "github.com/apache/devlake/plugins/jira" +} +func (jiraPluginStub) Description() string { return "" } + +var registerJiraOnce sync.Once + +func registerJiraPlugin(t *testing.T) { + t.Helper() + registerJiraOnce.Do(func() { + _ = plugin.RegisterPlugin("jira", jiraPluginStub{}) + }) +} + +type boardIssueRow struct { + IssueKey string + IssueId uint64 +} + +func setupCleanupMocks(t *testing.T, data *JiraTaskData) (*mockplugin.SubTaskContext, *mockdal.Dal, *mockdal.Transaction) { + t.Helper() + registerJiraPlugin(t) + + ctx := new(mockplugin.SubTaskContext) + db := new(mockdal.Dal) + logger := new(mocklog.Logger) + tx := new(mockdal.Transaction) + + ctx.On("GetData").Return(data) + ctx.On("GetDal").Return(db) + ctx.On("GetLogger").Return(logger) + logger.On("Info", mock.Anything, mock.Anything).Maybe() + logger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + logger.On("Debug", mock.Anything, mock.Anything).Maybe() + + return ctx, db, tx +} + +func stubBoardIssues(db *mockdal.Dal, rows []boardIssueRow, allErr errors.Error) { + if allErr != nil { + db.On("All", mock.Anything, mock.Anything).Return(allErr) + return + } + db.On("All", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + dest := args.Get(0).(*[]struct { + IssueKey string + IssueId uint64 + }) + out := make([]struct { + IssueKey string + IssueId uint64 + }, len(rows)) + for i, r := range rows { + out[i] = struct { + IssueKey string + IssueId uint64 + }{r.IssueKey, r.IssueId} + } + *dest = out + }).Return(nil) +} + +func newCleanupHTTPServer(boardStatus int, onBoardKeys []string, issueStatus int) *httptest.Server { + if boardStatus == 0 { + boardStatus = http.StatusOK + } + if issueStatus == 0 { + issueStatus = http.StatusNotFound + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/board/") { + if boardStatus != http.StatusOK { + w.WriteHeader(boardStatus) + return + } + issues := make([]boardRespIssue, 0, len(onBoardKeys)) + for _, k := range onBoardKeys { + issues = append(issues, boardRespIssue{Key: k}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(boardResponse{Total: len(issues), Issues: issues}) + return + } + w.WriteHeader(issueStatus) + })) +} + +func TestCleanupStaleBoardIssues(t *testing.T) { + tests := []struct { + name string + allErr errors.Error + boardIssues []boardIssueRow + boardStatus int + onBoardKeys []string + issueStatus int + deleteFailAt int // 1 = tool-layer delete, 2 = domain-layer delete + commitErr errors.Error + wantErr bool + wantBegins int + wantDeletes int + wantCommits int + wantRollbacks int + }{ + { + name: "All query fails", + allErr: errors.Default.New("db down"), + wantErr: true, + }, + { + name: "no board issues", + boardIssues: nil, + }, + { + name: "board 404 skips cleanup", + boardIssues: []boardIssueRow{{"PROJ-1", 1}}, + boardStatus: http.StatusNotFound, + }, + { + name: "board API 500", + boardIssues: []boardIssueRow{{"PROJ-1", 1}}, + boardStatus: http.StatusInternalServerError, + wantErr: true, + }, + { + name: "all issues still on board", + boardIssues: []boardIssueRow{{"PROJ-1", 1}, {"PROJ-2", 2}}, + onBoardKeys: []string{"PROJ-1", "PROJ-2"}, + }, + { + name: "stale issue deleted from both tables", + boardIssues: []boardIssueRow{{"PROJ-1", 1}, {"PROJ-2", 2}}, + onBoardKeys: []string{"PROJ-1"}, + wantBegins: 1, + wantDeletes: 2, + wantCommits: 1, + }, + { + name: "issue re-fetch failure still deletes", + boardIssues: []boardIssueRow{{"PROJ-2", 2}}, + issueStatus: http.StatusInternalServerError, + wantBegins: 1, + wantDeletes: 2, + wantCommits: 1, + }, + { + name: "tool-layer delete failure rolls back", + boardIssues: []boardIssueRow{{"PROJ-1", 1}}, + deleteFailAt: 1, + wantBegins: 1, + wantDeletes: 1, + wantRollbacks: 1, + }, + { + name: "domain-layer delete failure rolls back", + boardIssues: []boardIssueRow{{"PROJ-1", 1}}, + deleteFailAt: 2, + wantBegins: 1, + wantDeletes: 2, + wantRollbacks: 1, + }, + { + name: "commit failure continues without error", + boardIssues: []boardIssueRow{{"PROJ-1", 1}}, + commitErr: errors.Default.New("commit failed"), + wantBegins: 1, + wantDeletes: 2, + wantCommits: 1, + }, + { + name: "two stale issues both deleted", + boardIssues: []boardIssueRow{{"PROJ-1", 1}, {"PROJ-2", 2}}, + wantBegins: 2, + wantDeletes: 4, + wantCommits: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := buildTestTaskData("http://127.0.0.1:1", 1, 42) + if tt.allErr == nil && len(tt.boardIssues) > 0 { + srv := newCleanupHTTPServer(tt.boardStatus, tt.onBoardKeys, tt.issueStatus) + defer srv.Close() + data = buildTestTaskData(srv.URL, 1, 42) + } + + ctx, db, tx := setupCleanupMocks(t, data) + stubBoardIssues(db, tt.boardIssues, tt.allErr) + + if tt.wantBegins > 0 { + db.On("Begin").Return(tx).Times(tt.wantBegins) + + switch tt.deleteFailAt { + case 1: + tx.On("Delete", mock.Anything, mock.Anything).Return(errors.Default.New("fk")).Once() + case 2: + tx.On("Delete", mock.Anything, mock.Anything).Return(nil).Once() + tx.On("Delete", mock.Anything, mock.Anything).Return(errors.Default.New("fk")).Once() + default: + tx.On("Delete", mock.Anything, mock.Anything).Return(nil).Times(tt.wantDeletes) + } + + if tt.wantCommits > 0 { + tx.On("Commit").Return(tt.commitErr).Times(tt.wantCommits) + } + if tt.wantRollbacks > 0 { + tx.On("Rollback").Return(nil).Times(tt.wantRollbacks) + } + } + + err := CleanupStaleBoardIssues(ctx) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + db.AssertNumberOfCalls(t, "Begin", tt.wantBegins) + if tt.wantBegins > 0 { + tx.AssertNumberOfCalls(t, "Delete", tt.wantDeletes) + tx.AssertNumberOfCalls(t, "Commit", tt.wantCommits) + tx.AssertNumberOfCalls(t, "Rollback", tt.wantRollbacks) + } + }) + } +} + +func TestCleanupStaleBoardIssues_DeletesToolThenDomain(t *testing.T) { + srv := newCleanupHTTPServer(http.StatusOK, nil, http.StatusNotFound) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + ctx, db, tx := setupCleanupMocks(t, data) + stubBoardIssues(db, []boardIssueRow{{"PROJ-9", 9}}, nil) + + var deleted []string + db.On("Begin").Return(tx) + tx.On("Delete", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + switch args.Get(0).(type) { + case *models.JiraBoardIssue: + deleted = append(deleted, "tool") + case *ticket.BoardIssue: + deleted = append(deleted, "domain") + default: + t.Errorf("unexpected delete type %T", args.Get(0)) + } + }).Return(nil) + tx.On("Commit").Return(nil) + + err := CleanupStaleBoardIssues(ctx) + assert.NoError(t, err) + assert.Equal(t, []string{"tool", "domain"}, deleted) +} + +func TestCleanupStaleBoardIssues_IncompleteMembershipDoesNotDelete(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := boardResponse{Total: 5, Issues: []boardRespIssue{}} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + data := buildTestTaskData(srv.URL, 1, 42) + ctx, db, tx := setupCleanupMocks(t, data) + stubBoardIssues(db, []boardIssueRow{{"PROJ-1", 1}, {"PROJ-2", 2}}, nil) + + err := CleanupStaleBoardIssues(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "incomplete board membership") + db.AssertNumberOfCalls(t, "Begin", 0) + tx.AssertNotCalled(t, "Delete") +} diff --git a/backend/plugins/jira_snowflake/AGENTS.md b/backend/plugins/jira_snowflake/AGENTS.md new file mode 100644 index 00000000000..ff32c088f27 --- /dev/null +++ b/backend/plugins/jira_snowflake/AGENTS.md @@ -0,0 +1,133 @@ +# jira_snowflake Plugin — Agent Context + +Drop-in replacement for the Jira API plugin per board. Reads Jira data from a +Snowflake replica (Fivetran, `JIRA_DB.CLOUDRHAI_MARTS`) and writes into the +existing `_tool_jira_*` tool-layer tables, then runs domain-layer convertors to +produce `ticket.*` domain records. + +## Build & Test + +```bash +cd backend +go build ./plugins/jira_snowflake/... +go test ./plugins/jira_snowflake/... -v +golangci-lint run ./plugins/jira_snowflake/... +``` + +## Layout + +``` +impl/impl.go — plugin interfaces, SubTaskMetas, PrepareTaskData +api/connection_api.go — connection CRUD (POST/GET/PATCH/DELETE) +models/connection.go — SnowflakeJiraConnection (table: _tool_jira_snowflake_connections) +models/migrationscripts/ — DB migrations +tasks/task_data.go — JiraSnowflakeOptions, JiraSnowflakeTaskData, OpenSnowflakeDB +tasks/shared.go — getStdStatus, getTypeMappings (adapted from jira/tasks) +tasks/sync_*.go — Snowflake SQL queries → _tool_jira_* tool-layer tables +tasks/convert_*.go — domain-layer convertors (adapted copies of jira/tasks/*) +``` + +## Subtask pipeline order + +1. `syncIssues` — Snowflake JIRA_ISSUE_NON_PII → `_tool_jira_issues` + `_tool_jira_board_issues` +2. `syncSprints` — Snowflake JIRA_SPRINT → `_tool_jira_sprints` + `_tool_jira_board_sprints` +3. `syncSprintIssues` — Snowflake JIRA_CUSTOMFIELDVALUE_NON_PII → `_tool_jira_sprint_issues` +4. `syncChangelogs` — Snowflake JIRA_CHANGELOG/ITEM → changelog tables +5. `syncWorklogs` — Snowflake JIRA_WORKLOG → `_tool_jira_worklogs` +6. `syncLabels` — Snowflake JIRA_LABEL → `_tool_jira_issue_labels` +7. `syncIssueLinks` — Snowflake JIRA_ISSUELINK → `_tool_jira_issue_relationships` +8. `convertBoard` — `_tool_jira_boards` → domain `ticket.Board` +9. `convertIssues` — `_tool_jira_issues` → domain `ticket.Issue` (adapted deletion) +10. `convertIssueLabels` — `_tool_jira_issue_labels` → domain `ticket.IssueLabel` +11. `convertWorklogs` — `_tool_jira_worklogs` → domain `ticket.IssueWorklog` +12. `convertChangelogs` — changelog tables → domain `ticket.IssueChangelogs` +13. `convertIssueRelationships` — relationships → domain `ticket.IssueRelationship` +14. `convertSprints` — `_tool_jira_sprints` → domain `ticket.Sprint` +15. `convertSprintIssues` — `_tool_jira_sprint_issues` → domain `ticket.SprintIssue` + +## Key conventions + +- **Scope unit is `JiraBoard`** (numeric BoardId + explicit ProjectKeys list). + A board may span multiple Jira projects; all queries use `WHERE i.PROJECT IN (:projectKeys)`. +- **No raw-table layer**: the plugin writes directly to `_tool_jira_*` tables. The + `convert_issues.go` uses `_raw_data_params`-scoped deletion instead of the + upstream `_raw_data_table`-based deletion. +- **`StatusKey` mapping**: `JIRA_ISSUESTATUS_RHAI.STATUSCATEGORY` stores the Jira + status category as a **numeric string**. On `redhat.atlassian.net` the ids are + `'2'`=new/todo, `'3'`=done, `'4'`=in-progress (verified against + `/rest/api/3/status` — note this differs from older Atlassian docs that listed + 3=indeterminate, 4=done). The `CASE` in `sync_issues.go` maps these to + DevLake keys (`new` / `done` / `indeterminate`). +- **`StdType` / `StdStatus`**: populated in `sync_issues.go` using scope config + type/status mappings (same logic as `issue_extractor.go` in the jira plugin). +- **Scope config precedence** (in `PrepareTaskData`, same as the jira plugin): + 1. Inline `options.scopeConfig` + 2. Else `options.scopeConfigId` + 3. Else `_tool_jira_boards.scope_config_id` for this connection + board + 4. Else empty config (raw type names uppercased) + Scope configs live in shared `_tool_jira_scope_configs` — reuse a jira-plugin + config id when possible. For prod, set the board link once: + `UPDATE _tool_jira_boards SET scope_config_id = WHERE connection_id = AND board_id = ;` + then blueprints only need `connectionId` / `boardId` / `projectKeys`. +- **`TIMEWORKED` → `TimeSpentSeconds`**: stored as-is (seconds); the worklog + convertor divides by 60 when writing `TimeSpentMinutes` to the domain layer. +- **AuthType**: `"keypair"` (default, JWT) or `"externalbrowser"` (SSO, dev only). + Browser auth only works when DevLake runs natively on a desktop host, not inside a container. + +## Snowflake schema notes (JIRA_DB.CLOUDRHAI_MARTS — verified 2026-07-09) + +| Table | Key columns | Notes | +|---|---|---| +| `JIRA_ISSUE_NON_PII` | `ID, ISSUE_KEY, PROJECT, ISSUETYPE, ISSUESTATUS_ID, SUMMARY, DESCRIPTION, PARENT_ID, TIMEORIGINALESTIMATE, TIMEESTIMATE, TIMESPENT, CREATED, UPDATED, RESOLUTIONDATE, DUEDATE, PRIORITY, COMPONENT, FIXFOR` | No `SUBTASK_FLAG`; use `JIRA_ISSUETYPE_RHAI.PSTYLE = 'subtask'` instead. `TIMEESTIMATE`, `DESCRIPTION`, `COMPONENT`, and `FIXFOR` can be NULL. `PRIORITY` stores a numeric Jira priority ID, not a human-readable name. | +| `JIRA_ISSUESTATUS_RHAI` | `ID, PNAME, STATUSCATEGORY` | `STATUSCATEGORY` is a numeric string (`'2'`=new, `'3'`=done, `'4'`=indeterminate on redhat.atlassian.net). | +| `JIRA_ISSUETYPE_RHAI` | `ID, PNAME, PSTYLE` | `PSTYLE = 'subtask'` marks subtask types; can be NULL for normal types. | +| `JIRA_PROJECT_RHAI` | `ID, PNAME, PKEY` | Join via `i.PROJECT = p.PKEY`. | +| `JIRA_CUSTOMFIELDVALUE_NON_PII` | `ID, CUSTOMFIELD_NAME, ISSUE, NUMBERVALUE, STRINGVALUE` | `NUMBERVALUE` is `NUMBER(38,0)` — use `::FLOAT` / `::BIGINT` cast, not `TRY_CAST`. | +| `JIRA_COMPONENT_RHAI` | `ID, PROJECT, CNAME` | Component name lookup. However `JIRA_ISSUE_NON_PII.COMPONENT` is always NULL (see limitations below). | +| `JIRA_CHANGEGROUP_RHAI_CLUSTERED` | `ID, ISSUEID, CREATED` | Replaces `JIRA_CHANGELOG`. No author column (PII-stripped). | +| `JIRA_CHANGEITEM_NON_PII_CLUSTERED` | `ID, GROUPID, FIELD, FIELDTYPE, OLDVALUE, NEWVALUE` | Replaces `JIRA_CHANGELOGITEM`. No `FIELDID`, `OLDSTRING`, `NEWSTRING` columns. | + +**Not available** in this schema (Fivetran PII policy or not replicated): +`JIRA_SPRINT`, `JIRA_WORKLOG`, `JIRA_LABEL`, `JIRA_ISSUELINK`, `JIRA_ISSUELINKTYPE`, +`JIRA_PRIORITY_RHAI` (priority name lookup), `JIRA_VERSION_RHAI` (fix-version name lookup) + +Therefore only these subtasks produce data: `syncIssues`, `syncChangelogs`, and their convertors. +The remaining sync subtasks (`syncSprints`, `syncSprintIssues`, `syncWorklogs`, `syncLabels`, `syncIssueLinks`) will fail if enabled. + +**Known field limitations (verified 2026-07-09):** + +| Field | Status | Reason | +|---|---|---| +| `priority_name` | Numeric Jira priority ID (e.g. `10001`) — not a name | `JIRA_PRIORITY_RHAI` not replicated by Fivetran; no name lookup available | +| `components` | Always empty | `JIRA_ISSUE_NON_PII.COMPONENT` is always NULL; issue-to-component is a many-to-many relation not stored on the issue row | +| `fix_versions` | Always empty | `JIRA_ISSUE_NON_PII.FIXFOR` is always NULL; `JIRA_VERSION_RHAI` not replicated by Fivetran | + +## Jira plugin models dependency + +The `tasks/` and `impl/` packages import from `plugins/jira/models` (tool-layer +struct definitions like `JiraIssue`, `JiraSprint`, etc.). This is intentional: +both plugins share the same `_tool_jira_*` table schemas. This is not a business- +logic cross-import — it is a shared schema dependency. + +The `jira` plugin does **not** need to be deployed alongside `jira_snowflake`. +`impl.Init` registers a minimal `jiraPluginStub` so that `didgen.NewDomainIdGenerator` +can resolve `jira/models` types without the full plugin being loaded. + +## Convertor files — adapted copies + +`tasks/convert_*.go` are adapted copies of the corresponding files in `plugins/jira/tasks/`. +They differ in: +- Package (`tasks` with `JiraSnowflakeTaskData` instead of `JiraTaskData`) +- `convert_issues.go`: full-sync deletion via `_raw_data_params` scope (not `_raw_data_table`) +- `convert_changelogs.go`: `getIssueFieldMap` call removed (no issue fields table populated) + +**TODO (long-term):** Extract the shared convertor logic to +`helpers/jiraconvertors/` so both `jira` and `jira_snowflake` import from a +neutral shared package instead of having diverging copies. + +## Don'ts + +- Don't add models without a migration in `migrationscripts/register.go` +- Don't skip the Apache 2.0 license header on new `.go` files +- Don't configure the same board in both a Jira API connection and a + jira_snowflake connection simultaneously — this causes domain ID duplication diff --git a/backend/plugins/jira_snowflake/api/connection_api.go b/backend/plugins/jira_snowflake/api/connection_api.go new file mode 100644 index 00000000000..317f89f3aed --- /dev/null +++ b/backend/plugins/jira_snowflake/api/connection_api.go @@ -0,0 +1,75 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/jira_snowflake/models" +) + +var connHelper *helper.ConnectionApiHelper + +// Init initialises the API layer. Called from impl.Init. +func Init(br context.BasicRes, p plugin.PluginMeta) { + connHelper = helper.NewConnectionHelper(br, nil, p.Name()) +} + +// PostConnections creates a new SnowflakeJiraConnection. +func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.SnowflakeJiraConnection{} + if err := connHelper.Create(connection, input); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection, Status: 200}, nil +} + +// GetConnections returns all SnowflakeJiraConnections. +func GetConnections(_ *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var connections []models.SnowflakeJiraConnection + if err := connHelper.List(&connections); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connections, Status: 200}, nil +} + +// GetConnection returns a single connection by connectionId path param. +func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.SnowflakeJiraConnection{} + if err := connHelper.First(connection, input.Params); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection, Status: 200}, nil +} + +// PatchConnection updates fields on an existing connection. +func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.SnowflakeJiraConnection{} + if err := connHelper.Patch(connection, input); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection, Status: 200}, nil +} + +// DeleteConnection removes a connection. +func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.SnowflakeJiraConnection{} + return connHelper.Delete(connection, input) +} diff --git a/backend/plugins/jira_snowflake/impl/impl.go b/backend/plugins/jira_snowflake/impl/impl.go new file mode 100644 index 00000000000..7daed67fdc2 --- /dev/null +++ b/backend/plugins/jira_snowflake/impl/impl.go @@ -0,0 +1,216 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + coremodels "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + jiramodels "github.com/apache/devlake/plugins/jira/models" + "github.com/apache/devlake/plugins/jira_snowflake/api" + "github.com/apache/devlake/plugins/jira_snowflake/models" + "github.com/apache/devlake/plugins/jira_snowflake/models/migrationscripts" + "github.com/apache/devlake/plugins/jira_snowflake/tasks" +) + +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginTask + plugin.PluginModel + plugin.PluginMigration + plugin.PluginApi + plugin.PluginSource +} = (*JiraSnowflake)(nil) + +// jiraPluginStub is a minimal plugin.PluginMeta that lets didgen resolve +// types from plugins/jira/models (JiraIssue, JiraSprint, etc.) without +// requiring the full jira plugin to be loaded alongside jira_snowflake. +type jiraPluginStub struct{} + +func (jiraPluginStub) Name() string { return "jira" } +func (jiraPluginStub) RootPkgPath() string { return "github.com/apache/devlake/plugins/jira" } +func (jiraPluginStub) Description() string { return "" } + +// JiraSnowflake is the plugin implementation struct. +type JiraSnowflake struct{} + +func (p JiraSnowflake) Connection() dal.Tabler { + return &models.SnowflakeJiraConnection{} +} + +func (p JiraSnowflake) Scope() plugin.ToolLayerScope { + return &jiramodels.JiraBoard{} +} + +func (p JiraSnowflake) ScopeConfig() dal.Tabler { + return &jiramodels.JiraScopeConfig{} +} + +func (p JiraSnowflake) Init(basicRes context.BasicRes) errors.Error { + // Only register the jira stub when the real jira plugin is absent. + // If both plugins are deployed in the same instance, the real registration + // must take precedence — overwriting it with this stub would break jira + // API routing, task execution, and didgen resolution for all jira connections. + if _, err := plugin.GetPlugin("jira"); err != nil { + _ = plugin.RegisterPlugin("jira", jiraPluginStub{}) + } + api.Init(basicRes, p) + return nil +} + +func (p JiraSnowflake) Name() string { + return "jira_snowflake" +} + +func (p JiraSnowflake) Description() string { + return "Ingest Jira data from a Snowflake replica (Fivetran) instead of the Jira REST API" +} + +func (p JiraSnowflake) RootPkgPath() string { + return "github.com/apache/devlake/plugins/jira_snowflake" +} + +func (p JiraSnowflake) SubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{ + // Sync tasks: Snowflake → _tool_jira_* tables + tasks.SyncIssuesMeta, + tasks.SyncSprintsMeta, + tasks.SyncSprintIssuesMeta, + tasks.SyncChangelogsMeta, + tasks.SyncWorklogsMeta, + tasks.SyncLabelsMeta, + tasks.SyncIssueLinksMeta, + // Convertor tasks: _tool_jira_* → domain layer + // Owned copies of jira/tasks convertors, adapted for the no-raw-table context. + tasks.ConvertBoardMeta, + tasks.ConvertIssuesMeta, + tasks.ConvertIssueLabelsMeta, + tasks.ConvertWorklogsMeta, + tasks.ConvertChangelogsMeta, + tasks.ConvertIssueRelationshipsMeta, + tasks.ConvertSprintsMeta, + tasks.ConvertSprintIssuesMeta, + } +} + +func (p JiraSnowflake) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) { + op, err := tasks.DecodeAndValidateTaskOptions(options) + if err != nil { + return nil, err + } + + // Load the Snowflake connection record + connection := &models.SnowflakeJiraConnection{} + connectionHelper := helper.NewConnectionHelper(taskCtx, nil, p.Name()) + if err := connectionHelper.FirstById(connection, op.ConnectionId); err != nil { + return nil, errors.Default.Wrap(err, "unable to get jira_snowflake connection") + } + + // Ensure a JiraBoard scope record exists so convertors can find it. + // Load the board before resolving scope config so we can inherit + // board.scope_config_id (same precedence as the jira plugin). + board := &jiramodels.JiraBoard{} + dbErr := taskCtx.GetDal().First(board, dal.Where("connection_id = ? AND board_id = ?", op.ConnectionId, op.BoardId)) + if dbErr != nil && taskCtx.GetDal().IsErrorNotFound(dbErr) { + board = &jiramodels.JiraBoard{ + Scope: coremodels.Scope{ConnectionId: op.ConnectionId}, + BoardId: op.BoardId, + Name: "Snowflake Board", + } + if createErr := taskCtx.GetDal().CreateIfNotExist(board); createErr != nil { + return nil, errors.Default.Wrap(createErr, "failed to create JiraBoard scope record") + } + } else if dbErr != nil { + return nil, errors.Default.Wrap(dbErr, "failed to look up JiraBoard scope record") + } + + // Scope config precedence: inline options.scopeConfig > options.scopeConfigId > + // board.scope_config_id > empty config. + if op.ScopeConfigId == 0 && board.ScopeConfigId != 0 { + op.ScopeConfigId = board.ScopeConfigId + } + if op.ScopeConfig == nil && op.ScopeConfigId != 0 { + var scopeConfig jiramodels.JiraScopeConfig + if loadErr := taskCtx.GetDal().First(&scopeConfig, dal.Where("id = ?", op.ScopeConfigId)); loadErr != nil { + return nil, errors.BadInput.Wrap(loadErr, "failed to load scope config") + } + op.ScopeConfig = &scopeConfig + } + if op.ScopeConfig == nil { + op.ScopeConfig = new(jiramodels.JiraScopeConfig) + } + + // Open the Snowflake SQL connection (closed in Close()) + snowDB, openErr := tasks.OpenSnowflakeDB( + connection.Account, + connection.User, + connection.AuthType, + connection.PrivateKey, + connection.Database, + connection.Schema, + connection.Warehouse, + connection.Role, + ) + if openErr != nil { + return nil, openErr + } + + return &tasks.JiraSnowflakeTaskData{ + Options: op, + SnowflakeDB: snowDB, + }, nil +} + +// Close is called after all subtasks complete; it closes the Snowflake connection. +func (p JiraSnowflake) Close(taskCtx plugin.TaskContext) errors.Error { + data, ok := taskCtx.GetData().(*tasks.JiraSnowflakeTaskData) + if ok && data != nil && data.SnowflakeDB != nil { + if err := data.SnowflakeDB.Close(); err != nil { + return errors.Default.Wrap(err, "failed to close Snowflake connection") + } + } + return nil +} + +func (p JiraSnowflake) GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + &models.SnowflakeJiraConnection{}, + } +} + +func (p JiraSnowflake) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +func (p JiraSnowflake) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{ + "connections": { + "POST": api.PostConnections, + "GET": api.GetConnections, + }, + "connections/:connectionId": { + "GET": api.GetConnection, + "PATCH": api.PatchConnection, + "DELETE": api.DeleteConnection, + }, + } +} diff --git a/backend/plugins/jira_snowflake/jira_snowflake.go b/backend/plugins/jira_snowflake/jira_snowflake.go new file mode 100644 index 00000000000..13eb1ad9903 --- /dev/null +++ b/backend/plugins/jira_snowflake/jira_snowflake.go @@ -0,0 +1,42 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/apache/devlake/core/runner" + "github.com/apache/devlake/plugins/jira_snowflake/impl" + "github.com/spf13/cobra" +) + +// PluginEntry is exported for the DevLake framework to discover and load this plugin. +var PluginEntry impl.JiraSnowflake //nolint + +func main() { + cmd := &cobra.Command{Use: "jira_snowflake"} + boardId := cmd.Flags().Uint64P("boardId", "b", 0, "Jira board ID to sync") + projectKeys := cmd.Flags().StringSliceP("projectKeys", "p", nil, "Jira project keys for this board") + timeAfter := cmd.Flags().StringP("timeAfter", "a", "", "only sync records created/updated after this time (RFC3339)") + + cmd.Run = func(cmd *cobra.Command, args []string) { + runner.DirectRun(cmd, args, PluginEntry, map[string]interface{}{ + "boardId": *boardId, + "projectKeys": *projectKeys, + }, *timeAfter) + } + runner.RunCmd(cmd) +} diff --git a/backend/plugins/jira_snowflake/models/connection.go b/backend/plugins/jira_snowflake/models/connection.go new file mode 100644 index 00000000000..31d60247f5a --- /dev/null +++ b/backend/plugins/jira_snowflake/models/connection.go @@ -0,0 +1,51 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +// SnowflakeJiraConnection holds the credentials and config for a Snowflake-backed Jira connection. +// The private key PEM is stored encrypted (encrypt:"yes") using DevLake's field-level encryption. +type SnowflakeJiraConnection struct { + helper.BaseConnection `mapstructure:",squash"` + // Account is the Snowflake account identifier, e.g. "myorg-myaccount" + Account string `json:"account" gorm:"column:account;not null" mapstructure:"account" validate:"required"` + // User is the Snowflake service user name + User string `json:"user" gorm:"column:sf_user;not null" mapstructure:"user" validate:"required"` + // AuthType controls how the plugin authenticates to Snowflake. + // "keypair" (default/production): JWT authentication using PrivateKey. + // "externalbrowser": SSO via browser — only works when DevLake runs on a desktop host (not in a container). + AuthType string `json:"authType" gorm:"column:auth_type;default:keypair" mapstructure:"authType"` + // PrivateKey is the RSA private key in PKCS#8 PEM format, stored encrypted. + // Required when AuthType is "keypair"; ignored for "externalbrowser". + PrivateKey string `json:"privateKey" encrypt:"yes" gorm:"column:private_key" mapstructure:"privateKey"` + // Database is the Snowflake database, e.g. "JIRA_DB" + Database string `json:"database" gorm:"column:sf_database;not null" mapstructure:"database" validate:"required"` + // Schema is the Snowflake schema, e.g. "CLOUDRHAI_MARTS" + Schema string `json:"schema" gorm:"column:sf_schema;not null" mapstructure:"schema" validate:"required"` + // Warehouse is the virtual warehouse to use; defaults to the account default + Warehouse string `json:"warehouse" gorm:"column:warehouse" mapstructure:"warehouse"` + // Role is the Snowflake role to assume + Role string `json:"role" gorm:"column:sf_role" mapstructure:"role"` +} + +func (SnowflakeJiraConnection) TableName() string { + return "_tool_jira_snowflake_connections" +} diff --git a/backend/plugins/jira_snowflake/models/migrationscripts/add_auth_type.go b/backend/plugins/jira_snowflake/models/migrationscripts/add_auth_type.go new file mode 100644 index 00000000000..d8d40d13471 --- /dev/null +++ b/backend/plugins/jira_snowflake/models/migrationscripts/add_auth_type.go @@ -0,0 +1,59 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +// addAuthType adds the auth_type column and removes the NOT NULL constraint on +// private_key so that externalbrowser connections don't require a key. +type addAuthType struct{} + +// snowflakeJiraConnection20260709 is the schema snapshot at migration version 20260709000001. +// Adds AuthType compared to the 20260708 snapshot. +type snowflakeJiraConnection20260709 struct { + helper.BaseConnection `mapstructure:",squash"` + Account string `gorm:"column:account;not null"` + User string `gorm:"column:sf_user;not null"` + AuthType string `gorm:"column:auth_type;default:keypair"` + PrivateKey string `gorm:"column:private_key"` + Database string `gorm:"column:sf_database;not null"` + Schema string `gorm:"column:sf_schema;not null"` + Warehouse string `gorm:"column:warehouse"` + Role string `gorm:"column:sf_role"` +} + +func (snowflakeJiraConnection20260709) TableName() string { + return "_tool_jira_snowflake_connections" +} + +func (u *addAuthType) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables(basicRes, &snowflakeJiraConnection20260709{}) +} + +func (u *addAuthType) Version() uint64 { + return 20260709000001 +} + +func (u *addAuthType) Name() string { + return "jira_snowflake add auth_type" +} diff --git a/backend/plugins/jira_snowflake/models/migrationscripts/init_schema.go b/backend/plugins/jira_snowflake/models/migrationscripts/init_schema.go new file mode 100644 index 00000000000..1328cd030f9 --- /dev/null +++ b/backend/plugins/jira_snowflake/models/migrationscripts/init_schema.go @@ -0,0 +1,56 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +type initSchema struct{} + +// snowflakeJiraConnection20260708 is the schema snapshot at migration version 20260708000001. +// AuthType was not yet present; it was added by the 20260709000001 migration. +type snowflakeJiraConnection20260708 struct { + helper.BaseConnection `mapstructure:",squash"` + Account string `gorm:"column:account;not null"` + User string `gorm:"column:sf_user;not null"` + PrivateKey string `gorm:"column:private_key"` + Database string `gorm:"column:sf_database;not null"` + Schema string `gorm:"column:sf_schema;not null"` + Warehouse string `gorm:"column:warehouse"` + Role string `gorm:"column:sf_role"` +} + +func (snowflakeJiraConnection20260708) TableName() string { + return "_tool_jira_snowflake_connections" +} + +func (u *initSchema) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables(basicRes, &snowflakeJiraConnection20260708{}) +} + +func (u *initSchema) Version() uint64 { + return 20260708000001 +} + +func (u *initSchema) Name() string { + return "jira_snowflake init schema" +} diff --git a/backend/plugins/jira_snowflake/models/migrationscripts/register.go b/backend/plugins/jira_snowflake/models/migrationscripts/register.go new file mode 100644 index 00000000000..c235ac2bd35 --- /dev/null +++ b/backend/plugins/jira_snowflake/models/migrationscripts/register.go @@ -0,0 +1,28 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import "github.com/apache/devlake/core/plugin" + +// All returns all migration scripts for the jira_snowflake plugin. +func All() []plugin.MigrationScript { + return []plugin.MigrationScript{ + new(initSchema), + new(addAuthType), + } +} diff --git a/backend/plugins/jira_snowflake/tasks/convert_board.go b/backend/plugins/jira_snowflake/tasks/convert_board.go new file mode 100644 index 00000000000..85936d84d06 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/convert_board.go @@ -0,0 +1,90 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// Adapted from plugins/jira/tasks/board_convertor.go. +// TODO: long-term, extract shared convertor logic to helpers/jiraconvertors/. + +import ( + "reflect" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var ConvertBoardMeta = plugin.SubTaskMeta{ + Name: "convertBoard", + EntryPoint: ConvertBoard, + EnabledByDefault: true, + Description: "Convert Jira board into domain layer ticket.Board", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ConvertBoard(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*JiraSnowflakeTaskData) + logger := taskCtx.GetLogger() + db := taskCtx.GetDal() + logger.Info("convert board:%d", data.Options.BoardId) + + idGen := didgen.NewDomainIdGenerator(&jiramodels.JiraBoard{}) + clauses := []dal.Clause{ + dal.Select("*"), + dal.From(&jiramodels.JiraBoard{}), + dal.Where("connection_id = ? AND board_id = ?", data.Options.ConnectionId, data.Options.BoardId), + } + cursor, err := db.Cursor(clauses...) + if err != nil { + return err + } + defer cursor.Close() + + converter, err := helper.NewDataConverter(helper.DataConverterArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: JiraApiParams{ + ConnectionId: data.Options.ConnectionId, + BoardId: data.Options.BoardId, + }, + Table: "jira_api_boards", + }, + InputRowType: reflect.TypeOf(jiramodels.JiraBoard{}), + Input: cursor, + Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { + board := inputRow.(*jiramodels.JiraBoard) + domainBoard := &ticket.Board{ + DomainEntity: domainlayer.DomainEntity{ + Id: idGen.Generate(data.Options.ConnectionId, data.Options.BoardId), + }, + Name: board.Name, + Url: board.Self, + Type: board.Type, + } + return []interface{}{domainBoard}, nil + }, + }) + if err != nil { + return err + } + return converter.Execute() +} diff --git a/backend/plugins/jira_snowflake/tasks/convert_changelogs.go b/backend/plugins/jira_snowflake/tasks/convert_changelogs.go new file mode 100644 index 00000000000..dc8749930b9 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/convert_changelogs.go @@ -0,0 +1,175 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// Adapted from plugins/jira/tasks/issue_changelog_convertor.go. +// Simplification: user-field type detection (getIssueFieldMap) is omitted because +// _tool_jira_issue_fields is not populated in jira_snowflake (no CollectIssueFieldsMeta). +// TODO: long-term, extract shared convertor logic to helpers/jiraconvertors/. + +import ( + "regexp" + "strconv" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var validID = regexp.MustCompile(`[0-9]+`) + +var ConvertChangelogsMeta = plugin.SubTaskMeta{ + Name: "convertChangelogs", + EntryPoint: ConvertChangelogs, + EnabledByDefault: true, + Description: "Convert Jira issue changelogs into domain layer ticket.IssueChangelogs", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET, plugin.DOMAIN_TYPE_CROSS}, +} + +type issueChangelogItemResult struct { + jiramodels.JiraIssueChangelogItems + IssueId uint64 `gorm:"index"` + AuthorAccountId string + AuthorDisplayName string + Created time.Time +} + +func ConvertChangelogs(subtaskCtx plugin.SubTaskContext) errors.Error { + data := subtaskCtx.GetData().(*JiraSnowflakeTaskData) + db := subtaskCtx.GetDal() + connectionId := data.Options.ConnectionId + boardId := data.Options.BoardId + + var allStatus []jiramodels.JiraStatus + if err := db.All(&allStatus, dal.Where("connection_id = ?", connectionId)); err != nil { + return err + } + statusMap := make(map[string]jiramodels.JiraStatus) + for _, v := range allStatus { + statusMap[v.ID] = v + } + + issueIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraIssue{}) + sprintIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraSprint{}) + changelogIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraIssueChangelogItems{}) + accountIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraAccount{}) + + converter, err := helper.NewStatefulDataConverter(&helper.StatefulDataConverterArgs[issueChangelogItemResult]{ + SubtaskCommonArgs: &helper.SubtaskCommonArgs{ + SubTaskContext: subtaskCtx, + Table: "jira_api_issues", + Params: JiraApiParams{ + ConnectionId: connectionId, + BoardId: boardId, + }, + }, + Input: func(stateManager *helper.SubtaskStateManager) (dal.Rows, errors.Error) { + clauses := []dal.Clause{ + dal.Select("_tool_jira_issue_changelog_items.*, _tool_jira_issue_changelogs.issue_id, author_account_id, author_display_name, created"), + dal.From("_tool_jira_issue_changelog_items"), + dal.Join(`LEFT JOIN _tool_jira_issue_changelogs ON ( + _tool_jira_issue_changelogs.connection_id = _tool_jira_issue_changelog_items.connection_id + AND _tool_jira_issue_changelogs.changelog_id = _tool_jira_issue_changelog_items.changelog_id + )`), + dal.Join(`LEFT JOIN _tool_jira_board_issues ON ( + _tool_jira_board_issues.connection_id = _tool_jira_issue_changelogs.connection_id + AND _tool_jira_board_issues.issue_id = _tool_jira_issue_changelogs.issue_id + )`), + dal.Where("_tool_jira_issue_changelog_items.connection_id = ? AND _tool_jira_board_issues.board_id = ?", connectionId, boardId), + } + if stateManager.IsIncremental() { + since := stateManager.GetSince() + if since != nil { + clauses = append(clauses, dal.Where("_tool_jira_issue_changelog_items.created_at >= ?", since)) + } + } + return db.Cursor(clauses...) + }, + Convert: func(row *issueChangelogItemResult) ([]interface{}, errors.Error) { + changelog := &ticket.IssueChangelogs{ + DomainEntity: domainlayer.DomainEntity{ + Id: changelogIdGen.Generate(row.ConnectionId, row.ChangelogId, row.Field), + }, + IssueId: issueIdGen.Generate(row.ConnectionId, row.IssueId), + AuthorId: accountIdGen.Generate(connectionId, row.AuthorAccountId), + AuthorName: row.AuthorDisplayName, + FieldId: row.FieldId, + FieldName: row.Field, + OriginalFromValue: row.FromString, + OriginalToValue: row.ToString, + CreatedDate: row.Created, + } + switch row.Field { + case "assignee", "reporter": + if row.FromValue != "" { + changelog.OriginalFromValue = accountIdGen.Generate(connectionId, row.FromValue) + } + if row.ToValue != "" { + changelog.OriginalToValue = accountIdGen.Generate(connectionId, row.ToValue) + } + case "Sprint": + var e errors.Error + changelog.OriginalFromValue, e = convertSprintIds(row.FromValue, connectionId, sprintIdGen) + if e != nil { + return nil, e + } + changelog.OriginalToValue, e = convertSprintIds(row.ToValue, connectionId, sprintIdGen) + if e != nil { + return nil, e + } + case "status": + if fromStatus, ok := statusMap[row.FromValue]; ok { + changelog.OriginalFromValue = fromStatus.Name + changelog.FromValue = getStdStatus(fromStatus.StatusCategory) + } + if toStatus, ok := statusMap[row.ToValue]; ok { + changelog.OriginalToValue = toStatus.Name + changelog.ToValue = getStdStatus(toStatus.StatusCategory) + } + } + return []interface{}{changelog}, nil + }, + }) + if err != nil { + return err + } + return converter.Execute() +} + +func convertSprintIds(ids string, connectionId uint64, gen *didgen.DomainIdGenerator) (string, errors.Error) { + var result []string + for _, item := range strings.Split(ids, ",") { + item = strings.TrimSpace(item) + item = validID.FindString(item) + if item != "" { + id, parseErr := strconv.ParseUint(item, 10, 64) + if parseErr != nil { + return "", errors.Convert(parseErr) + } + result = append(result, gen.Generate(connectionId, id)) + } + } + return strings.Join(result, ","), nil +} diff --git a/backend/plugins/jira_snowflake/tasks/convert_changelogs_test.go b/backend/plugins/jira_snowflake/tasks/convert_changelogs_test.go new file mode 100644 index 00000000000..9a51bd26d9c --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/convert_changelogs_test.go @@ -0,0 +1,102 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "strings" + "testing" + + "github.com/apache/devlake/core/models/domainlayer/didgen" + jiramodels "github.com/apache/devlake/plugins/jira/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// convertSprintIds +// --------------------------------------------------------------------------- + +func newSprintIdGen() *didgen.DomainIdGenerator { + return didgen.NewDomainIdGenerator(&jiramodels.JiraSprint{}) +} + +func TestConvertSprintIds_Empty(t *testing.T) { + gen := newSprintIdGen() + result, err := convertSprintIds("", 1, gen) + require.Nil(t, err) + assert.Equal(t, "", result) +} + +func TestConvertSprintIds_Single(t *testing.T) { + gen := newSprintIdGen() + result, err := convertSprintIds("42", 1, gen) + require.Nil(t, err) + assert.NotEmpty(t, result) + // The generated domain ID should contain the sprint number + assert.Contains(t, result, "42") +} + +func TestConvertSprintIds_Multiple(t *testing.T) { + gen := newSprintIdGen() + result, err := convertSprintIds("10, 20, 30", 1, gen) + require.Nil(t, err) + // Comma-separated output for multiple sprint IDs + parts := splitAndTrim(result, ",") + assert.Len(t, parts, 3) +} + +func TestConvertSprintIds_JiraFormatWithLabel(t *testing.T) { + // Jira often stores sprint values like "com.atlassian.greenhopper.service.sprint.Sprint@abc[id=42,...]" + gen := newSprintIdGen() + result, err := convertSprintIds("com.atlassian.greenhopper.service.sprint.Sprint@abc[id=42,rapidViewId=100]", 1, gen) + require.Nil(t, err) + // Should still extract numeric ID 42 + assert.NotEmpty(t, result) +} + +func TestConvertSprintIds_InvalidNumber(t *testing.T) { + // A string that contains no digits at all + gen := newSprintIdGen() + result, err := convertSprintIds("no-digits-here", 1, gen) + require.Nil(t, err) + assert.Equal(t, "", result) +} + +func TestConvertSprintIds_DeterministicForSameInput(t *testing.T) { + gen := newSprintIdGen() + r1, _ := convertSprintIds("99", 5, gen) + r2, _ := convertSprintIds("99", 5, gen) + assert.Equal(t, r1, r2, "same input should always produce same domain ID") +} + +func TestConvertSprintIds_DifferentConnectionIds(t *testing.T) { + gen := newSprintIdGen() + r1, _ := convertSprintIds("99", 1, gen) + r2, _ := convertSprintIds("99", 2, gen) + assert.NotEqual(t, r1, r2, "different connection IDs should produce different domain IDs") +} + +func splitAndTrim(s, sep string) []string { + var result []string + for _, part := range strings.Split(s, sep) { + if p := strings.TrimSpace(part); p != "" { + result = append(result, p) + } + } + return result +} diff --git a/backend/plugins/jira_snowflake/tasks/convert_issue_labels.go b/backend/plugins/jira_snowflake/tasks/convert_issue_labels.go new file mode 100644 index 00000000000..49142696786 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/convert_issue_labels.go @@ -0,0 +1,86 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// Adapted from plugins/jira/tasks/issue_label_convertor.go. +// TODO: long-term, extract shared convertor logic to helpers/jiraconvertors/. + +import ( + "reflect" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var ConvertIssueLabelsMeta = plugin.SubTaskMeta{ + Name: "convertIssueLabels", + EntryPoint: ConvertIssueLabels, + EnabledByDefault: false, + Description: "Convert Jira issue labels into domain layer ticket.IssueLabel", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ConvertIssueLabels(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + data := taskCtx.GetData().(*JiraSnowflakeTaskData) + + cursor, err := db.Cursor( + dal.Select("jil.*"), + dal.From("_tool_jira_issue_labels jil"), + dal.Join(`LEFT JOIN _tool_jira_board_issues jbi + ON jil.connection_id = jbi.connection_id AND jil.issue_id = jbi.issue_id`), + dal.Where("jil.connection_id = ? AND jbi.board_id = ?", data.Options.ConnectionId, data.Options.BoardId), + dal.Orderby("issue_id ASC"), + ) + if err != nil { + return err + } + defer cursor.Close() + + issueIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraIssue{}) + converter, err := helper.NewDataConverter(helper.DataConverterArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: JiraApiParams{ + ConnectionId: data.Options.ConnectionId, + BoardId: data.Options.BoardId, + }, + Table: "jira_api_issues", + }, + InputRowType: reflect.TypeOf(jiramodels.JiraIssueLabel{}), + Input: cursor, + Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { + issueLabel := inputRow.(*jiramodels.JiraIssueLabel) + return []interface{}{ + &ticket.IssueLabel{ + IssueId: issueIdGen.Generate(data.Options.ConnectionId, issueLabel.IssueId), + LabelName: issueLabel.LabelName, + }, + }, nil + }, + }) + if err != nil { + return err + } + return converter.Execute() +} diff --git a/backend/plugins/jira_snowflake/tasks/convert_issue_links.go b/backend/plugins/jira_snowflake/tasks/convert_issue_links.go new file mode 100644 index 00000000000..c13b5af0bdb --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/convert_issue_links.go @@ -0,0 +1,91 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// Adapted from plugins/jira/tasks/issue_relationship_convertor.go. +// TODO: long-term, extract shared convertor logic to helpers/jiraconvertors/. + +import ( + "reflect" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var ConvertIssueRelationshipsMeta = plugin.SubTaskMeta{ + Name: "convertIssueRelationships", + EntryPoint: ConvertIssueRelationships, + EnabledByDefault: false, + Description: "Convert Jira issue relationships into domain layer ticket.IssueRelationship", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ConvertIssueRelationships(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + data := taskCtx.GetData().(*JiraSnowflakeTaskData) + + cursor, err := db.Cursor( + dal.Select("jir.*"), + dal.From("_tool_jira_issue_relationships jir"), + dal.Join(`LEFT JOIN _tool_jira_board_issues jbi + ON jir.connection_id = jbi.connection_id AND jir.issue_id = jbi.issue_id`), + dal.Where("jir.connection_id = ? AND jbi.board_id = ?", data.Options.ConnectionId, data.Options.BoardId), + dal.Orderby("issue_id ASC"), + ) + if err != nil { + return err + } + defer cursor.Close() + + issueIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraIssue{}) + converter, err := helper.NewDataConverter(helper.DataConverterArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: JiraApiParams{ + ConnectionId: data.Options.ConnectionId, + BoardId: data.Options.BoardId, + }, + Table: "jira_api_issues", + }, + InputRowType: reflect.TypeOf(jiramodels.JiraIssueRelationship{}), + Input: cursor, + Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { + rel := inputRow.(*jiramodels.JiraIssueRelationship) + domainRel := &ticket.IssueRelationship{ + SourceIssueId: issueIdGen.Generate(rel.ConnectionId, rel.IssueId), + } + if rel.InwardIssueId != 0 { + domainRel.TargetIssueId = issueIdGen.Generate(rel.ConnectionId, rel.InwardIssueId) + domainRel.OriginalType = rel.Inward + } else { + domainRel.TargetIssueId = issueIdGen.Generate(rel.ConnectionId, rel.OutwardIssueId) + domainRel.OriginalType = rel.Outward + } + return []interface{}{domainRel}, nil + }, + }) + if err != nil { + return err + } + return converter.Execute() +} diff --git a/backend/plugins/jira_snowflake/tasks/convert_issues.go b/backend/plugins/jira_snowflake/tasks/convert_issues.go new file mode 100644 index 00000000000..a4dda7337a6 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/convert_issues.go @@ -0,0 +1,192 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// Adapted from plugins/jira/tasks/issue_convertor.go. +// Key adaptation: full-sync deletion uses board scope (connection_id + board_id) instead +// of _raw_data_table matching, because jira_snowflake has no raw-table layer. +// TODO: long-term, extract shared convertor logic to helpers/jiraconvertors/. + +import ( + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var ConvertIssuesMeta = plugin.SubTaskMeta{ + Name: "convertIssues", + EntryPoint: ConvertIssues, + EnabledByDefault: true, + Description: "Convert Jira issues from tool layer into domain layer ticket.Issue", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ConvertIssues(subtaskCtx plugin.SubTaskContext) errors.Error { + logger := subtaskCtx.GetLogger() + data := subtaskCtx.GetData().(*JiraSnowflakeTaskData) + db := subtaskCtx.GetDal() + + mappings, err := getTypeMappings(data, db) + if err != nil { + return err + } + + issueIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraIssue{}) + accountIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraAccount{}) + boardIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraBoard{}) + boardId := boardIdGen.Generate(data.Options.ConnectionId, data.Options.BoardId) + + converter, err := helper.NewStatefulDataConverter(&helper.StatefulDataConverterArgs[jiramodels.JiraIssue]{ + SubtaskCommonArgs: &helper.SubtaskCommonArgs{ + SubTaskContext: subtaskCtx, + Table: "jira_api_issues", + Params: JiraApiParams{ + ConnectionId: data.Options.ConnectionId, + BoardId: data.Options.BoardId, + }, + SubtaskConfig: mappings, + }, + Input: func(stateManager *helper.SubtaskStateManager) (dal.Rows, errors.Error) { + clauses := []dal.Clause{ + dal.Select("_tool_jira_issues.*"), + dal.From("_tool_jira_issues"), + dal.Join(`LEFT JOIN _tool_jira_board_issues + ON _tool_jira_board_issues.issue_id = _tool_jira_issues.issue_id + AND _tool_jira_board_issues.connection_id = _tool_jira_issues.connection_id`), + dal.Where( + "_tool_jira_board_issues.connection_id = ? AND _tool_jira_board_issues.board_id = ?", + data.Options.ConnectionId, + data.Options.BoardId, + ), + } + if stateManager.IsIncremental() { + since := stateManager.GetSince() + if since != nil { + clauses = append(clauses, dal.Where("_tool_jira_issues.updated_at >= ?", since)) + } + } + return db.Cursor(clauses...) + }, + Convert: func(jiraIssue *jiramodels.JiraIssue) ([]interface{}, errors.Error) { + var result []interface{} + issue := &ticket.Issue{ + DomainEntity: domainlayer.DomainEntity{ + Id: issueIdGen.Generate(jiraIssue.ConnectionId, jiraIssue.IssueId), + }, + Url: issueURL(jiraIssue.IssueKey), + IssueKey: jiraIssue.IssueKey, + Title: jiraIssue.Summary, + Description: jiraIssue.Description, + EpicKey: jiraIssue.EpicKey, + Type: jiraIssue.StdType, + OriginalType: jiraIssue.Type, + Status: jiraIssue.StdStatus, + OriginalStatus: jiraIssue.StatusName, + StoryPoint: jiraIssue.StoryPoint, + OriginalEstimateMinutes: jiraIssue.OriginalEstimateMinutes, + ResolutionDate: jiraIssue.ResolutionDate, + Priority: jiraIssue.PriorityName, + CreatedDate: &jiraIssue.Created, + UpdatedDate: &jiraIssue.Updated, + LeadTimeMinutes: jiraIssue.LeadTimeMinutes, + TimeSpentMinutes: jiraIssue.SpentMinutes, + TimeRemainingMinutes: &jiraIssue.RemainingEstimateMinutes, + OriginalProject: jiraIssue.ProjectName, + Component: jiraIssue.Components, + IsSubtask: jiraIssue.Subtask, + DueDate: jiraIssue.DueDate, + FixVersions: jiraIssue.FixVersions, + } + if jiraIssue.CreatorAccountId != "" { + issue.CreatorId = accountIdGen.Generate(data.Options.ConnectionId, jiraIssue.CreatorAccountId) + } + if jiraIssue.CreatorDisplayName != "" { + issue.CreatorName = jiraIssue.CreatorDisplayName + } + if jiraIssue.AssigneeDisplayName != "" { + issue.AssigneeName = jiraIssue.AssigneeDisplayName + } + if jiraIssue.ParentId != 0 { + issue.ParentIssueId = issueIdGen.Generate(data.Options.ConnectionId, jiraIssue.ParentId) + } + // only set type to subtask if no type mapping is set + mapped, ok := mappings.StdTypeMappings[jiraIssue.Type] + if !(ok && mapped != "") && jiraIssue.Subtask { + issue.Type = ticket.SUBTASK + } + result = append(result, issue) + if jiraIssue.AssigneeAccountId != "" { + issue.AssigneeId = accountIdGen.Generate(data.Options.ConnectionId, jiraIssue.AssigneeAccountId) + issueAssignee := &ticket.IssueAssignee{ + IssueId: issue.Id, + AssigneeId: issue.AssigneeId, + AssigneeName: issue.AssigneeName, + } + result = append(result, issueAssignee) + } + result = append(result, &ticket.BoardIssue{ + BoardId: boardId, + IssueId: issue.Id, + }) + return result, nil + }, + }) + if err != nil { + return err + } + + // Full-sync deletion: since there is no raw-table layer, we cannot use + // the _raw_data_table-based deletion from the upstream jira convertor. + // Instead we delete domain rows scoped to this board's domain ID. + // + // Trade-off: deletion happens before conversion rather than after. If the + // converter fails partway through, previously synced domain data is lost + // until the next successful full sync. The alternative (delete after) is + // unsafe here because converter.Execute() upserts rows with the same + // _raw_data_params scope, so a post-convert delete would erase the freshly + // written data. Acceptable given that full-sync is an admin-triggered operation + // and re-running it is straightforward. + if !converter.IsIncremental() { + logger.Debug("deleting outdated domain rows for board %d", data.Options.BoardId) + if dbErr := db.Delete(&ticket.Issue{}, dal.Where("_raw_data_params = ?", converter.GetRawDataParams())); dbErr != nil { + logger.Error(dbErr, "delete issues") + return dbErr + } + if dbErr := db.Delete(&ticket.IssueAssignee{}, dal.Where("_raw_data_params = ?", converter.GetRawDataParams())); dbErr != nil { + logger.Error(dbErr, "delete issue_assignees") + return dbErr + } + if dbErr := db.Delete(&ticket.BoardIssue{}, dal.Where("board_id = ?", boardId)); dbErr != nil { + logger.Error(dbErr, "delete board_issues") + return dbErr + } + } + + return converter.Execute() +} + +// issueURL returns the issue key as the URL placeholder. +// The Jira base URL is PII-stripped in Snowflake so we cannot construct a full URL. +func issueURL(issueKey string) string { + return issueKey +} diff --git a/backend/plugins/jira_snowflake/tasks/convert_sprint_issues.go b/backend/plugins/jira_snowflake/tasks/convert_sprint_issues.go new file mode 100644 index 00000000000..313debdea08 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/convert_sprint_issues.go @@ -0,0 +1,86 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// Adapted from plugins/jira/tasks/sprint_issues_convertor.go. +// TODO: long-term, extract shared convertor logic to helpers/jiraconvertors/. + +import ( + "reflect" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var ConvertSprintIssuesMeta = plugin.SubTaskMeta{ + Name: "convertSprintIssues", + EntryPoint: ConvertSprintIssues, + EnabledByDefault: false, + Description: "Convert Jira sprint-issue memberships into domain layer ticket.SprintIssue", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ConvertSprintIssues(taskCtx plugin.SubTaskContext) errors.Error { + db := taskCtx.GetDal() + data := taskCtx.GetData().(*JiraSnowflakeTaskData) + + clauses := []dal.Clause{ + dal.Select("*"), + dal.From(&jiramodels.JiraSprintIssue{}), + dal.Where("_tool_jira_sprint_issues.connection_id = ?", data.Options.ConnectionId), + } + cursor, err := db.Cursor(clauses...) + if err != nil { + return err + } + defer cursor.Close() + + issueIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraIssue{}) + sprintIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraSprint{}) + + converter, err := helper.NewDataConverter(helper.DataConverterArgs{ + InputRowType: reflect.TypeOf(jiramodels.JiraSprintIssue{}), + Input: cursor, + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: JiraApiParams{ + ConnectionId: data.Options.ConnectionId, + BoardId: data.Options.BoardId, + }, + Table: "jira_api_issues", + }, + Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { + jiraSprintIssue := inputRow.(*jiramodels.JiraSprintIssue) + return []interface{}{ + &ticket.SprintIssue{ + SprintId: sprintIdGen.Generate(data.Options.ConnectionId, jiraSprintIssue.SprintId), + IssueId: issueIdGen.Generate(data.Options.ConnectionId, jiraSprintIssue.IssueId), + }, + }, nil + }, + }) + if err != nil { + return err + } + return converter.Execute() +} diff --git a/backend/plugins/jira_snowflake/tasks/convert_sprints.go b/backend/plugins/jira_snowflake/tasks/convert_sprints.go new file mode 100644 index 00000000000..2dc5148b433 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/convert_sprints.go @@ -0,0 +1,104 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// Adapted from plugins/jira/tasks/sprint_convertor.go. +// TODO: long-term, extract shared convertor logic to helpers/jiraconvertors/. + +import ( + "reflect" + "strings" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var ConvertSprintsMeta = plugin.SubTaskMeta{ + Name: "convertSprints", + EntryPoint: ConvertSprints, + EnabledByDefault: false, + Description: "Convert Jira sprints into domain layer ticket.Sprint", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ConvertSprints(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*JiraSnowflakeTaskData) + connectionId := data.Options.ConnectionId + boardId := data.Options.BoardId + db := taskCtx.GetDal() + + clauses := []dal.Clause{ + dal.Select("tjs.*"), + dal.From("_tool_jira_sprints tjs"), + dal.Join(`LEFT JOIN _tool_jira_board_sprints tjbs + ON tjbs.sprint_id = tjs.sprint_id AND tjbs.connection_id = tjs.connection_id`), + dal.Where("tjs.connection_id = ? AND tjbs.board_id = ?", connectionId, boardId), + } + cursor, err := db.Cursor(clauses...) + if err != nil { + return err + } + defer cursor.Close() + + domainBoardId := didgen.NewDomainIdGenerator(&jiramodels.JiraBoard{}).Generate(connectionId, boardId) + sprintIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraSprint{}) + boardIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraBoard{}) + + converter, err := helper.NewDataConverter(helper.DataConverterArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: JiraApiParams{ + ConnectionId: connectionId, + BoardId: boardId, + }, + Table: "jira_api_sprints", + }, + InputRowType: reflect.TypeOf(jiramodels.JiraSprint{}), + Input: cursor, + Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { + jiraSprint := inputRow.(*jiramodels.JiraSprint) + sprint := &ticket.Sprint{ + DomainEntity: domainlayer.DomainEntity{Id: sprintIdGen.Generate(connectionId, jiraSprint.SprintId)}, + Url: jiraSprint.Self, + Status: strings.ToUpper(jiraSprint.State), + Name: jiraSprint.Name, + StartedDate: jiraSprint.StartDate, + EndedDate: jiraSprint.EndDate, + CompletedDate: jiraSprint.CompleteDate, + OriginalBoardID: boardIdGen.Generate(connectionId, jiraSprint.OriginBoardID), + } + return []interface{}{ + sprint, + &ticket.BoardSprint{ + BoardId: domainBoardId, + SprintId: sprint.Id, + }, + }, nil + }, + }) + if err != nil { + return err + } + return converter.Execute() +} diff --git a/backend/plugins/jira_snowflake/tasks/convert_worklogs.go b/backend/plugins/jira_snowflake/tasks/convert_worklogs.go new file mode 100644 index 00000000000..1f3bce9a912 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/convert_worklogs.go @@ -0,0 +1,101 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// Adapted from plugins/jira/tasks/worklog_convertor.go. +// TODO: long-term, extract shared convertor logic to helpers/jiraconvertors/. + +import ( + "reflect" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer" + "github.com/apache/devlake/core/models/domainlayer/didgen" + "github.com/apache/devlake/core/models/domainlayer/ticket" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var ConvertWorklogsMeta = plugin.SubTaskMeta{ + Name: "convertWorklogs", + EntryPoint: ConvertWorklogs, + EnabledByDefault: false, + Description: "Convert Jira worklogs into domain layer ticket.IssueWorklog", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func ConvertWorklogs(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*JiraSnowflakeTaskData) + db := taskCtx.GetDal() + + clauses := []dal.Clause{ + dal.From(&jiramodels.JiraWorklog{}), + dal.Select("_tool_jira_worklogs.*"), + dal.Join(`LEFT JOIN _tool_jira_board_issues + ON _tool_jira_board_issues.connection_id = _tool_jira_worklogs.connection_id + AND _tool_jira_board_issues.issue_id = _tool_jira_worklogs.issue_id`), + dal.Where( + "_tool_jira_board_issues.connection_id = ? AND _tool_jira_board_issues.board_id = ?", + data.Options.ConnectionId, data.Options.BoardId, + ), + } + cursor, err := db.Cursor(clauses...) + if err != nil { + return err + } + defer cursor.Close() + + worklogIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraWorklog{}) + accountIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraAccount{}) + issueIdGen := didgen.NewDomainIdGenerator(&jiramodels.JiraIssue{}) + + converter, err := helper.NewDataConverter(helper.DataConverterArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: JiraApiParams{ + ConnectionId: data.Options.ConnectionId, + BoardId: data.Options.BoardId, + }, + Table: "jira_api_worklogs", + }, + InputRowType: reflect.TypeOf(jiramodels.JiraWorklog{}), + Input: cursor, + Convert: func(inputRow interface{}) ([]interface{}, errors.Error) { + jiraWorklog := inputRow.(*jiramodels.JiraWorklog) + worklog := &ticket.IssueWorklog{ + DomainEntity: domainlayer.DomainEntity{ + Id: worklogIdGen.Generate(jiraWorklog.ConnectionId, jiraWorklog.IssueId, jiraWorklog.WorklogId), + }, + IssueId: issueIdGen.Generate(jiraWorklog.ConnectionId, jiraWorklog.IssueId), + TimeSpentMinutes: jiraWorklog.TimeSpentSeconds / 60, + StartedDate: &jiraWorklog.Started, + LoggedDate: &jiraWorklog.Updated, + } + if jiraWorklog.AuthorId != "" { + worklog.AuthorId = accountIdGen.Generate(data.Options.ConnectionId, jiraWorklog.AuthorId) + } + return []interface{}{worklog}, nil + }, + }) + if err != nil { + return err + } + return converter.Execute() +} diff --git a/backend/plugins/jira_snowflake/tasks/main_test.go b/backend/plugins/jira_snowflake/tasks/main_test.go new file mode 100644 index 00000000000..e8ccb588b2b --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/main_test.go @@ -0,0 +1,52 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "os" + "testing" + + "github.com/apache/devlake/core/plugin" +) + +// jiraPluginStub satisfies plugin.PluginMeta so that didgen.NewDomainIdGenerator +// can resolve model types from github.com/apache/devlake/plugins/jira/... +// in unit tests without importing the full jira impl package. +type jiraPluginStub struct{} + +func (jiraPluginStub) Name() string { return "jira" } +func (jiraPluginStub) RootPkgPath() string { return "github.com/apache/devlake/plugins/jira" } +func (jiraPluginStub) Description() string { return "" } + +func TestMain(m *testing.M) { + // Register the jira plugin so that didgen can resolve jira model types + // (e.g. JiraSprint, JiraIssue) without a running DevLake server. + _ = plugin.RegisterPlugin("jira", jiraPluginStub{}) + // Also register ourselves so that domain ID generation for our own types works. + _ = plugin.RegisterPlugin("jira_snowflake", JiraSnowflake{}) + os.Exit(m.Run()) +} + +// JiraSnowflake is a minimal PluginMeta stub for jira_snowflake model types. +type JiraSnowflake struct{} + +func (JiraSnowflake) Name() string { return "jira_snowflake" } +func (JiraSnowflake) RootPkgPath() string { + return "github.com/apache/devlake/plugins/jira_snowflake" +} +func (JiraSnowflake) Description() string { return "" } diff --git a/backend/plugins/jira_snowflake/tasks/shared.go b/backend/plugins/jira_snowflake/tasks/shared.go new file mode 100644 index 00000000000..cbdb47c75f3 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/shared.go @@ -0,0 +1,79 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// Adapted from plugins/jira/tasks/shared.go and issue_extractor.go. +// TODO: long-term these helpers should be extracted to helpers/jiraconvertors/ +// so both jira and jira_snowflake import from a neutral package. + +import ( + "strings" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/domainlayer/ticket" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +// buildProjectInClause builds a parameterized IN clause fragment "(?, ?, ...)" +// and the corresponding argument slice for the given project keys. +// Usage: clause, args := buildProjectInClause(keys); db.QueryContext(ctx, "...IN "+clause, args...) +func buildProjectInClause(projectKeys []string) (string, []interface{}) { + placeholders := make([]string, len(projectKeys)) + args := make([]interface{}, len(projectKeys)) + for i, k := range projectKeys { + placeholders[i] = "?" + args[i] = k + } + return "(" + strings.Join(placeholders, ", ") + ")", args +} + +// getStdStatus maps a Jira status category key to a DevLake standard status. +// Copied from plugins/jira/tasks/shared.go. +func getStdStatus(statusKey string) string { + if statusKey == "done" { + return ticket.DONE + } else if statusKey == "new" { + return ticket.TODO + } + return ticket.IN_PROGRESS +} + +type typeMappings struct { + StdTypeMappings map[string]string + StandardStatusMappings map[string]jiramodels.StatusMappings +} + +// getTypeMappings builds type/status mappings from the scope config. +// In jira_snowflake, JiraIssue.Type is already the type name (not an ID), +// so TypeIdMappings are not needed. +// Adapted from plugins/jira/tasks/issue_extractor.go getTypeMappings(). +func getTypeMappings(data *JiraSnowflakeTaskData, _ dal.Dal) (*typeMappings, errors.Error) { + stdTypeMappings := make(map[string]string) + standardStatusMappings := make(map[string]jiramodels.StatusMappings) + if data.Options.ScopeConfig != nil { + for userType, stdType := range data.Options.ScopeConfig.TypeMappings { + stdTypeMappings[userType] = strings.ToUpper(stdType.StandardType) + standardStatusMappings[userType] = stdType.StatusMappings + } + } + return &typeMappings{ + StdTypeMappings: stdTypeMappings, + StandardStatusMappings: standardStatusMappings, + }, nil +} diff --git a/backend/plugins/jira_snowflake/tasks/shared_test.go b/backend/plugins/jira_snowflake/tasks/shared_test.go new file mode 100644 index 00000000000..381cb0b0087 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/shared_test.go @@ -0,0 +1,105 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/apache/devlake/core/models/domainlayer/ticket" + jiramodels "github.com/apache/devlake/plugins/jira/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// getStdStatus +// --------------------------------------------------------------------------- + +func TestGetStdStatus(t *testing.T) { + tests := []struct { + statusKey string + want string + }{ + {"done", ticket.DONE}, + {"new", ticket.TODO}, + {"indeterminate", ticket.IN_PROGRESS}, + // any unknown key should also map to IN_PROGRESS + {"", ticket.IN_PROGRESS}, + {"unknown", ticket.IN_PROGRESS}, + {"in_progress", ticket.IN_PROGRESS}, + } + for _, tt := range tests { + t.Run(tt.statusKey, func(t *testing.T) { + assert.Equal(t, tt.want, getStdStatus(tt.statusKey)) + }) + } +} + +// --------------------------------------------------------------------------- +// getTypeMappings +// --------------------------------------------------------------------------- + +func TestGetTypeMappings_NilScopeConfig(t *testing.T) { + data := &JiraSnowflakeTaskData{ + Options: &JiraSnowflakeOptions{ScopeConfig: nil}, + } + m, err := getTypeMappings(data, nil) + require.Nil(t, err) + assert.Empty(t, m.StdTypeMappings) + assert.Empty(t, m.StandardStatusMappings) +} + +func TestGetTypeMappings_EmptyScopeConfig(t *testing.T) { + data := &JiraSnowflakeTaskData{ + Options: &JiraSnowflakeOptions{ + ScopeConfig: &jiramodels.JiraScopeConfig{}, + }, + } + m, err := getTypeMappings(data, nil) + require.Nil(t, err) + assert.Empty(t, m.StdTypeMappings) +} + +func TestGetTypeMappings_WithTypeMappings(t *testing.T) { + data := &JiraSnowflakeTaskData{ + Options: &JiraSnowflakeOptions{ + ScopeConfig: &jiramodels.JiraScopeConfig{ + TypeMappings: map[string]jiramodels.TypeMapping{ + "Story": { + StandardType: "requirement", + StatusMappings: jiramodels.StatusMappings{ + "done": {StandardStatus: "DONE"}, + }, + }, + "Bug": { + StandardType: "bug", + }, + }, + }, + }, + } + m, err := getTypeMappings(data, nil) + require.Nil(t, err) + + assert.Equal(t, "REQUIREMENT", m.StdTypeMappings["Story"], "type name mapping should be uppercased") + assert.Equal(t, "BUG", m.StdTypeMappings["Bug"]) + assert.Equal(t, "", m.StdTypeMappings["Task"], "unmapped type should return empty string") + + // status override for Story + assert.Equal(t, jiramodels.StatusMappings{"done": {StandardStatus: "DONE"}}, m.StandardStatusMappings["Story"]) +} diff --git a/backend/plugins/jira_snowflake/tasks/sync_changelogs.go b/backend/plugins/jira_snowflake/tasks/sync_changelogs.go new file mode 100644 index 00000000000..71e2442e078 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/sync_changelogs.go @@ -0,0 +1,153 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "hash/fnv" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +// hashUUID converts a UUID/hex string to a uint64 via FNV-1a. +// The CLOUDRHAI_MARTS schema uses UUID strings for changelog IDs instead of +// the numeric IDs that the upstream Jira API uses. We hash to uint64 to stay +// compatible with JiraIssueChangelogs.ChangelogId (uint64 primary key). +func hashUUID(s string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(s)) + return h.Sum64() +} + +var SyncChangelogsMeta = plugin.SubTaskMeta{ + Name: "syncChangelogs", + EntryPoint: SyncChangelogs, + EnabledByDefault: true, + Description: "Sync Jira issue changelogs from Snowflake into _tool_jira_issue_changelogs and _tool_jira_issue_changelog_items", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func SyncChangelogs(subtaskCtx plugin.SubTaskContext) errors.Error { + data := subtaskCtx.GetData().(*JiraSnowflakeTaskData) + db := subtaskCtx.GetDal() + logger := subtaskCtx.GetLogger() + + connectionId := data.Options.ConnectionId + projectKeys := data.Options.ProjectKeys + + inClause, args := buildProjectInClause(projectKeys) + syncPolicy := subtaskCtx.TaskContext().SyncPolicy() + timeFilter := "" + if syncPolicy != nil && syncPolicy.TimeAfter != nil { + timeFilter = "AND cl.CREATED > ?" + args = append(args, *syncPolicy.TimeAfter) + } + + const queryTmpl = ` +SELECT + cl.ID AS changelog_id, + cl.ISSUEID AS issue_id, + cl.CREATED, + ci.FIELD, + ci.FIELDTYPE, + NULL AS field_id, + ci.OLDVALUE AS from_value, + ci.OLDVALUE AS from_string, + ci.NEWVALUE AS to_value, + ci.NEWVALUE AS to_string +FROM JIRA_CHANGEGROUP_RHAI_CLUSTERED cl +JOIN JIRA_CHANGEITEM_NON_PII_CLUSTERED ci ON ci.GROUPID = cl.ID +JOIN JIRA_ISSUE_NON_PII i ON i.ID = cl.ISSUEID +WHERE i.PROJECT IN %s +%s +` + query := fmt.Sprintf(queryTmpl, inClause, timeFilter) //nolint:gosec // G201: inClause contains only '?' placeholders from buildProjectInClause; timeFilter is a static string + + rows, goErr := data.SnowflakeDB.QueryContext(subtaskCtx.GetContext(), query, args...) + if goErr != nil { + return errors.Default.Wrap(goErr, "failed to query Snowflake for changelogs") + } + defer rows.Close() + + changelogCount := 0 + itemCount := 0 + seenChangelogs := make(map[uint64]bool) + + for rows.Next() { + var ( + changelogIdStr string + issueId uint64 + created time.Time + field string + fieldType string + fieldId *string + fromValue *string + fromString *string + toValue *string + toString *string + ) + if scanErr := rows.Scan( + &changelogIdStr, &issueId, &created, + &field, &fieldType, &fieldId, + &fromValue, &fromString, &toValue, &toString, + ); scanErr != nil { + return errors.Default.Wrap(scanErr, "failed to scan Snowflake changelog row") + } + changelogId := hashUUID(changelogIdStr) + + // Upsert parent changelog header once per changelog ID + if !seenChangelogs[changelogId] { + cl := &jiramodels.JiraIssueChangelogs{ + ConnectionId: connectionId, + ChangelogId: changelogId, + IssueId: issueId, + Created: created, + } + if dbErr := db.CreateOrUpdate(cl); dbErr != nil { + return dbErr + } + seenChangelogs[changelogId] = true + changelogCount++ + } + + // Upsert changelog item + item := &jiramodels.JiraIssueChangelogItems{ + ConnectionId: connectionId, + ChangelogId: changelogId, + Field: field, + FieldType: fieldType, + FieldId: stringVal(fieldId), + FromValue: stringVal(fromValue), + FromString: stringVal(fromString), + ToValue: stringVal(toValue), + ToString: stringVal(toString), + } + if dbErr := db.CreateOrUpdate(item); dbErr != nil { + return dbErr + } + itemCount++ + } + if goErr = rows.Err(); goErr != nil { + return errors.Default.Wrap(goErr, "Snowflake changelog rows iteration error") + } + logger.Info("finished syncing %d changelogs (%d items)", changelogCount, itemCount) + return nil +} diff --git a/backend/plugins/jira_snowflake/tasks/sync_issue_links.go b/backend/plugins/jira_snowflake/tasks/sync_issue_links.go new file mode 100644 index 00000000000..71be7295088 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/sync_issue_links.go @@ -0,0 +1,113 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var SyncIssueLinksMeta = plugin.SubTaskMeta{ + Name: "syncIssueLinks", + EntryPoint: SyncIssueLinks, + EnabledByDefault: false, + Description: "Sync Jira issue links from Snowflake into _tool_jira_issue_relationships", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func SyncIssueLinks(subtaskCtx plugin.SubTaskContext) errors.Error { + data := subtaskCtx.GetData().(*JiraSnowflakeTaskData) + db := subtaskCtx.GetDal() + logger := subtaskCtx.GetLogger() + + connectionId := data.Options.ConnectionId + projectKeys := data.Options.ProjectKeys + + inClause, args := buildProjectInClause(projectKeys) + + const queryTmpl = ` +SELECT + il.ID AS link_id, + il.SOURCE AS issue_id, + src.ISSUE_KEY AS issue_key, + lt.ID AS type_id, + lt.LINKNAME AS type_name, + lt.INWARD, + lt.OUTWARD, + il.DESTINATION AS inward_issue_id, + dst.ISSUE_KEY AS inward_issue_key +FROM JIRA_ISSUELINK il +JOIN JIRA_ISSUE_NON_PII src ON src.ID = il.SOURCE +JOIN JIRA_ISSUE_NON_PII dst ON dst.ID = il.DESTINATION +JOIN JIRA_ISSUELINKTYPE lt ON lt.ID = il.LINKTYPE +WHERE src.PROJECT IN %s +` + query := fmt.Sprintf(queryTmpl, inClause) //nolint:gosec // G201: inClause contains only '?' placeholders from buildProjectInClause; no user data is interpolated + + rows, goErr := data.SnowflakeDB.QueryContext(subtaskCtx.GetContext(), query, args...) + if goErr != nil { + return errors.Default.Wrap(goErr, "failed to query Snowflake for issue links") + } + defer rows.Close() + + count := 0 + for rows.Next() { + var ( + linkId uint64 + issueId uint64 + issueKey string + typeId uint64 + typeName string + inward string + outward string + inwardIssueId uint64 + inwardIssueKey string + ) + if scanErr := rows.Scan( + &linkId, &issueId, &issueKey, + &typeId, &typeName, &inward, &outward, + &inwardIssueId, &inwardIssueKey, + ); scanErr != nil { + return errors.Default.Wrap(scanErr, "failed to scan Snowflake issue link row") + } + + rel := &jiramodels.JiraIssueRelationship{ + ConnectionId: connectionId, + IssueId: issueId, + IssueKey: issueKey, + TypeId: typeId, + TypeName: typeName, + Inward: inward, + Outward: outward, + InwardIssueId: inwardIssueId, + InwardIssueKey: inwardIssueKey, + } + if dbErr := db.CreateOrUpdate(rel); dbErr != nil { + return dbErr + } + count++ + } + if goErr = rows.Err(); goErr != nil { + return errors.Default.Wrap(goErr, "Snowflake issue link rows iteration error") + } + logger.Info("finished syncing %d issue links", count) + return nil +} diff --git a/backend/plugins/jira_snowflake/tasks/sync_issues.go b/backend/plugins/jira_snowflake/tasks/sync_issues.go new file mode 100644 index 00000000000..72ba2e4fd78 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/sync_issues.go @@ -0,0 +1,263 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "strings" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var SyncIssuesMeta = plugin.SubTaskMeta{ + Name: "syncIssues", + EntryPoint: SyncIssues, + EnabledByDefault: true, + Description: "Sync Jira issues from Snowflake into _tool_jira_issues and _tool_jira_board_issues", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func SyncIssues(subtaskCtx plugin.SubTaskContext) errors.Error { + data := subtaskCtx.GetData().(*JiraSnowflakeTaskData) + db := subtaskCtx.GetDal() + logger := subtaskCtx.GetLogger() + + connectionId := data.Options.ConnectionId + boardId := data.Options.BoardId + projectKeys := data.Options.ProjectKeys + + mappings, err := getTypeMappings(data, db) + if err != nil { + return err + } + + var timeAfter *time.Time + syncPolicy := subtaskCtx.TaskContext().SyncPolicy() + if syncPolicy != nil { + timeAfter = syncPolicy.TimeAfter + } + + query, args := buildIssuesQuery(projectKeys, timeAfter) + + rows, goErr := data.SnowflakeDB.QueryContext(subtaskCtx.GetContext(), query, args...) + if goErr != nil { + return errors.Default.Wrap(goErr, "failed to query Snowflake for issues") + } + defer rows.Close() + + count := 0 + for rows.Next() { + var ( + issueId uint64 + issueKey string + projectId uint64 + projectKey string + projectName string + issueType string + statusName string + statusKey string + summary *string + description *string + created time.Time + updated time.Time + resolutionDate *time.Time + dueDate *time.Time + parentId *uint64 + originalEstimateSeconds *int64 + remainingEstimateSeconds *int64 + timeSpentSeconds *int64 + storyPoint *float64 + epicKey *string + sprintId *uint64 + isSubtask bool + priorityName *string + components *string + fixVersions *string + ) + if scanErr := rows.Scan( + &issueId, &issueKey, &projectId, &projectKey, &projectName, + &issueType, &statusName, &statusKey, + &summary, &description, + &created, &updated, &resolutionDate, &dueDate, + &parentId, &originalEstimateSeconds, &remainingEstimateSeconds, + &timeSpentSeconds, &storyPoint, &epicKey, &sprintId, &isSubtask, + &priorityName, &components, &fixVersions, + ); scanErr != nil { + return errors.Default.Wrap(scanErr, "failed to scan Snowflake issue row") + } + + // Compute StdType from scope config + stdType := mappings.StdTypeMappings[issueType] + if stdType == "" { + stdType = strings.ToUpper(issueType) + } + + // Compute StdStatus — first the category default, then scope config override + stdStatus := getStdStatus(statusKey) + if overrides, ok := mappings.StandardStatusMappings[issueType]; ok { + if v, ok2 := overrides[statusKey]; ok2 { + stdStatus = v.StandardStatus + } + } + + issue := &jiramodels.JiraIssue{ + ConnectionId: connectionId, + IssueId: issueId, + ProjectId: uint64(projectId), + ProjectName: projectName, + IssueKey: issueKey, + Summary: stringVal(summary), + Description: stringVal(description), + Type: issueType, + StatusName: statusName, + StatusKey: statusKey, + StdType: stdType, + StdStatus: stdStatus, + StoryPoint: storyPoint, + EpicKey: stringVal(epicKey), + PriorityName: stringVal(priorityName), + Components: stringVal(components), + FixVersions: stringVal(fixVersions), + Created: created, + Updated: updated, + Subtask: isSubtask, + } + if originalEstimateSeconds != nil { + minutes := *originalEstimateSeconds / 60 + issue.OriginalEstimateMinutes = &minutes + } + if remainingEstimateSeconds != nil { + issue.RemainingEstimateMinutes = *remainingEstimateSeconds / 60 + } + if timeSpentSeconds != nil { + minutes := *timeSpentSeconds / 60 + issue.SpentMinutes = &minutes + } + issue.ResolutionDate = resolutionDate + issue.DueDate = dueDate + if parentId != nil && *parentId != 0 { + issue.ParentId = *parentId + } + if sprintId != nil { + issue.SprintId = *sprintId + } + + if dbErr := db.CreateOrUpdate(issue); dbErr != nil { + return dbErr + } + + // Maintain _tool_jira_board_issues so convertor scope queries work + boardIssue := &jiramodels.JiraBoardIssue{ + ConnectionId: connectionId, + BoardId: boardId, + IssueId: issueId, + } + if dbErr := db.CreateOrUpdate(boardIssue); dbErr != nil { + return dbErr + } + + count++ + if count%500 == 0 { + logger.Info("synced %d issues", count) + subtaskCtx.SetProgress(count, -1) + } + } + if goErr = rows.Err(); goErr != nil { + return errors.Default.Wrap(goErr, "Snowflake issue rows iteration error") + } + logger.Info("finished syncing %d issues for board %d", count, boardId) + return nil +} + +// buildIssuesQuery constructs the parameterized Snowflake SELECT for issues. +// Returns the query template (with ? placeholders) and the bound arguments. +// Extracted as a pure function so it can be unit-tested independently of the DB. +func buildIssuesQuery(projectKeys []string, timeAfter *time.Time) (string, []interface{}) { + inClause, args := buildProjectInClause(projectKeys) + + timeFilter := "" + if timeAfter != nil { + timeFilter = "AND i.UPDATED > ?" + args = append(args, *timeAfter) + } + + const queryTmpl = ` +SELECT + i.ID AS issue_id, + i.ISSUE_KEY, + p.ID AS project_id, + i.PROJECT AS project_key, + p.PNAME AS project_name, + it.PNAME AS issue_type, + s.PNAME AS status_name, + -- redhat.atlassian.net statusCategory ids: 2=new, 3=done, 4=indeterminate + -- (differs from older Atlassian docs that listed 3=indeterminate, 4=done). + CASE s.STATUSCATEGORY + WHEN '2' THEN 'new' + WHEN '3' THEN 'done' + WHEN '4' THEN 'indeterminate' + ELSE 'indeterminate' + END AS status_key, + i.SUMMARY, + i.DESCRIPTION, + i.CREATED, + i.UPDATED, + i.RESOLUTIONDATE, + i.DUEDATE, + i.PARENT_ID, + i.TIMEORIGINALESTIMATE AS original_estimate_seconds, + i.TIMEESTIMATE AS remaining_estimate_seconds, + i.TIMESPENT AS time_spent_seconds, + sp.NUMBERVALUE::FLOAT AS story_point, + el.STRINGVALUE AS epic_key, + sprint_cf.NUMBERVALUE::BIGINT AS sprint_id, + COALESCE(it.PSTYLE = 'subtask', FALSE) AS is_subtask, + i.PRIORITY AS priority_name, + i.COMPONENT AS components, + i.FIXFOR AS fix_versions +FROM JIRA_ISSUE_NON_PII i +JOIN JIRA_ISSUETYPE_RHAI it ON i.ISSUETYPE = it.ID +JOIN JIRA_ISSUESTATUS_RHAI s ON i.ISSUESTATUS_ID = s.ID +JOIN JIRA_PROJECT_RHAI p ON i.PROJECT = p.PKEY +LEFT JOIN JIRA_CUSTOMFIELDVALUE_NON_PII sp + ON sp.ISSUE = i.ID AND sp.CUSTOMFIELD_NAME = 'Story Points' +LEFT JOIN JIRA_CUSTOMFIELDVALUE_NON_PII el + ON el.ISSUE = i.ID AND el.CUSTOMFIELD_NAME = 'Epic Link' +LEFT JOIN JIRA_CUSTOMFIELDVALUE_NON_PII sprint_cf + ON sprint_cf.ISSUE = i.ID AND sprint_cf.CUSTOMFIELD_NAME = 'Sprint' +WHERE i.PROJECT IN %s +%s +QUALIFY ROW_NUMBER() OVER ( + PARTITION BY i.ID + ORDER BY sprint_cf.NUMBERVALUE DESC NULLS LAST, + sp.NUMBERVALUE DESC NULLS LAST +) = 1 +` + query := fmt.Sprintf(queryTmpl, inClause, timeFilter) //nolint:gosec // G201: inClause contains only '?' placeholders from buildProjectInClause; timeFilter is a static string + return query, args +} + +func stringVal(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/backend/plugins/jira_snowflake/tasks/sync_issues_test.go b/backend/plugins/jira_snowflake/tasks/sync_issues_test.go new file mode 100644 index 00000000000..31932e6d9fe --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/sync_issues_test.go @@ -0,0 +1,133 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// --------------------------------------------------------------------------- +// buildIssuesQuery +// --------------------------------------------------------------------------- + +func TestBuildIssuesQuery_SingleProject(t *testing.T) { + q, args := buildIssuesQuery([]string{"KONFLUX"}, nil) + assert.Contains(t, q, "WHERE i.PROJECT IN (?)") + assert.Equal(t, []interface{}{"KONFLUX"}, args) + assert.NotContains(t, q, "i.UPDATED >", "no time filter expected for nil timeAfter") +} + +func TestBuildIssuesQuery_MultipleProjects(t *testing.T) { + q, args := buildIssuesQuery([]string{"PROJ1", "PROJ2", "PROJ3"}, nil) + assert.Contains(t, q, "IN (?, ?, ?)") + assert.Equal(t, []interface{}{"PROJ1", "PROJ2", "PROJ3"}, args) +} + +func TestBuildIssuesQuery_ProjectKeyPassedAsArg(t *testing.T) { + // Project keys with special characters are passed as bound args, not interpolated. + _, args := buildIssuesQuery([]string{"O'TOOL"}, nil) + assert.Equal(t, []interface{}{"O'TOOL"}, args, "project key must be passed as a bound arg, not interpolated") +} + +func TestBuildIssuesQuery_WithTimeFilter(t *testing.T) { + ts := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) + q, args := buildIssuesQuery([]string{"PROJ"}, &ts) + assert.Contains(t, q, "AND i.UPDATED > ?") + assert.Equal(t, 2, len(args), "should have project key arg + time arg") + assert.Equal(t, "PROJ", args[0]) + assert.Equal(t, ts, args[1]) +} + +func TestBuildIssuesQuery_ContainsRequiredColumns(t *testing.T) { + q, _ := buildIssuesQuery([]string{"PROJ"}, nil) + for _, col := range []string{ + "issue_id", "ISSUE_KEY", "issue_type", "status_key", + "SUMMARY", "CREATED", "UPDATED", + "STATUSCATEGORY", "story_point", "is_subtask", + "priority_name", "components", "fix_versions", + } { + assert.True(t, strings.Contains(q, col), "query must contain column %q", col) + } +} + +func TestBuildIssuesQuery_StatusCategoryMapping(t *testing.T) { + q, _ := buildIssuesQuery([]string{"PROJ"}, nil) + // STATUSCATEGORY on redhat.atlassian.net: '2'=new, '3'=done, '4'=indeterminate + assert.Contains(t, q, "WHEN '2' THEN 'new'") + assert.Contains(t, q, "WHEN '3' THEN 'done'") + assert.Contains(t, q, "WHEN '4' THEN 'indeterminate'") + assert.Contains(t, q, "ELSE 'indeterminate'") + assert.Contains(t, q, "s.STATUSCATEGORY") +} + +func TestBuildIssuesQuery_ContainsQUALIFY(t *testing.T) { + // QUALIFY deduplicates issues that appear in multiple sprints + q, _ := buildIssuesQuery([]string{"PROJ"}, nil) + assert.Contains(t, q, "QUALIFY ROW_NUMBER() OVER") + assert.Contains(t, q, "PARTITION BY i.ID") +} + +// --------------------------------------------------------------------------- +// StdType / StdStatus computation (pure logic, no DB) +// --------------------------------------------------------------------------- + +func TestStdTypeFromScopeConfig(t *testing.T) { + tests := []struct { + typeName string + mappings map[string]string + wantStdType string + }{ + // Mapped type uses the mapping value (already uppercased by getTypeMappings) + {"Story", map[string]string{"Story": "REQUIREMENT"}, "REQUIREMENT"}, + // Unmapped type falls back to uppercased type name + {"Task", map[string]string{}, "TASK"}, + {"bug", map[string]string{}, "BUG"}, + } + for _, tt := range tests { + t.Run(tt.typeName, func(t *testing.T) { + tm := &typeMappings{StdTypeMappings: tt.mappings} + stdType := tm.StdTypeMappings[tt.typeName] + if stdType == "" { + // mirrors the logic in SyncIssues + stdType = strings.ToUpper(tt.typeName) + } + assert.Equal(t, tt.wantStdType, stdType) + }) + } +} + +func TestStdStatusFromScopeConfig(t *testing.T) { + // Without a scope config override, status category key → standard status + tests := []struct { + statusKey string + wantStdStatus string + }{ + {"new", "TODO"}, + {"done", "DONE"}, + {"indeterminate", "IN_PROGRESS"}, + } + for _, tt := range tests { + t.Run(tt.statusKey, func(t *testing.T) { + assert.Equal(t, tt.wantStdStatus, getStdStatus(tt.statusKey)) + }) + } +} diff --git a/backend/plugins/jira_snowflake/tasks/sync_labels.go b/backend/plugins/jira_snowflake/tasks/sync_labels.go new file mode 100644 index 00000000000..7cc8c7c0b9c --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/sync_labels.go @@ -0,0 +1,85 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var SyncLabelsMeta = plugin.SubTaskMeta{ + Name: "syncLabels", + EntryPoint: SyncLabels, + EnabledByDefault: false, + Description: "Sync Jira issue labels from Snowflake into _tool_jira_issue_labels", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func SyncLabels(subtaskCtx plugin.SubTaskContext) errors.Error { + data := subtaskCtx.GetData().(*JiraSnowflakeTaskData) + db := subtaskCtx.GetDal() + logger := subtaskCtx.GetLogger() + + connectionId := data.Options.ConnectionId + projectKeys := data.Options.ProjectKeys + + inClause, args := buildProjectInClause(projectKeys) + + const queryTmpl = ` +SELECT + l.ISSUE AS issue_id, + l.LABELNAME AS label_name +FROM JIRA_LABEL l +JOIN JIRA_ISSUE_NON_PII i ON i.ID = l.ISSUE +WHERE i.PROJECT IN %s +` + query := fmt.Sprintf(queryTmpl, inClause) //nolint:gosec // G201: inClause contains only '?' placeholders from buildProjectInClause; no user data is interpolated + + rows, goErr := data.SnowflakeDB.QueryContext(subtaskCtx.GetContext(), query, args...) + if goErr != nil { + return errors.Default.Wrap(goErr, "failed to query Snowflake for labels") + } + defer rows.Close() + + count := 0 + for rows.Next() { + var issueId uint64 + var labelName string + if scanErr := rows.Scan(&issueId, &labelName); scanErr != nil { + return errors.Default.Wrap(scanErr, "failed to scan Snowflake label row") + } + + label := &jiramodels.JiraIssueLabel{ + ConnectionId: connectionId, + IssueId: issueId, + LabelName: labelName, + } + if dbErr := db.CreateOrUpdate(label); dbErr != nil { + return dbErr + } + count++ + } + if goErr = rows.Err(); goErr != nil { + return errors.Default.Wrap(goErr, "Snowflake label rows iteration error") + } + logger.Info("finished syncing %d issue labels", count) + return nil +} diff --git a/backend/plugins/jira_snowflake/tasks/sync_sprint_issues.go b/backend/plugins/jira_snowflake/tasks/sync_sprint_issues.go new file mode 100644 index 00000000000..1d32ccd7676 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/sync_sprint_issues.go @@ -0,0 +1,85 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var SyncSprintIssuesMeta = plugin.SubTaskMeta{ + Name: "syncSprintIssues", + EntryPoint: SyncSprintIssues, + EnabledByDefault: false, + Description: "Sync sprint-issue memberships from Snowflake into _tool_jira_sprint_issues", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func SyncSprintIssues(subtaskCtx plugin.SubTaskContext) errors.Error { + data := subtaskCtx.GetData().(*JiraSnowflakeTaskData) + db := subtaskCtx.GetDal() + logger := subtaskCtx.GetLogger() + + connectionId := data.Options.ConnectionId + projectKeys := data.Options.ProjectKeys + + inClause, args := buildProjectInClause(projectKeys) + + const queryTmpl = ` +SELECT + TRY_CAST(cf.NUMBERVALUE AS BIGINT) AS sprint_id, + i.ID AS issue_id +FROM JIRA_CUSTOMFIELDVALUE_NON_PII cf +JOIN JIRA_ISSUE_NON_PII i ON i.ID = cf.ISSUE +WHERE cf.CUSTOMFIELD_NAME = 'Sprint' + AND cf.NUMBERVALUE IS NOT NULL + AND i.PROJECT IN %s +` + query := fmt.Sprintf(queryTmpl, inClause) //nolint:gosec // G201: inClause contains only '?' placeholders from buildProjectInClause; no user data is interpolated + + rows, goErr := data.SnowflakeDB.QueryContext(subtaskCtx.GetContext(), query, args...) + if goErr != nil { + return errors.Default.Wrap(goErr, "failed to query Snowflake for sprint issues") + } + defer rows.Close() + + count := 0 + for rows.Next() { + var sprintId, issueId uint64 + if scanErr := rows.Scan(&sprintId, &issueId); scanErr != nil { + return errors.Default.Wrap(scanErr, "failed to scan Snowflake sprint issue row") + } + si := &jiramodels.JiraSprintIssue{ + ConnectionId: connectionId, + SprintId: sprintId, + IssueId: issueId, + } + if dbErr := db.CreateOrUpdate(si); dbErr != nil { + return dbErr + } + count++ + } + if goErr = rows.Err(); goErr != nil { + return errors.Default.Wrap(goErr, "Snowflake sprint issue rows iteration error") + } + logger.Info("finished syncing %d sprint-issue records", count) + return nil +} diff --git a/backend/plugins/jira_snowflake/tasks/sync_sprints.go b/backend/plugins/jira_snowflake/tasks/sync_sprints.go new file mode 100644 index 00000000000..73f4156adbe --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/sync_sprints.go @@ -0,0 +1,123 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var SyncSprintsMeta = plugin.SubTaskMeta{ + Name: "syncSprints", + EntryPoint: SyncSprints, + EnabledByDefault: false, + Description: "Sync Jira sprints from Snowflake into _tool_jira_sprints and _tool_jira_board_sprints", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func SyncSprints(subtaskCtx plugin.SubTaskContext) errors.Error { + data := subtaskCtx.GetData().(*JiraSnowflakeTaskData) + db := subtaskCtx.GetDal() + logger := subtaskCtx.GetLogger() + + connectionId := data.Options.ConnectionId + boardId := data.Options.BoardId + projectKeys := data.Options.ProjectKeys + + inClause, args := buildProjectInClause(projectKeys) + + // Select distinct sprints associated with issues in the board's projects. + // State is derived from STARTED/CLOSED booleans. + const queryTmpl = ` +SELECT DISTINCT + sp.ID AS sprint_id, + sp.NAME AS sprint_name, + CASE + WHEN sp.CLOSED THEN 'closed' + WHEN sp.STARTED THEN 'active' + ELSE 'future' + END AS state, + sp.STARTDATE, + sp.ENDDATE, + sp.COMPLETEDATE, + sp.RAPID_VIEW_ID AS origin_board_id +FROM JIRA_SPRINT sp +JOIN JIRA_CUSTOMFIELDVALUE_NON_PII cf + ON cf.CUSTOMFIELD_NAME = 'Sprint' + AND TRY_CAST(cf.NUMBERVALUE AS BIGINT) = sp.ID +JOIN JIRA_ISSUE_NON_PII i ON i.ID = cf.ISSUE +WHERE i.PROJECT IN %s +` + query := fmt.Sprintf(queryTmpl, inClause) //nolint:gosec // G201: inClause contains only '?' placeholders from buildProjectInClause; no user data is interpolated + + rows, goErr := data.SnowflakeDB.QueryContext(subtaskCtx.GetContext(), query, args...) + if goErr != nil { + return errors.Default.Wrap(goErr, "failed to query Snowflake for sprints") + } + defer rows.Close() + + count := 0 + for rows.Next() { + var ( + sprintId uint64 + sprintName string + state string + startDate *time.Time + endDate *time.Time + completeDate *time.Time + originBoardId uint64 + ) + if scanErr := rows.Scan(&sprintId, &sprintName, &state, &startDate, &endDate, &completeDate, &originBoardId); scanErr != nil { + return errors.Default.Wrap(scanErr, "failed to scan Snowflake sprint row") + } + + sprint := &jiramodels.JiraSprint{ + ConnectionId: connectionId, + SprintId: sprintId, + Name: sprintName, + State: state, + StartDate: startDate, + EndDate: endDate, + CompleteDate: completeDate, + OriginBoardID: originBoardId, + } + if dbErr := db.CreateOrUpdate(sprint); dbErr != nil { + return dbErr + } + + boardSprint := &jiramodels.JiraBoardSprint{ + ConnectionId: connectionId, + BoardId: boardId, + SprintId: sprintId, + } + if dbErr := db.CreateOrUpdate(boardSprint); dbErr != nil { + return dbErr + } + + count++ + } + if goErr = rows.Err(); goErr != nil { + return errors.Default.Wrap(goErr, "Snowflake sprint rows iteration error") + } + logger.Info("finished syncing %d sprints for board %d", count, boardId) + return nil +} diff --git a/backend/plugins/jira_snowflake/tasks/sync_worklogs.go b/backend/plugins/jira_snowflake/tasks/sync_worklogs.go new file mode 100644 index 00000000000..cae4c5ce58c --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/sync_worklogs.go @@ -0,0 +1,107 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + jiramodels "github.com/apache/devlake/plugins/jira/models" +) + +var SyncWorklogsMeta = plugin.SubTaskMeta{ + Name: "syncWorklogs", + EntryPoint: SyncWorklogs, + EnabledByDefault: false, + Description: "Sync Jira worklogs from Snowflake into _tool_jira_worklogs", + DomainTypes: []string{plugin.DOMAIN_TYPE_TICKET}, +} + +func SyncWorklogs(subtaskCtx plugin.SubTaskContext) errors.Error { + data := subtaskCtx.GetData().(*JiraSnowflakeTaskData) + db := subtaskCtx.GetDal() + logger := subtaskCtx.GetLogger() + + connectionId := data.Options.ConnectionId + projectKeys := data.Options.ProjectKeys + + inClause, args := buildProjectInClause(projectKeys) + syncPolicy := subtaskCtx.TaskContext().SyncPolicy() + timeFilter := "" + if syncPolicy != nil && syncPolicy.TimeAfter != nil { + timeFilter = "AND w.UPDATED > ?" + args = append(args, *syncPolicy.TimeAfter) + } + + // TIMEWORKED is in seconds — store directly into TimeSpentSeconds. + // The worklog convertor (convert_worklogs.go) divides by 60 when writing to the domain layer. + // Assignee/author fields are null (PII stripped in Snowflake). + const queryTmpl = ` +SELECT + TO_VARCHAR(w.ID) AS worklog_id, + w.ISSUEID AS issue_id, + w.TIMEWORKED AS time_spent_seconds, + w.STARTDATE AS started, + w.UPDATED +FROM JIRA_WORKLOG w +JOIN JIRA_ISSUE_NON_PII i ON i.ID = w.ISSUEID +WHERE i.PROJECT IN %s +%s +` + query := fmt.Sprintf(queryTmpl, inClause, timeFilter) //nolint:gosec // G201: inClause contains only '?' placeholders from buildProjectInClause; timeFilter is a static string + + rows, goErr := data.SnowflakeDB.QueryContext(subtaskCtx.GetContext(), query, args...) + if goErr != nil { + return errors.Default.Wrap(goErr, "failed to query Snowflake for worklogs") + } + defer rows.Close() + + count := 0 + for rows.Next() { + var ( + worklogId string + issueId uint64 + timeSpentSeconds int + started time.Time + updated time.Time + ) + if scanErr := rows.Scan(&worklogId, &issueId, &timeSpentSeconds, &started, &updated); scanErr != nil { + return errors.Default.Wrap(scanErr, "failed to scan Snowflake worklog row") + } + + wl := &jiramodels.JiraWorklog{ + ConnectionId: connectionId, + IssueId: issueId, + WorklogId: worklogId, + TimeSpentSeconds: timeSpentSeconds, + Started: started, + Updated: updated, + } + if dbErr := db.CreateOrUpdate(wl); dbErr != nil { + return dbErr + } + count++ + } + if goErr = rows.Err(); goErr != nil { + return errors.Default.Wrap(goErr, "Snowflake worklog rows iteration error") + } + logger.Info("finished syncing %d worklogs", count) + return nil +} diff --git a/backend/plugins/jira_snowflake/tasks/task_data.go b/backend/plugins/jira_snowflake/tasks/task_data.go new file mode 100644 index 00000000000..16a4be7e4b6 --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/task_data.go @@ -0,0 +1,132 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "crypto/rsa" + "crypto/x509" + "database/sql" + "encoding/pem" + "fmt" + + "github.com/apache/devlake/core/errors" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + jiramodels "github.com/apache/devlake/plugins/jira/models" + sf "github.com/snowflakedb/gosnowflake" +) + +// JiraSnowflakeOptions contains all per-pipeline task options. +type JiraSnowflakeOptions struct { + ConnectionId uint64 `json:"connectionId" mapstructure:"connectionId"` + BoardId uint64 `json:"boardId" mapstructure:"boardId"` + // ProjectKeys lists all Jira project keys that belong to this board, + // e.g. ["KONFLUX", "HELM"]. Required because a board may span multiple projects. + ProjectKeys []string `json:"projectKeys" mapstructure:"projectKeys"` + ScopeConfigId uint64 `json:"scopeConfigId" mapstructure:"scopeConfigId"` + ScopeConfig *jiramodels.JiraScopeConfig `json:"scopeConfig" mapstructure:"scopeConfig"` +} + +// JiraSnowflakeTaskData is passed to every subtask via taskCtx.GetData(). +type JiraSnowflakeTaskData struct { + Options *JiraSnowflakeOptions + SnowflakeDB *sql.DB +} + +// JiraApiParams mirrors jira/models.JiraApiParams so that RawDataSubTaskArgs +// produces the same _raw_data_params format for state management. +type JiraApiParams struct { + ConnectionId uint64 + BoardId uint64 +} + +// DecodeAndValidateTaskOptions decodes and validates options for the task. +func DecodeAndValidateTaskOptions(options map[string]interface{}) (*JiraSnowflakeOptions, errors.Error) { + var op JiraSnowflakeOptions + if err := helper.Decode(options, &op, nil); err != nil { + return nil, err + } + if op.ConnectionId == 0 { + return nil, errors.BadInput.New(fmt.Sprintf("invalid connectionId: %d", op.ConnectionId)) + } + if op.BoardId == 0 { + return nil, errors.BadInput.New(fmt.Sprintf("invalid boardId: %d", op.BoardId)) + } + if len(op.ProjectKeys) == 0 { + return nil, errors.BadInput.New("projectKeys must not be empty") + } + return &op, nil +} + +// OpenSnowflakeDB opens a database/sql connection to Snowflake. +// +// authType controls authentication: +// - "keypair" (default): JWT key-pair auth using privateKeyPEM. Works in containers and CI. +// - "externalbrowser": SSO via browser pop-up. Only works when DevLake runs on a desktop host +// (i.e. via `make run`, not inside a Docker container). +func OpenSnowflakeDB(account, user, authType, privateKeyPEM, database, schema, warehouse, role string) (*sql.DB, errors.Error) { + cfg := &sf.Config{ + Account: account, + User: user, + Database: database, + Schema: schema, + } + if warehouse != "" { + cfg.Warehouse = warehouse + } + if role != "" { + cfg.Role = role + } + + if authType == "externalbrowser" { + cfg.Authenticator = sf.AuthTypeExternalBrowser + } else { + privKey, err := parseRSAPrivateKey(privateKeyPEM) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to parse Snowflake private key") + } + cfg.Authenticator = sf.AuthTypeJwt + cfg.PrivateKey = privKey + } + + dsn, goErr := sf.DSN(cfg) + if goErr != nil { + return nil, errors.Default.Wrap(goErr, "failed to build Snowflake DSN") + } + db, goErr := sql.Open("snowflake", dsn) + if goErr != nil { + return nil, errors.Default.Wrap(goErr, "failed to open Snowflake connection") + } + return db, nil +} + +// parseRSAPrivateKey parses a PKCS#8 PEM-encoded RSA private key. +func parseRSAPrivateKey(pemStr string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block from private key") + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS8 private key: %w", err) + } + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key is not an RSA key") + } + return rsaKey, nil +} diff --git a/backend/plugins/jira_snowflake/tasks/task_data_test.go b/backend/plugins/jira_snowflake/tasks/task_data_test.go new file mode 100644 index 00000000000..42e7c8ef16c --- /dev/null +++ b/backend/plugins/jira_snowflake/tasks/task_data_test.go @@ -0,0 +1,148 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// DecodeAndValidateTaskOptions +// --------------------------------------------------------------------------- + +func TestDecodeAndValidateTaskOptions_Valid(t *testing.T) { + opts, err := DecodeAndValidateTaskOptions(map[string]interface{}{ + "connectionId": uint64(1), + "boardId": uint64(42), + "projectKeys": []string{"KONFLUX", "HELM"}, + }) + require.Nil(t, err) + assert.Equal(t, uint64(1), opts.ConnectionId) + assert.Equal(t, uint64(42), opts.BoardId) + assert.Equal(t, []string{"KONFLUX", "HELM"}, opts.ProjectKeys) +} + +func TestDecodeAndValidateTaskOptions_MissingConnectionId(t *testing.T) { + _, err := DecodeAndValidateTaskOptions(map[string]interface{}{ + "boardId": uint64(42), + "projectKeys": []string{"KONFLUX"}, + }) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "connectionId") +} + +func TestDecodeAndValidateTaskOptions_MissingBoardId(t *testing.T) { + _, err := DecodeAndValidateTaskOptions(map[string]interface{}{ + "connectionId": uint64(1), + "projectKeys": []string{"KONFLUX"}, + }) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "boardId") +} + +func TestDecodeAndValidateTaskOptions_EmptyProjectKeys(t *testing.T) { + _, err := DecodeAndValidateTaskOptions(map[string]interface{}{ + "connectionId": uint64(1), + "boardId": uint64(42), + "projectKeys": []string{}, + }) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "projectKeys") +} + +func TestDecodeAndValidateTaskOptions_MissingProjectKeys(t *testing.T) { + _, err := DecodeAndValidateTaskOptions(map[string]interface{}{ + "connectionId": uint64(1), + "boardId": uint64(42), + }) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "projectKeys") +} + +// --------------------------------------------------------------------------- +// parseRSAPrivateKey +// --------------------------------------------------------------------------- + +func generateTestPKCS8PEM(t *testing.T) string { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + der, err := x509.MarshalPKCS8PrivateKey(key) + require.NoError(t, err) + block := &pem.Block{Type: "PRIVATE KEY", Bytes: der} + return string(pem.EncodeToMemory(block)) +} + +func TestParseRSAPrivateKey_ValidPKCS8(t *testing.T) { + pemStr := generateTestPKCS8PEM(t) + key, err := parseRSAPrivateKey(pemStr) + require.NoError(t, err) + assert.NotNil(t, key) + assert.Equal(t, 2048, key.N.BitLen()) +} + +func TestParseRSAPrivateKey_EmptyString(t *testing.T) { + _, err := parseRSAPrivateKey("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "PEM") +} + +func TestParseRSAPrivateKey_InvalidPEM(t *testing.T) { + _, err := parseRSAPrivateKey("not a pem block") + assert.Error(t, err) + assert.Contains(t, err.Error(), "PEM") +} + +func TestParseRSAPrivateKey_WrongKeyType(t *testing.T) { + // PKCS#1 format (BEGIN RSA PRIVATE KEY) is not PKCS#8, should fail ParsePKCS8PrivateKey + key, genErr := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, genErr) + block := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + } + pemStr := string(pem.EncodeToMemory(block)) + + _, err := parseRSAPrivateKey(pemStr) + assert.Error(t, err, "PKCS#1 key should be rejected; expected PKCS#8") +} + +// --------------------------------------------------------------------------- +// stringVal +// --------------------------------------------------------------------------- + +func TestStringVal(t *testing.T) { + t.Run("nil pointer returns empty string", func(t *testing.T) { + assert.Equal(t, "", stringVal(nil)) + }) + t.Run("non-nil pointer returns value", func(t *testing.T) { + s := "hello" + assert.Equal(t, "hello", stringVal(&s)) + }) + t.Run("empty string pointer returns empty string", func(t *testing.T) { + s := "" + assert.Equal(t, "", stringVal(&s)) + }) +} diff --git a/backend/plugins/table_info_test.go b/backend/plugins/table_info_test.go index faea9f2ad7e..e309db29b0b 100644 --- a/backend/plugins/table_info_test.go +++ b/backend/plugins/table_info_test.go @@ -22,6 +22,9 @@ import ( "github.com/apache/devlake/helpers/unithelper" ae "github.com/apache/devlake/plugins/ae/impl" + agentready "github.com/apache/devlake/plugins/agentready/impl" + aireview "github.com/apache/devlake/plugins/aireview/impl" + aisdlc "github.com/apache/devlake/plugins/aisdlc/impl" argocd "github.com/apache/devlake/plugins/argocd/impl" asana "github.com/apache/devlake/plugins/asana/impl" azuredevops "github.com/apache/devlake/plugins/azuredevops_go/impl" @@ -31,6 +34,7 @@ import ( circleci "github.com/apache/devlake/plugins/circleci/impl" claudeCode "github.com/apache/devlake/plugins/claude_code/impl" clickup "github.com/apache/devlake/plugins/clickup/impl" + codecov "github.com/apache/devlake/plugins/codecov/impl" cursor "github.com/apache/devlake/plugins/cursor/impl" customize "github.com/apache/devlake/plugins/customize/impl" dora "github.com/apache/devlake/plugins/dora/impl" @@ -47,6 +51,7 @@ import ( issueTrace "github.com/apache/devlake/plugins/issue_trace/impl" jenkins "github.com/apache/devlake/plugins/jenkins/impl" jira "github.com/apache/devlake/plugins/jira/impl" + jiraSnowflake "github.com/apache/devlake/plugins/jira_snowflake/impl" kiro "github.com/apache/devlake/plugins/kiro/impl" linear "github.com/apache/devlake/plugins/linear/impl" linker "github.com/apache/devlake/plugins/linker/impl" @@ -64,6 +69,7 @@ import ( teambition "github.com/apache/devlake/plugins/teambition/impl" tempo "github.com/apache/devlake/plugins/tempo/impl" testmo "github.com/apache/devlake/plugins/testmo/impl" + testregistry "github.com/apache/devlake/plugins/testregistry/impl" trello "github.com/apache/devlake/plugins/trello/impl" webhook "github.com/apache/devlake/plugins/webhook/impl" youtrack "github.com/apache/devlake/plugins/youtrack/impl" @@ -75,6 +81,9 @@ func Test_GetPluginTablesInfo(t *testing.T) { checker := unithelper.NewTableInfoChecker(unithelper.TableInfoCheckerConfig{ ValidatePluginCount: true, }) + checker.FeedIn("agentready/models", agentready.AgentReady{}.GetTablesInfo) + checker.FeedIn("aireview/models", aireview.AiReview{}.GetTablesInfo) + checker.FeedIn("aisdlc/models", aisdlc.Aisdlc{}.GetTablesInfo) checker.FeedIn("ae/models", ae.AE{}.GetTablesInfo) checker.FeedIn("azuredevops_go/models", azuredevops.Azuredevops{}.GetTablesInfo) checker.FeedIn("bamboo/models", bamboo.Bamboo{}.GetTablesInfo) @@ -95,6 +104,7 @@ func Test_GetPluginTablesInfo(t *testing.T) { checker.FeedIn("incidentio/models", incidentio.Incidentio{}.GetTablesInfo) checker.FeedIn("jenkins/models", jenkins.Jenkins{}.GetTablesInfo) checker.FeedIn("jira/models", jira.Jira{}.GetTablesInfo) + checker.FeedIn("jira_snowflake/models", jiraSnowflake.JiraSnowflake{}.GetTablesInfo) checker.FeedIn("linear/models", linear.Linear{}.GetTablesInfo) checker.FeedIn("org", org.Org{}.GetTablesInfo) checker.FeedIn("pagerduty/models", pagerduty.PagerDuty{}.GetTablesInfo) @@ -108,6 +118,7 @@ func Test_GetPluginTablesInfo(t *testing.T) { checker.FeedIn("teambition/models", teambition.Teambition{}.GetTablesInfo) checker.FeedIn("tempo/models", tempo.Tempo{}.GetTablesInfo) checker.FeedIn("testmo/models", testmo.Testmo{}.GetTablesInfo) + checker.FeedIn("testregistry/models", testregistry.TestRegistry{}.GetTablesInfo) checker.FeedIn("trello/models", trello.Trello{}.GetTablesInfo) checker.FeedIn("webhook/models", webhook.Webhook{}.GetTablesInfo) checker.FeedIn("youtrack/models", youtrack.Youtrack{}.GetTablesInfo) @@ -116,6 +127,7 @@ func Test_GetPluginTablesInfo(t *testing.T) { checker.FeedIn("cursor/models", cursor.Cursor{}.GetTablesInfo) checker.FeedIn("circleci/models", circleci.Circleci{}.GetTablesInfo) checker.FeedIn("clickup/models", clickup.ClickUp{}.GetTablesInfo) + checker.FeedIn("codecov/models", codecov.Codecov{}.GetTablesInfo) checker.FeedIn("opsgenie/models", opsgenie.Opsgenie{}.GetTablesInfo) checker.FeedIn("linker/models", linker.Linker{}.GetTablesInfo) checker.FeedIn("issue_trace/models", issueTrace.IssueTrace{}.GetTablesInfo) diff --git a/backend/plugins/testregistry/AGENTS.md b/backend/plugins/testregistry/AGENTS.md new file mode 100644 index 00000000000..a770878ade0 --- /dev/null +++ b/backend/plugins/testregistry/AGENTS.md @@ -0,0 +1,57 @@ +# testregistry Plugin — Agent Context + +Data-source plugin that collects CI test results from Openshift CI (Prow) and Tekton CI, fetches JUnit XML artifacts from GCS/Quay/ORAS, and stores normalized test suites/cases for quality tracking dashboards. + +## Build & Test + +```bash +cd backend +go test ./plugins/testregistry/... -v # unit tests +golangci-lint run ./plugins/testregistry/... # lint +``` + +Single-file verification: `go vet ./plugins/testregistry/...` + +No e2e tests yet — Prow/GCS integration tested via unit tests and manual runs. + +## Layout + +- `impl/impl.go` — plugin interfaces (PluginSource, DataSourcePluginBlueprintV200) +- `models/` — connection, scope, scope_config, ci_job, test_suite, test_case, tekton_task + `migrationscripts/register.go` +- `tasks/prow_collector.go` — Prow job collection with retry logic (502/503/504/429) +- `tasks/tekton_collector.go` — Tekton pipeline run collection +- `tasks/gcs_client.go` — GCS bucket access for JUnit XML artifacts +- `tasks/quay_client.go` — Quay.io ORAS artifact access +- `tasks/junit-processor.go` — JUnit XML parsing +- `tasks/task_data.go` — options, task data, JUnit regex configuration +- `api/` — REST endpoints (connections, scopes, scope-configs, remote-scopes) + +## Conventions + +- Connection model has `CITool` field: `"Openshift CI"` or `"Tekton CI"` — collectors check this and skip if wrong type +- JUnit regex is configurable per-connection (`JUnitRegex` field) with a compiled default +- Prow API returns all jobs; filtering by org/repo happens client-side in `matchesScope()` +- Prow retries: 5 attempts with exponential backoff (10s base) for transient HTTP errors +- GitHub token in connection is encrypted via `serializer:encdec` tag + +## Don'ts + +- Don't add models to `GetTablesInfo()` without a migration script in `migrationscripts/register.go` +- Don't import from other plugins (plugins must be independent) +- Don't skip the Apache 2.0 license header on new files +- Don't store secrets in plain text — use `serializer:encdec` gorm tag +- Don't call Prow API without retry logic — the endpoint is unreliable + +## Pattern References + +| Change Type | Example File | +|---|---| +| Add new CI source | `tasks/tekton_collector.go` (follow Prow pattern) | +| Add migration | `models/migrationscripts/20250113_add_junit_regex_column.go` | +| Add model | `models/test_case.go` + migration + update `GetTablesInfo()` | +| Add API endpoint | `api/connection.go`, register in `impl/impl.go:ApiResources()` | +| Add artifact source | `tasks/quay_client.go` (follow GCS client pattern) | + +## Skills + +- **PR Definition of Done**: see [skills/pr-definition-of-done/SKILL.md](skills/pr-definition-of-done/SKILL.md) diff --git a/backend/plugins/testregistry/api/blueprint_v200.go b/backend/plugins/testregistry/api/blueprint_v200.go new file mode 100644 index 00000000000..82f4ce02485 --- /dev/null +++ b/backend/plugins/testregistry/api/blueprint_v200.go @@ -0,0 +1,116 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/plugin" + helperapi "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/helpers/srvhelper" + "github.com/apache/devlake/plugins/testregistry/models" + "github.com/apache/devlake/plugins/testregistry/tasks" +) + +// MakeDataSourcePipelinePlanV200Impl is the actual implementation +func MakeDataSourcePipelinePlanV200Impl( + subtaskMetas []plugin.SubTaskMeta, + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + connection, err := dsHelper.ConnSrv.FindByPk(connectionId) + if err != nil { + return nil, nil, err + } + scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes) + if err != nil { + return nil, nil, err + } + plan, err := makePipelinePlanV200(subtaskMetas, scopeDetails, connection) + if err != nil { + return nil, nil, err + } + scopes, err := makeScopesV200(scopeDetails, connection) + return plan, scopes, err +} + +func makePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + scopeDetails []*srvhelper.ScopeDetail[models.TestRegistryScope, models.TestRegistryScopeConfig], + connection *models.TestRegistryConnection, +) (coreModels.PipelinePlan, errors.Error) { + plan := make(coreModels.PipelinePlan, len(scopeDetails)) + for i, scopeDetail := range scopeDetails { + stage := plan[i] + if stage == nil { + stage = coreModels.PipelineStage{} + } + + scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig + + // Determine which entities to collect based on CI tool type + // Default to CICD domain type for testregistry plugin + entities := []string{plugin.DOMAIN_TYPE_CICD} + + if connection.CITool == models.CIToolOpenshiftCI { + // For Openshift CI, collect Prow jobs (CICD domain) + if scopeConfig != nil && len(scopeConfig.Entities) > 0 { + entities = scopeConfig.Entities + } + } else if connection.CITool == models.CIToolTektonCI { + // For Tekton CI, collect OCI artifacts (to be implemented) + if scopeConfig != nil && len(scopeConfig.Entities) > 0 { + entities = scopeConfig.Entities + } + } + + // construct task options for testregistry + task, err := helperapi.MakePipelinePlanTask( + "testregistry", + subtaskMetas, + entities, + tasks.TestRegistryOptions{ + ConnectionId: connection.ID, + FullName: scope.FullName, + ScopeConfig: scopeConfig, + }, + ) + if err != nil { + return nil, err + } + stage = append(stage, task) + plan[i] = stage + } + + return plan, nil +} + +func makeScopesV200( + scopeDetails []*srvhelper.ScopeDetail[models.TestRegistryScope, models.TestRegistryScopeConfig], + connection *models.TestRegistryConnection, +) ([]plugin.Scope, errors.Error) { + scopes := make([]plugin.Scope, 0, len(scopeDetails)) + + for _, scopeDetail := range scopeDetails { + scope := scopeDetail.Scope + // Return the TestRegistryScope itself as it implements plugin.Scope + scopes = append(scopes, scope) + } + + return scopes, nil +} diff --git a/backend/plugins/testregistry/api/connection.go b/backend/plugins/testregistry/api/connection.go new file mode 100644 index 00000000000..db1825b44ae --- /dev/null +++ b/backend/plugins/testregistry/api/connection.go @@ -0,0 +1,335 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + gocontext "context" + "fmt" + "net/http" + "net/url" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/testregistry/models" +) + +// PostConnections +// @Summary create testregistry connection +// @Description Create testregistry connection +// @Tags plugins/testregistry +// @Param body body models.TestRegistryConnection true "json body" +// @Success 200 {object} models.TestRegistryConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/testregistry/connections [POST] +func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Post(input) +} + +// PatchConnection +// @Summary patch testregistry connection +// @Description Patch testregistry connection +// @Tags plugins/testregistry +// @Param body body models.TestRegistryConnection true "json body" +// @Success 200 {object} models.TestRegistryConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/testregistry/connections/{connectionId} [PATCH] +func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Patch(input) +} + +// DeleteConnection +// @Summary delete a testregistry connection +// @Description Delete a testregistry connection +// @Tags plugins/testregistry +// @Success 200 {object} models.TestRegistryConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/testregistry/connections/{connectionId} [DELETE] +func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.Delete(input) +} + +// ListConnections +// @Summary get all testregistry connections +// @Description Get all testregistry connections +// @Tags plugins/testregistry +// @Success 200 {object} []models.TestRegistryConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/testregistry/connections [GET] +func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetAll(input) +} + +// GetConnection +// @Summary get testregistry connection detail +// @Description Get testregistry connection detail +// @Tags plugins/testregistry +// @Success 200 {object} models.TestRegistryConnection +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/testregistry/connections/{connectionId} [GET] +func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ConnApi.GetDetail(input) +} + +// TestConnection test testregistry connection +// @Summary test testregistry connection +// @Description Test testregistry connection by pinging Quay.io API (Tekton CI) or GitHub API (Openshift CI) +// @Tags plugins/testregistry +// @Param body body models.TestRegistryConnection true "json body" +// @Success 200 {object} shared.ApiBody +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/testregistry/test [POST] +func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + // input.Body is already decoded as map[string]interface{} by the framework + // However, frontend pick() might exclude custom fields, so we check both input.Body and try struct decode + bodyMap := input.Body + if bodyMap == nil { + bodyMap = make(map[string]interface{}) + } + + // Also try struct decode to see if we can get fields that way + var conn models.TestRegistryConnection + if err := api.Decode(input.Body, &conn, nil); err == nil { + // Merge struct values into bodyMap (only if not already present) + if ciToolVal, ok := bodyMap["ciTool"].(string); !ok || ciToolVal == "" { + if conn.CITool != "" { + bodyMap["ciTool"] = conn.CITool + } + } + if quayOrgVal, ok := bodyMap["quayOrganization"].(string); !ok || quayOrgVal == "" { + if conn.QuayOrganization != "" { + bodyMap["quayOrganization"] = conn.QuayOrganization + } + } + if githubOrgVal, ok := bodyMap["githubOrganization"].(string); !ok || githubOrgVal == "" { + if conn.GitHubOrganization != "" { + bodyMap["githubOrganization"] = conn.GitHubOrganization + } + } + if githubTokenVal, ok := bodyMap["githubToken"].(string); !ok || githubTokenVal == "" { + if conn.GitHubToken != "" { + bodyMap["githubToken"] = conn.GitHubToken + } + } + } + + // Log what we received for debugging + if basicRes != nil { + logger := basicRes.GetLogger() + logger.Info("TestConnection received body fields: %v", bodyMap) + } + + // Extract fields with type assertions + ciTool, _ := bodyMap["ciTool"].(string) + quayOrg, _ := bodyMap["quayOrganization"].(string) + githubOrg, _ := bodyMap["githubOrganization"].(string) + githubToken, _ := bodyMap["githubToken"].(string) + + // Provide helpful error if ciTool is missing + if ciTool == "" { + // Check if any fields are present to provide better error message + hasAnyField := len(bodyMap) > 0 + if hasAnyField { + return nil, errors.BadInput.New("ciTool field is missing from request. The frontend may not be sending custom fields. Please try saving the connection first, then test it using the 'Test' button from the connection list.") + } + return nil, errors.BadInput.New("ciTool field is required. Please ensure all form fields are filled correctly.") + } + + var testErr errors.Error + var successMsg string + + // Test based on CI tool type + switch ciTool { + case models.CIToolTektonCI: + if quayOrg == "" { + return nil, errors.BadInput.New("quayOrganization is required for Tekton CI") + } + testErr = testQuayConnection(gocontext.TODO(), quayOrg) + if testErr == nil { + successMsg = fmt.Sprintf("Successfully connected to Quay.io organization: %s", quayOrg) + } + case models.CIToolOpenshiftCI: + if githubOrg == "" { + return nil, errors.BadInput.New("githubOrganization is required for Openshift CI") + } + if githubToken == "" { + return nil, errors.BadInput.New("githubToken is required for Openshift CI") + } + testErr = testGitHubConnection(gocontext.TODO(), githubOrg, githubToken) + if testErr == nil { + successMsg = fmt.Sprintf("Successfully connected to GitHub organization: %s", githubOrg) + } + default: + return nil, errors.BadInput.New(fmt.Sprintf("invalid ciTool: %s. Must be 'Openshift CI' or 'Tekton CI'", ciTool)) + } + + if testErr != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, testErr) + } + + return &plugin.ApiResourceOutput{ + Body: map[string]interface{}{ + "success": true, + "message": successMsg, + }, + Status: http.StatusOK, + }, nil +} + +// TestExistingConnection test existing testregistry connection +// @Summary test existing testregistry connection +// @Description Test existing testregistry connection by pinging Quay.io API (Tekton CI) or GitHub API (Openshift CI) +// @Tags plugins/testregistry +// @Param connectionId path int true "connection ID" +// @Success 200 {object} shared.ApiBody +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/testregistry/connections/{connectionId}/test [POST] +func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection, err := dsHelper.ConnApi.GetMergedConnection(input) + if err != nil { + return nil, errors.Convert(err) + } + + var testErr errors.Error + var successMsg string + + // Test based on CI tool type + switch connection.CITool { + case models.CIToolTektonCI: + if connection.QuayOrganization == "" { + return nil, errors.BadInput.New("quayOrganization is required for Tekton CI") + } + testErr = testQuayConnection(gocontext.TODO(), connection.QuayOrganization) + if testErr == nil { + successMsg = fmt.Sprintf("Successfully connected to Quay.io organization: %s", connection.QuayOrganization) + } + case models.CIToolOpenshiftCI: + if connection.GitHubOrganization == "" { + return nil, errors.BadInput.New("githubOrganization is required for Openshift CI") + } + if connection.GitHubToken == "" { + return nil, errors.BadInput.New("githubToken is required for Openshift CI") + } + testErr = testGitHubConnection(gocontext.TODO(), connection.GitHubOrganization, connection.GitHubToken) + if testErr == nil { + successMsg = fmt.Sprintf("Successfully connected to GitHub organization: %s", connection.GitHubOrganization) + } + default: + return nil, errors.BadInput.New(fmt.Sprintf("invalid ciTool: %s. Must be 'Openshift CI' or 'Tekton CI'", connection.CITool)) + } + + if testErr != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, testErr) + } + + return &plugin.ApiResourceOutput{ + Body: map[string]interface{}{ + "success": true, + "message": successMsg, + }, + Status: http.StatusOK, + }, nil +} + +// testQuayConnection pings Quay.io API to verify the organization is accessible +func testQuayConnection(ctx gocontext.Context, quayOrganization string) errors.Error { + // Create API client for Quay.io + apiClient, err := api.NewApiClient(ctx, "https://quay.io", nil, 0, "", basicRes) + if err != nil { + return errors.Default.Wrap(err, "failed to create API client") + } + + // Ping Quay.io by trying to list repositories for the organization + apiURL := "/api/v1/repository" + queryParams := url.Values{} + queryParams.Set("namespace", quayOrganization) + queryParams.Set("public", "true") + queryParams.Set("limit", "1") // Only need to check if the request succeeds + + resp, err := apiClient.Get(apiURL, queryParams, nil) + if err != nil { + return errors.Default.Wrap(err, fmt.Sprintf("failed to connect to Quay.io organization '%s'", quayOrganization)) + } + + // Check response status + if resp.StatusCode == http.StatusNotFound { + return errors.BadInput.New(fmt.Sprintf("Quay.io organization '%s' not found or not accessible", quayOrganization)) + } + + if resp.StatusCode != http.StatusOK { + return errors.Default.New(fmt.Sprintf("Quay.io API returned status %d for organization '%s'", resp.StatusCode, quayOrganization)) + } + + return nil +} + +// testGitHubConnection pings GitHub API to verify the organization and token are valid +func testGitHubConnection(ctx gocontext.Context, githubOrganization, githubToken string) errors.Error { + // Create API client for GitHub + apiClient, err := api.NewApiClient(ctx, "https://api.github.com", nil, 0, "", basicRes) + if err != nil { + return errors.Default.Wrap(err, "failed to create API client") + } + + // Set authentication header + apiClient.SetHeaders(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", githubToken), + }) + + // Test token validity by getting authenticated user + userResp, err := apiClient.Get("user", nil, nil) + if err != nil { + return errors.Default.Wrap(err, "failed to authenticate with GitHub token") + } + + if userResp.StatusCode == http.StatusUnauthorized { + return errors.BadInput.New("GitHub token is invalid or expired") + } + + if userResp.StatusCode != http.StatusOK { + return errors.Default.New(fmt.Sprintf("GitHub API returned status %d while testing token", userResp.StatusCode)) + } + + // Verify organization access by trying to get organization info + orgURL := fmt.Sprintf("orgs/%s", githubOrganization) + orgResp, err := apiClient.Get(orgURL, nil, nil) + if err != nil { + return errors.Default.Wrap(err, fmt.Sprintf("failed to access GitHub organization '%s'", githubOrganization)) + } + + if orgResp.StatusCode == http.StatusNotFound { + return errors.BadInput.New(fmt.Sprintf("GitHub organization '%s' not found", githubOrganization)) + } + + if orgResp.StatusCode == http.StatusForbidden { + return errors.BadInput.New(fmt.Sprintf("GitHub token does not have access to organization '%s'", githubOrganization)) + } + + if orgResp.StatusCode != http.StatusOK { + return errors.Default.New(fmt.Sprintf("GitHub API returned status %d for organization '%s'", orgResp.StatusCode, githubOrganization)) + } + + return nil +} diff --git a/backend/plugins/testregistry/api/init.go b/backend/plugins/testregistry/api/init.go new file mode 100644 index 00000000000..68499c0e1ca --- /dev/null +++ b/backend/plugins/testregistry/api/init.go @@ -0,0 +1,53 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/testregistry/models" +) + +const pluginName = "testregistry" + +var basicRes context.BasicRes + +var dsHelper *api.DsHelper[models.TestRegistryConnection, models.TestRegistryScope, models.TestRegistryScopeConfig] + +// connectionHelper is used by the push endpoint to look up connections by ID or name. +var connectionHelper *api.ConnectionApiHelper + +func Init(br context.BasicRes, p plugin.PluginMeta) { + basicRes = br + connectionHelper = api.NewConnectionHelper(br, nil, p.Name()) + + // Use DataSourceHelper for scope config support + dsHelper = api.NewDataSourceHelper[ + models.TestRegistryConnection, models.TestRegistryScope, models.TestRegistryScopeConfig, + ]( + br, + p.Name(), + []string{"fullName"}, // Use fullName as unique identifier for scopes + func(c models.TestRegistryConnection) models.TestRegistryConnection { + return c // No sanitization needed for now + }, + nil, + nil, + ) +} diff --git a/backend/plugins/testregistry/api/push.go b/backend/plugins/testregistry/api/push.go new file mode 100644 index 00000000000..91cf362f21c --- /dev/null +++ b/backend/plugins/testregistry/api/push.go @@ -0,0 +1,330 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/dbhelper" + "github.com/apache/devlake/plugins/testregistry/models" + "github.com/apache/devlake/plugins/testregistry/tasks" +) + +const maxJUnitFilesPerRequest = 100 + +// PostTestResults handles pushing CI test results via the testregistry plugin by connection ID. +// Accepts multipart/form-data with job metadata as form fields and JUnit XML as file uploads. +func PostTestResults(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.TestRegistryConnection{} + err := connectionHelper.First(connection, input.Params) + if err != nil { + return nil, err + } + return postTestResultsImpl(input, connection.ID) +} + +// PostTestResultsByName handles pushing CI test results via the testregistry plugin by connection name. +func PostTestResultsByName(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.TestRegistryConnection{} + err := connectionHelper.FirstByName(connection, input.Params) + if err != nil { + return nil, err + } + return postTestResultsImpl(input, connection.ID) +} + +func postTestResultsImpl(input *plugin.ApiResourceInput, connectionId uint64) (*plugin.ApiResourceOutput, errors.Error) { + if input.Request == nil { + return nil, errors.BadInput.New("request must be multipart/form-data with job metadata as form fields and JUnit XML as file uploads (field name: junit)") + } + + if err := input.Request.ParseMultipartForm(32 << 20); err != nil { + return nil, errors.BadInput.Wrap(err, "failed to parse multipart form") + } + + // Read required form fields + jobId := input.Request.FormValue("jobId") + jobName := input.Request.FormValue("jobName") + organization := input.Request.FormValue("organization") + repository := input.Request.FormValue("repository") + result := input.Request.FormValue("result") + + if jobId == "" || jobName == "" || organization == "" || repository == "" || result == "" { + return nil, errors.BadInput.New("required form fields: jobId, jobName, organization, repository, result") + } + + // Validate field lengths to prevent DB column overflow (job_id is varchar(255), job_name is varchar(500)) + domainJobId := fmt.Sprintf("testregistry:%d:%s", connectionId, jobId) + if len(domainJobId) > 255 { + return nil, errors.BadInput.New("jobId is too long; the prefixed ID must fit in 255 characters") + } + + // Read optional form fields + jobType := input.Request.FormValue("jobType") + commitSha := input.Request.FormValue("commitSha") + pullRequestAuthor := input.Request.FormValue("pullRequestAuthor") + triggerType := input.Request.FormValue("triggerType") + viewUrl := input.Request.FormValue("viewUrl") + scopeId := input.Request.FormValue("scopeId") + + var pullRequestNumber *int + if prStr := input.Request.FormValue("pullRequestNumber"); prStr != "" { + prNum, parseErr := strconv.Atoi(prStr) + if parseErr != nil { + return nil, errors.BadInput.New(fmt.Sprintf("pullRequestNumber must be a valid integer, got %q", prStr)) + } + pullRequestNumber = &prNum + } + + var startedAt, finishedAt *time.Time + if s := input.Request.FormValue("startedAt"); s != "" { + t, parseErr := common.ConvertStringToTime(s) + if parseErr != nil { + return nil, errors.BadInput.New(fmt.Sprintf("startedAt must be a valid ISO 8601 timestamp, got %q", s)) + } + startedAt = &t + } + if s := input.Request.FormValue("finishedAt"); s != "" { + t, parseErr := common.ConvertStringToTime(s) + if parseErr != nil { + return nil, errors.BadInput.New(fmt.Sprintf("finishedAt must be a valid ISO 8601 timestamp, got %q", s)) + } + finishedAt = &t + } + + var durationSec *float64 + if s := input.Request.FormValue("durationSec"); s != "" { + d, parseErr := strconv.ParseFloat(s, 64) + if parseErr != nil { + return nil, errors.BadInput.New(fmt.Sprintf("durationSec must be a valid number, got %q", s)) + } + durationSec = &d + } else if startedAt != nil && finishedAt != nil { + d := finishedAt.Sub(*startedAt).Seconds() + durationSec = &d + } + + if scopeId == "" { + scopeId = repository + } + + // Enforce file count limit + junitFiles := input.Request.MultipartForm.File["junit"] + if len(junitFiles) > maxJUnitFilesPerRequest { + return nil, errors.BadInput.New(fmt.Sprintf("too many JUnit files; maximum is %d", maxJUnitFilesPerRequest)) + } + + // Clean up multipart temp files when done + defer func() { + if input.Request.MultipartForm != nil { + _ = input.Request.MultipartForm.RemoveAll() + } + }() + + // Wrap all writes in a transaction for consistency + var err errors.Error + txHelper := dbhelper.NewTxHelper(basicRes, &err) + defer txHelper.End() + db := txHelper.Begin() + + // Delete existing suites and cases for this job to ensure idempotent retries + if delErr := db.Delete(&models.TestCase{}, dal.Where("connection_id = ? AND job_id = ?", connectionId, domainJobId)); delErr != nil { + err = errors.Default.Wrap(delErr, "failed to delete existing test cases") + return nil, err + } + if delErr := db.Delete(&models.TestSuite{}, dal.Where("connection_id = ? AND job_id = ?", connectionId, domainJobId)); delErr != nil { + err = errors.Default.Wrap(delErr, "failed to delete existing test suites") + return nil, err + } + + // Save CI job + ciJob := &models.TestRegistryCIJob{ + ConnectionId: connectionId, + JobId: domainJobId, + JobName: jobName, + JobType: jobType, + Organization: organization, + Repository: repository, + CommitSHA: commitSha, + PullRequestNumber: pullRequestNumber, + PullRequestAuthor: pullRequestAuthor, + TriggerType: triggerType, + Result: result, + StartedAt: startedAt, + FinishedAt: finishedAt, + DurationSec: durationSec, + ViewURL: viewUrl, + ScopeId: scopeId, + } + + if dbErr := db.CreateOrUpdate(ciJob); dbErr != nil { + err = errors.Default.Wrap(dbErr, "failed to save CI job") + return nil, err + } + + savedSuites := 0 + savedCases := 0 + + // Parse JUnit XML files from multipart uploads + for _, fileHeader := range junitFiles { + f, openErr := fileHeader.Open() + if openErr != nil { + err = errors.Default.Wrap(openErr, fmt.Sprintf("failed to open uploaded file %s", fileHeader.Filename)) + return nil, err + } + + xmlContent, readErr := io.ReadAll(io.LimitReader(f, 10*1024*1024)) + _ = f.Close() + if readErr != nil { + err = errors.Default.Wrap(readErr, fmt.Sprintf("failed to read uploaded file %s", fileHeader.Filename)) + return nil, err + } + + // Parse XML — try first, then bare + var suitesXml tasks.TestSuites + firstErr := xml.Unmarshal(xmlContent, &suitesXml) + + // Handle bare root element (or failed parse) + if len(suitesXml.Suites) == 0 { + var singleSuite tasks.TestSuite + if xmlErr := xml.Unmarshal(xmlContent, &singleSuite); xmlErr == nil && singleSuite.Name != "" { + suitesXml.Suites = []*tasks.TestSuite{&singleSuite} + } + } + + // Return error if file could not be parsed as JUnit XML + if len(suitesXml.Suites) == 0 { + errMsg := fmt.Sprintf("failed to parse JUnit XML from file %s", fileHeader.Filename) + if firstErr != nil { + err = errors.BadInput.Wrap(firstErr, errMsg) + } else { + err = errors.BadInput.New(errMsg + ": no test suites found") + } + return nil, err + } + + // Save each suite and its test cases + for _, suite := range suitesXml.Suites { + if suite == nil || suite.Name == "" { + continue + } + + suiteId, uidErr := generateUID() + if uidErr != nil { + err = uidErr + return nil, err + } + testSuite := &models.TestSuite{ + ConnectionId: connectionId, + JobId: domainJobId, + SuiteId: suiteId, + Name: suite.Name, + NumTests: suite.NumTests, + NumFailed: suite.NumFailed, + NumSkipped: suite.NumSkipped, + Duration: suite.Duration, + } + + if dbErr := db.CreateOrUpdate(testSuite); dbErr != nil { + err = errors.Default.Wrap(dbErr, fmt.Sprintf("failed to save test suite %s", suite.Name)) + return nil, err + } + savedSuites++ + + for _, tc := range suite.TestCases { + if tc == nil { + continue + } + + testCaseId, uidErr := generateUID() + if uidErr != nil { + err = uidErr + return nil, err + } + status := "passed" + var failureMsg, failureOut, skipMsg *string + + if tc.FailureOutput != nil { + status = "failed" + msg := tc.FailureOutput.Message + failureMsg = &msg + out := tc.FailureOutput.Output + failureOut = &out + } else if tc.SkipMessage != nil { + status = "skipped" + msg := tc.SkipMessage.Message + skipMsg = &msg + } + + testCase := &models.TestCase{ + ConnectionId: connectionId, + JobId: domainJobId, + SuiteId: suiteId, + TestCaseId: testCaseId, + Name: tc.Name, + Classname: tc.Classname, + Duration: tc.Duration, + Status: status, + FailureMessage: failureMsg, + FailureOutput: failureOut, + SkipMessage: skipMsg, + } + + if dbErr := db.CreateOrUpdate(testCase); dbErr != nil { + err = errors.Default.Wrap(dbErr, fmt.Sprintf("failed to save test case %s", tc.Name)) + return nil, err + } + savedCases++ + } + } + } + + return &plugin.ApiResourceOutput{ + Body: map[string]interface{}{ + "jobId": domainJobId, + "suitesSaved": savedSuites, + "casesSaved": savedCases, + "filesRead": len(junitFiles), + }, + Status: http.StatusOK, + }, nil +} + +// generateUID creates a random 16-char hex string for unique IDs. +func generateUID() (string, errors.Error) { + return generateUIDFrom(rand.Reader) +} + +func generateUIDFrom(r io.Reader) (string, errors.Error) { + b := make([]byte, 8) + if _, readErr := r.Read(b); readErr != nil { + return "", errors.Default.Wrap(readErr, "failed to generate random UID") + } + return hex.EncodeToString(b), nil +} diff --git a/backend/plugins/testregistry/api/push_test.go b/backend/plugins/testregistry/api/push_test.go new file mode 100644 index 00000000000..735472c4740 --- /dev/null +++ b/backend/plugins/testregistry/api/push_test.go @@ -0,0 +1,322 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "bytes" + "encoding/xml" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/testregistry/tasks" +) + +func TestParseJUnitXML_TestSuites(t *testing.T) { + xmlContent := []byte(` + + + + + Expected healthy cluster + + + +`) + + var suitesXml tasks.TestSuites + if err := xml.Unmarshal(xmlContent, &suitesXml); err != nil { + t.Fatalf("Failed to parse testsuites XML: %v", err) + } + + if len(suitesXml.Suites) != 1 { + t.Fatalf("Expected 1 suite, got %d", len(suitesXml.Suites)) + } + + suite := suitesXml.Suites[0] + if suite.Name != "step graph" { + t.Errorf("Suite name = %q, want %q", suite.Name, "step graph") + } + if suite.NumTests != 3 { + t.Errorf("Suite NumTests = %d, want 3", suite.NumTests) + } + if suite.NumFailed != 1 { + t.Errorf("Suite NumFailed = %d, want 1", suite.NumFailed) + } + if len(suite.TestCases) != 3 { + t.Fatalf("Expected 3 test cases, got %d", len(suite.TestCases)) + } + + if suite.TestCases[0].Name != "pre phase" { + t.Errorf("TestCase[0].Name = %q, want %q", suite.TestCases[0].Name, "pre phase") + } + if suite.TestCases[0].FailureOutput != nil { + t.Error("TestCase[0] should not have failure output") + } + + if suite.TestCases[1].FailureOutput == nil { + t.Fatal("TestCase[1] should have failure output") + } + if suite.TestCases[1].FailureOutput.Message != "test failed" { + t.Errorf("TestCase[1].FailureOutput.Message = %q, want %q", suite.TestCases[1].FailureOutput.Message, "test failed") + } +} + +func TestParseJUnitXML_BareTestSuite(t *testing.T) { + xmlContent := []byte(` + + + + + + +`) + + var suitesXml tasks.TestSuites + err := xml.Unmarshal(xmlContent, &suitesXml) + if err == nil && len(suitesXml.Suites) > 0 { + t.Fatal("Expected TestSuites parse to fail or return empty suites for bare ") + } + + var singleSuite tasks.TestSuite + if err := xml.Unmarshal(xmlContent, &singleSuite); err != nil { + t.Fatalf("Failed to parse bare testsuite XML: %v", err) + } + + if singleSuite.Name != "e2e-console" { + t.Errorf("Suite name = %q, want %q", singleSuite.Name, "e2e-console") + } + if singleSuite.NumTests != 3 { + t.Errorf("Suite NumTests = %d, want 3", singleSuite.NumTests) + } + if singleSuite.NumSkipped != 1 { + t.Errorf("Suite NumSkipped = %d, want 1", singleSuite.NumSkipped) + } + if len(singleSuite.TestCases) != 3 { + t.Fatalf("Expected 3 test cases, got %d", len(singleSuite.TestCases)) + } + + if singleSuite.TestCases[0].Classname != "DashboardPage" { + t.Errorf("TestCase[0].Classname = %q, want %q", singleSuite.TestCases[0].Classname, "DashboardPage") + } + + if singleSuite.TestCases[2].SkipMessage == nil { + t.Fatal("TestCase[2] should have skip message") + } + if singleSuite.TestCases[2].SkipMessage.Message != "GPU not available" { + t.Errorf("TestCase[2].SkipMessage = %q, want %q", singleSuite.TestCases[2].SkipMessage.Message, "GPU not available") + } +} + +func TestParseJUnitXML_EmptyTestSuites(t *testing.T) { + xmlContent := []byte(` + +`) + + var suitesXml tasks.TestSuites + if err := xml.Unmarshal(xmlContent, &suitesXml); err != nil { + t.Fatalf("Failed to parse empty testsuites XML: %v", err) + } + + if len(suitesXml.Suites) != 0 { + t.Errorf("Expected 0 suites, got %d", len(suitesXml.Suites)) + } +} + +func TestParseJUnitXML_InvalidXML(t *testing.T) { + xmlContent := []byte(`this is not XML at all`) + + var suitesXml tasks.TestSuites + err := xml.Unmarshal(xmlContent, &suitesXml) + if err == nil { + t.Error("Expected error parsing invalid XML") + } +} + +func TestParseJUnitXML_MultipleSuites(t *testing.T) { + xmlContent := []byte(` + + + + + + + + Connection refused + + +`) + + var suitesXml tasks.TestSuites + if err := xml.Unmarshal(xmlContent, &suitesXml); err != nil { + t.Fatalf("Failed to parse multi-suite XML: %v", err) + } + + if len(suitesXml.Suites) != 2 { + t.Fatalf("Expected 2 suites, got %d", len(suitesXml.Suites)) + } + + if suitesXml.Suites[0].Name != "unit" { + t.Errorf("Suite[0].Name = %q, want %q", suitesXml.Suites[0].Name, "unit") + } + if suitesXml.Suites[1].Name != "integration" { + t.Errorf("Suite[1].Name = %q, want %q", suitesXml.Suites[1].Name, "integration") + } + if suitesXml.Suites[1].TestCases[0].FailureOutput == nil { + t.Error("Suite[1].TestCase[0] should have failure output") + } +} + +func TestTestCaseStatusDetection(t *testing.T) { + xmlContent := []byte(` + + + + expected true, got false + + + + + +`) + + var suitesXml tasks.TestSuites + if err := xml.Unmarshal(xmlContent, &suitesXml); err != nil { + t.Fatalf("Failed to parse XML: %v", err) + } + + cases := suitesXml.Suites[0].TestCases + if len(cases) != 3 { + t.Fatalf("Expected 3 cases, got %d", len(cases)) + } + + if cases[0].FailureOutput != nil || cases[0].SkipMessage != nil { + t.Error("passed-test should have no failure/skip") + } + + if cases[1].FailureOutput == nil { + t.Fatal("failed-test should have failure output") + } + if cases[1].FailureOutput.Message != "assertion failed" { + t.Errorf("failure message = %q, want %q", cases[1].FailureOutput.Message, "assertion failed") + } + if cases[1].FailureOutput.Output != "expected true, got false" { + t.Errorf("failure output = %q, want %q", cases[1].FailureOutput.Output, "expected true, got false") + } + + if cases[2].SkipMessage == nil { + t.Fatal("skipped-test should have skip message") + } + if cases[2].SkipMessage.Message != "not ready" { + t.Errorf("skip message = %q, want %q", cases[2].SkipMessage.Message, "not ready") + } +} + +func TestMaxJUnitFilesConstant(t *testing.T) { + if maxJUnitFilesPerRequest != 100 { + t.Errorf("maxJUnitFilesPerRequest = %d, want 100", maxJUnitFilesPerRequest) + } +} + +type errorReader struct{} + +func (errorReader) Read(_ []byte) (int, error) { + return 0, fmt.Errorf("simulated rand failure") +} + +func TestGenerateUID_ReturnsErrorOnReadFailure(t *testing.T) { + _, err := generateUIDFrom(errorReader{}) + if err == nil { + t.Fatal("generateUIDFrom should return error when reader fails") + } + if !strings.Contains(err.Error(), "failed to generate random UID") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestGenerateUID_Success(t *testing.T) { + id, err := generateUID() + if err != nil { + t.Fatalf("generateUID() returned unexpected error: %v", err) + } + if len(id) != 16 { + t.Errorf("generateUID() length = %d, want 16", len(id)) + } + for _, c := range id { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("generateUID() contains non-hex character: %c in %s", c, id) + } + } +} + +// makeMultipartRequest builds a POST request with the given form fields. +func makeMultipartRequest(t *testing.T, fields map[string]string) *http.Request { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("WriteField(%q): %v", k, err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("multipart.Writer.Close: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/test_results", &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + return req +} + +func TestPostTestResultsImpl_NilRequest(t *testing.T) { + _, err := postTestResultsImpl(&plugin.ApiResourceInput{Request: nil}, 1) + if err == nil { + t.Fatal("expected error for nil request, got nil") + } +} + +func TestPostTestResultsImpl_MissingRequiredFields(t *testing.T) { + allFields := map[string]string{ + "jobId": "job-1", + "jobName": "build", + "organization": "my-org", + "repository": "my-repo", + "result": "SUCCESS", + } + + for _, missing := range []string{"jobId", "jobName", "organization", "repository", "result"} { + t.Run("missing_"+missing, func(t *testing.T) { + fields := make(map[string]string, len(allFields)) + for k, v := range allFields { + fields[k] = v + } + delete(fields, missing) + + input := &plugin.ApiResourceInput{ + Request: makeMultipartRequest(t, fields), + } + _, err := postTestResultsImpl(input, 1) + if err == nil { + t.Errorf("expected error when %q is missing, got nil", missing) + } + }) + } +} diff --git a/backend/plugins/testregistry/api/remote_api.go b/backend/plugins/testregistry/api/remote_api.go new file mode 100644 index 00000000000..d73e1514742 --- /dev/null +++ b/backend/plugins/testregistry/api/remote_api.go @@ -0,0 +1,294 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + gocontext "context" + "fmt" + "net/http" + "net/url" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/pluginhelper/api" + dsmodels "github.com/apache/devlake/helpers/pluginhelper/api/models" + "github.com/apache/devlake/plugins/testregistry/models" +) + +type QuayRepository struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Description *string `json:"description"` // Can be null + IsPublic bool `json:"is_public"` + Kind string `json:"kind"` // e.g., "image" + State string `json:"state"` // e.g., "NORMAL" + // quota_report is ignored as we don't need it +} + +type QuayRepositoryResponse struct { + Repositories []QuayRepository `json:"repositories"` + NextPage *string `json:"next_page"` +} + +type TestRegistryRemotePagination struct { + PageToken string `json:"pageToken"` +} + +// GitHubContentItem represents a file or directory in GitHub repository +type GitHubContentItem struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // "file" or "dir" + DownloadURL string `json:"download_url"` + SHA string `json:"sha"` +} + +func listTestRegistryRemoteScopes( + connection *models.TestRegistryConnection, + apiClient plugin.ApiClient, + _ string, + pageToken string, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.TestRegistryScope], + nextPageToken string, + err errors.Error, +) { + // Check CI tool type to determine which API to use + if connection.CITool == models.CIToolOpenshiftCI { + return listOpenshiftCIScopes(connection, apiClient, pageToken) + } else if connection.CITool == models.CIToolTektonCI { + return listTektonCIScopes(connection, apiClient, pageToken) + } + + return nil, "", errors.BadInput.New("ciTool must be either 'Openshift CI' or 'Tekton CI'") +} + +func listOpenshiftCIScopes( + connection *models.TestRegistryConnection, + apiClient plugin.ApiClient, + _ string, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.TestRegistryScope], + nextPageToken string, + err errors.Error, +) { + if connection.GitHubOrganization == "" { + return nil, "", errors.BadInput.New("githubOrganization is required for Openshift CI") + } + + // Fetch contents from GitHub: openshift/release repository, ci-operator/config/{org} path + // Fixed repository: openshift/release + // Path: ci-operator/config/{githubOrganization} + repoPath := fmt.Sprintf("repos/openshift/release/contents/ci-operator/config/%s", connection.GitHubOrganization) + // Use master branch (or main) + branch := "master" + apiURL := fmt.Sprintf("%s?ref=%s", repoPath, branch) + + resp, err := apiClient.Get(apiURL, nil, nil) + if err != nil { + return nil, "", errors.Default.Wrap(err, fmt.Sprintf("failed to fetch contents from GitHub repo openshift/release/ci-operator/config/%s", connection.GitHubOrganization)) + } + + // Check if response is successful + if resp.StatusCode == http.StatusNotFound { + // Try with 'main' branch if 'master' doesn't exist + apiURL = fmt.Sprintf("%s?ref=%s", repoPath, "main") + resp, err = apiClient.Get(apiURL, nil, nil) + if err != nil { + return nil, "", errors.Default.Wrap(err, fmt.Sprintf("failed to fetch contents from GitHub repo openshift/release/ci-operator/config/%s", connection.GitHubOrganization)) + } + } + + if resp.StatusCode != http.StatusOK { + return nil, "", errors.Default.New(fmt.Sprintf("GitHub API returned status %d for repo openshift/release/ci-operator/config/%s", resp.StatusCode, connection.GitHubOrganization)) + } + + var contents []GitHubContentItem + if err = api.UnmarshalResponse(resp, &contents); err != nil { + return nil, "", errors.Default.Wrap(err, "failed to parse GitHub API response") + } + + // Log for debugging + if basicRes != nil { + logger := basicRes.GetLogger() + logger.Info("GitHub API response: %d items found in ci-operator/config/%s for org %s", len(contents), connection.GitHubOrganization, connection.GitHubOrganization) + } + + // Convert GitHub contents to scope entries + // Each file/directory under ci-operator/config/{org} becomes a scope + for _, item := range contents { + // Include all directories (which typically contain CI configs) + // Also include YAML files directly in the org directory + isYAMLFile := item.Type == "file" && (len(item.Name) >= 5 && (item.Name[len(item.Name)-5:] == ".yaml" || item.Name[len(item.Name)-5:] == ".yml")) + if item.Type == "dir" || isYAMLFile { + // Name and FullName should only contain the repo name inside the org folder + // e.g., if item.Name is "my-repo", then both Name and FullName are "my-repo" + scopeName := item.Name + scopeFullName := item.Name // Same as name, just the repo name without the path + + // Create scope data + scopeData := &models.TestRegistryScope{ + Name: scopeName, + FullName: scopeFullName, + } + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.TestRegistryScope]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + ParentId: nil, + Id: scopeData.ScopeId(), + Name: scopeData.ScopeName(), + FullName: scopeData.ScopeFullName(), + Data: scopeData, + }) + } + } + + return children, "", nil // GitHub Contents API doesn't use pagination tokens in the same way +} + +func listTektonCIScopes( + connection *models.TestRegistryConnection, + apiClient plugin.ApiClient, + pageToken string, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.TestRegistryScope], + nextPageToken string, + err errors.Error, +) { + if connection.QuayOrganization == "" { + return nil, "", errors.BadInput.New("quayOrganization is required for Tekton CI") + } + + // Quay.io API endpoint: /api/v1/repository?namespace= + // Note: apiClient endpoint is already set to "https://quay.io", so use relative path + apiURL := "/api/v1/repository" + queryParams := url.Values{} + queryParams.Set("namespace", connection.QuayOrganization) + queryParams.Set("public", "true") // Include public repositories + + if pageToken != "" { + // Parse pageToken to extract page number if needed + // For Quay.io, we can use next_page from the response + queryParams.Set("next_page", pageToken) + } + + resp, err := apiClient.Get(apiURL, queryParams, nil) + if err != nil { + return nil, "", errors.Default.Wrap(err, "failed to fetch repositories from Quay.io") + } + + // Check if response is successful + if resp.StatusCode != http.StatusOK { + return nil, "", errors.Default.New(fmt.Sprintf("Quay.io API returned status %d", resp.StatusCode)) + } + + var quayResponse QuayRepositoryResponse + if err = api.UnmarshalResponse(resp, &quayResponse); err != nil { + return nil, "", errors.Default.Wrap(err, "failed to parse Quay.io API response") + } + + // Log for debugging + if basicRes != nil { + logger := basicRes.GetLogger() + logger.Info("Quay.io API response: %d repositories found for organization %s", len(quayResponse.Repositories), connection.QuayOrganization) + } + + // Convert Quay repositories to scope entries + for _, repo := range quayResponse.Repositories { + repoName := repo.Name + repoFullName := fmt.Sprintf("%s/%s", repo.Namespace, repo.Name) + // Create scope data - the embedded common.Scope fields will be set when saved + scopeData := &models.TestRegistryScope{ + Name: repoName, + FullName: repoFullName, + // common.Scope fields (ConnectionId, etc.) will be set when the scope is saved to DB + } + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.TestRegistryScope]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + ParentId: nil, // No parent grouping + Id: scopeData.ScopeId(), // Use scope method to get ID + Name: scopeData.ScopeName(), // Use scope method to get name + FullName: scopeData.ScopeFullName(), // Use scope method to get full name + Data: scopeData, + }) + } + + // Set next page token if available + if quayResponse.NextPage != nil && *quayResponse.NextPage != "" { + nextPageToken = *quayResponse.NextPage + } + + return children, nextPageToken, nil +} + +// RemoteScopes fetches scopes based on CI tool type +// @Summary get testregistry remote scopes +// @Description Get scopes from Quay.io (Tekton CI) or GitHub (Openshift CI) +// @Tags plugins/testregistry +// @Param connectionId path int true "connection ID" +// @Param pageToken query string false "page token for pagination" +// @Success 200 {object} dsmodels.DsRemoteApiScopeList[models.TestRegistryScope] +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/testregistry/connections/{connectionId}/remote-scopes [GET] +func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection, err := dsHelper.ConnApi.ModelApiHelper.FindByPk(input) + if err != nil { + return nil, err + } + + pageToken := input.Query.Get("pageToken") + + var apiClient plugin.ApiClient + + // Create API client based on CI tool type + if connection.CITool == models.CIToolOpenshiftCI { + // GitHub API client with authentication + apiClient, err = api.NewApiClient(gocontext.TODO(), "https://api.github.com", nil, 0, "", basicRes) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to create GitHub API client") + } + + // Set authentication header with GitHub token + if connection.GitHubToken != "" { + apiClient.SetHeaders(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", connection.GitHubToken), + }) + } + } else if connection.CITool == models.CIToolTektonCI { + // Quay.io API client (no authentication needed for public repos) + apiClient, err = api.NewApiClient(gocontext.TODO(), "https://quay.io", nil, 0, "", basicRes) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to create Quay.io API client") + } + } else { + return nil, errors.BadInput.New("ciTool must be either 'Openshift CI' or 'Tekton CI'") + } + + children, nextPageToken, err := listTestRegistryRemoteScopes(connection, apiClient, "", pageToken) + if err != nil { + return nil, err + } + + return &plugin.ApiResourceOutput{ + Body: map[string]interface{}{ + "children": children, + "nextPageToken": nextPageToken, + }, + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/testregistry/api/scope.go b/backend/plugins/testregistry/api/scope.go new file mode 100644 index 00000000000..1f0b054a3c2 --- /dev/null +++ b/backend/plugins/testregistry/api/scope.go @@ -0,0 +1,57 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +func GetScopeDispatcher(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + scopeIdWithSuffix := strings.TrimLeft(input.Params["scopeId"], "/") + if strings.HasSuffix(scopeIdWithSuffix, "/latest-sync-state") { + input.Params["scopeId"] = strings.TrimSuffix(scopeIdWithSuffix, "/latest-sync-state") + return GetScopeLatestSyncState(input) + } + return GetScope(input) +} + +func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.GetScopeDetail(input) +} + +func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.Patch(input) +} + +func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.Delete(input) +} + +func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetPage(input) +} + +func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.PutMultiple(input) +} diff --git a/backend/plugins/testregistry/api/scope_config.go b/backend/plugins/testregistry/api/scope_config.go new file mode 100644 index 00000000000..f868c2491b9 --- /dev/null +++ b/backend/plugins/testregistry/api/scope_config.go @@ -0,0 +1,47 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +func CreateScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Post(input) +} + +func UpdateScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Patch(input) +} + +func GetScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetDetail(input) +} + +func DeleteScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Delete(input) +} + +func GetScopeConfigList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetAll(input) +} + +func GetProjectsByScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetProjectsByScopeConfig(input) +} diff --git a/backend/plugins/testregistry/api/scope_state_api.go b/backend/plugins/testregistry/api/scope_state_api.go new file mode 100644 index 00000000000..49aa39efa4f --- /dev/null +++ b/backend/plugins/testregistry/api/scope_state_api.go @@ -0,0 +1,30 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +func GetScopeLatestSyncState(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + input.Params["scopeId"] = strings.TrimLeft(input.Params["scopeId"], "/") + return dsHelper.ScopeApi.GetScopeLatestSyncState(input) +} diff --git a/backend/plugins/testregistry/e2e/push_e2e_test.go b/backend/plugins/testregistry/e2e/push_e2e_test.go new file mode 100644 index 00000000000..bea2af8db35 --- /dev/null +++ b/backend/plugins/testregistry/e2e/push_e2e_test.go @@ -0,0 +1,203 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "testing" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/plugin" + pluginhelper "github.com/apache/devlake/helpers/pluginhelper/api" + trmodels "github.com/apache/devlake/plugins/testregistry/models" + testregistry "github.com/apache/devlake/plugins/testregistry/impl" + "github.com/apache/devlake/test/helper" + "github.com/stretchr/testify/require" +) + +const junitSingle = ` + + + + + expected 1, got 2 + + + + + +` + +// postTestResults sends a multipart POST to the testregistry push endpoint. +// fields are form fields; junitXML is optional JUnit content (empty string = no file). +func postTestResults(t *testing.T, endpoint string, connID uint64, fields map[string]string, junitXML string) *http.Response { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + require.NoError(t, w.WriteField(k, v)) + } + if junitXML != "" { + fw, err := w.CreateFormFile("junit", "results.xml") + require.NoError(t, err) + _, err = io.WriteString(fw, junitXML) + require.NoError(t, err) + } + require.NoError(t, w.Close()) + + url := fmt.Sprintf("%s/plugins/testregistry/connections/%d/test_results", endpoint, connID) + req, err := http.NewRequest(http.MethodPost, url, &buf) + require.NoError(t, err) + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +func countRows[T any](t *testing.T, db dal.Dal, dest *[]T, clauses ...dal.Clause) { + t.Helper() + require.NoError(t, db.All(dest, clauses...)) +} + +func TestTestRegistryPush(t *testing.T) { + client := helper.StartDevLakeServer(t, []plugin.PluginMeta{ + testregistry.TestRegistry{}, + }) + db := client.GetDal() + + conn := client.CreateConnection("testregistry", trmodels.TestRegistryConnection{ + BaseConnection: pluginhelper.BaseConnection{Name: "e2e-push"}, + CITool: trmodels.CIToolOpenshiftCI, + Project: "e2e-project", + GitHubOrganization: "e2e-org", + GitHubToken: "fake-token", + }) + connID := conn.ID + + baseFields := map[string]string{ + "jobId": "e2e-job-1", + "jobName": "e2e-unit-tests", + "organization": "test-org", + "repository": "test-repo", + "result": "FAILURE", + "jobType": "github_actions", + "triggerType": "pull_request", + } + + t.Run("happy_path_with_junit", func(t *testing.T) { + resp := postTestResults(t, client.Endpoint, connID, baseFields, junitSingle) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Equal(t, float64(1), body["suitesSaved"]) + require.Equal(t, float64(3), body["casesSaved"]) + require.Equal(t, float64(1), body["filesRead"]) + + // Verify ci_test_jobs + var jobs []trmodels.TestRegistryCIJob + countRows(t, db, &jobs, dal.Where("connection_id = ? AND job_name = ?", connID, "e2e-unit-tests")) + require.Len(t, jobs, 1) + require.Equal(t, "FAILURE", jobs[0].Result) + require.Equal(t, "test-org", jobs[0].Organization) + + // Verify ci_test_suites + domainJobID := fmt.Sprintf("testregistry:%d:e2e-job-1", connID) + var suites []trmodels.TestSuite + countRows(t, db, &suites, dal.Where("connection_id = ? AND job_id = ?", connID, domainJobID)) + require.Len(t, suites, 1) + require.Equal(t, "unit", suites[0].Name) + require.Equal(t, uint(3), suites[0].NumTests) + require.Equal(t, uint(1), suites[0].NumFailed) + + // Verify ci_test_cases + var cases []trmodels.TestCase + countRows(t, db, &cases, dal.Where("connection_id = ? AND job_id = ?", connID, domainJobID)) + require.Len(t, cases, 3) + statuses := make(map[string]int) + for _, c := range cases { + statuses[c.Status]++ + } + require.Equal(t, 1, statuses["passed"]) + require.Equal(t, 1, statuses["failed"]) + require.Equal(t, 1, statuses["skipped"]) + }) + + t.Run("no_junit_files_only_job_saved", func(t *testing.T) { + fields := map[string]string{ + "jobId": "e2e-job-build-only", + "jobName": "e2e-build", + "organization": "test-org", + "repository": "test-repo", + "result": "SUCCESS", + } + resp := postTestResults(t, client.Endpoint, connID, fields, "") + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Equal(t, float64(0), body["suitesSaved"]) + require.Equal(t, float64(0), body["casesSaved"]) + + var jobs []trmodels.TestRegistryCIJob + countRows(t, db, &jobs, dal.Where("connection_id = ? AND job_name = ?", connID, "e2e-build")) + require.Len(t, jobs, 1) + }) + + t.Run("idempotent_on_repeat_post", func(t *testing.T) { + resp1 := postTestResults(t, client.Endpoint, connID, baseFields, junitSingle) + resp1.Body.Close() + require.Equal(t, http.StatusOK, resp1.StatusCode) + + resp2 := postTestResults(t, client.Endpoint, connID, baseFields, junitSingle) + resp2.Body.Close() + require.Equal(t, http.StatusOK, resp2.StatusCode) + + var jobs []trmodels.TestRegistryCIJob + countRows(t, db, &jobs, dal.Where("connection_id = ? AND job_name = ?", connID, "e2e-unit-tests")) + require.Len(t, jobs, 1, "repeated POST must not duplicate ci_test_jobs rows") + }) + + t.Run("missing_required_field_no_db_write", func(t *testing.T) { + var jobsBefore []trmodels.TestRegistryCIJob + countRows(t, db, &jobsBefore, dal.Where("connection_id = ?", connID)) + + incompleteFields := map[string]string{ + // missing jobId + "jobName": "should-not-be-saved", + "organization": "test-org", + "repository": "test-repo", + "result": "SUCCESS", + } + resp := postTestResults(t, client.Endpoint, connID, incompleteFields, "") + resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + var jobsAfter []trmodels.TestRegistryCIJob + countRows(t, db, &jobsAfter, dal.Where("connection_id = ?", connID)) + require.Equal(t, len(jobsBefore), len(jobsAfter), "failed request must not write any rows") + }) +} diff --git a/backend/plugins/testregistry/impl/impl.go b/backend/plugins/testregistry/impl/impl.go new file mode 100644 index 00000000000..d1a42082fc6 --- /dev/null +++ b/backend/plugins/testregistry/impl/impl.go @@ -0,0 +1,186 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + coreModels "github.com/apache/devlake/core/models" + "github.com/apache/devlake/core/plugin" + pluginhelper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/testregistry/api" + "github.com/apache/devlake/plugins/testregistry/models" + "github.com/apache/devlake/plugins/testregistry/models/migrationscripts" + "github.com/apache/devlake/plugins/testregistry/tasks" +) + +// make sure interface is implemented +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginApi + plugin.PluginModel + plugin.PluginMigration + plugin.PluginTask + plugin.DataSourcePluginBlueprintV200 +} = (*TestRegistry)(nil) + +type TestRegistry struct{} + +func (p TestRegistry) Description() string { + return "Test Registry plugin for isolated development" +} + +func (p TestRegistry) Name() string { + return "testregistry" +} + +func (p TestRegistry) Init(basicRes context.BasicRes) errors.Error { + api.Init(basicRes, p) + return nil +} + +func (p TestRegistry) GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + &models.TestRegistryConnection{}, + &models.TestRegistryScope{}, + &models.TestRegistryScopeConfig{}, + &models.TestRegistryCIJob{}, + &models.TestSuite{}, + &models.TestCase{}, + } +} + +func (p TestRegistry) SubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{ + tasks.CollectProwJobsMeta, + tasks.CollectTektonJobsMeta, + // Add more tasks here as needed (extractors, converters, etc.) + } +} + +func (p TestRegistry) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) { + var op tasks.TestRegistryOptions + if err := pluginhelper.Decode(options, &op, nil); err != nil { + return nil, err + } + + connectionHelper := pluginhelper.NewConnectionHelper( + taskCtx, + nil, + p.Name(), + ) + connection := &models.TestRegistryConnection{} + err := connectionHelper.FirstById(connection, op.ConnectionId) + if err != nil { + return nil, err + } + + // Initialize the JUnit regex from connection configuration + // Uses default regex if JUnitRegex is empty or invalid + logger := taskCtx.GetLogger() + junitRegex := tasks.GetJUnitRegexOrDefault(connection.JUnitRegex, logger) + if connection.JUnitRegex != "" { + logger.Info("Using custom JUnit regex pattern: %s", connection.JUnitRegex) + } else { + logger.Debug("Using default JUnit regex pattern: %s", tasks.DefaultJUnitRegexPattern) + } + + taskData := &tasks.TestRegistryTaskData{ + Options: &op, + Connection: connection, + JUnitRegex: junitRegex, + } + + return taskData, nil +} + +func (p TestRegistry) MakeDataSourcePipelinePlanV200( + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (pp coreModels.PipelinePlan, sc []plugin.Scope, err errors.Error) { + return api.MakeDataSourcePipelinePlanV200Impl(p.SubTaskMetas(), connectionId, bpScopes) +} + +// RootPkgPath information lost when compiled as plugin(.so) +func (p TestRegistry) RootPkgPath() string { + return "github.com/apache/devlake/plugins/testregistry" +} + +func (p TestRegistry) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +func (p TestRegistry) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{ + "test": { + "POST": api.TestConnection, + }, + "connections": { + "POST": api.PostConnections, + "GET": api.ListConnections, + }, + "connections/:connectionId": { + "GET": api.GetConnection, + "PATCH": api.PatchConnection, + "DELETE": api.DeleteConnection, + }, + "connections/:connectionId/test": { + "POST": api.TestExistingConnection, + }, + "connections/:connectionId/remote-scopes": { + "GET": api.RemoteScopes, + }, + "connections/:connectionId/scopes/*scopeId": { + // Behind 'GetScopeDispatcher', there are two paths so far: + // GetScopeLatestSyncState "connections/:connectionId/scopes/:scopeId/latest-sync-state" + // GetScope "connections/:connectionId/scopes/:scopeId" + // Because there may be slash in scopeId (fullName), so we handle it manually. + "GET": api.GetScopeDispatcher, + "PATCH": api.PatchScope, + "DELETE": api.DeleteScope, + }, + "connections/:connectionId/scopes": { + "GET": api.GetScopeList, + "PUT": api.PutScopes, + }, + "connections/:connectionId/scope-configs": { + "POST": api.CreateScopeConfig, + "GET": api.GetScopeConfigList, + }, + "connections/:connectionId/scope-configs/:scopeConfigId": { + "PATCH": api.UpdateScopeConfig, + "GET": api.GetScopeConfig, + "DELETE": api.DeleteScopeConfig, + }, + "scope-config/:scopeConfigId/projects": { + "GET": api.GetProjectsByScopeConfig, + }, + // Push endpoints: external CI systems POST JUnit XML results here. + "connections/:connectionId/test_results": { + "POST": api.PostTestResults, + }, + ":connectionId/test_results": { + "POST": api.PostTestResults, + }, + "connections/by-name/:connectionName/test_results": { + "POST": api.PostTestResultsByName, + }, + } +} diff --git a/backend/plugins/testregistry/models/ci_job.go b/backend/plugins/testregistry/models/ci_job.go new file mode 100644 index 00000000000..e19cd767555 --- /dev/null +++ b/backend/plugins/testregistry/models/ci_job.go @@ -0,0 +1,76 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +// TestRegistryCIJob represents a unified CI/CD job from either Prow (Openshift CI) or Tekton +// This table stores common information that both job types share, making it easy for agents +// to query and analyze CI/CD data regardless of the underlying platform. +type TestRegistryCIJob struct { + common.NoPKModel + + // Primary keys: connection + unique job identifier + ConnectionId uint64 `gorm:"primaryKey;type:BIGINT NOT NULL"` + JobId string `gorm:"primaryKey;type:varchar(255)" json:"job_id"` // Unique job ID from source system + + // Job identification + JobName string `gorm:"type:varchar(500);index" json:"job_name"` // Name of the job/pipeline + JobType string `gorm:"type:varchar(50);index" json:"job_type"` // "prow" or "tekton" + + // Repository and Organization (key identifiers as requested) + Organization string `gorm:"type:varchar(255);index" json:"organization"` // GitHub org or Quay org + Repository string `gorm:"type:varchar(255);index" json:"repository"` // Repository name + + // Git references + CommitSHA string `gorm:"type:varchar(40);index" json:"commit_sha"` // Git commit SHA + + // Pull Request information (for Prow presubmit, Tekton PR-triggered runs) + PullRequestNumber *int `gorm:"type:int" json:"pull_request_number"` // PR number if triggered by PR + PullRequestAuthor string `gorm:"type:varchar(255)" json:"pull_request_author"` + + // Trigger type: "pull_request" (PR-triggered/Presubmit), "push" (push to branch/Postsubmit), or "periodic" (scheduled) + TriggerType string `gorm:"type:varchar(50);index" json:"trigger_type"` + + // Status and result + Result string `gorm:"type:varchar(100)" json:"result"` // "SUCCESS", "FAILURE", "ABORTED", etc. + + // Execution environment (optional - only if applicable) + Namespace string `gorm:"type:varchar(255)" json:"namespace"` // Kubernetes namespace (if applicable) + + // Timestamps + QueuedAt *time.Time `gorm:"index" json:"queued_at"` // When job was queued + StartedAt *time.Time `gorm:"index" json:"started_at"` // When job started executing + FinishedAt *time.Time `gorm:"index" json:"finished_at"` // When job completed + DurationSec *float64 `json:"duration_sec"` // Execution duration in seconds + QueuedDurationSec *float64 `json:"queued_duration_sec"` // Time spent in queue + + // URLs + ViewURL string `gorm:"type:text" json:"view_url"` // URL to view job in UI + + // Foreign key to scope (which repository/scope this job belongs to) + ScopeId string `gorm:"type:varchar(500);index" json:"scope_id"` // Links to TestRegistryScope.FullName +} + +func (TestRegistryCIJob) TableName() string { + return "ci_test_jobs" +} diff --git a/backend/plugins/testregistry/models/connection.go b/backend/plugins/testregistry/models/connection.go new file mode 100644 index 00000000000..4b479a0026e --- /dev/null +++ b/backend/plugins/testregistry/models/connection.go @@ -0,0 +1,73 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/utils" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +const ( + CIToolOpenshiftCI = "Openshift CI" + CIToolTektonCI = "Tekton CI" +) + +type TestRegistryConnection struct { + helper.BaseConnection `mapstructure:",squash"` + CITool string `mapstructure:"ciTool" json:"ciTool" validate:"required" gorm:"column:ci_tool;type:varchar(50)"` // CI tool type: Openshift CI or Tekton CI + Project string `mapstructure:"project" json:"project" validate:"required" gorm:"column:project;type:varchar(200)"` + + // Openshift CI fields + GitHubOrganization string `mapstructure:"githubOrganization" json:"githubOrganization" gorm:"column:github_organization;type:varchar(200)"` // GitHub organization (required when CI tool is Openshift CI) + GitHubToken string `mapstructure:"githubToken" json:"githubToken" gorm:"column:github_token;serializer:encdec"` // GitHub token (required when CI tool is Openshift CI, encrypted) + + // Tekton CI fields + QuayOrganization string `mapstructure:"quayOrganization" json:"quayOrganization" gorm:"column:quay_organization;type:varchar(200)"` // Quay.io organization (required when CI tool is Tekton CI) + + // JUnit XML file matching configuration + // Regex pattern to match JUnit XML file names in artifacts + // Default: "(devlake-|e2e|qd-report-)[0-9a-z-]+\\.(xml|junit)" - matches files starting with "devlake-", "e2e", or "qd-report-" + JUnitRegex string `mapstructure:"junitRegex" json:"junitRegex" gorm:"column:junit_regex;type:varchar(500)"` // Regex pattern for JUnit file matching (optional, uses default if empty) +} + +func (TestRegistryConnection) TableName() string { + return "_tool_testregistry_connections" +} + +func (c TestRegistryConnection) Sanitize() TestRegistryConnection { + if c.GitHubToken != "" { + c.GitHubToken = utils.SanitizeString(c.GitHubToken) + } + return c +} + +func (connection *TestRegistryConnection) MergeFromRequest(target *TestRegistryConnection, body map[string]interface{}) error { + // Preserve existing GitHub token if it wasn't changed (user sent sanitized version) + existingToken := target.GitHubToken + if err := helper.DecodeMapStruct(body, target, true); err != nil { + return err + } + modifiedToken := target.GitHubToken + + // If token is empty or matches the sanitized version, restore the original + if modifiedToken == "" || modifiedToken == utils.SanitizeString(existingToken) { + target.GitHubToken = existingToken + } + + return nil +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250101_add_init_tables.go b/backend/plugins/testregistry/models/migrationscripts/20250101_add_init_tables.go new file mode 100644 index 00000000000..f3b2f887532 --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250101_add_init_tables.go @@ -0,0 +1,58 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +// testRegistryConnection20250101 is a frozen snapshot of models.TestRegistryConnection as of 2025-01-01. +// Do not update this struct — create a new migration instead. +type testRegistryConnection20250101 struct { + helper.BaseConnection `mapstructure:",squash"` + CITool string `gorm:"column:ci_tool;type:varchar(50)"` + Project string `gorm:"column:project;type:varchar(200)"` + GitHubOrganization string `gorm:"column:github_organization;type:varchar(200)"` + GitHubToken string `gorm:"column:github_token;serializer:encdec"` + QuayOrganization string `gorm:"column:quay_organization;type:varchar(200)"` + JUnitRegex string `gorm:"column:junit_regex;type:varchar(500)"` +} + +func (testRegistryConnection20250101) TableName() string { + return "_tool_testregistry_connections" +} + +type addInitTables struct{} + +func (u *addInitTables) Up(baseRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + baseRes, + &testRegistryConnection20250101{}, + ) +} + +func (*addInitTables) Version() uint64 { + return 20250101000001 +} + +func (*addInitTables) Name() string { + return "testregistry init schemas" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250102_add_project_column.go b/backend/plugins/testregistry/models/migrationscripts/20250102_add_project_column.go new file mode 100644 index 00000000000..748d835b8e5 --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250102_add_project_column.go @@ -0,0 +1,58 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "strings" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addProjectColumn)(nil) + +type addProjectColumn struct{} + +func (*addProjectColumn) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + // Add project column to connections table + // MySQL returns error 1060 if column already exists, which is OK + err := db.Exec("ALTER TABLE _tool_testregistry_connections ADD COLUMN project VARCHAR(200)") + if err != nil { + // Check if error is "duplicate column" (MySQL error 1060) + errMsg := err.Error() + if strings.Contains(errMsg, "Duplicate column name") || strings.Contains(errMsg, "1060") { + // Column already exists, this is OK + } else { + // For other errors, return them + return errors.Default.Wrap(err, "failed to add project column") + } + } + + return nil +} + +func (*addProjectColumn) Version() uint64 { + return 20250102000001 +} + +func (*addProjectColumn) Name() string { + return "add project column to testregistry connections" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250103_add_scope_config_table.go b/backend/plugins/testregistry/models/migrationscripts/20250103_add_scope_config_table.go new file mode 100644 index 00000000000..df9c106c2b1 --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250103_add_scope_config_table.go @@ -0,0 +1,63 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*addScopeConfigTable)(nil) + +// testRegistryScopeConfig20250103 is a frozen snapshot of models.TestRegistryScopeConfig as of 2025-01-03. +// Do not update this struct — create a new migration instead. +type testRegistryScopeConfig20250103 struct { + // common.Model fields + ID uint64 `gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // common.ScopeConfig fields + Entities []string `gorm:"type:json;serializer:json" json:"entities"` + ConnectionId uint64 `json:"connectionId" gorm:"index"` + Name string `gorm:"type:varchar(255);uniqueIndex" json:"name"` +} + +func (testRegistryScopeConfig20250103) TableName() string { + return "_tool_testregistry_scope_configs" +} + +type addScopeConfigTable struct{} + +func (*addScopeConfigTable) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &testRegistryScopeConfig20250103{}, + ) +} + +func (*addScopeConfigTable) Version() uint64 { + return 20250103000001 +} + +func (*addScopeConfigTable) Name() string { + return "add testregistry scope config table" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250104_add_scope_table.go b/backend/plugins/testregistry/models/migrationscripts/20250104_add_scope_table.go new file mode 100644 index 00000000000..3f117048ef8 --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250104_add_scope_table.go @@ -0,0 +1,66 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/helpers/migrationhelper" +) + +// testRegistryScope20250104 is a frozen snapshot of models.TestRegistryScope as of 2025-01-04. +// Do not update this struct — create a new migration instead. +type testRegistryScope20250104 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // common.RawDataOrigin fields + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // common.Scope fields + ConnectionId uint64 `json:"connectionId" gorm:"primaryKey"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty"` + // TestRegistryScope-specific fields + FullName string `gorm:"primaryKey;type:varchar(500)" json:"fullName"` + Name string `gorm:"type:varchar(500)" json:"name"` +} + +func (testRegistryScope20250104) TableName() string { + return "_tool_testregistry_scopes" +} + +type addScopeTable struct{} + +func (*addScopeTable) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &testRegistryScope20250104{}, + ) +} + +func (*addScopeTable) Version() uint64 { + return 20250104000001 +} + +func (*addScopeTable) Name() string { + return "add scope table for testregistry" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250105_rename_oci_to_quay.go b/backend/plugins/testregistry/models/migrationscripts/20250105_rename_oci_to_quay.go new file mode 100644 index 00000000000..0cc7dc3d8cb --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250105_rename_oci_to_quay.go @@ -0,0 +1,110 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "strings" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*renameOciToQuay)(nil) + +type renameOciToQuay struct{} + +func (*renameOciToQuay) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + // Rename column in connections table from oci_artifact_url to quay_organization + // MySQL returns error 1054 if column doesn't exist, which is OK + err := db.Exec("ALTER TABLE _tool_testregistry_connections CHANGE COLUMN oci_artifact_url quay_organization VARCHAR(200)") + if err != nil { + errMsg := err.Error() + // Check if error is "unknown column" (MySQL error 1054) or "duplicate column" (1060) + if strings.Contains(errMsg, "Unknown column") || strings.Contains(errMsg, "1054") { + // Column doesn't exist, try to add it instead + err2 := db.Exec("ALTER TABLE _tool_testregistry_connections ADD COLUMN quay_organization VARCHAR(200)") + if err2 != nil { + errMsg2 := err2.Error() + if strings.Contains(errMsg2, "Duplicate column name") || strings.Contains(errMsg2, "1060") { + // Column already exists, this is OK + } else { + return errors.Default.Wrap(err2, "failed to add quay_organization column") + } + } + } else if strings.Contains(errMsg, "Duplicate column name") || strings.Contains(errMsg, "1060") { + // Column already exists with new name, this is OK + } else { + // For other errors, return them + return errors.Default.Wrap(err, "failed to rename oci_artifact_url to quay_organization") + } + } + + // Update scopes table: rename repository_full_name to full_name and repository_name to name + // First check if the table exists and has data to migrate + // Step 1: Handle repository_full_name -> full_name + err = db.Exec("ALTER TABLE _tool_testregistry_scopes CHANGE COLUMN repository_full_name full_name VARCHAR(500)") + if err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "Unknown column") || strings.Contains(errMsg, "1054") { + // Column doesn't exist, check if full_name already exists, if not add it + err2 := db.Exec("ALTER TABLE _tool_testregistry_scopes ADD COLUMN full_name VARCHAR(500)") + if err2 != nil { + errMsg2 := err2.Error() + if !strings.Contains(errMsg2, "Duplicate column name") && !strings.Contains(errMsg2, "1060") { + // If adding failed and it's not a duplicate, continue to next step + } + } + } + } + + // Step 2: Handle repository_name -> name + err = db.Exec("ALTER TABLE _tool_testregistry_scopes CHANGE COLUMN repository_name name VARCHAR(500)") + if err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "Unknown column") || strings.Contains(errMsg, "1054") { + // Column doesn't exist, check if name already exists, if not add it + err2 := db.Exec("ALTER TABLE _tool_testregistry_scopes ADD COLUMN name VARCHAR(500)") + if err2 != nil { + errMsg2 := err2.Error() + if !strings.Contains(errMsg2, "Duplicate column name") && !strings.Contains(errMsg2, "1060") { + // If adding failed and it's not a duplicate, continue to next step + } + } + } + } + + // Step 3: Handle legacy oci_artifact_url column if it exists (copy data to full_name if full_name is empty) + // First check if oci_artifact_url column exists and copy data if needed + // We'll try to copy data, but if the column doesn't exist, that's fine + db.Exec("UPDATE _tool_testregistry_scopes SET full_name = oci_artifact_url WHERE (full_name IS NULL OR full_name = '') AND oci_artifact_url IS NOT NULL AND oci_artifact_url != ''") + // Then try to drop the old column (ignore errors if it doesn't exist) + db.Exec("ALTER TABLE _tool_testregistry_scopes DROP COLUMN oci_artifact_url") + + return nil +} + +func (*renameOciToQuay) Version() uint64 { + return 20250105000001 +} + +func (*renameOciToQuay) Name() string { + return "rename oci_artifact_url to quay_organization and update scope fields" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250106_add_ci_tool_fields.go b/backend/plugins/testregistry/models/migrationscripts/20250106_add_ci_tool_fields.go new file mode 100644 index 00000000000..9be0f1ebae2 --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250106_add_ci_tool_fields.go @@ -0,0 +1,75 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "strings" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addCiToolFields)(nil) + +type addCiToolFields struct{} + +func (*addCiToolFields) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + // Add ci_tool column + err := db.Exec("ALTER TABLE _tool_testregistry_connections ADD COLUMN ci_tool VARCHAR(50)") + if err != nil { + errMsg := err.Error() + if !strings.Contains(errMsg, "Duplicate column name") && !strings.Contains(errMsg, "1060") { + return errors.Default.Wrap(err, "failed to add ci_tool column") + } + } + + // Add github_organization column + err = db.Exec("ALTER TABLE _tool_testregistry_connections ADD COLUMN github_organization VARCHAR(200)") + if err != nil { + errMsg := err.Error() + if !strings.Contains(errMsg, "Duplicate column name") && !strings.Contains(errMsg, "1060") { + return errors.Default.Wrap(err, "failed to add github_organization column") + } + } + + // Add github_token column (for encrypted storage) + err = db.Exec("ALTER TABLE _tool_testregistry_connections ADD COLUMN github_token TEXT") + if err != nil { + errMsg := err.Error() + if !strings.Contains(errMsg, "Duplicate column name") && !strings.Contains(errMsg, "1060") { + return errors.Default.Wrap(err, "failed to add github_token column") + } + } + + // Set default ci_tool to 'Tekton CI' for existing records (migrate existing data) + // Also ensure quay_organization is set where it exists + db.Exec("UPDATE _tool_testregistry_connections SET ci_tool = 'Tekton CI' WHERE ci_tool IS NULL OR ci_tool = ''") + + return nil +} + +func (*addCiToolFields) Version() uint64 { + return 20250106000001 +} + +func (*addCiToolFields) Name() string { + return "add CI tool fields to testregistry connections" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250107_add_ci_jobs_table.go b/backend/plugins/testregistry/models/migrationscripts/20250107_add_ci_jobs_table.go new file mode 100644 index 00000000000..02a6efaac03 --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250107_add_ci_jobs_table.go @@ -0,0 +1,83 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*addCIJobsTable)(nil) + +// testRegistryCIJob20250107 is a frozen snapshot of models.TestRegistryCIJob as of 2025-01-07. +// Do not update this struct — create a new migration instead. +type testRegistryCIJob20250107 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:BIGINT NOT NULL"` + JobId string `gorm:"primaryKey;type:varchar(255)" json:"job_id"` + // fields + JobName string `gorm:"type:varchar(500);index" json:"job_name"` + JobType string `gorm:"type:varchar(50);index" json:"job_type"` + Organization string `gorm:"type:varchar(255);index" json:"organization"` + Repository string `gorm:"type:varchar(255);index" json:"repository"` + CommitSHA string `gorm:"type:varchar(40);index" json:"commit_sha"` + PullRequestNumber *int `gorm:"type:int" json:"pull_request_number"` + PullRequestAuthor string `gorm:"type:varchar(255)" json:"pull_request_author"` + TriggerType string `gorm:"type:varchar(50);index" json:"trigger_type"` + Result string `gorm:"type:varchar(100)" json:"result"` + Namespace string `gorm:"type:varchar(255)" json:"namespace"` + QueuedAt *time.Time `gorm:"index" json:"queued_at"` + StartedAt *time.Time `gorm:"index" json:"started_at"` + FinishedAt *time.Time `gorm:"index" json:"finished_at"` + DurationSec *float64 `json:"duration_sec"` + QueuedDurationSec *float64 `json:"queued_duration_sec"` + ViewURL string `gorm:"type:text" json:"view_url"` + ScopeId string `gorm:"type:varchar(500);index" json:"scope_id"` +} + +func (testRegistryCIJob20250107) TableName() string { + return "ci_test_jobs" +} + +type addCIJobsTable struct{} + +func (*addCIJobsTable) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &testRegistryCIJob20250107{}, + ) +} + +func (*addCIJobsTable) Version() uint64 { + return 20250107000001 +} + +func (*addCIJobsTable) Name() string { + return "add ci_test_jobs table for testregistry" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250108_add_trigger_type.go b/backend/plugins/testregistry/models/migrationscripts/20250108_add_trigger_type.go new file mode 100644 index 00000000000..f560c708fad --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250108_add_trigger_type.go @@ -0,0 +1,65 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "strings" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addTriggerType)(nil) + +type addTriggerType struct{} + +func (*addTriggerType) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + // Add trigger_type column to ci_test_jobs table + err := db.Exec("ALTER TABLE ci_test_jobs ADD COLUMN trigger_type VARCHAR(50)") + if err != nil { + errMsg := err.Error() + if !strings.Contains(errMsg, "Duplicate column name") && !strings.Contains(errMsg, "1060") { + return errors.Default.Wrap(err, "failed to add trigger_type column") + } + } + + // Add index on trigger_type for efficient filtering + // MySQL doesn't support IF NOT EXISTS, so we'll try to create it and ignore errors if it already exists + err = db.Exec("CREATE INDEX idx_ci_test_jobs_trigger_type ON ci_test_jobs(trigger_type)") + if err != nil { + errMsg := err.Error() + // Index might already exist (MySQL error 1061), ignore that case + if !strings.Contains(errMsg, "Duplicate key name") && !strings.Contains(errMsg, "1061") { + // For other errors, log but don't fail (index is optional for functionality) + basicRes.GetLogger().Warn(err, "failed to create index on trigger_type") + } + } + + return nil +} + +func (*addTriggerType) Version() uint64 { + return 20250108000001 +} + +func (*addTriggerType) Name() string { + return "add trigger_type column to ci_test_jobs table" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250109_add_raw_cicd_test_jobs_table.go b/backend/plugins/testregistry/models/migrationscripts/20250109_add_raw_cicd_test_jobs_table.go new file mode 100644 index 00000000000..4c14817dc50 --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250109_add_raw_cicd_test_jobs_table.go @@ -0,0 +1,68 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + pluginhelper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +var _ plugin.MigrationScript = (*addRawCicdTestJobsTable)(nil) + +type addRawCicdTestJobsTable struct{} + +// rawCicdTestJobsData is a wrapper around RawData to specify the table name +type rawCicdTestJobsData struct { + pluginhelper.RawData +} + +func (rawCicdTestJobsData) TableName() string { + return "_raw_cicd_test_jobs" +} + +func (*addRawCicdTestJobsTable) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + // Create _raw_cicd_test_jobs table using RawData schema + // Use a wrapper struct with TableName() method to specify the table name + rawDataModel := &rawCicdTestJobsData{} + + // Check if table already exists + if db.HasTable(rawDataModel) { + // Table already exists, no need to create it + return nil + } + + // Use GORM's AutoMigrate to create the table with RawData schema + err := db.AutoMigrate(rawDataModel) + if err != nil { + return errors.Default.Wrap(err, "failed to create _raw_cicd_test_jobs table") + } + + return nil +} + +func (*addRawCicdTestJobsTable) Version() uint64 { + return 20250109000001 +} + +func (*addRawCicdTestJobsTable) Name() string { + return "add _raw_cicd_test_jobs table for storing raw Prow job JSON data" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250110_add_test_suites_table.go b/backend/plugins/testregistry/models/migrationscripts/20250110_add_test_suites_table.go new file mode 100644 index 00000000000..552a76d7c91 --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250110_add_test_suites_table.go @@ -0,0 +1,74 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*addTestSuitesTable)(nil) + +// testSuite20250110 is a frozen snapshot of models.TestSuite as of 2025-01-10. +// Do not update this struct — create a new migration instead. +type testSuite20250110 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:BIGINT NOT NULL"` + JobId string `gorm:"primaryKey;type:varchar(255);index" json:"job_id"` + SuiteId string `gorm:"primaryKey;type:varchar(255)" json:"suite_id"` + // fields + Name string `gorm:"type:varchar(500);index" json:"name"` + NumTests uint `json:"num_tests"` + NumSkipped uint `json:"num_skipped"` + NumFailed uint `json:"num_failed"` + Duration float64 `json:"duration"` + Properties string `gorm:"type:text" json:"properties"` + ParentSuiteId *string `gorm:"type:varchar(255);index" json:"parent_suite_id"` +} + +func (testSuite20250110) TableName() string { + return "ci_test_suites" +} + +type addTestSuitesTable struct{} + +func (*addTestSuitesTable) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &testSuite20250110{}, + ) +} + +func (*addTestSuitesTable) Version() uint64 { + return 20250110000001 +} + +func (*addTestSuitesTable) Name() string { + return "add ci_test_suites table for testregistry" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250111_add_test_cases_table.go b/backend/plugins/testregistry/models/migrationscripts/20250111_add_test_cases_table.go new file mode 100644 index 00000000000..81cf4cddce4 --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250111_add_test_cases_table.go @@ -0,0 +1,77 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*addTestCasesTable)(nil) + +// testCase20250111 is a frozen snapshot of models.TestCase as of 2025-01-11. +// Do not update this struct — create a new migration instead. +type testCase20250111 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:BIGINT NOT NULL"` + JobId string `gorm:"primaryKey;type:varchar(255);index" json:"job_id"` + SuiteId string `gorm:"primaryKey;type:varchar(255);index" json:"suite_id"` + TestCaseId string `gorm:"primaryKey;type:varchar(255)" json:"test_case_id"` + // fields + Name string `gorm:"type:varchar(500);index" json:"name"` + Classname string `gorm:"type:varchar(500)" json:"classname"` + Duration float64 `json:"duration"` + Status string `gorm:"type:varchar(50);index" json:"status"` + FailureMessage *string `gorm:"type:text" json:"failure_message"` + FailureOutput *string `gorm:"type:text" json:"failure_output"` + SkipMessage *string `gorm:"type:text" json:"skip_message"` + SystemOut *string `gorm:"type:text" json:"system_out"` + SystemErr *string `gorm:"type:text" json:"system_err"` +} + +func (testCase20250111) TableName() string { + return "ci_test_cases" +} + +type addTestCasesTable struct{} + +func (*addTestCasesTable) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &testCase20250111{}, + ) +} + +func (*addTestCasesTable) Version() uint64 { + return 20250111000001 +} + +func (*addTestCasesTable) Name() string { + return "add ci_test_cases table for testregistry" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250112_add_tekton_tasks_table.go b/backend/plugins/testregistry/models/migrationscripts/20250112_add_tekton_tasks_table.go new file mode 100644 index 00000000000..d1a3dd75e84 --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250112_add_tekton_tasks_table.go @@ -0,0 +1,69 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/helpers/migrationhelper" +) + +var _ plugin.MigrationScript = (*addTektonTasksTable)(nil) + +// tektonTask20250112 is a frozen snapshot of models.TektonTask as of 2025-01-12. +// Do not update this struct — create a new migration instead. +type tektonTask20250112 struct { + // common.NoPKModel fields + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + RawDataParams string `gorm:"column:_raw_data_params;type:varchar(255);index" json:"_raw_data_params"` + RawDataTable string `gorm:"column:_raw_data_table;type:varchar(255)" json:"_raw_data_table"` + RawDataId uint64 `gorm:"column:_raw_data_id" json:"_raw_data_id"` + RawDataRemark string `gorm:"column:_raw_data_remark" json:"_raw_data_remark"` + // primary keys + ConnectionId uint64 `gorm:"primaryKey;type:BIGINT NOT NULL"` + JobId string `gorm:"primaryKey;type:varchar(255);index" json:"job_id"` + TaskName string `gorm:"primaryKey;type:varchar(500);index" json:"task_name"` + // fields + Status string `gorm:"type:varchar(100);index" json:"status"` + DurationSec float64 `json:"duration_sec"` +} + +func (tektonTask20250112) TableName() string { + return "ci_tekton_tasks" +} + +type addTektonTasksTable struct{} + +func (*addTektonTasksTable) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &tektonTask20250112{}, + ) +} + +func (*addTektonTasksTable) Version() uint64 { + return 20250112000001 +} + +func (*addTektonTasksTable) Name() string { + return "add ci_tekton_tasks table for testregistry" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/20250113_add_junit_regex_column.go b/backend/plugins/testregistry/models/migrationscripts/20250113_add_junit_regex_column.go new file mode 100644 index 00000000000..97f84cec9db --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/20250113_add_junit_regex_column.go @@ -0,0 +1,57 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "strings" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*addJUnitRegexColumn)(nil) + +type addJUnitRegexColumn struct{} + +func (*addJUnitRegexColumn) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + // Add junit_regex column + err := db.Exec("ALTER TABLE _tool_testregistry_connections ADD COLUMN junit_regex VARCHAR(500)") + if err != nil { + errMsg := err.Error() + if !strings.Contains(errMsg, "Duplicate column name") && !strings.Contains(errMsg, "1060") { + return errors.Default.Wrap(err, "failed to add junit_regex column") + } + } + + // Note: We don't set a default value in the database because we handle the default + // in the application code. This allows existing connections to continue working + // with the default regex pattern without requiring database updates. + + return nil +} + +func (*addJUnitRegexColumn) Version() uint64 { + return 20250113000001 +} + +func (*addJUnitRegexColumn) Name() string { + return "add junit_regex column to testregistry connections for configurable JUnit file matching" +} diff --git a/backend/plugins/testregistry/models/migrationscripts/register.go b/backend/plugins/testregistry/models/migrationscripts/register.go new file mode 100644 index 00000000000..ca9acc1f52e --- /dev/null +++ b/backend/plugins/testregistry/models/migrationscripts/register.go @@ -0,0 +1,41 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/devlake/core/plugin" +) + +// All return all the migration scripts +func All() []plugin.MigrationScript { + return []plugin.MigrationScript{ + new(addInitTables), + new(addProjectColumn), + new(addScopeConfigTable), + new(addScopeTable), + new(renameOciToQuay), + new(addCiToolFields), + new(addCIJobsTable), + new(addTriggerType), + new(addRawCicdTestJobsTable), + new(addTestSuitesTable), + new(addTestCasesTable), + new(addTektonTasksTable), + new(addJUnitRegexColumn), + } +} diff --git a/backend/plugins/testregistry/models/scope.go b/backend/plugins/testregistry/models/scope.go new file mode 100644 index 00000000000..f42315b0652 --- /dev/null +++ b/backend/plugins/testregistry/models/scope.go @@ -0,0 +1,74 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "encoding/json" + + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" +) + +type TestRegistryScope struct { + common.Scope `mapstructure:",squash"` + Name string `gorm:"type:varchar(500)" json:"name" mapstructure:"name"` // Repository name (e.g., "konflux-team/release-service-catalog") + FullName string `gorm:"primaryKey;type:varchar(500)" json:"fullName" mapstructure:"fullName" validate:"required"` // Full name with organization (e.g., "konflux-test-storage/konflux-team/release-service-catalog") + Id string `gorm:"-" json:"id" mapstructure:"-"` // Computed field: same as FullName, for frontend compatibility with default getPluginScopeId +} + +// MarshalJSON customizes JSON serialization to populate Id field from FullName +func (s TestRegistryScope) MarshalJSON() ([]byte, error) { + // Create an alias type to avoid infinite recursion + type Alias TestRegistryScope + + // Populate Id from FullName for frontend compatibility + alias := Alias(s) + alias.Id = s.FullName + + return json.Marshal(alias) +} + +func (TestRegistryScope) TableName() string { + return "_tool_testregistry_scopes" +} + +func (s TestRegistryScope) ScopeId() string { + return s.FullName +} + +func (s TestRegistryScope) ScopeName() string { + return s.Name +} + +func (s TestRegistryScope) ScopeFullName() string { + return s.FullName +} + +func (s TestRegistryScope) ScopeParams() interface{} { + return &TestRegistryApiParams{ + ConnectionId: s.ConnectionId, + FullName: s.FullName, + } +} + +type TestRegistryApiParams struct { + ConnectionId uint64 `json:"connectionId"` + FullName string `json:"fullName"` +} + +var _ plugin.ToolLayerScope = (*TestRegistryScope)(nil) diff --git a/backend/plugins/testregistry/models/scope_config.go b/backend/plugins/testregistry/models/scope_config.go new file mode 100644 index 00000000000..ff97147e8a3 --- /dev/null +++ b/backend/plugins/testregistry/models/scope_config.go @@ -0,0 +1,30 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +type TestRegistryScopeConfig struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` +} + +func (TestRegistryScopeConfig) TableName() string { + return "_tool_testregistry_scope_configs" +} diff --git a/backend/plugins/testregistry/models/tekton_task.go b/backend/plugins/testregistry/models/tekton_task.go new file mode 100644 index 00000000000..087aec708cf --- /dev/null +++ b/backend/plugins/testregistry/models/tekton_task.go @@ -0,0 +1,43 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// TektonTask represents a Tekton task run within a PipelineRun +// This table stores individual task execution details from Tekton PipelineRuns +type TektonTask struct { + common.NoPKModel + + // Primary keys: connection + job + task name + ConnectionId uint64 `gorm:"primaryKey;type:BIGINT NOT NULL"` + JobId string `gorm:"primaryKey;type:varchar(255);index" json:"job_id"` // Links to TestRegistryCIJob.JobId (PipelineRunName) + TaskName string `gorm:"primaryKey;type:varchar(500);index" json:"task_name"` // Task name (e.g., "deploy-konflux") + + // Task status: "Succeeded", "Failed", etc. + Status string `gorm:"type:varchar(100);index" json:"status"` // Task status + + // Duration in seconds (parsed from duration string like "499s") + DurationSec float64 `json:"duration_sec"` // Duration in seconds as a number +} + +func (TektonTask) TableName() string { + return "ci_tekton_tasks" +} diff --git a/backend/plugins/testregistry/models/test_case.go b/backend/plugins/testregistry/models/test_case.go new file mode 100644 index 00000000000..56d0890a24d --- /dev/null +++ b/backend/plugins/testregistry/models/test_case.go @@ -0,0 +1,56 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// TestCase represents a single test case within a test suite +type TestCase struct { + common.NoPKModel + + // Primary keys: connection + job + suite + unique test case identifier + ConnectionId uint64 `gorm:"primaryKey;type:BIGINT NOT NULL"` + JobId string `gorm:"primaryKey;type:varchar(255);index" json:"job_id"` // Links to TestRegistryCIJob.JobId + SuiteId string `gorm:"primaryKey;type:varchar(255);index" json:"suite_id"` // Links to TestSuite.SuiteId + TestCaseId string `gorm:"primaryKey;type:varchar(255)" json:"test_case_id"` // Unique identifier for the test case + + // Test case identification + Name string `gorm:"type:varchar(500);index" json:"name"` // Name of the test case + Classname string `gorm:"type:varchar(500)" json:"classname"` // Class name (if applicable) + Duration float64 `json:"duration"` // Duration in seconds + + // Test result status: "passed", "failed", "skipped" + Status string `gorm:"type:varchar(50);index" json:"status"` // Test case status + + // Failure information (if status is "failed") + FailureMessage *string `gorm:"type:text" json:"failure_message"` // Failure message from the test + FailureOutput *string `gorm:"type:text" json:"failure_output"` // Detailed failure output + + // Skip information (if status is "skipped") + SkipMessage *string `gorm:"type:text" json:"skip_message"` // Reason why the test was skipped + + // Output streams + SystemOut *string `gorm:"type:text" json:"system_out"` // stdout output + SystemErr *string `gorm:"type:text" json:"system_err"` // stderr output +} + +func (TestCase) TableName() string { + return "ci_test_cases" +} diff --git a/backend/plugins/testregistry/models/test_suite.go b/backend/plugins/testregistry/models/test_suite.go new file mode 100644 index 00000000000..032a6ecbafa --- /dev/null +++ b/backend/plugins/testregistry/models/test_suite.go @@ -0,0 +1,52 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// TestSuite represents a JUnit test suite from a CI job +// Each CI job can have multiple test suites from JUnit XML files +type TestSuite struct { + common.NoPKModel + + // Primary keys: connection + job + unique suite identifier + ConnectionId uint64 `gorm:"primaryKey;type:BIGINT NOT NULL"` + JobId string `gorm:"primaryKey;type:varchar(255);index" json:"job_id"` // Links to TestRegistryCIJob.JobId + SuiteId string `gorm:"primaryKey;type:varchar(255)" json:"suite_id"` // Unique identifier for the suite (UID) + + // Suite identification + Name string `gorm:"type:varchar(500);index" json:"name"` // Name of the test suite + + // Test statistics + NumTests uint `json:"num_tests"` // Total number of tests in the suite + NumSkipped uint `json:"num_skipped"` // Number of skipped tests + NumFailed uint `json:"num_failed"` // Number of failed tests + Duration float64 `json:"duration"` // Duration in seconds + + // Properties stored as JSON (optional test suite properties) + Properties string `gorm:"type:text" json:"properties"` // JSON string of suite properties + + // Parent suite reference (for nested suites) + ParentSuiteId *string `gorm:"type:varchar(255);index" json:"parent_suite_id"` // NULL for top-level suites +} + +func (TestSuite) TableName() string { + return "ci_test_suites" +} diff --git a/backend/plugins/testregistry/skills/pr-definition-of-done/SKILL.md b/backend/plugins/testregistry/skills/pr-definition-of-done/SKILL.md new file mode 100644 index 00000000000..a358707b793 --- /dev/null +++ b/backend/plugins/testregistry/skills/pr-definition-of-done/SKILL.md @@ -0,0 +1,41 @@ +--- +description: >- + Checklist for merge-ready PRs touching the testregistry plugin. + Use when preparing, reviewing, or finalizing a PR for testregistry. +--- + +# PR Definition of Done — testregistry Plugin + +## Required for All PRs + +- [ ] Changes scoped to `backend/plugins/testregistry/` +- [ ] `go vet ./plugins/testregistry/...` passes +- [ ] `go test ./plugins/testregistry/... -v` passes +- [ ] `golangci-lint run ./plugins/testregistry/...` passes (CI only runs lint on main, so check locally) +- [ ] No `//nolint` without justification comment +- [ ] Apache 2.0 license header on every new `.go` file + +## Schema / Model Changes + +- [ ] Migration script in `models/migrationscripts/` named `YYYYMMDD_description.go` +- [ ] Migration registered in `migrationscripts/register.go:All()` +- [ ] New models added to `GetTablesInfo()` in `impl/impl.go` +- [ ] Table name follows `_tool_testregistry_` convention + +## New Subtasks + +- [ ] Registered in `impl/impl.go:SubTaskMetas()` in correct execution order + +## Plugin-Specific + +- [ ] `CITool` field checked in collector entry point (skip if wrong CI tool type) +- [ ] Prow retry logic preserved (5 attempts, exponential backoff, transient status codes) +- [ ] Secrets use `serializer:encdec` gorm tag; sanitized in API responses +- [ ] JUnit regex changes tested with both default and custom patterns + +## CI Behavior + +- **Unit tests**: Run on PRs via `make unit-test-go` (excludes `e2e/` and `models/` packages) +- **E2E tests**: No e2e tests exist yet; adding `testregistry/e2e/` would be auto-discovered by CI +- **Lint**: Runs only on push to main/tags, not on PRs +- Upstream CI also runs `plugins/table_info_test.go` — fails if `GetTablesInfo()` is incomplete diff --git a/backend/plugins/testregistry/tasks/gcs_client.go b/backend/plugins/testregistry/tasks/gcs_client.go new file mode 100644 index 00000000000..f2071da53bc --- /dev/null +++ b/backend/plugins/testregistry/tasks/gcs_client.go @@ -0,0 +1,108 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "fmt" + "regexp" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/pkg/gcshelper" + "google.golang.org/api/iterator" + + "cloud.google.com/go/storage" +) + +// GCSBucket wraps gcshelper.GCSBucket and adds testregistry-specific helpers +// for fetching JUnit XML artifacts from the Openshift CI public bucket. +type GCSBucket struct { + *gcshelper.GCSBucket + // bkt is needed for the JUnit artifact listing which uses the raw GCS API. + bkt *storage.BucketHandle +} + +// NewGCSBucketClient creates a new GCS client for the Openshift CI public bucket +// (test-platform-results-public). +func NewGCSBucketClient(ctx context.Context) (*GCSBucket, errors.Error) { + inner, err := gcshelper.New(ctx, gcshelper.OpenshiftCIBucketName) + if err != nil { + return nil, err + } + // Access the underlying bucket handle via a thin accessor so that + // GetJobJunitContent can build its own object iterator. + bkt := inner.BucketHandle() + return &GCSBucket{GCSBucket: inner, bkt: bkt}, nil +} + +// maxJUnitFilesPerJob limits the number of JUnit files collected per job to +// prevent excessive memory usage. +const maxJUnitFilesPerJob = 50 + +// JUnitFile represents a single JUnit XML file fetched from GCS. +type JUnitFile struct { + Content []byte + Path string +} + +// GetJobJunitContent retrieves all matching JUnit XML files from GCS for a +// specific job. It iterates through all objects in the artifact directory and +// returns every file matching the regex pattern. +// +// Based on the quality-dashboard implementation: +// https://github.com/konflux-ci/quality-dashboard/blob/main/backend/pkg/connectors/gcs/gcs_authentication.go +func (b *GCSBucket) GetJobJunitContent(ctx context.Context, orgName, repoName, pullNumber, jobId, jobType, jobName string, fileName *regexp.Regexp) ([]JUnitFile, error) { + query := &storage.Query{} + + switch jobType { + case "presubmit": + query.Prefix = fmt.Sprintf("pr-logs/pull/%s_%s/%s/%s/%s/artifacts", orgName, repoName, pullNumber, jobName, jobId) + case "postsubmit": + query.Prefix = fmt.Sprintf("logs/%s/%s/artifacts", jobName, jobId) + case "periodic": + query.Prefix = fmt.Sprintf("logs/%s/%s/artifacts", jobName, jobId) + default: + query.Prefix = fmt.Sprintf("logs/%s/%s/artifacts", jobName, jobId) + } + + var results []JUnitFile + + it := b.bkt.Objects(ctx, query) + for { + obj, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return results, fmt.Errorf("GCS listing interrupted: %w", err) + } + + if fileName != nil && fileName.MatchString(obj.Name) { + content, err := b.GetContent(ctx, obj.Name) + if err != nil { + continue + } + results = append(results, JUnitFile{Content: content, Path: obj.Name}) + if len(results) >= maxJUnitFilesPerJob { + break + } + } + } + + return results, nil +} diff --git a/backend/plugins/testregistry/tasks/junit-processor.go b/backend/plugins/testregistry/tasks/junit-processor.go new file mode 100644 index 00000000000..3b9059e7847 --- /dev/null +++ b/backend/plugins/testregistry/tasks/junit-processor.go @@ -0,0 +1,517 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "crypto/rand" + "encoding/json" + "encoding/xml" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/log" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/testregistry/models" +) + +const ( + // uidChars are the characters used for generating unique IDs + uidChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + // uidLength is the length of generated UIDs + uidLength = 16 +) + +// isJobAlreadyProcessed checks if a CI job already has test suites and test cases in the database. +// This helps avoid duplicate fetching and processing of JUnit XML data. +// +// Parameters: +// - db: Database connection +// - connectionId: The DevLake connection ID +// - jobId: The CI job ID to check +// +// Returns: +// - bool: true if the job already has test suites in the database, false otherwise +func isJobAlreadyProcessed(db dal.Dal, connectionId uint64, jobId string) bool { + // Check if any test suites exist for this job + suiteCount, err := db.Count( + dal.From(&models.TestSuite{}), + dal.Where("connection_id = ? AND job_id = ?", connectionId, jobId), + ) + if err != nil { + // If query fails, assume not processed (safer to fetch again) + return false + } + + // If we have suites, the job is already processed + return suiteCount > 0 +} + +// fetchAndPrintJUnitSuites fetches JUnit XML from GCS and logs test suite information. +// +// This function: +// 1. Checks if the job is already processed (avoids duplicate fetching) +// 2. Creates a GCS client for accessing Openshift CI test results +// 3. Determines the correct GCS path based on job type (presubmit/postsubmit/periodic) +// 4. Fetches and parses JUnit XML files +// 5. Logs comprehensive suite information including nested suites +// 6. Saves test suites and test cases to the database +// +// For non-periodic jobs, it extracts org/repo from Prow job refs (matching quality-dashboard behavior). +// Reference: https://github.com/konflux-ci/quality-dashboard/blob/e846aa2dd9b3c1cad9ac4d16d18ddf677e3e6247/backend/api/server/prow_rotate.go#L64-L67 +// +// Parameters: +// - taskCtx: The subtask context +// - job: The source Prow job +// - githubOrg: Default GitHub organization (used as fallback) +// - repoName: Default repository name (used as fallback) +// - ciJob: The CI job model +// - junitRegex: Compiled regex pattern for matching JUnit file names (uses default if nil) +// +// Returns: +// - bool: true if JUnit XML was found and parsed successfully, false otherwise +func fetchAndPrintJUnitSuites(taskCtx plugin.SubTaskContext, gcsClient *GCSBucket, job *ProwJob, githubOrg, repoName string, ciJob *models.TestRegistryCIJob, junitRegex *regexp.Regexp) bool { + logger := taskCtx.GetLogger() + db := taskCtx.GetDal() + + // Use default regex if not provided + if junitRegex == nil { + junitRegex = JUnitRegexpSearch + } + + // Check if this job is already processed (has test suites in database) + if isJobAlreadyProcessed(db, ciJob.ConnectionId, ciJob.JobId) { + logger.Info("Job already processed, skipping JUnit fetch", "job_id", ciJob.JobId, "job_name", ciJob.JobName) + return true // Return true since we consider it "found" (already in DB) + } + + // Determine job type for GCS path construction + jobTypeForGCS, err := determineJobTypeForGCS(ciJob, job) + if err != nil { + logger.Info("unknown trigger type, skipping JUnit fetch", "trigger_type", ciJob.TriggerType, "job_id", ciJob.JobId, "job_name", ciJob.JobName) + return false + } + + // Extract PR number for presubmit jobs + pullNumber := extractPullRequestNumber(ciJob) + + // Fetch all JUnit XML files from GCS using configurable regex + ctx := taskCtx.GetContext() + junitFiles := fetchJUnitFromGCS(ctx, gcsClient, job, ciJob, jobTypeForGCS, githubOrg, repoName, pullNumber, logger, junitRegex) + + if len(junitFiles) == 0 { + logger.Info("No JUnit XML found for job", "job_id", ciJob.JobId, "job_name", ciJob.JobName, "trigger_type", ciJob.TriggerType) + return false + } + + logger.Info("Found JUnit XML files for job", "job_id", ciJob.JobId, "job_name", ciJob.JobName, "file_count", len(junitFiles)) + + // Parse, log, and save suite information from all files + anySuccess := false + for _, jf := range junitFiles { + if parseAndSaveJUnitSuites(taskCtx, logger, jf.Content, jf.Path, ciJob, githubOrg, repoName) { + anySuccess = true + } + } + return anySuccess +} + +// determineJobTypeForGCS maps our trigger type to GCS job type format. +// +// Mapping: +// - "pull_request" -> "presubmit" +// - "push" -> "postsubmit" +// - "periodic" -> "periodic" +// +// Parameters: +// - ciJob: The CI job model +// - job: The source Prow job (used as fallback) +// +// Returns: +// - string: The job type for GCS ("presubmit", "postsubmit", or "periodic") +// - errors.Error: Error if type cannot be determined +func determineJobTypeForGCS(ciJob *models.TestRegistryCIJob, job *ProwJob) (string, errors.Error) { + switch ciJob.TriggerType { + case "pull_request": + return "presubmit", nil + case "push": + return "postsubmit", nil + case "periodic": + return "periodic", nil + default: + // Fallback: try to infer from Prow job spec type + if job.Spec.Type != "" { + return strings.ToLower(job.Spec.Type), nil + } + return "", errors.Default.New("cannot determine job type for GCS") + } +} + +// extractPullRequestNumber extracts the PR number as a string for GCS path construction. +// +// Parameters: +// - ciJob: The CI job model +// +// Returns: +// - string: PR number as string, or empty string if not available +func extractPullRequestNumber(ciJob *models.TestRegistryCIJob) string { + if ciJob.TriggerType == "pull_request" && ciJob.PullRequestNumber != nil { + return strconv.Itoa(*ciJob.PullRequestNumber) + } + return "" +} + +// fetchJUnitFromGCS fetches all matching JUnit XML files from Google Cloud Storage. +// +// For non-periodic jobs, it extracts org/repo from Prow job refs to match quality-dashboard behavior. +// Reference: https://github.com/konflux-ci/quality-dashboard/blob/e846aa2dd9b3c1cad9ac4d16d18ddf677e3e6247/backend/api/server/prow_rotate.go#L64-L67 +func fetchJUnitFromGCS( + ctx context.Context, + gcsClient *GCSBucket, + job *ProwJob, + ciJob *models.TestRegistryCIJob, + jobTypeForGCS string, + githubOrg string, + repoName string, + pullNumber string, + logger log.Logger, + junitRegex *regexp.Regexp, +) []JUnitFile { + logger.Debug("Searching for JUnit XML in GCS", "job_id", ciJob.JobId, "job_name", ciJob.JobName, "job_type_for_gcs", jobTypeForGCS, "org", githubOrg, "repo", repoName, "pull_number", pullNumber) + + var files []JUnitFile + var gcsErr error + + // Periodic jobs: empty org/repo/pr + if jobTypeForGCS == "periodic" { + files, gcsErr = gcsClient.GetJobJunitContent(ctx, "", "", "", ciJob.JobId, "periodic", ciJob.JobName, junitRegex) + } else { + // For non-periodic jobs, extract org/repo from Prow job refs + orgForGCS, repoForGCS := extractOrgRepoForGCS(job, githubOrg, repoName, ciJob.JobId, logger) + + // Presubmit: need org, repo, and PR number + if jobTypeForGCS == "presubmit" { + if pullNumber == "" { + logger.Info("Missing PR number for presubmit job, skipping JUnit fetch", "job_id", ciJob.JobId, "job_name", ciJob.JobName) + return nil + } + files, gcsErr = gcsClient.GetJobJunitContent(ctx, orgForGCS, repoForGCS, pullNumber, ciJob.JobId, "presubmit", ciJob.JobName, junitRegex) + } else { + // Postsubmit: need org and repo, but no PR number + files, gcsErr = gcsClient.GetJobJunitContent(ctx, orgForGCS, repoForGCS, "", ciJob.JobId, "postsubmit", ciJob.JobName, junitRegex) + } + } + + if gcsErr != nil { + logger.Info("GCS listing error (partial results may be returned)", "error", gcsErr, "job_id", ciJob.JobId, "files_found", len(files)) + } + + return files +} + +// extractOrgRepoForGCS extracts organization and repository names for GCS path construction. +// +// For non-periodic jobs, this function extracts org/repo from Prow job refs (matching quality-dashboard). +// Falls back to connection values if refs are not available. +// +// Parameters: +// - job: The source Prow job +// - githubOrg: Default GitHub organization (used as fallback) +// - repoName: Default repository name (used as fallback) +// - jobId: Job ID for logging +// - logger: Logger for debug messages +// +// Returns: +// - string: Organization name +// - string: Repository name +func extractOrgRepoForGCS(job *ProwJob, githubOrg, repoName, jobId string, logger log.Logger) (string, string) { + if job.Spec.Refs != nil && job.Spec.Refs.Org != "" && job.Spec.Refs.Repo != "" { + return job.Spec.Refs.Org, job.Spec.Refs.Repo + } + + // Fallback to connection values + logger.Debug("Using connection org/repo as fallback", "org", githubOrg, "repo", repoName, "job_id", jobId) + return githubOrg, repoName +} + +// parseAndSaveJUnitSuites parses JUnit XML, logs comprehensive test suite information, and saves to database. +// +// Parameters: +// - taskCtx: The subtask context (for database access) +// - logger: Logger for output +// - suites: JUnit XML content (can be nil) +// - xmlFileName: Name of the XML file (for logging) +// - ciJob: The CI job model +// - githubOrg: GitHub organization (for logging) +// - repoName: Repository name (for logging) +// +// Returns: +// - bool: true if JUnit XML was successfully parsed, logged, and saved, false otherwise +func parseAndSaveJUnitSuites(taskCtx plugin.SubTaskContext, logger log.Logger, suites []byte, xmlFileName string, ciJob *models.TestRegistryCIJob, githubOrg, repoName string) bool { + if len(suites) == 0 { + logger.Info("No JUnit XML found for job", "job_id", ciJob.JobId, "job_name", ciJob.JobName, "trigger_type", ciJob.TriggerType) + return false + } + + // Parse XML — JUnit files can have either (wrapper) or bare as root + var suitesXml TestSuites + if err := xml.Unmarshal(suites, &suitesXml); err != nil { + logger.Debug("failed to parse JUnit XML", "error", err, "job_id", ciJob.JobId, "xml_file", xmlFileName) + return false + } + + // If no suites found, try parsing as a single bare root element + // (e.g., prowjob_junit.xml uses instead of ) + if len(suitesXml.Suites) == 0 { + var singleSuite TestSuite + if err := xml.Unmarshal(suites, &singleSuite); err == nil && singleSuite.Name != "" { + suitesXml.Suites = []*TestSuite{&singleSuite} + } + } + + // Log job context + logger.Info("JUnit XML found for job", + "job_id", ciJob.JobId, + "job_name", ciJob.JobName, + "organization", githubOrg, + "repository", repoName, + "trigger_type", ciJob.TriggerType, + "xml_file", xmlFileName, + "result", ciJob.Result) + + // Check if we have any suites + if len(suitesXml.Suites) == 0 { + logger.Info("No test suites found in JUnit XML", "job_id", ciJob.JobId, "job_name", ciJob.JobName, "xml_file", xmlFileName) + return false + } + + logger.Info("Processing test suites", "job_id", ciJob.JobId, "total_suites", len(suitesXml.Suites)) + + // Get database connection + db := taskCtx.GetDal() + + // Process and save each suite (including nested ones) + savedSuites := 0 + savedTestCases := 0 + for idx, suite := range suitesXml.Suites { + if suite != nil && suite.Name != "" { + logSuiteInfo(logger, suite, ciJob.JobId, idx+1, 0) + + // Save top-level suite and all nested suites recursively + suiteCount, testCaseCount := saveSuiteRecursively(db, logger, suite, ciJob.ConnectionId, ciJob.JobId, nil) + savedSuites += suiteCount + savedTestCases += testCaseCount + } + } + + logger.Info("Saved JUnit data to database", + "job_id", ciJob.JobId, + "suites_saved", savedSuites, + "test_cases_saved", savedTestCases) + + return true +} + +// logSuiteInfo logs information about a test suite. +// +// Parameters: +// - logger: Logger for output +// - suite: The test suite to log +// - jobId: Job ID for correlation +// - suiteIndex: Index of the suite (1-based) +// - depth: Nesting depth (0 for top-level) +func logSuiteInfo(logger log.Logger, suite *TestSuite, jobId string, suiteIndex int, depth int) { + logger.Info("Test Suite", + "job_id", jobId, + "suite_index", suiteIndex, + "suite_name", suite.Name, + "tests", suite.NumTests, + "failures", suite.NumFailed, + "skipped", suite.NumSkipped, + "duration_sec", suite.Duration) +} + +// generateUID generates a unique identifier for database records using crypto/rand. +func generateUID() string { + b := make([]byte, uidLength) + randomBytes := make([]byte, uidLength) + if _, err := rand.Read(randomBytes); err != nil { + // crypto/rand.Read only fails on catastrophic system issues; use fallback + for i := range b { + b[i] = uidChars[0] + } + return string(b) + } + for i := range b { + b[i] = uidChars[int(randomBytes[i])%len(uidChars)] + } + return string(b) +} + +// saveSuiteRecursively saves a test suite and all its nested suites and test cases to the database. +// +// This function recursively processes nested suites and saves them with proper parent-child relationships. +// +// Parameters: +// - db: Database connection +// - logger: Logger for output +// - suite: The test suite XML structure to save +// - connectionId: The DevLake connection ID +// - jobId: The CI job ID +// - parentSuiteId: The parent suite ID (nil for top-level suites) +// +// Returns: +// - int: Number of suites saved (including nested ones) +// - int: Number of test cases saved +func saveSuiteRecursively(db dal.Dal, logger log.Logger, suite *TestSuite, connectionId uint64, jobId string, parentSuiteId *string) (int, int) { + if suite == nil || suite.Name == "" { + return 0, 0 + } + + // Always create a new suite — dedup across JUnit files is intentionally skipped so that + // suites with the same name from different files (e.g., same test suite run with different + // parameters) are stored independently. The job-level isJobAlreadyProcessed check prevents + // re-processing across blueprint runs. + suiteId := generateUID() + + // Convert properties to JSON string + propertiesJSON := "" + if len(suite.Properties) > 0 { + propertiesBytes, err := json.Marshal(suite.Properties) + if err != nil { + logger.Debug("failed to marshal suite properties", "suite_name", suite.Name, "job_id", jobId, "error", err) + } else { + propertiesJSON = string(propertiesBytes) + } + } + + // Create database model + testSuite := &models.TestSuite{ + ConnectionId: connectionId, + JobId: jobId, + SuiteId: suiteId, + Name: suite.Name, + NumTests: suite.NumTests, + NumSkipped: suite.NumSkipped, + NumFailed: suite.NumFailed, + Duration: suite.Duration, + Properties: propertiesJSON, + ParentSuiteId: parentSuiteId, + } + + // Save suite to database + if err := db.CreateOrUpdate(testSuite); err != nil { + logger.Warn(err, "failed to save test suite", "suite_id", suiteId, "suite_name", suite.Name, "job_id", jobId) + return 0, 0 + } + + suiteCount := 1 + testCaseCount := 0 + + // Save test cases for this suite + for _, testCase := range suite.TestCases { + if testCase != nil { + if err := saveTestCase(db, logger, testCase, connectionId, jobId, suiteId); err == nil { + testCaseCount++ + } + } + } + + // Recursively save nested suites + for _, child := range suite.Children { + if child != nil { + childSuiteId := suiteId // Pass current suite ID as parent + nestedSuiteCount, nestedTestCaseCount := saveSuiteRecursively(db, logger, child, connectionId, jobId, &childSuiteId) + suiteCount += nestedSuiteCount + testCaseCount += nestedTestCaseCount + } + } + + return suiteCount, testCaseCount +} + +// saveTestCase saves a single test case to the database. +// +// Parameters: +// - db: Database connection +// - logger: Logger for output +// - testCase: The test case XML structure to save +// - connectionId: The DevLake connection ID +// - jobId: The CI job ID +// - suiteId: The parent suite ID +// +// Returns: +// - errors.Error: Any error encountered during saving, or nil if successful +func saveTestCase(db dal.Dal, logger log.Logger, testCase *TestCase, connectionId uint64, jobId, suiteId string) errors.Error { + // Always create a new test case — each suite has a unique ID so test cases are + // naturally scoped to their source JUnit file. No cross-file dedup needed. + testCaseId := generateUID() + + // Determine test case status + status := "passed" + var failureMessage, failureOutput *string + var skipMessage *string + + if testCase.FailureOutput != nil { + status = "failed" + failureMsg := testCase.FailureOutput.Message + failureMessage = &failureMsg + failureOut := testCase.FailureOutput.Output + failureOutput = &failureOut + } else if testCase.SkipMessage != nil { + status = "skipped" + skipMsg := testCase.SkipMessage.Message + skipMessage = &skipMsg + } + + // Create database model + testCaseModel := &models.TestCase{ + ConnectionId: connectionId, + JobId: jobId, + SuiteId: suiteId, + TestCaseId: testCaseId, + Name: testCase.Name, + Classname: testCase.Classname, + Duration: testCase.Duration, + Status: status, + FailureMessage: failureMessage, + FailureOutput: failureOutput, + SkipMessage: skipMessage, + SystemOut: stringPtrOrNil(testCase.SystemOut), + SystemErr: stringPtrOrNil(testCase.SystemErr), + } + + // Save test case to database + if err := db.CreateOrUpdate(testCaseModel); err != nil { + return errors.Default.Wrap(err, fmt.Sprintf("failed to save test case %s", testCase.Name)) + } + + return nil +} + +// stringPtrOrNil converts a string to a pointer, returning nil if the string is empty +func stringPtrOrNil(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/backend/plugins/testregistry/tasks/junit_processor_test.go b/backend/plugins/testregistry/tasks/junit_processor_test.go new file mode 100644 index 00000000000..b523aa912fa --- /dev/null +++ b/backend/plugins/testregistry/tasks/junit_processor_test.go @@ -0,0 +1,373 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/apache/devlake/core/errors" + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + mockplugin "github.com/apache/devlake/mocks/core/plugin" + "github.com/apache/devlake/plugins/testregistry/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestDetermineJobTypeForGCS(t *testing.T) { + t.Run("pull_request maps to presubmit", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{TriggerType: "pull_request"} + result, err := determineJobTypeForGCS(ciJob, &ProwJob{}) + assert.Nil(t, err) + assert.Equal(t, "presubmit", result) + }) + + t.Run("push maps to postsubmit", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{TriggerType: "push"} + result, err := determineJobTypeForGCS(ciJob, &ProwJob{}) + assert.Nil(t, err) + assert.Equal(t, "postsubmit", result) + }) + + t.Run("periodic maps to periodic", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{TriggerType: "periodic"} + result, err := determineJobTypeForGCS(ciJob, &ProwJob{}) + assert.Nil(t, err) + assert.Equal(t, "periodic", result) + }) + + t.Run("unknown falls back to prow spec type", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{TriggerType: "custom"} + prowJob := &ProwJob{Spec: ProwJobSpec{Type: "Presubmit"}} + result, err := determineJobTypeForGCS(ciJob, prowJob) + assert.Nil(t, err) + assert.Equal(t, "presubmit", result) + }) + + t.Run("unknown with no fallback returns error", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{TriggerType: "custom"} + _, err := determineJobTypeForGCS(ciJob, &ProwJob{}) + assert.NotNil(t, err) + }) +} + +func TestExtractPullRequestNumber(t *testing.T) { + t.Run("returns PR number for pull_request trigger", func(t *testing.T) { + prNum := 42 + ciJob := &models.TestRegistryCIJob{ + TriggerType: "pull_request", + PullRequestNumber: &prNum, + } + assert.Equal(t, "42", extractPullRequestNumber(ciJob)) + }) + + t.Run("returns empty for non-pull_request trigger", func(t *testing.T) { + prNum := 42 + ciJob := &models.TestRegistryCIJob{ + TriggerType: "push", + PullRequestNumber: &prNum, + } + assert.Equal(t, "", extractPullRequestNumber(ciJob)) + }) + + t.Run("returns empty when PR number is nil", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{ + TriggerType: "pull_request", + } + assert.Equal(t, "", extractPullRequestNumber(ciJob)) + }) +} + +func TestStringPtrOrNil(t *testing.T) { + t.Run("non-empty returns pointer", func(t *testing.T) { + result := stringPtrOrNil("hello") + assert.NotNil(t, result) + assert.Equal(t, "hello", *result) + }) + + t.Run("empty returns nil", func(t *testing.T) { + result := stringPtrOrNil("") + assert.Nil(t, result) + }) +} + +func TestGenerateUID(t *testing.T) { + t.Run("returns 16-char string", func(t *testing.T) { + uid := generateUID() + assert.Len(t, uid, 16) + }) + + t.Run("two calls produce different UIDs", func(t *testing.T) { + uid1 := generateUID() + uid2 := generateUID() + assert.NotEqual(t, uid1, uid2) + }) + + t.Run("only contains valid characters", func(t *testing.T) { + uid := generateUID() + for _, c := range uid { + assert.Contains(t, uidChars, string(c)) + } + }) +} + +func TestIsJobAlreadyProcessed(t *testing.T) { + t.Run("count > 0 returns true", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Count", mock.Anything).Return(int64(5), nil) + assert.True(t, isJobAlreadyProcessed(mockDal, 1, "job-1")) + }) + + t.Run("count = 0 returns false", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Count", mock.Anything).Return(int64(0), nil) + assert.False(t, isJobAlreadyProcessed(mockDal, 1, "job-1")) + }) + + t.Run("error returns false", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Count", mock.Anything).Return(int64(0), errors.Default.New("db error")) + assert.False(t, isJobAlreadyProcessed(mockDal, 1, "job-1")) + }) +} + +func TestLogSuiteInfo(t *testing.T) { + mockLogger := new(mocklog.Logger) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + suite := &TestSuite{ + Name: "TestSuite1", + NumTests: 10, + NumFailed: 2, + NumSkipped: 1, + Duration: 5.5, + } + + // Should not panic + logSuiteInfo(mockLogger, suite, "job-1", 1, 0) + mockLogger.AssertCalled(t, "Info", mock.Anything, mock.Anything) +} + +func TestSaveTestCase(t *testing.T) { + t.Run("passing test", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + tc := &TestCase{Name: "TestFoo", Classname: "pkg.Foo", Duration: 1.5} + err := saveTestCase(mockDal, mockLogger, tc, 1, "job-1", "suite-1") + assert.Nil(t, err) + mockDal.AssertCalled(t, "CreateOrUpdate", mock.Anything, mock.Anything) + }) + + t.Run("failed test", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + tc := &TestCase{ + Name: "TestBar", + FailureOutput: &FailureOutput{Message: "assertion failed", Output: "expected true"}, + } + err := saveTestCase(mockDal, mockLogger, tc, 1, "job-1", "suite-1") + assert.Nil(t, err) + }) + + t.Run("skipped test", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + + tc := &TestCase{ + Name: "TestSkipped", + SkipMessage: &SkipMessage{Message: "not implemented"}, + } + err := saveTestCase(mockDal, mockLogger, tc, 1, "job-1", "suite-1") + assert.Nil(t, err) + }) + + t.Run("CreateOrUpdate error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(errors.Default.New("db error")) + + tc := &TestCase{Name: "TestErr"} + err := saveTestCase(mockDal, mockLogger, tc, 1, "job-1", "suite-1") + assert.NotNil(t, err) + }) +} + +func TestSaveSuiteRecursively(t *testing.T) { + t.Run("nil suite returns 0,0", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + s, tc := saveSuiteRecursively(mockDal, mockLogger, nil, 1, "job-1", nil) + assert.Equal(t, 0, s) + assert.Equal(t, 0, tc) + }) + + t.Run("empty name returns 0,0", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + suite := &TestSuite{Name: ""} + s, tc := saveSuiteRecursively(mockDal, mockLogger, suite, 1, "job-1", nil) + assert.Equal(t, 0, s) + assert.Equal(t, 0, tc) + }) + + t.Run("suite with one test case", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + + suite := &TestSuite{ + Name: "MySuite", + NumTests: 1, + TestCases: []*TestCase{ + {Name: "TestFoo", Duration: 1.0}, + }, + } + s, tc := saveSuiteRecursively(mockDal, mockLogger, suite, 1, "job-1", nil) + assert.Equal(t, 1, s) + assert.Equal(t, 1, tc) + }) + + t.Run("suite with nested child", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + + child := &TestSuite{ + Name: "ChildSuite", + TestCases: []*TestCase{ + {Name: "ChildTest"}, + }, + } + suite := &TestSuite{ + Name: "ParentSuite", + Children: []*TestSuite{child}, + } + s, tc := saveSuiteRecursively(mockDal, mockLogger, suite, 1, "job-1", nil) + assert.Equal(t, 2, s) + assert.Equal(t, 1, tc) + }) + + t.Run("suite with properties", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + + suite := &TestSuite{ + Name: "PropSuite", + Properties: []*TestSuiteProperty{ + {Name: "key1", Value: "val1"}, + }, + } + s, tc := saveSuiteRecursively(mockDal, mockLogger, suite, 1, "job-1", nil) + assert.Equal(t, 1, s) + assert.Equal(t, 0, tc) + }) + + t.Run("CreateOrUpdate error on suite", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(errors.Default.New("db error")) + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + + suite := &TestSuite{Name: "FailSuite"} + s, tc := saveSuiteRecursively(mockDal, mockLogger, suite, 1, "job-1", nil) + assert.Equal(t, 0, s) + assert.Equal(t, 0, tc) + }) +} + +func TestParseAndSaveJUnitSuites(t *testing.T) { + t.Run("valid XML with one suite", func(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + mockCtx.On("GetDal").Return(mockDal) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + + xmlData := []byte(` + + + + `) + + ciJob := &models.TestRegistryCIJob{ConnectionId: 1, JobId: "job-1", JobName: "test", TriggerType: "push", Result: "SUCCESS"} + result := parseAndSaveJUnitSuites(mockCtx, mockLogger, xmlData, "junit.xml", ciJob, "org", "repo") + assert.True(t, result) + }) + + t.Run("empty suites bytes", func(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockLogger := new(mocklog.Logger) + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + + ciJob := &models.TestRegistryCIJob{JobId: "job-1", JobName: "test"} + result := parseAndSaveJUnitSuites(mockCtx, mockLogger, []byte{}, "junit.xml", ciJob, "org", "repo") + assert.False(t, result) + }) + + t.Run("invalid XML", func(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockLogger := new(mocklog.Logger) + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + + ciJob := &models.TestRegistryCIJob{JobId: "job-1", JobName: "test"} + result := parseAndSaveJUnitSuites(mockCtx, mockLogger, []byte("not xml"), "junit.xml", ciJob, "org", "repo") + assert.False(t, result) + }) + + t.Run("bare testsuite root element returns false", func(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockLogger := new(mocklog.Logger) + + // xml.Unmarshal returns an error when root is instead of + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + + xmlData := []byte(``) + ciJob := &models.TestRegistryCIJob{ConnectionId: 1, JobId: "job-1", JobName: "test", Result: "SUCCESS"} + result := parseAndSaveJUnitSuites(mockCtx, mockLogger, xmlData, "junit.xml", ciJob, "org", "repo") + assert.False(t, result) + }) + + t.Run("testsuites with empty suites and bare fallback", func(t *testing.T) { + mockCtx := new(mockplugin.SubTaskContext) + mockLogger := new(mocklog.Logger) + + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + + // Empty testsuites wrapper triggers the fallback path, but since it's a valid + // with no children, the single suite fallback won't match either + xmlData := []byte(``) + ciJob := &models.TestRegistryCIJob{ConnectionId: 1, JobId: "job-1", JobName: "test", Result: "SUCCESS"} + result := parseAndSaveJUnitSuites(mockCtx, mockLogger, xmlData, "junit.xml", ciJob, "org", "repo") + assert.False(t, result) + }) +} diff --git a/backend/plugins/testregistry/tasks/junit_types.go b/backend/plugins/testregistry/tasks/junit_types.go new file mode 100644 index 00000000000..dfcb03251ed --- /dev/null +++ b/backend/plugins/testregistry/tasks/junit_types.go @@ -0,0 +1,205 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/xml" + "regexp" + "sync" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/log" +) + +// DefaultJUnitRegexPattern is the default regex pattern for matching JUnit XML file names. +// Matches files starting with "devlake-", "e2e", or "qd-report-" and ending with .xml or .junit. +// Users can override this per-connection via the junitRegex field. +const DefaultJUnitRegexPattern = `(devlake-|e2e|qd-report-)[0-9a-z-]+\.(xml|junit)` + +// JUnitRegexpSearch is the compiled default regex for backwards compatibility +// Deprecated: Use GetJUnitRegex(pattern) instead for configurable regex support +var JUnitRegexpSearch = regexp.MustCompile(DefaultJUnitRegexPattern) + +// maxRegexCacheSize limits the number of cached regex patterns to prevent unbounded memory growth +const maxRegexCacheSize = 100 + +// regexCache caches compiled regex patterns to avoid recompilation +var regexCache = struct { + sync.RWMutex + patterns map[string]*regexp.Regexp +}{ + patterns: make(map[string]*regexp.Regexp), +} + +// GetJUnitRegex returns a compiled regex for the given pattern, or the default if pattern is empty. +// The compiled regex is cached for performance. +// +// Parameters: +// - pattern: The regex pattern string to compile. If empty, DefaultJUnitRegexPattern is used. +// - logger: Optional logger for error reporting (can be nil) +// +// Returns: +// - *regexp.Regexp: The compiled regex pattern +// - errors.Error: Any error if the pattern is invalid +func GetJUnitRegex(pattern string, logger log.Logger) (*regexp.Regexp, errors.Error) { + // Use default pattern if not specified + if pattern == "" { + return JUnitRegexpSearch, nil + } + + // Check cache first + regexCache.RLock() + if cached, ok := regexCache.patterns[pattern]; ok { + regexCache.RUnlock() + return cached, nil + } + regexCache.RUnlock() + + // Compile the pattern + compiled, err := regexp.Compile(pattern) + if err != nil { + if logger != nil { + logger.Warn(err, "invalid JUnit regex pattern, falling back to default", "pattern", pattern) + } + // Return default regex on compilation error for backwards compatibility + return JUnitRegexpSearch, errors.BadInput.Wrap(err, "invalid JUnit regex pattern") + } + + // Cache the compiled pattern (with size limit) + regexCache.Lock() + if len(regexCache.patterns) < maxRegexCacheSize { + regexCache.patterns[pattern] = compiled + } + regexCache.Unlock() + + return compiled, nil +} + +// GetJUnitRegexOrDefault returns the compiled regex for the given pattern, falling back to default on error. +// This is a convenience function that never returns an error. +// +// Parameters: +// - pattern: The regex pattern string to compile. If empty, DefaultJUnitRegexPattern is used. +// - logger: Optional logger for error reporting (can be nil) +// +// Returns: +// - *regexp.Regexp: The compiled regex pattern (always returns a valid regex) +func GetJUnitRegexOrDefault(pattern string, logger log.Logger) *regexp.Regexp { + regex, err := GetJUnitRegex(pattern, logger) + if err != nil { + return JUnitRegexpSearch + } + return regex +} + +// The below types are directly marshalled into XML. The types correspond to jUnit +// XML schema, but do not contain all valid fields. For instance, the class name +// field for test cases is omitted, as this concept does not directly apply to Go. +// For XML specifications see http://help.catchsoftware.com/display/ET/JUnit+Format +// or view the XSD included in this package as 'junit.xsd' +// + +// TestSuites represents a flat collection of jUnit test suites. +type TestSuites struct { + XMLName xml.Name `xml:"testsuites"` + + // Suites are the jUnit test suites held in this collection + Suites []*TestSuite `xml:"testsuite"` +} + +// TestSuite represents a single jUnit test suite, potentially holding child suites. +type TestSuite struct { + XMLName xml.Name `xml:"testsuite"` + + // Name is the name of the test suite + Name string `xml:"name,attr"` + + // NumTests records the number of tests in the TestSuite + NumTests uint `xml:"tests,attr"` + + // NumSkipped records the number of skipped tests in the suite + NumSkipped uint `xml:"skipped,attr"` + + // NumFailed records the number of failed tests in the suite + NumFailed uint `xml:"failures,attr"` + + // Duration is the time taken in seconds to run all tests in the suite + Duration float64 `xml:"time,attr"` + + // Properties holds other properties of the test suite as a mapping of name to value + Properties []*TestSuiteProperty `xml:"properties,omitempty"` + + // TestCases are the test cases contained in the test suite + TestCases []*TestCase `xml:"testcase"` + + // Children holds nested test suites + Children []*TestSuite `xml:"testsuite"` //nolint +} + +// TestSuiteProperty contains a mapping of a property name to a value +type TestSuiteProperty struct { + XMLName xml.Name `xml:"properties"` + + Name string `xml:"name,attr"` + Value string `xml:"value,attr"` +} + +// TestCase represents a jUnit test case +type TestCase struct { + XMLName xml.Name `xml:"testcase"` + + // Name is the name of the test case + Name string `xml:"name,attr"` + + // Classname is an attribute set by the package type and is required + Classname string `xml:"classname,attr,omitempty"` + + // Duration is the time taken in seconds to run the test + Duration float64 `xml:"time,attr"` + + // SkipMessage holds the reason why the test was skipped + SkipMessage *SkipMessage `xml:"skipped"` + + // FailureOutput holds the output from a failing test + FailureOutput *FailureOutput `xml:"failure"` + + // SystemOut is output written to stdout during the execution of this test case + SystemOut string `xml:"system-out,omitempty"` + + // SystemErr is output written to stderr during the execution of this test case + SystemErr string `xml:"system-err,omitempty"` +} + +// SkipMessage holds a message explaining why a test was skipped +type SkipMessage struct { + XMLName xml.Name `xml:"skipped"` + + // Message explains why the test was skipped + Message string `xml:"message,attr,omitempty"` +} + +// FailureOutput holds the output from a failing test +type FailureOutput struct { + XMLName xml.Name `xml:"failure"` + + // Message holds the failure message from the test + Message string `xml:"message,attr"` + + // Output holds verbose failure output from the test + Output string `xml:",chardata"` +} diff --git a/backend/plugins/testregistry/tasks/junit_types_test.go b/backend/plugins/testregistry/tasks/junit_types_test.go new file mode 100644 index 00000000000..47fa8de75f1 --- /dev/null +++ b/backend/plugins/testregistry/tasks/junit_types_test.go @@ -0,0 +1,89 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + mocklog "github.com/apache/devlake/mocks/core/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestGetJUnitRegex(t *testing.T) { + t.Run("empty pattern returns default", func(t *testing.T) { + regex, err := GetJUnitRegex("", nil) + assert.Nil(t, err) + assert.Equal(t, JUnitRegexpSearch, regex) + }) + + t.Run("valid custom pattern returns compiled regex", func(t *testing.T) { + regex, err := GetJUnitRegex(`test-.*\.xml`, nil) + assert.Nil(t, err) + assert.NotNil(t, regex) + assert.True(t, regex.MatchString("test-results.xml")) + assert.False(t, regex.MatchString("other.json")) + }) + + t.Run("cached pattern returns same object", func(t *testing.T) { + pattern := `cached-pattern-[0-9]+\.xml` + first, err1 := GetJUnitRegex(pattern, nil) + second, err2 := GetJUnitRegex(pattern, nil) + assert.Nil(t, err1) + assert.Nil(t, err2) + assert.Same(t, first, second) + }) + + t.Run("invalid pattern returns error and default", func(t *testing.T) { + mockLogger := new(mocklog.Logger) + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + + regex, err := GetJUnitRegex(`[invalid`, mockLogger) + assert.NotNil(t, err) + assert.Equal(t, JUnitRegexpSearch, regex) + }) + + t.Run("default regex matches expected patterns", func(t *testing.T) { + regex, _ := GetJUnitRegex("", nil) + assert.True(t, regex.MatchString("devlake-abc123.xml")) + assert.True(t, regex.MatchString("e2e-test.xml")) + assert.True(t, regex.MatchString("qd-report-results.junit")) + assert.False(t, regex.MatchString("random-file.txt")) + }) +} + +func TestGetJUnitRegexOrDefault(t *testing.T) { + t.Run("empty pattern returns default", func(t *testing.T) { + regex := GetJUnitRegexOrDefault("", nil) + assert.Equal(t, JUnitRegexpSearch, regex) + }) + + t.Run("valid pattern returns compiled regex", func(t *testing.T) { + regex := GetJUnitRegexOrDefault(`custom-.*\.xml`, nil) + assert.NotNil(t, regex) + assert.True(t, regex.MatchString("custom-test.xml")) + }) + + t.Run("invalid pattern returns default without error", func(t *testing.T) { + mockLogger := new(mocklog.Logger) + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe() + + regex := GetJUnitRegexOrDefault(`[broken`, mockLogger) + assert.Equal(t, JUnitRegexpSearch, regex) + }) +} diff --git a/backend/plugins/testregistry/tasks/openshift_ci_jobs_types.go b/backend/plugins/testregistry/tasks/openshift_ci_jobs_types.go new file mode 100644 index 00000000000..86762ab3db5 --- /dev/null +++ b/backend/plugins/testregistry/tasks/openshift_ci_jobs_types.go @@ -0,0 +1,126 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// ProwJobPull represents a pull request associated with a Prow job. +// Prow jobs can be triggered by pull requests, and this struct contains the PR details. +type ProwJobPull struct { + // Number is the pull request number (e.g., 123) + Number int `json:"number"` + + // Author is the GitHub username of the PR author + Author string `json:"author"` + + // SHA is the git commit SHA of the PR head commit + SHA string `json:"sha"` + + // Title is the pull request title + Title string `json:"title"` +} + +// ProwJobRefs represents git repository references (refs) used by a Prow job. +// Prow jobs clone and test code from specific git repositories and branches. +// This struct contains information about which repository and branch to use. +type ProwJobRefs struct { + // Org is the GitHub organization name (e.g., "openshift") + Org string `json:"org"` + + // Repo is the repository name within the organization (e.g., "release") + Repo string `json:"repo"` + + // BaseRef is the base branch to test against (e.g., "master", "main", "release-4.10") + BaseRef string `json:"base_ref"` + + // BaseSHA is the specific commit SHA of the base branch to test + BaseSHA string `json:"base_sha"` + + // Pulls is the list of pull requests being tested (empty for periodic/postsubmit jobs) + Pulls []ProwJobPull `json:"pulls"` + + // PathAlias is an optional path alias for the repository (used in GOPATH scenarios) + PathAlias string `json:"path_alias"` +} + +// ProwJobSpec represents the specification/configuration of a Prow job. +// It defines what the job should do, where it runs, and how it should be executed. +type ProwJobSpec struct { + // Job is the name of the Prow job (e.g., "periodic-ci-openshift-release-master-test") + Job string `json:"job"` + + // Refs are the primary git repository references (main repo to test) + Refs *ProwJobRefs `json:"refs"` + + // ExtraRefs are additional git repositories needed (dependencies, test frameworks, etc.) + ExtraRefs []*ProwJobRefs `json:"extra_refs"` + + // Type is the job type: "presubmit", "postsubmit", or "periodic" + Type string `json:"type"` + + // Agent is the execution agent: "kubernetes" or "jenkins" + Agent string `json:"agent"` + + // Cluster is the Kubernetes cluster name where the job runs (e.g., "build01", "build07") + Cluster string `json:"cluster"` + + // Namespace is the Kubernetes namespace (typically "ci") + Namespace string `json:"namespace"` + + // ReporterConfig is the configuration for reporting results (GCS, Slack, etc.) + ReporterConfig map[string]interface{} `json:"reporter_config"` + + // Decorate indicates whether Prow should add metadata and artifacts (logs, test results) to the job + Decorate bool `json:"decorate"` +} + +// ProwJobStatus represents the current status and execution details of a Prow job. +// It tracks the job's lifecycle from creation to completion. +type ProwJobStatus struct { + // State is the current state: "pending", "triggered", "success", "failure", "aborted", "error" + State string `json:"state"` + // StartTime is the ISO 8601 timestamp when the job started executing + StartTime string `json:"startTime"` + // PendingTime is the ISO 8601 timestamp when the job was created and pending + PendingTime string `json:"pendingTime"` + // CompletionTime is the ISO 8601 timestamp when the job finished (success or failure) + CompletionTime string `json:"completionTime"` + // URL is the URL to view the job in Prow UI (e.g., "https://prow.ci.openshift.org/view/gs/...") + URL string `json:"url"` + // BuildID is the unique build identifier (used for GCS paths and job identification) + BuildID string `json:"build_id"` + // PodName is the Kubernetes pod name running the job (for kubernetes agent jobs) + PodName string `json:"pod_name"` + // Description is a human-readable description of the current job state + Description string `json:"description"` +} + +// ProwJob represents a complete Prow job from the OpenShift CI Prow API. +// Prow is the CI/CD system used by OpenShift/Kubernetes projects. +// This struct matches the JSON structure returned by https://prow.ci.openshift.org/prowjobs.js +type ProwJob struct { + // Spec contains the job specification (what to run, where, how) + Spec ProwJobSpec `json:"spec"` + // Status contains the job status (current state, timestamps, URLs) + Status ProwJobStatus `json:"status"` + // Labels are Kubernetes labels with metadata used for filtering and identification: + // - "prow.k8s.io/refs.org": GitHub organization + // - "prow.k8s.io/refs.repo": Repository name + // - "prow.k8s.io/type": Job type (presubmit/postsubmit/periodic) + // - "prow.k8s.io/job": Job name + // - "prow.k8s.io/build-id": Build identifier + Labels map[string]string `json:"labels"` +} diff --git a/backend/plugins/testregistry/tasks/oras_client.go b/backend/plugins/testregistry/tasks/oras_client.go new file mode 100644 index 00000000000..ea315a420e2 --- /dev/null +++ b/backend/plugins/testregistry/tasks/oras_client.go @@ -0,0 +1,272 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/log" +) + +// ORASClient wraps the ORAS CLI tool for pulling OCI artifacts from Quay.io +// Similar to the qe-tools controller: https://github.com/konflux-ci/qe-tools/blob/main/pkg/oci/controller.go +// Uses the ORAS CLI tool (oras pull) to pull artifacts from Quay.io +type ORASClient struct { + registryURL string + repoPath string + loggingDir string + logger log.Logger + orasPath string // Path to oras executable (default: "oras") +} + +// NewORASClient creates a new ORAS client that uses the ORAS CLI tool +// +// Parameters: +// - ctx: Context for the operation +// - registryURL: Registry URL (e.g., "quay.io") +// - repoPath: Repository path (e.g., "org/repo") +// - loggingDir: Directory to store pulled artifacts (from LOGGING_DIR env var) +// - logger: Logger for output +// +// Returns: +// - *ORASClient: The ORAS client instance +// - errors.Error: Any error encountered during client creation +func NewORASClient(ctx context.Context, registryURL, repoPath, loggingDir string, logger log.Logger) (*ORASClient, errors.Error) { + if loggingDir == "" { + // Fallback to LOGGING_DIR environment variable or default + loggingDir = os.Getenv("LOGGING_DIR") + if loggingDir == "" { + loggingDir = "/app/logs" + } + } + + // Ensure logging directory exists + if err := os.MkdirAll(loggingDir, 0755); err != nil { + return nil, errors.Default.Wrap(err, "failed to create logging directory") + } + + // Check if oras is available globally in PATH + orasPath, err := exec.LookPath("oras") + if err != nil { + return nil, errors.Default.Wrap(err, "oras CLI not found in PATH. Please ensure ORAS CLI is installed.") + } + + return &ORASClient{ + registryURL: registryURL, + repoPath: repoPath, + loggingDir: loggingDir, + logger: logger, + orasPath: orasPath, + }, nil +} + +// generateUUID generates a unique identifier using crypto/rand +// Returns a hex-encoded string (16 bytes = 32 hex characters) +func generateUUID() (string, errors.Error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", errors.Default.Wrap(err, "failed to generate UUID") + } + return hex.EncodeToString(bytes), nil +} + +// PullArtifact pulls an OCI artifact from Quay.io using ORAS CLI and stores it in a unique tmp directory +// +// This method: +// 1. Generates a unique UUID for this artifact pull +// 2. Creates a tmp/{uuid} directory for storing the artifact +// 3. Uses `oras pull` command to pull the artifact from the registry +// 4. Returns the local path where artifacts were stored (tmp/{uuid}) +// +// Parameters: +// - ctx: Context for the operation +// - ref: Artifact reference (tag, digest, or "latest") +// +// Returns: +// - string: Local directory path where artifacts were stored (tmp/{uuid}) +// - errors.Error: Any error encountered during pull operation +func (c *ORASClient) PullArtifact(ctx context.Context, ref string) (string, errors.Error) { + if ref == "" { + ref = "latest" + } + + // Generate unique UUID for this artifact pull + uuid, err := generateUUID() + if err != nil { + return "", err + } + + // Create unique directory for this artifact: tmp/{uuid} + tmpBaseDir := filepath.Join(c.loggingDir, "tmp") + artifactDir := filepath.Join(tmpBaseDir, uuid) + if mkdirErr := os.MkdirAll(artifactDir, 0755); mkdirErr != nil { + return "", errors.Default.Wrap(mkdirErr, "failed to create artifact directory") + } + + // Build artifact reference + artifactRef := fmt.Sprintf("%s/%s:%s", c.registryURL, c.repoPath, ref) + + c.logger.Info("Pulling OCI artifact using ORAS CLI", "artifact", artifactRef, "target", artifactDir, "uuid", uuid) + + // Execute oras pull command + // oras pull quay.io/org/repo:tag -o /path/to/output + cmd := exec.CommandContext(ctx, c.orasPath, "pull", artifactRef, "-o", artifactDir) + + // Capture output for logging + output, execErr := cmd.CombinedOutput() + if execErr != nil { + outputStr := string(output) + c.logger.Error(execErr, "failed to pull artifact with ORAS CLI", "artifact", artifactRef, "output", outputStr, "uuid", uuid) + return "", errors.Default.Wrap(execErr, fmt.Sprintf("oras pull failed: %s", outputStr)) + } + + c.logger.Info("Successfully pulled OCI artifact", "artifact", artifactRef, "local_path", artifactDir, "uuid", uuid, "output", string(output)) + return artifactDir, nil +} + +// ListArtifacts lists available artifacts (tags) in the Quay.io repository +// Uses Quay.io REST API since ORAS CLI doesn't have a direct tag listing command +// +// Parameters: +// - ctx: Context for the operation +// +// Returns: +// - []string: List of available artifact tags/refs +// - errors.Error: Any error encountered during listing +func (c *ORASClient) ListArtifacts(ctx context.Context) ([]string, errors.Error) { + // Quay.io API endpoint for listing tags + tagsURL := fmt.Sprintf("https://quay.io/api/v1/repository/%s/tag/", c.repoPath) + + // Use curl or http client to fetch tags + // For simplicity, we'll use exec with curl (or we could use http.Client) + cmd := exec.CommandContext(ctx, "curl", "-s", tagsURL) + + output, err := cmd.Output() + if err != nil { + // Fallback: return "latest" if we can't list tags + c.logger.Warn(err, "failed to list tags from Quay.io API, will use 'latest'", "url", tagsURL) + return []string{"latest"}, nil + } + + // Simple JSON parsing (we could use encoding/json but this is simpler for now) + // For production, we should properly parse the JSON + outputStr := string(output) + var tagList []string + + // Simple extraction of tag names from JSON + // This is a basic implementation - should use proper JSON parsing + if strings.Contains(outputStr, `"name"`) { + lines := strings.Split(outputStr, "\n") + for _, line := range lines { + if strings.Contains(line, `"name"`) && strings.Contains(line, ":") { + parts := strings.Split(line, `"name":`) + if len(parts) > 1 { + namePart := strings.TrimSpace(parts[1]) + namePart = strings.Trim(namePart, `"`) + namePart = strings.Trim(namePart, `,`) + namePart = strings.TrimSpace(namePart) + if namePart != "" && !strings.Contains(namePart, "{") { + tagList = append(tagList, namePart) + } + } + } + } + } + + // Fallback to "latest" if no tags found + if len(tagList) == 0 { + c.logger.Info("No tags found or failed to parse, using 'latest'", "output_preview", outputStr[:min(200, len(outputStr))]) + return []string{"latest"}, nil + } + + return tagList, nil +} + +// GetArtifactContent retrieves the content of a file from a pulled artifact +// +// Parameters: +// - ctx: Context for the operation +// - artifactPath: Local path to the artifact (from PullArtifact) +// - filePath: Relative path to the file within the artifact +// +// Returns: +// - []byte: The file content +// - errors.Error: Any error encountered during retrieval +func (c *ORASClient) GetArtifactContent(ctx context.Context, artifactPath, filePath string) ([]byte, errors.Error) { + fullPath := filepath.Join(artifactPath, filePath) + content, err := os.ReadFile(fullPath) + if err != nil { + return nil, errors.Default.Wrap(err, fmt.Sprintf("failed to read file %s", fullPath)) + } + return content, nil +} + +// ExtractArtifactFiles lists all files extracted from an OCI artifact +// ORAS CLI extracts files automatically to the output directory +// +// Parameters: +// - ctx: Context for the operation +// - artifactPath: Local path to the artifact (from PullArtifact) +// - targetDir: Directory where files were extracted (same as artifactPath typically) +// +// Returns: +// - []string: List of extracted file paths +// - errors.Error: Any error encountered during listing +func (c *ORASClient) ExtractArtifactFiles(ctx context.Context, artifactPath, targetDir string) ([]string, errors.Error) { + var extractedFiles []string + + // Walk the artifact directory and collect all files + err := filepath.Walk(targetDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err // Continue on error + } + + if !info.IsDir() { + // Get relative path from targetDir + relPath, err := filepath.Rel(targetDir, path) + if err != nil { + return err + } + extractedFiles = append(extractedFiles, relPath) + } + return nil + }) + + if err != nil { + return nil, errors.Default.Wrap(err, "failed to walk artifact directory") + } + + c.logger.Info("Found extracted files in artifact", "artifact_path", artifactPath, "file_count", len(extractedFiles)) + return extractedFiles, nil +} + +// min returns the minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/plugins/testregistry/tasks/oras_client_test.go b/backend/plugins/testregistry/tasks/oras_client_test.go new file mode 100644 index 00000000000..892a68668b3 --- /dev/null +++ b/backend/plugins/testregistry/tasks/oras_client_test.go @@ -0,0 +1,46 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateUUID(t *testing.T) { + t.Run("returns 32-char hex string", func(t *testing.T) { + uuid, err := generateUUID() + assert.Nil(t, err) + assert.Len(t, uuid, 32) + }) + + t.Run("two calls produce different UUIDs", func(t *testing.T) { + uuid1, _ := generateUUID() + uuid2, _ := generateUUID() + assert.NotEqual(t, uuid1, uuid2) + }) +} + +func TestMin(t *testing.T) { + assert.Equal(t, 3, min(3, 5)) + assert.Equal(t, 3, min(5, 3)) + assert.Equal(t, 3, min(3, 3)) + assert.Equal(t, 0, min(0, 1)) + assert.Equal(t, -1, min(-1, 0)) +} diff --git a/backend/plugins/testregistry/tasks/prow_collector.go b/backend/plugins/testregistry/tasks/prow_collector.go new file mode 100644 index 00000000000..e1ef96b70cf --- /dev/null +++ b/backend/plugins/testregistry/tasks/prow_collector.go @@ -0,0 +1,630 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/testregistry/models" +) + +const ( + // ProwBaseURL is the base URL for the Openshift CI Prow API + ProwBaseURL = "https://prow.ci.openshift.org" + + // ProwJobsPath is the API endpoint path for fetching all Prow jobs + ProwJobsPath = "prowjobs.js" + + // RAW_PROW_TABLE is the raw data table name for storing Prow job JSON responses + RAW_PROW_TABLE = "cicd_test_jobs" +) + +// CollectProwJobsMeta defines the metadata for the Prow job collection subtask +var CollectProwJobsMeta = plugin.SubTaskMeta{ + Name: "collectProwJobs", + EntryPoint: CollectProwJobs, + EnabledByDefault: true, + Description: "Collect Prow jobs from Openshift CI (https://prow.ci.openshift.org) for the specified GitHub organization and repository scope. Saves both raw JSON data and normalized CI job records.", + DomainTypes: []string{plugin.DOMAIN_TYPE_CICD}, +} + +// CollectProwJobs is the main entry point for collecting Prow jobs from Openshift CI. +// +// This function: +// 1. Fetches all Prow jobs from the Openshift CI API +// 2. Filters jobs that match the specified GitHub organization and repository +// 3. Saves raw job JSON to the raw data table +// 4. Converts and saves normalized CI job records +// 5. Attempts to fetch and log JUnit test suite information from GCS +// +// Parameters: +// - taskCtx: The subtask context providing access to logger, database, and other resources +// +// Returns: +// - errors.Error: Any error encountered during collection, or nil if successful +func CollectProwJobs(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*TestRegistryTaskData) + logger := taskCtx.GetLogger() + logger.Info("collecting Prow jobs for scope: %s", data.Options.FullName) + + // Validate connection type + if data.Connection.CITool != models.CIToolOpenshiftCI { + logger.Info("Connection is not Openshift CI, skipping Prow job collection") + return nil + } + + // Extract scope information + repoName := data.Options.FullName + githubOrg := data.Connection.GitHubOrganization + if githubOrg == "" { + return errors.BadInput.New("GitHub organization is required for Openshift CI") + } + + // Setup raw data collection + rawDataSubTask, err := setupRawDataCollection(taskCtx, data) + if err != nil { + return err + } + + // Fetch Prow jobs from API + allJobs, err := fetchProwJobsFromAPI(taskCtx) + if err != nil { + return err + } + + logger.Info("Fetched %d Prow jobs total, filtering for scope %s/%s", len(allJobs), githubOrg, repoName) + + // Process and save matching jobs + db := taskCtx.GetDal() + rawTable := rawDataSubTask.GetTable() + rawParams := rawDataSubTask.GetParams() + apiURL := fmt.Sprintf("%s/%s", ProwBaseURL, ProwJobsPath) + + stats := &collectionStats{} + stats.processJobs( + taskCtx, + db, + allJobs, + rawTable, + rawParams, + apiURL, + githubOrg, + repoName, + data, + ) + + // Log final summary + logger.Info( + "Found %d Prow jobs matching scope %s/%s, saved %d CI jobs and %d raw records to database. JUnit XML found for %d jobs, not found for %d jobs", + stats.matchingCount, + githubOrg, + repoName, + stats.savedCount, + stats.rawSavedCount, + stats.junitFoundCount, + stats.junitNotFoundCount, + ) + + return nil +} + +// collectionStats tracks statistics during job collection +type collectionStats struct { + matchingCount int + savedCount int + rawSavedCount int + processedCount int + junitFoundCount int + junitNotFoundCount int +} + +// processJobs iterates through all Prow jobs, filters matching ones, and saves them to the database +func (stats *collectionStats) processJobs( + taskCtx plugin.SubTaskContext, + db dal.Dal, + allJobs []ProwJob, + rawTable string, + rawParams string, + apiURL string, + githubOrg string, + repoName string, + data *TestRegistryTaskData, +) { + logger := taskCtx.GetLogger() + taskCtx.SetProgress(0, len(allJobs)) + + // Create GCS client once for the entire task run + gcsClient, gcsErr := NewGCSBucketClient(taskCtx.GetContext()) + if gcsErr != nil { + logger.Warn(gcsErr, "failed to create GCS client, JUnit collection will be skipped") + } + if gcsClient != nil { + defer func() { _ = gcsClient.Close() }() + } + + for _, job := range allJobs { + stats.processedCount++ + + // Update progress periodically + if stats.processedCount%100 == 0 || stats.processedCount == len(allJobs) { + taskCtx.SetProgress(stats.processedCount, len(allJobs)) + } + + // Process matching jobs only + if !matchesScope(&job, githubOrg, repoName) { + continue + } + + stats.matchingCount++ + + // Save raw job JSON + if err := saveRawJobData(db, rawTable, rawParams, apiURL, &job); err != nil { + logger.Warn(err, "failed to save raw Prow job data") + } else { + stats.rawSavedCount++ + } + + // Convert and save normalized CI job + ciJob, err := convertProwJobToCIJob(&job, data.Options.ConnectionId, data.Options.FullName, githubOrg, repoName) + if err != nil { + logger.Warn(err, "failed to convert Prow job to CI job") + continue + } + + if err := db.CreateOrUpdate(ciJob); err != nil { + logger.Warn(err, "failed to save CI job to database", "job_id", ciJob.JobId) + continue + } + + stats.savedCount++ + + // Fetch and log JUnit test suites using configured regex + if gcsClient == nil { + stats.junitNotFoundCount++ + continue + } + logger.Debug("Attempting to fetch JUnit XML for job", "job_id", ciJob.JobId, "job_name", ciJob.JobName, "trigger_type", ciJob.TriggerType) + if fetchAndPrintJUnitSuites(taskCtx, gcsClient, &job, githubOrg, repoName, ciJob, data.JUnitRegex) { + stats.junitFoundCount++ + } else { + stats.junitNotFoundCount++ + } + } + + // Final progress update + taskCtx.SetProgress(len(allJobs), len(allJobs)) +} + +// setupRawDataCollection initializes the raw data collection subtask +func setupRawDataCollection(taskCtx plugin.SubTaskContext, data *TestRegistryTaskData) (*helper.RawDataSubTask, errors.Error) { + return helper.NewRawDataSubTask(helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: TestRegistryApiParams{ + ConnectionId: data.Options.ConnectionId, + FullName: data.Options.FullName, + }, + Table: RAW_PROW_TABLE, + }) +} + +const ( + prowMaxRetries = 5 + prowRetryBaseWait = 10 * time.Second +) + +// isTransientStatusCode returns true for HTTP status codes that warrant a retry. +func isTransientStatusCode(code int) bool { + return code == http.StatusBadGateway || + code == http.StatusServiceUnavailable || + code == http.StatusGatewayTimeout || + code == http.StatusTooManyRequests +} + +// fetchProwJobsFromAPI retrieves all Prow jobs from the Openshift CI API. +// Transient errors (502, 503, 504, 429) are retried up to prowMaxRetries times +// with exponential backoff starting at prowRetryBaseWait. +func fetchProwJobsFromAPI(taskCtx plugin.SubTaskContext) ([]ProwJob, errors.Error) { + logger := taskCtx.GetLogger() + + apiClient, err := helper.NewApiClient(taskCtx.GetContext(), ProwBaseURL, nil, 0, "", taskCtx) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to create API client for Prow") + } + + var lastErr errors.Error + for attempt := 1; attempt <= prowMaxRetries; attempt++ { + resp, fetchErr := apiClient.Get(ProwJobsPath, nil, nil) + if fetchErr != nil { + lastErr = errors.Default.Wrap(fetchErr, "failed to fetch Prow jobs") + wait := prowRetryBaseWait * time.Duration(attempt) + logger.Warn(lastErr, "Prow API request failed (attempt %d/%d), retrying in %s", attempt, prowMaxRetries, wait) + time.Sleep(wait) + continue + } + + if isTransientStatusCode(resp.StatusCode) { + _ = resp.Body.Close() + lastErr = errors.Default.New(fmt.Sprintf("Prow API returned status %d", resp.StatusCode)) + wait := prowRetryBaseWait * time.Duration(attempt) + logger.Warn(lastErr, "Prow API transient error (attempt %d/%d), retrying in %s", attempt, prowMaxRetries, wait) + time.Sleep(wait) + continue + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, errors.Default.New(fmt.Sprintf("Prow API returned status %d", resp.StatusCode)) + } + + // Parse response — Prow API returns {"items": [...]} format. + var results map[string][]ProwJob + if parseErr := helper.UnmarshalResponse(resp, &results); parseErr != nil { + return nil, errors.Default.Wrap(parseErr, "failed to parse Prow jobs response") + } + + allJobs := results["items"] + if allJobs == nil { + allJobs = []ProwJob{} + } + return allJobs, nil + } + + return nil, errors.Default.Wrap(lastErr, fmt.Sprintf("Prow API failed after %d attempts", prowMaxRetries)) +} + +// saveRawJobData saves the raw Prow job JSON to the raw data table +// +// Parameters: +// - db: Database connection +// - rawTable: Name of the raw data table +// - rawParams: Parameters identifying this collection run +// - apiURL: The API URL from which this data was fetched +// - job: The Prow job to save +// +// Returns: +// - errors.Error: Any error encountered during saving, or nil if successful +func saveRawJobData(db dal.Dal, rawTable, rawParams, apiURL string, job *ProwJob) errors.Error { + jobJSON, err := json.Marshal(job) + if err != nil { + return errors.Default.Wrap(err, "failed to marshal Prow job to JSON") + } + + rawData := &helper.RawData{ + Params: rawParams, + Data: jobJSON, + Url: apiURL, + CreatedAt: time.Now(), + } + + return db.Create(rawData, dal.From(rawTable)) +} + +// matchesScope checks if a Prow job matches the given GitHub organization and repository. +// +// This function checks multiple sources in order of reliability: +// 1. Prow job labels (most reliable): "prow.k8s.io/refs.org" and "prow.k8s.io/refs.repo" +// 2. Main refs: job.Spec.Refs.Org and job.Spec.Refs.Repo +// 3. Extra refs: Any matching org/repo in job.Spec.ExtraRefs +// +// Additionally, jobs in "aborted", "pending", or "triggered" states are excluded, +// matching the behavior of the quality-dashboard implementation. +// +// Parameters: +// - job: The Prow job to check +// - githubOrg: Expected GitHub organization name +// - repoName: Expected repository name +// +// Returns: +// - bool: true if the job matches the scope and is in a valid state, false otherwise +func matchesScope(job *ProwJob, githubOrg, repoName string) bool { + // Check labels first (most reliable method, used by quality-dashboard) + if job.Labels != nil { + prowOrg := job.Labels["prow.k8s.io/refs.org"] + prowRepo := job.Labels["prow.k8s.io/refs.repo"] + + if prowOrg == githubOrg && prowRepo == repoName { + return isValidJobState(job.Status.State) + } + } + + // Fallback: Check main refs + if job.Spec.Refs != nil { + if job.Spec.Refs.Org == githubOrg && job.Spec.Refs.Repo == repoName { + return isValidJobState(job.Status.State) + } + } + + // Fallback: Check extra refs + for _, extraRef := range job.Spec.ExtraRefs { + if extraRef != nil && extraRef.Org == githubOrg && extraRef.Repo == repoName { + return isValidJobState(job.Status.State) + } + } + + return false +} + +// isValidJobState checks if a Prow job state is valid for processing. +// +// Jobs in "aborted", "pending", or "triggered" states are excluded as they +// don't represent completed job runs. +// +// Parameters: +// - state: The Prow job state string (e.g., "success", "failure", "aborted") +// +// Returns: +// - bool: true if the state is valid for processing, false otherwise +func isValidJobState(state string) bool { + stateLower := strings.ToLower(state) + return stateLower != "aborted" && stateLower != "pending" && stateLower != "triggered" +} + +// convertProwJobToCIJob converts a ProwJob structure to a TestRegistryCIJob database model. +// +// This function extracts relevant information from the Prow job and maps it to +// the unified CI job structure. It handles: +// - Job identification (ID, name, type) +// - Repository information (org, repo) +// - Git references (commit SHA, PR information) +// - Trigger type mapping (presubmit -> pull_request, postsubmit -> push, periodic -> periodic) +// - Status and timing information +// +// Parameters: +// - prowJob: The source Prow job structure +// - connectionId: The DevLake connection ID +// - scopeId: The scope identifier (repository full name) +// - organization: Default organization name (used as fallback) +// - repository: Default repository name (used as fallback) +// +// Returns: +// - *models.TestRegistryCIJob: The converted CI job model +// - errors.Error: Any error encountered during conversion, or nil if successful +func convertProwJobToCIJob(prowJob *ProwJob, connectionId uint64, scopeId, organization, repository string) (*models.TestRegistryCIJob, errors.Error) { + ciJob := &models.TestRegistryCIJob{ + ConnectionId: connectionId, + JobType: "prow", + Organization: organization, + Repository: repository, + ScopeId: scopeId, + } + + // Extract job ID + ciJob.JobId = extractJobID(prowJob) + ciJob.JobName = prowJob.Spec.Job + + // Extract organization and repository from Prow job metadata + extractOrgRepo(ciJob, prowJob) + + // Extract git commit SHA and PR information + extractGitInfo(ciJob, prowJob) + + // Map trigger type + mapTriggerType(ciJob, prowJob) + + // Map job status + mapJobStatus(ciJob, prowJob) + + // Set namespace + ciJob.Namespace = prowJob.Spec.Namespace + + // Parse and set timestamps + parseTimestamps(ciJob, prowJob) + + // Calculate durations + calculateDurations(ciJob) + + // Set view URL + ciJob.ViewURL = prowJob.Status.URL + + return ciJob, nil +} + +// extractJobID extracts a unique job identifier from the Prow job. +// +// Priority order: +// 1. BuildID (most reliable) +// 2. PodName (if BuildID is not available) +// 3. Generated ID from job name + timestamp (fallback) +// +// Parameters: +// - prowJob: The Prow job to extract ID from +// +// Returns: +// - string: The extracted or generated job ID +func extractJobID(prowJob *ProwJob) string { + if prowJob.Status.BuildID != "" { + return prowJob.Status.BuildID + } + if prowJob.Status.PodName != "" { + return prowJob.Status.PodName + } + // Fallback: use job name + timestamp + return fmt.Sprintf("%s-%d", prowJob.Spec.Job, time.Now().Unix()) +} + +// extractOrgRepo extracts organization and repository information from Prow job labels or spec refs. +// +// This function prioritizes labels over spec refs, as labels are more reliable. +// +// Parameters: +// - ciJob: The CI job model to populate +// - prowJob: The source Prow job +func extractOrgRepo(ciJob *models.TestRegistryCIJob, prowJob *ProwJob) { + // First, try to extract from labels (most reliable) + if prowJob.Labels != nil { + if org := prowJob.Labels["prow.k8s.io/refs.org"]; org != "" { + ciJob.Organization = org + } + if repo := prowJob.Labels["prow.k8s.io/refs.repo"]; repo != "" { + ciJob.Repository = repo + } + } + + // Fallback to spec refs if labels don't have org/repo + if ciJob.Organization == "" || ciJob.Repository == "" { + if prowJob.Spec.Refs != nil { + if ciJob.Organization == "" && prowJob.Spec.Refs.Org != "" { + ciJob.Organization = prowJob.Spec.Refs.Org + } + if ciJob.Repository == "" && prowJob.Spec.Refs.Repo != "" { + ciJob.Repository = prowJob.Spec.Refs.Repo + } + } + } +} + +// extractGitInfo extracts git commit SHA and pull request information from Prow job refs. +// +// For presubmit jobs, it extracts PR number and author from the pulls array. +// For postsubmit jobs, it uses the base SHA. +// +// Parameters: +// - ciJob: The CI job model to populate +// - prowJob: The source Prow job +func extractGitInfo(ciJob *models.TestRegistryCIJob, prowJob *ProwJob) { + if prowJob.Spec.Refs == nil { + return + } + + // Extract commit SHA: prefer PR SHA if available, otherwise use base SHA + if len(prowJob.Spec.Refs.Pulls) > 0 { + ciJob.CommitSHA = prowJob.Spec.Refs.Pulls[0].SHA + } else if prowJob.Spec.Refs.BaseSHA != "" { + ciJob.CommitSHA = prowJob.Spec.Refs.BaseSHA + } + + // Extract pull request information + if len(prowJob.Spec.Refs.Pulls) > 0 { + prNumber := prowJob.Spec.Refs.Pulls[0].Number + ciJob.PullRequestNumber = &prNumber + ciJob.PullRequestAuthor = prowJob.Spec.Refs.Pulls[0].Author + } +} + +// mapTriggerType maps Prow job type to our unified trigger type format. +// +// Mapping: +// - "presubmit" -> "pull_request" +// - "postsubmit" -> "push" +// - "periodic" -> "periodic" +// +// If the type is missing, it infers from the presence of PR information. +// +// Parameters: +// - ciJob: The CI job model to populate +// - prowJob: The source Prow job +func mapTriggerType(ciJob *models.TestRegistryCIJob, prowJob *ProwJob) { + prowType := strings.ToLower(prowJob.Spec.Type) + switch prowType { + case "presubmit": + ciJob.TriggerType = "pull_request" + case "postsubmit": + ciJob.TriggerType = "push" + case "periodic": + ciJob.TriggerType = "periodic" + default: + // Fallback: infer from presence of PR + if ciJob.PullRequestNumber != nil { + ciJob.TriggerType = "pull_request" + } else { + ciJob.TriggerType = "push" // Default assumption if type is missing + } + } +} + +// mapJobStatus maps Prow job state to our unified result format. +// +// Mapping: +// - "success" -> "SUCCESS" +// - "failure", "error" -> "FAILURE" +// - "aborted" -> "ABORTED" +// - Other states are uppercased as-is +// +// Parameters: +// - ciJob: The CI job model to populate +// - prowJob: The source Prow job +func mapJobStatus(ciJob *models.TestRegistryCIJob, prowJob *ProwJob) { + state := strings.ToLower(prowJob.Status.State) + switch state { + case "success": + ciJob.Result = "SUCCESS" + case "failure", "error": + ciJob.Result = "FAILURE" + case "aborted": + ciJob.Result = "ABORTED" + default: + ciJob.Result = strings.ToUpper(state) + } +} + +// parseTimestamps parses ISO 8601 timestamp strings from Prow job status into Go time.Time values. +// +// Parameters: +// - ciJob: The CI job model to populate +// - prowJob: The source Prow job +func parseTimestamps(ciJob *models.TestRegistryCIJob, prowJob *ProwJob) { + if prowJob.Status.PendingTime != "" { + if t, err := common.ConvertStringToTime(prowJob.Status.PendingTime); err == nil { + ciJob.QueuedAt = &t + } + } + if prowJob.Status.StartTime != "" { + if t, err := common.ConvertStringToTime(prowJob.Status.StartTime); err == nil { + ciJob.StartedAt = &t + } + } + if prowJob.Status.CompletionTime != "" { + if t, err := common.ConvertStringToTime(prowJob.Status.CompletionTime); err == nil { + ciJob.FinishedAt = &t + } + } +} + +// calculateDurations calculates job execution durations from timestamp differences. +// +// It calculates: +// - DurationSec: Time from start to finish +// - QueuedDurationSec: Time spent waiting in queue before execution +// +// Parameters: +// - ciJob: The CI job model to populate (timestamps must already be set) +func calculateDurations(ciJob *models.TestRegistryCIJob) { + if ciJob.StartedAt != nil && ciJob.FinishedAt != nil { + duration := ciJob.FinishedAt.Sub(*ciJob.StartedAt).Seconds() + ciJob.DurationSec = &duration + } + if ciJob.QueuedAt != nil && ciJob.StartedAt != nil { + queuedDuration := ciJob.StartedAt.Sub(*ciJob.QueuedAt).Seconds() + ciJob.QueuedDurationSec = &queuedDuration + } +} + +// TestRegistryApiParams represents parameters for identifying a raw data collection run +type TestRegistryApiParams struct { + ConnectionId uint64 `json:"connectionId"` + FullName string `json:"fullName"` +} diff --git a/backend/plugins/testregistry/tasks/prow_collector_test.go b/backend/plugins/testregistry/tasks/prow_collector_test.go new file mode 100644 index 00000000000..31cc8ab80ce --- /dev/null +++ b/backend/plugins/testregistry/tasks/prow_collector_test.go @@ -0,0 +1,545 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + "time" + + "github.com/apache/devlake/core/errors" + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + "github.com/apache/devlake/plugins/testregistry/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestMatchesScope(t *testing.T) { + t.Run("matches via labels", func(t *testing.T) { + job := &ProwJob{ + Labels: map[string]string{ + "prow.k8s.io/refs.org": "openshift", + "prow.k8s.io/refs.repo": "console", + }, + Status: ProwJobStatus{State: "success"}, + } + assert.True(t, matchesScope(job, "openshift", "console")) + }) + + t.Run("matches via main refs", func(t *testing.T) { + job := &ProwJob{ + Spec: ProwJobSpec{ + Refs: &ProwJobRefs{Org: "openshift", Repo: "console"}, + }, + Status: ProwJobStatus{State: "failure"}, + } + assert.True(t, matchesScope(job, "openshift", "console")) + }) + + t.Run("matches via extra refs", func(t *testing.T) { + job := &ProwJob{ + Spec: ProwJobSpec{ + ExtraRefs: []*ProwJobRefs{ + {Org: "openshift", Repo: "console"}, + }, + }, + Status: ProwJobStatus{State: "success"}, + } + assert.True(t, matchesScope(job, "openshift", "console")) + }) + + t.Run("no match returns false", func(t *testing.T) { + job := &ProwJob{ + Labels: map[string]string{ + "prow.k8s.io/refs.org": "other-org", + "prow.k8s.io/refs.repo": "other-repo", + }, + Status: ProwJobStatus{State: "success"}, + } + assert.False(t, matchesScope(job, "openshift", "console")) + }) + + t.Run("aborted state excluded", func(t *testing.T) { + job := &ProwJob{ + Labels: map[string]string{ + "prow.k8s.io/refs.org": "openshift", + "prow.k8s.io/refs.repo": "console", + }, + Status: ProwJobStatus{State: "aborted"}, + } + assert.False(t, matchesScope(job, "openshift", "console")) + }) + + t.Run("pending state excluded", func(t *testing.T) { + job := &ProwJob{ + Labels: map[string]string{ + "prow.k8s.io/refs.org": "openshift", + "prow.k8s.io/refs.repo": "console", + }, + Status: ProwJobStatus{State: "pending"}, + } + assert.False(t, matchesScope(job, "openshift", "console")) + }) + + t.Run("triggered state excluded", func(t *testing.T) { + job := &ProwJob{ + Labels: map[string]string{ + "prow.k8s.io/refs.org": "openshift", + "prow.k8s.io/refs.repo": "console", + }, + Status: ProwJobStatus{State: "triggered"}, + } + assert.False(t, matchesScope(job, "openshift", "console")) + }) + + t.Run("nil labels falls back to refs", func(t *testing.T) { + job := &ProwJob{ + Spec: ProwJobSpec{ + Refs: &ProwJobRefs{Org: "openshift", Repo: "console"}, + }, + Status: ProwJobStatus{State: "success"}, + } + assert.True(t, matchesScope(job, "openshift", "console")) + }) +} + +func TestIsValidJobState(t *testing.T) { + tests := []struct { + state string + want bool + }{ + {"success", true}, + {"failure", true}, + {"error", true}, + {"aborted", false}, + {"pending", false}, + {"triggered", false}, + {"Aborted", false}, + {"PENDING", false}, + {"Triggered", false}, + } + for _, tt := range tests { + t.Run(tt.state, func(t *testing.T) { + assert.Equal(t, tt.want, isValidJobState(tt.state)) + }) + } +} + +func TestExtractJobID(t *testing.T) { + t.Run("uses BuildID when available", func(t *testing.T) { + job := &ProwJob{ + Status: ProwJobStatus{BuildID: "build-123", PodName: "pod-456"}, + } + assert.Equal(t, "build-123", extractJobID(job)) + }) + + t.Run("falls back to PodName", func(t *testing.T) { + job := &ProwJob{ + Status: ProwJobStatus{PodName: "pod-456"}, + } + assert.Equal(t, "pod-456", extractJobID(job)) + }) + + t.Run("generates fallback ID from job name", func(t *testing.T) { + job := &ProwJob{ + Spec: ProwJobSpec{Job: "e2e-test"}, + } + id := extractJobID(job) + assert.Contains(t, id, "e2e-test-") + assert.NotEmpty(t, id) + }) +} + +func TestExtractOrgRepo(t *testing.T) { + t.Run("extracts from labels", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{ + Labels: map[string]string{ + "prow.k8s.io/refs.org": "openshift", + "prow.k8s.io/refs.repo": "console", + }, + } + extractOrgRepo(ciJob, prowJob) + assert.Equal(t, "openshift", ciJob.Organization) + assert.Equal(t, "console", ciJob.Repository) + }) + + t.Run("falls back to spec refs", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{ + Spec: ProwJobSpec{ + Refs: &ProwJobRefs{Org: "org-from-refs", Repo: "repo-from-refs"}, + }, + } + extractOrgRepo(ciJob, prowJob) + assert.Equal(t, "org-from-refs", ciJob.Organization) + assert.Equal(t, "repo-from-refs", ciJob.Repository) + }) + + t.Run("labels take precedence over refs", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{ + Labels: map[string]string{ + "prow.k8s.io/refs.org": "label-org", + "prow.k8s.io/refs.repo": "label-repo", + }, + Spec: ProwJobSpec{ + Refs: &ProwJobRefs{Org: "ref-org", Repo: "ref-repo"}, + }, + } + extractOrgRepo(ciJob, prowJob) + assert.Equal(t, "label-org", ciJob.Organization) + assert.Equal(t, "label-repo", ciJob.Repository) + }) + + t.Run("keeps defaults when no labels or refs", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{ + Organization: "default-org", + Repository: "default-repo", + } + prowJob := &ProwJob{} + extractOrgRepo(ciJob, prowJob) + assert.Equal(t, "default-org", ciJob.Organization) + assert.Equal(t, "default-repo", ciJob.Repository) + }) +} + +func TestExtractGitInfo(t *testing.T) { + t.Run("extracts PR SHA and info", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{ + Spec: ProwJobSpec{ + Refs: &ProwJobRefs{ + BaseSHA: "base-sha-abc", + Pulls: []ProwJobPull{ + {Number: 42, SHA: "pr-sha-xyz", Author: "dev1"}, + }, + }, + }, + } + extractGitInfo(ciJob, prowJob) + assert.Equal(t, "pr-sha-xyz", ciJob.CommitSHA) + assert.NotNil(t, ciJob.PullRequestNumber) + assert.Equal(t, 42, *ciJob.PullRequestNumber) + assert.Equal(t, "dev1", ciJob.PullRequestAuthor) + }) + + t.Run("uses base SHA when no pulls", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{ + Spec: ProwJobSpec{ + Refs: &ProwJobRefs{BaseSHA: "base-sha-abc"}, + }, + } + extractGitInfo(ciJob, prowJob) + assert.Equal(t, "base-sha-abc", ciJob.CommitSHA) + assert.Nil(t, ciJob.PullRequestNumber) + }) + + t.Run("nil refs is safe", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{} + extractGitInfo(ciJob, prowJob) + assert.Empty(t, ciJob.CommitSHA) + assert.Nil(t, ciJob.PullRequestNumber) + }) +} + +func TestMapTriggerType(t *testing.T) { + tests := []struct { + name string + prowType string + hasPR bool + want string + }{ + {"presubmit maps to pull_request", "presubmit", false, "pull_request"}, + {"postsubmit maps to push", "postsubmit", false, "push"}, + {"periodic maps to periodic", "periodic", false, "periodic"}, + {"unknown with PR infers pull_request", "", true, "pull_request"}, + {"unknown without PR defaults to push", "", false, "push"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + if tt.hasPR { + prNum := 1 + ciJob.PullRequestNumber = &prNum + } + prowJob := &ProwJob{Spec: ProwJobSpec{Type: tt.prowType}} + mapTriggerType(ciJob, prowJob) + assert.Equal(t, tt.want, ciJob.TriggerType) + }) + } +} + +func TestMapJobStatus(t *testing.T) { + tests := []struct { + state string + want string + }{ + {"success", "SUCCESS"}, + {"failure", "FAILURE"}, + {"error", "FAILURE"}, + {"aborted", "ABORTED"}, + {"Success", "SUCCESS"}, + {"unknown", "UNKNOWN"}, + } + for _, tt := range tests { + t.Run(tt.state, func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{Status: ProwJobStatus{State: tt.state}} + mapJobStatus(ciJob, prowJob) + assert.Equal(t, tt.want, ciJob.Result) + }) + } +} + +func TestCalculateDurations(t *testing.T) { + t.Run("calculates both durations", func(t *testing.T) { + queued := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + started := time.Date(2024, 1, 1, 10, 1, 0, 0, time.UTC) + finished := time.Date(2024, 1, 1, 10, 11, 0, 0, time.UTC) + ciJob := &models.TestRegistryCIJob{ + QueuedAt: &queued, + StartedAt: &started, + FinishedAt: &finished, + } + calculateDurations(ciJob) + assert.NotNil(t, ciJob.DurationSec) + assert.InDelta(t, 600.0, *ciJob.DurationSec, 0.001) + assert.NotNil(t, ciJob.QueuedDurationSec) + assert.InDelta(t, 60.0, *ciJob.QueuedDurationSec, 0.001) + }) + + t.Run("nil timestamps produce nil durations", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + calculateDurations(ciJob) + assert.Nil(t, ciJob.DurationSec) + assert.Nil(t, ciJob.QueuedDurationSec) + }) + + t.Run("missing finish leaves duration nil", func(t *testing.T) { + started := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + ciJob := &models.TestRegistryCIJob{StartedAt: &started} + calculateDurations(ciJob) + assert.Nil(t, ciJob.DurationSec) + }) +} + +func TestIsTransientStatusCode(t *testing.T) { + tests := []struct { + code int + want bool + }{ + {200, false}, + {404, false}, + {500, false}, + {429, true}, + {502, true}, + {503, true}, + {504, true}, + } + for _, tt := range tests { + t.Run("", func(t *testing.T) { + assert.Equal(t, tt.want, isTransientStatusCode(tt.code)) + }) + } +} + +func TestConvertProwJobToCIJob(t *testing.T) { + t.Run("full conversion", func(t *testing.T) { + prowJob := &ProwJob{ + Labels: map[string]string{ + "prow.k8s.io/refs.org": "openshift", + "prow.k8s.io/refs.repo": "console", + }, + Spec: ProwJobSpec{ + Job: "e2e-test", + Type: "presubmit", + Namespace: "ci", + Refs: &ProwJobRefs{ + Org: "openshift", + Repo: "console", + Pulls: []ProwJobPull{ + {Number: 99, SHA: "abc123", Author: "dev1"}, + }, + }, + }, + Status: ProwJobStatus{ + State: "success", + BuildID: "build-42", + StartTime: "2024-06-15T10:00:00Z", + CompletionTime: "2024-06-15T10:30:00Z", + URL: "https://prow.ci/view/123", + }, + } + + ciJob, err := convertProwJobToCIJob(prowJob, 1, "openshift/console", "openshift", "console") + assert.Nil(t, err) + assert.Equal(t, "build-42", ciJob.JobId) + assert.Equal(t, "e2e-test", ciJob.JobName) + assert.Equal(t, "prow", ciJob.JobType) + assert.Equal(t, "openshift", ciJob.Organization) + assert.Equal(t, "console", ciJob.Repository) + assert.Equal(t, "pull_request", ciJob.TriggerType) + assert.Equal(t, "SUCCESS", ciJob.Result) + assert.Equal(t, "ci", ciJob.Namespace) + assert.Equal(t, "abc123", ciJob.CommitSHA) + assert.NotNil(t, ciJob.PullRequestNumber) + assert.Equal(t, 99, *ciJob.PullRequestNumber) + assert.Equal(t, "dev1", ciJob.PullRequestAuthor) + assert.Equal(t, "https://prow.ci/view/123", ciJob.ViewURL) + assert.Equal(t, uint64(1), ciJob.ConnectionId) + assert.Equal(t, "openshift/console", ciJob.ScopeId) + }) + + t.Run("postsubmit job", func(t *testing.T) { + prowJob := &ProwJob{ + Spec: ProwJobSpec{ + Job: "post-deploy", + Type: "postsubmit", + Refs: &ProwJobRefs{ + Org: "org", + Repo: "repo", + BaseSHA: "sha456", + }, + }, + Status: ProwJobStatus{ + State: "failure", + BuildID: "build-99", + }, + } + + ciJob, err := convertProwJobToCIJob(prowJob, 2, "scope", "org", "repo") + assert.Nil(t, err) + assert.Equal(t, "push", ciJob.TriggerType) + assert.Equal(t, "FAILURE", ciJob.Result) + assert.Equal(t, "sha456", ciJob.CommitSHA) + assert.Nil(t, ciJob.PullRequestNumber) + }) +} + +func TestExtractOrgRepoForGCS(t *testing.T) { + t.Run("refs present", func(t *testing.T) { + mockLogger := new(mocklog.Logger) + job := &ProwJob{ + Spec: ProwJobSpec{ + Refs: &ProwJobRefs{Org: "test-org", Repo: "test-repo"}, + }, + } + org, repo := extractOrgRepoForGCS(job, "fallback-org", "fallback-repo", "job-1", mockLogger) + assert.Equal(t, "test-org", org) + assert.Equal(t, "test-repo", repo) + }) + + t.Run("refs nil falls back", func(t *testing.T) { + mockLogger := new(mocklog.Logger) + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + job := &ProwJob{} + org, repo := extractOrgRepoForGCS(job, "fallback-org", "fallback-repo", "job-1", mockLogger) + assert.Equal(t, "fallback-org", org) + assert.Equal(t, "fallback-repo", repo) + }) +} + +func TestSaveRawJobData(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Create", mock.Anything, mock.Anything).Return(nil) + + job := &ProwJob{ + Spec: ProwJobSpec{Job: "e2e-test"}, + Status: ProwJobStatus{State: "success", BuildID: "b1"}, + } + err := saveRawJobData(mockDal, "raw_table", `{"ConnectionId":1}`, "https://api.example.com", job) + assert.Nil(t, err) + mockDal.AssertCalled(t, "Create", mock.Anything, mock.Anything) + }) + + t.Run("error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Create", mock.Anything, mock.Anything).Return(errors.Default.New("db error")) + + job := &ProwJob{Spec: ProwJobSpec{Job: "test"}} + err := saveRawJobData(mockDal, "raw_table", `{}`, "url", job) + assert.NotNil(t, err) + }) +} + +func TestParseTimestamps(t *testing.T) { + t.Run("all timestamps set", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{ + Status: ProwJobStatus{ + PendingTime: "2024-06-15T09:00:00Z", + StartTime: "2024-06-15T09:01:00Z", + CompletionTime: "2024-06-15T09:30:00Z", + }, + } + parseTimestamps(ciJob, prowJob) + assert.NotNil(t, ciJob.QueuedAt) + assert.NotNil(t, ciJob.StartedAt) + assert.NotNil(t, ciJob.FinishedAt) + assert.Equal(t, 2024, ciJob.QueuedAt.Year()) + }) + + t.Run("all timestamps empty", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{ + Status: ProwJobStatus{ + PendingTime: "", + StartTime: "", + CompletionTime: "", + }, + } + parseTimestamps(ciJob, prowJob) + assert.Nil(t, ciJob.QueuedAt) + assert.Nil(t, ciJob.StartedAt) + assert.Nil(t, ciJob.FinishedAt) + }) + + t.Run("only PendingTime set", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{ + Status: ProwJobStatus{ + PendingTime: "2024-06-15T09:00:00Z", + StartTime: "", + CompletionTime: "", + }, + } + parseTimestamps(ciJob, prowJob) + assert.NotNil(t, ciJob.QueuedAt) + assert.Nil(t, ciJob.StartedAt) + assert.Nil(t, ciJob.FinishedAt) + }) + + t.Run("only StartTime and CompletionTime set", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + prowJob := &ProwJob{ + Status: ProwJobStatus{ + PendingTime: "", + StartTime: "2024-06-15T09:01:00Z", + CompletionTime: "2024-06-15T09:30:00Z", + }, + } + parseTimestamps(ciJob, prowJob) + assert.Nil(t, ciJob.QueuedAt) + assert.NotNil(t, ciJob.StartedAt) + assert.NotNil(t, ciJob.FinishedAt) + }) +} diff --git a/backend/plugins/testregistry/tasks/quay_client.go b/backend/plugins/testregistry/tasks/quay_client.go new file mode 100644 index 00000000000..72d6bb06268 --- /dev/null +++ b/backend/plugins/testregistry/tasks/quay_client.go @@ -0,0 +1,206 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/log" +) + +// QuayClient wraps a Quay.io API client for listing artifacts/tags +// Similar to GCSBucket for Openshift CI +type QuayClient struct { + baseURL string + httpClient *http.Client + logger log.Logger +} + +// QuayTag represents a tag from Quay.io API +type QuayTag struct { + Name string `json:"name"` + Reversion bool `json:"reversion"` + StartTS int64 `json:"start_ts"` // Unix timestamp - used for date filtering + EndTS *int64 `json:"end_ts,omitempty"` + LastModified string `json:"last_modified"` // RFC 1123 format string (not used for filtering, stored as string to avoid parsing issues) + Expiration *string `json:"expiration,omitempty"` + DockerImageID string `json:"docker_image_id"` + IsManifestList bool `json:"is_manifest_list"` + ManifestDigest string `json:"manifest_digest"` + Size *int64 `json:"size,omitempty"` +} + +// QuayTagsResponse represents the response from Quay.io tags API +type QuayTagsResponse struct { + Tags []QuayTag `json:"tags"` + Page int `json:"page"` + HasAdditional bool `json:"has_additional"` + NextPage string `json:"next_page,omitempty"` +} + +// NewQuayClient creates a new Quay.io API client +// +// Parameters: +// - ctx: Context for the operation +// - logger: Logger for output +// +// Returns: +// - *QuayClient: The Quay.io client instance +// - errors.Error: Any error encountered during client creation +func NewQuayClient(ctx context.Context, logger log.Logger) (*QuayClient, errors.Error) { + return &QuayClient{ + baseURL: "https://quay.io", + httpClient: &http.Client{}, + logger: logger, + }, nil +} + +// ListTags lists all tags for a repository with optional date filtering +// +// Parameters: +// - ctx: Context for the operation +// - org: Organization name +// - repo: Repository name +// - since: Optional start date (from sync policy) +// - until: Optional end date (from sync policy) +// +// Returns: +// - []QuayTag: List of tags matching the date range +// - errors.Error: Any error encountered during listing +func (c *QuayClient) ListTags(ctx context.Context, org, repo string, since, until *time.Time) ([]QuayTag, errors.Error) { + var allTags []QuayTag + page := 1 + hasMore := true + + // Quay.io API endpoint: /api/v1/repository/{org}/{repo}/tag/ + baseURL := fmt.Sprintf("%s/api/v1/repository/%s/%s/tag/", c.baseURL, org, repo) + apiURL := baseURL + + for hasMore { + // Build request with pagination + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to create request") + } + + // If we're not using NextPage (first iteration or fallback), add page parameter manually + if page > 1 && !strings.Contains(apiURL, "?") { + q := req.URL.Query() + q.Set("page", fmt.Sprintf("%d", page)) + req.URL.RawQuery = q.Encode() + } + + c.logger.Debug("Fetching tags from Quay.io", "url", req.URL.String(), "page", page) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to fetch tags from Quay.io") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.Default.New(fmt.Sprintf("Quay.io API returned status %d for tags", resp.StatusCode)) + } + + var tagsResponse QuayTagsResponse + if err := json.NewDecoder(resp.Body).Decode(&tagsResponse); err != nil { + return nil, errors.Default.Wrap(err, "failed to parse tags response") + } + + // Filter tags by date range + for _, tag := range tagsResponse.Tags { + // Convert start_ts (Unix timestamp) to time.Time + tagTime := time.Unix(tag.StartTS, 0) + + // Apply date filters + if since != nil && tagTime.Before(*since) { + c.logger.Debug("Skipping tag (before since date)", "tag", tag.Name, "tag_time", tagTime, "since", *since) + continue + } + if until != nil && tagTime.After(*until) { + c.logger.Debug("Skipping tag (after until date)", "tag", tag.Name, "tag_time", tagTime, "until", *until) + continue + } + + allTags = append(allTags, tag) + } + + // Check if there are more pages + hasMore = tagsResponse.HasAdditional + if hasMore { + if tagsResponse.NextPage != "" { + // Use the next_page query string directly - it already contains pagination info + apiURL = baseURL + "?" + tagsResponse.NextPage + } else { + // Fallback: increment page number if NextPage URL not provided + page++ + apiURL = baseURL + } + } + } + + c.logger.Info("Listed tags from Quay.io", "org", org, "repo", repo, "total_tags", len(allTags), "since", since, "until", until) + return allTags, nil +} + +// GetTagByName gets a specific tag by name +// +// Parameters: +// - ctx: Context for the operation +// - org: Organization name +// - repo: Repository name +// - tagName: Tag name +// +// Returns: +// - *QuayTag: The tag if found, nil otherwise +// - errors.Error: Any error encountered during fetching +func (c *QuayClient) GetTagByName(ctx context.Context, org, repo, tagName string) (*QuayTag, errors.Error) { + apiURL := fmt.Sprintf("%s/api/v1/repository/%s/%s/tag/%s", c.baseURL, org, repo, tagName) + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to create request") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to fetch tag from Quay.io") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil // Tag not found + } + + if resp.StatusCode != http.StatusOK { + return nil, errors.Default.New(fmt.Sprintf("Quay.io API returned status %d for tag %s", resp.StatusCode, tagName)) + } + + var tag QuayTag + if err := json.NewDecoder(resp.Body).Decode(&tag); err != nil { + return nil, errors.Default.Wrap(err, "failed to parse tag response") + } + + return &tag, nil +} diff --git a/backend/plugins/testregistry/tasks/task_data.go b/backend/plugins/testregistry/tasks/task_data.go new file mode 100644 index 00000000000..1a9a64fdf49 --- /dev/null +++ b/backend/plugins/testregistry/tasks/task_data.go @@ -0,0 +1,39 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "regexp" + + "github.com/apache/devlake/plugins/testregistry/models" +) + +type TestRegistryOptions struct { + ConnectionId uint64 `json:"connectionId"` + FullName string `json:"fullName"` // Repository name (scope fullName) + ScopeConfig *models.TestRegistryScopeConfig +} + +type TestRegistryTaskData struct { + Options *TestRegistryOptions + Connection *models.TestRegistryConnection + + // JUnitRegex is the compiled regex pattern for matching JUnit XML files + // This is compiled once during task initialization and reused throughout collection + JUnitRegex *regexp.Regexp +} diff --git a/backend/plugins/testregistry/tasks/tekton_collector.go b/backend/plugins/testregistry/tasks/tekton_collector.go new file mode 100644 index 00000000000..6430f936cfa --- /dev/null +++ b/backend/plugins/testregistry/tasks/tekton_collector.go @@ -0,0 +1,684 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/log" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/testregistry/models" +) + +const ( + // QuayRegistryURL is the base URL for Quay.io registry + QuayRegistryURL = "quay.io" + // RAW_TEKTON_TABLE is the raw data table for storing Tekton PipelineRun JSON + RAW_TEKTON_TABLE = "cicd_test_jobs" +) + +// CollectTektonJobsMeta defines the metadata for the Tekton job collection subtask +var CollectTektonJobsMeta = plugin.SubTaskMeta{ + Name: "collectTektonJobs", + EntryPoint: CollectTektonJobs, + EnabledByDefault: true, + Description: "Collect Tekton PipelineRun jobs from OCI artifacts in Quay.io for the specified organization and repository scope. Pulls artifacts using ORAS and saves both raw data and normalized CI job records.", + DomainTypes: []string{plugin.DOMAIN_TYPE_CICD}, +} + +// CollectTektonJobs is the main entry point for collecting Tekton jobs from OCI artifacts. +// +// This function: +// 1. Validates that the connection is for Tekton CI +// 2. Sets up ORAS client to pull OCI artifacts from Quay.io +// 3. Pulls artifacts for the specified repository scope +// 4. Parses Tekton PipelineRun data from artifacts +// 5. Saves raw data and normalized CI job records +// 6. Processes associated JUnit XML files if present +// +// Parameters: +// - taskCtx: The subtask context providing access to logger, database, and other resources +// +// Returns: +// - errors.Error: Any error encountered during collection, or nil if successful +func CollectTektonJobs(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*TestRegistryTaskData) + logger := taskCtx.GetLogger() + logger.Info("Collecting Tekton CI jobs", "scope", data.Options.FullName) + + // Validate connection type + if data.Connection.CITool != models.CIToolTektonCI { + logger.Debug("Connection is not Tekton CI, skipping") + return nil + } + + // Extract scope information + // For Tekton CI, FullName format is "quayOrg/repoName" or "quayOrg/sub-org/repoName" + // Example: FullName = "konflux-test-storage/konflux-team/release-service" + // quayOrg = "konflux-test-storage" + // Result: repoName = "konflux-team/release-service" + quayOrg := strings.TrimSpace(data.Connection.QuayOrganization) + if quayOrg == "" { + return errors.BadInput.New("Quay organization is required for Tekton CI") + } + + fullName := strings.TrimSpace(data.Options.FullName) + if fullName == "" { + return errors.BadInput.New("FullName is required") + } + + // Remove Quay organization prefix from FullName to get repository name + // Example: FullName = "konflux-test-storage/konflux-team/release-service" + // quayOrg = "konflux-test-storage" + // Result: repoName = "konflux-team/release-service" + orgPrefix := quayOrg + "/" + repoName := strings.TrimPrefix(fullName, orgPrefix) + + if repoName == "" { + return errors.BadInput.New("Repository name could not be extracted from FullName") + } + + // Build full repository path for Quay.io: org/repo + repoFullPath := fmt.Sprintf("%s/%s", quayOrg, repoName) + + // Get logging directory from environment variable + loggingDir := os.Getenv("LOGGING_DIR") + if loggingDir == "" { + loggingDir = "/app/logs" + } + + // Setup raw data collection + rawDataSubTask, err := setupRawTektonDataCollection(taskCtx, data) + if err != nil { + return err + } + + // Get sync policy to determine date range for artifact collection + syncPolicy := taskCtx.TaskContext().SyncPolicy() + var since, until *time.Time + if syncPolicy != nil { + since = syncPolicy.TimeAfter + if since == nil { + // Default to last 6 months if no timeAfter is set + sixMonthsAgo := time.Now().AddDate(0, -6, 0) + since = &sixMonthsAgo + } + until = nil // Collect until now + } else { + // Default to last 6 months if no sync policy + sixMonthsAgo := time.Now().AddDate(0, -6, 0) + since = &sixMonthsAgo + } + + // Setup Quay.io API client for listing tags with date filtering + ctx := taskCtx.GetContext() + quayClient, err := NewQuayClient(ctx, logger) + if err != nil { + return errors.Default.Wrap(err, "failed to create Quay.io client") + } + + // List all tags within sync policy dates + quayTags, err := quayClient.ListTags(ctx, quayOrg, repoName, since, until) + if err != nil { + logger.Warn(err, "failed to list tags from Quay.io API, will try to pull 'latest'") + quayTags = []QuayTag{{Name: "latest"}} + } + + if len(quayTags) == 0 { + logger.Info("No tags found for repository in the specified date range", "repository", repoFullPath) + return nil + } + + logger.Info("Found tags matching date range", "count", len(quayTags), "repository", repoFullPath) + + // Setup ORAS client for pulling artifacts + orasClient, err := NewORASClient(ctx, QuayRegistryURL, repoFullPath, loggingDir, logger) + if err != nil { + return errors.Default.Wrap(err, "failed to create ORAS client") + } + + // Get database connection and raw data parameters + db := taskCtx.GetDal() + rawTable := rawDataSubTask.GetTable() + rawParams := rawDataSubTask.GetParams() + apiURL := fmt.Sprintf("oras://%s/%s", QuayRegistryURL, repoFullPath) + + // Process artifacts + stats := processTektonArtifacts(taskCtx, orasClient, quayTags, data, rawDataSubTask, db, rawTable, rawParams, apiURL, loggingDir, repoFullPath, quayOrg, repoName) + + // Log final statistics + logger.Info("Completed Tekton job collection", "repository", repoFullPath, "artifacts_processed", len(quayTags), "jobs_saved", stats.savedCount, "raw_records_saved", stats.rawSavedCount, "junit_found", stats.junitFoundCount, "junit_not_found", stats.junitNotFoundCount) + + return nil +} + +// processTektonArtifacts processes Tekton OCI artifacts and extracts PipelineRun data +// +// Parameters: +// - taskCtx: The subtask context +// - orasClient: ORAS client for pulling artifacts +// - artifacts: List of QuayTag objects to process (includes tag name and date) +// - data: The task data +// - rawDataSubTask: Raw data subtask for saving raw JSON +// - loggingDir: Directory to store artifacts +// - repoFullPath: Full repository path (org/repo) - used for ORAS pull and logging +// - quayOrg: Quay.io organization name (for CI job organization field) +// - repoName: Repository name (for CI job repository field) +// +// Returns: +// - collectionStats: Statistics about the processed artifacts +func processTektonArtifacts( + taskCtx plugin.SubTaskContext, + orasClient *ORASClient, + artifacts []QuayTag, + data *TestRegistryTaskData, + rawDataSubTask *helper.RawDataSubTask, + db dal.Dal, + rawTable string, + rawParams string, + apiURL string, + loggingDir string, + repoFullPath string, + quayOrg string, + repoName string, +) collectionStats { + logger := taskCtx.GetLogger() + ctx := taskCtx.GetContext() + + stats := collectionStats{} + processedCount := 0 + + // Ensure tmp directory cleanup happens even if processing fails + tmpDir := filepath.Join(loggingDir, "tmp") + defer func() { + if cleanupErr := os.RemoveAll(tmpDir); cleanupErr != nil { + logger.Warn(cleanupErr, "failed to cleanup tmp directory") + } + }() + + taskCtx.SetProgress(0, len(artifacts)) + + for _, tag := range artifacts { + processedCount++ + if processedCount%10 == 0 || processedCount == len(artifacts) { + taskCtx.SetProgress(processedCount, len(artifacts)) + } + + artifactRef := tag.Name + + // Check if tag (job_id) already exists in database before pulling artifact + // The tag name from Quay is the same as the job_id (PipelineRunName) in the database + if isTektonJobAlreadyProcessed(db, data.Options.ConnectionId, artifactRef) { + logger.Debug("Tag already processed as job_id, skipping artifact pull", "tag", artifactRef) + continue + } + + logger.Info("Processing artifact [%d/%d]: quay.io/%s:%s", processedCount, len(artifacts), repoFullPath, artifactRef) + + // Pull artifact using ORAS + artifactPath, err := orasClient.PullArtifact(ctx, artifactRef) + if err != nil { + logger.Warn(err, "failed to pull artifact", "ref", artifactRef) + continue + } + + // Extract and parse PipelineRun data from artifact + pipelineRuns, err := extractTektonPipelineRuns(ctx, orasClient, artifactPath, loggingDir, logger) + if err != nil { + logger.Warn(err, "failed to extract PipelineRuns from artifact", "ref", artifactRef) + // Cleanup and skip this artifact + if artifactPath != "" { + os.RemoveAll(artifactPath) + } + continue + } + + // If no valid pipeline runs found or structure doesn't match, cleanup and skip + if len(pipelineRuns) == 0 { + logger.Warn(nil, "no valid PipelineRuns found in artifact", "ref", artifactRef) + if artifactPath != "" { + os.RemoveAll(artifactPath) + } + continue + } + + logger.Debug("Found %d PipelineRuns in artifact", len(pipelineRuns), "ref", artifactRef) + + // Process each PipelineRun (keep artifactPath until all jobs are processed for JUnit extraction) + for _, pipelineRun := range pipelineRuns { + if pipelineRun == nil { + continue + } + + // Extract job ID early to check if already processed + jobId := pipelineRun.PipelineRunName + if jobId == "" { + logger.Warn(nil, "PipelineRun missing PipelineRunName, skipping") + continue + } + + // Check if job already processed + if isTektonJobAlreadyProcessed(db, data.Options.ConnectionId, jobId) { + logger.Debug("Tekton job already processed, skipping", "job_id", jobId) + continue + } + + // Save raw PipelineRun JSON + if err := saveRawTektonData(db, logger, pipelineRun, rawParams, rawTable, apiURL); err != nil { + logger.Warn(err, "failed to save raw Tekton PipelineRun data") + } else { + stats.rawSavedCount++ + } + + // Convert to normalized CI job + ciJob, err := convertTektonPipelineRunToCIJob(pipelineRun, data.Options.ConnectionId, data.Options.FullName, quayOrg, repoName) + if err != nil { + logger.Warn(err, "failed to convert Tekton PipelineRun to CI job") + continue + } + + // Validate required fields + missingFields := validateRequiredCIJobFields(ciJob) + if len(missingFields) > 0 { + logger.Warn(nil, "CI job missing required fields, skipping", "job_id", ciJob.JobId, "missing_fields", missingFields) + continue + } + + // Save to database + if err := db.CreateOrUpdate(ciJob); err != nil { + logger.Warn(err, "failed to save CI job to database", "job_id", ciJob.JobId) + continue + } + + stats.savedCount++ + logger.Debug("Saved Tekton CI job", "job_id", ciJob.JobId, "job_name", ciJob.JobName, "result", ciJob.Result) + + // Save Tekton task runs + if err := saveTektonTasks(db, logger, data.Options.ConnectionId, ciJob.JobId, pipelineRun.TaskRuns); err != nil { + logger.Warn(err, "failed to save Tekton tasks", "job_id", ciJob.JobId) + } + + // Find and process JUnit XML files from artifact using configured regex + if findAndProcessJUnitFiles(taskCtx, artifactPath, ciJob, quayOrg, repoName, data.JUnitRegex) { + stats.junitFoundCount++ + } else { + stats.junitNotFoundCount++ + } + } + + // Cleanup artifact after processing all PipelineRuns + if artifactPath != "" { + os.RemoveAll(artifactPath) + } + } + + return stats +} + +// TektonPipelineRun represents a Tekton PipelineRun structure +// This is a placeholder - the actual structure should match Tekton API schema +// TektonTaskRun represents a task run within a PipelineRun +type TektonTaskRun struct { + Name string `json:"name"` // Task run name (e.g., "deploy-konflux") + Status string `json:"status"` // Task status: "Succeeded", "Failed", etc. + Duration string `json:"duration"` // Duration in seconds (e.g., "483s") +} + +// TektonGitInfo represents Git organization and repository information +type TektonGitInfo struct { + GitOrganization string `json:"gitOrganization"` // GitHub organization (e.g., "konflux-ci") + GitRepository string `json:"gitRepository"` // Repository name (e.g., "integration-service") + PullRequestNumber string `json:"pullRequestNumber"` // PR number as string (e.g., "1315") + CommitSha string `json:"commitSha"` // Git commit SHA (e.g., "b4f3f3f") + PullRequestAuthor string `json:"pullRequestAuthor"` // PR author (e.g., "bot-konflux") +} + +// TektonTimestamps represents timestamp information for a PipelineRun +type TektonTimestamps struct { + CreatedAt string `json:"createdAt"` // Creation timestamp in RFC3339 format (e.g., "2024-06-18T10:15:30Z") + StartedAt string `json:"startedAt"` // Start timestamp in RFC3339 format (e.g., "2024-06-18T10:16:00Z") + FinishedAt string `json:"finishedAt"` // Finish timestamp in RFC3339 format (e.g., "2024-06-18T11:20:06Z") +} + +// TektonPipelineRun represents a Tekton PipelineRun from pipeline-status.json +// This structure matches the JSON format found in OCI artifacts +type TektonPipelineRun struct { + PipelineRunName string `json:"pipelineRunName"` // Pipeline run name (e.g., "konflux-e2e-z28lw") + Namespace string `json:"namespace"` // Kubernetes namespace (e.g., "konflux-ci") + Duration string `json:"duration"` // Total duration in seconds (e.g., "3846s") + Status string `json:"status"` // Overall status: "Succeeded", "Failed", etc. + EventType string `json:"eventType"` // Event type: "push", "pull_request", etc. + Scenario string `json:"scenario"` // Test scenario name (e.g., "konflux-e2e") + ConsoleUrl string `json:"consoleUrl"` // URL to view the pipeline in console (e.g., "https://ci.konflux-ci.dev/...") + Git TektonGitInfo `json:"git"` // Git organization and repository info + Timestamps TektonTimestamps `json:"timestamps"` // Timestamp information + TaskRuns []TektonTaskRun `json:"taskRuns"` // List of task runs within the pipeline +} + +// extractTektonPipelineRuns extracts Tekton PipelineRun data from OCI artifact +// Looks for pipeline-status.json files in the pulled artifact and parses them +// If the JSON structure doesn't match the expected format, it will be skipped +// +// Parameters: +// - ctx: Context for the operation +// - orasClient: ORAS client +// - artifactPath: Local path where artifact was pulled (tmp/{uuid}/) +// - loggingDir: Base logging directory (for logging purposes) +// - logger: Logger for error reporting +// +// Returns: +// - []*TektonPipelineRun: List of PipelineRun objects found in the artifact +// - errors.Error: Any error encountered during extraction (should trigger cleanup) +func extractTektonPipelineRuns(ctx context.Context, orasClient *ORASClient, artifactPath, loggingDir string, logger log.Logger) ([]*TektonPipelineRun, errors.Error) { + var pipelineRuns []*TektonPipelineRun + + // ORAS extracts files directly to artifactPath, so we search there + // Walk the artifact directory to find pipeline-status.json files + err := filepath.Walk(artifactPath, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr // Continue on error, but log it + } + + // Look for pipeline-status.json files + if !info.IsDir() && filepath.Base(path) == "pipeline-status.json" { + // Read and parse the pipeline-status.json file + content, readErr := os.ReadFile(path) + if readErr != nil { + logger.Warn(readErr, "failed to read pipeline-status.json file") + return nil // Continue processing other files + } + + var pipelineRun TektonPipelineRun + if unmarshalErr := json.Unmarshal(content, &pipelineRun); unmarshalErr != nil { + logger.Warn(unmarshalErr, "failed to parse pipeline-status.json") + return nil // Continue processing other files + } + + // Verify it's a valid PipelineRun (has required fields) + if pipelineRun.PipelineRunName == "" || pipelineRun.Status == "" { + logger.Warn(nil, "pipeline-status.json missing required fields", "pipelineRunName", pipelineRun.PipelineRunName, "status", pipelineRun.Status) + return nil // Skip this invalid pipeline run + } + + pipelineRuns = append(pipelineRuns, &pipelineRun) + logger.Debug("Parsed pipeline-status.json", "pipelineRunName", pipelineRun.PipelineRunName, "status", pipelineRun.Status) + } + + return nil + }) + + if err != nil { + return nil, errors.Default.Wrap(err, "failed to walk artifact directory") + } + + return pipelineRuns, nil +} + +// isTektonJobAlreadyProcessed checks if a Tekton CI job already exists in the database. +// +// Parameters: +// - db: Database connection +// - connectionId: The DevLake connection ID +// - jobId: The CI job ID to check +// +// Returns: +// - bool: true if the job already exists in the database, false otherwise +func isTektonJobAlreadyProcessed(db dal.Dal, connectionId uint64, jobId string) bool { + // Check if job exists in ci_test_jobs table + jobCount, err := db.Count( + dal.From(&models.TestRegistryCIJob{}), + dal.Where("connection_id = ? AND job_id = ?", connectionId, jobId), + ) + if err != nil { + // If query fails, assume not processed (safer to process again) + return false + } + + // If job exists, it's already processed + return jobCount > 0 +} + +// setupRawTektonDataCollection initializes the raw data collection subtask for Tekton +func setupRawTektonDataCollection(taskCtx plugin.SubTaskContext, data *TestRegistryTaskData) (*helper.RawDataSubTask, errors.Error) { + return helper.NewRawDataSubTask(helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Params: TestRegistryApiParams{ + ConnectionId: data.Options.ConnectionId, + FullName: data.Options.FullName, + }, + Table: RAW_TEKTON_TABLE, + }) +} + +// saveRawTektonData saves the raw Tekton PipelineRun JSON to the raw data table +// +// Parameters: +// - db: Database connection +// - logger: Logger for error reporting +// - pipelineRun: The Tekton PipelineRun to save +// - rawParams: Parameters identifying this collection run +// - rawTable: Name of the raw data table +// - apiURL: The API URL from which this data was fetched +// +// Returns: +// - errors.Error: Any error encountered during saving, or nil if successful +func saveRawTektonData(db dal.Dal, logger log.Logger, pipelineRun *TektonPipelineRun, rawParams string, rawTable, apiURL string) errors.Error { + pipelineRunJSON, err := json.Marshal(pipelineRun) + if err != nil { + return errors.Default.Wrap(err, "failed to marshal Tekton PipelineRun to JSON") + } + + rawData := &helper.RawData{ + Params: rawParams, + Data: pipelineRunJSON, + Url: apiURL, + CreatedAt: time.Now(), + } + + return db.Create(rawData, dal.From(rawTable)) +} + +// convertTektonPipelineRunToCIJob converts a TektonPipelineRun to a TestRegistryCIJob model +// +// Parameters: +// - pipelineRun: The source Tekton PipelineRun from pipeline-status.json +// - connectionId: The DevLake connection ID +// - scopeId: The scope ID (repository full name) +// - organization: The Quay.io organization name +// - repository: The repository name +// +// Returns: +// - *models.TestRegistryCIJob: The converted CI job model +// - errors.Error: An error if conversion fails +func convertTektonPipelineRunToCIJob(pipelineRun *TektonPipelineRun, connectionId uint64, scopeId, organization, repository string) (*models.TestRegistryCIJob, errors.Error) { + ciJob := &models.TestRegistryCIJob{ + ConnectionId: connectionId, + JobType: "tekton", + Organization: organization, + Repository: repository, + ScopeId: scopeId, + } + + // Extract job ID from PipelineRunName, and job name from Scenario + ciJob.JobId = pipelineRun.PipelineRunName + ciJob.JobName = pipelineRun.Scenario + + // Use Git info from pipeline-status.json if available, otherwise use provided org/repo + if pipelineRun.Git.GitOrganization != "" { + ciJob.Organization = pipelineRun.Git.GitOrganization + } + if pipelineRun.Git.GitRepository != "" { + ciJob.Repository = pipelineRun.Git.GitRepository + } + + // Extract commit SHA + if pipelineRun.Git.CommitSha != "" { + ciJob.CommitSHA = pipelineRun.Git.CommitSha + } + + // Extract namespace + if pipelineRun.Namespace != "" { + ciJob.Namespace = pipelineRun.Namespace + } + + // Map trigger type from EventType field + if pipelineRun.EventType == "pull_request" { + ciJob.TriggerType = "pull_request" + + // Only extract PR info when event type is pull_request + if pipelineRun.Git.PullRequestNumber != "" { + if prNum, parseErr := strconv.Atoi(pipelineRun.Git.PullRequestNumber); parseErr == nil { + ciJob.PullRequestNumber = &prNum + } + } + + // Extract pull request author only for pull_request events + if pipelineRun.Git.PullRequestAuthor != "" { + ciJob.PullRequestAuthor = pipelineRun.Git.PullRequestAuthor + } + } else { + ciJob.TriggerType = "push" // Default for "push" or any other event type + } + + // Map status from Status field to database result + // Based on Tekton PipelineRun status values: + // Reference: https://github.com/konflux-ci/tekton-integration-catalog/blob/main/tasks/store-pipeline-status/0.1/store-pipeline-status.yaml + // Possible values: "Succeeded", "Failed", "Cancelled", "Running", "Pending", etc. + switch pipelineRun.Status { + case "Succeeded": + ciJob.Result = "SUCCESS" + case "Failed": + ciJob.Result = "FAILURE" + case "Cancelled": + ciJob.Result = "ABORTED" // Map cancelled to ABORTED (standard CI/CD status) + case "Running": + ciJob.Result = "OTHER" // Running jobs are typically not stored, but handle if found + case "Pending": + ciJob.Result = "OTHER" // Pending jobs are typically not stored, but handle if found + default: + ciJob.Result = "OTHER" // For "Unknown", or any other unexpected status + } + + // Parse duration from string (e.g., "3846s") to seconds + // Duration format is like "3846s" - extract the number and convert to float64 + if pipelineRun.Duration != "" { + // Remove "s" suffix and parse to float64 + durationStr := strings.TrimSuffix(pipelineRun.Duration, "s") + if duration, parseErr := strconv.ParseFloat(durationStr, 64); parseErr == nil { + ciJob.DurationSec = &duration + } + } + + // Parse timestamps from Timestamps field + if pipelineRun.Timestamps.CreatedAt != "" { + if t, err := common.ConvertStringToTime(pipelineRun.Timestamps.CreatedAt); err == nil { + ciJob.QueuedAt = &t + } + } + if pipelineRun.Timestamps.StartedAt != "" { + if t, err := common.ConvertStringToTime(pipelineRun.Timestamps.StartedAt); err == nil { + ciJob.StartedAt = &t + } + } + if pipelineRun.Timestamps.FinishedAt != "" { + if t, err := common.ConvertStringToTime(pipelineRun.Timestamps.FinishedAt); err == nil { + ciJob.FinishedAt = &t + } + } + + // Calculate queued duration (time between creation and start) + if ciJob.QueuedAt != nil && ciJob.StartedAt != nil { + queuedDuration := ciJob.StartedAt.Sub(*ciJob.QueuedAt).Seconds() + ciJob.QueuedDurationSec = &queuedDuration + } + + // Extract console URL (ViewURL) + if pipelineRun.ConsoleUrl != "" { + ciJob.ViewURL = pipelineRun.ConsoleUrl + } + + return ciJob, nil +} + +// validateRequiredCIJobFields validates that all required fields are present in a CI job +// Returns a list of missing required field names +func validateRequiredCIJobFields(ciJob *models.TestRegistryCIJob) []string { + var missingFields []string + + // Primary key fields (always required) + if ciJob.ConnectionId == 0 { + missingFields = append(missingFields, "ConnectionId") + } + if ciJob.JobId == "" { + missingFields = append(missingFields, "JobId") + } + + // Required indexed fields + if ciJob.JobName == "" { + missingFields = append(missingFields, "JobName") + } + if ciJob.JobType == "" { + missingFields = append(missingFields, "JobType") + } + if ciJob.Organization == "" { + missingFields = append(missingFields, "Organization") + } + if ciJob.Repository == "" { + missingFields = append(missingFields, "Repository") + } + if ciJob.TriggerType == "" { + missingFields = append(missingFields, "TriggerType") + } + if ciJob.Result == "" { + missingFields = append(missingFields, "Result") + } + if ciJob.ScopeId == "" { + missingFields = append(missingFields, "ScopeId") + } + + // Conditional required fields based on trigger type + if ciJob.TriggerType == "pull_request" && ciJob.PullRequestNumber == nil { + missingFields = append(missingFields, "PullRequestNumber (required for pull_request)") + } + + // Required timestamps (at least FinishedAt should be present) + if ciJob.FinishedAt == nil { + missingFields = append(missingFields, "FinishedAt") + } + if ciJob.StartedAt == nil { + missingFields = append(missingFields, "StartedAt") + } + + // CommitSHA is required for traceability + if ciJob.CommitSHA == "" { + missingFields = append(missingFields, "CommitSHA") + } + + return missingFields +} diff --git a/backend/plugins/testregistry/tasks/tekton_collector_extract_test.go b/backend/plugins/testregistry/tasks/tekton_collector_extract_test.go new file mode 100644 index 00000000000..29ad6b05471 --- /dev/null +++ b/backend/plugins/testregistry/tasks/tekton_collector_extract_test.go @@ -0,0 +1,187 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "os" + "path/filepath" + "testing" + + mocklog "github.com/apache/devlake/mocks/core/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func newMockLogger() *mocklog.Logger { + logger := new(mocklog.Logger) + logger.On("Info", mock.Anything, mock.Anything).Maybe() + logger.On("Debug", mock.Anything, mock.Anything).Maybe() + logger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + return logger +} + +func TestExtractTektonPipelineRuns(t *testing.T) { + ctx := context.Background() + + t.Run("valid pipeline-status.json", func(t *testing.T) { + logger := newMockLogger() + + dir := t.TempDir() + subDir := filepath.Join(dir, "run-1") + assert.NoError(t, os.MkdirAll(subDir, 0o755)) + + json := `{ + "pipelineRunName": "test-run-1", + "status": "Succeeded", + "namespace": "test-ns", + "duration": "120s", + "eventType": "push", + "scenario": "e2e-test", + "git": { + "gitOrganization": "org", + "gitRepository": "repo", + "pullRequestNumber": "", + "commitSha": "abc123" + }, + "timestamps": { + "createdAt": "2024-06-18T10:15:30Z", + "startedAt": "2024-06-18T10:16:00Z", + "finishedAt": "2024-06-18T11:20:06Z" + } + }` + assert.NoError(t, os.WriteFile(filepath.Join(subDir, "pipeline-status.json"), []byte(json), 0o644)) + + runs, err := extractTektonPipelineRuns(ctx, nil, dir, "/tmp/logs", logger) + assert.Nil(t, err) + assert.Len(t, runs, 1) + assert.Equal(t, "test-run-1", runs[0].PipelineRunName) + assert.Equal(t, "Succeeded", runs[0].Status) + assert.Equal(t, "test-ns", runs[0].Namespace) + assert.Equal(t, "120s", runs[0].Duration) + assert.Equal(t, "org", runs[0].Git.GitOrganization) + assert.Equal(t, "repo", runs[0].Git.GitRepository) + assert.Equal(t, "abc123", runs[0].Git.CommitSha) + }) + + t.Run("missing required fields skips entry", func(t *testing.T) { + logger := newMockLogger() + + dir := t.TempDir() + subDir := filepath.Join(dir, "run-empty") + assert.NoError(t, os.MkdirAll(subDir, 0o755)) + + // PipelineRunName is empty — should be skipped + json := `{"pipelineRunName": "", "status": "Failed"}` + assert.NoError(t, os.WriteFile(filepath.Join(subDir, "pipeline-status.json"), []byte(json), 0o644)) + + runs, err := extractTektonPipelineRuns(ctx, nil, dir, "/tmp/logs", logger) + assert.Nil(t, err) + assert.Empty(t, runs) + }) + + t.Run("missing status skips entry", func(t *testing.T) { + logger := newMockLogger() + + dir := t.TempDir() + subDir := filepath.Join(dir, "run-nostatus") + assert.NoError(t, os.MkdirAll(subDir, 0o755)) + + json := `{"pipelineRunName": "run-1", "status": ""}` + assert.NoError(t, os.WriteFile(filepath.Join(subDir, "pipeline-status.json"), []byte(json), 0o644)) + + runs, err := extractTektonPipelineRuns(ctx, nil, dir, "/tmp/logs", logger) + assert.Nil(t, err) + assert.Empty(t, runs) + }) + + t.Run("invalid JSON is skipped", func(t *testing.T) { + logger := newMockLogger() + + dir := t.TempDir() + subDir := filepath.Join(dir, "run-invalid") + assert.NoError(t, os.MkdirAll(subDir, 0o755)) + + assert.NoError(t, os.WriteFile(filepath.Join(subDir, "pipeline-status.json"), []byte("{not valid json"), 0o644)) + + runs, err := extractTektonPipelineRuns(ctx, nil, dir, "/tmp/logs", logger) + assert.Nil(t, err) + assert.Empty(t, runs) + }) + + t.Run("no pipeline-status.json files", func(t *testing.T) { + logger := newMockLogger() + + dir := t.TempDir() + // Empty directory — no files + runs, err := extractTektonPipelineRuns(ctx, nil, dir, "/tmp/logs", logger) + assert.Nil(t, err) + assert.Empty(t, runs) + }) + + t.Run("multiple pipeline-status.json files", func(t *testing.T) { + logger := newMockLogger() + + dir := t.TempDir() + + // First pipeline run in subdir1 + sub1 := filepath.Join(dir, "subdir1") + assert.NoError(t, os.MkdirAll(sub1, 0o755)) + json1 := `{"pipelineRunName": "run-alpha", "status": "Succeeded", "namespace": "ns1"}` + assert.NoError(t, os.WriteFile(filepath.Join(sub1, "pipeline-status.json"), []byte(json1), 0o644)) + + // Second pipeline run in subdir2 + sub2 := filepath.Join(dir, "subdir2") + assert.NoError(t, os.MkdirAll(sub2, 0o755)) + json2 := `{"pipelineRunName": "run-beta", "status": "Failed", "namespace": "ns2"}` + assert.NoError(t, os.WriteFile(filepath.Join(sub2, "pipeline-status.json"), []byte(json2), 0o644)) + + runs, err := extractTektonPipelineRuns(ctx, nil, dir, "/tmp/logs", logger) + assert.Nil(t, err) + assert.Len(t, runs, 2) + + // Collect names since walk order may vary + names := make(map[string]bool) + for _, r := range runs { + names[r.PipelineRunName] = true + } + assert.True(t, names["run-alpha"]) + assert.True(t, names["run-beta"]) + }) + + t.Run("nonexistent directory returns error", func(t *testing.T) { + logger := newMockLogger() + + runs, err := extractTektonPipelineRuns(ctx, nil, "/nonexistent/path", "/tmp/logs", logger) + assert.NotNil(t, err) + assert.Nil(t, runs) + }) + + t.Run("other files are ignored", func(t *testing.T) { + logger := newMockLogger() + + dir := t.TempDir() + // Write a file that is NOT pipeline-status.json + assert.NoError(t, os.WriteFile(filepath.Join(dir, "other-file.json"), []byte(`{"key":"val"}`), 0o644)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "pipeline-status.txt"), []byte(`not json`), 0o644)) + + runs, err := extractTektonPipelineRuns(ctx, nil, dir, "/tmp/logs", logger) + assert.Nil(t, err) + assert.Empty(t, runs) + }) +} diff --git a/backend/plugins/testregistry/tasks/tekton_collector_test.go b/backend/plugins/testregistry/tasks/tekton_collector_test.go new file mode 100644 index 00000000000..b993caaa21b --- /dev/null +++ b/backend/plugins/testregistry/tasks/tekton_collector_test.go @@ -0,0 +1,304 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + "time" + + "github.com/apache/devlake/core/errors" + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + "github.com/apache/devlake/plugins/testregistry/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestConvertTektonPipelineRunToCIJob(t *testing.T) { + t.Run("full conversion with pull_request", func(t *testing.T) { + pr := &TektonPipelineRun{ + PipelineRunName: "run-abc", + Namespace: "ci-ns", + Duration: "600s", + Status: "Succeeded", + EventType: "pull_request", + Scenario: "e2e-test", + ConsoleUrl: "https://console.example.com/run-abc", + Git: TektonGitInfo{ + GitOrganization: "konflux-ci", + GitRepository: "integration-service", + PullRequestNumber: "42", + CommitSha: "abc123def", + PullRequestAuthor: "developer1", + }, + Timestamps: TektonTimestamps{ + CreatedAt: "2024-06-15T10:00:00Z", + StartedAt: "2024-06-15T10:01:00Z", + FinishedAt: "2024-06-15T10:11:00Z", + }, + } + + ciJob, err := convertTektonPipelineRunToCIJob(pr, 1, "scope-1", "quay-org", "repo") + assert.Nil(t, err) + assert.Equal(t, "run-abc", ciJob.JobId) + assert.Equal(t, "e2e-test", ciJob.JobName) + assert.Equal(t, "tekton", ciJob.JobType) + assert.Equal(t, "konflux-ci", ciJob.Organization) + assert.Equal(t, "integration-service", ciJob.Repository) + assert.Equal(t, "pull_request", ciJob.TriggerType) + assert.Equal(t, "SUCCESS", ciJob.Result) + assert.Equal(t, "ci-ns", ciJob.Namespace) + assert.Equal(t, "abc123def", ciJob.CommitSHA) + assert.NotNil(t, ciJob.PullRequestNumber) + assert.Equal(t, 42, *ciJob.PullRequestNumber) + assert.Equal(t, "developer1", ciJob.PullRequestAuthor) + assert.Equal(t, "https://console.example.com/run-abc", ciJob.ViewURL) + assert.NotNil(t, ciJob.DurationSec) + assert.InDelta(t, 600.0, *ciJob.DurationSec, 0.001) + assert.NotNil(t, ciJob.QueuedAt) + assert.NotNil(t, ciJob.StartedAt) + assert.NotNil(t, ciJob.FinishedAt) + assert.NotNil(t, ciJob.QueuedDurationSec) + assert.InDelta(t, 60.0, *ciJob.QueuedDurationSec, 0.001) + }) + + t.Run("push event type", func(t *testing.T) { + pr := &TektonPipelineRun{ + PipelineRunName: "run-push", + Status: "Failed", + EventType: "push", + Scenario: "build", + Git: TektonGitInfo{ + GitOrganization: "org", + GitRepository: "repo", + CommitSha: "sha999", + }, + } + + ciJob, err := convertTektonPipelineRunToCIJob(pr, 1, "scope", "org", "repo") + assert.Nil(t, err) + assert.Equal(t, "push", ciJob.TriggerType) + assert.Equal(t, "FAILURE", ciJob.Result) + assert.Nil(t, ciJob.PullRequestNumber) + assert.Empty(t, ciJob.PullRequestAuthor) + }) + + t.Run("status mapping", func(t *testing.T) { + statuses := []struct { + input string + expected string + }{ + {"Succeeded", "SUCCESS"}, + {"Failed", "FAILURE"}, + {"Cancelled", "ABORTED"}, + {"Running", "OTHER"}, + {"Pending", "OTHER"}, + {"Unknown", "OTHER"}, + } + for _, tt := range statuses { + t.Run(tt.input, func(t *testing.T) { + pr := &TektonPipelineRun{ + PipelineRunName: "run-" + tt.input, + Status: tt.input, + Scenario: "test", + } + ciJob, err := convertTektonPipelineRunToCIJob(pr, 1, "s", "o", "r") + assert.Nil(t, err) + assert.Equal(t, tt.expected, ciJob.Result) + }) + } + }) + + t.Run("uses fallback org/repo when git info empty", func(t *testing.T) { + pr := &TektonPipelineRun{ + PipelineRunName: "run-1", + Status: "Succeeded", + Scenario: "test", + } + + ciJob, err := convertTektonPipelineRunToCIJob(pr, 1, "scope", "fallback-org", "fallback-repo") + assert.Nil(t, err) + assert.Equal(t, "fallback-org", ciJob.Organization) + assert.Equal(t, "fallback-repo", ciJob.Repository) + }) + + t.Run("unparseable duration leaves DurationSec nil", func(t *testing.T) { + pr := &TektonPipelineRun{ + PipelineRunName: "run-1", + Status: "Succeeded", + Scenario: "test", + Duration: "not-a-duration", + } + ciJob, err := convertTektonPipelineRunToCIJob(pr, 1, "s", "o", "r") + assert.Nil(t, err) + assert.Nil(t, ciJob.DurationSec) + }) + + t.Run("empty duration", func(t *testing.T) { + pr := &TektonPipelineRun{ + PipelineRunName: "run-1", + Status: "Succeeded", + Scenario: "test", + } + ciJob, err := convertTektonPipelineRunToCIJob(pr, 1, "s", "o", "r") + assert.Nil(t, err) + assert.Nil(t, ciJob.DurationSec) + }) +} + +func TestValidateRequiredCIJobFields(t *testing.T) { + t.Run("fully valid job returns no missing fields", func(t *testing.T) { + prNum := 1 + started := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + finished := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC) + ciJob := &models.TestRegistryCIJob{ + ConnectionId: 1, + JobId: "job-1", + JobName: "e2e-test", + JobType: "tekton", + Organization: "org", + Repository: "repo", + TriggerType: "pull_request", + Result: "SUCCESS", + ScopeId: "scope-1", + PullRequestNumber: &prNum, + StartedAt: &started, + FinishedAt: &finished, + CommitSHA: "abc123", + } + missing := validateRequiredCIJobFields(ciJob) + assert.Empty(t, missing) + }) + + t.Run("empty job returns all missing fields", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{} + missing := validateRequiredCIJobFields(ciJob) + assert.Contains(t, missing, "JobId") + assert.Contains(t, missing, "JobName") + assert.Contains(t, missing, "JobType") + assert.Contains(t, missing, "Organization") + assert.Contains(t, missing, "Repository") + assert.Contains(t, missing, "TriggerType") + assert.Contains(t, missing, "Result") + assert.Contains(t, missing, "ScopeId") + assert.Contains(t, missing, "FinishedAt") + assert.Contains(t, missing, "StartedAt") + assert.Contains(t, missing, "CommitSHA") + }) + + t.Run("pull_request without PR number reports it", func(t *testing.T) { + started := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + finished := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC) + ciJob := &models.TestRegistryCIJob{ + ConnectionId: 1, + JobId: "job-1", + JobName: "test", + JobType: "prow", + Organization: "org", + Repository: "repo", + TriggerType: "pull_request", + Result: "SUCCESS", + ScopeId: "s", + StartedAt: &started, + FinishedAt: &finished, + CommitSHA: "abc", + } + missing := validateRequiredCIJobFields(ciJob) + found := false + for _, m := range missing { + if m == "PullRequestNumber (required for pull_request)" { + found = true + } + } + assert.True(t, found, "should report missing PullRequestNumber for pull_request trigger") + }) + + t.Run("push trigger without PR number is valid", func(t *testing.T) { + started := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + finished := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC) + ciJob := &models.TestRegistryCIJob{ + ConnectionId: 1, + JobId: "job-1", + JobName: "test", + JobType: "prow", + Organization: "org", + Repository: "repo", + TriggerType: "push", + Result: "SUCCESS", + ScopeId: "s", + StartedAt: &started, + FinishedAt: &finished, + CommitSHA: "abc", + } + missing := validateRequiredCIJobFields(ciJob) + assert.Empty(t, missing) + }) + + t.Run("ConnectionId zero is reported", func(t *testing.T) { + ciJob := &models.TestRegistryCIJob{ + JobId: "j", + } + missing := validateRequiredCIJobFields(ciJob) + assert.Contains(t, missing, "ConnectionId") + }) +} + +func TestIsTektonJobAlreadyProcessed(t *testing.T) { + t.Run("count > 0 returns true", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Count", mock.Anything).Return(int64(3), nil) + assert.True(t, isTektonJobAlreadyProcessed(mockDal, 1, "job-1")) + }) + + t.Run("count = 0 returns false", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Count", mock.Anything).Return(int64(0), nil) + assert.False(t, isTektonJobAlreadyProcessed(mockDal, 1, "job-1")) + }) + + t.Run("error returns false", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockDal.On("Count", mock.Anything).Return(int64(0), errors.Default.New("db error")) + assert.False(t, isTektonJobAlreadyProcessed(mockDal, 1, "job-1")) + }) +} + +func TestSaveRawTektonData(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("Create", mock.Anything, mock.Anything).Return(nil) + + pr := &TektonPipelineRun{ + PipelineRunName: "run-1", + Status: "Succeeded", + } + err := saveRawTektonData(mockDal, mockLogger, pr, `{"ConnectionId":1}`, "raw_table", "https://api.example.com") + assert.Nil(t, err) + }) + + t.Run("error", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("Create", mock.Anything, mock.Anything).Return(errors.Default.New("db error")) + + pr := &TektonPipelineRun{PipelineRunName: "run-err"} + err := saveRawTektonData(mockDal, mockLogger, pr, `{}`, "raw_table", "url") + assert.NotNil(t, err) + }) +} diff --git a/backend/plugins/testregistry/tasks/tekton_tasks.go b/backend/plugins/testregistry/tasks/tekton_tasks.go new file mode 100644 index 00000000000..17b62c4fb9c --- /dev/null +++ b/backend/plugins/testregistry/tasks/tekton_tasks.go @@ -0,0 +1,175 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/log" + "github.com/apache/devlake/core/plugin" + "github.com/apache/devlake/plugins/testregistry/models" +) + +// saveTektonTasks saves all task runs from a Tekton PipelineRun to the database +// +// Parameters: +// - db: Database connection +// - logger: Logger for error reporting +// - connectionId: The DevLake connection ID +// - jobId: The CI job ID (PipelineRunName) +// - taskRuns: List of TektonTaskRun objects from pipeline-status.json +// +// Returns: +// - errors.Error: Any error encountered during saving, or nil if successful +func saveTektonTasks(db dal.Dal, logger log.Logger, connectionId uint64, jobId string, taskRuns []TektonTaskRun) errors.Error { + for _, taskRun := range taskRuns { + if taskRun.Name == "" { + logger.Warn(nil, "Task run missing name, skipping", "job_id", jobId) + continue + } + + // Parse duration from string (e.g., "499s") to float64 + var durationSec float64 + if taskRun.Duration != "" { + // Remove "s" suffix and parse to float64 + durationStr := strings.TrimSuffix(taskRun.Duration, "s") + if duration, parseErr := strconv.ParseFloat(durationStr, 64); parseErr == nil { + durationSec = duration + } else { + logger.Debug("Failed to parse task duration", "job_id", jobId, "task_name", taskRun.Name, "duration", taskRun.Duration) + } + } + + task := &models.TektonTask{ + ConnectionId: connectionId, + JobId: jobId, + TaskName: taskRun.Name, + Status: taskRun.Status, + DurationSec: durationSec, + } + + if err := db.CreateOrUpdate(task); err != nil { + logger.Warn(err, "failed to save Tekton task", "job_id", jobId, "task_name", taskRun.Name) + continue + } + + logger.Debug("Saved Tekton task", "job_id", jobId, "task_name", taskRun.Name, "status", taskRun.Status, "duration_sec", durationSec) + } + + return nil +} + +// findAndProcessJUnitFiles finds JUnit XML files in the artifact directory and processes them +// +// Parameters: +// - taskCtx: The subtask context +// - artifactPath: Local path where artifact was pulled (tmp/{uuid}/) +// - ciJob: The CI job model +// - organization: The organization name (for logging) +// - repository: The repository name (for logging) +// - junitRegex: Compiled regex pattern for matching JUnit file names +// +// Returns: +// - bool: true if at least one JUnit XML file was found and processed successfully, false otherwise +func findAndProcessJUnitFiles(taskCtx plugin.SubTaskContext, artifactPath string, ciJob *models.TestRegistryCIJob, organization, repository string, junitRegex *regexp.Regexp) bool { + logger := taskCtx.GetLogger() + + // Use default regex if not provided + if junitRegex == nil { + junitRegex = JUnitRegexpSearch + } + + // Collect all JUnit files found in the artifact directory + type junitFile struct { + content []byte + fileName string + path string + } + var junitFiles []junitFile + + // Walk the artifact directory to find all JUnit XML files matching the regex + err := filepath.Walk(artifactPath, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + + // Look for files matching the JUnit regex pattern + if !info.IsDir() { + fileName := filepath.Base(path) + if junitRegex.MatchString(fileName) { + logger.Debug("Found JUnit XML file", "file", fileName, "path", path, "job_id", ciJob.JobId) + + // Read the JUnit XML content + content, readErr := os.ReadFile(path) + if readErr != nil { + logger.Warn(readErr, "failed to read JUnit XML file", "path", path, "job_id", ciJob.JobId) + return nil // Continue processing other files + } + + // Collect all matching JUnit files + junitFiles = append(junitFiles, junitFile{ + content: content, + fileName: fileName, + path: path, + }) + } + } + + return nil + }) + + if err != nil { + logger.Warn(err, "failed to walk artifact directory for JUnit files", "job_id", ciJob.JobId) + return false + } + + if len(junitFiles) == 0 { + logger.Debug("No JUnit XML files found in artifact", "job_id", ciJob.JobId, "artifact_path", artifactPath) + return false + } + + if len(junitFiles) > 1 { + logger.Info("Found multiple JUnit XML files, processing all", "job_id", ciJob.JobId, "file_count", len(junitFiles)) + } else { + logger.Info("Found JUnit XML file, processing", "job_id", ciJob.JobId, "file", junitFiles[0].fileName) + } + + // Process each JUnit file found + successCount := 0 + for idx, junitFile := range junitFiles { + logger.Debug("Processing JUnit XML file", "job_id", ciJob.JobId, "file", junitFile.fileName, "index", idx+1, "total", len(junitFiles)) + + // Process and save JUnit XML using the same function as Prow + if parseAndSaveJUnitSuites(taskCtx, logger, junitFile.content, junitFile.fileName, ciJob, organization, repository) { + successCount++ + } else { + logger.Warn(nil, "failed to process JUnit XML file", "job_id", ciJob.JobId, "file", junitFile.fileName) + } + } + + logger.Info("Finished processing JUnit XML files", "job_id", ciJob.JobId, "total_files", len(junitFiles), "successful", successCount) + + // Return true if at least one file was successfully processed + return successCount > 0 +} diff --git a/backend/plugins/testregistry/tasks/tekton_tasks_test.go b/backend/plugins/testregistry/tasks/tekton_tasks_test.go new file mode 100644 index 00000000000..b2148a45cd2 --- /dev/null +++ b/backend/plugins/testregistry/tasks/tekton_tasks_test.go @@ -0,0 +1,228 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/apache/devlake/core/errors" + mockdal "github.com/apache/devlake/mocks/core/dal" + mocklog "github.com/apache/devlake/mocks/core/log" + mockplugin "github.com/apache/devlake/mocks/core/plugin" + "github.com/apache/devlake/plugins/testregistry/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestSaveTektonTasks(t *testing.T) { + t.Run("saves tasks successfully", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + + taskRuns := []TektonTaskRun{ + {Name: "build", Status: "Succeeded", Duration: "120s"}, + {Name: "test", Status: "Failed", Duration: "300s"}, + } + err := saveTektonTasks(mockDal, mockLogger, 1, "job-1", taskRuns) + assert.Nil(t, err) + mockDal.AssertNumberOfCalls(t, "CreateOrUpdate", 2) + }) + + t.Run("empty task runs", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + err := saveTektonTasks(mockDal, mockLogger, 1, "job-1", []TektonTaskRun{}) + assert.Nil(t, err) + }) + + t.Run("skips task with empty name", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + + taskRuns := []TektonTaskRun{{Name: "", Status: "Succeeded"}} + err := saveTektonTasks(mockDal, mockLogger, 1, "job-1", taskRuns) + assert.Nil(t, err) + mockDal.AssertNotCalled(t, "CreateOrUpdate") + }) + + t.Run("unparseable duration uses 0", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil) + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + + taskRuns := []TektonTaskRun{{Name: "task1", Status: "Succeeded", Duration: "invalid"}} + err := saveTektonTasks(mockDal, mockLogger, 1, "job-1", taskRuns) + assert.Nil(t, err) + }) + + t.Run("CreateOrUpdate error continues", func(t *testing.T) { + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(errors.Default.New("db error")) + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + + taskRuns := []TektonTaskRun{{Name: "task1", Status: "Failed"}} + err := saveTektonTasks(mockDal, mockLogger, 1, "job-1", taskRuns) + assert.Nil(t, err) // saveTektonTasks continues on error, returns nil + }) +} + +// setupMockContext creates a mock SubTaskContext with logger and dal wired up. +// The dal mock is configured to allow CreateOrUpdate calls (for suite/test case saves). +func setupMockContext(t *testing.T) (*mockplugin.SubTaskContext, *mockdal.Dal, *mocklog.Logger) { + t.Helper() + mockCtx := new(mockplugin.SubTaskContext) + mockDal := new(mockdal.Dal) + mockLogger := new(mocklog.Logger) + + mockCtx.On("GetLogger").Return(mockLogger) + mockCtx.On("GetDal").Return(mockDal) + + // Logger — the generated mock packs variadic args into a single slice arg + mockLogger.On("Info", mock.Anything, mock.Anything).Maybe() + mockLogger.On("Debug", mock.Anything, mock.Anything).Maybe() + mockLogger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe() + + // Dal — CreateOrUpdate is called by saveSuiteRecursively and saveTestCase + mockDal.On("CreateOrUpdate", mock.Anything, mock.Anything).Return(nil).Maybe() + + return mockCtx, mockDal, mockLogger +} + +const validJUnitXML = ` + + + + + expected true got false + + +` + +func TestFindAndProcessJUnitFiles(t *testing.T) { + ciJob := &models.TestRegistryCIJob{ + ConnectionId: 1, + JobId: "junit-test-job", + JobName: "e2e-tests", + TriggerType: "push", + Result: "FAILURE", + } + + t.Run("single JUnit file found and processed", func(t *testing.T) { + mockCtx, _, _ := setupMockContext(t) + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "e2e-results.xml"), []byte(validJUnitXML), 0o644) + assert.NoError(t, err) + + // Use a regex that matches the file we created + re := regexp.MustCompile(`e2e-results\.xml`) + result := findAndProcessJUnitFiles(mockCtx, dir, ciJob, "org", "repo", re) + assert.True(t, result) + }) + + t.Run("no matching files returns false", func(t *testing.T) { + mockCtx, _, _ := setupMockContext(t) + + dir := t.TempDir() + // Empty directory — no files at all + result := findAndProcessJUnitFiles(mockCtx, dir, ciJob, "org", "repo", regexp.MustCompile(`junit.*\.xml`)) + assert.False(t, result) + }) + + t.Run("multiple JUnit files", func(t *testing.T) { + mockCtx, _, _ := setupMockContext(t) + + dir := t.TempDir() + assert.NoError(t, os.WriteFile(filepath.Join(dir, "e2e-first.xml"), []byte(validJUnitXML), 0o644)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "e2e-second.xml"), []byte(validJUnitXML), 0o644)) + + re := regexp.MustCompile(`e2e-.*\.xml`) + result := findAndProcessJUnitFiles(mockCtx, dir, ciJob, "org", "repo", re) + assert.True(t, result) + }) + + t.Run("nil junitRegex uses default pattern", func(t *testing.T) { + mockCtx, _, _ := setupMockContext(t) + + dir := t.TempDir() + // File name must match the DefaultJUnitRegexPattern: (devlake-|e2e|qd-report-)[0-9a-z-]+\.(xml|junit) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "e2e-abc123.xml"), []byte(validJUnitXML), 0o644)) + + result := findAndProcessJUnitFiles(mockCtx, dir, ciJob, "org", "repo", nil) + assert.True(t, result) + }) + + t.Run("unreadable file is skipped gracefully", func(t *testing.T) { + mockCtx, _, _ := setupMockContext(t) + + dir := t.TempDir() + filePath := filepath.Join(dir, "e2e-unreadable.xml") + assert.NoError(t, os.WriteFile(filePath, []byte(validJUnitXML), 0o644)) + // Remove read permission so os.ReadFile fails + assert.NoError(t, os.Chmod(filePath, 0o000)) + t.Cleanup(func() { os.Chmod(filePath, 0o644) }) + + re := regexp.MustCompile(`e2e-.*\.xml`) + result := findAndProcessJUnitFiles(mockCtx, dir, ciJob, "org", "repo", re) + // The file is found but cannot be read, so no files are successfully processed + assert.False(t, result) + }) + + t.Run("non-matching files ignored", func(t *testing.T) { + mockCtx, _, _ := setupMockContext(t) + + dir := t.TempDir() + // Create files that do NOT match the regex + assert.NoError(t, os.WriteFile(filepath.Join(dir, "output.log"), []byte("log data"), 0o644)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "report.json"), []byte("{}"), 0o644)) + + re := regexp.MustCompile(`e2e-.*\.xml`) + result := findAndProcessJUnitFiles(mockCtx, dir, ciJob, "org", "repo", re) + assert.False(t, result) + }) + + t.Run("nonexistent directory returns false", func(t *testing.T) { + mockCtx, _, _ := setupMockContext(t) + + result := findAndProcessJUnitFiles(mockCtx, "/nonexistent/path/to/artifacts", ciJob, "org", "repo", regexp.MustCompile(`.*\.xml`)) + assert.False(t, result) + }) + + t.Run("JUnit file in subdirectory is found", func(t *testing.T) { + mockCtx, _, _ := setupMockContext(t) + + dir := t.TempDir() + subDir := filepath.Join(dir, "artifacts", "test-output") + assert.NoError(t, os.MkdirAll(subDir, 0o755)) + assert.NoError(t, os.WriteFile(filepath.Join(subDir, "e2e-deep.xml"), []byte(validJUnitXML), 0o644)) + + re := regexp.MustCompile(`e2e-.*\.xml`) + result := findAndProcessJUnitFiles(mockCtx, dir, ciJob, "org", "repo", re) + assert.True(t, result) + }) +} diff --git a/backend/plugins/testregistry/testregistry.go b/backend/plugins/testregistry/testregistry.go new file mode 100644 index 00000000000..4a131952201 --- /dev/null +++ b/backend/plugins/testregistry/testregistry.go @@ -0,0 +1,39 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/apache/devlake/plugins/testregistry/impl" + "github.com/spf13/cobra" +) + +// PluginEntry is a variable named for Framework to search and load +var PluginEntry impl.TestRegistry //nolint + +// standalone mode for debugging +func main() { + cmd := &cobra.Command{Use: "testregistry"} + + cmd.Run = func(cmd *cobra.Command, args []string) { + println(`testregistry plugin loaded`) + } + err := cmd.Execute() + if err != nil { + panic(err) + } +} diff --git a/backend/python/run_tests.sh b/backend/python/run_tests.sh index 41c5982f092..0f5bf981691 100755 --- a/backend/python/run_tests.sh +++ b/backend/python/run_tests.sh @@ -17,11 +17,29 @@ # SCRIPT_DIR=$(CDPATH= cd -- "${0%/*}" && pwd) +if [ -n "${COVERAGE_OUTPUT_DIR:-}" ]; then + mkdir -p "$COVERAGE_OUTPUT_DIR" + COV_ABS_DIR=$(cd "$COVERAGE_OUTPUT_DIR" && pwd) +fi + +cov_index=0 for test_dir in $(find "$SCRIPT_DIR" -path '*/.venv' -prune -o -type f -name "*_test.py" -print | xargs dirname | sort -u); do project_dir=$(dirname "$test_dir") printf "Running Python tests in $test_dir\n" sh "$SCRIPT_DIR/uv.sh" sync "$project_dir" - sh "$SCRIPT_DIR/uv.sh" pytest "$project_dir" "$test_dir" + + if [ -n "${COV_ABS_DIR:-}" ]; then + cov_index=$((cov_index + 1)) + python_bin="$project_dir/.venv/bin/python" + "$python_bin" -m pip install pytest pytest-cov + "$python_bin" -m pytest \ + --cov="$project_dir" \ + --cov-report="xml:${COV_ABS_DIR}/coverage-${cov_index}.xml" \ + "$test_dir" + else + sh "$SCRIPT_DIR/uv.sh" pytest "$project_dir" "$test_dir" + fi + if [ $? != 0 ]; then exit 1 fi diff --git a/backend/scripts/build-plugins.sh b/backend/scripts/build-plugins.sh index 3619e722653..97851cf9187 100755 --- a/backend/scripts/build-plugins.sh +++ b/backend/scripts/build-plugins.sh @@ -50,6 +50,11 @@ if [ -n "$DEVLAKE_DEBUG" ]; then GCFLAGS=all=-N\ -l fi +COVER_FLAGS="" +if [ -n "$DEVLAKE_COVER" ]; then + COVER_FLAGS="-cover -covermode=atomic -coverpkg=github.com/apache/devlake/..." +fi + if [ -z "$DEVLAKE_PLUGINS" ]; then echo "Building all plugins" # schema_e2e is not a plugin, it only holds the cross-plugin schema-drift e2e test @@ -84,7 +89,7 @@ echo "Plugin build parallelism: $PARALLELISM" for PLUG in $PLUGINS; do NAME=$(basename $PLUG) echo "Building plugin $NAME to bin/plugins/$NAME/$NAME.so with args: $* --gcflags="$GCFLAGS"" - go build -buildmode=plugin --gcflags="$GCFLAGS" -o $PLUGIN_OUTPUT_DIR/$NAME/$NAME.so $PLUG/*.go & + go build $COVER_FLAGS -buildmode=plugin --gcflags="$GCFLAGS" -o $PLUGIN_OUTPUT_DIR/$NAME/$NAME.so $PLUG/*.go & PIDS="$PIDS $!" # avoid too many processes causing signal killed COUNT=$(echo "$PIDS" | wc -w) diff --git a/backend/scripts/build-server.sh b/backend/scripts/build-server.sh index b390185c3f0..47c75ced40b 100755 --- a/backend/scripts/build-server.sh +++ b/backend/scripts/build-server.sh @@ -22,9 +22,14 @@ echo build-server.sh VERSION: $VERSION ROOT_DIR=$(dirname $(dirname "$0")) VERSION=${VERSION:-$(git describe --tags --always --dirty || true)} EXTRA="" +COVER_FLAGS="" if [ -n "$DEVLAKE_DEBUG" ]; then EXTRA="-gcflags='all=-N -l'" fi -go build $EXTRA -ldflags "-X 'github.com/apache/devlake/core/version.Version=$VERSION'" -o $ROOT_DIR/bin/lake $ROOT_DIR/server/ \ No newline at end of file +if [ -n "$DEVLAKE_COVER" ]; then + COVER_FLAGS="-cover -covermode=atomic -tags=coverage -coverpkg=github.com/apache/devlake/..." +fi + +go build $COVER_FLAGS $EXTRA -ldflags "-X 'github.com/apache/devlake/core/version.Version=$VERSION'" -o $ROOT_DIR/bin/lake $ROOT_DIR/server/ diff --git a/backend/scripts/coverage-custom-plugins.sh b/backend/scripts/coverage-custom-plugins.sh new file mode 100755 index 00000000000..8344b0fac91 --- /dev/null +++ b/backend/scripts/coverage-custom-plugins.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +PLUGINS="aireview codecov testregistry" + +for p in $PLUGINS; do + echo "==> Running tests for plugin: $p" + go test -coverprofile="coverage-${p}.out" -covermode=atomic \ + -coverpkg="github.com/apache/devlake/plugins/${p}/..." \ + "./plugins/${p}/..." 2>&1 || true +done + +echo "mode: atomic" > coverage-custom-plugins.out +for p in $PLUGINS; do + if [ -f "coverage-${p}.out" ]; then + tail -n +2 "coverage-${p}.out" >> coverage-custom-plugins.out + rm -f "coverage-${p}.out" + fi +done + +echo "" +echo "==> Combined coverage:" +go tool cover -func=coverage-custom-plugins.out | tail -1 diff --git a/backend/server/api/api.go b/backend/server/api/api.go index 3116e59429e..7a1804b3f9e 100644 --- a/backend/server/api/api.go +++ b/backend/server/api/api.go @@ -24,8 +24,9 @@ import ( "net/url" "strconv" "strings" + "time" - // "github.com/gin-contrib/cors" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" @@ -53,6 +54,23 @@ const DB_MIGRATING = `Database migration is in progress. Please wait until it is var basicRes context.BasicRes +// parseCORSAllowOrigins splits comma-separated origins so env vars like +// "https://a.com,https://b.com" (often a single slice element from Viper) become multiple origins. +func parseCORSAllowOrigins(origins []string) []string { + if len(origins) == 0 { + return nil + } + out := make([]string, 0, len(origins)) + for _, s := range origins { + for _, part := range strings.Split(s, ",") { + if o := strings.TrimSpace(part); o != "" { + out = append(out, o) + } + } + } + return out +} + func Init() { services.Init() basicRes = services.GetBasicRes() @@ -82,22 +100,16 @@ func CreateApiServer() *gin.Engine { // Create router router := gin.Default() - // Enable CORS - // cfg := basicRes.GetConfigReader() - // router.Use(cors.New(cors.Config{ - // // Allow all origins - // AllowOrigins: cfg.GetStringSlice("CORS_ALLOW_ORIGIN"), - // // Allow common methods - // AllowMethods: []string{"PUT", "PATCH", "POST", "GET", "OPTIONS"}, - // // Allow common headers - // AllowHeaders: []string{"Origin", "Content-Type"}, - // // Expose these headers - // ExposeHeaders: []string{"Content-Length"}, - // // Allow credentials - // AllowCredentials: false, - // // Cache for 2 hours - // MaxAge: 120 * time.Hour, - // })) + // Enable CORS (split comma-separated origins: env vars often come as one string) + cfg := basicRes.GetConfigReader() + router.Use(cors.New(cors.Config{ + AllowOrigins: parseCORSAllowOrigins(cfg.GetStringSlice("CORS_ALLOW_ORIGIN")), + AllowMethods: []string{"PUT", "PATCH", "POST", "GET", "OPTIONS", "DELETE"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 2 * time.Hour, + })) // For both protected and unprotected routes router.GET("/ping", ping.Get) diff --git a/backend/server/coverage_init.go b/backend/server/coverage_init.go new file mode 100644 index 00000000000..4b1bcaeb1d6 --- /dev/null +++ b/backend/server/coverage_init.go @@ -0,0 +1,22 @@ +//go:build coverage + +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import _ "github.com/konflux-ci/coverport/instrumentation/go" diff --git a/backend/server/services/project.go b/backend/server/services/project.go index c93d44f2553..04d9b127fa9 100644 --- a/backend/server/services/project.go +++ b/backend/server/services/project.go @@ -19,11 +19,13 @@ package services import ( "fmt" - "golang.org/x/exp/slices" - "golang.org/x/sync/errgroup" + "strconv" "strings" "time" + "golang.org/x/exp/slices" + "golang.org/x/sync/errgroup" + "github.com/apache/devlake/core/dal" "github.com/apache/devlake/core/errors" "github.com/apache/devlake/core/models" @@ -32,6 +34,8 @@ import ( helper "github.com/apache/devlake/helpers/pluginhelper/api" ) +const defaultSyncDays = 180 + type ProjectService interface { RenameProject(db dal.Transaction, oldProjectName, newProjectName string) errors.Error } @@ -147,11 +151,17 @@ func CreateProject(projectInput *models.ApiInputProject) (*models.ApiOutputProje ProjectName: project.Name, Mode: "NORMAL", Enable: true, - CronConfig: "0 0 * * *", + CronConfig: "0 0 * * 0-5", IsManual: false, SyncPolicy: models.SyncPolicy{ TimeAfter: func() *time.Time { - t := time.Now().AddDate(0, -6, 0) + days := defaultSyncDays + if v := cfg.GetString("DEFAULT_SYNC_DAYS"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + days = parsed + } + } + t := time.Now().AddDate(0, 0, -days) t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) return &t }(), @@ -501,15 +511,13 @@ func makeProjectOutput(project *models.Project, withLastPipeline bool) (*models. if err != nil { return nil, errors.Default.Wrap(err, "failed to load project metrics") } - // convert metric to api output - if len(projectMetrics) > 0 { - baseMetrics := make([]*models.BaseMetric, len(projectMetrics)) - for i, projectMetric := range projectMetrics { - baseMetric := projectMetric.BaseMetric - baseMetrics[i] = &baseMetric - } - projectOutput.Metrics = baseMetrics + // convert metric to api output — always assign so JSON serializes as [] not null + baseMetrics := make([]*models.BaseMetric, 0, len(projectMetrics)) + for _, projectMetric := range projectMetrics { + baseMetric := projectMetric.BaseMetric + baseMetrics = append(baseMetrics, &baseMetric) } + projectOutput.Metrics = baseMetrics // load blueprint projectOutput.Blueprint, err = GetBlueprintByProjectName(projectOutput.Name) diff --git a/backend/test/e2e/remote/docker-compose.test.yml b/backend/test/e2e/remote/docker-compose.test.yml index de87976ad2d..dbfa8fccdbd 100644 --- a/backend/test/e2e/remote/docker-compose.test.yml +++ b/backend/test/e2e/remote/docker-compose.test.yml @@ -46,4 +46,4 @@ services: volumes: mysql-test-storage: - postgres-test-storage: \ No newline at end of file + postgres-test-storage: diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000000..ef0e184f9e3 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,68 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +codecov: + require_ci_to_pass: true + +coverage: + status: + project: + default: + target: auto + threshold: 1% + informational: true + patch: + default: + target: 80% + informational: true + +ignore: + - "grafana/**" + - "devops/**" + - "backend/mocks/**" + - "backend/test/**" + - "backend/python/test/**" + - "**/mock_*.go" + - "**/*_mock.go" + - "**/*_test.go" + - "**/*_test.py" + - ".github/**" + - "**/*.yml" + - "**/*.yaml" + - "**/*.toml" + - "**/*.sh" + - "**/*.md" + +flags: + unit-tests-go: + carryforward: true + paths: + - backend/plugins/aireview/ + - backend/plugins/codecov/ + - backend/plugins/testregistry/ + unit-tests-python: + carryforward: true + paths: + - backend/python/ + e2e-go: + carryforward: true + paths: + - backend/plugins/aireview/ + - backend/plugins/codecov/ + - backend/plugins/testregistry/ + +comment: + layout: "reach,diff,flags,files,footer" + require_changes: true diff --git a/config-ui/Containerfile b/config-ui/Containerfile new file mode 100644 index 00000000000..6746e659f43 --- /dev/null +++ b/config-ui/Containerfile @@ -0,0 +1,68 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# UBI 9-based DevLake config-ui image (parity with Dockerfile). + +# Pin to multi-arch manifest digest (not :latest) for reproducible builds. +FROM registry.access.redhat.com/ubi9/nodejs-18@sha256:ffbad8210aee178157e7621b5fa43fb85f1ac205246b0d2606bea778549da8c1 AS builder + +USER 0 +WORKDIR /opt/app-root/src + +COPY . . + +# Use committed Yarn Berry binary (.yarn/releases/yarn-3.4.1.cjs via yarnPath) +RUN node .yarn/releases/yarn-3.4.1.cjs install && \ + node .yarn/releases/yarn-3.4.1.cjs build + +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:57c8151c51445a07e503dab9dc9211dc3cdeac9d45ed81a10954b7d770659b3b + +USER 0 + +RUN microdnf -y update && \ + microdnf -y install \ + nginx \ + httpd-tools \ + iproute \ + gettext \ + shadow-utils \ + findutils \ + tar \ + coreutils-single && \ + microdnf clean all && \ + rm -rf /var/cache/yum /var/cache/dnf + +# Unprivileged nginx: drop user directive, write pid under /tmp, +# and remove the packaged server block that binds privileged port 80. +RUN sed -i '/^user /d' /etc/nginx/nginx.conf && \ + sed -i 's|pid\s\+/run/nginx.pid;|pid /tmp/nginx.pid;|' /etc/nginx/nginx.conf && \ + sed -i 's|pid\s\+/var/run/nginx.pid;|pid /tmp/nginx.pid;|' /etc/nginx/nginx.conf && \ + awk 'BEGIN{skip=0} /^[[:space:]]*server[[:space:]]*\{/{if(!done++){skip=1}} skip{if(/\{/) depth++; if(/\}/){depth--; if(depth==0){skip=0; next}} next} {print}' \ + /etc/nginx/nginx.conf > /tmp/nginx.conf && \ + mv /tmp/nginx.conf /etc/nginx/nginx.conf && \ + rm -f /etc/nginx/conf.d/default.conf + +COPY ./nginx.conf /etc/nginx/conf.d/default.conf.tpl +COPY --from=builder /opt/app-root/src/dist/ /usr/share/nginx/html/ +COPY --from=builder /opt/app-root/src/nginx.sh /usr/bin/nginx.sh +RUN chmod +x /usr/bin/nginx.sh && \ + mkdir -p /var/log/nginx /var/lib/nginx/tmp /run && \ + chgrp -R 0 /etc/nginx /var/log/nginx /var/lib/nginx /usr/share/nginx /run /tmp && \ + chmod -R g=u /etc/nginx /var/log/nginx /var/lib/nginx /usr/share/nginx /run /tmp + +EXPOSE 4000 4443 + +USER 101 +CMD ["/usr/bin/nginx.sh"] diff --git a/config-ui/Dockerfile b/config-ui/Dockerfile index 9667df3d245..6427de9ffe5 100644 --- a/config-ui/Dockerfile +++ b/config-ui/Dockerfile @@ -23,6 +23,9 @@ #While incubation status is not necessarily a reflection of the completeness or stability of the code, #it does indicate that the project has yet to be fully endorsed by the ASF. +# DEPRECATED: Prefer config-ui/Containerfile (UBI 9) for local compose and Konflux builds. +# This Debian Dockerfile is retained for upstream compatibility and will be removed later. + FROM --platform=$BUILDPLATFORM node:24-bookworm-slim AS builder WORKDIR /home/node/code diff --git a/config-ui/src/api/connection/index.ts b/config-ui/src/api/connection/index.ts index 990495ce33a..5aa5c05b813 100644 --- a/config-ui/src/api/connection/index.ts +++ b/config-ui/src/api/connection/index.ts @@ -59,7 +59,15 @@ export const test = ( | 'adminApiKey' | 'organization' | 'organizationId' + | 'service' | 'customHeaders' + | 'ciTool' + | 'quayOrganization' + | 'githubOrganization' + | 'githubToken' + | 'project' + | 'junitRegex' + | 'gitlabConnectionId' > >, ): Promise => @@ -85,6 +93,14 @@ export const testOld = ( | 'adminApiKey' | 'organization' | 'organizationId' + | 'service' | 'customHeaders' + | 'ciTool' + | 'quayOrganization' + | 'githubOrganization' + | 'githubToken' + | 'project' + | 'junitRegex' + | 'gitlabConnectionId' >, ): Promise => request(`/plugins/${plugin}/test`, { method: 'post', data: payload }); diff --git a/config-ui/src/api/plugin/aireview.ts b/config-ui/src/api/plugin/aireview.ts new file mode 100644 index 00000000000..6c4cae6b2da --- /dev/null +++ b/config-ui/src/api/plugin/aireview.ts @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { request } from '@/utils'; + +export interface IAiReviewScopeConfig { + id?: number; + name?: string; + + // CodeRabbit detection + codeRabbitEnabled: boolean; + codeRabbitUsername: string; + codeRabbitPattern: string; + + // Cursor Bugbot detection + cursorBugbotEnabled: boolean; + cursorBugbotUsername: string; + cursorBugbotPattern: string; + + // Qodo detection + qodoEnabled: boolean; + qodoUsername: string; + qodoPattern: string; + + // Gemini Code Assist detection + geminiEnabled: boolean; + geminiUsername: string; + geminiPattern: string; + + // Generic AI detection + aiCommitPatterns: string; + aiPrLabelPattern: string; + + // Risk detection + riskHighPattern: string; + riskMediumPattern: string; + riskLowPattern: string; + + // Thresholds + observationWindowDays: number; + warningThreshold: number; + + // CI failure source: 'test_cases' | 'job_result' | 'both' + ciFailureSource: string; + + // CI failure filtering + excludeFlakyTests: boolean; + excludeInfraFailures: boolean; + + // Issue linking + bugLinkPattern: string; +} + +export const getDefaultScopeConfig = (): Promise => + request('/plugins/aireview/scope-configs/default'); + +export const getScopeConfig = (id: number): Promise => + request(`/plugins/aireview/scope-configs/${id}`); + +export const createScopeConfig = (data: Partial): Promise => + request('/plugins/aireview/scope-configs', { method: 'post', data }); + +export const updateScopeConfig = (id: number, data: Partial): Promise => + request(`/plugins/aireview/scope-configs/${id}`, { method: 'patch', data }); diff --git a/config-ui/src/api/plugin/index.ts b/config-ui/src/api/plugin/index.ts index 314e622ff06..8aab993693f 100644 --- a/config-ui/src/api/plugin/index.ts +++ b/config-ui/src/api/plugin/index.ts @@ -18,6 +18,7 @@ import { request } from '@/utils'; +import * as aireview from './aireview'; import * as jira from './jira'; import * as tapd from './tapd'; import * as webhook from './webhook'; @@ -27,6 +28,7 @@ export const list = (): Promise<[{ plugin: string }]> => request('/plugins'); export const plugin = { list, + aireview, jira, tapd, webhook, diff --git a/config-ui/src/components/block/index.tsx b/config-ui/src/components/block/index.tsx index 2df5368798f..0b3c7cbe0f8 100644 --- a/config-ui/src/components/block/index.tsx +++ b/config-ui/src/components/block/index.tsx @@ -42,10 +42,11 @@ interface Props { title?: React.ReactNode; description?: React.ReactNode; required?: boolean; + error?: string; children?: React.ReactNode; } -export const Block = ({ style, title, description, required, children }: Props) => { +export const Block = ({ style, title, description, required, error, children }: Props) => { return ( {title && ( @@ -56,6 +57,7 @@ export const Block = ({ style, title, description, required, children }: Props) )} {description &&

{description}

} {children} + {error &&

{error}

}
); }; diff --git a/config-ui/src/config/cron.test.ts b/config-ui/src/config/cron.test.ts new file mode 100644 index 00000000000..36ecc5f80d2 --- /dev/null +++ b/config-ui/src/config/cron.test.ts @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { describe, it, expect } from 'vitest'; + +import { cronPresets, getCron, getCronOptions } from './cron'; + +describe('cronPresets', () => { + it('includes Daily, Weekly, and Monthly', () => { + expect(cronPresets).toHaveLength(3); + expect(cronPresets.map((p) => p.label)).toEqual(['Daily', 'Weekly', 'Monthly']); + }); + + it('has valid cron expressions', () => { + expect(cronPresets[0].config).toBe('0 0 * * *'); + expect(cronPresets[1].config).toBe('0 0 * * 1'); + expect(cronPresets[2].config).toBe('0 0 1 * *'); + }); +}); + +describe('getCron', () => { + it('returns manual config when isManual is true', () => { + const result = getCron(true, ''); + expect(result).toEqual({ + label: 'Manual', + config: '', + description: '', + nextTime: '', + nextTimes: [], + }); + }); + + it('matches a known preset and computes next times', () => { + const result = getCron(false, '0 0 * * *'); + expect(result.label).toBe('Daily'); + expect(result.description).toBe('(at 00:00AM UTC)'); + expect(result.nextTime).toBeTruthy(); + expect(result.nextTimes).toHaveLength(3); + }); + + it('returns Custom label for a non-preset cron expression', () => { + const result = getCron(false, '0 12 * * *'); + expect(result.label).toBe('Custom'); + expect(result.config).toBe('0 12 * * *'); + expect(result.nextTime).toBeTruthy(); + expect(result.nextTimes).toHaveLength(3); + }); + + it('handles an invalid cron expression gracefully', () => { + const result = getCron(false, 'invalid'); + expect(result.label).toBe('Custom'); + expect(result.nextTime).toBeNull(); + expect(result.nextTimes).toEqual([]); + }); +}); + +describe('getCronOptions', () => { + it('returns Manual + presets + Custom', () => { + const options = getCronOptions(); + expect(options).toHaveLength(5); + expect(options[0]).toEqual({ label: 'Manual', value: 'manual', subLabel: '' }); + expect(options[4]).toEqual({ label: 'Custom', value: 'custom', subLabel: '' }); + }); + + it('includes preset values in the middle', () => { + const options = getCronOptions(); + expect(options[1].value).toBe('0 0 * * *'); + expect(options[2].value).toBe('0 0 * * 1'); + expect(options[3].value).toBe('0 0 1 * *'); + }); +}); diff --git a/config-ui/src/config/entities.test.ts b/config-ui/src/config/entities.test.ts new file mode 100644 index 00000000000..43f37ad2337 --- /dev/null +++ b/config-ui/src/config/entities.test.ts @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { describe, it, expect } from 'vitest'; + +import { EntitiesLabel, transformEntities } from './entities'; + +describe('EntitiesLabel', () => { + it('maps all known domain entity types', () => { + expect(EntitiesLabel.CODE).toBe('Source Code Management'); + expect(EntitiesLabel.TICKET).toBe('Issue Tracking'); + expect(EntitiesLabel.CODEREVIEW).toBe('Code Review'); + expect(EntitiesLabel.CICD).toBe('CI/CD'); + expect(EntitiesLabel.CROSS).toBe('Cross Domain'); + expect(EntitiesLabel.CLAUDE_CODE).toBe('Claude Code'); + expect(EntitiesLabel.CODEQUALITY).toBe('Code Quality Domain'); + }); + + it('contains exactly 7 entity types', () => { + expect(Object.keys(EntitiesLabel)).toHaveLength(7); + }); +}); + +describe('transformEntities', () => { + it('transforms entity keys to label-value pairs', () => { + const result = transformEntities(['CODE', 'TICKET']); + expect(result).toEqual([ + { label: 'Source Code Management', value: 'CODE' }, + { label: 'Issue Tracking', value: 'TICKET' }, + ]); + }); + + it('returns undefined label for unknown entity', () => { + const result = transformEntities(['UNKNOWN']); + expect(result).toEqual([{ label: undefined, value: 'UNKNOWN' }]); + }); + + it('handles empty array', () => { + expect(transformEntities([])).toEqual([]); + }); + + it('preserves order', () => { + const result = transformEntities(['CICD', 'CODE', 'CROSS']); + expect(result.map((r) => r.value)).toEqual(['CICD', 'CODE', 'CROSS']); + }); +}); diff --git a/config-ui/src/plugins/components/connection-form/fields/index.tsx b/config-ui/src/plugins/components/connection-form/fields/index.tsx index cb55f5751d8..c894d670fc4 100644 --- a/config-ui/src/plugins/components/connection-form/fields/index.tsx +++ b/config-ui/src/plugins/components/connection-form/fields/index.tsx @@ -29,6 +29,7 @@ import { ConnectionSecretKey } from './secret-key'; interface Props { type: 'create' | 'update'; name: string; + connectionId?: ID; fields: any[]; initialValues: any; values: any; @@ -37,7 +38,7 @@ interface Props { setErrors: (errors: any) => void; } -export const Form = ({ type, name, fields, initialValues, values, errors, setValues, setErrors }: Props) => { +export const Form = ({ type, name, connectionId, fields, initialValues, values, errors, setValues, setErrors }: Props) => { const onValues = (values: any) => setValues((prev: any) => ({ ...prev, ...values })); const onErrors = (values: any) => setErrors((prev: any) => ({ ...prev, ...values })); @@ -57,6 +58,7 @@ export const Form = ({ type, name, fields, initialValues, values, errors, setVal if (typeof field === 'function') { return field({ type, + connectionId, initialValues, values, setValues: onValues, diff --git a/config-ui/src/plugins/components/connection-form/index.tsx b/config-ui/src/plugins/components/connection-form/index.tsx index 80ebab38415..f87b16d6243 100644 --- a/config-ui/src/plugins/components/connection-form/index.tsx +++ b/config-ui/src/plugins/components/connection-form/index.tsx @@ -63,7 +63,17 @@ const buildUpdateTestPayload = (connection: any, values: any, customHeaders: ICu companyId: isEqual(connection?.companyId, values.companyId) ? undefined : values.companyId, organization: isEqual(connection?.organization, values.organization) ? undefined : values.organization, enterprise: isEqual(connection?.enterprise, values.enterprise) ? undefined : values.enterprise, + service: isEqual(connection?.service, values.service) ? undefined : values.service, customHeaders: isEqual(connection?.customHeaders, customHeaders) ? undefined : customHeaders, + ciTool: isEqual(connection?.ciTool, values.ciTool) ? undefined : values.ciTool, + quayOrganization: isEqual(connection?.quayOrganization, values.quayOrganization) ? undefined : values.quayOrganization, + githubOrganization: isEqual(connection?.githubOrganization, values.githubOrganization) ? undefined : values.githubOrganization, + githubToken: isEqual(connection?.githubToken, values.githubToken) ? undefined : values.githubToken, + project: isEqual(connection?.project, values.project) ? undefined : values.project, + junitRegex: isEqual(connection?.junitRegex, values.junitRegex) ? undefined : values.junitRegex, + gitlabConnectionId: isEqual(connection?.gitlabConnectionId, values.gitlabConnectionId) + ? undefined + : values.gitlabConnectionId, }); const buildCreateTestPayload = (initialValues: any, values: any, customHeaders: ICustomHeader[] | undefined) => @@ -93,7 +103,15 @@ const buildCreateTestPayload = (initialValues: any, values: any, customHeaders: 'companyId', 'organization', 'enterprise', + 'service', 'customHeaders', + 'ciTool', + 'quayOrganization', + 'githubOrganization', + 'githubToken', + 'project', + 'junitRegex', + 'gitlabConnectionId', ]); interface Props { @@ -186,6 +204,7 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => {
)} + {plugin === 'aisdlc' && ( + + )} + {plugin === 'tapd' && scopeId && ( + + diff --git a/config-ui/src/plugins/register/agentready/config.tsx b/config-ui/src/plugins/register/agentready/config.tsx new file mode 100644 index 00000000000..d27e25fb535 --- /dev/null +++ b/config-ui/src/plugins/register/agentready/config.tsx @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg?react'; +import { GitHubConnectionSelect, SubmissionsRepo, SubmissionsPath, BranchInput } from './connection-fields'; + +export const AgentReadyConfig: IPluginConfig = { + plugin: 'agentready', + name: 'AgentReady', + icon: ({ color }) => , + sort: 16, + connection: { + docLink: 'https://github.com/konflux-ci/devlake/tree/main/backend/plugins/agentready#connection-model', + fields: [ + 'name', + ({ initialValues, values, errors, setValues, setErrors }: any) => ( + setValues({ githubConnectionId: value })} + setError={(error: string) => setErrors({ githubConnectionId: error })} + /> + ), + ({ initialValues, values, errors, setValues, setErrors }: any) => ( + setValues({ submissionsRepo: value })} + setError={(error: string) => setErrors({ submissionsRepo: error })} + /> + ), + ({ initialValues, values, errors, setValues, setErrors }: any) => ( + setValues({ submissionsPath: value })} + setError={(error: string) => setErrors({ submissionsPath: error })} + /> + ), + ({ initialValues, values, errors, setValues, setErrors }: any) => ( + setValues({ branch: value })} + setError={(error: string) => setErrors({ branch: error })} + /> + ), + ], + initialValues: {}, + }, + dataScope: { + title: 'Repositories', + }, + scopeConfig: { + entities: ['CODE'], + transformation: {}, + }, +}; diff --git a/config-ui/src/plugins/register/agentready/connection-fields/branch.tsx b/config-ui/src/plugins/register/agentready/connection-fields/branch.tsx new file mode 100644 index 00000000000..b9545753f3a --- /dev/null +++ b/config-ui/src/plugins/register/agentready/connection-fields/branch.tsx @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect } from 'react'; +import { Input } from 'antd'; + +import { Block } from '@/components'; + +interface Props { + initialValue: string; + value: string; + error: string; + setValue: (value: string) => void; + setError: (error: string) => void; +} + +export const BranchInput = ({ initialValue, value, setValue }: Props) => { + useEffect(() => { + if (initialValue && !value) { + setValue(initialValue); + } + }, [initialValue]); + + return ( + + setValue(e.target.value)} /> + + ); +}; diff --git a/config-ui/src/plugins/register/agentready/connection-fields/github-connection-select.tsx b/config-ui/src/plugins/register/agentready/connection-fields/github-connection-select.tsx new file mode 100644 index 00000000000..a5e0bf7b853 --- /dev/null +++ b/config-ui/src/plugins/register/agentready/connection-fields/github-connection-select.tsx @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useState } from 'react'; +import { Select } from 'antd'; + +import API from '@/api'; +import { Block } from '@/components'; + +interface Props { + initialValue: number; + value: number; + error: string; + setValue: (value: number) => void; + setError: (error: string) => void; +} + +export const GitHubConnectionSelect = ({ initialValue, value, error, setValue, setError }: Props) => { + const [connections, setConnections] = useState>([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + API.connection + .list('github') + .then((conns) => setConnections(conns.map((c: any) => ({ id: c.id, name: c.name })))) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + if (initialValue && !value) { + setValue(initialValue); + } + }, [initialValue]); + + useEffect(() => { + if (value === 0 || value === undefined) { + setError('A GitHub connection is required'); + } else { + setError(''); + } + }, [value]); + + return ( + + setValue(e.target.value)} + /> + + ); +}; diff --git a/config-ui/src/plugins/register/agentready/connection-fields/submissions-repo.tsx b/config-ui/src/plugins/register/agentready/connection-fields/submissions-repo.tsx new file mode 100644 index 00000000000..3648e21b537 --- /dev/null +++ b/config-ui/src/plugins/register/agentready/connection-fields/submissions-repo.tsx @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect } from 'react'; +import { Input } from 'antd'; + +import { Block } from '@/components'; + +interface Props { + initialValue: string; + value: string; + error: string; + setValue: (value: string) => void; + setError: (error: string) => void; +} + +export const SubmissionsRepo = ({ initialValue, value, error, setValue, setError }: Props) => { + useEffect(() => { + if (initialValue && !value) { + setValue(initialValue); + } + }, [initialValue]); + + useEffect(() => { + if (!value || value.trim() === '') { + setError('Submissions repository is required'); + } else if (!value.includes('/')) { + setError('Must be in owner/repo format'); + } else { + setError(''); + } + }, [value]); + + return ( + + setValue(e.target.value)} /> + + ); +}; diff --git a/config-ui/src/plugins/register/agentready/index.ts b/config-ui/src/plugins/register/agentready/index.ts new file mode 100644 index 00000000000..de415db39ab --- /dev/null +++ b/config-ui/src/plugins/register/agentready/index.ts @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './config'; diff --git a/config-ui/src/plugins/register/aisdlc/assets/icon.svg b/config-ui/src/plugins/register/aisdlc/assets/icon.svg new file mode 100644 index 00000000000..1d9b639bfdd --- /dev/null +++ b/config-ui/src/plugins/register/aisdlc/assets/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/config-ui/src/plugins/register/aisdlc/config.tsx b/config-ui/src/plugins/register/aisdlc/config.tsx new file mode 100644 index 00000000000..ca2deb0cace --- /dev/null +++ b/config-ui/src/plugins/register/aisdlc/config.tsx @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg?react'; +import { GitLabConnectionSelect } from './connection-fields/gitlab-connection-select'; + +export const AisdlcConfig: IPluginConfig = { + plugin: 'aisdlc', + name: 'AISDLC', + icon: ({ color }) => , + sort: 19, + connection: { + docLink: '', + fields: [ + 'name', + ({ initialValues, values, errors, setValues, setErrors }: any) => ( + setValues({ gitlabConnectionId: value })} + setError={(error: string) => setErrors({ gitlabConnectionId: error })} + /> + ), + ], + initialValues: {}, + }, + dataScope: { + title: 'Repositories', + }, + scopeConfig: { + entities: ['CODE'], + transformation: { + tenantId: '', + sourceId: '', + stationId: '', + displayName: '', + parser: '', + branch: '', + pathPrefix: '', + }, + }, +}; diff --git a/config-ui/src/plugins/register/aisdlc/connection-fields/gitlab-connection-select.tsx b/config-ui/src/plugins/register/aisdlc/connection-fields/gitlab-connection-select.tsx new file mode 100644 index 00000000000..b098bccb064 --- /dev/null +++ b/config-ui/src/plugins/register/aisdlc/connection-fields/gitlab-connection-select.tsx @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useState } from 'react'; +import { Select } from 'antd'; + +import API from '@/api'; +import { Block } from '@/components'; + +interface Props { + initialValue: number; + value: number; + error: string; + setValue: (value: number) => void; + setError: (error: string) => void; +} + +export const GitLabConnectionSelect = ({ initialValue, value, error, setValue, setError }: Props) => { + const [connections, setConnections] = useState>([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + API.connection + .list('gitlab') + .then((conns) => setConnections(conns.map((c: any) => ({ id: c.id, name: c.name })))) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + if (initialValue && !value) { + setValue(initialValue); + } + }, [initialValue]); + + useEffect(() => { + if (value === 0 || value === undefined) { + setError('A GitLab connection is required'); + } else { + setError(''); + } + }, [value]); + + return ( + + setField('tenantId', e.target.value)} /> + + + setField('sourceId', e.target.value)} /> + + + setField('stationId', e.target.value)} /> + + + setField('displayName', e.target.value)} + /> + + + setField('branch', e.target.value)} /> + + + setField('pathPrefix', e.target.value)} + /> + + + ), + }, + ]} + /> + ); +}; diff --git a/config-ui/src/plugins/register/codecov/assets/icon.svg b/config-ui/src/plugins/register/codecov/assets/icon.svg new file mode 100644 index 00000000000..28b8e1cd8c2 --- /dev/null +++ b/config-ui/src/plugins/register/codecov/assets/icon.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/config-ui/src/plugins/register/codecov/config.tsx b/config-ui/src/plugins/register/codecov/config.tsx new file mode 100644 index 00000000000..0b406979ae6 --- /dev/null +++ b/config-ui/src/plugins/register/codecov/config.tsx @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg?react'; +import { Organization, Service, Token } from './connection-fields'; + +export const CodecovConfig: IPluginConfig = { + plugin: 'codecov', + name: 'Codecov', + icon: ({ color }) => , + sort: 15, + connection: { + docLink: 'https://docs.codecov.com', + initialValues: { + endpoint: 'https://api.codecov.io', + service: 'github', + }, + fields: [ + 'name', + { + key: 'endpoint', + subLabel: 'The Codecov API endpoint URL', + defaultValue: 'https://api.codecov.io', + }, + ({ type, initialValues, values, errors, setValues, setErrors }: any) => ( + + ), + ({ type, initialValues, values, errors, setValues, setErrors }: any) => ( + + ), + ({ type, initialValues, values, errors, setValues, setErrors }: any) => ( + + ), + 'proxy', + { + key: 'rateLimitPerHour', + subLabel: + 'By default, DevLake uses dynamic rate limit for optimized data collection for Codecov. But you can adjust the collection speed by entering a fixed value.', + defaultValue: 1000, + }, + ], + }, + dataScope: { + title: 'Repositories', + }, +}; + diff --git a/config-ui/src/plugins/register/codecov/connection-fields/index.ts b/config-ui/src/plugins/register/codecov/connection-fields/index.ts new file mode 100644 index 00000000000..365fa671819 --- /dev/null +++ b/config-ui/src/plugins/register/codecov/connection-fields/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './organization'; +export * from './service'; +export * from './token'; + diff --git a/config-ui/src/plugins/register/codecov/connection-fields/organization.tsx b/config-ui/src/plugins/register/codecov/connection-fields/organization.tsx new file mode 100644 index 00000000000..45854b2729d --- /dev/null +++ b/config-ui/src/plugins/register/codecov/connection-fields/organization.tsx @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { Input } from 'antd'; + +import { Block, ExternalLink } from '@/components'; +import { useEffect } from 'react'; + +interface Props { + type: 'create' | 'update'; + initialValues: any; + values: any; + errors: any; + setValues: (value: any) => void; + setErrors: (value: any) => void; +} + +export const Organization = ({ type, initialValues, values, setValues, setErrors }: Props) => { + useEffect(() => { + setValues({ organization: initialValues.organization }); + }, [initialValues.organization]); + + useEffect(() => { + setErrors({ + organization: values.organization ? '' : 'organization is required', + }); + }, [values.organization]); + + const handleChange = (e: React.ChangeEvent) => { + setValues({ + organization: e.target.value, + }); + }; + + return ( + + Enter the Codecov organization owner name. This is the organization, group, or username on your code hosting + service (GitHub org, GitLab group, etc.) as registered in Codecov.{' '} + Learn more about Codecov + + } + required + > + + + ); +}; + diff --git a/config-ui/src/plugins/register/codecov/connection-fields/service.tsx b/config-ui/src/plugins/register/codecov/connection-fields/service.tsx new file mode 100644 index 00000000000..43683edd768 --- /dev/null +++ b/config-ui/src/plugins/register/codecov/connection-fields/service.tsx @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { Select } from 'antd'; +import { useEffect } from 'react'; + +import { Block } from '@/components'; + +interface Props { + type: 'create' | 'update'; + initialValues: any; + values: any; + errors: any; + setValues: (value: any) => void; + setErrors: (value: any) => void; +} + +const serviceOptions = [ + { value: 'github', label: 'GitHub' }, + { value: 'gitlab', label: 'GitLab' }, + { value: 'github_enterprise', label: 'GitHub Enterprise' }, + { value: 'gitlab_enterprise', label: 'GitLab Enterprise' }, + { value: 'bitbucket', label: 'Bitbucket' }, + { value: 'bitbucket_server', label: 'Bitbucket Server' }, +]; + +export const Service = ({ initialValues, values, setValues }: Props) => { + useEffect(() => { + setValues({ service: initialValues.service || 'github' }); + }, [initialValues.service]); + + const handleChange = (value: string) => { + setValues({ service: value }); + }; + + return ( + + ({ label: it.name, value: it.id }))} + optionFilterProp="children" + filterOption={(input: string, option?: { label: string; value: string }) => + (option?.label ?? '').toLowerCase().includes(input.toLowerCase()) + } + value={transformation.epicKeyField} + onChange={(value) => + onChangeTransformation({ + ...transformation, + epicKeyField: value, + }) + } + /> + ), }, diff --git a/config-ui/src/plugins/register/testregistry/assets/icon.svg b/config-ui/src/plugins/register/testregistry/assets/icon.svg new file mode 100644 index 00000000000..f197ae30cc7 --- /dev/null +++ b/config-ui/src/plugins/register/testregistry/assets/icon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/config-ui/src/plugins/register/testregistry/config.tsx b/config-ui/src/plugins/register/testregistry/config.tsx new file mode 100644 index 00000000000..ef136ee75ef --- /dev/null +++ b/config-ui/src/plugins/register/testregistry/config.tsx @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg?react'; +import { + QuayOrganization, + ProjectSelect, + CIToolSelect, + GitHubOrganization, + GitHubToken, + JUnitRegex, +} from './connection-fields'; + +export const TestRegistryConfig: IPluginConfig = { + plugin: 'testregistry', + name: 'Test Registry', + icon: ({ color }) => , + sort: 17, // Between Testmo (17) and ZenTao (18) + connection: { + docLink: '', + fields: [ + 'name', + ({ type, initialValues, values, errors, setValues, setErrors }: any) => ( + setValues({ project: value })} + setError={(error: string) => setErrors({ project: error })} + /> + ), + ({ type, initialValues, values, errors, setValues, setErrors }: any) => ( + { + // Update CI tool and clear conditional fields and their errors synchronously + setValues({ ciTool: value }); + + // Clear conditional fields and their errors based on selected CI tool + if (value === 'Openshift CI') { + setValues({ quayOrganization: '' }); + // Clear Quay errors, keep junitRegex as it's common to both + setErrors({ quayOrganization: '', junitRegex: '' }); + } else if (value === 'Tekton CI') { + setValues({ githubOrganization: '', githubToken: '' }); + // Clear GitHub errors, keep junitRegex as it's common to both + setErrors({ githubOrganization: '', githubToken: '', junitRegex: '' }); + } else { + // No CI tool selected - clear all conditional field errors + setErrors({ quayOrganization: '', githubOrganization: '', githubToken: '', junitRegex: '' }); + } + }} + setError={(error: string) => setErrors({ ciTool: error })} + /> + ), + // Conditional fields based on CI tool selection + ({ type, initialValues, values, errors, setValues, setErrors }: any) => { + const ciTool = values?.ciTool ?? initialValues?.ciTool ?? ''; + + if (ciTool === 'Openshift CI') { + return ( + <> + setValues({ githubOrganization: value })} + setError={(error: string) => setErrors({ githubOrganization: error })} + /> + setValues({ githubToken: value })} + setError={(error: string) => setErrors({ githubToken: error })} + /> + setValues({ junitRegex: value })} + setError={(error: string) => setErrors({ junitRegex: error })} + /> + + ); + } + + if (ciTool === 'Tekton CI') { + return ( + <> + setValues({ quayOrganization: value })} + setError={(error: string) => setErrors({ quayOrganization: error })} + /> + setValues({ junitRegex: value })} + setError={(error: string) => setErrors({ junitRegex: error })} + /> + + ); + } + + return null; + }, + ], + initialValues: {}, + }, + dataScope: { + title: 'Scopes', + }, + scopeConfig: { + entities: ['CICD'], + transformation: {}, + }, +}; diff --git a/config-ui/src/plugins/register/testregistry/connection-fields/ci-tool-select.tsx b/config-ui/src/plugins/register/testregistry/connection-fields/ci-tool-select.tsx new file mode 100644 index 00000000000..876d370251f --- /dev/null +++ b/config-ui/src/plugins/register/testregistry/connection-fields/ci-tool-select.tsx @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect } from 'react'; +import { Select } from 'antd'; + +import { Block } from '@/components'; + +interface Props { + initialValue: string; + value: string; + error: string; + setValue: (value: string) => void; + setError: (error: string) => void; +} + +const CI_TOOL_OPTIONS = [ + { + label: 'Openshift CI', + value: 'Openshift CI', + description: 'Pulls test metadata from GCS (Google Cloud Storage) based on GitHub organization scopes' + }, + { + label: 'Tekton CI', + value: 'Tekton CI', + description: 'Pulls data from OCI artifacts in Quay.io based on the organization you define' + }, +]; + +export const CIToolSelect = ({ initialValue, value, setValue, setError }: Props) => { + // Initialize with initialValue when editing + useEffect(() => { + if (initialValue && !value) { + setValue(initialValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValue]); + + // Validate on value change + useEffect(() => { + if (!value || value.trim() === '') { + setError('CI tool is required'); + } else { + setError(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + const handleChange = (selectedValue: string) => { + setValue(selectedValue); + }; + + // Get description for selected CI tool + const selectedOption = CI_TOOL_OPTIONS.find(opt => opt.value === value); + const description = selectedOption + ? selectedOption.description + : 'Select the CI tool type for this connection'; + + return ( + + + + ); +}; + diff --git a/config-ui/src/plugins/register/testregistry/connection-fields/github-token.tsx b/config-ui/src/plugins/register/testregistry/connection-fields/github-token.tsx new file mode 100644 index 00000000000..c1daecc6d53 --- /dev/null +++ b/config-ui/src/plugins/register/testregistry/connection-fields/github-token.tsx @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect } from 'react'; +import { Input } from 'antd'; + +import { Block } from '@/components'; + +interface Props { + initialValue: string; + value: string; + error: string; + setValue: (value: string) => void; + setError: (error: string) => void; +} + +export const GitHubToken = ({ initialValue, value, error, setValue, setError }: Props) => { + // Initialize with initialValue when editing + // Note: Token might be sanitized (showing ***), so we check if it's different + useEffect(() => { + if (initialValue && (!value || value === initialValue)) { + // Only set if value is empty or same as initial (not user-modified) + setValue(initialValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValue]); + + // Validate on value change (only if value exists or was changed by user) + useEffect(() => { + // Skip validation on initial mount if value is empty (allows form to start without errors) + if (value === '' && !initialValue) { + return; + } + + if (!value || value.trim() === '') { + setError('GitHub token is required'); + } else { + setError(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + setValue(e.target.value); + }; + + return ( + + + + ); +}; + diff --git a/config-ui/src/plugins/register/testregistry/connection-fields/index.ts b/config-ui/src/plugins/register/testregistry/connection-fields/index.ts new file mode 100644 index 00000000000..a8f1069932d --- /dev/null +++ b/config-ui/src/plugins/register/testregistry/connection-fields/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export { QuayOrganization } from './quay-organization'; +export { ProjectSelect } from './project-select'; +export { CIToolSelect } from './ci-tool-select'; +export { GitHubOrganization } from './github-organization'; +export { GitHubToken } from './github-token'; +export { JUnitRegex } from './junit-regex'; + diff --git a/config-ui/src/plugins/register/testregistry/connection-fields/junit-regex.tsx b/config-ui/src/plugins/register/testregistry/connection-fields/junit-regex.tsx new file mode 100644 index 00000000000..0a017ab9e33 --- /dev/null +++ b/config-ui/src/plugins/register/testregistry/connection-fields/junit-regex.tsx @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useState } from 'react'; +import { Input, Typography } from 'antd'; + +import { Block } from '@/components'; + +const { Text } = Typography; + +interface Props { + initialValue: string; + value: string; + error: string; + setValue: (value: string) => void; + setError: (error: string) => void; +} + +// Default JUnit regex pattern - matches files starting with "devlake-", "e2e", or "qd-report-" +const DEFAULT_JUNIT_REGEX = '(devlake-|e2e|qd-report-)[0-9a-z-]+\\.(xml|junit)'; + +// Validate if a string is a valid regex +const isValidRegex = (pattern: string): boolean => { + try { + new RegExp(pattern); + return true; + } catch (e) { + return false; + } +}; + +export const JUnitRegex = ({ initialValue, value, error, setValue, setError }: Props) => { + const [touched, setTouched] = useState(false); + + // Initialize with initialValue when editing + useEffect(() => { + if (initialValue && !value) { + setValue(initialValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValue]); + + // Validate on value change (only if field has been touched) + useEffect(() => { + // Only validate if user has interacted with the field + if (!touched) { + return; + } + + // Empty is valid (will use default) + if (!value || value.trim() === '') { + setError(''); + return; + } + + // Validate regex syntax + if (!isValidRegex(value.trim())) { + setError('Invalid regex pattern. Please check the syntax.'); + } else { + setError(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, touched]); + + const handleChange = (e: React.ChangeEvent) => { + setTouched(true); + setValue(e.target.value); + }; + + const handleBlur = () => { + setTouched(true); + }; + + return ( + + Regex pattern to match JUnit XML file names in CI artifacts. Leave empty to use the default pattern. +
+ + Default: {DEFAULT_JUNIT_REGEX} + +
+ + Example pattern: [0-9a-z_-]*junit[0-9a-z_-]*\.xml matches{' '} + junit_operator.xml, junit_install_status.xml + + + } + error={error} + > + +
+ ); +}; + + diff --git a/config-ui/src/plugins/register/testregistry/connection-fields/project-select.tsx b/config-ui/src/plugins/register/testregistry/connection-fields/project-select.tsx new file mode 100644 index 00000000000..ca55668a8c8 --- /dev/null +++ b/config-ui/src/plugins/register/testregistry/connection-fields/project-select.tsx @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useState } from 'react'; +import { Select, Button } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; + +import API from '@/api'; +import { Block } from '@/components'; + +interface Props { + initialValue: string; + value: string; + error: string; + setValue: (value: string) => void; + setError: (error: string) => void; +} + +interface IProject { + name: string; + description?: string; +} + +export const ProjectSelect = ({ initialValue, value, setValue, setError }: Props) => { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + + // Initialize with initialValue (like DBUrl component does) + // Only sync if value is empty or different from initialValue + useEffect(() => { + if (initialValue && (!value || value !== initialValue)) { + setValue(initialValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValue]); + + // Fetch DevLake projects + const fetchProjects = async () => { + setLoading(true); + try { + const response = await API.project.list({ pageSize: 1000, page: 1 }); + setProjects(response.projects || []); + } catch { + setProjects([]); + } finally { + setLoading(false); + } + }; + + // Fetch projects on mount + useEffect(() => { + fetchProjects(); + }, []); + + // Validate selection (only if value exists or was changed by user) + useEffect(() => { + // Skip validation on initial mount if value is empty (allows form to start without errors) + if (value === '' && !initialValue) { + return; + } + + if (!value) { + setError('Project is required'); + } else { + setError(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + const handleChange = (selectedValue: string) => { + setValue(selectedValue); + }; + + const options = projects.map((project: IProject) => ({ + label: project.name, + value: project.name, + })); + + return ( + + + + ); +}; + diff --git a/config-ui/src/plugins/register/testregistry/index.ts b/config-ui/src/plugins/register/testregistry/index.ts new file mode 100644 index 00000000000..de415db39ab --- /dev/null +++ b/config-ui/src/plugins/register/testregistry/index.ts @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './config'; diff --git a/config-ui/src/plugins/utils.ts b/config-ui/src/plugins/utils.ts index 4344d33cdce..24028b705a1 100644 --- a/config-ui/src/plugins/utils.ts +++ b/config-ui/src/plugins/utils.ts @@ -43,6 +43,8 @@ export const getPluginScopeId = (plugin: string, scope: any) => { return `${scope.planKey}`; case 'argocd': return `${scope.name}`; + case 'codecov': + return `${scope.codecovId}`; case 'asana': return `${scope.gid}`; case 'linear': diff --git a/config-ui/src/routes/blueprint/utils.test.ts b/config-ui/src/routes/blueprint/utils.test.ts new file mode 100644 index 00000000000..539dd9f17c7 --- /dev/null +++ b/config-ui/src/routes/blueprint/utils.test.ts @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { describe, it, expect } from 'vitest'; + +import { validRawPlan } from './utils'; + +describe('validRawPlan', () => { + it('returns true for invalid JSON', () => { + expect(validRawPlan('not json')).toBe(true); + expect(validRawPlan('{broken')).toBe(true); + }); + + it('returns true for empty nested arrays', () => { + expect(validRawPlan('[[]]')).toBe(true); + expect(validRawPlan('[]')).toBe(true); + }); + + it('returns false when plan contains tasks', () => { + expect(validRawPlan('[["task1"]]')).toBe(false); + expect(validRawPlan('[[{"plugin":"github"}]]')).toBe(false); + }); + + it('returns false for a flat array with items', () => { + expect(validRawPlan('["item"]')).toBe(false); + }); +}); diff --git a/config-ui/src/routes/pipeline/components/task.tsx b/config-ui/src/routes/pipeline/components/task.tsx index 73cfe532cff..2d0a236008e 100644 --- a/config-ui/src/routes/pipeline/components/task.tsx +++ b/config-ui/src/routes/pipeline/components/task.tsx @@ -92,6 +92,9 @@ export const PipelineTask = ({ task }: Props) => { case ['argocd'].includes(config.plugin): name = `${name}:${options.ApplicationName}`; break; + case ['codecov'].includes(config.plugin): + name = `${name}:${options.fullName}`; + break; } return [config.icon, name]; diff --git a/config-ui/src/routes/project/detail/aireview-scope-config-modal.tsx b/config-ui/src/routes/project/detail/aireview-scope-config-modal.tsx new file mode 100644 index 00000000000..ffa4bcbcf92 --- /dev/null +++ b/config-ui/src/routes/project/detail/aireview-scope-config-modal.tsx @@ -0,0 +1,338 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useState } from 'react'; +import { Checkbox, Collapse, Divider, Form, Input, InputNumber, message, Modal, Select, Space, Spin, Typography } from 'antd'; + +import API from '@/api'; +import { IAiReviewScopeConfig } from '@/api/plugin/aireview'; + +const { Text } = Typography; + +interface Props { + scopeConfigId?: number; + onCancel: () => void; + onSave: (id: number) => void; +} + +interface ToolSectionProps { + label: string; + enabledField: string; + usernameField: string; + patternField: string; + enabled: boolean; +} + +const ToolSection = ({ label, enabledField, usernameField, patternField, enabled }: ToolSectionProps) => ( + + + {`Enable ${label}`} + + {enabled && ( + <> + + + + + + + + )} + +); + +export const AiReviewScopeConfigModal = ({ scopeConfigId, onCancel, onSave }: Props) => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + // Track enabled toggles for conditional field display + const codeRabbitEnabled = Form.useWatch('codeRabbitEnabled', form); + const cursorBugbotEnabled = Form.useWatch('cursorBugbotEnabled', form); + const qodoEnabled = Form.useWatch('qodoEnabled', form); + const geminiEnabled = Form.useWatch('geminiEnabled', form); + + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const config = scopeConfigId + ? await API.plugin.aireview.getScopeConfig(scopeConfigId) + : await API.plugin.aireview.getDefaultScopeConfig(); + if (config) { + form.setFieldsValue(config); + } + } catch { + message.error('Failed to load configuration.'); + } finally { + setLoading(false); + } + }; + load(); + }, [scopeConfigId, form]); + + const handleSave = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + const saved = scopeConfigId + ? await API.plugin.aireview.updateScopeConfig(scopeConfigId, values) + : await API.plugin.aireview.createScopeConfig(values); + if (saved.id == null) { + message.error('Saved configuration is missing an ID.'); + return; + } + onSave(saved.id); + } catch { + message.error('Failed to save configuration. Please try again.'); + } finally { + setSaving(false); + } + }; + + return ( + + +
+ {/* ── Prediction Thresholds ── */} + + Prediction Thresholds + + + + + + + + + + + + + + {/* ── CI Failure Filtering ── */} + + CI Failure Filtering + + + Filter out noisy CI failures to improve prediction accuracy. Changes take effect on the next blueprint run. + + + + Exclude flaky tests — ignore tests/jobs that also fail on periodic or push runs (30-day lookback window) + + + + + Exclude infrastructure failures — ignore job failures where no test cases actually failed (e.g. cluster + provisioning, quota, timeout). Requires test case data to be collected (CI source "Test Cases" or + "Both"). + + + + {/* ── CI Backfill ── */} + + CI Data Backfill (Openshift CI / GCS) + + + Fetch historical CI job results from the public Openshift CI GCS bucket for PRs that have AI reviews but no + CI data yet. Requires network access to GCS. Set to 0 to disable backfill. + + + + + + {/* ── Tool Detection ── */} + + AI Tool Detection + + + Configure which AI review bots to detect. Each tool is identified by its bot username and a regex pattern + matched against comment bodies. + + + ), + }, + { + key: 'qodo', + label: 'Qodo (formerly Codium)', + children: ( + + ), + }, + { + key: 'gemini', + label: 'Gemini Code Assist', + children: ( + + ), + }, + { + key: 'cursor', + label: 'Cursor Bugbot', + children: ( + + ), + }, + ]} + /> + + {/* ── Advanced Settings - Matching Patterns ── */} + + + Regex patterns for risk level classification and AI detection. + + + + + + + + + + + + + + + + + + + + + ), + }, + ]} + /> + +
+
+ ); +}; diff --git a/config-ui/src/routes/project/detail/settings-panel.tsx b/config-ui/src/routes/project/detail/settings-panel.tsx index 7b62c620efb..a5d97675fa4 100644 --- a/config-ui/src/routes/project/detail/settings-panel.tsx +++ b/config-ui/src/routes/project/detail/settings-panel.tsx @@ -27,6 +27,7 @@ import { PATHS } from '@/config'; import { IProject } from '@/types'; import { operator } from '@/utils'; +import { AiReviewScopeConfigModal } from './aireview-scope-config-modal'; import * as S from './styled'; const RegexPrIssueDefaultValue = '(?mi)(Closes)[\\s]*.*(((and )?#\\d+[ ]*)+)'; @@ -68,6 +69,11 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => { enable: false, subProjects: [emptySubProject], }); + const [aiReview, setAiReview] = useState({ + enable: false, + scopeConfigId: undefined as number | undefined, + }); + const [aiReviewModalOpen, setAiReviewModalOpen] = useState(false); const [operating, setOperating] = useState(false); const [open, setOpen] = useState(false); @@ -78,6 +84,7 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => { const linker = project.metrics.find((ms) => ms.pluginName === 'linker'); const issueTrace = project.metrics.find((ms) => ms.pluginName === 'issue_trace'); const monorepo = project.metrics.find((ms) => ms.pluginName === 'monorepo'); + const aireview = project.metrics.find((ms) => ms.pluginName === 'aireview'); setName(project.name); setDora({ @@ -103,6 +110,10 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => { })) : [emptySubProject], }); + setAiReview({ + enable: aireview?.enable ?? false, + scopeConfigId: aireview?.pluginOption?.scopeConfigId, + }); }, [project]); const handleAddSubProject = () => { @@ -167,6 +178,13 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => { }, enable: monorepo.enable, }, + { + pluginName: 'aireview', + pluginOption: { + scopeConfigId: aiReview.scopeConfigId, + }, + enable: aiReview.enable, + }, ], }), { @@ -346,6 +364,23 @@ export const SettingsPanel = ({ project, onRefresh }: Props) => { )}
+ setAiReview({ ...aiReview, enable: e.target.checked })} + > + Enable AI Review Analysis + + } + description="Extract AI code review data (CodeRabbit, Qodo, Gemini, etc.) from pull request comments and calculate failure prediction metrics against CI test outcomes." + > + {aiReview.enable && ( + + )} + + {aiReviewModalOpen && ( + setAiReviewModalOpen(false)} + onSave={(id) => { + setAiReview({ ...aiReview, scopeConfigId: id }); + setAiReviewModalOpen(false); + }} + /> + )} { + it('accepts alphanumeric names with hyphens and underscores', () => { + expect(validName('my-project')).toBe(true); + expect(validName('my_project')).toBe(true); + expect(validName('myProject123')).toBe(true); + }); + + it('accepts names with forward slashes', () => { + expect(validName('org/project')).toBe(true); + expect(validName('a/b/c')).toBe(true); + }); + + it('rejects empty string', () => { + expect(validName('')).toBe(false); + }); + + it('rejects names with spaces', () => { + expect(validName('my project')).toBe(false); + }); + + it('rejects names with special characters', () => { + expect(validName('my@project')).toBe(false); + expect(validName('hello!')).toBe(false); + expect(validName('a b')).toBe(false); + }); +}); + +describe('encodeName', () => { + it('encodes forward slashes', () => { + expect(encodeName('org/project')).toBe('org%2Fproject'); + }); + + it('encodes spaces', () => { + expect(encodeName('hello world')).toBe('hello%20world'); + }); + + it('passes through simple alphanumeric strings', () => { + expect(encodeName('simple')).toBe('simple'); + }); +}); diff --git a/config-ui/src/test-setup.ts b/config-ui/src/test-setup.ts new file mode 100644 index 00000000000..88ea97aeeb1 --- /dev/null +++ b/config-ui/src/test-setup.ts @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import '@testing-library/jest-dom/vitest'; diff --git a/config-ui/src/types/connection.ts b/config-ui/src/types/connection.ts index 3a678007a8e..1f4a128bb2b 100644 --- a/config-ui/src/types/connection.ts +++ b/config-ui/src/types/connection.ts @@ -41,7 +41,16 @@ export interface IConnectionAPI { rateLimitPerHour?: number; organization?: string; organizationId?: string; + enterprise?: string; + service?: string; customHeaders?: ICustomHeader[]; + ciTool?: string; + quayOrganization?: string; + githubOrganization?: string; + githubToken?: string; + project?: string; + junitRegex?: string; + gitlabConnectionId?: number; } export interface IConnectionTestResult { @@ -101,5 +110,14 @@ export interface IConnection { rateLimitPerHour?: number; organization?: string; organizationId?: string; + enterprise?: string; + service?: string; customHeaders?: ICustomHeader[]; + ciTool?: string; + quayOrganization?: string; + githubOrganization?: string; + githubToken?: string; + project?: string; + junitRegex?: string; + gitlabConnectionId?: number; } diff --git a/config-ui/src/utils/request.ts b/config-ui/src/utils/request.ts index e91739f7242..3badcc0d4b7 100644 --- a/config-ui/src/utils/request.ts +++ b/config-ui/src/utils/request.ts @@ -41,18 +41,29 @@ export type RequestConfig = { const isLoginRoute = () => window.location.pathname.replace(/\/+$/, '').endsWith('/login'); +// Plugin connection tests often return 401 when the *remote* credential is +// wrong. That is not a DevLake session expiry — do not wipe the form. +const isPluginConnectionTest = (url?: string) => + !!url && /\/plugins\/[^/]+(?:\/connections\/[^/]+)?\/test\/?$/.test(url); + let redirectingToLogin = false; instance.interceptors.response.use( (response) => response, (error) => { const status = error.response?.status; + const requestUrl = error.config?.url as string | undefined; if (status === 428) { window.location.replace('/db-migrate'); } - if (status === 401 && !isLoginRoute() && !redirectingToLogin) { + if ( + status === 401 && + !isLoginRoute() && + !redirectingToLogin && + !isPluginConnectionTest(requestUrl) + ) { redirectingToLogin = true; const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); window.location.replace(`/login?return_url=${returnUrl}`); diff --git a/config-ui/src/utils/time.test.ts b/config-ui/src/utils/time.test.ts new file mode 100644 index 00000000000..d0bdae43eaa --- /dev/null +++ b/config-ui/src/utils/time.test.ts @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { describe, it, expect } from 'vitest'; + +import { formatTime } from './time'; + +describe('formatTime', () => { + it('formats a date string with the default YYYY-MM-DD HH:mm pattern', () => { + const result = formatTime('2024-06-15T12:00:00Z'); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + }); + + it('formats with a custom format', () => { + const result = formatTime('2024-06-15T12:00:00Z', 'YYYY'); + expect(result).toBe('2024'); + }); + + it('formats a Date object', () => { + const result = formatTime(new Date('2024-01-01T00:00:00Z'), 'YYYY'); + expect(result).toBe('2024'); + }); + + it('returns dash for null', () => { + expect(formatTime(null)).toBe('-'); + }); + + it('returns dash for empty string', () => { + expect(formatTime('')).toBe('-'); + }); +}); diff --git a/docker-compose-custom-ca.yml b/docker-compose-custom-ca.yml new file mode 100644 index 00000000000..32443590942 --- /dev/null +++ b/docker-compose-custom-ca.yml @@ -0,0 +1,14 @@ +# Custom CA certificates override for internal services (e.g., CEE GitLab) +# +# Usage: +# 1. Set CA_CERT_FILE in .env to your CA bundle path +# 2. Run: podman compose -f docker-compose-dev.yml -f docker-compose-custom-ca.yml up -d +# +services: + devlake: + env_file: + - ./.env + volumes: + - ${CA_CERT_FILE}:/etc/ssl/certs/ca-certificates.crt:ro + environment: + SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt diff --git a/docker-compose-dev-mysql.yml b/docker-compose-dev-mysql.yml index 716252295a7..71119c3a370 100644 --- a/docker-compose-dev-mysql.yml +++ b/docker-compose-dev-mysql.yml @@ -32,6 +32,7 @@ services: TZ: UTC command: --character-set-server=utf8mb4 --collation-server=utf8mb4_bin --skip-log-bin + --sql-mode="ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION" grafana: image: ${DEVLAKE_IMAGE_REGISTRY:-devlake.docker.scarf.sh}/apache/devlake-dashboard:latest @@ -45,6 +46,9 @@ services: environment: GF_SERVER_ROOT_URL: "${GRAFANA_ROOT_URL:-http://localhost:4001/grafana}" GF_USERS_DEFAULT_THEME: "light" + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin" + GF_AUTH_DISABLE_LOGIN_FORM: "true" DATABASE_TYPE: "${GRAFANA_DATABASE_TYPE:-mysql}" DATABASE_HOST: "${GRAFANA_DATABASE_HOST:-mysql}" DATABASE_PORT: "${GRAFANA_DATABASE_PORT:-3306}" @@ -62,10 +66,10 @@ services: - mysql devlake: - image: devlake-local:latest + image: localhost/devlake-backend:local build: context: backend - dockerfile: Dockerfile.local + dockerfile: Containerfile args: HTTPS_PROXY: "${HTTPS_PROXY}" GOPROXY: "${GOPROXY}" @@ -87,9 +91,10 @@ services: - mysql config-ui: - image: ${DEVLAKE_IMAGE_REGISTRY:-devlake.docker.scarf.sh}/apache/devlake-config-ui:latest + image: localhost/devlake-frontend:local build: context: "config-ui" + dockerfile: Containerfile ports: - 4001:4000 env_file: diff --git a/docker-compose-dev-postgresql.yml b/docker-compose-dev-postgresql.yml index 4b54949bb7d..d94e720a6ff 100644 --- a/docker-compose-dev-postgresql.yml +++ b/docker-compose-dev-postgresql.yml @@ -41,6 +41,9 @@ services: environment: GF_SERVER_ROOT_URL: "${GRAFANA_ROOT_URL:-http://localhost:4000/grafana}" GF_USERS_DEFAULT_THEME: "light" + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin" + GF_AUTH_DISABLE_LOGIN_FORM: "true" DATABASE_TYPE: "${GRAFANA_DATABASE_TYPE:-postgresql}" DATABASE_HOST: "${GRAFANA_DATABASE_HOST:-postgres}" DATABASE_PORT: "${GRAFANA_DATABASE_PORT:-5432}" @@ -59,10 +62,10 @@ services: - postgres devlake: - image: devlake-local:latest + image: localhost/devlake-backend:local build: context: backend - dockerfile: Dockerfile.local + dockerfile: Containerfile args: HTTPS_PROXY: "${HTTPS_PROXY}" GOPROXY: "${GOPROXY}" @@ -84,9 +87,10 @@ services: - postgres config-ui: - image: ${DEVLAKE_IMAGE_REGISTRY:-devlake.docker.scarf.sh}/apache/devlake-config-ui:latest + image: localhost/devlake-frontend:local build: context: "config-ui" + dockerfile: Containerfile ports: - 4000:4000 env_file: diff --git a/docker-compose-e2e-coverage.yml b/docker-compose-e2e-coverage.yml new file mode 100644 index 00000000000..a9ff18f986f --- /dev/null +++ b/docker-compose-e2e-coverage.yml @@ -0,0 +1,46 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# E2E coverage overlay — use with: +# docker compose -f docker-compose-dev.yml -f docker-compose-e2e-coverage.yml up -d +# +# WARNING: Sets AUTH_ENABLED=false — do not use outside CI/test environments. +# +# Backend: built with -cover + coverport HTTP server on port 9095. +services: + mysql: + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-umerico", "-pmerico"] + interval: 5s + timeout: 3s + retries: 30 + + devlake: + build: + context: backend + args: + DEVLAKE_COVER: "1" + depends_on: + mysql: + condition: service_healthy + ports: + - "9095:9095" + environment: + COVERAGE_PORT: "9095" + DB_URL: mysql://merico:merico@mysql:3306/lake?charset=utf8mb4&parseTime=True&loc=UTC + FORCE_MIGRATION: "true" + AUTH_ENABLED: "false" diff --git a/docker-compose.datasources.yml b/docker-compose.datasources.yml index 6feff331fd1..080d48f148d 100644 --- a/docker-compose.datasources.yml +++ b/docker-compose.datasources.yml @@ -97,4 +97,4 @@ services: - ./.docker/bitbucket/lib/native:/var/atlassian/application-data/bitbucket/lib/native - ./.docker/bitbucket:/var/atlassian/application-data/bitbucket depends_on: - - mysql-ds \ No newline at end of file + - mysql-ds diff --git a/docs/jira-snowflake-local-testing.md b/docs/jira-snowflake-local-testing.md new file mode 100644 index 00000000000..49a1f7a3117 --- /dev/null +++ b/docs/jira-snowflake-local-testing.md @@ -0,0 +1,250 @@ +# jira_snowflake Plugin — Local Testing Guide + +## Prerequisites + +- Go 1.21+ +- podman + podman-compose +- Access to the Snowflake account (`JIRA_DB.CLOUDRHAI_MARTS`) +- Snowflake role with `SELECT` on that schema + +--- + +## Step 1 — Verify Snowflake access + +Before starting DevLake, confirm you can reach Snowflake from your machine. + +Create a throwaway Go file (outside the repo): + +```go +// /tmp/check_snowflake.go +package main + +import ( + "database/sql" + "fmt" + "log" + + sf "github.com/snowflakedb/gosnowflake" +) + +func main() { + cfg := &sf.Config{ + Account: "YOUR_ACCOUNT", // e.g. "myorg-myaccount" (not the full URL) + User: "YOUR_USER", + Role: "YOUR_ROLE", + Warehouse: "YOUR_WAREHOUSE", + Database: "JIRA_DB", + Schema: "CLOUDRHAI_MARTS", + Authenticator: sf.AuthTypeExternalBrowser, // opens browser for SSO + } + dsn, err := sf.DSN(cfg) + if err != nil { + log.Fatal(err) + } + + db, err := sql.Open("snowflake", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + var n int + row := db.QueryRow("SELECT COUNT(*) FROM JIRA_ISSUE_NON_PII") + if err := row.Scan(&n); err != nil { + log.Fatal(err) + } + fmt.Printf("OK — %d issues visible\n", n) +} +``` + +Run it from `backend/` where `gosnowflake` is already in `go.mod`: + +```bash +cd backend +go run /tmp/check_snowflake.go +# A browser window opens for SSO — log in once, then the script prints: +# OK — NNNN issues visible +``` + +If you see `OK — NNNN issues visible`, your credentials and role are working. + +**Common errors:** + +| Error | Fix | +|---|---| +| `account is empty` | Use the account identifier (e.g. `myorg-myaccount`), not the full `*.snowflakecomputing.com` URL | +| `Object does not exist or not authorized` | Your role lacks `SELECT` on `JIRA_DB.CLOUDRHAI_MARTS` — ask a Snowflake admin | +| Browser window never opens | You may be running inside a container or headless SSH session — run locally | + +--- + +## Step 2 — Start MySQL + +```bash +podman machine start +podman compose -f docker-compose-dev.yml up -d mysql +``` + +--- + +## Step 3 — Configure `.env` + +Create or edit `.env` in the repo root: + +```env +# Disable auth for local testing (skip bearer token on every API call) +AUTH_ENABLED=false + +# Required: any 32+ char string works for local dev — replace with your own value +ENCRYPTION_SECRET= + +DB_URL=mysql://merico:merico@127.0.0.1:3306/lake?charset=utf8mb4&parseTime=True + +# Generate ENCRYPTION_SECRET with: +# openssl rand -hex 16 +``` + +--- + +## Step 4 — Build and run DevLake (plugin only) + +Browser-based SSO (`externalbrowser` auth) only works when DevLake runs **natively on your desktop**, not inside a container (the browser pop-up cannot reach a container process). + +Build only the `jira_snowflake` plugin and start the server: + +```bash +cd backend +DEVLAKE_PLUGINS=jira_snowflake DISABLED_REMOTE_PLUGINS=true ENV_FILE=../.env make build-plugin run +``` + +- `DEVLAKE_PLUGINS=jira_snowflake` — only compile this plugin (much faster than building all plugins) +- `DISABLED_REMOTE_PLUGINS=true` — skip loading remote/dynamic plugins +- `ENV_FILE=../.env` — point to the `.env` in the repo root + +Verify the plugin loaded: + +```bash +curl -s http://localhost:8080/plugins | jq '.[] | select(.plugin == "jira_snowflake")' +``` + +Trigger DB migrations (creates `_tool_jira_snowflake_connections` table): + +```bash +curl -s http://localhost:8080/proceed-db-migration | jq . +``` + +Verify the table exists: + +```bash +podman compose -f docker-compose-dev.yml exec mysql \ + mysql -umerico -pmerico lake -e "SHOW TABLES LIKE '_tool_jira_snowflake%';" +``` + +--- + +## Step 5 — Create a connection + +```bash +curl -s -X POST http://localhost:8080/plugins/jira_snowflake/connections \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "my-snowflake-jira", + "account": "YOUR_ACCOUNT", + "user": "YOUR_USER", + "authType": "externalbrowser", + "database": "JIRA_DB", + "schema": "CLOUDRHAI_MARTS", + "warehouse": "YOUR_WAREHOUSE", + "role": "YOUR_ROLE" + }' | jq . +``` + +Note the `id` in the response — that is your `connectionId`. + +> For **key-pair auth** (production / CI), use `"authType": "keypair"` and +> add `"privateKey": "-----BEGIN PRIVATE KEY-----\n..."`. +> Key-pair auth works inside containers and does not require a browser. + +--- + +## Step 6 — Run a pipeline + +You need: +- `connectionId` from Step 5 +- `boardId`: the numeric Jira board ID (visible in the Jira board URL: `.../boards/NNN`) +- `projectKeys`: the list of Jira project keys that board covers (e.g. `["KONFLUX"]`) + +```bash +curl -s -X POST http://localhost:8080/pipelines \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "jira-snowflake-test", + "plan": [[{ + "plugin": "jira_snowflake", + "options": { + "connectionId": 1, + "boardId": 1, + "projectKeys": ["KONFLUX"] + } + }]] + }' | jq '{id, status}' +``` + +Poll the pipeline until it completes: + +```bash +PIPELINE_ID= +while true; do + curl -s http://localhost:8080/pipelines/$PIPELINE_ID | jq '{status, message}' + sleep 5 +done +``` + +A successful run ends with `"status": "TASK_COMPLETED"`. + +--- + +## Step 7 — Verify results in MySQL + +```bash +MYSQL="podman compose -f docker-compose-dev.yml exec mysql mysql -umerico -pmerico lake -e" + +# Row counts — tool layer and domain layer should match +$MYSQL "SELECT + (SELECT COUNT(*) FROM _tool_jira_issues) AS tool_issues, + (SELECT COUNT(*) FROM issues) AS domain_issues, + (SELECT COUNT(*) FROM _tool_jira_changelogs) AS tool_changelogs, + (SELECT COUNT(*) FROM issue_changelogs) AS domain_changelogs\G" + +# Status distribution +$MYSQL "SELECT std_type AS type, std_status AS status, COUNT(*) AS n + FROM _tool_jira_issues + GROUP BY std_type, std_status + ORDER BY n DESC + LIMIT 15;" + +# Sample issues +$MYSQL "SELECT issue_key, priority_name, std_type, std_status + FROM _tool_jira_issues LIMIT 10;" +``` + +**Expected results:** +- `tool_issues` and `domain_issues` counts should be equal (or domain slightly higher if rows remain from a prior run with a different board). +- `std_status` values should be `TODO`, `IN_PROGRESS`, or `DONE` — never `indeterminate`. +- `std_type` values should be `FEATURE`, `BUG`, `TASK`, `SUB-TASK`, etc. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| Plugin not listed at `/plugins` | Build step skipped or failed | Re-run `make build-plugin run` and check for compile errors | +| `ENCRYPTION_SECRET` error on startup | `.env` missing or key too short | Set a 32+ char hex string in `.env` | +| `401 Unauthorized` on API calls | Auth enabled | Set `AUTH_ENABLED=false` in `.env` | +| `invalid identifier 'X'` in pipeline logs | Column/table name mismatch | Run `DESCRIBE TABLE JIRA_DB.CLOUDRHAI_MARTS.
Estimated effort to review: 2
;` in Snowflake to verify column names | +| `Object 'X' does not exist or not authorized` | Table not replicated or role lacks access | See "Not available" table list in `AGENTS.md`; check Snowflake role grants | +| Browser pop-up doesn't open | Running inside a container | Run DevLake natively with `make run`, not via `podman compose` | +| Pipeline ends with `TASK_FAILED` | Check `message` field | `curl -s http://localhost:8080/pipelines/$ID | jq .message` for the full error | +| `tool_issues` populated but `domain_issues` = 0 | `convertIssues` subtask failed | Check pipeline subtask logs; ensure the subtask is enabled | +| Status shows `indeterminate` | `STATUSCATEGORY` value unexpected | The `CASE` in `sync_issues.go` maps `'2'`/`'3'`/`'4'`; any other value falls through to `indeterminate` | diff --git a/docs/local-dev.md b/docs/local-dev.md new file mode 100644 index 00000000000..2ed913b21cf --- /dev/null +++ b/docs/local-dev.md @@ -0,0 +1,153 @@ +# DevLake Local Development + +Quick reference for local development on this project. + +## Environment + +- **Container runtime**: podman (not docker) +- **Compose file**: `docker-compose-dev.yml` +- **Database**: MySQL 8, credentials `merico:merico`, database `lake` +- **Test database**: `lake_test` (auto-created via `scripts/mysql-init.sql`) +- **MySQL root**: `root:admin` (for admin operations) +- **Go version**: 1.21+ + +## Service URLs + +| Service | URL | +|---------|-----| +| Config UI | http://localhost:4000 | +| DevLake API | http://localhost:8080 | +| Grafana | http://localhost:4000/grafana/ | +| MySQL | localhost:3306 | + +## Custom CA (CEE GitLab) + +For internal services using Red Hat CA: + +```bash +# Create combined CA bundle: Mozilla CAs + Red Hat Root CAs (macOS) +curl -sL https://curl.se/ca/cacert.pem -o ./custom-ca.crt +security find-certificate -a -p -c "Root CA" /Library/Keychains/System.keychain >> ./custom-ca.crt + +# Set in .env +CA_CERT_FILE=./custom-ca.crt + +# Start with custom CA +podman compose -f docker-compose-dev.yml -f docker-compose-custom-ca.yml up -d +``` + +## Common Commands + +```bash +# Start all services +podman compose -f docker-compose-dev.yml up -d + +# Start just MySQL (for local Go dev) +podman compose -f docker-compose-dev.yml up -d mysql + +# Stop services +podman compose -f docker-compose-dev.yml down + +# Rebuild and restart devlake container +podman compose -f docker-compose-dev.yml up -d --build devlake + +# View logs +podman compose -f docker-compose-dev.yml logs -f devlake +``` + +## UBI Container images + +Local compose builds DevLake from UBI 9 Containerfiles: + +- Backend: [`backend/Containerfile`](../backend/Containerfile) +- Frontend: [`config-ui/Containerfile`](../config-ui/Containerfile) + +Compose tags local images as `localhost/devlake-backend:local` and `localhost/devlake-frontend:local`. + +```bash +# Build UBI images via compose +podman compose -f docker-compose-dev.yml build devlake config-ui + +# Or build directly (same tags compose expects) +podman build -f backend/Containerfile -t localhost/devlake-backend:local backend +podman build -f config-ui/Containerfile -t localhost/devlake-frontend:local config-ui +``` + +Debian-based Dockerfiles (`backend/Dockerfile`, `backend/Dockerfile.local`, `config-ui/Dockerfile`) are **deprecated**; prefer the UBI Containerfiles. Local compose and Konflux Tekton both build from `Containerfile`. `backend/Dockerfile.konflux` was removed after Tekton switched. + +## Building (from backend/) + +```bash +cd backend +make go-dep # Install dependencies +make build # Build everything (plugins + server) +make build-plugin # Build plugins only +make run # Run server +make dev # Build and run +``` + +## Testing + +```bash +cd backend + +# All tests (unit + e2e) +make test + +# Unit tests only +make unit-test + +# E2E tests (requires MySQL running with lake_test) +export E2E_DB_URL="mysql://merico:merico@localhost:3306/lake_test?charset=utf8mb4&parseTime=True" +make e2e-test + +# Specific plugin tests +go test ./plugins/aireview/... -v + +# Lint and format +make lint +make fmt +``` + +## Database + +```bash +# Connect to MySQL +podman compose -f docker-compose-dev.yml exec mysql mysql -umerico -pmerico lake + +# Check migrations +podman compose -f docker-compose-dev.yml exec mysql mysql -umerico -pmerico lake -e \ + "SELECT * FROM _devlake_migration_history ORDER BY created_at DESC LIMIT 10;" + +# Trigger migration after schema changes +curl -s http://localhost:4000/api/proceed-db-migration +``` + +## Plugin Verification + +```bash +# Check plugin is loaded +curl -s http://localhost:4000/api/plugins | jq '.[] | select(.plugin == "aireview")' + +# Check aireview tables +podman compose -f docker-compose-dev.yml exec mysql mysql -umerico -pmerico lake -e \ + "SHOW TABLES LIKE '_tool_aireview%';" +``` + +## Before Running Commands + +1. Ensure podman machine is running: `podman machine start` +2. Check if services are already running: `podman ps` +3. For E2E tests, MySQL must be running with `lake_test` database + +## If E2E tests fail with "Access denied for lake_test" + +Existing MySQL volumes may not have `lake_test`. Fix with: + +```bash +podman compose -f docker-compose-dev.yml exec mysql mysql -uroot -padmin -e " + CREATE DATABASE IF NOT EXISTS lake_test; + GRANT ALL PRIVILEGES ON lake_test.* TO 'merico'@'%'; + FLUSH PRIVILEGES; +" +``` diff --git a/docs/research/bot-commit-identification.md b/docs/research/bot-commit-identification.md new file mode 100644 index 00000000000..8759649a64b --- /dev/null +++ b/docs/research/bot-commit-identification.md @@ -0,0 +1,467 @@ + +# Bot Commit & PR Identification in DevLake + +Research findings from production database analysis (June 2026). + +## Problem Statement + +Production dashboards cannot reliably differentiate bot commits/PRs from real users. +The n8n workflows use pattern-based heuristics (`author_name LIKE '%[bot]%'`, empty +`author_name`, commit message patterns) which are fragile and inconsistent across +commits vs PRs. The problem is compounded by needing to work across multiple +providers (GitHub and GitLab), each with different bot conventions. + +There are two distinct concerns: + +1. **Historical data cleanup** — phantom PRs with empty `author_name` caused by + a removed zero-ID guard, addressed via manual backfill (June 2026) +2. **Going-forward bot identification** — adding a cross-provider `IsBot` field + to the domain model so downstream queries don't need provider-specific heuristics + +## Key Findings + +### Commits: Bot detection works but patterns differ by provider + +| Category | Count | Detection Method | +|----------|-------|-----------------| +| Bot-pattern authors (`[bot]` suffix, etc.) | 365,088 | `author_name LIKE '%[bot]%'` | +| Empty `author_name` | 53 | All are MkDocs CI deployments (`ci-bot@example.com`) | +| Total commits | 772,379 | — | + +The `[bot]` suffix in `author_name` is a reliable signal for GitHub commits. The 53 +empty-author commits are MkDocs CI deployment commits (empty author_name and +author_email) — unrelated to the GitHub/GitLab bot identity problem. + +### Pull Requests: Phantom author problem (GitHub) + +Current state (283,242 total PRs: 275,217 GitHub, 8,025 GitLab): + +| Category | Count | Root Cause | +|----------|-------|-----------| +| Empty `author_name` | 135,066 | Phantom `:0` author_id from removed zero-ID guard | +| Bot-pattern `author_name` | 30,755 | `dependabot[bot]`, `renovate[bot]`, etc. (GraphQL-collected) | +| Normal `author_name` | 117,421 | Human authors with proper identity | +| Tool-layer `author_id=0` | 116,033 | GitHub REST API returns `author: null` for bot PRs | + +All 135K empty-author PRs are GitHub-only and have phantom `github:GithubAccount:X:0` +domain IDs (perfect 1:1 correlation). These are caused by the fork's removal of the +upstream zero-ID guard in `pr_convertor.go`. When the GitHub REST API returns +`author: null` for a PR, `AuthorId=0`, and the fork generates phantom IDs that +match no `accounts` row. + +See "Historical Data Cleanup" section below for backfill details. + +### Tool-Layer Account Types + +**GitHub** (`_tool_github_accounts`): +``` +Type Count +User 23,850 +Bot 37 +Organization 11 +``` + +The `Type` field is barely populated — REST API `/users/` returns 404 for most +bots, so no `GithubAccount` row is created. Only GraphQL collection (PR #84's +`... on Bot` fragment) correctly populates bot type. + +**GitLab** (`_tool_gitlab_accounts`): 237,367 total accounts, no `Type` field at all. + +### GitLab Bot Patterns (from prod data) + +GitLab has distinct bot naming conventions, none of which overlap with GitHub's: + +| Pattern | Count | Example | +|---------|-------|---------| +| `project_*_bot*` (project access tokens) | 25,169 | `project_78877_bot_ec63972b...` | +| `group_*_bot*` (group access tokens) | 5,955 | `group_80889_bot_73ed0037...` | +| Name contains "Service Account" | 2,852 | `rh-renovate-bot` / `renovate service account` | +| Username ends in `-bot` | 1,291 | `pnc-stage-bot`, `snyk-broker-bot` | + +GitLab bots in commits also differ from GitHub: `Release Service Bot`, `ci-robot`, +`OpenShift Cherrypick Robot` — no `[bot]` suffix convention. + +## Historical Data Cleanup (completed June 2026) + +### Root cause: the zero-ID guard bug + +Upstream apache/devlake has this guard in `pr_convertor.go`: + +```go +if pr.AuthorId != 0 { + domainPr.AuthorId = accountIdGen.Generate(data.Options.ConnectionId, pr.AuthorId) +} +``` + +Our fork removed it, unconditionally generating `AuthorId`. When `pr.AuthorId == 0` +(null author from GitHub API), this produces `github:GithubAccount:X:0` — a phantom +ID that matches no `accounts` row, resulting in empty `author_name` after JOIN. + +### Two distinct data paths (GitHub) + +1. **GraphQL collection (PR #84):** Correctly identifies bots with proper login + (`dependabot[bot]`), ID, and Type. These PRs have valid `author_id` and `author_name`. + 30,755 PRs currently have proper bot names via this path. +2. **REST API collection:** Returns `author: null` for bot PRs. With the removed + guard, these get phantom IDs and empty names. + +### Manual backfill (partially effective) + +A manual backfill was performed using `backfill-pr-authors.py` which generated +`backfill-authors-update.sql` — a 4.3 MB script that resolved `author_name` for +15,107 PRs by looking up the actual author login from the GitHub API. This covered +both human users and bots (e.g. `rh-tap-build-team[bot]`, `seanconroy2021`). + +However, subsequent data collection cycles appear to have regenerated the phantom +IDs, overwriting the backfill. As of June 2026, 135,066 PRs still have empty +`author_name` with phantom `:0` author_id. The backfill approach is not durable +without also restoring the zero-ID guard to prevent re-generation. + +### Going forward + +The root fix requires two parts: + +1. **Restore the zero-ID guard** (Step 1 in Implementation Plan) — prevents new + phantom IDs from being generated during collection +2. **Re-run backfill or re-collect** — fixes the 135K existing phantom PRs + +New data collected via GraphQL (PR #84) correctly identifies bot PRs with proper +names and IDs. The remaining challenge beyond data cleanup is the separate concern +of **bot identification** — how to reliably distinguish bot accounts from human +accounts across providers without per-query pattern heuristics. + +## Cross-Provider Bot Identification (going forward) + +### Cross-provider pattern fragility + +Pattern-based detection requires maintaining provider-specific heuristics that +differ fundamentally between GitHub and GitLab: + +| Signal | GitHub | GitLab | +|--------|--------|--------| +| Login/username pattern | `*[bot]` | `project_*_bot*`, `group_*_bot*`, `*-bot` | +| API-level type field | `Type` (User/Bot) — poorly populated | None | +| Service account marker | N/A | Name contains "Service Account" | +| Null author | `author_id=0` (REST API) | N/A | + +These patterns will keep evolving as providers add new bot/service account concepts. +Encoding them in every n8n workflow query is brittle and error-prone. + +## Recommendation: Add `is_bot` to Domain `accounts` Model + +### Alternatives considered + +**Pattern-based detection in queries** — Each downstream query (n8n workflow, Grafana +dashboard) applies provider-specific heuristics (`author_name LIKE '%[bot]%'` for +GitHub, `user_name LIKE 'project_%_bot%'` for GitLab, etc.). This works for a single +provider but breaks down across GitHub and GitLab because their bot conventions differ +fundamentally. Every consumer must duplicate all patterns, and new bot conventions from +either provider require updating every query. + +**Centralized SQL view** — A `v_account_bot_status` view would centralize the pattern +logic in one place. This avoids duplication across queries but still requires maintaining +provider-specific heuristics as SQL. It doesn't solve the fundamental problem — the +domain layer has no concept of bot identity — and adds DDL management overhead without +improving the data model. + +### Why `IsBot` on the domain model + +With `is_bot` on the `accounts` model, each plugin's convertor applies provider-specific +logic once during data conversion, and all downstream consumers check a single boolean +field. This is the right layer for this concern — bot identity is a property of the +account, not a query-time derivation. + +### Upstream divergence reduction + +An additional benefit: `IsBot` reduces divergence from `apache/devlake`. The fork +currently diverges on 8 files (~320 changed lines) for bot account handling. + +**Update (2026-07-16):** while implementing this, we found that upstream +independently fixed the exact orphan-account problem this fork patched via +`cd9928159` ("fix: populate bot account identity...", DPROD-1259, 2026-05-13) — +but more completely, via +[apache/devlake#8894](https://github.com/apache/devlake/pull/8894) (fixes +[#8886](https://github.com/apache/devlake/issues/8886), merged 2026-06-12). +That PR: + +- Rewrites `account_convertor.go`'s `ConvertAccounts` to source from + `_tool_github_repo_accounts LEFT JOIN _tool_github_accounts` in a single + query — every account the repo references gets a domain row (enriched when + a profile was collected, login-only otherwise), with correct `_raw_data` + provenance via `COALESCE`. This fully replaces the need for this fork's + separate `convertOrphanedRepoAccounts` two-pass patch. +- Adds the exact same zero-ID guard to `pr_convertor.go` proposed in Step 1 + below (same reasoning, same `#8886` reference). +- Restores `pr_extractor.go`'s `merged_by` → `_tool_github_repo_accounts` + emission, fixing `pull_requests.merged_by_id` resolution. + +**Decision: adopt #8894 as the base instead of extending the fork's own +two-pass design.** It is more correct (fixes `_raw_data` provenance loss that +this fork's orphan pass had) and shrinks divergence further than originally +planned — `IsBot` becomes a small addition on top of upstream's exact code, +instead of a fork-specific pass layered on a fork-specific query rewrite. + +After adopting #8894 and layering `IsBot` on top, three files revert to match +upstream exactly and the domain model gains one new field: + +| File | Current divergence | After adopting #8894 + IsBot | +|------|-------------------|--------------------------| +| `account_convertor.go` | Reversed join, custom struct, rewritten query, orphan pass (135 lines) | **Matches upstream's #8894 query/struct**, plus `Type` field capture and `IsBot` population (~15 lines net addition) | +| `pr_convertor.go` | Removed zero-ID guard (13 lines) | **Reverted to upstream** (0 lines) — confirmed byte-identical to upstream's `#8886` guard | +| `pr_extractor.go` | Removed MergedBy repo_account emission¹ (10 lines) | **Reverted to upstream** (0 lines) | +| 4 GraphQL files | `... on Bot` fragment support (~160 lines) | Unchanged — candidate for upstream contribution | +| `account.go` (domain model) | Matches upstream | +1 field: `IsBot bool` (~1 line) | + +¹ The fork removed the repo_account emission for MergedBy users (the `results = append(results, mergedByUser)` block in the Extract function). The MergedBy *field extraction* in `convertGithubPullRequest()` (lines 209-212) is unchanged from upstream. + +**Net result:** 8 files / ~320 lines → 5 files / ~145 lines, with `pr_convertor.go` +and `pr_extractor.go` fully reverted and `account_convertor.go`'s divergence +reduced to a thin, well-isolated `IsBot`-specific addition on top of upstream's +own query. Rebase risk drops significantly — the largest conflict source (the +fork's independent query rewrite) is eliminated by adopting upstream's version +directly instead of maintaining a parallel one. + +For the full target-state divergence, see `docs/upstream-diffs.md`. + +### Implementation Plan + +**Step 1: Adopt upstream's account-identity fix** (apache/devlake#8894, fixes #8886 — prerequisite) + +Three files, all covered by the single upstream PR: + +1. `pr_convertor.go` — restore the zero-ID guard: + + ```go + if pr.AuthorId != 0 { + domainPr.AuthorId = accountIdGen.Generate(data.Options.ConnectionId, pr.AuthorId) + } + if pr.MergedById != 0 { + domainPr.MergedById = accountIdGen.Generate(data.Options.ConnectionId, pr.MergedById) + } + ``` + + Prevents phantom `github:GithubAccount:X:0` IDs from being generated for any + future REST API collection. Without this guard, any backfill of historical + data will be overwritten by the next collection cycle (see "Historical Data + Cleanup" above). Confirmed byte-identical to upstream's current code. + +2. `pr_extractor.go` — restore the `merged_by` repo_account emission block + (the 10 lines this fork previously removed), so `pull_requests.merged_by_id` + resolves to a domain account instead of an orphan FK. + +3. `account_convertor.go` — replace `ConvertAccounts` with upstream's unified + query (`FROM _tool_github_repo_accounts LEFT JOIN _tool_github_accounts`, + `_raw_data` provenance via `COALESCE`) instead of maintaining this fork's + separate `convertOrphanedRepoAccounts` two-pass patch. See Step 3 for how + `IsBot` layers onto this. + +After adopting the guard, re-run the backfill or trigger a full re-collection +to fix the 135K existing phantom PRs (see Step 5). + +**Step 2: Add `IsBot` field to domain `accounts` model** + +In `backend/core/models/domainlayer/crossdomain/account.go`: + +```go +type Account struct { + domainlayer.DomainEntity + Email string `gorm:"type:varchar(255)"` + FullName string `gorm:"type:varchar(255)"` + UserName string `gorm:"type:varchar(255)"` + AvatarUrl string `gorm:"type:varchar(255)"` + Organization string `gorm:"type:varchar(255)"` + CreatedDate *time.Time + Status int + IsBot bool `gorm:"type:boolean;default:false"` +} +``` + +Add a migration script in `backend/core/models/migrationscripts/`. + +**Step 3: Populate `IsBot` in GitHub account convertor** (on top of Step 1's unified query) + +Upstream's rewritten `ConvertAccounts` (Step 1.3) projects rows via a local +`repoAccountForConvert` struct (`Id, Login, Name, Email, AvatarUrl` — no +`Type`). Extend it minimally: + +- Add `Type string` to the local struct and `COALESCE(ga.type, '') AS type` + to the query's `SELECT`. +- Set `IsBot` via `isBotAccount(login, type)` based on: + - `Type == "Bot"` + - Login matches `*[bot]`, `-bot`, `-robot`, or exact logins `copilot`/`dependabot`/`github-actions`/`codecov-commenter` + +**Step 4: Populate `IsBot` in GitLab account convertor** + +In `backend/plugins/gitlab/tasks/account_convertor.go`, set `IsBot` based on: +- Username matches `project_*_bot*` or `group_*_bot*` +- Username ends in `-bot` +- Name contains "Service Account" (case-insensitive) + +**Step 5: Backfill `IsBot` for existing accounts** + +```sql +-- GitHub bots +-- codecov-commenter added 2026-07-16: verified live against konflux-ci/devlake#130. +-- Reported by the GitHub API as Type: "User" with a real profile, so neither the +-- type check nor the no-profile-data fallback catch it — only the login does. +UPDATE accounts SET is_bot = true +WHERE user_name LIKE '%[bot]' + OR user_name LIKE '%codecov-commenter%'; + +-- GitLab project/group access tokens +UPDATE accounts SET is_bot = true +WHERE user_name LIKE 'project\_%\_bot%' ESCAPE '\' + OR user_name LIKE 'group\_%\_bot%' ESCAPE '\'; + +-- GitLab named bots and service accounts +UPDATE accounts SET is_bot = true +WHERE user_name LIKE '%-bot' + OR full_name LIKE '%Service Account%'; +``` + +Note: `isBotAccount()`'s actual pattern set (`-bot`, `-robot`, `copilot`, `dependabot`, +`github-actions`, in addition to `[bot]` and `codecov-commenter`) is broader than this +backfill SQL. Those extra patterns are shipped and unit-tested but weren't +independently re-verified against production data the way `codecov-commenter` was — +reconcile deliberately before running this in production, don't assume the two +already match. + +Note: the phantom `author_id LIKE '%:0'` cleanup should be re-run after +restoring the zero-ID guard (Step 1), since the earlier manual backfill was +overwritten by subsequent collection cycles. The cleanup SQL: + +```sql +UPDATE pull_requests SET author_id = '', author_name = '' WHERE author_id LIKE '%:0'; +``` + +Alternatively, trigger a full re-collection which will correctly skip +`AuthorId=0` with the guard restored. + +**Limitation:** After restoring the zero-ID guard, the 135K phantom PRs will have +*empty* `author_id` — no `accounts` row to join on, so `IsBot` filtering cannot +help them. These PRs require either re-collection via GraphQL (which correctly +identifies bot authors with proper IDs) or a targeted backfill that resolves +author identity from the GitHub API. + +**Step 6: Simplify n8n workflow queries** + +Replace all provider-specific bot patterns with: + +```sql +-- Exclude bots from metrics +WHERE a.is_bot = false + +-- Or for tables without account join +WHERE author_name NOT IN ( + SELECT user_name FROM accounts WHERE is_bot = true +) +``` + +## Appendix A: N8n Workflow Bot Detection Patterns (Current) + +Patterns found in `../n8n-workflows/` that would be replaced by `is_bot`: + +| Workflow | Pattern | Purpose | +|----------|---------|---------| +| `8Oi1vXcAdLbL2owl.json` | `author_name LIKE '%[bot]%'` | Exclude bots from contributor metrics | +| `ajIm9fOvAjEUbUuj.json` | `author_email = 'noreply@github.com'` | Bot email detection | +| `4Ffddz0D5jELpsIf.json` | `message LIKE 'Merge pull request #%'` | Merge commit detection | + +## Appendix B: Verification Queries + +Queries used to produce the datapoints in this document. Run against the prod +database (`lake`). Data verified 2026-06-24. + +```sql +-- Commits: bot-pattern authors +SELECT COUNT(*) FROM commits WHERE author_name LIKE '%[bot]%'; +-- Result: 365,088 + +-- Commits: empty author_name (all are MkDocs CI deployment commits with +-- both author_name and author_email empty, message "Deployed with MkDocs version: 1.6.1") +SELECT COUNT(*) FROM commits WHERE author_name = '' OR author_name IS NULL; +-- Result: 53 + +-- Commits: total +SELECT COUNT(*) FROM commits; +-- Result: 772,379 + +-- PRs: total and breakdown +SELECT COUNT(*) FROM pull_requests; +-- Result: 283,242 + +SELECT + CASE + WHEN author_name = '' OR author_name IS NULL THEN 'empty' + WHEN author_name LIKE '%[bot]%' THEN 'bot_pattern' + ELSE 'normal' + END AS category, + COUNT(*) AS cnt +FROM pull_requests GROUP BY category; +-- Result: empty=135,066 bot_pattern=30,755 normal=117,421 + +-- PRs: by provider +SELECT + CASE + WHEN id LIKE 'github:%' THEN 'github' + WHEN id LIKE 'gitlab:%' THEN 'gitlab' + ELSE 'other' + END AS provider, + COUNT(*) AS cnt +FROM pull_requests GROUP BY provider; +-- Result: github=275,217 gitlab=8,025 + +-- PRs: phantom author_id correlation +SELECT COUNT(*) FROM pull_requests WHERE author_id LIKE '%:0'; +-- Result: 135,066 (all empty author_name PRs have phantom IDs — perfect correlation) + +SELECT COUNT(*) FROM pull_requests +WHERE (author_name = '' OR author_name IS NULL) AND author_id NOT LIKE '%:0'; +-- Result: 0 (no empty names without phantom IDs) + +-- PRs: empty author_name by provider (all GitHub) +SELECT COUNT(*) FROM pull_requests +WHERE (author_name = '' OR author_name IS NULL) AND id LIKE 'github:%'; +-- Result: 135,066 + +-- Tool-layer: author_id=0 +SELECT COUNT(*) FROM _tool_github_pull_requests WHERE author_id = 0; +-- Result: 116,033 + +-- GitHub accounts by type +SELECT COALESCE(type,'(null)') AS type, COUNT(*) +FROM _tool_github_accounts GROUP BY type; +-- Result: User=23,850 Bot=37 Organization=11 + +-- GitLab accounts total +SELECT COUNT(*) FROM _tool_gitlab_accounts; +-- Result: 237,367 + +-- GitLab bot patterns +SELECT COUNT(*) FROM _tool_gitlab_accounts WHERE username LIKE 'project\_%\_bot%'; +-- Result: 25,169 + +SELECT COUNT(*) FROM _tool_gitlab_accounts WHERE username LIKE 'group\_%\_bot%'; +-- Result: 5,955 + +SELECT COUNT(*) FROM _tool_gitlab_accounts WHERE name LIKE '%Service Account%'; +-- Result: 2,852 + +SELECT COUNT(*) FROM _tool_gitlab_accounts WHERE username LIKE '%-bot'; +-- Result: 1,291 +``` diff --git a/docs/research/snowflake-jira-plugin.md b/docs/research/snowflake-jira-plugin.md new file mode 100644 index 00000000000..75a283f190b --- /dev/null +++ b/docs/research/snowflake-jira-plugin.md @@ -0,0 +1,281 @@ + +# Proposal: `jira_snowflake` Plugin for Snowflake-Backed Jira Ingestion + +**This is a documentation-only proposal for team review — no code changes.** + +## Summary + +- Red Hat's Jira history is already replicated into Snowflake by Fivetran (`JIRA_DB.CLOUDRHAI_MARTS`), covering 5.5M+ issues, 47M+ changelog rows, sprints, worklogs, and issue links. +- DevLake currently ingests Jira data by calling the Jira REST API directly, duplicating work already done by Fivetran and placing unnecessary load on the Jira API. +- Per-project historical backfills that take hours via the Jira API complete in minutes via Snowflake. +- Proposed: a new `jira_snowflake` plugin that reads from Snowflake and writes into the same `_tool_jira_*` tables as the existing Jira plugin, then calls existing Jira convertor tasks (with a `*JiraTaskData` shim — see implementation notes). +- Migration is per-board and non-destructive: a team removes a board from the Jira API connection and adds it to the Snowflake connection. The two connections cannot hold the same board simultaneously, preventing duplication. + +## Problem Statement + +DevLake ingests Jira data by polling the Jira REST API. For Red Hat projects this creates two problems: + +**Redundant data collection.** Fivetran already continuously replicates the full Red Hat Jira instance into Snowflake on a 15–60 minute schedule. Running a separate DevLake collector against the same Jira instance means two systems polling the same API for the same data. + +**Slow historical backfill.** The Jira API is rate-limited and paginates in small pages. A full changelog backfill for a large project (e.g. KONFLUX) takes many hours. The same data is immediately available in Snowflake with no rate limit — a per-project backfill that takes hours via the API takes minutes via a Snowflake query. + +## What is Available in Snowflake + +All data required to fully populate DevLake's Jira tool-layer tables is present in `JIRA_DB.CLOUDRHAI_MARTS`: + +| Snowflake table | DevLake target table | Notes | +|---|---|---| +| `JIRA_ISSUE_NON_PII` | `_tool_jira_issues` | 5.5M+ issues | +| `JIRA_CHANGELOG` + `JIRA_CHANGELOGITEM` | `_tool_jira_issue_changelogs` / `_tool_jira_issue_changelog_items` | 47M / 17M rows | +| `JIRA_SPRINT` | `_tool_jira_sprints`, `_tool_jira_board_sprints` | Sprint state derived from `STARTED`/`CLOSED` booleans | +| `JIRA_CUSTOMFIELDVALUE_NON_PII` (field `Sprint`) | `_tool_jira_sprint_issues`, `_tool_jira_board_issues` | Sprint membership via custom field | +| `JIRA_WORKLOG` | `_tool_jira_worklogs` | `TIMEWORKED` in seconds — store directly into `TimeSpentSeconds` | +| `JIRA_ISSUELINK` | `_tool_jira_issue_relationships` | | +| `JIRA_LABEL` | `_tool_jira_issue_labels` | | +| `JIRA_PROJECT_RHAI` | `_tool_jira_boards` (scope) | | + +**Confirmed gap — user PII.** Assignee and creator names/IDs are stripped from the Snowflake tables (`_NON_PII` suffix). DevLake `assignee_name`, `assignee_id`, `creator_name` fields will be null. This affects "issues by assignee" dashboard breakdowns but does not affect flow metrics (lead time, cycle time, DORA). + +**Non-team contributor noise — known gap.** A Jira project space is often shared — upstream contributors or members of adjacent teams may file or work on issues in the same project. Schema investigation confirmed that all user-identifying data is stripped: the `Team` custom field has 5.17M rows but all value columns are null, `JIRA_RAPIDVIEW_RHAI` (board JQL filter) is not present, and assignee/creator are stripped. There is currently no reliable way to filter non-team contributors using Snowflake data alone. + +This gap will be addressed if and when the use case arises. The fallback is to supplement the Snowflake sync with a targeted Jira API query to retrieve team membership or board membership data — a minimal additional call rather than a full API-based ingestion. + +## Proposed Approach + +Build a new plugin `jira_snowflake` that: + +1. Opens a Snowflake connection using key-pair (JWT) authentication — the only viable auth method for a headless server process. +2. For each sync task, runs a Snowflake SQL query scoped to all project keys belonging to the configured board and (for incremental runs) filtered by `UPDATED > timeRangeStart` from DevLake's `Sync Policy`. +3. Writes rows directly into the existing `_tool_jira_*` tool-layer tables, including populating `StdType` and `StdStatus` using the same mapping logic that the Jira extractor applies. +4. Calls the **existing Jira convertor tasks** by constructing a `*tasks.JiraTaskData` shim (see convertor reuse strategy below). + +## How Teams Migrate + +The Snowflake-Jira plugin is a **drop-in replacement** for the Jira API plugin, not an addition. A board is configured in one connection at a time. + +Steps for a team migrating a board: + +1. A DevLake admin creates a `Snowflake-Jira` connection (Snowflake account, service user, RSA key, database/schema config). +2. For each board being migrated: remove it from the Jira API connection, add it to the Snowflake connection. +3. On the next pipeline run, DevLake performs a full sync from Snowflake for that board. The convertor layer replaces existing domain data for the board. +4. The Jira API connection stops receiving requests for those boards. + +Teams with boards from multiple Jira instances (Red Hat + external) can split per-board: Red Hat boards via Snowflake, others via the existing Jira API connection. No duplication occurs as long as the same board is not in both connections. + +The plugin must reach full feature parity with the Jira API plugin before any board is migrated, so that no dashboard metrics regress after migration. + +## Data Freshness and Trade-offs + +| Dimension | Snowflake connection | Jira API connection | +|---|---|---| +| Freshness | Lags real-time by 1 Fivetran sync (15–60 min) + pipeline run time | Near real-time | +| Historical backfill speed | Minutes per project | Hours per project | +| API load on Jira | None | Continuous polling | +| Assignee/creator fields | Null (PII stripped) | Populated | +| Rate limiting | None (Snowflake compute credits only) | Yes (Jira API rate limits) | + +With DevLake pipelines running on a daily schedule and Fivetran syncing every 15–60 minutes, the Snowflake path may actually deliver fresher data than a daily Jira API pipeline. Performance at scale should be verified during piloting. + +## Operational Considerations + +**Authentication.** A dedicated Snowflake service account with an RSA key pair is required. Browser SSO (`externalbrowser`) cannot be used in a headless server. The private key PEM is stored encrypted in DevLake's connection record. + +**Warehouse.** The plugin uses the `DEFAULT` warehouse, which is the warehouse we have access to. It is a Medium, multi-cluster (1–2) warehouse with auto-resume enabled. No additional provisioning is required. Because it is a shared warehouse, DevLake queries run alongside other workloads — this is expected and the multi-cluster configuration should handle concurrent demand automatically. + +**Concurrency.** With a multi-cluster warehouse, queries queue only when all clusters are at capacity. + +--- + +## Implementation Reference + +> The section below is a technical specification intended for the implementer or AI agent. It is not required reading for the team design review. + +### Development build order + +The plugin must reach full parity before any board migration. Recommended order: + +1. Connection model, `sync_issues`, reuse `ConvertIssuesMeta` — verify issue counts in dashboards +2. `sync_sprints`, `sync_sprint_issues`, `sync_changelogs` — verify sprint reports and lead time +3. `sync_worklogs`, `sync_labels`, `sync_issue_links` — verify time tracking and dependency views + +### Plugin file structure + +``` +backend/plugins/jira_snowflake/ +├── jira_snowflake.go # binary entry, var PluginEntry impl.JiraSnowflake +├── impl/ +│ └── impl.go # plugin registration, SubTaskMetas(), ApiResources() +├── api/ +│ └── connection_api.go # connection CRUD, uses helper.BaseConnection +├── models/ +│ └── connection.go # SnowflakeJiraConnection struct +└── tasks/ + ├── task_data.go # JiraSnowflakeTaskData, Options struct + ├── sync_issues.go # → _tool_jira_issues + ├── sync_sprints.go # → _tool_jira_sprints, _tool_jira_boards, _tool_jira_board_sprints + ├── sync_sprint_issues.go # → _tool_jira_sprint_issues, _tool_jira_board_issues + ├── sync_changelogs.go # → _tool_jira_issue_changelogs, _tool_jira_issue_changelog_items + ├── sync_worklogs.go # → _tool_jira_worklogs + ├── sync_issue_links.go # → _tool_jira_issue_relationships + └── sync_labels.go # → _tool_jira_issue_labels +``` + +Reference: `backend/plugins/jira/` for all patterns. The plugin has no collector or raw-table layer. + +### Convertor reuse strategy + +After the sync tasks populate `_tool_jira_*` tables, the plugin calls existing Jira convertor tasks from `impl.go`. The full list of convertors to include (cross-checked against `jira/impl/impl.go`): + +```go +import jiratasks "github.com/apache/devlake/backend/plugins/jira/tasks" + +// SubTaskMetas() — convertor tasks only (no Collect/Extract needed): +jiratasks.ConvertBoardMeta, +jiratasks.ConvertIssuesMeta, +jiratasks.ConvertIssueLabelsMeta, +// ConvertIssueCommentsMeta: omit — no JIRA_COMMENT table available in Snowflake; also EnabledByDefault: false +jiratasks.ConvertWorklogsMeta, +jiratasks.ConvertIssueChangelogsMeta, +jiratasks.ConvertIssueRelationshipsMeta, +jiratasks.ConvertSprintsMeta, +jiratasks.ConvertSprintIssuesMeta, +// ConvertIssueCommitsMeta / ConvertIssueRepoCommitsMeta: omit unless dev-panel data is available +// ConvertAccountsMeta: omit — assignee/creator PII is stripped in Snowflake +``` + +**`JiraTaskData` shim — type assertion compatibility.** Every convertor performs `subtaskCtx.GetData().(*JiraTaskData)`. The plugin's `PrepareTaskData` must therefore return a `*tasks.JiraTaskData`, not a custom struct. Critically, after auditing all convertor source files, **no convertor accesses `data.ApiClient` or `data.JiraServerInfo`** — those fields are only used by collector and extractor tasks. The shim is safe to construct with `ApiClient = nil` and a zero-value `JiraServerInfo`: + +```go +func (p JiraSnowflake) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) { + // decode options into JiraSnowflakeOptions... + return &jiratasks.JiraTaskData{ + Options: &jiratasks.JiraOptions{ + ConnectionId: opt.ConnectionId, + BoardId: opt.BoardId, + // ScopeConfig from DB as normal + }, + ApiClient: nil, // not used by any convertor + JiraServerInfo: models.JiraServerInfo{}, // zero value safe for convertors + }, nil +} +``` + +**Raw-table lineage — full-sync deletion.** The Jira convertor tasks use `StatefulDataConverter`, which in full-sync mode deletes domain rows matched by `_raw_data_table` and `_raw_data_params`. Because this plugin has no raw-table layer, those delete queries will match no rows and stale domain records will not be removed automatically. Mitigation: the sync tasks should upsert (not insert-only) into `_tool_jira_*`, and the `BeforeConvert` hook or explicit pre-deletion should be used to remove stale domain rows by scope. This is the same pattern used by the GitLab MR convertor. Document and implement this explicitly before production migration. + +### Connection model + +```go +type SnowflakeJiraConnection struct { + helper.BaseConnection `mapstructure:",squash"` // provides ID, Name, CreatedAt, UpdatedAt + + Account string `json:"account" gorm:"column:account"` + User string `json:"user" gorm:"column:sf_user"` + PrivateKey string `json:"privateKey" encrypt:"yes" gorm:"column:private_key"` + Database string `json:"database" gorm:"column:sf_database"` // e.g. JIRA_DB + Schema string `json:"schema" gorm:"column:sf_schema"` // e.g. CLOUDRHAI_MARTS + Warehouse string `json:"warehouse" gorm:"column:warehouse"` // DEFAULT + Role string `json:"role" gorm:"column:sf_role"` +} +``` + +Use `gosnowflake.DSN(&gosnowflake.Config{Authenticator: gosnowflake.AuthTypeJwt, PrivateKey: ...})`. The private key PEM is stored encrypted in DevLake's connection record. + +**Scope model — board-level, not project-level.** A Jira board can contain issues from multiple projects (e.g. a board tracking both DPROD and HELM). The scope unit is therefore the **board** (`models.JiraBoard`), not a single project PKEY. The `Options` struct carries a numeric `BoardId` (matching `JiraBoard.BoardId uint64`). The set of project keys belonging to that board must be resolved at sync time — either by querying `JIRA_SPRINT.RAPID_VIEW_ID` → project association, or by providing the list explicitly in scope config. The `WHERE` clause in all sync queries uses `i.PROJECT IN (:projectKeys)`, not `i.PROJECT = :singleKey`. Reuse `models.JiraScopeConfig` for type/status mappings. + +### Key sync queries + +**`sync_issues.go`** — incremental filter uses DevLake's `Sync Policy` `timeRangeStart` (set per blueprint). For a full sync `timeRangeStart` is zero/null and the filter is omitted. + +**Critical: `StdType` and `StdStatus` must be populated by this sync task.** The Jira convertor reads `jiraIssue.StdType` and `jiraIssue.StdStatus` directly from `_tool_jira_issues` to populate `ticket.Issue.Type` and `ticket.Issue.Status` in the domain layer. These fields are normally set by `ExtractIssuesMeta` — since this plugin has no extract phase, the sync task must apply equivalent logic: + +- `StdType`: look up `JiraIssueType` records for the connection from `_tool_jira_issue_types`, apply scope config `StdTypeMappings`; fall back to `strings.ToUpper(typeName)`. +- `StdStatus`: apply `getStdStatus(statusKey)` (maps Jira status category IDs to `"TODO"/"IN_PROGRESS"/"DONE"`); override from scope config `StandardStatusMappings` if present. + +Reference: `backend/plugins/jira/tasks/issue_extractor.go` lines 174–182 and `getTypeMappings()`. + +```sql +SELECT + i.ID, + i.ISSUE_KEY, + p.ID AS project_id, -- numeric uint64, not the PKEY string + i.PROJECT AS project_key, + p.PNAME AS project_name, + it.PNAME AS issue_type, + s.PNAME AS status_name, + s.STATUSCATEGORY_ID AS status_key, + i.SUMMARY, + i.DESCRIPTION, + i.CREATED, + i.UPDATED, + i.RESOLUTIONDATE, + i.DUEDATE, + i.PARENT_ID, + i.TIMEORIGINALESTIMATE AS original_estimate_seconds, + i.TIMEESTIMATE AS remaining_estimate_seconds, + i.TIMESPENT AS time_spent_seconds, + sp.NUMBERVALUE AS story_point, + el.STRINGVALUE AS epic_key, + sprint_cf.NUMBERVALUE AS sprint_id +FROM JIRA_DB.CLOUDRHAI_MARTS.JIRA_ISSUE_NON_PII i +JOIN JIRA_DB.CLOUDRHAI_MARTS.JIRA_ISSUETYPE_RHAI it ON i.ISSUETYPE = it.ID +JOIN JIRA_DB.CLOUDRHAI_MARTS.JIRA_ISSUESTATUS_RHAI s ON i.ISSUESTATUS_ID = s.ID +JOIN JIRA_DB.CLOUDRHAI_MARTS.JIRA_PROJECT_RHAI p ON i.PROJECT = p.PKEY +LEFT JOIN JIRA_DB.CLOUDRHAI_MARTS.JIRA_CUSTOMFIELDVALUE_NON_PII sp + ON sp.ISSUE = i.ID AND sp.CUSTOMFIELD_NAME = 'Story Points' +LEFT JOIN JIRA_DB.CLOUDRHAI_MARTS.JIRA_CUSTOMFIELDVALUE_NON_PII el + ON el.ISSUE = i.ID AND el.CUSTOMFIELD_NAME = 'Epic Link' +LEFT JOIN JIRA_DB.CLOUDRHAI_MARTS.JIRA_CUSTOMFIELDVALUE_NON_PII sprint_cf + ON sprint_cf.ISSUE = i.ID AND sprint_cf.CUSTOMFIELD_NAME = 'Sprint' +WHERE i.PROJECT IN (:projectKeys) -- all projects on the board + AND i.UPDATED > :timeRangeStart -- omitted on full sync +QUALIFY ROW_NUMBER() OVER ( + PARTITION BY i.ID + ORDER BY sprint_cf.NUMBERVALUE DESC NULLS LAST, + sp.NUMBERVALUE DESC NULLS LAST +) = 1 +``` + +The `QUALIFY ROW_NUMBER()` deduplicates rows produced by the three `LEFT JOIN`s on `JIRA_CUSTOMFIELDVALUE_NON_PII` — an issue with multiple sprint assignments, story point entries, or epic link entries would otherwise appear multiple times. The `ORDER BY` keeps the most recent sprint and highest story point value. Note: issues in multiple active sprints will retain only one sprint ID; this is an acceptable trade-off since the same limitation applies to the existing Jira API extractor. + +**`sync_sprints.go`** — derive state from `STARTED`/`CLOSED` booleans: +- `CLOSED = true` → `"closed"`, `STARTED = true` → `"active"`, else → `"future"` +- `RAPID_VIEW_ID` → `origin_board_id` (also populates `_tool_jira_board_sprints`) + +**`sync_sprint_issues.go`**: + +```sql +SELECT cf.NUMBERVALUE AS sprint_id, i.ID AS issue_id +FROM JIRA_DB.CLOUDRHAI_MARTS.JIRA_CUSTOMFIELDVALUE_NON_PII cf +JOIN JIRA_DB.CLOUDRHAI_MARTS.JIRA_ISSUE_NON_PII i ON i.ID = cf.ISSUE +WHERE cf.CUSTOMFIELD_NAME = 'Sprint' + AND cf.NUMBERVALUE IS NOT NULL + AND i.PROJECT IN (:projectKeys) +``` + +**`sync_changelogs.go`** — join `JIRA_CHANGELOG` and `JIRA_CHANGELOGITEM` on `GROUPID = ID`. `std_status` mapping is handled by the reused `issue_changelog_convertor.go`. + +**`sync_worklogs.go`** — `TIMEWORKED` is in seconds; store it directly into `JiraWorklog.TimeSpentSeconds` **without dividing**. The worklog convertor (`worklog_convertor.go`) already divides by 60 when writing to the domain layer. Pre-dividing would produce values 60× too small. Author fields set to null (PII stripped). + +## Test Plan + +- [ ] Review problem framing — does the team agree the API redundancy is worth addressing? +- [ ] Confirm the Snowflake service account provisioning process (who owns it, key rotation policy) +- [ ] Confirm the PII gap is acceptable for the metrics teams care about +- [ ] **Acknowledge non-team noise gap** — filtering non-team contributors from shared Jira projects is not solvable with Snowflake data alone (all user-identifying fields are stripped). Confirm this is acceptable for the initial use cases; revisit if needed, potentially supplementing with a targeted Jira API call. +- [ ] Agree on which project(s) to use as a pilot migration +- [ ] Review implementation reference section for correctness before coding begins diff --git a/docs/upstream-diffs.md b/docs/upstream-diffs.md new file mode 100644 index 00000000000..2b698791625 --- /dev/null +++ b/docs/upstream-diffs.md @@ -0,0 +1,516 @@ +# Upstream Divergence Tracking + +This file tracks modifications to files originating from [apache/incubator-devlake](https://github.com/apache/devlake) +that must be maintained during upstream syncs. + +Owned plugins (`aireview`, `codecov`, `testregistry`, `agentready`, `langfuse`, `jira_snowflake`) are additions, +not modifications, and are not tracked here. + +`backend/pkg/gcshelper` is an owned addition, not a modification, and is not tracked here. +`oidchelper` now lives in upstream `backend/helpers/oidchelper`; see the auth section. +`jira_snowflake/tasks/convert_*.go` are adapted copies of `jira/tasks/` convertors — see the +[jira_snowflake AGENTS.md](../backend/plugins/jira_snowflake/AGENTS.md) for the diff details. + +## gitextractor: ForceFullClone / FORCE_FULL_GIT_HISTORY + +**Files:** +- `backend/plugins/gitextractor/impl/impl.go` +- `backend/plugins/gitextractor/parser/clone_gitcli.go` +- `backend/plugins/gitextractor/parser/taskdata.go` +- `env.example` + +**Reason:** Upstream gitextractor incremental syncs miss commits via `--shallow-since`. +Adds a separate `ForceFullClone` strategy that bypasses shallow cloning entirely, +controlled by the `FORCE_FULL_GIT_HISTORY` environment variable. Also fixes a temp +directory leak in `doubleClone()`. + +**Upstream status:** Pending +**Upstream PR:** none yet +**Owner:** @kpiwko + +**Rebase notes:** Touches clone strategy selection in `clone_gitcli.go`. +Watch for upstream changes to `CloneRepo()`, `shallowClone()`, or `doubleClone()`. + +## gitlab: Map WorkInProgress to IsDraft in MR converter + +**Files:** +- `backend/plugins/gitlab/tasks/mr_convertor.go` + +**Reason:** `WorkInProgress` (from the GitLab API `work_in_progress` field) was extracted and +stored in `_tool_gitlab_merge_requests` but never forwarded to `code.PullRequest.IsDraft` +in the converter. This meant draft/WIP MRs were indistinguishable from non-draft MRs at the +domain layer. One-liner fix: `IsDraft: gitlabMr.WorkInProgress`. + +**Upstream status:** Merged in apache/devlake#8982. No outstanding fork delta. +**Upstream PR:** https://github.com/apache/devlake/pull/8982 +**Owner:** @fmuntean + +**Rebase notes:** Change is isolated to the struct literal in `mr_convertor.go`'s `Convert` func. +No conflicts expected unless upstream touches the same field mapping block. + +## server/api/auth: OIDC authentication + +**Files:** +- `backend/server/api/auth/auth.go` +- `backend/server/api/auth/middleware.go` +- `backend/server/api/auth/handlers_test.go` +- `backend/server/api/auth/store.go` +- `backend/server/api/auth/cleanup.go` +- `backend/server/api/auth/revocation_cache.go` +- `backend/server/api/auth/revocation_cache_test.go` +- `backend/server/api/auth/auth_test.go` +- `config-ui/src/utils/request.ts` (401 interceptor; skip plugin `/test` so remote credential failures do not redirect to `/login`) +- `env.example` (OIDC/auth env var documentation) + +**Reason:** Upstream now includes OIDC login. The remaining fork behavior is that auth is +enabled by default; `AUTH_ENABLED=false` is required to opt out (e.g. in local dev or CI). +The config-ui 401 interceptor still skips plugin `/test` so a remote credential failure +does not redirect to `/login`. + +The supporting library now lives in upstream `backend/helpers/oidchelper/` (allowlists included). This fork keeps `AUTH_ENABLED` defaulting to true unless it is explicitly false; that default was re-applied on top of upstream's opt-in default. Do not move the package back to `backend/pkg/oidchelper/`. + +**Upstream status:** Auth itself is upstream (`#8854`, `#8984`, `#9147`). Outstanding delta is the default-on `AUTH_ENABLED` and the plugin `/test` 401 exception. +**Upstream PR:** https://github.com/apache/devlake/pull/8854 +**Owner:** @fmuntean + +**Rebase notes:** +- `auth.go` / `middleware.go` wire into `backend/server/api/api.go` via `auth.Init()` and three + `router.Use()` calls — watch for upstream changes to `api.go`'s middleware chain. +- `store.go` introduces the `auth_sessions` table; no upstream equivalent. +- `env.example` additions are at the end of the file and unlikely to conflict. + +## jira: Scope collectParentIssues to current board + +**Files:** +- `backend/plugins/jira/tasks/parent_issue_collector.go` +- `backend/plugins/jira/impl/impl.go` + +**Reason:** collectParentIssues queries all issues on the Jira connection for epic keys +(filtering by connection_id only). Scoped the epic key query to the current board via board_id filter. + +**Upstream status:** N/A — collectParentIssues is Konflux-specific (commit f1c634d), not present in upstream Apache DevLake. +**Upstream PR:** none — not applicable +**Owner:** @cmulliga + +**Rebase notes:** `parent_issue_collector.go` is Konflux-only, no upstream conflicts expected. +`impl.go` has a Konflux addition (`CollectParentIssuesMeta` in `SubTaskMetas()`) — watch for upstream changes to the subtask registration list. +## archived/base.go: inline Unsigned constraint + +**Files:** +- `backend/core/models/migrationscripts/archived/base.go` + +**Reason:** `golang.org/x/exp/constraints` was imported only for `constraints.Unsigned` in +`GenericModel`. Recent versions of `golang.org/x/exp` require Go 1.23+ (they import the +standard `cmp` package added in Go 1.21). The CI environment runs an older Go, so the +transitive dependency chain through `core/runner` → `archived/base.go` caused a `typecheck` +failure in golangci-lint for any PR that introduces a new plugin main package. + +Replaced `constraints.Unsigned` with a locally-defined `unsignedInteger` interface that has +identical semantics, eliminating the `golang.org/x/exp` import entirely. + +**Upstream status:** Merged in apache/devlake#9032. No outstanding fork delta. +**Upstream PR:** https://github.com/apache/devlake/pull/9032 +**Owner:** @fmuntean + +**Rebase notes:** If upstream changes `GenericModel`, check whether they still reference +`golang.org/x/exp/constraints` and reapply the inline if needed. + +## docker-compose-dev: local UBI image builds + +**Files:** +- `docker-compose-dev.yml` +- `Makefile` (root `build-config-ui-image`) +- `backend/Makefile` (`build-server-image`) +- `backend/Dockerfile`, `config-ui/Dockerfile` (DEPRECATED comments only) + +**Reason:** Points `devlake` and `config-ui` builds at UBI Containerfiles +(`dockerfile: Containerfile`) and tags images as `localhost/devlake-*:local` +instead of personal Quay tags / Apache Scarf images. Makefile image targets use +`Containerfile`. Debian Dockerfiles are marked DEPRECATED pending removal. +Konflux Tekton builds `backend/Containerfile` and `config-ui/Containerfile` +(fork-only `backend/Dockerfile.konflux` removed). + +**Upstream status:** N/A — Konflux local-dev / image-build customization. +**Upstream PR:** none — not applicable +**Owner:** @fmuntean + +**Rebase notes:** If upstream regenerates `docker-compose-dev.yml`, re-add under +`devlake` / `config-ui`: `dockerfile: Containerfile` and +`image: localhost/devlake-backend:local` / `localhost/devlake-frontend:local`. +If upstream changes Makefile image targets or Dockerfiles, re-point builds at +`Containerfile` and keep DEPRECATED headers until the Debian files are deleted. + +(`backend/Containerfile` and `config-ui/Containerfile` are fork additions, not +tracked here.) + +## jira: cleanup stale board associations after tickets leave/change board + +**Files:** +- `backend/plugins/jira/impl/impl.go` +- `backend/plugins/jira/tasks/stale_board_issue_cleaner.go` +- `backend/plugins/jira/tasks/stale_board_issue_cleaner_test.go` + +**Reason:** Incremental Jira collection only adds board associations; it never removes +issues that left the board or moved to a different team. Stale rows in +`_tool_jira_board_issues` / domain `board_issues` keep those tickets on the old +board in metrics. Adds `cleanupStaleBoardIssues`, which batch-checks membership +via `agile/1.0/board/:id/issue` JQL (`issue IN (...)`, 100 per request), +re-fetches current issue state, and deletes the stale associations. + +**Upstream status:** N/A — Konflux-specific subtask, not present in upstream Apache DevLake. Also depends on `collectAndExtractSingleIssue` in `parent_issue_collector.go` (Konflux-only). +**Upstream PR:** none — not applicable +**Owner:** @rsoaresd + +**Rebase notes:** `stale_board_issue_cleaner.go` specific use case; no upstream equivalent. +`impl.go` registers `CleanupStaleBoardIssuesMeta` in `SubTaskMetas()` after +`ExtractEpicsMeta` — same conflict hotspot as `CollectParentIssuesMeta`. +Cleanup reuses `collectAndExtractSingleIssue` from `parent_issue_collector.go` +(also Konflux-only). + +## core: IsBot field on domain accounts model + +**Files:** +- `backend/core/models/domainlayer/crossdomain/account.go` +- `backend/core/models/migrationscripts/20260715_add_is_bot_to_accounts.go` (new file) +- `backend/core/models/migrationscripts/register.go` (append-only) + +**Reason:** Added `IsBot bool` to the domain `Account` struct so GitHub/GitLab +account convertors can flag bot identity once, instead of every downstream +consumer (n8n workflows, Grafana dashboards) re-deriving it via provider-specific +`author_name LIKE '%[bot]%'`-style patterns. See +`docs/research/bot-commit-identification.md` for the full analysis. Tracked in +DPROD-1342. + +**Upstream status:** Pending — not proposed upstream. This is a Konflux-specific +domain-model addition; upstream has no equivalent field. +**Upstream PR:** none +**Owner:** @kpiwko + +**Rebase notes:** Pure field addition, low conflict risk. Migration file is new +(no conflict); `register.go` only needs an append at the end of the `All()` slice. + +## github: IsBot field in account convertor + +**Files:** +- `backend/plugins/github/tasks/account_convertor.go` + +**Reason:** Populate the new `IsBot` field via `isBotAccount()` (API +`Type == "Bot"`, `[bot]`/`-bot`/`-robot` suffixes, or exact logins +`copilot`/`dependabot`/`github-actions`/`codecov-commenter`). + +**History:** this fork previously carried a much larger divergence here — a +two-pass design (`ConvertAccounts` + a separate `convertOrphanedRepoAccounts` +pass) introduced by `cd9928159` ("fix: populate bot account identity...", +2026-05-13, DPROD-1259) to handle accounts referenced only via +`_tool_github_repo_accounts` with no matching `_tool_github_accounts` row. +That divergence was never logged here. While implementing `IsBot`, we found +upstream had independently fixed the exact same problem +([apache/devlake#8894](https://github.com/apache/devlake/pull/8894), fixes +[#8886](https://github.com/apache/devlake/issues/8886), merged 2026-06-12) via +a more complete, unified-query design (also fixing `_raw_data` provenance, +which our two-pass patch had broken). We adopted upstream's version verbatim +instead of extending our own — `account_convertor.go` now matches upstream's +`ConvertAccounts` exactly except for `IsBot` population, and the fork's +`convertOrphanedRepoAccounts`/`buildOrphanDomainAccount` are gone. + +**Upstream status:** Pending — `IsBot` itself is not proposed upstream (the +underlying query/struct now match upstream exactly, via #8894). +**Upstream PR:** none yet (fork proposal: konflux-ci/devlake#118) +**Owner:** @kpiwko + +**Rebase notes:** Low conflict risk — the query/struct match upstream's #8894 +version exactly (`Type` field + its `COALESCE(ga.type, '')` select and the +`IsBot` line in `buildDomainAccount` are the only additions). Future upstream +changes to `ConvertAccounts` should apply cleanly except around those two +insertion points. + +## github: zero-ID guard and merged_by emission — adopted from upstream, no outstanding divergence + +**Files:** +- `backend/plugins/github/tasks/pr_convertor.go` +- `backend/plugins/github/tasks/pr_extractor.go` + +**Reason:** Both files previously diverged from upstream (missing zero-ID guard on +`AuthorId`/`MergedById`; missing `merged_by` → `_tool_github_repo_accounts` +emission), both fixed by +[apache/devlake#8894](https://github.com/apache/devlake/pull/8894). + +- `pr_extractor.go` is byte-identical to upstream (verified via diff against + `apache/incubator-devlake`) — no outstanding divergence. +- `pr_convertor.go` is behavior-identical (same zero-ID guard semantics) but + not byte-identical: we extracted the check into a named `hasValidAccountId()` + helper for unit-testability, which upstream doesn't have. This is a + deliberate, cosmetic divergence, not a functional one — worth a quick glance + on future upstream syncs to `pr_convertor.go`, but not a rebase risk. + +No entry needed going forward for either file — listed here only for +traceability since the fix landed alongside the `IsBot` work above. + +**Upstream status:** N/A — functionally matches upstream, zero behavioral divergence. +**Owner:** @kpiwko + +## gitlab: Bot identity (IsBot) in account convertor + +**Files:** +- `backend/plugins/gitlab/tasks/account_convertor.go` + +**Reason:** Populate `IsBot` via `isGitlabBotAccount()`. GitLab accounts have no +API-reported type field (unlike GitHub), so detection is username/name-pattern +only: `project__bot...`/`group__bot...` access-token prefixes, +`-bot`/`-robot` suffixes, and "Service Account" in the display name. + +**Upstream status:** Pending — not proposed upstream. +**Upstream PR:** none yet (fork proposal: konflux-ci/devlake#118) +**Owner:** @kpiwko + +**Rebase notes:** Upstream's `account_convertor.go` for GitLab is currently +unchanged from the version this fork started from (single `Convert` closure, +no orphan handling) — low conflict risk, isolated to the `Convert` closure body. + +## core: ai_commits domain table + +**Files:** +- `backend/core/models/domainlayer/code/ai_commit.go` (new file) +- `backend/core/models/domainlayer/domaininfo/domaininfo.go` (append) +- `backend/core/models/migrationscripts/20260826_add_ai_commits_domain.go` (new file) +- `backend/core/models/migrationscripts/register.go` (append-only) + +**Reason:** Project-scoped domain table for commits classified as AI-assisted by +the aireview plugin. Grafana LEFT JOINs `ai_commits` instead of evaluating +Co-Authored-By / Assisted-by / Made-with trailers at query time. Follows the +same pattern as `ai_reviews` (tool table in aireview, domain table in core). + +**Upstream status:** N/A — Konflux aireview addition; upstream Apache DevLake +has no equivalent table. +**Upstream PR:** none — not applicable +**Owner:** @fmuntean + +**Rebase notes:** New files plus an append in `domaininfo.go` and +`register.go:All()`. Low conflict risk unless upstream adds adjacent domain +tables in the same slice. + +## jira: OAuth 2.0 client-credentials (service account / 2LO) + +**Files:** +- `backend/plugins/jira/models/connection.go` +- `backend/plugins/jira/models/oauth.go` +- `backend/plugins/jira/models/connection_test.go` +- `backend/plugins/jira/models/oauth_test.go` +- `backend/plugins/jira/models/migrationscripts/20260907_add_oauth2_credentials.go` +- `backend/plugins/jira/models/migrationscripts/register.go` +- `backend/plugins/jira/api/connection_api.go` +- `backend/plugins/jira/api/connection_api_test.go` +- `backend/plugins/jira/tasks/api_client.go` +- `backend/plugins/jira/token/token_provider.go` +- `backend/plugins/jira/token/round_tripper.go` +- `backend/plugins/jira/token/token_provider_test.go` +- `config-ui/src/plugins/register/jira/connection-fields/auth.tsx` +- `config-ui/src/plugins/components/connection-form/index.tsx` +- `config-ui/src/types/connection.ts` +- `config-ui/src/api/connection/index.ts` + +**Reason:** Jira Cloud service accounts authenticate with OAuth 2.0 client +credentials (`grant_type=client_credentials`), not Basic Auth / PAT. Access +tokens last 60 minutes and API calls must go through +`https://api.atlassian.com/ex/jira/{cloudId}/rest/`. Adds `authMethod=OAuth2` +handling inside the Jira plugin (no core MultiAuth change), in-memory token +minting, and a refresh round tripper for long collections. + +**Upstream status:** Merged in apache/devlake#9159. This tree matches that version; no outstanding fork delta. +**Upstream PR:** https://github.com/apache/devlake/pull/9159 +**Owner:** @fmuntean + +**Rebase notes:** Watch upstream changes to `JiraConn` / `_tool_jira_connections`, +`NewJiraApiClient`, `testConnection` validation, and Config UI Cloud auth fields. +`register.go` append is low risk. Shared Config UI connection form/types/API pick +lists gained `cloudId` / `clientId` / `clientSecret`. +## graphql collector: include data-error message and variables in logs + +**Files:** +- `backend/helpers/pluginhelper/api/graphql_collector.go` +- `backend/helpers/pluginhelper/api/graphql_collector_test.go` + +**Reason:** `errors.Default.Wrap(dataError, "graphql query got error")` uses +cockroachdb `WithDetail`. `Error()` then `%+v`s that wrapper, so pipeline +messages look like `Wraps: (2) graphql query got error Error types: (1) +*hintdetail.withDetail (2) *errors.errorString` and drop the GraphQL +`Message`, locations, and request variables. Store/log a flattened string +instead (`errors.Default.New`) so subtask `message` and worker logs include +the real GitHub error and variables. + +**Upstream status:** Pending — should be contributed upstream as a diagnostics +fix. +**Upstream PR:** none yet +**Owner:** @rsoaresd + +**Rebase notes:** Touches `fetchAsync` data-error / transport-error handling +and `Execute` worker-error logging. Watch for upstream changes around +`IgnoreQueryErrors` / `isIgnorableGraphqlQueryError`. + +## github/github_graphql: graphql client token refresh (cherry-pick) + +**Files:** +- `backend/helpers/pluginhelper/api/graphql_async_client.go` +- `backend/plugins/github/tasks/api_client.go` +- `backend/plugins/github/tasks/http_client.go` (new) +- `backend/plugins/github/token/round_tripper.go` +- `backend/plugins/github_graphql/impl/impl.go` +- `backend/plugins/github_graphql/tasks/graphql_client.go` (new) +- `env.example` + +**Reason:** `updateRateRemaining` in `graphql_async_client.go` called `panic(err)` +when the GitHub rate-limit API returned an error (e.g. 401 from an expired token). +Because the panic fired inside a background goroutine, Go killed the entire process, +causing ~80 container restarts in 4 days on the prod instance. The upstream fix +replaces the panic with a logged fallback + retry, adds `StaticRoundTripper` for +PAT token rotation, shares the REST client's authenticated HTTP transport with the +GraphQL client (enabling GitHub App token auto-refresh), and introduces a +hierarchical rate-limit fallback (`GRAPHQL_RATE_LIMIT` env → `WithFallbackRateLimit` +→ default 1000). + +**Upstream status:** Merged, and present in this tree via the upstream history. No outstanding fork delta. +**Upstream PR:** https://github.com/apache/devlake/pull/8791 +**Upstream commit:** `61d66a1339d35709868105cd85153c60b824f290` +**Cherry-picked as:** konflux-ci/devlake#162 +**Owner:** @psturc + +**Rebase notes:** The cherry-pick arrived with upstream. This entry is kept for history only. + +## ci: unit-test Codecov upload on a separate Ubuntu job + +**Files:** +- `.github/workflows/test.yml` + +**Reason:** Fork-only Codecov upload for owned plugins. `codecov-action` needs +`gpg`, which is not in `mericodev/lake-builder` (Debian 11 / Bullseye). Installing +it via `apt-get` in that container fails because `bullseye-security` InRelease +expired after Debian 11 LTS EOL (2026-08-31). Tests stay in `lake-builder`; +coverage artifacts are uploaded from a follow-on `ubuntu-latest` job with OIDC. + +**Upstream status:** N/A — upstream `test.yml` has no Codecov steps. +**Upstream PR:** none — not applicable +**Owner:** @fmuntean + +**Rebase notes:** Keep the `test` job aligned with upstream (container, unit +tests). Re-apply the `Custom plugins coverage` / artifact upload steps and the +`upload-coverage` job after upstream workflow changes. + +## config-ui: connection fields for owned plugins + +**Files:** +- `config-ui/src/plugins/components/connection-form/index.tsx` +- `config-ui/src/api/connection/index.ts` +- `config-ui/src/types/connection.ts` + +**Reason:** The shared connection form whitelists which values reach the backend, +so each owned-plugin field must be listed in `IConnectionAPI`, the `test` / +`testOld` payload `Pick<>`s, and both test-payload builders. Fields added so far: +`service` (codecov); `ciTool`, `quayOrganization`, `githubOrganization`, +`githubToken`, `project`, `junitRegex` (testregistry). Missing a builder is +silent — Save Connection still works while Test Connection drops the field and +the backend falls back to its default (COVERPORT-351). + +**Upstream status:** N/A — these fields belong to fork-only plugins. +**Upstream PR:** none — not applicable +**Owner:** @lipka28 + +**Rebase notes:** The lists are additive; on conflict keep both upstream's fields +and the fork's. + +## config-ui: aisdlc plugin registration + +**Files:** +- `config-ui/src/plugins/register/index.ts` +- `config-ui/src/plugins/register/aisdlc/` +- `config-ui/src/plugins/components/scope-config-form/index.tsx` +- `config-ui/src/plugins/components/connection-form/index.tsx` +- `config-ui/src/api/connection/index.ts` +- `config-ui/src/types/connection.ts` + +**Reason:** The AISDLC plugin is registered in the shared plugin list, and its +station fields are edited on the scope-config form. `gitlabConnectionId` is also +added to the connection test whitelist (same silent-drop as COVERPORT-351). The +connection-form files are shared with the section above; keep both sets of fields. + +**Upstream status:** N/A — fork-only plugin. +**Upstream PR:** none — not applicable +**Owner:** @fmuntean + +**Rebase notes:** Re-apply the `AisdlcConfig` import, the `plugin === 'aisdlc'` +branch, and the `gitlabConnectionId` entries. Plugin code under +`config-ui/src/plugins/register/aisdlc/` is new and does not conflict with upstream. + +## license header: AISDLC fixture files + +**Files:** +- `.licenserc.yaml` + +**Reason:** The RFE result and strategy-task fixtures are copied source artifacts. +An Apache header would change the bytes the parser tests read, and the strategy +fixture must start with YAML front matter. They are ignored the same way other +testdata inputs are. + +**Upstream status:** N/A — fork-only fixtures. +**Upstream PR:** none — not applicable +**Owner:** @fmuntean + +**Rebase notes:** Keep the `backend/plugins/aisdlc/tasks/testdata/**/*.md` entry in +`paths-ignore`. + +## server/api: enable CORS + +**Files:** +- `backend/server/api/api.go` +- `backend/core/config/config_viper.go` + +**Reason:** External apps and the config UI call the API from another origin. +CORS is enabled with `DELETE` and `Authorization`, credentials, and a 2-hour +preflight cache. `CORS_ALLOW_ORIGIN` defaults to `*`, and comma-separated values +in one env var are split into separate origins. + +**Upstream status:** Pending. Upstream still has the middleware commented out. +**Upstream PR:** none +**Owner:** @kpiwko + +**Rebase notes:** Re-apply `parseCORSAllowOrigins` and the live `router.Use(cors.New(...))` +block. Leave `/proceed-db-migration` public; upstream `#8930` and `#9068` removed +the auth requirement the fork still has because it is behind those commits. + +## projects: default sync window, weekday schedule, empty metrics + +**Files:** +- `backend/server/services/project.go` +- `env.example` + +**Reason:** New projects sync the last `DEFAULT_SYNC_DAYS` (default 180) and run +on `0 0 * * 0-5` so Saturday is skipped. Project responses always include +`metrics: []` when none are configured, so the config UI does not call `.find()` +on null. + +**Upstream status:** Pending. +**Upstream PR:** none +**Owner:** @psturc, @kpiwko + +**Rebase notes:** Keep `validateProjectMetrics`. Only change the blueprint cron, +the `TimeAfter` window, and the metrics slice assignment. + +## local dev: MySQL zero dates and Grafana anonymous admin + +**Files:** +- `docker-compose-dev-mysql.yml` +- `docker-compose-dev-postgresql.yml` +- `.devcontainer/docker-compose.yml` + +**Reason:** GitHub PAT connections store a zero `time.Time` for unused OAuth +expiry fields. MySQL 8 strict mode rejects `0000-00-00`, so local MySQL drops +`NO_ZERO_DATE` from `sql_mode`. Local Grafana allows anonymous Admin so +dashboards open without a login. + +**Upstream status:** N/A — local-dev only. `docker-compose-dev.yml` was split +upstream into the mysql and postgresql files; do not restore the old file. +**Upstream PR:** none +**Owner:** @kpiwko + +**Rebase notes:** Put the `sql-mode` flag on the MySQL services and the three +`GF_AUTH_ANONYMOUS_*` / `GF_AUTH_DISABLE_LOGIN_FORM` variables on the Grafana +services. diff --git a/docs/v1migration.md b/docs/v1migration.md new file mode 100644 index 00000000000..e7683dee958 --- /dev/null +++ b/docs/v1migration.md @@ -0,0 +1,143 @@ +# V1 vs V2 Confusion Matrix Discrepancy — Root Cause & Fix Plan + +**Status:** In progress +**Date:** 2026-04-02 + +## Problem + +TP / TN / FP / FN counts differ between the AiFailurePredictionV1 and AiFailurePredictionV2 Grafana dashboards. +Users would expect them to be identical (same data, different rendering), but they are not. + +--- + +## Data Inventory (as of 2026-04-02) + +``` +_tool_aireview_failure_predictions (406 rows total) + ci_failure_source = NULL → 122 rows (orphaned, stale) + ci_failure_source = 'test_cases' → 142 rows (current) + ci_failure_source = 'job_result' → 142 rows (current) + +_tool_aireview_reviews → 1,205 rows + created_date range: 2025-11-11 → 2026-04-01 +``` + +Confusion matrix for `test_cases` source (current): +``` +TP = 32 FP = 89 TN = 15 FN = 6 +``` + +--- + +## Root Cause 1 — `flagged_at` is never populated (PRIMARY) + +**File:** `backend/plugins/aireview/tasks/calculate_failure_predictions.go` + +`loadAiReviewPrSummaries` does not select `created_date` from `_tool_aireview_reviews`. +The resulting `AiFailurePrediction` record has `FlaggedAt` left as zero / nil. + +`created_at` is set to `time.Now()` — the moment the DevLake task ran. +All 284 current predictions share the same two task-run timestamps (2026-03-31 and 2026-04-02). + +**Consequence:** + +| Dashboard | Time filter column | What it means | +|-----------|------------------------|------------------------------------| +| V1 | `ar.created_date` | When the AI review was posted | +| V2 | `created_at` | When the DevLake task ran | + +In V2, `$__timeFilter(created_at)` is effectively meaningless: if the Grafana time range includes +the task run date (today), all 284 current rows pass. If it doesn't, zero rows pass. +V1 filters against the real review timeline (Nov 2025 – Apr 2026), giving genuine time-windowed results. + +**Fix:** Include `MIN(ar.created_date) AS created_date` in the summary query, store it in `prAiSummary`, +and set `FlaggedAt = ps.CreatedDate` when building each `AiFailurePrediction`. +Then update the V2 dashboard to use `$__timeFilter(flagged_at)`. + +--- + +## Root Cause 2 — Stale orphaned predictions (`ci_failure_source = NULL`) + +**When:** 20260330–20260331, before migration `20260331_add_ci_failure_source.go` ran. + +`generatePredictionId` hashes `prId + aiTool + ciFailureSource`. +At that time `ciFailureSource` was always `""` (empty string), producing IDs like: + +``` +aipred:sha256("github:pr-123:CodeRabbit:") +``` + +After the migration added `CiFailureSource`, new task runs use e.g. `"test_cases"` or `"job_result"`, +producing **different IDs**. The old rows are never matched by `CreateOrUpdate` and accumulate as orphans. + +**Consequence in V2 dashboard:** + +- Filter `ci_failure_source = 'both'`: all 406 rows included (122 stale + 284 current). +- Filter `ci_failure_source = 'test_cases'`: only 142 rows (NULLs don't match). + +This inflates counts under "both" and makes comparisons across filter values inconsistent. + +**Fix:** Migration `20260402_cleanup_null_ci_source_predictions.go` that deletes all rows where +`ci_failure_source IS NULL`. They will be regenerated correctly on the next task run. + +--- + +## Root Cause 3 — 3-month CI lookback vs. Grafana time window (minor) + +`loadCiOutcomesByTestCases` and `loadCiOutcomesByJobResult` hard-code a 3-month CI lookback: + +```go +dal.Where("... AND j.finished_at >= ?", time.Now().AddDate(0, -3, 0)) +``` + +V1's dashboard uses `$__timeFilter(j.finished_at)`, which respects the user-chosen Grafana time range. +If a user sets a 6-month window in V1, they see more PRs than V2 pre-computed. + +This is a design trade-off (performance vs. flexibility). Not addressed in this fix cycle; +the lookback is controlled by `CiBackfillDays` in scope config (added 20260401). + +--- + +## Fix Plan + +### Fix 1: Populate `flagged_at` in `calculate_failure_predictions.go` + +- Add `CreatedDate time.Time` to `prAiSummary`. +- Extend the SELECT in `loadAiReviewPrSummaries` with `MIN(ar.created_date) AS created_date`. +- Set `FlaggedAt: ps.CreatedDate` in the prediction record. + +### Fix 2: Update V2 Grafana dashboard + +Replace every occurrence of `$__timeFilter(created_at)` with `$__timeFilter(flagged_at)` in +`grafana/dashboards/AiFailurePredictionV2.json`. + +### Fix 3: Add cleanup migration + +Create `backend/plugins/aireview/models/migrationscripts/20260402_cleanup_null_ci_source_predictions.go`: + +```go +db.Exec("DELETE FROM _tool_aireview_failure_predictions WHERE ci_failure_source IS NULL") +``` + +Register in `register.go`. + +--- + +## Expected Outcome After Fixes + +After next task run: +- All predictions have `flagged_at` set to the AI review's `created_date`. +- V2 dashboard time filter behaves identically to V1 (filters on review date). +- The 122 stale NULL rows are removed; fresh rows with correct `ci_failure_source` are written. +- TP/TN/FP/FN counts in V1 and V2 converge for the same Grafana time range and threshold. + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `backend/plugins/aireview/tasks/calculate_failure_predictions.go` | Populate `FlaggedAt` | +| `grafana/dashboards/AiFailurePredictionV2.json` | `created_at` → `flagged_at` in time filter | +| `backend/plugins/aireview/models/migrationscripts/20260402_cleanup_null_ci_source_predictions.go` | New migration | +| `backend/plugins/aireview/models/migrationscripts/register.go` | Register new migration | diff --git a/e2e/basic-flow.spec.ts b/e2e/basic-flow.spec.ts new file mode 100644 index 00000000000..c6f3592adcf --- /dev/null +++ b/e2e/basic-flow.spec.ts @@ -0,0 +1,111 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect, request } from '@playwright/test'; + +const API = 'http://localhost:8080'; +const UI = 'http://localhost:4000'; + +const state: { + connectionId: number; + projectName: string; +} = { connectionId: 0, projectName: '' }; + +test.describe.serial('Basic Flow (no external credentials)', () => { + test('Set onboard flag and verify home page', async ({ page }) => { + const api = await request.newContext({ baseURL: API }); + await api.put('/store/onboard', { + data: { step: 4, records: [], done: true, projectName: '', plugin: '' }, + }); + + await page.goto(UI); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveTitle(/DevLake/); + }); + + test('Create webhook connection via API', async ({ page }) => { + const api = await request.newContext({ baseURL: API }); + + const resp = await api.post('/plugins/webhook/connections', { + data: { name: `e2e-webhook-${Date.now()}` }, + }); + if (!resp.ok()) { + const text = await resp.text(); + throw new Error(`Webhook creation failed (${resp.status()}): ${text}`); + } + const body = await resp.json(); + state.connectionId = body.id; + expect(state.connectionId).toBeGreaterThan(0); + + await page.goto(`${UI}/connections`); + await page.waitForLoadState('networkidle'); + }); + + test('Create project via API', async ({ page }) => { + const api = await request.newContext({ baseURL: API }); + state.projectName = `e2e-project-${Date.now()}`; + + const resp = await api.post('/projects', { + data: { + name: state.projectName, + description: 'E2E test project', + }, + }); + if (!resp.ok()) { + const text = await resp.text(); + throw new Error(`Project creation failed (${resp.status()}): ${text}`); + } + + await page.goto(UI); + await page.waitForLoadState('networkidle'); + }); + + test('Project visible in UI', async ({ page }) => { + await page.goto(`${UI}/projects`); + await page.waitForLoadState('networkidle'); + + const projectLink = page.locator(`text=${state.projectName}`); + await expect(projectLink).toBeVisible({ timeout: 15000 }); + }); + + test('Connections page loads', async ({ page }) => { + await page.goto(`${UI}/connections`); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: 'Webhooks', exact: true })).toBeVisible({ timeout: 10000 }); + }); + + test('Pipelines page loads', async ({ page }) => { + await page.goto(`${UI}/pipelines`); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + await expect(page.locator('text=Error')).not.toBeVisible(); + }); + + test('Cleanup', async ({ page }) => { + const api = await request.newContext({ baseURL: API }); + + if (state.projectName) { + await api.delete(`/projects/${state.projectName}`); + } + if (state.connectionId) { + await api.delete(`/plugins/webhook/connections/${state.connectionId}`); + } + + await page.goto(UI); + await page.waitForLoadState('networkidle'); + }); +}); diff --git a/e2e/codecov-flow.spec.ts b/e2e/codecov-flow.spec.ts new file mode 100644 index 00000000000..8c0a290893b --- /dev/null +++ b/e2e/codecov-flow.spec.ts @@ -0,0 +1,229 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect, request } from '@playwright/test'; + +const API = 'http://localhost:8080'; +const TOKEN = process.env.CODECOV_TOKEN; +const ORG = process.env.CODECOV_ORG ?? 'konflux-ci'; +const SERVICE = process.env.CODECOV_SERVICE ?? 'github'; +const ENDPOINT = process.env.CODECOV_ENDPOINT ?? 'https://api.codecov.io'; +const REPO = process.env.CODECOV_REPO ?? 'konflux-ci/coverport'; + +const EXPECTED_SUBTASKS = [ + 'CollectFlags', + 'ConvertFlags', + 'CollectCommits', + 'ExtractCommits', + 'CollectCommitTotals', + 'CollectCommitCoverage', + 'CollectComparison', + 'CollectFlagCoverageTrend', + 'CollectRepoConfig', + 'ConvertComparison', + 'ConvertCoverage', + 'ConvertCommitCoverage', + 'ConvertCoverageTrend', +]; + +const state = { + connectionId: 0, + scopeConfigId: 0, + scopeId: '', + blueprintId: 0, + pipelineId: 0, + remoteRepo: null as Record | null, +}; + +async function cleanupResources() { + const api = await request.newContext({ baseURL: API }); + + if (state.blueprintId) { + await api.delete(`/blueprints/${state.blueprintId}`); + console.log(`Deleted blueprint ${state.blueprintId}`); + } + if (state.scopeId) { + await api.delete( + `/plugins/codecov/connections/${state.connectionId}/scopes/${encodeURIComponent(state.scopeId)}`, + ); + console.log(`Deleted scope ${state.scopeId}`); + } + if (state.scopeConfigId) { + await api.delete( + `/plugins/codecov/connections/${state.connectionId}/scope-configs/${state.scopeConfigId}`, + ); + console.log(`Deleted scope config ${state.scopeConfigId}`); + } + if (state.connectionId) { + await api.delete(`/plugins/codecov/connections/${state.connectionId}`); + console.log(`Deleted connection ${state.connectionId}`); + } +} + +test.describe.serial('Codecov Plugin Full Flow', () => { + test.skip(!process.env.CODECOV_TOKEN, 'CODECOV_TOKEN not set'); + test.setTimeout(900_000); + + test.afterAll(async () => { + if (state.connectionId) { + await cleanupResources(); + console.log('Cleanup complete'); + } + }); + + test('Step 1: Create Connection', async () => { + const api = await request.newContext({ baseURL: API }); + const resp = await api.post('/plugins/codecov/connections', { + data: { + name: `e2e-codecov-${Date.now()}`, + token: TOKEN, + organization: ORG, + service: SERVICE, + endpoint: ENDPOINT, + }, + }); + expect(resp.ok()).toBeTruthy(); + const conn = await resp.json(); + state.connectionId = conn.id; + console.log(`Connection created: id=${state.connectionId}`); + }); + + test('Step 2: Test Connection', async () => { + const api = await request.newContext({ baseURL: API }); + const resp = await api.post(`/plugins/codecov/connections/${state.connectionId}/test`); + const body = await resp.json(); + expect(resp.ok()).toBeTruthy(); + expect(body.success).toBe(true); + console.log('Test connection: OK'); + }); + + test('Step 3: Discover Remote Scopes', async () => { + const api = await request.newContext({ baseURL: API }); + const resp = await api.get(`/plugins/codecov/connections/${state.connectionId}/remote-scopes`); + expect(resp.ok()).toBeTruthy(); + const body = await resp.json(); + + const repo = body.children?.find((c: { fullName: string }) => c.fullName === REPO); + expect(repo, `Repository ${REPO} not found in remote-scopes`).toBeTruthy(); + state.remoteRepo = repo.data; + console.log(`Found remote repo: ${REPO}`); + }); + + test('Step 4: Create Scope Config', async () => { + const api = await request.newContext({ baseURL: API }); + const resp = await api.post( + `/plugins/codecov/connections/${state.connectionId}/scope-configs`, + { data: { name: `e2e-scope-config-${Date.now()}`, entities: ['CODE'] } }, + ); + expect(resp.ok()).toBeTruthy(); + const body = await resp.json(); + state.scopeConfigId = body.id; + console.log(`Scope config created: id=${state.scopeConfigId}`); + }); + + test('Step 5: Add Scope', async () => { + const api = await request.newContext({ baseURL: API }); + const resp = await api.put(`/plugins/codecov/connections/${state.connectionId}/scopes`, { + data: { + data: [{ + ...state.remoteRepo, + scopeConfigId: state.scopeConfigId, + }], + }, + }); + expect(resp.ok()).toBeTruthy(); + const scopes = await resp.json(); + state.scopeId = scopes[0].codecovId; + expect(state.scopeId).toBe(REPO); + console.log(`Scope added: id=${state.scopeId}`); + }); + + test('Step 6: Create Blueprint via API', async () => { + const api = await request.newContext({ baseURL: API }); + const resp = await api.post('/blueprints', { + data: { + name: `e2e-blueprint-${Date.now()}`, + mode: 'NORMAL', + enable: true, + cronConfig: '0 0 * * *', + isManual: true, + connections: [{ + pluginName: 'codecov', + connectionId: state.connectionId, + scopes: [{ scopeId: state.scopeId }], + }], + }, + }); + expect(resp.ok()).toBeTruthy(); + const body = await resp.json(); + state.blueprintId = body.id; + console.log(`Blueprint created: id=${state.blueprintId}`); + }); + + test('Step 7: Trigger Pipeline via API', async () => { + const api = await request.newContext({ baseURL: API }); + const resp = await api.post(`/blueprints/${state.blueprintId}/trigger`, { data: {} }); + expect(resp.ok()).toBeTruthy(); + const body = await resp.json(); + state.pipelineId = body.id; + console.log(`Pipeline triggered: id=${state.pipelineId}`); + }); + + test('Step 8: Wait for Pipeline to Complete', async () => { + const api = await request.newContext({ baseURL: API }); + const maxWait = 900_000; // 15 min — first Codecov collection can be slow + const start = Date.now(); + let status = ''; + + while (Date.now() - start < maxWait) { + const resp = await api.get(`/pipelines/${state.pipelineId}`); + const pipeline = await resp.json(); + status = pipeline.status; + console.log(`Pipeline status: ${status} (${Math.round((Date.now() - start) / 1000)}s)`); + if (['TASK_COMPLETED', 'TASK_FAILED', 'TASK_PARTIAL'].includes(status)) break; + await new Promise((r) => setTimeout(r, 5000)); + } + + const tasksResp = await api.get(`/pipelines/${state.pipelineId}/tasks`); + if (tasksResp.ok()) { + const { tasks } = await tasksResp.json(); + for (const t of tasks || []) { + console.log(` Task ${t.id}: ${t.status}${t.failedSubTask ? ` (failed: ${t.failedSubTask})` : ''}`); + if (t.message) console.log(` Error: ${t.message.substring(0, 300)}`); + } + } + + expect(status).toBe('TASK_COMPLETED'); + }); + + test('Step 9: Verify Pipeline Subtasks', async () => { + const api = await request.newContext({ baseURL: API }); + + const tasksResp = await api.get(`/pipelines/${state.pipelineId}/tasks`); + expect(tasksResp.ok()).toBeTruthy(); + const { tasks } = await tasksResp.json(); + expect(tasks[0].status).toBe('TASK_COMPLETED'); + expect(tasks[0].subtasks).toEqual(EXPECTED_SUBTASKS); + console.log(`Pipeline completed in ${tasks[0].spentSeconds}s with ${tasks[0].subtasks.length} subtasks`); + }); + + test('Step 10: Cleanup', async () => { + await cleanupResources(); + state.connectionId = 0; + console.log('Cleanup complete'); + }); +}); diff --git a/e2e/package.json b/e2e/package.json index 0c612162d30..622abe1bfdd 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,14 +1,17 @@ { "name": "e2e", "version": "1.0.0", - "description": "", - "main": "index.js", + "private": true, + "description": "Playwright e2e tests with coverage", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "npx playwright test basic-flow.spec.ts", + "test:all": "npx playwright test", + "test:coverage": "npx playwright test basic-flow.spec.ts", + "test:codecov": "npx playwright test codecov-flow.spec.ts --timeout=900000" }, "keywords": [], "author": "", - "license": "ISC", + "license": "Apache-2.0", "type": "commonjs", "dependencies": { "@playwright/test": "^1.64.1" diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index d4006283687..68ca78f82a0 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -29,7 +29,10 @@ export default defineConfig({ screenshot: 'on', trace: 'on-first-retry', }, - reporter: [['html', { open: 'never' }], ['list']], + reporter: [ + ['list'], + ['html', { open: 'never' }], + ], projects: [ { name: 'chromium', diff --git a/env.example b/env.example index effca30dbaa..a638e2b3433 100755 --- a/env.example +++ b/env.example @@ -74,6 +74,10 @@ API_TIMEOUT=120s API_RETRY=3 API_REQUESTS_PER_HOUR=10000 PIPELINE_MAX_PARALLEL=1 +# Default sync time range for new projects (in days). +# New blueprints collect data from (today - DEFAULT_SYNC_DAYS) onwards. +# Default: 180 +DEFAULT_SYNC_DAYS=180 # resume undone pipelines on start RESUME_PIPELINES=true # Debug Info Warn Error @@ -114,6 +118,10 @@ USE_GO_GIT_IN_GIT_EXTRACTOR=false # NOTE that COMMIT_FILES is part of the COMMIT_STAT SKIP_COMMIT_STAT=false SKIP_COMMIT_FILES=true +# Set to true to force full git clone (complete history) on every run, including incremental syncs. +# Fixes missing commits caused by shallow clone edge cases. Uses more disk and memory. +# Takes precedence over noShallowClone when both are set. +FORCE_FULL_GIT_HISTORY=false # In plugin jira, continue collecting when a page of issues cannot be parsed. # A page that returns a successful HTTP status but a body that is not the @@ -135,10 +143,10 @@ ENABLE_SUBTASKS_BY_DEFAULT="jira:collectIssueChangelogs:true,jira:extractIssueCh ########################## # OIDC / Authentication ########################## -# Master switch. Auth is disabled by default; set true to require -# authentication. When enabled without OIDC, DevLake accepts API keys for -# /rest/* and can trust X-Forwarded-User from an upstream proxy. -AUTH_ENABLED=false +# Master switch. Auth is enabled by default; set false only for isolated local +# development. When enabled without OIDC, DevLake accepts API keys for /rest/* +# and can trust X-Forwarded-User from an upstream proxy. +AUTH_ENABLED=true # OIDC user login. Requires AUTH_ENABLED=true. OIDC_ENABLED=false @@ -204,3 +212,15 @@ COOKIE_SECURE=true # Restrict OIDC logins to specific users or domains (comma-separated, case-insensitive) OIDC_ALLOW_EMAILS= OIDC_ALLOW_DOMAINS= + +# Custom CA certificate file for internal services (e.g., CEE GitLab). +# Used with docker-compose-custom-ca.yml. +# CA_CERT_FILE=./custom-ca.crt + +# Codecov plugin e2e tests (e2e/codecov-flow.spec.ts) — not used by Docker Compose. +# Export before running: cd e2e && npm run test:codecov +# CODECOV_TOKEN= +# CODECOV_ORG=konflux-ci +# CODECOV_SERVICE=github +# CODECOV_ENDPOINT=https://api.codecov.io +# CODECOV_REPO=konflux-ci/coverport diff --git a/grafana/dashboards/AIFailurePredictions.json b/grafana/dashboards/AIFailurePredictions.json new file mode 100644 index 00000000000..0788f1eb68b --- /dev/null +++ b/grafana/dashboards/AIFailurePredictions.json @@ -0,0 +1,3404 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 44, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 300, + "panels": [], + "title": "Overview \u2014 Developer Interaction Signals", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Total AI code review events logged in the selected repositories and time window.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 1 + }, + "id": 301, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) AS value FROM ai_reviews WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring}) AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date)", + "refId": "A" + } + ], + "title": "Total AI Reviews", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Percentage of AI reviews that received at least one \ud83d\udc4d or \ud83d\udc4e reaction from a developer. Low values mean developers are reading but not responding \u2014 high values show active engagement.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 5 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 1 + }, + "id": 302, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT ROUND(100.0 * SUM(CASE WHEN reactions_total_count > 0 THEN 1 ELSE 0 END) / COUNT(*), 2) AS value FROM ai_reviews WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring}) AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date)", + "refId": "A" + } + ], + "title": "Developer Reaction Rate", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Of all AI-reviewed PRs that were either merged or closed, what percentage made it through? Baseline for PRs without AI review: ~71.9%.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "yellow", + "value": 80 + }, + { + "color": "green", + "value": 90 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 1 + }, + "id": 303, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT ROUND(100.0 * COUNT(DISTINCT CASE WHEN pr.status = 'MERGED' THEN pr.id END) / COUNT(DISTINCT pr.id), 1) AS value FROM pull_requests pr INNER JOIN ai_reviews ar ON ar.pull_request_id = pr.id WHERE pr.status IN ('MERGED', 'CLOSED') AND ar.repo_id IN (${repo_id:sqlstring}) AND ar.ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(ar.created_date)", + "refId": "A" + } + ], + "title": "AI-Reviewed PR Merge Rate", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Total \ud83d\udc4d reactions left by developers on AI review comments. A positive signal that developers found the feedback useful.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 1 + }, + "id": 304, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT SUM(reactions_thumbs_up) AS value FROM ai_reviews WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring}) AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date)", + "refId": "A" + } + ], + "title": "\ud83d\udc4d Developer Thumbs Up", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Total \ud83d\udc4e reactions \u2014 developer signals that the AI review was unhelpful or wrong. Rising counts warrant a look at what the AI is flagging.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 1 + }, + "id": 305, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT SUM(reactions_thumbs_down) AS value FROM ai_reviews WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring}) AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date)", + "refId": "A" + } + ], + "title": "\ud83d\udc4e Developer Thumbs Down", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Number of distinct repositories covered by AI code review in the current filter selection.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 1 + }, + "id": 306, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(DISTINCT repo_id) AS value FROM ai_reviews WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring}) AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date)", + "refId": "A" + } + ], + "title": "Repos with AI Review Coverage", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 5 + }, + "id": 314, + "panels": [], + "title": "Suggestion Acceptance — Are Developers Using AI Suggestions?", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Percentage of AI code suggestions that developers accepted and applied. Higher rates indicate the AI is producing actionable, high-quality suggestions.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "yellow", + "value": 20 + }, + { + "color": "green", + "value": 50 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 5 + }, + "id": 311, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT ROUND(100.0 * SUM(GREATEST(suggestions_accepted, suggestions_diff_accepted)) / NULLIF(SUM(suggestions_count), 0), 1) AS value FROM _tool_aireview_reviews WHERE repo_id IN (${repo_id:sqlstring}) AND ai_tool IN (${ai_tool:sqlstring})\n AND body NOT LIKE '%Review skipped%'\n AND suggestions_count > 0\n AND $__timeFilter(created_date)", + "refId": "A" + } + ], + "title": "Suggestion Acceptance Rate", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Total number of AI suggestions that were accepted/applied by developers across all reviews.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 6, + "y": 5 + }, + "id": 312, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT SUM(GREATEST(suggestions_accepted, suggestions_diff_accepted)) AS value FROM _tool_aireview_reviews WHERE repo_id IN (${repo_id:sqlstring}) AND ai_tool IN (${ai_tool:sqlstring})\n AND body NOT LIKE '%Review skipped%'\n AND $__timeFilter(created_date)", + "refId": "A" + } + ], + "title": "Total Suggestions Accepted", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Suggestion acceptance rate broken down by AI tool. Shows which AI tools produce suggestions developers actually use.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 14, + "x": 10, + "y": 5 + }, + "id": 313, + "options": { + "barRadius": 0.1, + "barWidth": 0.6, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "orientation": "horizontal", + "showValue": "always", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0 + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT ai_tool, ROUND(100.0 * SUM(GREATEST(suggestions_accepted, suggestions_diff_accepted)) / NULLIF(SUM(suggestions_count), 0), 1) AS acceptance_rate FROM _tool_aireview_reviews WHERE repo_id IN (${repo_id:sqlstring}) AND ai_tool IN (${ai_tool:sqlstring})\n AND body NOT LIKE '%Review skipped%'\n AND suggestions_count > 0\n AND $__timeFilter(created_date)\n GROUP BY ai_tool\n ORDER BY acceptance_rate DESC", + "refId": "A" + } + ], + "title": "Acceptance Rate by AI Tool", + "type": "barchart" + }, + { + "id": 510, + "type": "row", + "title": "Diff-Based Suggestion Acceptance — Line-Level Code Matching", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 10 + }, + "panels": [] + }, + { + "id": 511, + "type": "stat", + "title": "Diff-Based Acceptance Rate", + "description": "Percentage of AI suggestions where matching code was found in subsequent commits (line-level comparison)", + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 11 + }, + "datasource": "mysql", + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 20 + }, + { + "color": "green", + "value": 50 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "value", + "graphMode": "none" + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT ROUND(100.0 * COUNT(CASE WHEN suggestion_diff_matched = 1 THEN 1 END) / NULLIF(COUNT(*), 0), 1) AS value\nFROM _tool_aireview_findings\nWHERE repo_id IN (${repo_id:sqlstring})\n AND type = 'suggestion'\n AND suggested_code != ''\n AND $__timeFilter(created_date)", + "refId": "A" + } + ] + }, + { + "id": 512, + "type": "stat", + "title": "Avg Lines Applied %", + "description": "Average percentage of non-trivial suggested lines found in commit diffs", + "gridPos": { + "h": 4, + "w": 4, + "x": 6, + "y": 11 + }, + "datasource": "mysql", + "fieldConfig": { + "defaults": { + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 30 + }, + { + "color": "green", + "value": 60 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "value", + "graphMode": "none" + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT ROUND(AVG(suggestion_match_score), 1) AS value\nFROM _tool_aireview_findings\nWHERE repo_id IN (${repo_id:sqlstring})\n AND type = 'suggestion'\n AND suggestion_diff_matched = 1\n AND $__timeFilter(created_date)", + "refId": "A" + } + ] + }, + { + "id": 513, + "type": "stat", + "title": "Suggestions 100% Applied", + "description": "Number of suggestions where all non-trivial lines were found in commits", + "gridPos": { + "h": 4, + "w": 4, + "x": 10, + "y": 11 + }, + "datasource": "mysql", + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "value", + "graphMode": "none" + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) AS value\nFROM _tool_aireview_findings\nWHERE repo_id IN (${repo_id:sqlstring})\n AND type = 'suggestion'\n AND suggestion_diff_matched = 1\n AND suggestion_match_score >= 99.9\n AND $__timeFilter(created_date)", + "refId": "A" + } + ] + }, + { + "id": 514, + "type": "barchart", + "title": "Suggestion Acceptance Distribution", + "description": "How much of each suggestion was applied (0%, 1-50%, 51-99%, 100%)", + "gridPos": { + "h": 4, + "w": 10, + "x": 14, + "y": 11 + }, + "datasource": "mysql", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": { + "orientation": "horizontal", + "showValue": "always", + "stacking": "none" + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT\n bucket,\n cnt AS count\nFROM (\n SELECT 'Not matched' AS bucket, 0 AS sort_order, COUNT(*) AS cnt\n FROM _tool_aireview_findings\n WHERE repo_id IN (${repo_id:sqlstring}) AND type = 'suggestion' AND suggested_code != ''\n AND suggestion_diff_matched = 0 AND $__timeFilter(created_date)\n UNION ALL\n SELECT '1-50% applied', 1, COUNT(*)\n FROM _tool_aireview_findings\n WHERE repo_id IN (${repo_id:sqlstring}) AND type = 'suggestion'\n AND suggestion_diff_matched = 1 AND suggestion_match_score > 0 AND suggestion_match_score <= 50\n AND $__timeFilter(created_date)\n UNION ALL\n SELECT '51-99% applied', 2, COUNT(*)\n FROM _tool_aireview_findings\n WHERE repo_id IN (${repo_id:sqlstring}) AND type = 'suggestion'\n AND suggestion_diff_matched = 1 AND suggestion_match_score > 50 AND suggestion_match_score < 99.9\n AND $__timeFilter(created_date)\n UNION ALL\n SELECT '100% applied', 3, COUNT(*)\n FROM _tool_aireview_findings\n WHERE repo_id IN (${repo_id:sqlstring}) AND type = 'suggestion'\n AND suggestion_diff_matched = 1 AND suggestion_match_score >= 99.9\n AND $__timeFilter(created_date)\n) t ORDER BY sort_order", + "refId": "A" + } + ] + }, + { + "id": 515, + "type": "table", + "title": "Suggestion Details — Line-Level Matching", + "description": "Individual AI suggestions with acceptance percentage based on code diff comparison", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 15 + }, + "datasource": "mysql", + "fieldConfig": { + "defaults": {}, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "pct_applied" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "suggestion_preview" + }, + "properties": [ + { + "id": "custom.width", + "value": 300 + } + ] + } + ] + }, + "options": { + "showHeader": true, + "footer": { + "show": true, + "reducer": [ + "count" + ] + } + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT\n f.matched_file_path AS file_path,\n f.suggestion_match_method AS method,\n ROUND(f.suggestion_match_score, 1) AS pct_applied,\n f.suggestion_lines_matched AS lines_matched,\n f.suggestion_lines_total AS lines_total,\n LEFT(f.suggested_code, 120) AS suggestion_preview,\n ar.ai_tool,\n pr.pull_request_key AS pr_number\nFROM _tool_aireview_findings f\nJOIN _tool_aireview_reviews ar ON f.ai_review_id = ar.id\nLEFT JOIN pull_requests pr ON f.pull_request_id = pr.id\nWHERE f.repo_id IN (${repo_id:sqlstring})\n AND f.type = 'suggestion'\n AND f.suggestion_diff_matched = 1\n AND $__timeFilter(f.created_date)\nORDER BY f.suggestion_match_score DESC", + "refId": "A" + } + ] + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 307, + "panels": [], + "title": "Risk Level & Tool Breakdown", + "type": "row" + }, + { + "datasource": "mysql", + "description": "How risky is the current mix of pull requests? High-risk PRs are more likely to have CI failures and lower merge rates.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "high" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "medium" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "low" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 24 + }, + "id": 308, + "options": { + "legend": { + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT risk_level, COUNT(*) as count FROM ai_reviews WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring}) AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date) GROUP BY risk_level", + "refId": "A" + } + ], + "title": "Risk Level Distribution", + "transformations": [ + { + "id": "rowsToFields", + "options": { + "mappings": [ + { + "fieldName": "risk_level", + "handlerKey": "field.name" + }, + { + "fieldName": "count", + "handlerKey": "field.value" + } + ] + } + } + ], + "type": "piechart" + }, + { + "datasource": "mysql", + "description": "Which AI tools are providing reviews in the selected repositories? Useful for checking tool adoption and mix.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 24 + }, + "id": 309, + "options": { + "legend": { + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT ai_tool, COUNT(*) as count FROM ai_reviews WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring}) AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date) GROUP BY ai_tool", + "refId": "A" + } + ], + "title": "Reviews by AI Tool", + "transformations": [ + { + "id": "rowsToFields", + "options": { + "mappings": [ + { + "fieldName": "ai_tool", + "handlerKey": "field.name" + }, + { + "fieldName": "count", + "handlerKey": "field.value" + } + ] + } + } + ], + "type": "piechart" + }, + { + "datasource": "mysql", + "description": "AI reviews where CodeRabbit skipped the review due to label configuration (e.g. do-not-merge/work-in-progress). These PRs are excluded from all other panels on this dashboard.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 10 + }, + { + "color": "red", + "value": 25 + } + ] + }, + "unit": "none" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Skipped %" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 24 + }, + "id": 310, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "horizontal", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n COUNT(*) AS `Skipped Reviews`,\n ROUND(100.0 * COUNT(*) / NULLIF((SELECT COUNT(*) FROM _tool_aireview_reviews ar2\n JOIN project_mapping pm2 ON ar2.repo_id = pm2.row_id AND pm2.`table` = 'repos'\n WHERE pm2.project_name = '${project}'\n AND ar2.repo_id IN (${repo_id:sqlstring})\n AND ar2.ai_tool IN (${ai_tool:sqlstring})\n AND $__timeFilter(ar2.created_date)), 0), 1) AS `Skipped %`\nFROM _tool_aireview_reviews ar\nJOIN project_mapping pm ON ar.repo_id = pm.row_id AND pm.`table` = 'repos'\nWHERE pm.project_name = '${project}'\n AND ar.repo_id IN (${repo_id:sqlstring})\n AND ar.ai_tool IN (${ai_tool:sqlstring})\n AND ar.body LIKE '%Review skipped%'\n AND $__timeFilter(ar.created_date)", + "refId": "A" + } + ], + "title": "Skipped AI Reviews", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 32 + }, + "id": 400, + "panels": [], + "title": "Review Iterations \u2014 How Often Did Developers Push Back?", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Distinct PRs that received at least one AI review in the selected time range. Each PR may have multiple review rounds \u2014 use this as the true denominator, not Total AI Reviews.", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 0, + "y": 33 + }, + "id": 409, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(DISTINCT pull_request_id) AS value\nFROM ai_reviews\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date)", + "refId": "A" + } + ], + "title": "Total PRs with AI Review", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "PRs where the developer made no additional commit after the initial AI review \u2014 the AI reviewed once and was never re-triggered. This does not mean the review was ignored; the developer may have merged, fixed locally, or addressed issues without pushing again.", + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 5, + "y": 33 + }, + "id": 408, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) AS value\nFROM (\n SELECT pull_request_id\n FROM ai_reviews\n WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date)\n GROUP BY pull_request_id\n HAVING COUNT(*) = 1\n) t", + "refId": "A" + } + ], + "title": "PRs with No AI Review Rounds", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "A PR with more than one AI review means the developer pushed new commits after the first review \u2014 the AI re-reviewed each time. This is the strongest proxy we have for developer engagement driven by AI feedback.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 10, + "y": 33 + }, + "id": 401, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(*) AS value\nFROM (\n SELECT pull_request_id\n FROM ai_reviews\n WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date)\n GROUP BY pull_request_id\n HAVING COUNT(*) > 1\n) t", + "refId": "A" + } + ], + "title": "PRs with Multiple AI Review Rounds", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Average number of AI review rounds on PRs that went through more than one round. Each round represents a developer push followed by a new AI review.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 15, + "y": 33 + }, + "id": 402, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT ROUND(AVG(review_count), 1) AS value\nFROM (\n SELECT pull_request_id, COUNT(*) AS review_count\n FROM ai_reviews\n WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date)\n GROUP BY pull_request_id\n HAVING COUNT(*) > 1\n) t", + "refId": "A" + } + ], + "title": "Avg Rounds per Iterated PR", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Human reviews (APPROVED or CHANGES_REQUESTED) submitted after the first AI review on the same PR. This is a proxy for AI-triggered developer engagement \u2014 the human may have reviewed in response to the AI's feedback. Correlation only, not causation.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 19, + "y": 33 + }, + "id": 403, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT COUNT(*) AS value\nFROM _tool_github_pull_request_reviews hrv\nJOIN (\n SELECT\n CAST(SUBSTRING_INDEX(ar.pull_request_id, ':', -1) AS UNSIGNED) AS pr_numeric_id,\n MIN(ar.created_date) AS first_ai_review\n FROM ai_reviews ar\n WHERE ar.project_name = '${project}' AND ar.repo_id IN (${repo_id:sqlstring})\n AND ar.ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(ar.created_date)\n GROUP BY ar.pull_request_id\n) ai ON hrv.pull_request_id = ai.pr_numeric_id\nWHERE hrv.github_submit_at > ai.first_ai_review\n AND hrv.author_username NOT LIKE '%[bot]%'\n AND hrv.author_username NOT IN ('coderabbitai', 'qodo-merge-pro')\n AND hrv.state IN ('APPROVED', 'CHANGES_REQUESTED')", + "refId": "A" + } + ], + "title": "Human Reviews After AI Review (proxy)", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "\"1 round (no re-review)\" = the PR triggered exactly one AI review and was not reviewed again. This is the large majority \u2014 the developer may have merged, fixed without pushing again, or ignored the review. These PRs have no iteration signal.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 37 + }, + "id": 405, + "options": { + "barRadius": 0, + "barWidth": 0.6, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n CASE\n WHEN review_count = 1 THEN '1 round'\n WHEN review_count BETWEEN 2 AND 3 THEN '2\u20133 rounds'\n WHEN review_count BETWEEN 4 AND 5 THEN '4\u20135 rounds'\n WHEN review_count BETWEEN 6 AND 10 THEN '6\u201310 rounds'\n ELSE '11+ rounds'\n END AS `Review Rounds`,\n COUNT(*) AS `PRs`\nFROM (\n SELECT pull_request_id, COUNT(*) AS review_count\n FROM ai_reviews\n WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring}) AND $__timeFilter(created_date)\n GROUP BY pull_request_id\n) t\nGROUP BY `Review Rounds`\nORDER BY MIN(review_count)", + "refId": "A" + } + ], + "title": "Distribution of AI Review Rounds per PR", + "type": "barchart" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 45 + }, + "id": 200, + "panels": [], + "title": "Overview", + "type": "row" + }, + { + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 46 + }, + "id": 201, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "## How Well Does AI Predict CI Failures?\n\nThis dashboard answers a simple question: **when the AI code reviewer says a pull request is risky, does CI actually fail?** And the reverse \u2014 when the AI says everything looks fine, does CI agree?\n\nWe look at PRs that have both an AI review and OCP CI test results. Flaky tests (tests that fail randomly regardless of the code change) are excluded so we only count real failures.\n\n**What we're aiming for:**\n- The AI should correctly warn about at least **60% of PRs that will fail CI** \u2014 and rank failing PRs higher than passing ones 80% of the time\n- Both scores are currently shown as 0\u20131 (green = on target, red = needs improvement)\n\nUse the **Repository** and **AI Tool** dropdowns above to filter to a specific codebase or tool.", + "mode": "markdown" + }, + "pluginVersion": "11.6.2", + "title": "", + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 51 + }, + "id": 202, + "panels": [], + "title": "AI Warnings vs What Actually Happened", + "type": "row" + }, + { + "datasource": "mysql", + "description": "The AI flagged this PR as risky (at the selected warning threshold) and CI tests confirmed a failure. A true positive \u2014 the AI was right.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 0, + "y": 52 + }, + "id": 203, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) AS value\nFROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n AND risk_score >= ${warning_threshold}\n AND had_ci_failure = 1", + "refId": "A" + } + ], + "title": "AI Warned \u2192 CI Failed \u2713 (TP)", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "The AI flagged this PR as risky but CI tests passed. A false alarm \u2014 the AI was too cautious.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "orange", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 5, + "y": 52 + }, + "id": 204, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) AS value\nFROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n AND risk_score >= ${warning_threshold}\n AND had_ci_failure = 0", + "refId": "A" + } + ], + "title": "AI Warned \u2192 CI Passed \u2014 False Alarm (FP)", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "The AI gave this PR a clean bill of health, but CI tests failed. A missed failure \u2014 the AI was too lenient.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 10, + "y": 52 + }, + "id": 205, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) AS value\nFROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n AND risk_score < ${warning_threshold}\n AND had_ci_failure = 1", + "refId": "A" + } + ], + "title": "AI Missed \u2192 CI Failed \u2717 (FN)", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "The AI flagged this PR as safe and CI tests passed. A true negative \u2014 everything agreed.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 5, + "x": 15, + "y": 52 + }, + "id": 206, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) AS value\nFROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n AND risk_score < ${warning_threshold}\n AND had_ci_failure = 0", + "refId": "A" + } + ], + "title": "AI OK \u2192 CI Passed \u2713 (TN)", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 56 + }, + "id": 210, + "panels": [], + "title": "How Good Are the AI Predictions?", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Formula: Precision = TP / (TP + FP)\n\nOf every PR the AI flagged as risky, how many actually had CI failures? High precision means warnings are trustworthy \u2014 developers don't waste time on false alarms.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "orange", + "value": 40 + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "green", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 57 + }, + "id": 211, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "noValue": "n/a \u2014 AI made no warnings in this period", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.6.6", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "WITH counts AS (\n SELECT\n SUM(CASE WHEN risk_score >= ${warning_threshold} AND had_ci_failure = 1 THEN 1 ELSE 0 END) AS tp,\n SUM(CASE WHEN risk_score >= ${warning_threshold} AND had_ci_failure = 0 THEN 1 ELSE 0 END) AS fp\n FROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n)\nSELECT ROUND(tp * 100.0 / NULLIF(tp + fp, 0), 1) AS value FROM counts", + "refId": "A" + } + ], + "title": "When AI Warns \u2014 How Often Is It Right?", + "type": "gauge" + }, + { + "datasource": "mysql", + "description": "Formula: Recall = TP / (TP + FN)\n\nOf every PR that actually broke CI, how many did the AI flag in advance? High recall means fewer failures slip through undetected.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "orange", + "value": 40 + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "green", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 57 + }, + "id": 212, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "noValue": "n/a \u2014 no CI failures matched in this period", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.6.6", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "WITH counts AS (\n SELECT\n SUM(CASE WHEN risk_score >= ${warning_threshold} AND had_ci_failure = 1 THEN 1 ELSE 0 END) AS tp,\n SUM(CASE WHEN risk_score < ${warning_threshold} AND had_ci_failure = 1 THEN 1 ELSE 0 END) AS fn\n FROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n)\nSELECT ROUND(tp * 100.0 / NULLIF(tp + fn, 0), 1) AS value FROM counts", + "refId": "A" + } + ], + "title": "How Many Real Failures Did AI Catch?", + "type": "gauge" + }, + { + "datasource": "mysql", + "description": "Formula: F1 = 2\u00b7TP / (2\u00b7TP + FP + FN)\n\nThe harmonic mean of Precision and Recall \u2014 balances both in a single number. Useful when you care equally about false alarms and missed failures. 1.0 = perfect, 0.0 = completely wrong.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "orange", + "value": 40 + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "green", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 57 + }, + "id": 213, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "noValue": "n/a \u2014 no warnings or failures in this period", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.6.6", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "WITH counts AS (\n SELECT\n SUM(CASE WHEN risk_score >= ${warning_threshold} AND had_ci_failure = 1 THEN 1 ELSE 0 END) AS tp,\n SUM(CASE WHEN risk_score >= ${warning_threshold} AND had_ci_failure = 0 THEN 1 ELSE 0 END) AS fp,\n SUM(CASE WHEN risk_score < ${warning_threshold} AND had_ci_failure = 1 THEN 1 ELSE 0 END) AS fn\n FROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n)\nSELECT ROUND(2 * tp * 100.0 / NULLIF(2 * tp + fp + fn, 0), 1) AS value FROM counts", + "refId": "A" + } + ], + "title": "Overall Prediction Balance (F1)", + "type": "gauge" + }, + { + "datasource": "mysql", + "description": "Formula: Specificity = TN / (TN + FP)\n\nOf every PR that CI confirmed was safe, how many did the AI correctly leave alone? High specificity means developers working on low-risk PRs aren't being bothered with unnecessary warnings.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "orange", + "value": 40 + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "green", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 57 + }, + "id": 214, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "noValue": "n/a \u2014 no AI-reviewed PRs with CI results in this period", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.6.6", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "WITH counts AS (\n SELECT\n SUM(CASE WHEN risk_score < ${warning_threshold} AND had_ci_failure = 0 THEN 1 ELSE 0 END) AS tn,\n SUM(CASE WHEN risk_score >= ${warning_threshold} AND had_ci_failure = 0 THEN 1 ELSE 0 END) AS fp\n FROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n)\nSELECT ROUND(tn * 100.0 / NULLIF(tn + fp, 0), 1) AS value FROM counts", + "refId": "A" + } + ], + "title": "How Often Does AI Correctly Clear Safe PRs?", + "type": "gauge" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 64 + }, + "id": 220, + "panels": [], + "title": "Overall Prediction Quality \u2014 Single Scores That Don't Depend on Any Threshold", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Formula: PR-AUC = area under the Precision-Recall curve across all risk thresholds\n\nSummarises how well the AI balances catching real failures vs avoiding false alarms \u2014 across all three risk levels at once. 1.0 = perfect, 0.5 = no better than random. Target: \u2265 0.6.\n\nUnlike a single threshold metric, this score doesn't change when you adjust the warning threshold above.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 3, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "orange", + "value": 0.4 + }, + { + "color": "yellow", + "value": 0.6 + }, + { + "color": "green", + "value": 0.75 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 65 + }, + "id": 221, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "noValue": "n/a \u2014 need both AI warnings and CI failures to compute", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.6", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "WITH pr_data AS (\n SELECT risk_score AS max_risk_score, had_ci_failure AS ci_failed\n FROM ai_failure_predictions\n WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n),\nthresholds AS (SELECT 0 AS t UNION ALL SELECT 10 UNION ALL SELECT 20 UNION ALL SELECT 50 UNION ALL SELECT 80 UNION ALL SELECT 100),\ncurve AS (\n SELECT th.t AS threshold,\n SUM(CASE WHEN pd.max_risk_score >= th.t AND pd.ci_failed = 1 THEN 1 ELSE 0 END) AS tp,\n SUM(CASE WHEN pd.max_risk_score >= th.t AND pd.ci_failed = 0 THEN 1 ELSE 0 END) AS fp,\n SUM(CASE WHEN pd.max_risk_score < th.t AND pd.ci_failed = 1 THEN 1 ELSE 0 END) AS fn,\n SUM(CASE WHEN pd.max_risk_score < th.t AND pd.ci_failed = 0 THEN 1 ELSE 0 END) AS tn\n FROM thresholds th CROSS JOIN pr_data pd GROUP BY th.t\n),\npr_points AS (\n SELECT threshold,\n COALESCE(tp * 1.0 / NULLIF(tp + fp, 0), 1.0) AS prec,\n COALESCE(tp * 1.0 / NULLIF(tp + fn, 0), 0.0) AS rec\n FROM curve\n),\nlag_points AS (\n SELECT threshold, prec, rec,\n LAG(prec) OVER (ORDER BY threshold ASC) AS prev_prec,\n LAG(rec) OVER (ORDER BY threshold ASC) AS prev_rec\n FROM pr_points\n)\nSELECT ROUND(SUM(CASE WHEN prev_rec IS NOT NULL AND prev_rec > rec\n THEN (prev_rec - rec) * (prec + prev_prec) / 2 ELSE 0 END), 3) AS value\nFROM lag_points", + "refId": "A" + } + ], + "title": "Warning Quality Score (target: 0.6+)", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Formula: ROC-AUC = area under the ROC curve (TPR vs FPR) across all risk thresholds\n\nMeasures how well the AI separates failing PRs from safe ones regardless of where you draw the line. 1.0 = perfect separation, 0.5 = random. Target: \u2265 0.8.\n\nUnlike a single threshold metric, this score doesn't change when you adjust the warning threshold above.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 3, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "orange", + "value": 0.5 + }, + { + "color": "yellow", + "value": 0.7 + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 65 + }, + "id": 222, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "noValue": "n/a \u2014 need both AI warnings and CI failures to compute", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.6", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "WITH pr_data AS (\n SELECT risk_score AS max_risk_score, had_ci_failure AS ci_failed\n FROM ai_failure_predictions\n WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n),\nthresholds AS (SELECT 0 AS t UNION ALL SELECT 10 UNION ALL SELECT 20 UNION ALL SELECT 50 UNION ALL SELECT 80 UNION ALL SELECT 100),\ncurve AS (\n SELECT th.t AS threshold,\n SUM(CASE WHEN pd.max_risk_score >= th.t AND pd.ci_failed = 1 THEN 1 ELSE 0 END) AS tp,\n SUM(CASE WHEN pd.max_risk_score >= th.t AND pd.ci_failed = 0 THEN 1 ELSE 0 END) AS fp,\n SUM(CASE WHEN pd.max_risk_score < th.t AND pd.ci_failed = 1 THEN 1 ELSE 0 END) AS fn,\n SUM(CASE WHEN pd.max_risk_score < th.t AND pd.ci_failed = 0 THEN 1 ELSE 0 END) AS tn\n FROM thresholds th CROSS JOIN pr_data pd GROUP BY th.t\n),\nroc_points AS (\n SELECT threshold,\n COALESCE(fp * 1.0 / NULLIF(fp + tn, 0), 0.0) AS fpr,\n COALESCE(tp * 1.0 / NULLIF(tp + fn, 0), 0.0) AS tpr\n FROM curve\n),\nlag_points AS (\n SELECT threshold, fpr, tpr,\n LAG(fpr) OVER (ORDER BY threshold DESC) AS prev_fpr,\n LAG(tpr) OVER (ORDER BY threshold DESC) AS prev_tpr\n FROM roc_points\n)\nSELECT ROUND(SUM(CASE WHEN prev_fpr IS NOT NULL AND fpr > prev_fpr\n THEN (fpr - prev_fpr) * (tpr + prev_tpr) / 2 ELSE 0 END), 3) AS value\nFROM lag_points", + "refId": "A" + } + ], + "title": "Risk Ranking Quality (target: 0.8+)", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Formula: False Positive Rate (FPR) = FP / (FP + TN)\n\nOf all PRs that CI confirmed safe, what percentage did the AI flag as risky anyway? Lower is better \u2014 high values mean developers are distracted by warnings that don't matter.\n\n\u26a0\ufe0f Interpret with caution: this metric is sensitive to the warning threshold selected above and to the CI failure rate in the data. With only a few days of CI data where ~80% of matched PRs had failures, the denominator (FP + TN) is very small, making this number volatile. It will stabilise as more CI data accumulates.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 20 + }, + { + "color": "orange", + "value": 35 + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 65 + }, + "id": 223, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "noValue": "n/a \u2014 no AI-reviewed PRs with CI results in this period", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.6", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "WITH counts AS (\n SELECT\n SUM(CASE WHEN risk_score >= ${warning_threshold} AND had_ci_failure = 0 THEN 1 ELSE 0 END) AS fp,\n SUM(CASE WHEN risk_score < ${warning_threshold} AND had_ci_failure = 0 THEN 1 ELSE 0 END) AS tn\n FROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n)\nSELECT ROUND(fp * 100.0 / NULLIF(fp + tn, 0), 1) AS value FROM counts", + "refId": "A" + } + ], + "title": "How Often Does AI Cry Wolf?", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 71 + }, + "id": 240, + "panels": [], + "title": "Prediction Quality Split by Repository", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Prediction quality metrics broken down by repository. Only repos where AI reviews and CI data overlap are shown. Repos with no matching CI data are excluded.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Precision %" + }, + "properties": [ + { + "id": "unit", + "value": "none" + }, + { + "id": "custom.cellOptions", + "value": { + "mode": "gradient", + "type": "color-background" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 60 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Recall %" + }, + "properties": [ + { + "id": "unit", + "value": "none" + }, + { + "id": "custom.cellOptions", + "value": { + "mode": "gradient", + "type": "color-background" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 60 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "F1 %" + }, + "properties": [ + { + "id": "unit", + "value": "none" + }, + { + "id": "custom.cellOptions", + "value": { + "mode": "gradient", + "type": "color-background" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 60 + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 72 + }, + "id": 241, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "PRs with CI" + } + ] + }, + "pluginVersion": "11.6.6", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawQuery": true, + "rawSql": "WITH counts AS (\n SELECT\n repo_id,\n SUM(CASE WHEN risk_score >= ${warning_threshold} AND had_ci_failure = 1 THEN 1 ELSE 0 END) AS tp,\n SUM(CASE WHEN risk_score >= ${warning_threshold} AND had_ci_failure = 0 THEN 1 ELSE 0 END) AS fp,\n SUM(CASE WHEN risk_score < ${warning_threshold} AND had_ci_failure = 1 THEN 1 ELSE 0 END) AS fn,\n SUM(CASE WHEN risk_score < ${warning_threshold} AND had_ci_failure = 0 THEN 1 ELSE 0 END) AS tn\n FROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n GROUP BY repo_id\n)\nSELECT\n COALESCE(r.name, c.repo_id) AS Repository,\n c.tp + c.fp + c.fn + c.tn AS `PRs with CI`,\n c.tp AS `AI Warned \u2192 Failed (TP)`,\n c.fp AS `AI Warned \u2192 Passed (FP)`,\n c.fn AS `AI Missed \u2192 Failed (FN)`,\n c.tn AS `AI OK \u2192 Passed (TN)`,\n ROUND(100.0 * c.tp / NULLIF(c.tp + c.fp, 0), 1) AS `Precision %`,\n ROUND(100.0 * c.tp / NULLIF(c.tp + c.fn, 0), 1) AS `Recall %`,\n ROUND(100.0 * 2 * c.tp / NULLIF(2 * c.tp + c.fp + c.fn, 0), 1) AS `F1 %`\nFROM counts c\nLEFT JOIN repos r ON c.repo_id = r.id\nHAVING c.tp + c.fp + c.fn + c.tn > 0\nORDER BY `PRs with CI` DESC", + "refId": "A" + } + ], + "title": "Per-Repository Breakdown: AI Warnings vs CI Failures", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 82 + }, + "id": 230, + "panels": [], + "title": "What If We Used a Different Risk Level as the Warning Threshold?", + "type": "row" + }, + { + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 83 + }, + "id": 231, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "Right now, the AI flags a PR as risky when its risk score is 70 or above. But what if we raised or lowered that bar?\n\n- **Lower threshold (e.g. 30)**: AI warns more often \u2192 catches more real failures, but also more false alarms\n- **Higher threshold (e.g. 80)**: AI warns less often \u2192 fewer false alarms, but more failures slip through\n\nThe table below shows what the numbers would look like at every possible setting. Look at the **F1 %** column to find the threshold that gives the best overall balance for your team. The quality scores at the top of this page summarise the whole table as a single number.", + "mode": "markdown" + }, + "pluginVersion": "11.6.6", + "title": "", + "type": "text" + }, + { + "datasource": "mysql", + "description": "Each row shows what happens if you flag PRs as risky at a different sensitivity level. 'High only' is the most conservative \u2014 fewer warnings but more precise. 'Any risk (Low+)' catches the most failures but generates more false alarms. Use the F1 % column to find the best overall balance.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "threshold" + }, + "properties": [ + { + "id": "displayName", + "value": "Warning Sensitivity (0=warn all, 100=warn none)" + }, + { + "id": "custom.width", + "value": 260 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "precision_pct" + }, + "properties": [ + { + "id": "displayName", + "value": "When AI Warns \u2014 Right? %" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "orange", + "value": 40 + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "green", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "recall_pct" + }, + "properties": [ + { + "id": "displayName", + "value": "Failures Caught %" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "orange", + "value": 40 + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "green", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "f1_pct" + }, + "properties": [ + { + "id": "displayName", + "value": "Overall Balance %" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "orange", + "value": 40 + }, + { + "color": "yellow", + "value": 60 + }, + { + "color": "green", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "specificity_pct" + }, + "properties": [ + { + "id": "displayName", + "value": "Safe PRs Correctly Cleared %" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "fpr_pct" + }, + "properties": [ + { + "id": "displayName", + "value": "False Alarm Rate %" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 20 + }, + { + "color": "red", + "value": 40 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "tp" + }, + "properties": [ + { + "id": "displayName", + "value": "TP" + }, + { + "id": "custom.width", + "value": 60 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "fp" + }, + "properties": [ + { + "id": "displayName", + "value": "FP" + }, + { + "id": "custom.width", + "value": 60 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "fn" + }, + "properties": [ + { + "id": "displayName", + "value": "FN" + }, + { + "id": "custom.width", + "value": 60 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "tn" + }, + "properties": [ + { + "id": "displayName", + "value": "TN" + }, + { + "id": "custom.width", + "value": 60 + } + ] + } + ] + }, + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 87 + }, + "id": 232, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Warning Sensitivity (0=warn all, 100=warn none)" + } + ] + }, + "pluginVersion": "11.6.6", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "WITH pr_data AS (\n SELECT risk_score AS max_risk_score, had_ci_failure AS ci_failed\n FROM ai_failure_predictions\n WHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND prediction_outcome != ''\n AND ci_failure_source = '${ci_failure_source}'\n AND $__timeFilter(flagged_at)\n),\nlevels AS (\n SELECT 'High only' AS label, 80 AS min_score, 1 AS sort_order UNION ALL\n SELECT 'Medium or higher', 50, 2 UNION ALL\n SELECT 'Any risk (Low+)', 10, 3\n),\ncurve AS (\n SELECT l.label, l.sort_order,\n SUM(CASE WHEN pd.max_risk_score >= l.min_score AND pd.ci_failed = 1 THEN 1 ELSE 0 END) AS tp,\n SUM(CASE WHEN pd.max_risk_score >= l.min_score AND pd.ci_failed = 0 THEN 1 ELSE 0 END) AS fp,\n SUM(CASE WHEN pd.max_risk_score < l.min_score AND pd.ci_failed = 1 THEN 1 ELSE 0 END) AS fn,\n SUM(CASE WHEN pd.max_risk_score < l.min_score AND pd.ci_failed = 0 THEN 1 ELSE 0 END) AS tn\n FROM levels l CROSS JOIN pr_data pd\n GROUP BY l.label, l.sort_order, l.min_score\n)\nSELECT\n label AS `Flag as Risky When`,\n tp AS `AI Warned \u2192 Failed (TP)`,\n fp AS `AI Warned \u2192 Passed (FP)`,\n fn AS `AI Missed \u2192 Failed (FN)`,\n tn AS `AI OK \u2192 Passed (TN)`,\n ROUND(tp * 100.0 / NULLIF(tp + fp, 0), 1) AS `Precision %`,\n ROUND(tp * 100.0 / NULLIF(tp + fn, 0), 1) AS `Recall %`,\n ROUND(2 * tp * 100.0 / NULLIF(2 * tp + fp + fn, 0), 1) AS `F1 %`,\n ROUND(tn * 100.0 / NULLIF(tn + fp, 0), 1) AS `Specificity %`\nFROM curve\nORDER BY sort_order", + "refId": "A" + } + ], + "title": "AI Performance at Different Sensitivity Levels", + "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 101 + }, + "id": 500, + "title": "PR Drill-down \u2014 Individual Pull Requests", + "type": "row" + }, + { + "datasource": "mysql", + "description": "AI-reviewed PRs that had no matching CI pipeline data and cannot be classified as TP/FP/FN/TN.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "purple", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple" + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 52 + }, + "id": 501, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) AS value\nFROM ai_failure_predictions\nWHERE project_name = '${project}' AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND $__timeFilter(flagged_at)\n AND prediction_outcome = 'NO_CI'", + "refId": "A" + } + ], + "title": "PRs with No CI Data (?)", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Click a row to open the PR in GitHub. Use the Outcome filter above to focus on TP / FP / FN / TN / NO_CI. NO_CI rows are always shown regardless of the CI Source filter.", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "URL" + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Title" + }, + "properties": [ + { + "id": "custom.width", + "value": 400 + }, + { + "id": "links", + "value": [ + { + "title": "Open PR", + "url": "${__data.fields.URL}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "PR #" + }, + "properties": [ + { + "id": "custom.width", + "value": 70 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Risk Score" + }, + "properties": [ + { + "id": "custom.width", + "value": 90 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background", + "mode": "gradient" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Outcome" + }, + "properties": [ + { + "id": "custom.width", + "value": 75 + }, + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "TP": { + "color": "green", + "index": 0, + "text": "TP" + } + } + }, + { + "type": "value", + "options": { + "FP": { + "color": "orange", + "index": 1, + "text": "FP" + } + } + }, + { + "type": "value", + "options": { + "FN": { + "color": "red", + "index": 2, + "text": "FN" + } + } + }, + { + "type": "value", + "options": { + "TN": { + "color": "blue", + "index": 3, + "text": "TN" + } + } + }, + { + "type": "value", + "options": { + "NO_CI": { + "color": "purple", + "index": 4, + "text": "NO_CI" + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "AI Flagged" + }, + "properties": [ + { + "id": "custom.width", + "value": 90 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "CI Failed" + }, + "properties": [ + { + "id": "custom.width", + "value": 80 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "CI Source" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Reviewed At" + }, + "properties": [ + { + "id": "custom.width", + "value": 160 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Size" + }, + "properties": [ + { + "id": "custom.width", + "value": 90 + } + ] + } + ] + }, + "gridPos": { + "h": 16, + "w": 24, + "x": 0, + "y": 106 + }, + "id": 502, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Reviewed At" + } + ] + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT\n pull_request_key AS `PR #`,\n pr_title AS `Title`,\n repo_name AS `Repository`,\n pr_author AS `Author`,\n MAX(risk_score) AS `Risk Score`,\n CASE WHEN MAX(was_flagged_risky) THEN 'Yes' ELSE 'No' END AS `AI Flagged`,\n CASE WHEN MAX(had_ci_failure) THEN 'Yes' ELSE 'No' END AS `CI Failed`,\n CASE\n WHEN MAX(was_flagged_risky) = 1 AND MAX(had_ci_failure) = 1 THEN 'TP'\n WHEN MAX(was_flagged_risky) = 1 AND MAX(had_ci_failure) = 0 AND ci_failure_source != 'none' THEN 'FP'\n WHEN MAX(was_flagged_risky) = 0 AND MAX(had_ci_failure) = 1 THEN 'FN'\n WHEN MAX(was_flagged_risky) = 0 AND MAX(had_ci_failure) = 0 AND ci_failure_source != 'none' THEN 'TN'\n ELSE 'NO_CI'\n END AS `Outcome`,\n ci_failure_source AS `CI Source`,\n GROUP_CONCAT(DISTINCT ai_tool ORDER BY ai_tool SEPARATOR ', ') AS `AI Tools`,\n MAX(flagged_at) AS `Reviewed At`,\n CONCAT(MAX(additions), '+/', MAX(deletions), '-') AS `Size`,\n pr_url AS `URL`\nFROM ai_failure_predictions\nWHERE project_name = '${project}'\n AND repo_id IN (${repo_id:sqlstring})\n AND ai_tool IN (${ai_tool:sqlstring})\n AND $__timeFilter(flagged_at)\n AND (ci_failure_source = '${ci_failure_source}' OR prediction_outcome = 'NO_CI')\nGROUP BY pull_request_id, pull_request_key, repo_name, pr_title, pr_author, pr_url, ci_failure_source\nHAVING '${outcome_filter}' = 'All'\n OR (CASE\n WHEN MAX(was_flagged_risky) = 1 AND MAX(had_ci_failure) = 1 THEN 'TP'\n WHEN MAX(was_flagged_risky) = 1 AND MAX(had_ci_failure) = 0 AND ci_failure_source != 'none' THEN 'FP'\n WHEN MAX(was_flagged_risky) = 0 AND MAX(had_ci_failure) = 1 THEN 'FN'\n WHEN MAX(was_flagged_risky) = 0 AND MAX(had_ci_failure) = 0 AND ci_failure_source != 'none' THEN 'TN'\n ELSE 'NO_CI'\n END) = '${outcome_filter}'\nORDER BY `Reviewed At` DESC\nLIMIT 500", + "refId": "A" + } + ], + "title": "Pull Requests \u2014 Drill-down (filter by Outcome, Repo, AI Tool, Time)", + "type": "table" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 41, + "tags": [ + "ai", + "code-review", + "aireview", + "pr-auc", + "prediction", + "dprod-1142" + ], + "templating": { + "list": [ + { + "datasource": "mysql", + "definition": "SELECT DISTINCT project_name FROM ai_failure_predictions ORDER BY project_name", + "hide": 0, + "includeAll": false, + "label": "Project", + "multi": false, + "name": "project", + "options": [], + "query": "SELECT DISTINCT project_name FROM ai_failure_predictions ORDER BY project_name", + "refresh": 2, + "sort": 1, + "type": "query" + }, + { + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": "mysql", + "definition": "SELECT r.name AS __text, ar.repo_id AS __value FROM ai_reviews ar JOIN repos r ON ar.repo_id = r.id WHERE ar.project_name = '${project}' GROUP BY ar.repo_id, r.name ORDER BY r.name", + "includeAll": true, + "label": "Repository", + "multi": true, + "name": "repo_id", + "options": [], + "query": "SELECT r.name AS __text, ar.repo_id AS __value FROM ai_reviews ar JOIN repos r ON ar.repo_id = r.id WHERE ar.project_name = '${project}' GROUP BY ar.repo_id, r.name ORDER BY r.name", + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT ai_tool FROM ai_reviews WHERE project_name = '${project}' ORDER BY ai_tool", + "includeAll": true, + "label": "AI Tool", + "multi": true, + "name": "ai_tool", + "options": [], + "query": "SELECT DISTINCT ai_tool FROM ai_reviews WHERE project_name = '${project}' ORDER BY ai_tool", + "refresh": 1, + "regex": "", + "sort": 1, + "type": "query" + }, + { + "current": { + "text": "50", + "value": "50" + }, + "includeAll": false, + "label": "AI Warning Threshold", + "name": "warning_threshold", + "options": [ + { + "selected": false, + "text": "High only", + "value": "80" + }, + { + "selected": true, + "text": "Medium+", + "value": "50" + }, + { + "selected": false, + "text": "Low+", + "value": "10" + } + ], + "query": "High only : 80,Medium+ : 50,Low+ : 10", + "type": "custom" + }, + { + "current": { + "selected": true, + "text": "Test Cases (test_cases)", + "value": "test_cases" + }, + "hide": 0, + "includeAll": false, + "label": "CI Failure Source", + "name": "ci_failure_source", + "options": [ + { + "selected": true, + "text": "Test Cases (test_cases)", + "value": "test_cases" + }, + { + "selected": false, + "text": "Test Runs (job_result)", + "value": "job_result" + } + ], + "query": "test_cases,job_result", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + }, + { + "current": { + "selected": false, + "text": "All", + "value": "All" + }, + "hide": 0, + "includeAll": false, + "label": "Outcome", + "multi": false, + "name": "outcome_filter", + "options": [ + { + "selected": true, + "text": "All", + "value": "All" + }, + { + "selected": false, + "text": "TP \u2014 AI warned, CI failed", + "value": "TP" + }, + { + "selected": false, + "text": "FP \u2014 AI warned, CI passed", + "value": "FP" + }, + { + "selected": false, + "text": "FN \u2014 AI missed, CI failed", + "value": "FN" + }, + { + "selected": false, + "text": "TN \u2014 AI ok, CI passed", + "value": "TN" + }, + { + "selected": false, + "text": "NO_CI \u2014 no CI data", + "value": "NO_CI" + } + ], + "query": "All,TP,FP,FN,TN,NO_CI", + "type": "custom" + } + ] + }, + "time": { + "from": "now-90d", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "How Well Does AI Predict CI Failures?", + "uid": "ai-fpa-interactions", + "version": 1 +} diff --git a/grafana/dashboards/Codecov.json b/grafana/dashboards/Codecov.json new file mode 100644 index 00000000000..a2f22763374 --- /dev/null +++ b/grafana/dashboards/Codecov.json @@ -0,0 +1,756 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 44, + "links": [], + "panels": [ + { + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "- **Use Cases**: This dashboard shows code coverage metrics from Codecov organized by flags (test types).\n- **Data Source Required**: Codecov\n- **Metrics**: Overall Coverage (per flag), Lines Coverage (per flag), and Patch Coverage (repository-wide)\n- **Patch Coverage**: Shows how well changed code is tested. Calculated by Codecov from actual git diffs. Note: Patch coverage is the same for all flags as it measures repository-wide coverage of changed code.", + "mode": "markdown" + }, + "pluginVersion": "11.6.2", + "title": "Dashboard Introduction", + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 4 + }, + "id": 7, + "panels": [], + "title": "Test Name: ${flag_name}", + "type": "row" + }, + { + "datasource": "mysql", + "description": "Total code coverage percentage for the selected test type (flag). Represents: (lines covered / total lines) × 100%", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "green", + "value": 80 + } + ] + }, + "unit": "percent", + "decimals": 2 + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 5 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "text": {}, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT ROUND(t.coverage_percentage, 2) as value\nFROM _tool_codecov_coverage_trends t\nINNER JOIN project_mapping pm ON t.repo_id = pm.row_id AND pm.table = '_tool_codecov_repos'\nWHERE pm.project_name = '${project}'\n AND t.repo_id = '${repo_id}'\n AND t.flag_name = '${flag_name}'\nORDER BY t.date DESC\nLIMIT 1", + "refId": "A" + } + ], + "title": "Overall Coverage", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Total number of lines of code tracked by the selected test type (flag)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 6, + "y": 5 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "text": {}, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n COALESCE(\n (SELECT c.lines_total FROM _tool_codecov_coverages c INNER JOIN project_mapping pm ON c.repo_id = pm.row_id AND pm.table = '_tool_codecov_repos' WHERE pm.project_name = '${project}' AND c.repo_id = '${repo_id}' AND c.flag_name = '${flag_name}' AND c.lines_total > 0 ORDER BY c.commit_timestamp DESC LIMIT 1),\n 0\n ) as value", + "refId": "A" + } + ], + "title": "Total Lines", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Number of lines covered by the selected test type (flag)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 12, + "y": 5 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "text": {}, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n COALESCE(\n (SELECT c.lines_covered FROM _tool_codecov_coverages c INNER JOIN project_mapping pm ON c.repo_id = pm.row_id AND pm.table = '_tool_codecov_repos' WHERE pm.project_name = '${project}' AND c.repo_id = '${repo_id}' AND c.flag_name = '${flag_name}' AND c.lines_covered > 0 ORDER BY c.commit_timestamp DESC LIMIT 1),\n 0\n ) as value", + "refId": "A" + } + ], + "title": "Lines Covered", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Number of lines NOT covered by the selected test type (flag). Calculated as: Total Lines - Lines Covered", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 18, + "y": 5 + }, + "id": 11, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "text": {}, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n COALESCE(\n (SELECT (c.lines_total - c.lines_covered) FROM _tool_codecov_coverages c INNER JOIN project_mapping pm ON c.repo_id = pm.row_id AND pm.table = '_tool_codecov_repos' WHERE pm.project_name = '${project}' AND c.repo_id = '${repo_id}' AND c.flag_name = '${flag_name}' AND c.lines_total > 0 ORDER BY c.commit_timestamp DESC LIMIT 1),\n 0\n ) as value", + "refId": "A" + } + ], + "title": "Uncovered Lines", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Historical trend of overall coverage percentage for the selected test type (flag). Shows how total code coverage changes over time.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 2, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 11 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT\n dates.time_sec,\n ROUND(COALESCE(\n d.coverage_percentage,\n (SELECT t2.coverage_percentage FROM _tool_codecov_coverage_trends t2\n WHERE t2.repo_id = '${repo_id}' AND t2.flag_name = '${flag_name}'\n AND t2.date <= dates.date_val\n ORDER BY t2.date DESC LIMIT 1)\n ), 2) as value\nFROM (\n SELECT\n UNIX_TIMESTAMP(DATE_ADD(DATE($__timeFrom()), INTERVAL n DAY)) as time_sec,\n DATE_ADD(DATE($__timeFrom()), INTERVAL n DAY) as date_val\n FROM (\n SELECT a.N + b.N * 10 + c.N * 100 as n\n FROM (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a,\n (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b,\n (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3) c\n ) nums\n WHERE DATE_ADD(DATE($__timeFrom()), INTERVAL n DAY) <= DATE($__timeTo())\n) dates\nLEFT JOIN (\n SELECT t.date as date_val, t.coverage_percentage\n FROM _tool_codecov_coverage_trends t\n INNER JOIN project_mapping pm ON t.repo_id = pm.row_id AND pm.table = '_tool_codecov_repos'\n WHERE pm.project_name = '${project}'\n AND t.repo_id = '${repo_id}'\n AND t.flag_name = '${flag_name}'\n) d ON dates.date_val = d.date_val\nORDER BY dates.time_sec", + "refId": "A", + "timeColumn": "commit_timestamp" + } + ], + "title": "Overall Coverage Trend", + "type": "timeseries" + }, + { + "datasource": "mysql", + "description": "Repository-wide patch coverage from Codecov. Shows how well changed code is tested across all test types. This is calculated by Codecov from actual git diffs. Note: Same value for all flags since Codecov calculates patch coverage at repository level.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red" + }, + { + "color": "yellow", + "value": 0 + }, + { + "color": "green", + "value": 0.01 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 11 + }, + "id": 13, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "text": {}, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT ROUND(\n COALESCE(\n (SELECT AVG(patch_per_commit) FROM (\n SELECT comp.commit_sha, MAX(comp.patch) as patch_per_commit\n FROM _tool_codecov_comparisons comp\n INNER JOIN _tool_codecov_commits cm ON comp.connection_id = cm.connection_id AND comp.repo_id = cm.repo_id AND comp.commit_sha = cm.commit_sha\n WHERE comp.repo_id = '${repo_id}' AND comp.patch IS NOT NULL\n AND cm.commit_timestamp >= $__timeFrom() AND cm.commit_timestamp <= $__timeTo()\n GROUP BY comp.commit_sha\n ) sub),\n 0\n ), 2) as value", + "refId": "A" + } + ], + "title": "Patch Coverage", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Historical trend of lines covered vs total lines for the selected test type (flag). Blue line shows total lines tracked, green line shows lines covered.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Lines Covered" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Lines Total" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT\n dates.time_sec,\n ROUND(@last_covered := COALESCE(d.avg_covered, @last_covered), 0) as 'Lines Covered',\n ROUND(@last_total := COALESCE(d.avg_total, @last_total), 0) as 'Lines Total'\nFROM (\n SELECT\n UNIX_TIMESTAMP(DATE_ADD(DATE($__timeFrom()), INTERVAL n DAY)) as time_sec,\n DATE_ADD(DATE($__timeFrom()), INTERVAL n DAY) as date_val\n FROM (\n SELECT a.N + b.N * 10 + c.N * 100 as n\n FROM (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a,\n (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b,\n (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3) c\n ) nums\n WHERE DATE_ADD(DATE($__timeFrom()), INTERVAL n DAY) <= DATE($__timeTo())\n) dates\nLEFT JOIN (\n SELECT\n DATE(c.commit_timestamp) as date_val,\n AVG(c.lines_covered) as avg_covered,\n AVG(c.lines_total) as avg_total\n FROM _tool_codecov_coverages c\n INNER JOIN project_mapping pm ON c.repo_id = pm.row_id AND pm.table = '_tool_codecov_repos'\n WHERE pm.project_name = '${project}'\n AND c.repo_id = '${repo_id}'\n AND c.flag_name = '${flag_name}'\n AND c.lines_total > 0\n AND c.commit_timestamp >= $__timeFrom()\n AND c.commit_timestamp <= $__timeTo()\n GROUP BY DATE(c.commit_timestamp)\n) d ON dates.date_val = d.date_val\nCROSS JOIN (SELECT @last_covered := NULL, @last_total := NULL) init\nORDER BY dates.time_sec", + "refId": "A", + "timeColumn": "commit_timestamp" + } + ], + "title": "Lines Coverage Trend", + "type": "timeseries" + }, + { + "datasource": "mysql", + "description": "Daily repository-wide patch coverage from Codecov. Shows how well changed code is tested each day. Calculated by Codecov from actual git diffs. Note: Same value for all flags. Days without changes carry forward the last value.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 2, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 27 + }, + "id": 15, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.6.2", + "targets": [ + { + "datasource": "mysql", + "editorMode": "code", + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT\n dates.time_sec,\n ROUND(@last_val := COALESCE(d.daily_patch, @last_val, 0), 2) as value\nFROM (\n SELECT\n UNIX_TIMESTAMP(DATE_ADD(DATE($__timeFrom()), INTERVAL n DAY)) as time_sec,\n DATE_ADD(DATE($__timeFrom()), INTERVAL n DAY) as date_val\n FROM (\n SELECT a.N + b.N * 10 + c.N * 100 as n\n FROM (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a,\n (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b,\n (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3) c\n ) nums\n WHERE DATE_ADD(DATE($__timeFrom()), INTERVAL n DAY) <= DATE($__timeTo())\n) dates\nLEFT JOIN (\n SELECT\n DATE(commit_ts) as date_val,\n AVG(patch_per_commit) as daily_patch\n FROM (\n SELECT comp.commit_sha, cm.commit_timestamp as commit_ts, MAX(comp.patch) as patch_per_commit\n FROM _tool_codecov_comparisons comp\n INNER JOIN _tool_codecov_commits cm ON comp.connection_id = cm.connection_id AND comp.repo_id = cm.repo_id AND comp.commit_sha = cm.commit_sha\n WHERE comp.repo_id = '${repo_id}' AND comp.patch IS NOT NULL\n GROUP BY comp.commit_sha, cm.commit_timestamp\n ) sub\n GROUP BY DATE(commit_ts)\n) d ON dates.date_val = d.date_val\nCROSS JOIN (SELECT @last_val := 0) init\nORDER BY dates.time_sec", + "refId": "A", + "timeColumn": "commit_timestamp" + } + ], + "title": "Patch Coverage Trend", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "", + "schemaVersion": 41, + "tags": [ + "codecov", + "coverage", + "testing" + ], + "templating": { + "list": [ + { + "current": { + "text": "Konflux", + "value": "Konflux" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT name FROM projects ORDER BY name LIMIT 1", + "includeAll": false, + "label": "Project", + "name": "project", + "options": [], + "query": "SELECT DISTINCT name FROM projects ORDER BY name", + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "current": { + "text": "konflux-ci/konflux-ui", + "value": "konflux-ci/konflux-ui" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT c.repo_id FROM _tool_codecov_coverages c INNER JOIN project_mapping pm ON c.repo_id = pm.row_id AND pm.table = '_tool_codecov_repos' WHERE pm.project_name = '${project}' ORDER BY c.repo_id LIMIT 1", + "includeAll": false, + "label": "Repository", + "name": "repo_id", + "options": [], + "query": "SELECT DISTINCT c.repo_id FROM _tool_codecov_coverages c INNER JOIN project_mapping pm ON c.repo_id = pm.row_id AND pm.table = '_tool_codecov_repos' WHERE pm.project_name = '${project}' ORDER BY c.repo_id", + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "current": { + "text": "unittests", + "value": "unittests" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT c.flag_name FROM _tool_codecov_coverages c INNER JOIN project_mapping pm ON c.repo_id = pm.row_id AND pm.table = '_tool_codecov_repos' WHERE pm.project_name = '${project}' AND c.repo_id = '${repo_id}' AND c.flag_name != '' AND c.flag_name IS NOT NULL ORDER BY c.flag_name LIMIT 1", + "includeAll": false, + "label": "Test Name", + "name": "flag_name", + "options": [], + "query": "SELECT DISTINCT c.flag_name FROM _tool_codecov_coverages c INNER JOIN project_mapping pm ON c.repo_id = pm.row_id AND pm.table = '_tool_codecov_repos' WHERE pm.project_name = '${project}' AND c.repo_id = '${repo_id}' AND c.flag_name != '' AND c.flag_name IS NOT NULL ORDER BY c.flag_name", + "refresh": 1, + "regex": "", + "type": "query" + } + ] + }, + "time": { + "from": "now-30d", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "Codecov", + "uid": "df5acb8mgy70ga", + "version": 1 +} \ No newline at end of file diff --git a/grafana/dashboards/JenkinsEnhanced.json b/grafana/dashboards/JenkinsEnhanced.json new file mode 100644 index 00000000000..caccde673c0 --- /dev/null +++ b/grafana/dashboards/JenkinsEnhanced.json @@ -0,0 +1,812 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "content": "# Jenkins Failure Analysis Dashboard\n\n- **Purpose**: Deep dive into build failures with direct links to console logs and stage-level analysis\n- **Features**: Failure trends, MTTR, stage failure patterns, console log access\n- **Data Source**: Jenkins builds collected via DevLake", + "mode": "markdown" + }, + "pluginVersion": "9.5.15", + "title": "Dashboard Introduction", + "type": "text" + }, + { + "datasource": "mysql", + "description": "Total number of jobs (showing latest build per job)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 3 + }, + "id": 100, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "SELECT\n COUNT(*) as 'Total Builds'\nFROM _tool_jenkins_builds b\nINNER JOIN (\n SELECT job_name, MAX(start_time) as max_time\n FROM _tool_jenkins_builds\n WHERE job_name IN (${job_id:sqlstring})\n GROUP BY job_name\n) latest ON b.job_name = latest.job_name AND b.start_time = latest.max_time", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "timeColumn": "start_time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "\ud83d\udcca Total Builds", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Number of Jobs passed in latest run = SUCCESS or UNSTABLE (UNSTABLE counted as success)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 3 + }, + "id": 101, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "SELECT\n COUNT(*) as 'Successful Builds'\nFROM _tool_jenkins_builds b\nINNER JOIN (\n SELECT job_name, MAX(start_time) as max_time\n FROM _tool_jenkins_builds\n WHERE job_name IN (${job_id:sqlstring})\n GROUP BY job_name\n) latest ON b.job_name = latest.job_name AND b.start_time = latest.max_time\nWHERE b.result IN ('SUCCESS', 'UNSTABLE')", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "timeColumn": "start_time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "\u2705 Successful Builds[Latest Runs]", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Number of Jobs failed in latest run = FAILURE status", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 3 + }, + "id": 102, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "SELECT\n COUNT(*) as 'Failed Builds'\nFROM _tool_jenkins_builds b\nINNER JOIN (\n SELECT job_name, MAX(start_time) as max_time\n FROM _tool_jenkins_builds\n WHERE job_name IN (${job_id:sqlstring})\n GROUP BY job_name\n) latest ON b.job_name = latest.job_name AND b.start_time = latest.max_time\nWHERE b.result = 'FAILURE'", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "timeColumn": "start_time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "\u274c Failed Builds[Latest Runs]", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Percentage of jobs with latest build = SUCCESS or UNSTABLE", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "green", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 3 + }, + "id": 103, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "SELECT\n ROUND(SUM(CASE WHEN b.result IN ('SUCCESS', 'UNSTABLE') THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as 'Success Rate'\nFROM _tool_jenkins_builds b\nINNER JOIN (\n SELECT job_name, MAX(start_time) as max_time\n FROM _tool_jenkins_builds\n WHERE job_name IN (${job_id:sqlstring})\n GROUP BY job_name\n) latest ON b.job_name = latest.job_name AND b.start_time = latest.max_time", + "refId": "A", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "column" + } + ] + ], + "timeColumn": "start_time", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "\ud83d\udcc8 Build Success Rate", + "type": "stat" + }, + { + "datasource": "mysql", + "description": "Most recent build for each selected job", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "console_url" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Console", + "url": "${__value.text}" + } + ] + }, + { + "id": "custom.displayMode", + "value": "color-background" + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 7 + }, + "id": 2, + "options": { + "footer": { + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "SELECT b.job_name as 'Job Name', b.number as 'Build #', b.result as 'Result', DATE_FORMAT(b.start_time, '%Y-%m-%d %H:%i') as 'Started', ROUND(b.duration/1000/60, 1) as 'Duration (min)', CONCAT(c.endpoint, '/job/', b.job_name, '/', b.number, '/console') as console_url FROM _tool_jenkins_builds b INNER JOIN _tool_jenkins_connections c ON b.connection_id = c.id INNER JOIN ( SELECT job_name, MAX(start_time) as max_time FROM _tool_jenkins_builds WHERE job_name IN (${job_id:sqlstring}) GROUP BY job_name ) latest ON b.job_name = latest.job_name AND b.start_time = latest.max_time ORDER BY b.start_time DESC", + "refId": "A", + "select": [ + [ + { + "params": [ + "duration_sec" + ], + "type": "column" + } + ] + ], + "table": "_tool_jenkins_builds", + "timeColumn": "start_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Build Results - From latest Job executions", + "type": "table" + }, + { + "datasource": "mysql", + "description": "Most frequently failing stages across latest builds only", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 5 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 17 + }, + "id": 3, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "WITH latest_builds AS (\n SELECT job_name, MAX(start_time) as max_time\n FROM _tool_jenkins_builds\n WHERE job_name IN (${job_id:sqlstring})\n GROUP BY job_name\n),\nlatest_failed_builds AS (\n SELECT b.*\n FROM _tool_jenkins_builds b\n INNER JOIN latest_builds lb ON b.job_name = lb.job_name AND b.start_time = lb.max_time\n WHERE b.result = 'FAILURE'\n)\nSELECT \n s.name as 'Stage Name',\n COUNT(*) as 'Failure Count'\nFROM _tool_jenkins_stages s\nINNER JOIN latest_failed_builds b ON s.build_name = b.full_name\nWHERE s.status = 'FAILED'\nGROUP BY s.name\nORDER BY COUNT(*) DESC\nLIMIT 15", + "refId": "A", + "select": [ + [ + { + "params": [ + "id" + ], + "type": "column" + } + ] + ], + "table": "_tool_jenkins_stages", + "timeColumn": "start_time_millis", + "timeColumnType": "bigint", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Top Failing Stages", + "type": "barchart" + }, + { + "datasource": "mysql", + "description": "Failure rate trend over time", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 30, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 17 + }, + "id": 4, + "options": { + "legend": { + "calcs": [ + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "format": "time_series", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "SELECT \n DATE(start_time) as time,\n job_name as metric,\n (SUM(CASE WHEN result='FAILURE' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) as value\nFROM _tool_jenkins_builds\nWHERE $__timeFilter(start_time)\n AND job_name in (${job_id:sqlstring})\nGROUP BY DATE(start_time), job_name\nORDER BY time", + "refId": "A", + "select": [ + [ + { + "params": [ + "id" + ], + "type": "column" + } + ] + ], + "table": "_tool_jenkins_builds", + "timeColumn": "start_time", + "timeColumnType": "timestamp", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Daily Failure Rate (%)", + "type": "timeseries" + }, + { + "datasource": "mysql", + "description": "Failed stages only from jobs where the latest build is FAILURE", + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "displayMode": "auto", + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Failed Stage Log" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Console", + "url": "${__value.text}" + } + ] + }, + { + "id": "custom.displayMode", + "value": "color-background" + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 25 + }, + "id": 5, + "options": { + "footer": { + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "9.5.15", + "targets": [ + { + "datasource": "mysql", + "format": "table", + "group": [], + "metricColumn": "none", + "rawQuery": true, + "rawSql": "WITH latest_builds AS ( SELECT job_name, MAX(start_time) as max_time FROM _tool_jenkins_builds WHERE job_name IN (${job_id:sqlstring}) GROUP BY job_name ), latest_failed_builds AS ( SELECT b.* FROM _tool_jenkins_builds b INNER JOIN latest_builds lb ON b.job_name = lb.job_name AND b.start_time = lb.max_time WHERE b.result = 'FAILURE' ), numbered_stages AS ( SELECT b.job_name, b.number, b.connection_id, b.start_time, b.full_name, s.name AS stage_name, s.status AS stage_status, s.duration_millis, s.start_time_millis, LPAD(ROW_NUMBER() OVER (PARTITION BY b.full_name ORDER BY s.start_time_millis) - 1, 2, '0') AS stage_num FROM latest_failed_builds b INNER JOIN _tool_jenkins_stages s ON b.full_name = s.build_name ) SELECT ns.job_name AS 'Job', ns.stage_name AS 'Failed Stage', ROUND(ns.duration_millis/1000/60, 1) AS 'Stage Duration (min)', DATE_FORMAT(FROM_UNIXTIME(ns.start_time_millis/1000), '%Y-%m-%d %H:%i') AS 'Failed At', CONCAT(c.endpoint, '/job/', ns.job_name, '/', ns.number, '/artifact/.sh/', ns.stage_num, '-', ns.stage_name, '.log') AS 'Failed Stage Log' FROM numbered_stages ns INNER JOIN _tool_jenkins_connections c ON ns.connection_id = c.id WHERE ns.stage_status = 'FAILED' ORDER BY ns.start_time DESC, ns.start_time_millis ASC LIMIT 50", + "refId": "A", + "select": [ + [ + { + "params": [ + "name" + ], + "type": "column" + } + ] + ], + "table": "_tool_jenkins_stages", + "timeColumn": "start_time_millis", + "timeColumnType": "bigint", + "where": [ + { + "name": "$__timeFilter", + "params": [], + "type": "macro" + } + ] + } + ], + "title": "Failed builds detailed analysis", + "type": "table" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "Jenkins", + "CI/CD", + "Failures" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": "All", + "value": "$__all" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT job_name FROM _tool_jenkins_builds", + "hide": 0, + "includeAll": true, + "label": "Job", + "multi": true, + "name": "job_id", + "options": [], + "query": "SELECT DISTINCT job_name FROM _tool_jenkins_builds", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-30d", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Jenkins Enhanced - Failure Analysis", + "uid": "jenkins-enhanced", + "version": 1, + "weekStart": "" +} diff --git a/grafana/dashboards/OpenshiftCI.json b/grafana/dashboards/OpenshiftCI.json new file mode 100644 index 00000000000..99108c36dd8 --- /dev/null +++ b/grafana/dashboards/OpenshiftCI.json @@ -0,0 +1,1060 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "refresh": "", + "schemaVersion": 39, + "tags": ["openshift", "ci", "prow", "testing"], + "time": { + "from": "now-90d", + "to": "now" + }, + "timepicker": {}, + "timezone": "utc", + "title": "OpenShift CI", + "uid": "openshift-ci-dashboard", + "version": 1, + "weekStart": "", + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": "All", + "value": "$__all" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT scope_id AS __text, scope_id AS __value FROM ci_test_jobs ORDER BY scope_id", + "hide": 0, + "includeAll": true, + "label": "Repository", + "multi": true, + "name": "repository", + "options": [], + "query": "SELECT DISTINCT scope_id AS __text, scope_id AS __value FROM ci_test_jobs ORDER BY scope_id", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "current": { + "selected": true, + "text": "All", + "value": "$__all" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT trigger_type AS __text, trigger_type AS __value FROM ci_test_jobs WHERE scope_id IN (${repository:sqlstring}) ORDER BY trigger_type", + "hide": 0, + "includeAll": true, + "label": "Trigger Type", + "multi": true, + "name": "trigger_type", + "options": [], + "query": "SELECT DISTINCT trigger_type AS __text, trigger_type AS __value FROM ci_test_jobs WHERE scope_id IN (${repository:sqlstring}) ORDER BY trigger_type", + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + }, + { + "current": { + "selected": true, + "text": "All", + "value": "$__all" + }, + "datasource": "mysql", + "definition": "SELECT DISTINCT job_name AS __text, job_name AS __value FROM ci_test_jobs WHERE scope_id IN (${repository:sqlstring}) AND trigger_type IN (${trigger_type:sqlstring}) ORDER BY job_name", + "hide": 0, + "includeAll": true, + "label": "Job Name", + "multi": true, + "name": "job_name", + "options": [], + "query": "SELECT DISTINCT job_name AS __text, job_name AS __value FROM ci_test_jobs WHERE scope_id IN (${repository:sqlstring}) AND trigger_type IN (${trigger_type:sqlstring}) ORDER BY job_name", + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "type": "query" + } + ] + }, + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "Overview", + "type": "row" + }, + { + "datasource": "mysql", + "type": "stat", + "title": "Total Jobs", + "id": 1, + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "blue", "value": null }] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) as total_jobs FROM ci_test_jobs WHERE scope_id IN (${repository:sqlstring}) AND trigger_type IN (${trigger_type:sqlstring}) AND job_name IN (${job_name:sqlstring}) AND $__timeFilter(finished_at)", + "refId": "A" + } + ] + }, + { + "datasource": "mysql", + "type": "stat", + "title": "Job Pass Rate", + "id": 2, + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 50 }, + { "color": "yellow", "value": 75 }, + { "color": "green", "value": 90 } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT ROUND(SUM(CASE WHEN result = 'SUCCESS' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as pass_rate FROM ci_test_jobs WHERE scope_id IN (${repository:sqlstring}) AND trigger_type IN (${trigger_type:sqlstring}) AND job_name IN (${job_name:sqlstring}) AND $__timeFilter(finished_at)", + "refId": "A" + } + ] + }, + { + "datasource": "mysql", + "type": "stat", + "title": "Total Test Cases", + "id": 3, + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "blue", "value": null }] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) as total_tests FROM ci_test_cases tc JOIN ci_test_jobs j ON tc.connection_id = j.connection_id AND tc.job_id = j.job_id WHERE j.scope_id IN (${repository:sqlstring}) AND j.trigger_type IN (${trigger_type:sqlstring}) AND j.job_name IN (${job_name:sqlstring}) AND $__timeFilter(j.finished_at)", + "refId": "A" + } + ] + }, + { + "datasource": "mysql", + "type": "stat", + "title": "Test Pass Rate", + "id": 4, + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 50 }, + { "color": "yellow", "value": 75 }, + { "color": "green", "value": 90 } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT ROUND(SUM(CASE WHEN tc.status = 'passed' THEN 1 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0), 1) as pass_rate FROM ci_test_cases tc JOIN ci_test_jobs j ON tc.connection_id = j.connection_id AND tc.job_id = j.job_id WHERE tc.status IN ('passed', 'failed') AND j.scope_id IN (${repository:sqlstring}) AND j.trigger_type IN (${trigger_type:sqlstring}) AND j.job_name IN (${job_name:sqlstring}) AND $__timeFilter(j.finished_at)", + "refId": "A" + } + ] + }, + { + "datasource": "mysql", + "type": "stat", + "title": "Failed Tests", + "id": 5, + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 10 }, + { "color": "orange", "value": 50 }, + { "color": "red", "value": 100 } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT COUNT(*) as failed_tests FROM ci_test_cases tc JOIN ci_test_jobs j ON tc.connection_id = j.connection_id AND tc.job_id = j.job_id WHERE tc.status = 'failed' AND j.scope_id IN (${repository:sqlstring}) AND j.trigger_type IN (${trigger_type:sqlstring}) AND j.job_name IN (${job_name:sqlstring}) AND $__timeFilter(j.finished_at)", + "refId": "A" + } + ] + }, + { + "datasource": "mysql", + "type": "stat", + "title": "JUnit Coverage", + "id": 6, + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 25 }, + { "color": "yellow", "value": 50 }, + { "color": "green", "value": 75 } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT ROUND(COUNT(DISTINCT s.job_id) * 100.0 / NULLIF(COUNT(DISTINCT j.job_id), 0), 1) as junit_coverage FROM ci_test_jobs j LEFT JOIN ci_test_suites s ON j.connection_id = s.connection_id AND j.job_id = s.job_id WHERE j.scope_id IN (${repository:sqlstring}) AND j.trigger_type IN (${trigger_type:sqlstring}) AND j.job_name IN (${job_name:sqlstring}) AND $__timeFilter(j.finished_at)", + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 101, + "title": "Job Results Over Time", + "type": "row" + }, + { + "datasource": "mysql", + "type": "timeseries", + "title": "Job Results Timeline", + "id": 7, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Jobs", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "SUCCESS" }, + "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "FAILURE" }, + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "ABORTED" }, + "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] + } + ] + }, + "options": { + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT DATE(finished_at) as time, result, COUNT(*) as count FROM ci_test_jobs WHERE finished_at IS NOT NULL AND scope_id IN (${repository:sqlstring}) AND trigger_type IN (${trigger_type:sqlstring}) AND job_name IN (${job_name:sqlstring}) AND $__timeFilter(finished_at) GROUP BY DATE(finished_at), result ORDER BY time", + "refId": "A" + } + ], + "transformations": [ + { "id": "prepareTimeSeries", "options": { "format": "multi" } } + ] + }, + { + "datasource": "mysql", + "type": "timeseries", + "title": "Job Pass Rate Trend", + "id": 8, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Pass Rate %", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "max": 100, + "min": 0, + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [] + }, + "options": { + "legend": { "calcs": ["mean"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": "mysql", + "format": "time_series", + "rawSql": "SELECT DATE(finished_at) as time, ROUND(SUM(CASE WHEN result = 'SUCCESS' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as pass_rate FROM ci_test_jobs WHERE finished_at IS NOT NULL AND scope_id IN (${repository:sqlstring}) AND trigger_type IN (${trigger_type:sqlstring}) AND job_name IN (${job_name:sqlstring}) AND $__timeFilter(finished_at) GROUP BY DATE(finished_at) ORDER BY time", + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "id": 102, + "title": "Test Failures Analysis", + "type": "row" + }, + { + "datasource": "mysql", + "type": "table", + "title": "Top Failing Tests", + "id": 9, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "fail_rate" }, + "properties": [ + { "id": "unit", "value": "percent" }, + { "id": "custom.cellOptions", "value": { "mode": "basic", "type": "gauge" } }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 25 }, + { "color": "orange", "value": 50 }, + { "color": "red", "value": 75 } + ] + } + } + ] + }, + { + "matcher": { "id": "byName", "options": "test_name" }, + "properties": [ + { "id": "custom.width", "value": 350 }, + { "id": "custom.cellOptions", "value": { "type": "auto", "wrapText": true } } + ] + } + ] + }, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "fail_count" }] + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT tc.name as test_name, SUM(CASE WHEN tc.status = 'failed' THEN 1 ELSE 0 END) as fail_count, SUM(CASE WHEN tc.status = 'passed' THEN 1 ELSE 0 END) as pass_count, ROUND(SUM(CASE WHEN tc.status = 'failed' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as fail_rate, COUNT(DISTINCT j.scope_id) as repos_affected FROM ci_test_cases tc JOIN ci_test_jobs j ON tc.connection_id = j.connection_id AND tc.job_id = j.job_id WHERE j.scope_id IN (${repository:sqlstring}) AND j.trigger_type IN (${trigger_type:sqlstring}) AND j.job_name IN (${job_name:sqlstring}) AND tc.status IN ('passed', 'failed') AND $__timeFilter(j.finished_at) GROUP BY tc.name HAVING fail_count > 0 ORDER BY fail_count DESC LIMIT 50", + "refId": "A" + } + ] + }, + { + "datasource": "mysql", + "type": "timeseries", + "title": "Test Failure Trend", + "id": 10, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Failed Tests", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [] + }, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT DATE(j.finished_at) as time, j.scope_id as repository, COUNT(*) as failed_count FROM ci_test_cases tc JOIN ci_test_jobs j ON tc.connection_id = j.connection_id AND tc.job_id = j.job_id WHERE tc.status = 'failed' AND j.finished_at IS NOT NULL AND j.scope_id IN (${repository:sqlstring}) AND j.trigger_type IN (${trigger_type:sqlstring}) AND j.job_name IN (${job_name:sqlstring}) AND $__timeFilter(j.finished_at) GROUP BY DATE(j.finished_at), j.scope_id ORDER BY time", + "refId": "A" + } + ], + "transformations": [ + { "id": "prepareTimeSeries", "options": { "format": "multi" } } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, + "id": 103, + "title": "Flaky Test Detection", + "type": "row" + }, + { + "datasource": "mysql", + "type": "table", + "title": "Flaky Tests", + "description": "Tests that have both passed and failed, sorted by inconsistency ratio", + "id": 11, + "gridPos": { "h": 8, "w": 14, "x": 0, "y": 24 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "flakiness" }, + "properties": [ + { "id": "unit", "value": "percent" }, + { "id": "custom.cellOptions", "value": { "mode": "basic", "type": "gauge" } }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 20 }, + { "color": "orange", "value": 40 }, + { "color": "red", "value": 60 } + ] + } + } + ] + }, + { + "matcher": { "id": "byName", "options": "test_name" }, + "properties": [ + { "id": "custom.width", "value": 400 }, + { "id": "custom.cellOptions", "value": { "type": "auto", "wrapText": true } } + ] + } + ] + }, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "flakiness" }] + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT tc.name as test_name, COUNT(*) as total_runs, SUM(CASE WHEN tc.status = 'passed' THEN 1 ELSE 0 END) as passes, SUM(CASE WHEN tc.status = 'failed' THEN 1 ELSE 0 END) as failures, ROUND(2.0 * LEAST(SUM(CASE WHEN tc.status = 'passed' THEN 1 ELSE 0 END), SUM(CASE WHEN tc.status = 'failed' THEN 1 ELSE 0 END)) * 100.0 / COUNT(*), 1) as flakiness FROM ci_test_cases tc JOIN ci_test_jobs j ON tc.connection_id = j.connection_id AND tc.job_id = j.job_id WHERE tc.status IN ('passed', 'failed') AND j.scope_id IN (${repository:sqlstring}) AND j.trigger_type IN (${trigger_type:sqlstring}) AND j.job_name IN (${job_name:sqlstring}) AND $__timeFilter(j.finished_at) GROUP BY tc.name HAVING passes > 0 AND failures > 0 ORDER BY flakiness DESC LIMIT 50", + "refId": "A" + } + ] + }, + { + "datasource": "mysql", + "type": "piechart", + "title": "Failure by Trigger Type", + "id": 12, + "gridPos": { "h": 8, "w": 10, "x": 14, "y": 24 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "hideFrom": { "legend": false, "tooltip": false, "viz": false } + }, + "mappings": [] + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "pull_request" }, + "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "periodic" }, + "properties": [{ "id": "color", "value": { "fixedColor": "purple", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "push" }, + "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] + } + ] + }, + "options": { + "legend": { "displayMode": "list", "placement": "right", "showLegend": true }, + "pieType": "donut", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": true }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT j.trigger_type, COUNT(*) as count FROM ci_test_cases tc JOIN ci_test_jobs j ON tc.connection_id = j.connection_id AND tc.job_id = j.job_id WHERE tc.status = 'failed' AND j.scope_id IN (${repository:sqlstring}) AND j.trigger_type IN (${trigger_type:sqlstring}) AND j.job_name IN (${job_name:sqlstring}) AND $__timeFilter(j.finished_at) GROUP BY j.trigger_type", + "refId": "A" + } + ], + "transformations": [ + { + "id": "rowsToFields", + "options": { + "mappings": [ + { "fieldName": "trigger_type", "handlerKey": "field.name" }, + { "fieldName": "count", "handlerKey": "field.value" } + ] + } + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 }, + "id": 104, + "title": "PR Correlation - Failure Impact", + "type": "row" + }, + { + "datasource": "mysql", + "type": "table", + "title": "PRs with Most Failures", + "id": 13, + "gridPos": { "h": 8, "w": 14, "x": 0, "y": 33 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "fail_rate" }, + "properties": [ + { "id": "unit", "value": "percent" }, + { "id": "custom.cellOptions", "value": { "mode": "basic", "type": "gauge" } }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 25 }, + { "color": "orange", "value": 50 }, + { "color": "red", "value": 75 } + ] + } + } + ] + }, + { + "matcher": { "id": "byName", "options": "prow_link" }, + "properties": [ + { + "id": "links", + "value": [{ "targetBlank": true, "title": "Open in Prow", "url": "${__value.text}" }] + }, + { "id": "custom.width", "value": 80 }, + { "id": "displayName", "value": "Prow" } + ] + } + ] + }, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "failed_jobs" }] + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT pull_request_number as pr_number, pull_request_author as author, scope_id as repository, COUNT(*) as total_jobs, SUM(CASE WHEN result = 'FAILURE' THEN 1 ELSE 0 END) as failed_jobs, ROUND(SUM(CASE WHEN result = 'FAILURE' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as fail_rate, MIN(view_url) as prow_link FROM ci_test_jobs WHERE pull_request_number IS NOT NULL AND pull_request_number > 0 AND scope_id IN (${repository:sqlstring}) AND trigger_type IN (${trigger_type:sqlstring}) AND job_name IN (${job_name:sqlstring}) AND $__timeFilter(finished_at) GROUP BY pull_request_number, pull_request_author, scope_id HAVING failed_jobs > 0 ORDER BY failed_jobs DESC LIMIT 50", + "refId": "A" + } + ] + }, + { + "datasource": "mysql", + "type": "barchart", + "title": "PR Failure Rate by Author", + "description": "Authors with highest failure rates (min 3 PRs)", + "id": 14, + "gridPos": { "h": 8, "w": 10, "x": 14, "y": 33 }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-RdYlGr", + "seriesBy": "last" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Failure Rate %", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineWidth": 1, + "scaleDistribution": { "type": "linear" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "max": 100, + "min": 0, + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [] + }, + "options": { + "barRadius": 0.1, + "barWidth": 0.7, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": false }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { "mode": "single", "sort": "none" }, + "xTickLabelRotation": 0 + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT pull_request_author as author, ROUND(SUM(CASE WHEN result = 'FAILURE' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) as fail_rate FROM ci_test_jobs WHERE pull_request_number IS NOT NULL AND pull_request_number > 0 AND pull_request_author IS NOT NULL AND pull_request_author != '' AND scope_id IN (${repository:sqlstring}) AND trigger_type IN (${trigger_type:sqlstring}) AND job_name IN (${job_name:sqlstring}) AND $__timeFilter(finished_at) GROUP BY pull_request_author HAVING COUNT(DISTINCT pull_request_number) >= 3 ORDER BY fail_rate DESC LIMIT 15", + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 41 }, + "id": 105, + "title": "Job Duration & Performance", + "type": "row" + }, + { + "datasource": "mysql", + "type": "barchart", + "title": "Job Duration Distribution", + "id": 15, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 42 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Job Count", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineWidth": 1, + "scaleDistribution": { "type": "linear" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [] + }, + "options": { + "barRadius": 0.1, + "barWidth": 0.8, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": false }, + "orientation": "vertical", + "showValue": "auto", + "stacking": "none", + "tooltip": { "mode": "single", "sort": "none" }, + "xTickLabelRotation": 0 + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT duration_bucket, job_count FROM (SELECT CASE WHEN duration_sec < 300 THEN '< 5 min' WHEN duration_sec < 600 THEN '5-10 min' WHEN duration_sec < 1800 THEN '10-30 min' WHEN duration_sec < 3600 THEN '30-60 min' WHEN duration_sec < 7200 THEN '1-2 hours' ELSE '> 2 hours' END as duration_bucket, CASE WHEN duration_sec < 300 THEN 1 WHEN duration_sec < 600 THEN 2 WHEN duration_sec < 1800 THEN 3 WHEN duration_sec < 3600 THEN 4 WHEN duration_sec < 7200 THEN 5 ELSE 6 END as sort_order, COUNT(*) as job_count FROM ci_test_jobs WHERE duration_sec IS NOT NULL AND scope_id IN (${repository:sqlstring}) AND trigger_type IN (${trigger_type:sqlstring}) AND job_name IN (${job_name:sqlstring}) AND $__timeFilter(finished_at) GROUP BY duration_bucket, sort_order ORDER BY sort_order) t ORDER BY t.sort_order", + "refId": "A" + } + ] + }, + { + "datasource": "mysql", + "type": "table", + "title": "Slowest Failing Jobs", + "id": 16, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 42 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "avg_duration_min" }, + "properties": [ + { "id": "displayName", "value": "Avg Duration (min)" }, + { "id": "custom.cellOptions", "value": { "mode": "basic", "type": "gauge" } }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 30 }, + { "color": "orange", "value": 60 }, + { "color": "red", "value": 120 } + ] + } + } + ] + }, + { + "matcher": { "id": "byName", "options": "job_name" }, + "properties": [ + { "id": "custom.width", "value": 350 }, + { "id": "custom.cellOptions", "value": { "type": "auto", "wrapText": true } } + ] + } + ] + }, + "options": { + "cellHeight": "sm", + "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "Avg Duration (min)" }] + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT job_name, ROUND(AVG(duration_sec) / 60, 1) as avg_duration_min, SUM(CASE WHEN result = 'FAILURE' THEN 1 ELSE 0 END) as failure_count, COUNT(*) as total_runs FROM ci_test_jobs WHERE duration_sec IS NOT NULL AND scope_id IN (${repository:sqlstring}) AND trigger_type IN (${trigger_type:sqlstring}) AND job_name IN (${job_name:sqlstring}) AND $__timeFilter(finished_at) GROUP BY job_name HAVING failure_count > 0 ORDER BY avg_duration_min DESC LIMIT 20", + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 50 }, + "id": 106, + "title": "Detailed Failed Test Cases", + "type": "row" + }, + { + "datasource": "mysql", + "type": "table", + "title": "Failed Test Details", + "id": 17, + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 51 }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "custom": { + "align": "auto", + "cellOptions": { "type": "auto" }, + "filterable": true, + "inspect": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "test_name" }, + "properties": [ + { "id": "custom.width", "value": 300 }, + { "id": "custom.cellOptions", "value": { "type": "auto", "wrapText": true } } + ] + }, + { + "matcher": { "id": "byName", "options": "failure_message" }, + "properties": [ + { "id": "custom.width", "value": 350 }, + { "id": "custom.cellOptions", "value": { "type": "auto", "wrapText": true } } + ] + }, + { + "matcher": { "id": "byName", "options": "prow_link" }, + "properties": [ + { + "id": "links", + "value": [{ "targetBlank": true, "title": "View in Prow", "url": "${__value.text}" }] + }, + { "id": "custom.width", "value": 80 }, + { "id": "displayName", "value": "Prow" } + ] + }, + { + "matcher": { "id": "byName", "options": "commit_sha" }, + "properties": [{ "id": "custom.width", "value": 100 }] + } + ] + }, + "options": { + "cellHeight": "md", + "footer": { "countRows": true, "fields": "", "reducer": ["count"], "show": true }, + "showHeader": true, + "sortBy": [{ "desc": true, "displayName": "finished_at" }] + }, + "targets": [ + { + "datasource": "mysql", + "format": "table", + "rawSql": "SELECT tc.name as test_name, j.job_name, j.pull_request_number as pr_number, j.pull_request_author as author, LEFT(j.commit_sha, 8) as commit_sha, DATE_FORMAT(j.finished_at, '%Y-%m-%d %H:%i') as finished_at, j.scope_id as repository, LEFT(tc.failure_message, 200) as failure_message, j.view_url as prow_link FROM ci_test_cases tc JOIN ci_test_jobs j ON tc.connection_id = j.connection_id AND tc.job_id = j.job_id WHERE tc.status = 'failed' AND j.scope_id IN (${repository:sqlstring}) AND j.trigger_type IN (${trigger_type:sqlstring}) AND j.job_name IN (${job_name:sqlstring}) AND $__timeFilter(j.finished_at) ORDER BY j.finished_at DESC LIMIT 200", + "refId": "A" + } + ] + } + ] +} diff --git a/scripts/mysql-init.sql b/scripts/mysql-init.sql new file mode 100644 index 00000000000..a89b0374e97 --- /dev/null +++ b/scripts/mysql-init.sql @@ -0,0 +1,6 @@ +-- Create test database for E2E tests +CREATE DATABASE IF NOT EXISTS lake_test; + +-- Grant permissions to merico user +GRANT ALL PRIVILEGES ON lake_test.* TO 'merico'@'%'; +FLUSH PRIVILEGES;