diff --git a/.alert-menta.user.yaml b/.alert-menta.user.yaml index f9f243b..b29d37a 100644 --- a/.alert-menta.user.yaml +++ b/.alert-menta.user.yaml @@ -1,5 +1,5 @@ system: - debug: + debug: log_level: debug ai: @@ -11,7 +11,22 @@ ai: project: "" location: "us-central1" model: "gemini-2.0-flash-001" - + + anthropic: + model: "claude-sonnet-4-20250514" + + # Fallback configuration - automatically switches to backup providers on failure + fallback: + enabled: false # Set to true to enable automatic failover + providers: # Providers tried in order (first = primary, rest = fallbacks) + - openai + - anthropic + # - vertexai # Uncomment if using VertexAI as fallback + retry: + max_retries: 2 # Retries per provider before moving to next + delay_ms: 1000 # Delay between retries in milliseconds + timeout_ms: 30000 # Request timeout (not yet implemented) + commands: - describe: description: "Generate a detailed description of the Issue." @@ -74,3 +89,323 @@ ai: --- The following is the GitHub Issue and comments on it. Please analyze: require_intent: false + - postmortem: + description: "Generate a postmortem document from the incident timeline." + system_prompt: | + You are an SRE expert specializing in incident management and postmortem documentation. + Generate a comprehensive postmortem document based on the incident Issue and its comments. + + Analyze the conversation timeline to extract: + 1. Key events and their timestamps + 2. People involved and their actions + 3. Root cause (if identified) + 4. Resolution steps taken + 5. Lessons learned + + Output in the following structured Markdown format: + + ## Postmortem: [Incident Title] + + ### Incident Summary + | Field | Value | + |-------|-------| + | **Incident ID** | Issue #[number] | + | **Date** | [Extract from issue/comments] | + | **Duration** | [Estimate from timeline] | + | **Severity** | [Estimate: Sev1/Sev2/Sev3/Sev4] | + | **Impact** | [Describe user/system impact] | + + ### Timeline + | Time | Event | Actor | + |------|-------|-------| + | [timestamp] | [event description] | [user/system] | + | ... | ... | ... | + + ### Root Cause + **Direct Cause**: [What directly caused the incident] + + **Contributing Factors**: + - [Factor 1] + - [Factor 2] + + ### Response & Resolution + 1. [Step 1: What was done] + 2. [Step 2: What was done] + 3. [Resolution: How was it fixed] + + ### What Went Well + - [Positive aspect 1] + - [Positive aspect 2] + + ### What Could Be Improved + - [Improvement area 1] + - [Improvement area 2] + + ### Action Items + | Priority | Action | Owner | Due Date | + |----------|--------|-------|----------| + | P1 | [Immediate action] | TBD | [date] | + | P2 | [Short-term fix] | TBD | [date] | + | P3 | [Long-term improvement] | TBD | [date] | + + ### Lessons Learned + - [Key takeaway 1] + - [Key takeaway 2] + + --- + *This postmortem was auto-generated by alert-menta. Please review and update with additional details.* + + --- + The following is the GitHub Issue and comments representing the incident timeline: + require_intent: false + - runbook: + description: "Generate a runbook with step-by-step response procedures for the incident." + system_prompt: | + You are an SRE expert who creates operational runbooks. Based on the incident information, + generate a step-by-step runbook that guides the responder through the investigation and resolution process. + + Your runbook should be: + 1. Actionable - each step should be specific and executable + 2. Progressive - start with diagnosis, then containment, then resolution + 3. Safe - include validation steps and rollback procedures + 4. Complete - cover the full incident lifecycle + + Output in the following structured Markdown format: + + ## Runbook: [Incident Type] + + ### Overview + - **Incident Type**: [e.g., High CPU, Memory Leak, API 5xx] + - **Severity**: [Sev1/Sev2/Sev3/Sev4] + - **Estimated Time**: [e.g., 15-30 minutes] + - **Required Access**: [e.g., kubectl, AWS Console, Database] + + ### Prerequisites + - [ ] Access to [required systems] + - [ ] Required tools installed: [list tools] + - [ ] Communication channel established + + ### Step 1: Initial Assessment + **Goal**: Understand the scope and impact + + ```bash + # Commands to run (if applicable) + [diagnostic commands] + ``` + + **Expected Output**: [What to look for] + **Decision Point**: If [condition], proceed to Step 2. If [other condition], escalate. + + ### Step 2: Containment + **Goal**: Limit the blast radius + + - [ ] [Action 1] + - [ ] [Action 2] + + **Validation**: [How to verify containment worked] + + ### Step 3: Investigation + **Goal**: Identify root cause + + | Check | Command/Action | What to Look For | + |-------|----------------|------------------| + | [Check 1] | [command] | [expected result] | + | [Check 2] | [command] | [expected result] | + + ### Step 4: Resolution + **Goal**: Fix the issue + + **Option A** (Recommended): [Primary fix] + ```bash + [fix commands] + ``` + + **Option B** (Fallback): [Alternative fix] + + **Validation**: [How to verify the fix worked] + + ### Step 5: Verification + **Goal**: Confirm system is healthy + + - [ ] [Health check 1] + - [ ] [Health check 2] + - [ ] Monitor for [duration] to ensure stability + + ### Escalation Criteria + Escalate to [team/person] if: + - [ ] Issue not resolved within [time] + - [ ] [Severity condition] + - [ ] [Impact condition] + + ### Rollback Procedure + If the fix causes issues: + 1. [Rollback step 1] + 2. [Rollback step 2] + + ### Post-Incident + - [ ] Update incident timeline + - [ ] Notify stakeholders + - [ ] Schedule postmortem if needed + + --- + *This runbook was auto-generated by alert-menta. Adapt the steps based on your specific environment.* + + --- + The following is the GitHub Issue describing the incident. Generate an appropriate runbook: + require_intent: false + - timeline: + description: "Generate a chronological timeline of incident response activities." + system_prompt: | + You are an SRE expert who creates incident timelines. Analyze the Issue and its comments to create a chronological timeline of all events and activities. + + Extract the following from the conversation: + 1. When was the incident first reported/detected + 2. Who was involved and when they joined + 3. What commands were executed (e.g., /describe, /analysis) + 4. Key findings and decisions + 5. Actions taken and their results + 6. When the incident was resolved (if applicable) + + Use emojis to categorize events: + - 🚨 Alert/Incident creation + - 👀 Investigation started + - 🔍 Analysis/Investigation + - 💡 Root cause identified + - 🔧 Fix/mitigation applied + - ✅ Resolved/Verified + - 📝 Documentation/Postmortem + - ⚠️ Escalation + - 💬 Communication/Update + + Output in the following structured Markdown format: + + ## Incident Timeline + + ### Summary + - **Incident**: [Brief description] + - **Status**: [Ongoing/Resolved] + - **Duration**: [Time from start to resolution or current time] + + ### Timeline + + | Time | Event | Actor | Details | + |------|-------|-------|---------| + | [HH:MM] | 🚨 Incident Created | @[user]/bot | [Brief description] | + | [HH:MM] | 👀 Investigation Started | @[user] | [What they did] | + | ... | ... | ... | ... | + + ### Key Metrics + - **Time to Acknowledge (TTA)**: [Time from creation to first human response] + - **Time to Resolve (TTR)**: [Time from creation to resolution, if resolved] + - **Number of Responders**: [Count] + + ### Current Status + [Summary of current situation and next steps if incident is ongoing] + + --- + *Timeline auto-generated by alert-menta from Issue comments.* + + --- + The following is the GitHub Issue and comments. Generate the incident timeline: + require_intent: false + # Example command with structured output (#64) + - triage: + description: "Automatically triage the incident with structured JSON output." + system_prompt: | + You are an SRE expert. Analyze the incident and provide a structured triage assessment. + Return ONLY a valid JSON object with the specified schema. + require_intent: false + structured_output: + enabled: true + schema_name: "incident_triage" + schema: + type: object + properties: + severity: + type: string + enum: ["critical", "high", "medium", "low"] + description: "Incident severity level" + category: + type: string + enum: ["infrastructure", "application", "database", "network", "security", "other"] + description: "Incident category" + summary: + type: string + description: "Brief summary of the incident" + affected_services: + type: array + items: + type: string + description: "List of affected services" + recommended_actions: + type: array + items: + type: string + description: "Recommended immediate actions" + estimated_impact: + type: string + description: "Estimated user/business impact" + required: + - severity + - category + - summary + fallback_to_text: true + +# First Response Guide settings (#62) +# Automatically posts an incident response guide when issues with specific labels are created +first_response: + enabled: false # Set to true to enable automatic first response guides + trigger_labels: # Issue labels that trigger the guide + - incident + - alert + - outage + slack_channel: "#incidents" # Optional: Slack channel for notifications + guides: # Severity-specific guide configurations + - severity: high + auto_notify: + - "@sre-team" + # template: ".alert-menta/guides/high-severity.md" # Optional: custom template + - severity: medium + auto_notify: [] + - severity: low + auto_notify: [] + escalation: + timeout_minutes: 15 # Escalation timeout for high severity + notify_target: "@oncall" + +# Auto-Triage settings (#63) +# Automatically assigns labels and comments on new issues +triage: + enabled: false # Set to true to enable auto-triage + auto_label: true # Automatically add labels based on triage + auto_comment: true # Post triage result as comment + confidence_threshold: 0.7 # Minimum confidence to apply labels (0.0-1.0) + labels: + priority: + - name: "priority:critical" + criteria: "Production service outage, data loss risk" + - name: "priority:high" + criteria: "User impact, requires urgent attention" + - name: "priority:medium" + criteria: "Feature degradation but workaround exists" + - name: "priority:low" + criteria: "Improvement request, minor issue" + category: + - name: "type:bug" + criteria: "Bug report for existing functionality" + - name: "type:feature" + criteria: "New feature request or enhancement" + - name: "type:docs" + criteria: "Documentation update or fix" + - name: "type:incident" + criteria: "Incident report, alert, or outage" + +# Slack notification settings +notifications: + slack: + enabled: false # Set to true to enable Slack notifications + webhook_url: "" # Your Slack Incoming Webhook URL (or use -slack-webhook-url flag) + channel: "" # Optional: Override webhook default channel (e.g., "#incidents") + notify_on: + - command_response # Notify when AI responds to a command + # - incident_created # Notify when new incident is created (future feature) diff --git a/.claude/agents/troubleshooter.md b/.claude/agents/troubleshooter.md new file mode 100644 index 0000000..1e90393 --- /dev/null +++ b/.claude/agents/troubleshooter.md @@ -0,0 +1,128 @@ +# Troubleshooter Agent + +このエージェントはClaude Codeが問題を自己診断・解決するための情報を提供します。 + +## GOROOT Misconfiguration + +**症状**: ビルド時に `package flag is not in std (/Users/nwiizo/go/src/flag)` のようなエラー + +**診断**: +```bash +go env GOROOT +# 正常: /opt/homebrew/Cellar/go/X.X.X/libexec +# 異常: /Users/nwiizo/go +``` + +**解決**: +1. シェル設定を確認: `~/.zshrc`, `~/.bashrc` +2. 誤ったGOROOT設定を削除 +3. 正しい設定: +```bash +export GOROOT=$(brew --prefix go)/libexec +export PATH=$GOROOT/bin:$PATH +``` + +**ワークアラウンド**: +```bash +unset GOROOT && export GOROOT=/opt/homebrew/opt/go/libexec && go build ./... +``` + +## golangci-lint Version Mismatch + +**症状**: CIとローカルでlint結果が異なる + +**診断**: `golangci-lint --version` (期待値: v2.8.0) + +**解決**: `make tools` + +## golangci-lint v2 Config + +**症状**: `unsupported version of the configuration` エラー + +**解決**: `.golangci.yaml` 先頭に追加: +```yaml +version: "2" +``` + +**注意**: v2ではフォーマッタは `formatters:` セクションに記載 + +## E2E Test Failures + +**症状**: E2Eテストがスキップされる + +**診断**: +```bash +echo $GITHUB_TOKEN +echo $OPENAI_API_KEY +``` + +**解決**: +```bash +export GITHUB_TOKEN=your_token +export OPENAI_API_KEY=your_key +``` + +## CI E2E Tests Skipped + +**症状**: CIでE2Eテストが全てSKIP + +**原因**: リポジトリSecrets未設定 + +**解決**: +1. GitHub → Settings → Secrets → Actions +2. 追加: `GH_TOKEN`, `OPENAI_API_KEY` + +## Viper Config Loading + +**症状**: 新しい設定項目をviperが読み込まない + +**原因**: `mapstructure` タグ不足 + +**解決**: +```go +type Config struct { + WebhookURL string `yaml:"webhook_url" mapstructure:"webhook_url"` +} +``` + +## External Service Testing + +**パターン**: httptest.NewServer でモック + +```go +server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 検証処理 + w.WriteHeader(http.StatusOK) +})) +defer server.Close() +client := NewClient(server.URL, "#channel") +``` + +## Cyclomatic Complexity + +**症状**: `cyclomatic complexity X of func main is high` + +**解決パターン**: +```go +func main() { + cfg := parseFlags() + if err := run(cfg); err != nil { + log.Fatalf("Error: %v", err) + } +} +func parseFlags() *Config { ... } +func run(cfg *Config) error { ... } +``` + +## Makefile Validation + +```bash +make -n # ドライラン +make help # 全ターゲット表示 +``` + +バージョン埋め込み: +```makefile +VERSION := $(shell git describe --tags --always --dirty) +LDFLAGS := -X main.version=$(VERSION) +``` diff --git a/.claude/rules/git-workflow.md b/.claude/rules/git-workflow.md new file mode 100644 index 0000000..aca717f --- /dev/null +++ b/.claude/rules/git-workflow.md @@ -0,0 +1,21 @@ +# Git Workflow Rules + +## Branch Strategy + +- PRs should target the `develop` branch, not `main` +- See wiki for details + +## Commit Format + +``` +(): +``` + +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `ci` + +## PR Verification + +After merging PRs: +1. Check CI logs +2. Verify E2E tests pass (not SKIP) +3. If skipped, check Secrets configuration diff --git a/.claude/rules/go-development.md b/.claude/rules/go-development.md new file mode 100644 index 0000000..0d3f65c --- /dev/null +++ b/.claude/rules/go-development.md @@ -0,0 +1,55 @@ +# Go Development Rules + +## Build Commands + +```bash +# Using Makefile (recommended) +make help # Show all available targets +make build # Build all binaries with version info +make test # Run tests with race detection and coverage +make lint # Run golangci-lint +make ci # Run lint, test, and build (for CI) +make dev-setup # Set up development environment +make security # Run gosec +make vuln # Run govulncheck + +# E2E tests (requires GITHUB_TOKEN and OPENAI_API_KEY) +make test-e2e +``` + +## Go Version & Tools + +- Go 1.24 (specified in `go.mod`) +- golangci-lint v2.8.0 (golangci-lint-action v7) +- Install tools: `make tools` + +## Code Quality Rules + +- Run `make ci` before committing +- Run `make security` for security scans (gosec + govulncheck) +- Avoid `log.Fatal`/`log.Fatalf` in library code - return errors instead +- Check all errors from function calls (errcheck linter) +- Use `fmt.Errorf("context: %w", err)` for error wrapping +- Error strings should not be capitalized (staticcheck ST1005) + +## Version Information + +CLI binaries support `-version` flag: +```bash +./bin/alert-menta -version +# alert-menta v0.2.0 +# commit: abc1234 +# built: 2024-01-01T00:00:00Z +``` + +## Dependency Management + +- Dependabot configured (`.github/dependabot.yml`) +- Weekly updates for Go modules and GitHub Actions +- Manual update: `make deps-update` + +## E2E Testing + +- Located in `e2e/` directory +- Requires `GITHUB_TOKEN` and `OPENAI_API_KEY` +- Run: `make test-e2e` diff --git a/.claude/skills/mcp-server/SKILL.md b/.claude/skills/mcp-server/SKILL.md new file mode 100644 index 0000000..cdda193 --- /dev/null +++ b/.claude/skills/mcp-server/SKILL.md @@ -0,0 +1,38 @@ +# MCP Server Setup + +alert-menta provides an MCP server for Claude Code integration. + +## Running the Server + +```bash +go run ./cmd/mcp/main.go -config .alert-menta.user.yaml +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `get_incident` | Get incident information from GitHub Issue | +| `analyze_incident` | Run analysis commands (describe, suggest, analysis, postmortem, runbook, timeline) | +| `post_comment` | Post a comment to GitHub Issue | +| `list_commands` | List all available commands | + +## Claude Code Configuration + +Add to `~/.claude/settings.json`: + +```json +{ + "mcpServers": { + "alert-menta": { + "command": "go", + "args": ["run", "./cmd/mcp/main.go", "-config", ".alert-menta.user.yaml"], + "cwd": "/path/to/alert-menta", + "env": { + "GITHUB_TOKEN": "...", + "OPENAI_API_KEY": "..." + } + } + } +} +``` diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..3060abd --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,32 @@ +# Release Workflow + +## Binaries Built + +| Binary | Description | +|--------|-------------| +| `alert-menta` | Main CLI | +| `alert-menta-mcp` | MCP server for Claude Code | +| `alert-menta-firstresponse` | First response guide generator | +| `alert-menta-triage` | Auto-triage for issues | + +## Supported Platforms + +- linux/amd64, linux/arm64 +- darwin/amd64, darwin/arm64 +- windows/amd64, windows/arm64 + +## Release Commands + +```bash +# Test release locally (no publish) +make release-dry-run + +# Actual release (triggered by tag push) +git tag v0.2.0 +git push origin v0.2.0 +``` + +## Configuration Files + +- GoReleaser: `.goreleaser.yaml` +- Workflow: `.github/workflows/release.yaml` diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f069f87 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Build artifacts +bin/ +dist/ +coverage.out +coverage.html + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Docker +Dockerfile* +docker-compose*.yaml + +# Documentation +*.md +!README.md + +# Test files +*_test.go +e2e/ diff --git a/.envrc.example b/.envrc.example new file mode 100644 index 0000000..c4d091f --- /dev/null +++ b/.envrc.example @@ -0,0 +1,13 @@ +# Copy this file to .envrc and fill in your keys +# Then run: direnv allow + +export GITHUB_TOKEN=$(gh auth token) +export OPENAI_API_KEY="your-openai-api-key-here" + +# Optional: For Anthropic provider +# export ANTHROPIC_API_KEY="your-anthropic-api-key-here" + +# E2E test configuration (optional) +# export E2E_TEST_OWNER="nwiizo" +# export E2E_TEST_REPO="alert-menta-test" +# export E2E_TEST_ISSUE="1" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e6aa855 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + golang-deps: + patterns: + - "golang.org/*" + update-types: + - "minor" + - "patch" + google-deps: + patterns: + - "google.golang.org/*" + - "cloud.google.com/*" + - "github.com/google/*" + - "github.com/googleapis/*" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 9c53a4b..331f137 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -9,6 +9,7 @@ on: branches: - develop - main + workflow_dispatch: # Allow manual triggering jobs: e2e: @@ -19,7 +20,18 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.24" + + - name: Check secrets configuration + run: | + if [ -z "${{ secrets.GH_TOKEN }}" ]; then + echo "::warning::GH_TOKEN secret is not configured. E2E tests will be skipped." + echo "Please add GH_TOKEN to repository secrets (Settings > Secrets > Actions)" + fi + if [ -z "${{ secrets.OPENAI_API_KEY }}" ]; then + echo "::warning::OPENAI_API_KEY secret is not configured. E2E tests will be skipped." + echo "Please add OPENAI_API_KEY to repository secrets (Settings > Secrets > Actions)" + fi - name: Run E2E tests env: diff --git a/.github/workflows/first-response.yaml.example b/.github/workflows/first-response.yaml.example new file mode 100644 index 0000000..8b43edd --- /dev/null +++ b/.github/workflows/first-response.yaml.example @@ -0,0 +1,38 @@ +# Example workflow for first response guide +# Copy this file to .github/workflows/first-response.yaml and customize + +name: "First Response Guide" +run-name: Post incident response guide + +on: + issues: + types: [opened, labeled] + +jobs: + first-response: + # Only trigger for issues with incident-related labels + if: contains(github.event.issue.labels.*.name, 'incident') || contains(github.event.issue.labels.*.name, 'alert') + runs-on: ubuntu-24.04 + permissions: + issues: write + contents: read + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Download and Install alert-menta + run: | + curl -sLJO -H 'Accept: application/octet-stream' \ + "https://${{ secrets.GH_TOKEN }}@api.github.com/repos/3-shake/alert-menta/releases/assets/$( \ + curl -sL "https://${{ secrets.GH_TOKEN }}@api.github.com/repos/3-shake/alert-menta/releases/latest" \ + | jq '.assets[] | select(.name | contains("Linux_x86")) | .id')" + tar -zxvf alert-menta_Linux_x86_64.tar.gz + + - name: Post First Response Guide + run: | + ./alert-menta-firstresponse \ + -owner ${{ github.repository_owner }} \ + -repo ${{ github.event.repository.name }} \ + -issue ${{ github.event.issue.number }} \ + -github-token ${{ secrets.GH_TOKEN }} \ + -config .alert-menta.user.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index c68d4c6..b36feb2 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -20,7 +20,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.24" - name: golangci-lint uses: golangci/golangci-lint-action@v7 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..68d7584 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml new file mode 100644 index 0000000..ca6fc77 --- /dev/null +++ b/.github/workflows/security.yaml @@ -0,0 +1,52 @@ +name: Security + +on: + push: + branches: [develop, main] + pull_request: + branches: [develop, main] + schedule: + # Run weekly on Monday at 00:00 UTC + - cron: '0 0 * * 1' + +permissions: + contents: read + security-events: write + +jobs: + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... + + gosec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: '-no-fail -fmt sarif -out gosec.sarif ./...' + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: gosec.sarif diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 873b18c..777c119 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version: "1.24" - name: Install dependencies run: go mod tidy diff --git a/.github/workflows/triage.yaml.example b/.github/workflows/triage.yaml.example new file mode 100644 index 0000000..604cf57 --- /dev/null +++ b/.github/workflows/triage.yaml.example @@ -0,0 +1,37 @@ +# Example workflow for auto-triage +# Copy this file to .github/workflows/triage.yaml and customize + +name: "Auto Triage" +run-name: AI triages new issues + +on: + issues: + types: [opened] + +jobs: + triage: + runs-on: ubuntu-24.04 + permissions: + issues: write + contents: read + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Download and Install alert-menta + run: | + curl -sLJO -H 'Accept: application/octet-stream' \ + "https://${{ secrets.GH_TOKEN }}@api.github.com/repos/3-shake/alert-menta/releases/assets/$( \ + curl -sL "https://${{ secrets.GH_TOKEN }}@api.github.com/repos/3-shake/alert-menta/releases/latest" \ + | jq '.assets[] | select(.name | contains("Linux_x86")) | .id')" + tar -zxvf alert-menta_Linux_x86_64.tar.gz + + - name: Run Auto Triage + run: | + ./alert-menta-triage \ + -owner ${{ github.repository_owner }} \ + -repo ${{ github.event.repository.name }} \ + -issue ${{ github.event.issue.number }} \ + -github-token ${{ secrets.GH_TOKEN }} \ + -api-key ${{ secrets.OPENAI_API_KEY }} \ + -config .alert-menta.user.yaml diff --git a/.gitignore b/.gitignore index 3bd89c3..2eaa624 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ coverage.html # OS .DS_Store +.envrc diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b69bbf2..c091e44 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,32 +1,86 @@ -# This is an example .goreleaser.yml file with some sensible defaults. -# Make sure to check the documentation at https://goreleaser.com - -# The lines below are called `modelines`. See `:help modeline` -# Feel free to remove those if you don't want/need to use them. # yaml-language-server: $schema=https://goreleaser.com/static/schema.json # vim: set ts=2 sw=2 tw=0 fo=cnqoj -version: 1 +version: 2 before: hooks: - # You may remove this if you don't use go modules. - go mod tidy - # you may remove this if you don't need go generate - go generate ./... builds: - - main: ./cmd/ + # Main CLI + - id: alert-menta + main: ./cmd/main.go + binary: alert-menta env: - CGO_ENABLED=0 goos: - linux - windows - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + + # MCP Server + - id: alert-menta-mcp + main: ./cmd/mcp/main.go + binary: alert-menta-mcp + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + + # First Response + - id: alert-menta-firstresponse + main: ./cmd/firstresponse/main.go + binary: alert-menta-firstresponse + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + + # Triage + - id: alert-menta-triage + main: ./cmd/triage/main.go + binary: alert-menta-triage + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w archives: - - format: tar.gz - # this name template makes the OS and Arch compatible with the results of `uname`. + - id: default + formats: + - tar.gz + - zip name_template: >- {{ .ProjectName }}_ {{- title .Os }}_ @@ -34,14 +88,51 @@ archives: {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} - # use zip for windows archives - format_overrides: - - goos: windows - format: zip + files: + - README.md + - LICENSE* + - .alert-menta.user.yaml + +checksum: + name_template: 'checksums.txt' changelog: sort: asc + use: github + groups: + - title: Features + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: Bug Fixes + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: Documentation + regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' + order: 2 + - title: Other + order: 999 filters: exclude: - - "^docs:" - "^test:" + - "^chore:" + - "^ci:" + +release: + github: + owner: 3-shake + name: alert-menta + draft: false + prerelease: auto + name_template: "{{.Tag}}" + header: | + ## What's Changed in {{.Tag}} + + ### Installation + ```bash + # Download and extract + curl -sLJO https://github.com/3-shake/alert-menta/releases/download/{{.Tag}}/alert-menta_Linux_x86_64.tar.gz + tar -xzf alert-menta_Linux_x86_64.tar.gz + ``` + footer: | + --- + **Full Changelog**: https://github.com/3-shake/alert-menta/compare/{{.PreviousTag}}...{{.Tag}} diff --git a/CLAUDE.md b/CLAUDE.md index 51688ed..0967d92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,233 +1,47 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - ## Project Overview -alert-menta is a GitHub Actions-based tool that uses LLM (OpenAI or VertexAI) to analyze and respond to GitHub Issues. It supports slash commands (`/describe`, `/suggest`, `/ask`) in Issue comments to generate AI-powered responses. +alert-menta is a GitHub Actions-based tool that uses LLM (OpenAI, Anthropic, VertexAI) to analyze and respond to GitHub Issues. Supports slash commands (`/describe`, `/suggest`, `/ask`, `/analysis`, `/postmortem`, `/runbook`, `/timeline`) in Issue comments. -## Build and Development Commands +## Quick Commands ```bash -# Using Makefile (recommended) -make help # Show all available targets -make build # Build the binary -make test # Run tests with race detection and coverage -make lint # Run golangci-lint -make ci # Run lint, test, and build (for CI) -make dev-setup # Set up development environment - -# E2E tests (requires GITHUB_TOKEN and OPENAI_API_KEY) -make test-e2e - -# Manual commands -go build ./... -go test -race -cover ./... -golangci-lint run -go fmt ./... - -# Local execution -go run ./cmd/main.go -repo -owner -issue \ - -github-token $GITHUB_TOKEN -api-key $OPENAI_API_KEY \ - -command -config .alert-menta.user.yaml +make ci # lint, test, build (before commit) +make test-e2e # E2E tests (requires GITHUB_TOKEN, OPENAI_API_KEY) +make release-dry-run # Test GoReleaser ``` ## Architecture ``` -cmd/main.go # Entry point, CLI flag parsing, orchestrates the flow +cmd/ + main.go # Main CLI + mcp/main.go # MCP server for Claude Code + triage/main.go # Auto-triage CLI + firstresponse/main.go # First response guide CLI internal/ - ai/ - ai.go # Ai interface definition (GetResponse) - openai.go # OpenAI implementation (uses Azure SDK) - vertexai.go # VertexAI/Gemini implementation - github/ - github.go # GitHubIssue struct for Issue/comment operations - slack/ - slack.go # Slack webhook client for notifications - slack_test.go # Unit tests with mock HTTP server - utils/ - utils.go # Config loading (viper), image download/base64 conversion + ai/ # AI providers (OpenAI, Anthropic, VertexAI) + github/ # GitHub API wrapper + mcp/ # MCP server implementation + triage/ # Auto-triage logic + firstresponse/ # First response logic + slack/ # Slack notifications + utils/ # Config, image processing ``` -**Flow**: Parse flags → Load config → Validate command → Fetch Issue+comments → Extract images → Build prompt → Call AI → Post response as comment +**Flow**: Parse flags → Load config → Fetch Issue → Build prompt → Call AI → Post comment ## Configuration -`.alert-menta.user.yaml` defines: -- `ai.provider`: "openai" or "vertexai" -- `ai.openai.model` / `ai.vertexai.{project,location,model}` -- `ai.commands`: Map of command names to `{description, system_prompt}` - -## Branch Strategy - -PRs should target the `develop` branch, not `main`. See wiki for details. - -## Development Notes - -### Go Version -- Project uses Go 1.23 (specified in `go.mod`) -- CI/CD workflows use Go 1.23 -- golangci-lint v2.8.0 is used for linting (golangci-lint-action v7) - -### Code Quality -- Run `make ci` before committing -- Avoid `log.Fatal`/`log.Fatalf` in library code - return errors instead -- Check all errors from function calls (errcheck linter) -- Use `fmt.Errorf("context: %w", err)` for error wrapping - -### E2E Testing -- E2E tests are located in `e2e/` directory -- Tests require `GITHUB_TOKEN` and `OPENAI_API_KEY` environment variables -- Run with `make test-e2e` or `go test -tags=e2e -v ./e2e/...` - -## Troubleshooting (for Claude Code) - -このセクションはClaude Codeが問題を自己診断・解決するための情報です。 - -### GOROOT misconfiguration - -**症状**: ビルド時に `package flag is not in std (/Users/nwiizo/go/src/flag)` のようなエラーが大量に出る - -**診断コマンド**: -```bash -go env GOROOT -# 正常: /opt/homebrew/Cellar/go/X.X.X/libexec など -# 異常: /Users/nwiizo/go (ユーザーディレクトリ) -``` - -**原因**: GOROOTが誤ってユーザーディレクトリに設定されている - -**解決方法**: -1. シェル設定ファイルを確認: `~/.zshrc`, `~/.bashrc`, `~/.zprofile` -2. 誤ったGOROOT設定を削除またはコメントアウト -3. 正しい設定に修正: -```bash -# ~/.zshrc に追記(必要な場合のみ) -export GOROOT=$(brew --prefix go)/libexec -export PATH=$GOROOT/bin:$PATH -``` -4. シェルを再起動: `exec $SHELL` - -**注意**: CIでは正常に動作するため、ローカル環境固有の問題 - -### golangci-lint version mismatch - -**症状**: CIとローカルでlint結果が異なる - -**診断コマンド**: -```bash -golangci-lint --version -# 期待値: v2.8.0 -``` - -**解決方法**: -```bash -make tools -# または -go install github.com/golangci/golangci-lint/cmd/golangci-lint@v2.8.0 -``` - -### E2E test failures - -**症状**: E2Eテストがスキップされる、または失敗する - -**診断コマンド**: -```bash -echo $GITHUB_TOKEN -echo $OPENAI_API_KEY -``` - -**解決方法**: 環境変数が未設定の場合は設定する -```bash -export GITHUB_TOKEN=your_token -export OPENAI_API_KEY=your_key -``` - -### GOROOT問題のワークアラウンド - -**症状**: シェル設定を変更できない/変更が反映されない場合 - -**ワークアラウンド**: コマンド実行時に一時的にGOROOTを設定 -```bash -unset GOROOT && export GOROOT=/opt/homebrew/opt/go/libexec && go build ./... -unset GOROOT && export GOROOT=/opt/homebrew/opt/go/libexec && go test -race -cover ./... -unset GOROOT && export GOROOT=/opt/homebrew/opt/go/libexec && golangci-lint run -``` - -**注意**: このワークアラウンドは各コマンドに適用が必要。根本解決には環境変数の修正が必要。 - -### golangci-lint v2 設定形式 - -**症状**: `unsupported version of the configuration` エラー - -**原因**: golangci-lint v2は設定ファイルに `version: "2"` が必要 - -**解決方法**: `.golangci.yaml` の先頭に追加 -```yaml -version: "2" -``` - -**注意**: v2ではフォーマッタが `formatters:` セクションに移動。`gofmt`, `gofumpt`, `goimports` は `linters:` ではなく `formatters:` に記載。 +`.alert-menta.user.yaml`: AI provider, models, commands, Slack notifications -### 設定構造体の追加パターン - -**症状**: 新しい設定項目を追加してもviperが読み込まない - -**原因**: `mapstructure` タグの不足、または構造体のフィールド名とYAMLキーの不一致 - -**解決方法**: -```go -// YAMLのsnake_caseキーには mapstructure タグが必要 -type SlackConfig struct { - Enabled bool `yaml:"enabled"` - WebhookURL string `yaml:"webhook_url" mapstructure:"webhook_url"` // snake_case - Channel string `yaml:"channel"` - NotifyOn []string `yaml:"notify_on" mapstructure:"notify_on"` // snake_case -} -``` - -**注意**: viperはデフォルトで `mapstructure` を使用。YAMLキーがsnake_caseの場合、Goのフィールド名がCamelCaseなら `mapstructure` タグが必要。 - -### 外部サービス連携のテストパターン - -**パターン**: Slack webhook等の外部サービス連携は httptest.NewServer でモック - -```go -func TestSendCommandResponse(t *testing.T) { - var receivedMessage Message - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // リクエストボディを検証 - if err := json.NewDecoder(r.Body).Decode(&receivedMessage); err != nil { - t.Errorf("failed to decode: %v", err) - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - client := NewClient(server.URL, "#test-channel") - err := client.SendCommandResponse("Test", "http://test", "describe", "response") - // ... -} -``` - -**利点**: 実際のAPIを呼び出さずにユニットテストが可能、CI/CDで安全に実行可能 - -### E2Eテストと環境変数 - -**設計**: E2Eテストは環境変数チェックでスキップ可能にする - -```go -func skipIfMissingEnv(t *testing.T) { - t.Helper() - if os.Getenv("GITHUB_TOKEN") == "" { - t.Skip("GITHUB_TOKEN not set") - } -} -``` +## Documentation -**理由**: -- ローカル開発では常に環境変数が設定されているとは限らない -- CIではGitHub Secretsから環境変数が供給される -- スキップにより `go test` が失敗しない +| Topic | Location | +|-------|----------| +| Go development rules | [.claude/rules/go-development.md](.claude/rules/go-development.md) | +| Git workflow | [.claude/rules/git-workflow.md](.claude/rules/git-workflow.md) | +| MCP server setup | [.claude/skills/mcp-server/SKILL.md](.claude/skills/mcp-server/SKILL.md) | +| Release workflow | [.claude/skills/release/SKILL.md](.claude/skills/release/SKILL.md) | +| Troubleshooting | [.claude/agents/troubleshooter.md](.claude/agents/troubleshooter.md) | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..addb558 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# syntax=docker/dockerfile:1 + +# Build stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# Install git for version info and ca-certificates for HTTPS +RUN apk add --no-cache git ca-certificates tzdata + +# Copy go mod files first for better layer caching +COPY go.mod go.sum ./ +RUN go mod download && go mod verify + +# Copy source code +COPY . . + +# Build arguments for version info +ARG VERSION=dev +ARG COMMIT=unknown +ARG DATE=unknown +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +# Build all binaries with optimizations +# -trimpath: Remove file system paths from binary +# -ldflags "-s -w": Strip debug info for smaller binary +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \ + -o /bin/alert-menta ./cmd/main.go && \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "-s -w" -o /bin/alert-menta-mcp ./cmd/mcp/main.go && \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "-s -w" -o /bin/alert-menta-firstresponse ./cmd/firstresponse/main.go && \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags "-s -w" -o /bin/alert-menta-triage ./cmd/triage/main.go + +# Runtime stage - using distroless for minimal attack surface +# distroless/static contains only ca-certificates and tzdata +FROM gcr.io/distroless/static-debian12:nonroot + +# Labels for container metadata +LABEL org.opencontainers.image.title="alert-menta" \ + org.opencontainers.image.description="LLM-powered incident response assistant for GitHub Issues" \ + org.opencontainers.image.source="https://github.com/3-shake/alert-menta" \ + org.opencontainers.image.vendor="3-shake" + +WORKDIR /app + +# Copy binaries from builder +COPY --from=builder /bin/alert-menta /usr/local/bin/ +COPY --from=builder /bin/alert-menta-mcp /usr/local/bin/ +COPY --from=builder /bin/alert-menta-firstresponse /usr/local/bin/ +COPY --from=builder /bin/alert-menta-triage /usr/local/bin/ + +# Copy timezone data and CA certificates from builder +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +# Run as non-root user (65532 is the nonroot user in distroless) +USER nonroot:nonroot + +# Default command +ENTRYPOINT ["alert-menta"] +CMD ["-help"] diff --git a/Makefile b/Makefile index b07ba0f..1ffa204 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,38 @@ # alert-menta Makefile -.PHONY: all build test test-verbose test-e2e lint lint-fix fmt vet clean help deps deps-update tools dev-setup ci coverage +.PHONY: all build test test-verbose test-e2e lint lint-fix fmt vet clean help deps deps-update tools dev-setup ci coverage release-dry-run security vuln docker docker-build docker-push docker-run # Versions -GO_VERSION := 1.23 +GO_VERSION := 1.24 GOLANGCI_LINT_VERSION := v2.8.0 # Build -BINARY_NAME := alert-menta BUILD_DIR := bin +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "none") +DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) + +# Docker +DOCKER_IMAGE := alert-menta +DOCKER_TAG := $(VERSION) +DOCKER_REGISTRY := ghcr.io/3-shake all: lint test build ## Build build: - go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/... + @echo "Building all binaries..." + @mkdir -p $(BUILD_DIR) + go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/alert-menta ./cmd/main.go + go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/alert-menta-mcp ./cmd/mcp/main.go + go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/alert-menta-firstresponse ./cmd/firstresponse/main.go + go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/alert-menta-triage ./cmd/triage/main.go + @echo "Build complete: $(BUILD_DIR)/" + +## Build single binary (for backward compatibility) +build-main: + go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/alert-menta ./cmd/main.go ## Test test: @@ -39,7 +57,7 @@ lint-fix: fmt: go fmt ./... - gofumpt -l -w . + go tool gofumpt -l -w . vet: go vet ./... @@ -53,10 +71,10 @@ deps-update: go get -u ./... go mod tidy -## Tools +## Tools (Go 1.24+ uses tool directive in go.mod) tools: go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) - go install mvdan.cc/gofumpt@latest + go install tool # Install tools defined in go.mod ## Clean clean: @@ -70,25 +88,83 @@ dev-setup: tools deps ## CI ci: lint test build +## Release +release-dry-run: + goreleaser release --snapshot --clean + +## Security (using Go 1.24 tool directive) +security: vuln + @echo "Running security checks..." + go tool gosec -quiet ./... + +vuln: + @echo "Checking for vulnerabilities..." + go tool govulncheck ./... + +## Docker +docker: docker-build + +docker-build: + @echo "Building Docker image..." + docker build \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + --build-arg DATE=$(DATE) \ + -t $(DOCKER_IMAGE):$(DOCKER_TAG) \ + -t $(DOCKER_IMAGE):latest \ + . + +docker-push: docker-build + docker tag $(DOCKER_IMAGE):$(DOCKER_TAG) $(DOCKER_REGISTRY)/$(DOCKER_IMAGE):$(DOCKER_TAG) + docker tag $(DOCKER_IMAGE):latest $(DOCKER_REGISTRY)/$(DOCKER_IMAGE):latest + docker push $(DOCKER_REGISTRY)/$(DOCKER_IMAGE):$(DOCKER_TAG) + docker push $(DOCKER_REGISTRY)/$(DOCKER_IMAGE):latest + +docker-run: + docker run --rm -it \ + -e GITHUB_TOKEN \ + -e OPENAI_API_KEY \ + -v $(PWD)/.alert-menta.user.yaml:/app/config.yaml:ro \ + $(DOCKER_IMAGE):latest -config /app/config.yaml -help + ## Help help: @echo "Usage: make [target]" @echo "" - @echo "Targets:" - @echo " all Run lint, test, and build" - @echo " build Build the binary" - @echo " test Run tests with race detection and coverage" - @echo " test-verbose Run tests with verbose output" - @echo " test-e2e Run E2E tests (requires GITHUB_TOKEN and OPENAI_API_KEY)" - @echo " coverage Generate HTML coverage report" - @echo " lint Run golangci-lint" - @echo " lint-fix Run golangci-lint with auto-fix" - @echo " fmt Format code with gofmt and gofumpt" - @echo " vet Run go vet" - @echo " deps Tidy and verify dependencies" - @echo " deps-update Update dependencies" - @echo " tools Install development tools" - @echo " clean Remove build artifacts" - @echo " dev-setup Set up development environment" - @echo " ci Run CI checks (lint, test, build)" - @echo " help Show this help" + @echo "Build:" + @echo " build Build all binaries with version info" + @echo " build-main Build only the main CLI binary" + @echo " clean Remove build artifacts" + @echo "" + @echo "Test:" + @echo " test Run tests with race detection and coverage" + @echo " test-verbose Run tests with verbose output" + @echo " test-e2e Run E2E tests (requires GITHUB_TOKEN, OPENAI_API_KEY)" + @echo " coverage Generate HTML coverage report" + @echo "" + @echo "Quality:" + @echo " lint Run golangci-lint" + @echo " lint-fix Run golangci-lint with auto-fix" + @echo " fmt Format code with gofmt and gofumpt" + @echo " vet Run go vet" + @echo " security Run security checks (gosec)" + @echo " vuln Check for known vulnerabilities (govulncheck)" + @echo "" + @echo "Docker:" + @echo " docker-build Build Docker image" + @echo " docker-push Push Docker image to registry" + @echo " docker-run Run Docker container" + @echo "" + @echo "Dependencies:" + @echo " deps Tidy and verify dependencies" + @echo " deps-update Update dependencies" + @echo " tools Install development tools" + @echo "" + @echo "CI/CD:" + @echo " ci Run CI checks (lint, test, build)" + @echo " release-dry-run Test release with goreleaser" + @echo " all Run lint, test, and build" + @echo "" + @echo "Other:" + @echo " dev-setup Set up development environment" + @echo " help Show this help" diff --git a/README.md b/README.md index 1ddd607..1755341 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,43 @@ # alert-menta -An innovative tool 🚀 for real-time analysis and management of Issues' alerts. 🔍 It identifies alert causes, proposes actionable solutions, 💡and detailed reports. 📈 -Designed for developers 👨‍💻, managers 📋, and IT teams .💻 Alert-menta enhances productivity and software quality. 🌟 -## Overview of alert-menta -### The purpose of alert-menta -We reduce the burden of system failure response using LLM. + +LLM-powered incident response assistant for GitHub Issues. Reduce MTTR with AI-driven analysis, runbooks, and postmortems. + +## Features + +| Category | Feature | Description | +|----------|---------|-------------| +| **Commands** | `/describe` | Summarize the incident | +| | `/analysis` | Root cause analysis (5 Whys) | +| | `/suggest` | Propose improvement measures | +| | `/ask` | Answer free-text questions | +| | `/postmortem` | Generate postmortem document | +| | `/runbook` | Generate response procedures | +| | `/timeline` | Generate incident timeline | +| | `/triage` | Structured JSON triage output | +| **Providers** | OpenAI | GPT-4, GPT-4o-mini | +| | Anthropic | Claude | +| | VertexAI | Gemini | +| **Integrations** | Slack | Notification on command response | +| | MCP | Claude Code integration | +| **Automation** | First Response | Auto-post incident guide | +| | Fallback | Auto-switch providers on failure | +| | Structured Output | JSON schema-compliant responses | + +## Overview + +### Purpose +Reduce the burden of system failure response using LLM. Get AI-powered incident support directly within GitHub Issues. + ### Main Features -You can receive support for failure handling that is completed within GitHub. -- Execute commands interactively in GitHub Issue comments: - - `describe` command to summarize the Issue - - `analysis` command for root cause analysis of failures using 5 Whys method - - `suggest` command for proposing improvement measures for failures - - `ask` command for asking additional questions -- Mechanism to improve response accuracy using [RAG](https://cloud.google.com/use-cases/retrieval-augmented-generation?hl=en) (in development) -- Selectable LLM models (OpenAI, VertexAI) -- Extensible prompt text - - Multilingual support -- Allows dialogue that includes images. +- **Slash Commands**: Execute AI commands in Issue comments (`/describe`, `/analysis`, etc.) +- **Multi-Provider Support**: OpenAI, Anthropic (Claude), VertexAI (Gemini) +- **Provider Fallback**: Automatic failover between providers +- **Structured Output**: JSON responses for system integrations +- **First Response Guide**: Auto-post incident guides for new issues +- **Slack Notifications**: Get notified when AI responds +- **MCP Server**: Claude Code integration for local development +- **Image Support**: Analyze screenshots and diagrams in issues +- **Customizable Prompts**: Define your own commands and prompts ## How to Use Alert-menta is intended to be run on GitHub Actions. ### 1. Prepare GitHub PAT @@ -99,6 +121,170 @@ The built-in `analysis` command uses the 5 Whys method for root cause analysis. require_intent: false ``` +The built-in `postmortem` command generates comprehensive postmortem documentation from the incident Issue and its comment timeline: +```yaml +- postmortem: + description: "Generate a postmortem document from the incident timeline." + system_prompt: | + You are an SRE expert. Generate a postmortem document based on the incident Issue. + + Output format: + - Incident Summary (date, duration, severity, impact) + - Timeline (chronological events from comments) + - Root Cause (direct cause and contributing factors) + - Response & Resolution + - What Went Well / What Could Be Improved + - Action Items (prioritized with owners) + - Lessons Learned + require_intent: false +``` + +### First Response Guide +alert-menta can automatically post an incident response guide when Issues with specific labels are created. This helps on-call responders quickly understand what steps to take. + +#### Configuration +Add the following to your `.alert-menta.user.yaml`: +```yaml +first_response: + enabled: true + trigger_labels: + - incident + - alert + guides: + - severity: high + auto_notify: + - "@sre-team" + - severity: medium + auto_notify: [] + - severity: low + auto_notify: [] + escalation: + timeout_minutes: 15 + notify_target: "@oncall" +``` + +#### Severity Detection +Severity is automatically determined from: +1. **Labels**: `severity:high`, `sev1`, `critical`, `p0`, etc. +2. **Issue body**: Keywords like "production", "outage", "service down" + +#### GitHub Actions Setup +See `.github/workflows/first-response.yaml.example` for a workflow template. + +#### Local Testing +```bash +go run ./cmd/firstresponse/main.go \ + -owner -repo -issue \ + -github-token $GITHUB_TOKEN \ + -config .alert-menta.user.yaml \ + -dry-run # Preview without posting +``` + +### Provider Fallback +alert-menta supports automatic failover between AI providers. If the primary provider fails (timeout, rate limit, server error), it automatically tries backup providers. + +#### Configuration +Add the following to your `.alert-menta.user.yaml`: +```yaml +ai: + fallback: + enabled: true + providers: # Tried in order + - openai + - anthropic + # - vertexai + retry: + max_retries: 2 # Retries per provider + delay_ms: 1000 # Delay between retries +``` + +When fallback is enabled, the primary `ai.provider` setting is ignored, and providers are tried in the order specified in `fallback.providers`. + +#### Supported Providers +- `openai` - OpenAI API (GPT-4, etc.) +- `anthropic` - Anthropic API (Claude) +- `vertexai` - Google Vertex AI (Gemini) + +### Structured Output +alert-menta supports structured JSON output for commands that need machine-parseable responses. This is useful for integrations with other systems. + +#### Configuration +Add `structured_output` to any command in your `.alert-menta.user.yaml`: +```yaml +commands: + - triage: + description: "Triage incident with structured output" + system_prompt: "Analyze the incident..." + require_intent: false + structured_output: + enabled: true + schema_name: "incident_triage" + schema: + type: object + properties: + severity: + type: string + enum: ["critical", "high", "medium", "low"] + category: + type: string + summary: + type: string + required: ["severity", "category", "summary"] + fallback_to_text: true +``` + +#### Output Example +```json +{ + "severity": "high", + "category": "infrastructure", + "summary": "API server returning 500 errors due to database connection issues" +} +``` + +#### Provider Support +| Provider | JSON Mode | Schema Validation | +|----------|-----------|-------------------| +| OpenAI | Yes | Yes (native) | +| Anthropic | Yes | Via prompt | +| VertexAI | Yes | Via prompt | + +### Slack Notifications +alert-menta can send notifications to Slack when AI responds to commands. This is useful for keeping your team informed about incident analysis. + +#### Configuration +Add the following to your `.alert-menta.user.yaml`: +```yaml +notifications: + slack: + enabled: true + webhook_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + channel: "#incidents" # Optional: Override webhook default channel + notify_on: + - command_response # Notify when AI responds to a command +``` + +#### Using CLI Flag +You can also pass the webhook URL as a command-line flag: +```bash +./alert-menta -slack-webhook-url "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" ... +``` +The CLI flag takes precedence over the config file setting. + +#### Setup Slack Webhook +1. Go to your Slack workspace settings +2. Navigate to "Apps" > "Incoming Webhooks" +3. Create a new webhook and select the channel +4. Copy the webhook URL to your config or secrets + +#### GitHub Actions Integration +Add your Slack webhook URL to GitHub Secrets and update your workflow: +```yaml +- name: Add Comment + run: | + ./alert-menta ... -slack-webhook-url ${{ secrets.SLACK_WEBHOOK_URL }} +``` + ### Actions #### Template The `.github/workflows/alert-menta.yaml` in this repository is a template. The contents are as follows: @@ -170,6 +356,40 @@ In an environment where Golang can be executed, clone the repository and run it ``` go run ./cmd/main.go -repo -owner -issue -github-token $GITHUB_TOKEN -api-key $OPENAI_API_KEY -command -config ``` +## Claude Code Integration (MCP) +alert-menta provides an MCP (Model Context Protocol) server that enables Claude Code to interact with GitHub Issues directly. + +### Setup +Add to your Claude Code settings (`~/.claude/settings.json`): +```json +{ + "mcpServers": { + "alert-menta": { + "command": "go", + "args": ["run", "./cmd/mcp/main.go", "-config", ".alert-menta.user.yaml"], + "cwd": "/path/to/alert-menta", + "env": { + "GITHUB_TOKEN": "your-github-token", + "OPENAI_API_KEY": "your-openai-api-key" + } + } + } +} +``` + +### Available Tools +- `get_incident`: Get incident information from a GitHub Issue +- `analyze_incident`: Run analysis commands (describe, suggest, analysis, postmortem, runbook, timeline) +- `post_comment`: Post a comment to a GitHub Issue +- `list_commands`: List all available commands + +### Usage Example +``` +> Get the details of Issue #123 in owner/repo +> Analyze Issue #123 using the analysis command +> Post a summary comment to Issue #123 +``` + ## Contribution We welcome you. Please submit pull requests to the develop branch. See [Branch strategy](https://github.com/3-shake/alert-menta/wiki/Branch-strategy) for more information. diff --git a/cmd/firstresponse/main.go b/cmd/firstresponse/main.go new file mode 100644 index 0000000..f8466bb --- /dev/null +++ b/cmd/firstresponse/main.go @@ -0,0 +1,151 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/3-shake/alert-menta/internal/firstresponse" + "github.com/3-shake/alert-menta/internal/github" + "github.com/3-shake/alert-menta/internal/utils" +) + +func main() { + // Parse command line flags + repo := flag.String("repo", "", "Repository name") + owner := flag.String("owner", "", "Repository owner") + issueNumber := flag.Int("issue", 0, "Issue number") + configFile := flag.String("config", ".alert-menta.user.yaml", "Configuration file") + ghToken := flag.String("github-token", "", "GitHub token (or set GITHUB_TOKEN env)") + dryRun := flag.Bool("dry-run", false, "Print guide without posting to GitHub") + flag.Parse() + + // Get GitHub token from environment if not provided + githubToken := *ghToken + if githubToken == "" { + githubToken = os.Getenv("GITHUB_TOKEN") + } + + // Validate required flags + if *repo == "" || *owner == "" || *issueNumber == 0 { + flag.PrintDefaults() + os.Exit(1) + } + + if githubToken == "" { + log.Fatal("GitHub token is required (--github-token or GITHUB_TOKEN env)") + } + + logger := log.New( + os.Stdout, "[alert-menta first-response] ", + log.Ldate|log.Ltime|log.Lmsgprefix, + ) + + // Load configuration + cfg, err := utils.NewConfig(*configFile) + if err != nil { + logger.Fatalf("Error loading config: %v", err) + } + + // Check if first response is enabled + if !cfg.FirstResponse.Enabled { + logger.Println("First response guide is disabled in config") + os.Exit(0) + } + + // Get issue information + issue := github.NewIssue(*owner, *repo, *issueNumber, githubToken) + + title, err := issue.GetTitle() + if err != nil { + logger.Fatalf("Failed to get issue title: %v", err) + } + + body, err := issue.GetBody() + if err != nil { + logger.Fatalf("Failed to get issue body: %v", err) + } + + labels, err := issue.GetLabels() + if err != nil { + logger.Fatalf("Failed to get issue labels: %v", err) + } + + // Check if should trigger based on labels + if !firstresponse.ShouldTrigger(labels, cfg.FirstResponse.TriggerLabels) { + logger.Printf("Issue labels %v don't match trigger labels %v, skipping", + labels, cfg.FirstResponse.TriggerLabels) + os.Exit(0) + } + + // Check if guide already exists + comments, err := issue.GetComments() + if err != nil { + logger.Fatalf("Failed to get comments: %v", err) + } + + var commentBodies []string + for _, c := range comments { + if c.Body != nil { + commentBodies = append(commentBodies, *c.Body) + } + } + + if firstresponse.HasExistingGuide(commentBodies) { + logger.Println("First response guide already exists, skipping") + os.Exit(0) + } + + // Build command list from config + var commands []firstresponse.CommandInfo + for name, cmd := range cfg.Ai.Commands { + commands = append(commands, firstresponse.CommandInfo{ + Name: name, + Description: cmd.Description, + }) + } + + // Create guide generator + frConfig := &firstresponse.Config{ + Enabled: cfg.FirstResponse.Enabled, + TriggerLabels: cfg.FirstResponse.TriggerLabels, + DefaultGuide: cfg.FirstResponse.DefaultGuide, + } + + for _, g := range cfg.FirstResponse.Guides { + frConfig.Guides = append(frConfig.Guides, firstresponse.GuideConfig{ + Severity: g.Severity, + Template: g.Template, + AutoNotify: g.AutoNotify, + }) + } + + generator := firstresponse.NewGenerator(frConfig, commands) + + // Generate guide + issueSummary := firstresponse.IssueSummary{ + Number: *issueNumber, + Title: *title, + URL: fmt.Sprintf("https://github.com/%s/%s/issues/%d", *owner, *repo, *issueNumber), + Labels: labels, + } + + guide, err := generator.Generate(issueSummary, *body) + if err != nil { + logger.Fatalf("Failed to generate guide: %v", err) + } + + if *dryRun { + fmt.Println("=== Generated Guide (dry-run) ===") + fmt.Println(guide) + os.Exit(0) + } + + // Post guide as comment + if err := issue.PostComment(guide); err != nil { + logger.Fatalf("Failed to post guide comment: %v", err) + } + + logger.Printf("First response guide posted to %s/%s#%d", *owner, *repo, *issueNumber) +} diff --git a/cmd/main.go b/cmd/main.go index 9684d98..90d9a69 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,23 +9,33 @@ import ( "github.com/3-shake/alert-menta/internal/ai" "github.com/3-shake/alert-menta/internal/github" + "github.com/3-shake/alert-menta/internal/slack" "github.com/3-shake/alert-menta/internal/utils" ) -// Struct to hold the command-line arguments +// Version information (set via ldflags) +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +// Config holds command-line arguments type Config struct { - repo string - owner string - issueNumber int - intent string - command string - configFile string - ghToken string - oaiKey string + repo string + owner string + issueNumber int + intent string + command string + configFile string + ghToken string + oaiKey string + slackWebhookURL string } func main() { cfg := &Config{} + showVersion := flag.Bool("version", false, "Show version information") flag.StringVar(&cfg.repo, "repo", "", "Repository name") flag.StringVar(&cfg.owner, "owner", "", "Repository owner") flag.IntVar(&cfg.issueNumber, "issue", 0, "Issue number") @@ -34,8 +44,16 @@ func main() { flag.StringVar(&cfg.configFile, "config", "", "Configuration file") flag.StringVar(&cfg.ghToken, "github-token", "", "GitHub token") flag.StringVar(&cfg.oaiKey, "api-key", "", "OpenAI api key") + flag.StringVar(&cfg.slackWebhookURL, "slack-webhook-url", "", "Slack webhook URL for notifications (optional)") flag.Parse() + if *showVersion { + fmt.Printf("alert-menta %s\n", version) + fmt.Printf(" commit: %s\n", commit) + fmt.Printf(" built: %s\n", date) + os.Exit(0) + } + if cfg.repo == "" || cfg.owner == "" || cfg.issueNumber == 0 || cfg.ghToken == "" || cfg.command == "" || cfg.configFile == "" { flag.PrintDefaults() os.Exit(1) @@ -118,6 +136,11 @@ func main() { if err := issue.PostComment(comment); err != nil { logger.Fatalf("Error creating comment: %v", err) } + + // Send Slack notification if configured + if err := sendSlackNotification(cfg, loadedcfg, issue, comment, logger); err != nil { + logger.Printf("Warning: failed to send Slack notification: %v", err) + } } // Validate the provided command @@ -207,22 +230,46 @@ func constructUserPrompt(ghToken string, issue *github.GitHubIssue, cfg *utils.C // Construct AI prompt func constructPrompt(command, intent, userPrompt string, imgs []ai.Image, cfg *utils.Config, logger *log.Logger) (*ai.Prompt, error) { + cmdConfig := cfg.Ai.Commands[command] var systemPrompt string - if cfg.Ai.Commands[command].RequireIntent { + if cmdConfig.RequireIntent { if intent == "" { return nil, fmt.Errorf("intent is required for '%s' command", command) } - systemPrompt = cfg.Ai.Commands[command].SystemPrompt + intent + "\n" + systemPrompt = cmdConfig.SystemPrompt + intent + "\n" } else { - systemPrompt = cfg.Ai.Commands[command].SystemPrompt + systemPrompt = cmdConfig.SystemPrompt } logger.Println("\x1b[34mPrompt: |\n", systemPrompt, userPrompt, "\x1b[0m") - return &ai.Prompt{UserPrompt: userPrompt, SystemPrompt: systemPrompt, Images: imgs}, nil + + prompt := &ai.Prompt{UserPrompt: userPrompt, SystemPrompt: systemPrompt, Images: imgs} + + // Add structured output options if configured + if cmdConfig.StructuredOutput != nil && cmdConfig.StructuredOutput.Enabled { + logger.Println("Structured output enabled for command:", command) + prompt.StructuredOutput = &ai.StructuredOutputOptions{ + Enabled: true, + SchemaName: cmdConfig.StructuredOutput.SchemaName, + Schema: cmdConfig.StructuredOutput.Schema, + } + } + + return prompt, nil } // Initialize AI client func getAIClient(oaiKey string, cfg *utils.Config, logger *log.Logger) (ai.Ai, error) { - switch cfg.Ai.Provider { + // Check if fallback is enabled + if cfg.Ai.Fallback.Enabled && len(cfg.Ai.Fallback.Providers) > 0 { + return getAIClientWithFallback(oaiKey, cfg, logger) + } + + return getSingleAIClient(cfg.Ai.Provider, oaiKey, cfg, logger) +} + +// getSingleAIClient creates a single AI client for the given provider +func getSingleAIClient(provider, oaiKey string, cfg *utils.Config, logger *log.Logger) (ai.Ai, error) { + switch provider { case "openai": if oaiKey == "" { return nil, fmt.Errorf("OpenAI API key is required") @@ -238,7 +285,95 @@ func getAIClient(oaiKey string, cfg *utils.Config, logger *log.Logger) (ai.Ai, e return nil, fmt.Errorf("new Vertex AI client: %w", err) } return aic, nil + case "anthropic": + if oaiKey == "" { + return nil, fmt.Errorf("anthropic API key is required") + } + logger.Println("Using Anthropic API") + logger.Println("Anthropic model:", cfg.Ai.Anthropic.Model) + return ai.NewAnthropicClient(oaiKey, cfg.Ai.Anthropic.Model), nil default: - return nil, fmt.Errorf("invalid provider: %s", cfg.Ai.Provider) + return nil, fmt.Errorf("invalid provider: %s", provider) + } +} + +// getAIClientWithFallback creates a fallback client with multiple providers +func getAIClientWithFallback(oaiKey string, cfg *utils.Config, logger *log.Logger) (ai.Ai, error) { + logger.Println("Fallback mode enabled") + logger.Printf("Provider order: %v", cfg.Ai.Fallback.Providers) + + var clients []ai.Ai + var names []string + + for _, provider := range cfg.Ai.Fallback.Providers { + client, err := getSingleAIClient(provider, oaiKey, cfg, logger) + if err != nil { + logger.Printf("Warning: could not initialize provider %s: %v", provider, err) + continue + } + clients = append(clients, client) + names = append(names, provider) + } + + if len(clients) == 0 { + return nil, fmt.Errorf("no valid providers configured for fallback") + } + + fallbackConfig := ai.FallbackClientConfig{ + MaxRetries: cfg.Ai.Fallback.Retry.MaxRetries, + DelayMs: cfg.Ai.Fallback.Retry.DelayMs, + Logger: logger, + } + + return ai.NewFallbackClient(clients, names, fallbackConfig), nil +} + +// sendSlackNotification sends a notification to Slack if configured +func sendSlackNotification(cfg *Config, loadedcfg *utils.Config, issue *github.GitHubIssue, response string, logger *log.Logger) error { + // Determine webhook URL (CLI flag takes precedence over config) + webhookURL := cfg.slackWebhookURL + if webhookURL == "" && loadedcfg.Notifications.Slack.Enabled { + webhookURL = loadedcfg.Notifications.Slack.WebhookURL + } + + // No Slack notification configured + if webhookURL == "" { + return nil + } + + // Check if command_response notification is enabled + if loadedcfg.Notifications.Slack.Enabled { + notifyOn := loadedcfg.Notifications.Slack.NotifyOn + if len(notifyOn) > 0 && !contains(notifyOn, "command_response") { + logger.Println("Slack notification skipped: command_response not in notify_on list") + return nil + } + } + + // Get issue details for notification + title, err := issue.GetTitle() + if err != nil { + return fmt.Errorf("getting issue title for Slack: %w", err) + } + + issueURL := fmt.Sprintf("https://github.com/%s/%s/issues/%d", cfg.owner, cfg.repo, cfg.issueNumber) + + // Create Slack client and send notification + slackClient := slack.NewClient(webhookURL, loadedcfg.Notifications.Slack.Channel) + if err := slackClient.SendCommandResponse(*title, issueURL, cfg.command, response); err != nil { + return fmt.Errorf("sending Slack notification: %w", err) + } + + logger.Println("Slack notification sent successfully") + return nil +} + +// contains checks if a string slice contains a specific string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } } + return false } diff --git a/cmd/mcp/main.go b/cmd/mcp/main.go new file mode 100644 index 0000000..4bbb473 --- /dev/null +++ b/cmd/mcp/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "flag" + "log" + "os" + + mcpserver "github.com/3-shake/alert-menta/internal/mcp" + "github.com/3-shake/alert-menta/internal/utils" +) + +func main() { + configFile := flag.String("config", ".alert-menta.user.yaml", "Configuration file path") + ghToken := flag.String("github-token", "", "GitHub token (or set GITHUB_TOKEN env)") + aiKey := flag.String("api-key", "", "AI API key (or set OPENAI_API_KEY/ANTHROPIC_API_KEY env)") + flag.Parse() + + // Get tokens from environment if not provided + githubToken := *ghToken + if githubToken == "" { + githubToken = os.Getenv("GITHUB_TOKEN") + } + + apiKey := *aiKey + if apiKey == "" { + apiKey = os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + apiKey = os.Getenv("ANTHROPIC_API_KEY") + } + } + + if githubToken == "" { + log.Fatal("GitHub token is required (--github-token or GITHUB_TOKEN env)") + } + + if apiKey == "" { + log.Fatal("AI API key is required (--api-key or OPENAI_API_KEY/ANTHROPIC_API_KEY env)") + } + + // Load configuration + cfg, err := utils.NewConfig(*configFile) + if err != nil { + log.Fatalf("Error loading config: %v", err) + } + + // Create and start MCP server + server := mcpserver.NewServer(cfg, githubToken, apiKey) + + if err := server.ServeStdio(); err != nil { + log.Fatalf("MCP server error: %v", err) + } +} diff --git a/cmd/triage/main.go b/cmd/triage/main.go new file mode 100644 index 0000000..4761010 --- /dev/null +++ b/cmd/triage/main.go @@ -0,0 +1,227 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/3-shake/alert-menta/internal/ai" + "github.com/3-shake/alert-menta/internal/github" + "github.com/3-shake/alert-menta/internal/triage" + "github.com/3-shake/alert-menta/internal/utils" +) + +// cliConfig holds command-line configuration +type cliConfig struct { + repo string + owner string + issueNumber int + configFile string + githubToken string + aiAPIKey string + dryRun bool +} + +func main() { + cfg := parseFlags() + logger := log.New(os.Stdout, "[alert-menta triage] ", log.Ldate|log.Ltime|log.Lmsgprefix) + + if err := run(cfg, logger); err != nil { + logger.Fatalf("Error: %v", err) + } +} + +func parseFlags() *cliConfig { + cfg := &cliConfig{} + flag.StringVar(&cfg.repo, "repo", "", "Repository name") + flag.StringVar(&cfg.owner, "owner", "", "Repository owner") + flag.IntVar(&cfg.issueNumber, "issue", 0, "Issue number") + flag.StringVar(&cfg.configFile, "config", ".alert-menta.user.yaml", "Configuration file") + ghToken := flag.String("github-token", "", "GitHub token (or set GITHUB_TOKEN env)") + apiKey := flag.String("api-key", "", "AI API key (or set OPENAI_API_KEY env)") + flag.BoolVar(&cfg.dryRun, "dry-run", false, "Print triage result without applying labels") + flag.Parse() + + cfg.githubToken = getEnvOrDefault(*ghToken, "GITHUB_TOKEN") + cfg.aiAPIKey = getEnvOrDefault(*apiKey, "OPENAI_API_KEY", "ANTHROPIC_API_KEY") + + return cfg +} + +func getEnvOrDefault(value string, envKeys ...string) string { + if value != "" { + return value + } + for _, key := range envKeys { + if v := os.Getenv(key); v != "" { + return v + } + } + return "" +} + +func run(cfg *cliConfig, logger *log.Logger) error { + if err := validateConfig(cfg); err != nil { + flag.PrintDefaults() + return err + } + + appConfig, err := utils.NewConfig(cfg.configFile) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if !appConfig.Triage.Enabled { + logger.Println("Triage is disabled in config") + return nil + } + + issue := github.NewIssue(cfg.owner, cfg.repo, cfg.issueNumber, cfg.githubToken) + title, body, existingLabels, err := getIssueInfo(issue) + if err != nil { + return err + } + + aiClient, err := getAIClient(cfg.aiAPIKey, appConfig) + if err != nil { + return fmt.Errorf("creating AI client: %w", err) + } + + triageConfig := buildTriageConfig(appConfig) + triager := triage.NewTriager(triageConfig, aiClient) + + logger.Printf("Triaging Issue #%d: %s", cfg.issueNumber, title) + + result, err := triager.Triage(title, body, existingLabels) + if err != nil { + return fmt.Errorf("triage failed: %w", err) + } + + logTriageResult(logger, result) + + if cfg.dryRun { + printDryRunResult(triager, result) + return nil + } + + return applyTriageResult(triageConfig, triager, result, issue, cfg.issueNumber, logger) +} + +func validateConfig(cfg *cliConfig) error { + if cfg.repo == "" || cfg.owner == "" || cfg.issueNumber == 0 { + return fmt.Errorf("repo, owner, and issue number are required") + } + if cfg.githubToken == "" { + return fmt.Errorf("github token is required (--github-token or GITHUB_TOKEN env)") + } + if cfg.aiAPIKey == "" { + return fmt.Errorf("ai API key is required (--api-key or OPENAI_API_KEY/ANTHROPIC_API_KEY env)") + } + return nil +} + +func getIssueInfo(issue *github.GitHubIssue) (string, string, []string, error) { + title, err := issue.GetTitle() + if err != nil { + return "", "", nil, fmt.Errorf("getting issue title: %w", err) + } + body, err := issue.GetBody() + if err != nil { + return "", "", nil, fmt.Errorf("getting issue body: %w", err) + } + labels, err := issue.GetLabels() + if err != nil { + return "", "", nil, fmt.Errorf("getting issue labels: %w", err) + } + return *title, *body, labels, nil +} + +func buildTriageConfig(cfg *utils.Config) *triage.Config { + triageConfig := &triage.Config{ + Enabled: cfg.Triage.Enabled, + AutoLabel: cfg.Triage.AutoLabel, + AutoComment: cfg.Triage.AutoComment, + ConfidenceThreshold: cfg.Triage.ConfidenceThreshold, + } + + for _, label := range cfg.Triage.Labels.Priority { + triageConfig.Labels.Priority = append(triageConfig.Labels.Priority, triage.LabelDefinition{ + Name: label.Name, + Criteria: label.Criteria, + }) + } + for _, label := range cfg.Triage.Labels.Category { + triageConfig.Labels.Category = append(triageConfig.Labels.Category, triage.LabelDefinition{ + Name: label.Name, + Criteria: label.Criteria, + }) + } + + if len(triageConfig.Labels.Priority) == 0 && len(triageConfig.Labels.Category) == 0 { + defaultConfig := triage.DefaultConfig() + triageConfig.Labels = defaultConfig.Labels + } + + if triageConfig.ConfidenceThreshold == 0 { + triageConfig.ConfidenceThreshold = 0.7 + } + + return triageConfig +} + +func logTriageResult(logger *log.Logger, result *triage.Result) { + logger.Printf("Triage result: Priority=%s (%.0f%%), Category=%s (%.0f%%)", + result.Priority.Label, result.Priority.Confidence*100, + result.Category.Label, result.Category.Confidence*100) + logger.Printf("Reasoning: %s", result.Reasoning) +} + +func printDryRunResult(triager *triage.Triager, result *triage.Result) { + fmt.Println("\n=== Triage Result (dry-run) ===") + fmt.Println(triager.FormatComment(result)) + fmt.Printf("\nLabels to apply: %v\n", triager.GetLabelsToApply(result)) +} + +func applyTriageResult(cfg *triage.Config, triager *triage.Triager, result *triage.Result, issue *github.GitHubIssue, issueNumber int, logger *log.Logger) error { + if cfg.AutoLabel { + if err := applyLabels(triager, result, issue, logger); err != nil { + logger.Printf("Warning: failed to add labels: %v", err) + } + } + + if cfg.AutoComment { + comment := triager.FormatComment(result) + if err := issue.PostComment(comment); err != nil { + return fmt.Errorf("posting comment: %w", err) + } + logger.Printf("Posted triage comment to Issue #%d", issueNumber) + } + + return nil +} + +func applyLabels(triager *triage.Triager, result *triage.Result, issue *github.GitHubIssue, logger *log.Logger) error { + labelsToApply := triager.GetLabelsToApply(result) + if len(labelsToApply) == 0 { + return nil + } + if err := issue.AddLabels(labelsToApply); err != nil { + return err + } + logger.Printf("Applied labels: %v", labelsToApply) + return nil +} + +func getAIClient(apiKey string, cfg *utils.Config) (ai.Ai, error) { + switch cfg.Ai.Provider { + case "openai": + return ai.NewOpenAIClient(apiKey, cfg.Ai.OpenAI.Model), nil + case "anthropic": + return ai.NewAnthropicClient(apiKey, cfg.Ai.Anthropic.Model), nil + case "vertexai": + return ai.NewVertexAIClient(cfg.Ai.VertexAI.Project, cfg.Ai.VertexAI.Region, cfg.Ai.VertexAI.Model) + default: + return nil, fmt.Errorf("invalid provider: %s", cfg.Ai.Provider) + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..14ed44c --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,74 @@ +services: + # Main CLI - run with: docker compose run --rm alert-menta + alert-menta: + build: + context: . + args: + VERSION: ${VERSION:-dev} + COMMIT: ${COMMIT:-unknown} + DATE: ${DATE:-unknown} + image: alert-menta:${VERSION:-latest} + environment: + - GITHUB_TOKEN + - OPENAI_API_KEY + - ANTHROPIC_API_KEY + volumes: + - ./.alert-menta.user.yaml:/app/config.yaml:ro + entrypoint: ["alert-menta"] + command: ["-config", "/app/config.yaml", "-help"] + read_only: true + security_opt: + - no-new-privileges:true + + # MCP Server - run with: docker compose up mcp + mcp: + build: + context: . + image: alert-menta:${VERSION:-latest} + environment: + - GITHUB_TOKEN + - OPENAI_API_KEY + - ANTHROPIC_API_KEY + volumes: + - ./.alert-menta.user.yaml:/app/config.yaml:ro + entrypoint: ["alert-menta-mcp"] + command: ["-config", "/app/config.yaml"] + stdin_open: true + tty: true + read_only: true + security_opt: + - no-new-privileges:true + + # Triage - run with: docker compose run --rm triage + triage: + build: + context: . + image: alert-menta:${VERSION:-latest} + environment: + - GITHUB_TOKEN + - OPENAI_API_KEY + - ANTHROPIC_API_KEY + volumes: + - ./.alert-menta.user.yaml:/app/config.yaml:ro + entrypoint: ["alert-menta-triage"] + command: ["-config", "/app/config.yaml", "-help"] + read_only: true + security_opt: + - no-new-privileges:true + + # First Response - run with: docker compose run --rm firstresponse + firstresponse: + build: + context: . + image: alert-menta:${VERSION:-latest} + environment: + - GITHUB_TOKEN + - OPENAI_API_KEY + - ANTHROPIC_API_KEY + volumes: + - ./.alert-menta.user.yaml:/app/config.yaml:ro + entrypoint: ["alert-menta-firstresponse"] + command: ["-config", "/app/config.yaml", "-help"] + read_only: true + security_opt: + - no-new-privileges:true diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index a090ee3..f2fc59a 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -5,10 +5,34 @@ package e2e import ( "os" "os/exec" + "path/filepath" + "runtime" "strings" "testing" ) +// Test configuration - can be overridden via environment variables +func getTestRepo() string { + if repo := os.Getenv("E2E_TEST_REPO"); repo != "" { + return repo + } + return "alert-menta-test" // default test repo +} + +func getTestOwner() string { + if owner := os.Getenv("E2E_TEST_OWNER"); owner != "" { + return owner + } + return "nwiizo" // default test owner +} + +func getTestIssue() string { + if issue := os.Getenv("E2E_TEST_ISSUE"); issue != "" { + return issue + } + return "1" // default test issue +} + func skipIfMissingEnv(t *testing.T) { t.Helper() if os.Getenv("GITHUB_TOKEN") == "" { @@ -19,27 +43,37 @@ func skipIfMissingEnv(t *testing.T) { } } -func runCommand(t *testing.T, command string, args ...string) (string, error) { +func getProjectRoot() string { + // Get the directory of the test file, then go up one level + _, filename, _, _ := runtime.Caller(0) + return filepath.Dir(filepath.Dir(filename)) +} + +func runCommand(t *testing.T, args ...string) (string, error) { t.Helper() + projectRoot := getProjectRoot() cmd := exec.Command("go", append([]string{"run", "./cmd/main.go"}, args...)...) - cmd.Dir = ".." + cmd.Dir = projectRoot cmd.Env = os.Environ() output, err := cmd.CombinedOutput() return string(output), err } +func getConfigPath() string { + return filepath.Join(getProjectRoot(), ".alert-menta.user.yaml") +} + func TestE2E_DescribeCommand(t *testing.T) { skipIfMissingEnv(t) output, err := runCommand(t, - "go", "run", "./cmd/main.go", - "-repo", "alert-menta", - "-owner", "3-shake", - "-issue", "1", + "-repo", getTestRepo(), + "-owner", getTestOwner(), + "-issue", getTestIssue(), "-github-token", os.Getenv("GITHUB_TOKEN"), "-api-key", os.Getenv("OPENAI_API_KEY"), "-command", "describe", - "-config", ".alert-menta.user.yaml", + "-config", getConfigPath(), ) if err != nil { t.Fatalf("E2E describe command failed: %v\nOutput: %s", err, output) @@ -54,14 +88,13 @@ func TestE2E_SuggestCommand(t *testing.T) { skipIfMissingEnv(t) output, err := runCommand(t, - "go", "run", "./cmd/main.go", - "-repo", "alert-menta", - "-owner", "3-shake", - "-issue", "1", + "-repo", getTestRepo(), + "-owner", getTestOwner(), + "-issue", getTestIssue(), "-github-token", os.Getenv("GITHUB_TOKEN"), "-api-key", os.Getenv("OPENAI_API_KEY"), "-command", "suggest", - "-config", ".alert-menta.user.yaml", + "-config", getConfigPath(), ) if err != nil { t.Fatalf("E2E suggest command failed: %v\nOutput: %s", err, output) @@ -76,15 +109,14 @@ func TestE2E_AskCommand(t *testing.T) { skipIfMissingEnv(t) output, err := runCommand(t, - "go", "run", "./cmd/main.go", - "-repo", "alert-menta", - "-owner", "3-shake", - "-issue", "1", + "-repo", getTestRepo(), + "-owner", getTestOwner(), + "-issue", getTestIssue(), "-github-token", os.Getenv("GITHUB_TOKEN"), "-api-key", os.Getenv("OPENAI_API_KEY"), "-command", "ask", "-intent", "What is the summary of this issue?", - "-config", ".alert-menta.user.yaml", + "-config", getConfigPath(), ) if err != nil { t.Fatalf("E2E ask command failed: %v\nOutput: %s", err, output) @@ -99,14 +131,13 @@ func TestE2E_AnalysisCommand(t *testing.T) { skipIfMissingEnv(t) output, err := runCommand(t, - "go", "run", "./cmd/main.go", - "-repo", "alert-menta", - "-owner", "3-shake", - "-issue", "1", + "-repo", getTestRepo(), + "-owner", getTestOwner(), + "-issue", getTestIssue(), "-github-token", os.Getenv("GITHUB_TOKEN"), "-api-key", os.Getenv("OPENAI_API_KEY"), "-command", "analysis", - "-config", ".alert-menta.user.yaml", + "-config", getConfigPath(), ) if err != nil { t.Fatalf("E2E analysis command failed: %v\nOutput: %s", err, output) @@ -116,3 +147,66 @@ func TestE2E_AnalysisCommand(t *testing.T) { t.Errorf("Expected response output, got: %s", output) } } + +func TestE2E_PostmortemCommand(t *testing.T) { + skipIfMissingEnv(t) + + output, err := runCommand(t, + "-repo", getTestRepo(), + "-owner", getTestOwner(), + "-issue", getTestIssue(), + "-github-token", os.Getenv("GITHUB_TOKEN"), + "-api-key", os.Getenv("OPENAI_API_KEY"), + "-command", "postmortem", + "-config", getConfigPath(), + ) + if err != nil { + t.Fatalf("E2E postmortem command failed: %v\nOutput: %s", err, output) + } + + if !strings.Contains(output, "Response:") { + t.Errorf("Expected response output, got: %s", output) + } +} + +func TestE2E_RunbookCommand(t *testing.T) { + skipIfMissingEnv(t) + + output, err := runCommand(t, + "-repo", getTestRepo(), + "-owner", getTestOwner(), + "-issue", getTestIssue(), + "-github-token", os.Getenv("GITHUB_TOKEN"), + "-api-key", os.Getenv("OPENAI_API_KEY"), + "-command", "runbook", + "-config", getConfigPath(), + ) + if err != nil { + t.Fatalf("E2E runbook command failed: %v\nOutput: %s", err, output) + } + + if !strings.Contains(output, "Response:") { + t.Errorf("Expected response output, got: %s", output) + } +} + +func TestE2E_TimelineCommand(t *testing.T) { + skipIfMissingEnv(t) + + output, err := runCommand(t, + "-repo", getTestRepo(), + "-owner", getTestOwner(), + "-issue", getTestIssue(), + "-github-token", os.Getenv("GITHUB_TOKEN"), + "-api-key", os.Getenv("OPENAI_API_KEY"), + "-command", "timeline", + "-config", getConfigPath(), + ) + if err != nil { + t.Fatalf("E2E timeline command failed: %v\nOutput: %s", err, output) + } + + if !strings.Contains(output, "Response:") { + t.Errorf("Expected response output, got: %s", output) + } +} diff --git a/go.mod b/go.mod index 9028084..514145e 100644 --- a/go.mod +++ b/go.mod @@ -1,68 +1,101 @@ module github.com/3-shake/alert-menta -go 1.23.0 +go 1.24.0 toolchain go1.24.12 require ( cloud.google.com/go/vertexai v0.13.2 github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.1 - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 + github.com/anthropics/anthropic-sdk-go v1.19.0 github.com/google/go-github v17.0.0+incompatible + github.com/mark3labs/mcp-go v0.43.2 github.com/spf13/viper v1.19.0 - golang.org/x/oauth2 v0.27.0 + golang.org/x/oauth2 v0.34.0 ) require ( - cloud.google.com/go v0.116.0 // indirect - cloud.google.com/go/aiplatform v1.68.0 // indirect - cloud.google.com/go/auth v0.9.9 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect - cloud.google.com/go/compute/metadata v0.5.2 // indirect - cloud.google.com/go/iam v1.2.1 // indirect - cloud.google.com/go/longrunning v0.6.1 // indirect + cloud.google.com/go v0.121.2 // indirect + cloud.google.com/go/aiplatform v1.89.0 // indirect + cloud.google.com/go/auth v0.16.5 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/longrunning v0.6.7 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/ccojocar/zxcvbn-go v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/s2a-go v0.1.8 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect - github.com/googleapis/gax-go/v2 v2.13.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gookit/color v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/openai/openai-go/v3 v3.8.1 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/securego/gosec/v2 v2.22.11 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect - go.opentelemetry.io/otel v1.29.0 // indirect - go.opentelemetry.io/otel/metric v1.29.0 // indirect - go.opentelemetry.io/otel/trace v1.29.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.28.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/net v0.30.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect - golang.org/x/time v0.7.0 // indirect - google.golang.org/api v0.203.0 // indirect - google.golang.org/genproto v0.0.0-20241015192408-796eee8c2d53 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect - google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.35.1 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.40.0 // indirect + golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect + golang.org/x/vuln v1.1.4 // indirect + google.golang.org/api v0.239.0 // indirect + google.golang.org/genai v1.37.0 // indirect + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + mvdan.cc/gofumpt v0.9.2 // indirect +) + +tool ( + github.com/securego/gosec/v2/cmd/gosec + golang.org/x/vuln/cmd/govulncheck + mvdan.cc/gofumpt ) diff --git a/go.sum b/go.sum index 5016ece..43d0ac8 100644 --- a/go.sum +++ b/go.sum @@ -1,42 +1,50 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= -cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/aiplatform v1.68.0 h1:EPPqgHDJpBZKRvv+OsB3cr0jYz3EL2pZ+802rBPcG8U= -cloud.google.com/go/aiplatform v1.68.0/go.mod h1:105MFA3svHjC3Oazl7yjXAmIR89LKhRAeNdnDKJczME= -cloud.google.com/go/auth v0.9.9 h1:BmtbpNQozo8ZwW2t7QJjnrQtdganSdmqeIBxHxNkEZQ= -cloud.google.com/go/auth v0.9.9/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= -cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= -cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= -cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= -cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= -cloud.google.com/go/iam v1.2.1 h1:QFct02HRb7H12J/3utj0qf5tobFh9V4vR6h9eX5EBRU= -cloud.google.com/go/iam v1.2.1/go.mod h1:3VUIJDPpwT6p/amXRC5GY8fCCh70lxPygguVtI0Z4/g= -cloud.google.com/go/longrunning v0.6.1 h1:lOLTFxYpr8hcRtcwWir5ITh1PAKUD/sG2lKrTSYjyMc= -cloud.google.com/go/longrunning v0.6.1/go.mod h1:nHISoOZpBcmlwbJmiVk5oDRz0qG/ZxPynEGs1iZ79s0= +cloud.google.com/go v0.121.2 h1:v2qQpN6Dx9x2NmwrqlesOt3Ys4ol5/lFZ6Mg1B7OJCg= +cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw= +cloud.google.com/go/aiplatform v1.89.0 h1:niSJYc6ldWWVM9faXPo1Et1MVSQoLvVGriD7fwbJdtE= +cloud.google.com/go/aiplatform v1.89.0/go.mod h1:TzZtegPkinfXTtXVvZZpxx7noINFMVDrLkE7cEWhYEk= +cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= +cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= cloud.google.com/go/vertexai v0.13.2 h1:dOnvkMDZy3GdKAz8Isd2d6KV3jQpk6CKvYao1SIupuk= cloud.google.com/go/vertexai v0.13.2/go.mod h1:+nmz1z8AeYILA5QM2yii3CED1PqGknZH1CUNDVatIg4= github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.1 h1:6njivKrpo02SQ3CsaGKIFh0c5ZhQyzjVhBmLIl84h4Q= github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.1/go.mod h1:W+7E7pJtvdzscy/I4tqL5C0/weLsa32wyTbHbPdkkv0= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 h1:JZg6HRh6W6U4OLl6lk7BZ7BLisIzM9dG1R50zUk9C/M= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0/go.mod h1:YL1xnZ6QejvQHWJrX/AvhFl4WW4rqHVoKspWNVwFk0M= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE= +github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= +github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -44,52 +52,50 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= -github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 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.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= -github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= -github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= -github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= +github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= +github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= +github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -98,28 +104,43 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= 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/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/openai/openai-go/v3 v3.8.1 h1:b+YWsmwqXnbpSHWQEntZAkKciBZ5CJXwL68j+l59UDg= +github.com/openai/openai-go/v3 v3.8.1/go.mod h1:UOpNxkqC9OdNXNUfpNByKOtB4jAL0EssQXq5p8gO0Xs= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg= +github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= @@ -127,109 +148,100 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= -go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= -go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= -go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= -go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= -go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +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/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= -golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -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.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= -golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= +golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I= +golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.203.0 h1:SrEeuwU3S11Wlscsn+LA1kb/Y5xT8uggJSkIhD08NAU= -google.golang.org/api v0.203.0/go.mod h1:BuOVyCSYEPwJb3npWvDnNmFI92f3GeRnHNkETneT3SI= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20241015192408-796eee8c2d53 h1:Df6WuGvthPzc+JiQ/G+m+sNX24kc0aTBqoDN/0yyykE= -google.golang.org/genproto v0.0.0-20241015192408-796eee8c2d53/go.mod h1:fheguH3Am2dGp1LfXkrvwqC/KlFq8F0nLq3LryOMrrE= -google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 h1:T6rh4haD3GVYsgEfWExoCZA2o2FmbNyKpTuAxbEFPTg= -google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:wp2WsuBYj6j8wUdo3ToZsdxxixbvQNAHqVJrTgi5E5M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= -google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo= +google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/genai v1.37.0 h1:dgp71k1wQ+/+APdZrN3LFgAGnVnr5IdTF1Oj0Dg+BQc= +google.golang.org/genai v1.37.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -238,5 +250,5 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= +mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= diff --git a/internal/ai/ai.go b/internal/ai/ai.go index 7be2110..70dc863 100644 --- a/internal/ai/ai.go +++ b/internal/ai/ai.go @@ -1,5 +1,7 @@ package ai +import "encoding/json" + type Ai interface { GetResponse(prompt *Prompt) (string, error) } @@ -9,8 +11,24 @@ type Image struct { Extension string } +// StructuredOutputOptions holds options for structured output +type StructuredOutputOptions struct { + Enabled bool + SchemaName string + Schema map[string]interface{} +} + type Prompt struct { - UserPrompt string - SystemPrompt string - Images []Image + UserPrompt string + SystemPrompt string + Images []Image + StructuredOutput *StructuredOutputOptions +} + +// GetSchemaJSON returns the schema as JSON bytes +func (s *StructuredOutputOptions) GetSchemaJSON() (json.RawMessage, error) { + if s == nil || s.Schema == nil { + return nil, nil + } + return json.Marshal(s.Schema) } diff --git a/internal/ai/anthropic.go b/internal/ai/anthropic.go new file mode 100644 index 0000000..a73abe3 --- /dev/null +++ b/internal/ai/anthropic.go @@ -0,0 +1,105 @@ +package ai + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" +) + +type Anthropic struct { + apiKey string + model string +} + +func (a *Anthropic) GetResponse(prompt *Prompt) (string, error) { + // Create a new Anthropic client + client := anthropic.NewClient( + option.WithAPIKey(a.apiKey), + ) + + // Build content blocks for the user message + var contentBlocks []anthropic.ContentBlockParamUnion + + // Add images first if present + for _, image := range prompt.Images { + mediaType := getMediaType(image.Extension) + base64Data := base64.StdEncoding.EncodeToString(image.Data) + + imageBlock := anthropic.NewImageBlockBase64(string(mediaType), base64Data) + contentBlocks = append(contentBlocks, imageBlock) + } + + // Add text content + contentBlocks = append(contentBlocks, anthropic.NewTextBlock(prompt.UserPrompt)) + + // Modify system prompt for structured output + systemPrompt := prompt.SystemPrompt + if prompt.StructuredOutput != nil && prompt.StructuredOutput.Enabled { + schemaJSON, err := prompt.StructuredOutput.GetSchemaJSON() + if err == nil && schemaJSON != nil { + systemPrompt += fmt.Sprintf("\n\nYou MUST respond with valid JSON that conforms to this schema:\n```json\n%s\n```\nRespond ONLY with the JSON object, no additional text.", string(schemaJSON)) + } else { + systemPrompt += "\n\nYou MUST respond with valid JSON. Respond ONLY with the JSON object, no additional text." + } + } + + // Create the message + message, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ + Model: anthropic.Model(a.model), + MaxTokens: 4096, + System: []anthropic.TextBlockParam{ + { + Type: "text", + Text: systemPrompt, + }, + }, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(contentBlocks...), + }, + }) + if err != nil { + return "", fmt.Errorf("Anthropic API error: %w", err) + } + + // Extract text from response + var response string + for _, block := range message.Content { + if block.Type == "text" { + response += block.Text + } + } + + // Validate JSON output if structured output is enabled + if prompt.StructuredOutput != nil && prompt.StructuredOutput.Enabled { + if !json.Valid([]byte(response)) { + return "", fmt.Errorf("structured output validation failed: response is not valid JSON") + } + } + + return response, nil +} + +func NewAnthropicClient(apiKey string, model string) *Anthropic { + return &Anthropic{ + apiKey: apiKey, + model: model, + } +} + +// getMediaType converts file extension to MIME type +func getMediaType(ext string) anthropic.Base64ImageSourceMediaType { + switch ext { + case "png": + return anthropic.Base64ImageSourceMediaTypeImagePNG + case "gif": + return anthropic.Base64ImageSourceMediaTypeImageGIF + case "webp": + return anthropic.Base64ImageSourceMediaTypeImageWebP + default: + return anthropic.Base64ImageSourceMediaTypeImageJPEG + } +} diff --git a/internal/ai/fallback.go b/internal/ai/fallback.go new file mode 100644 index 0000000..17844b7 --- /dev/null +++ b/internal/ai/fallback.go @@ -0,0 +1,128 @@ +package ai + +import ( + "fmt" + "log" + "strings" + "time" +) + +// FallbackClient wraps multiple AI clients and tries them in order +type FallbackClient struct { + clients []Ai + names []string + maxRetries int + delayMs int + logger *log.Logger +} + +// FallbackClientConfig holds configuration for creating a FallbackClient +type FallbackClientConfig struct { + MaxRetries int + DelayMs int + Logger *log.Logger +} + +// NewFallbackClient creates a new FallbackClient with the given clients +func NewFallbackClient(clients []Ai, names []string, config FallbackClientConfig) *FallbackClient { + maxRetries := config.MaxRetries + if maxRetries <= 0 { + maxRetries = 1 + } + + delayMs := config.DelayMs + if delayMs <= 0 { + delayMs = 1000 + } + + return &FallbackClient{ + clients: clients, + names: names, + maxRetries: maxRetries, + delayMs: delayMs, + logger: config.Logger, + } +} + +// GetResponse tries each client in order until one succeeds +func (f *FallbackClient) GetResponse(prompt *Prompt) (string, error) { + var allErrors []string + + for i, client := range f.clients { + providerName := f.names[i] + + for retry := 0; retry < f.maxRetries; retry++ { + if f.logger != nil { + if retry > 0 { + f.logger.Printf("Retry %d/%d for provider %s", retry+1, f.maxRetries, providerName) + } else { + f.logger.Printf("Trying provider: %s", providerName) + } + } + + response, err := client.GetResponse(prompt) + if err == nil { + if f.logger != nil && i > 0 { + f.logger.Printf("Successfully got response from fallback provider: %s", providerName) + } + return response, nil + } + + errMsg := fmt.Sprintf("%s (attempt %d): %v", providerName, retry+1, err) + allErrors = append(allErrors, errMsg) + + if f.logger != nil { + f.logger.Printf("Provider %s failed: %v", providerName, err) + } + + // Check if error is retryable + if !isRetryableError(err) { + if f.logger != nil { + f.logger.Printf("Error is not retryable, moving to next provider") + } + break + } + + // Wait before retry (but not after last retry) + if retry < f.maxRetries-1 { + time.Sleep(time.Duration(f.delayMs) * time.Millisecond) + } + } + } + + return "", fmt.Errorf("all providers failed: %s", strings.Join(allErrors, "; ")) +} + +// isRetryableError determines if an error should trigger a retry +func isRetryableError(err error) bool { + if err == nil { + return false + } + + errStr := strings.ToLower(err.Error()) + + // Retryable conditions + retryablePatterns := []string{ + "timeout", + "deadline exceeded", + "connection refused", + "connection reset", + "temporary failure", + "rate limit", + "429", // Too Many Requests + "500", // Internal Server Error + "502", // Bad Gateway + "503", // Service Unavailable + "504", // Gateway Timeout + "server error", + "service unavailable", + } + + for _, pattern := range retryablePatterns { + if strings.Contains(errStr, pattern) { + return true + } + } + + return false +} diff --git a/internal/ai/openai.go b/internal/ai/openai.go index 0c35cb8..ebff8f8 100644 --- a/internal/ai/openai.go +++ b/internal/ai/openai.go @@ -2,6 +2,7 @@ package ai import ( "context" + "encoding/json" "fmt" "github.com/3-shake/alert-menta/internal/utils" @@ -49,20 +50,33 @@ func (ai *OpenAI) GetResponse(prompt *Prompt) (string, error) { }, } - // Call the chat completion endpoint - resp, err := client.GetChatCompletions(context.TODO(), azopenai.ChatCompletionsOptions{ + // Build chat completion options + options := azopenai.ChatCompletionsOptions{ DeploymentName: &ai.model, Messages: messages, - }, nil) + } + + // Add structured output (JSON mode) if enabled + if prompt.StructuredOutput != nil && prompt.StructuredOutput.Enabled { + options.ResponseFormat = &azopenai.ChatCompletionsJSONResponseFormat{} + } + + // Call the chat completion endpoint + resp, err := client.GetChatCompletions(context.TODO(), options, nil) if err != nil { return "", fmt.Errorf("ChatCompletion error: %w", err) } - // Print the response - // resp.Choices[0].Message.Content is type *string with azopenai and type string with go-openai - // fmt.Println(*resp.Choices[0].Message.Content) + response := *resp.Choices[0].Message.Content + + // Validate JSON output if structured output is enabled + if prompt.StructuredOutput != nil && prompt.StructuredOutput.Enabled { + if !json.Valid([]byte(response)) { + return "", fmt.Errorf("structured output validation failed: response is not valid JSON") + } + } - return *resp.Choices[0].Message.Content, nil + return response, nil } func NewOpenAIClient(apiKey string, model string) *OpenAI { diff --git a/internal/ai/vertexai.go b/internal/ai/vertexai.go index 2c4adc0..f87318a 100644 --- a/internal/ai/vertexai.go +++ b/internal/ai/vertexai.go @@ -2,6 +2,7 @@ package ai import ( "context" + "encoding/json" "fmt" "reflect" @@ -19,11 +20,26 @@ func (ai *VertexAI) GetResponse(prompt *Prompt) (string, error) { // Temperature recommended by LLM model.SetTemperature(0.5) + // Set JSON mode for structured output + if prompt.StructuredOutput != nil && prompt.StructuredOutput.Enabled { + model.ResponseMIMEType = "application/json" + } + integratedPrompt := []genai.Part{} // image + text prompt for _, image := range prompt.Images { integratedPrompt = append(integratedPrompt, genai.ImageData(image.Extension, image.Data)) } - integratedPrompt = append(integratedPrompt, genai.Text(prompt.SystemPrompt+prompt.UserPrompt)) + + // Modify prompt for structured output + systemPrompt := prompt.SystemPrompt + if prompt.StructuredOutput != nil && prompt.StructuredOutput.Enabled { + schemaJSON, err := prompt.StructuredOutput.GetSchemaJSON() + if err == nil && schemaJSON != nil { + systemPrompt += fmt.Sprintf("\n\nRespond with valid JSON that conforms to this schema:\n%s", string(schemaJSON)) + } + } + + integratedPrompt = append(integratedPrompt, genai.Text(systemPrompt+prompt.UserPrompt)) // Generate AI response resp, err := model.GenerateContent(ai.context, integratedPrompt...) @@ -31,7 +47,16 @@ func (ai *VertexAI) GetResponse(prompt *Prompt) (string, error) { return "", fmt.Errorf("GenerateContent error: %w", err) } - return getResponseText(resp), nil + response := getResponseText(resp) + + // Validate JSON output if structured output is enabled + if prompt.StructuredOutput != nil && prompt.StructuredOutput.Enabled { + if !json.Valid([]byte(response)) { + return "", fmt.Errorf("structured output validation failed: response is not valid JSON") + } + } + + return response, nil } func getResponseText(resp *genai.GenerateContentResponse) string { diff --git a/internal/firstresponse/config.go b/internal/firstresponse/config.go new file mode 100644 index 0000000..36194d7 --- /dev/null +++ b/internal/firstresponse/config.go @@ -0,0 +1,51 @@ +package firstresponse + +import "time" + +// Config holds the first response guide configuration +type Config struct { + Enabled bool `yaml:"enabled" mapstructure:"enabled"` + TriggerLabels []string `yaml:"trigger_labels" mapstructure:"trigger_labels"` + Guides []GuideConfig `yaml:"guides" mapstructure:"guides"` + DefaultGuide string `yaml:"default_guide" mapstructure:"default_guide"` + Escalation EscalationConfig `yaml:"escalation" mapstructure:"escalation"` +} + +// GuideConfig holds configuration for a severity-specific guide +type GuideConfig struct { + Severity string `yaml:"severity" mapstructure:"severity"` + Template string `yaml:"template" mapstructure:"template"` + AutoNotify []string `yaml:"auto_notify" mapstructure:"auto_notify"` +} + +// EscalationConfig holds escalation settings +type EscalationConfig struct { + Timeout time.Duration `yaml:"timeout" mapstructure:"timeout"` + NotifyTarget string `yaml:"notify_target" mapstructure:"notify_target"` +} + +// TemplateData holds data for rendering guide templates +type TemplateData struct { + Issue IssueSummary + Severity string + SeverityEmoji string + OnCallTeam []string + SlackChannel string + EscalationTimeout string + Commands []CommandInfo +} + +// IssueSummary contains issue information for templates +type IssueSummary struct { + Number int + Title string + URL string + Author string + Labels []string +} + +// CommandInfo describes an available command +type CommandInfo struct { + Name string + Description string +} diff --git a/internal/firstresponse/guide.go b/internal/firstresponse/guide.go new file mode 100644 index 0000000..ebb93ca --- /dev/null +++ b/internal/firstresponse/guide.go @@ -0,0 +1,193 @@ +package firstresponse + +import ( + "bytes" + "fmt" + "os" + "text/template" +) + +// DefaultGuideTemplate is the default first response guide template +const DefaultGuideTemplate = ` +## {{.SeverityEmoji}} インシデント対応ガイド + +**重大度**: {{.Severity}} {{.SeverityEmoji}} +{{if .Issue.Title}}**Issue**: {{.Issue.Title}}{{end}} + +--- + +### Step 1: 状況確認 (今すぐ) +以下のコマンドで状況を把握してください: +` + "```" + ` +/describe +` + "```" + ` + +### Step 2: 影響範囲の確認 +- [ ] ユーザー影響の有無を確認 +- [ ] 影響を受けているサービス/リージョンを特定 +- [ ] 発生頻度を確認(継続中 or 断続的) + +### Step 3: コミュニケーション +{{if .SlackChannel}}- {{.SlackChannel}} チャンネルで報告{{else}}- Slackの #incidents チャンネルで報告{{end}} +- 必要に応じてエスカレーション +{{if .OnCallTeam}} +**オンコール担当**: {{range .OnCallTeam}}{{.}} {{end}} +{{end}} + +### Step 4: 調査開始 +類似インシデントと対応手順を確認: +` + "```" + ` +/runbook +` + "```" + ` + +根本原因を分析: +` + "```" + ` +/analysis +` + "```" + ` + +--- + +### 利用可能なコマンド +| コマンド | 説明 | +|----------|------| +{{range .Commands}}| ` + "`" + `/{{.Name}}` + "`" + ` | {{.Description}} | +{{end}} + +--- + +{{if .EscalationTimeout}}⏱️ 対応開始から{{.EscalationTimeout}}経過しても解決の目処が立たない場合は、エスカレーションを検討してください。{{end}} + +🤖 Generated by alert-menta first-response +` + +// HighSeverityTemplate is the template for high severity incidents +const HighSeverityTemplate = ` +## 🔴 緊急インシデント対応ガイド + +**重大度**: HIGH (Sev1) 🔴 +{{if .Issue.Title}}**Issue**: {{.Issue.Title}}{{end}} + +--- + +### ⚡ 即座に実行 + +1. **コミュニケーション開始** + {{if .SlackChannel}}- {{.SlackChannel}} に参加{{else}}- #incident-war-room に参加{{end}} + - ステータス更新を開始 + +2. **状況把握** + ` + "```" + ` + /describe + ` + "```" + ` + +3. **影響範囲の確認** + - [ ] 本番環境への影響を確認 + - [ ] ユーザー数/リクエスト数への影響 + - [ ] データ整合性の確認 + +### 🔍 調査フェーズ + +4. **根本原因分析** + ` + "```" + ` + /analysis + ` + "```" + ` + +5. **対応手順の確認** + ` + "```" + ` + /runbook + ` + "```" + ` + +### 📋 チェックリスト +- [ ] インシデント宣言完了 +- [ ] 関係者への通知完了 +- [ ] 原因特定 +- [ ] 一時対応実施 +- [ ] 恒久対応計画策定 + +--- + +{{if .OnCallTeam}}**エスカレーション先**: {{range .OnCallTeam}}{{.}} {{end}}{{end}} + +⏱️ **15分以内**に解決の目処が立たない場合は即座にエスカレーションしてください。 + +🤖 Generated by alert-menta first-response +` + +// Generator generates first response guides +type Generator struct { + config *Config + commands []CommandInfo +} + +// NewGenerator creates a new guide generator +func NewGenerator(config *Config, commands []CommandInfo) *Generator { + return &Generator{ + config: config, + commands: commands, + } +} + +// Generate generates a first response guide for the given issue +func (g *Generator) Generate(issue IssueSummary, body string) (string, error) { + // Determine severity + severity := DetermineSeverity(issue.Labels, body) + + // Prepare template data + data := TemplateData{ + Issue: issue, + Severity: string(severity), + SeverityEmoji: SeverityEmoji(severity), + Commands: g.commands, + } + + // Set escalation timeout based on severity + switch severity { + case SeverityHigh: + data.EscalationTimeout = "15分" + case SeverityMedium: + data.EscalationTimeout = "30分" + case SeverityLow: + data.EscalationTimeout = "1時間" + } + + // Find matching guide configuration + for _, guide := range g.config.Guides { + if guide.Severity == string(severity) { + data.OnCallTeam = guide.AutoNotify + // If custom template file is specified, try to load it + if guide.Template != "" { + content, err := os.ReadFile(guide.Template) + if err == nil { + return g.renderTemplate(string(content), data) + } + // Fall through to default if file not found + } + } + } + + // Use built-in template based on severity + var templateStr string + switch severity { + case SeverityHigh: + templateStr = HighSeverityTemplate + default: + templateStr = DefaultGuideTemplate + } + + return g.renderTemplate(templateStr, data) +} + +// renderTemplate renders a template with the given data +func (g *Generator) renderTemplate(templateStr string, data TemplateData) (string, error) { + tmpl, err := template.New("guide").Parse(templateStr) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} diff --git a/internal/firstresponse/severity.go b/internal/firstresponse/severity.go new file mode 100644 index 0000000..af05c52 --- /dev/null +++ b/internal/firstresponse/severity.go @@ -0,0 +1,98 @@ +package firstresponse + +import ( + "strings" +) + +// SeverityLevel represents incident severity +type SeverityLevel string + +const ( + SeverityHigh SeverityLevel = "high" + SeverityMedium SeverityLevel = "medium" + SeverityLow SeverityLevel = "low" +) + +// SeverityEmoji returns an emoji for the severity level +func SeverityEmoji(severity SeverityLevel) string { + switch severity { + case SeverityHigh: + return "🔴" + case SeverityMedium: + return "🟡" + case SeverityLow: + return "🟢" + default: + return "⚪" + } +} + +// DetermineSeverity determines the severity from issue labels and body +func DetermineSeverity(labels []string, body string) SeverityLevel { + // 1. Check labels first (explicit severity) + for _, label := range labels { + labelLower := strings.ToLower(label) + + // Check for severity prefix patterns + if strings.HasPrefix(labelLower, "severity:") || + strings.HasPrefix(labelLower, "sev:") || + strings.HasPrefix(labelLower, "priority:") { + parts := strings.SplitN(labelLower, ":", 2) + if len(parts) == 2 { + return parseSeverityValue(strings.TrimSpace(parts[1])) + } + } + + // Check for direct severity labels + switch labelLower { + case "critical", "sev1", "p0", "high", "urgent": + return SeverityHigh + case "major", "sev2", "p1", "medium", "warning": + return SeverityMedium + case "minor", "sev3", "sev4", "p2", "p3", "low": + return SeverityLow + } + } + + // 2. Infer from body content + bodyLower := strings.ToLower(body) + + // High severity indicators + highIndicators := []string{ + "production", "本番", "全ユーザー", "all users", + "service down", "サービス停止", "outage", "障害", + "data loss", "データ損失", "security", "セキュリティ", + "緊急", "urgent", "critical", + } + for _, indicator := range highIndicators { + if strings.Contains(bodyLower, indicator) { + return SeverityHigh + } + } + + // Low severity indicators + lowIndicators := []string{ + "staging", "ステージング", "development", "開発", + "test", "テスト", "minor", "cosmetic", + } + for _, indicator := range lowIndicators { + if strings.Contains(bodyLower, indicator) { + return SeverityLow + } + } + + // Default to medium + return SeverityMedium +} + +// parseSeverityValue parses a severity value string +func parseSeverityValue(value string) SeverityLevel { + switch strings.ToLower(value) { + case "high", "critical", "1", "sev1", "p0": + return SeverityHigh + case "low", "minor", "3", "4", "sev3", "sev4", "p2", "p3": + return SeverityLow + default: + return SeverityMedium + } +} diff --git a/internal/firstresponse/severity_test.go b/internal/firstresponse/severity_test.go new file mode 100644 index 0000000..b5cfc55 --- /dev/null +++ b/internal/firstresponse/severity_test.go @@ -0,0 +1,110 @@ +package firstresponse + +import "testing" + +func TestDetermineSeverity(t *testing.T) { + tests := []struct { + name string + labels []string + body string + expected SeverityLevel + }{ + { + name: "High severity from label", + labels: []string{"severity:high"}, + body: "", + expected: SeverityHigh, + }, + { + name: "High severity from sev1 label", + labels: []string{"sev1"}, + body: "", + expected: SeverityHigh, + }, + { + name: "High severity from critical label", + labels: []string{"critical"}, + body: "", + expected: SeverityHigh, + }, + { + name: "Medium severity from label", + labels: []string{"severity:medium"}, + body: "", + expected: SeverityMedium, + }, + { + name: "Low severity from label", + labels: []string{"severity:low"}, + body: "", + expected: SeverityLow, + }, + { + name: "Low severity from minor label", + labels: []string{"minor"}, + body: "", + expected: SeverityLow, + }, + { + name: "High severity from body - production", + labels: []string{}, + body: "Production server is down", + expected: SeverityHigh, + }, + { + name: "High severity from body - outage", + labels: []string{}, + body: "Service outage affecting all users", + expected: SeverityHigh, + }, + { + name: "Low severity from body - staging", + labels: []string{}, + body: "Issue in staging environment", + expected: SeverityLow, + }, + { + name: "Default to medium", + labels: []string{}, + body: "Some generic issue", + expected: SeverityMedium, + }, + { + name: "Label takes precedence over body", + labels: []string{"severity:low"}, + body: "Production server is down", + expected: SeverityLow, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetermineSeverity(tt.labels, tt.body) + if result != tt.expected { + t.Errorf("DetermineSeverity(%v, %q) = %v, want %v", + tt.labels, tt.body, result, tt.expected) + } + }) + } +} + +func TestSeverityEmoji(t *testing.T) { + tests := []struct { + severity SeverityLevel + expected string + }{ + {SeverityHigh, "🔴"}, + {SeverityMedium, "🟡"}, + {SeverityLow, "🟢"}, + } + + for _, tt := range tests { + t.Run(string(tt.severity), func(t *testing.T) { + result := SeverityEmoji(tt.severity) + if result != tt.expected { + t.Errorf("SeverityEmoji(%v) = %v, want %v", + tt.severity, result, tt.expected) + } + }) + } +} diff --git a/internal/firstresponse/trigger.go b/internal/firstresponse/trigger.go new file mode 100644 index 0000000..b47a7f9 --- /dev/null +++ b/internal/firstresponse/trigger.go @@ -0,0 +1,44 @@ +package firstresponse + +import ( + "strings" +) + +// ShouldTrigger checks if the first response guide should be triggered +func ShouldTrigger(issueLabels []string, triggerLabels []string) bool { + if len(triggerLabels) == 0 { + // No trigger labels configured, don't trigger + return false + } + + // Check if any of the trigger labels match + for _, triggerLabel := range triggerLabels { + triggerLower := strings.ToLower(triggerLabel) + for _, issueLabel := range issueLabels { + if strings.ToLower(issueLabel) == triggerLower { + return true + } + } + } + + return false +} + +// HasExistingGuide checks if a guide comment already exists +func HasExistingGuide(comments []string) bool { + guideMarkers := []string{ + "## 🚨 インシデント対応ガイド", + "## Incident Response Guide", + "", + } + + for _, comment := range comments { + for _, marker := range guideMarkers { + if strings.Contains(comment, marker) { + return true + } + } + } + + return false +} diff --git a/internal/firstresponse/trigger_test.go b/internal/firstresponse/trigger_test.go new file mode 100644 index 0000000..1c5cfa5 --- /dev/null +++ b/internal/firstresponse/trigger_test.go @@ -0,0 +1,103 @@ +package firstresponse + +import "testing" + +func TestShouldTrigger(t *testing.T) { + tests := []struct { + name string + issueLabels []string + triggerLabels []string + expected bool + }{ + { + name: "Match single label", + issueLabels: []string{"incident", "bug"}, + triggerLabels: []string{"incident"}, + expected: true, + }, + { + name: "Match one of multiple trigger labels", + issueLabels: []string{"alert"}, + triggerLabels: []string{"incident", "alert", "outage"}, + expected: true, + }, + { + name: "No match", + issueLabels: []string{"bug", "enhancement"}, + triggerLabels: []string{"incident", "alert"}, + expected: false, + }, + { + name: "Case insensitive match", + issueLabels: []string{"INCIDENT"}, + triggerLabels: []string{"incident"}, + expected: true, + }, + { + name: "Empty trigger labels", + issueLabels: []string{"incident"}, + triggerLabels: []string{}, + expected: false, + }, + { + name: "Empty issue labels", + issueLabels: []string{}, + triggerLabels: []string{"incident"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ShouldTrigger(tt.issueLabels, tt.triggerLabels) + if result != tt.expected { + t.Errorf("ShouldTrigger(%v, %v) = %v, want %v", + tt.issueLabels, tt.triggerLabels, result, tt.expected) + } + }) + } +} + +func TestHasExistingGuide(t *testing.T) { + tests := []struct { + name string + comments []string + expected bool + }{ + { + name: "Has guide marker", + comments: []string{"Some comment", "## 🚨 インシデント対応ガイド\nMore content"}, + expected: true, + }, + { + name: "Has English guide marker", + comments: []string{"## Incident Response Guide"}, + expected: true, + }, + { + name: "Has HTML marker", + comments: []string{"\nGuide content"}, + expected: true, + }, + { + name: "No guide", + comments: []string{"Some comment", "Another comment"}, + expected: false, + }, + { + name: "Empty comments", + comments: []string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HasExistingGuide(tt.comments) + if result != tt.expected { + t.Errorf("HasExistingGuide(%v) = %v, want %v", + tt.comments, result, tt.expected) + } + }) + } +} diff --git a/internal/github/github.go b/internal/github/github.go index 3932845..e182bf1 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -49,6 +49,20 @@ func (gh *GitHubIssue) GetTitle() (*string, error) { return issue.Title, err } +func (gh *GitHubIssue) GetLabels() ([]string, error) { + issue, err := gh.GetIssue() + if err != nil { + return nil, err + } + var labels []string + for _, label := range issue.Labels { + if label.Name != nil { + labels = append(labels, *label.Name) + } + } + return labels, nil +} + func (gh *GitHubIssue) GetComments() ([]*github.IssueComment, error) { // Options opt := &github.IssueListCommentsOptions{Direction: "asc", Sort: "created"} @@ -69,6 +83,18 @@ func (gh *GitHubIssue) PostComment(commentBody string) error { return nil } +func (gh *GitHubIssue) AddLabels(labels []string) error { + if len(labels) == 0 { + return nil + } + _, _, err := gh.client.Issues.AddLabelsToIssue(gh.ctx, gh.owner, gh.repo, gh.issueNumber, labels) + if err != nil { + return fmt.Errorf("error adding labels: %w", err) + } + gh.logger.Printf("Labels %v added successfully to Issue %d", labels, gh.issueNumber) + return nil +} + func NewIssue(owner string, repo string, issueNumber int, token string) *GitHubIssue { // Create GitHub client with OAuth2 token ctx := context.Background() diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..d72aab3 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,294 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/3-shake/alert-menta/internal/ai" + "github.com/3-shake/alert-menta/internal/github" + "github.com/3-shake/alert-menta/internal/utils" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// Server wraps the MCP server with alert-menta functionality +type Server struct { + mcpServer *server.MCPServer + config *utils.Config + githubToken string + aiKey string +} + +// NewServer creates a new MCP server for alert-menta +func NewServer(config *utils.Config, githubToken, aiKey string) *Server { + s := &Server{ + config: config, + githubToken: githubToken, + aiKey: aiKey, + } + + mcpServer := server.NewMCPServer( + "alert-menta", + "1.0.0", + server.WithToolCapabilities(true), + ) + + // Register tools + s.registerTools(mcpServer) + s.mcpServer = mcpServer + + return s +} + +// registerTools adds all alert-menta tools to the MCP server +func (s *Server) registerTools(mcpServer *server.MCPServer) { + // get_incident tool + mcpServer.AddTool( + mcp.NewTool("get_incident", + mcp.WithDescription("Get incident information from a GitHub Issue"), + mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), + mcp.WithNumber("issue_number", mcp.Required(), mcp.Description("Issue number")), + ), + s.handleGetIncident, + ) + + // analyze_incident tool + mcpServer.AddTool( + mcp.NewTool("analyze_incident", + mcp.WithDescription("Analyze incident using AI (describe, suggest, analysis, postmortem, runbook, timeline)"), + mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), + mcp.WithNumber("issue_number", mcp.Required(), mcp.Description("Issue number")), + mcp.WithString("command", mcp.Required(), + mcp.Description("Analysis command: describe, suggest, analysis, postmortem, runbook, timeline"), + ), + mcp.WithString("intent", mcp.Description("Additional intent for ask command")), + ), + s.handleAnalyzeIncident, + ) + + // post_comment tool + mcpServer.AddTool( + mcp.NewTool("post_comment", + mcp.WithDescription("Post a comment to a GitHub Issue"), + mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), + mcp.WithNumber("issue_number", mcp.Required(), mcp.Description("Issue number")), + mcp.WithString("body", mcp.Required(), mcp.Description("Comment body in Markdown")), + ), + s.handlePostComment, + ) + + // list_commands tool + mcpServer.AddTool( + mcp.NewTool("list_commands", + mcp.WithDescription("List all available alert-menta commands"), + ), + s.handleListCommands, + ) +} + +// handleGetIncident retrieves incident information from GitHub Issue +func (s *Server) handleGetIncident(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := request.GetArguments() + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + issueNum, _ := args["issue_number"].(float64) + + issue := github.NewIssue(owner, repo, int(issueNum), s.githubToken) + + title, err := issue.GetTitle() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get issue title: %v", err)), nil + } + + body, err := issue.GetBody() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get issue body: %v", err)), nil + } + + comments, err := issue.GetComments() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get comments: %v", err)), nil + } + + // Format response + result := fmt.Sprintf("## Issue #%d: %s\n\n", int(issueNum), *title) + result += fmt.Sprintf("**Body:**\n%s\n\n", *body) + result += fmt.Sprintf("**Comments (%d):**\n", len(comments)) + for i, c := range comments { + if c.User != nil && c.Body != nil { + result += fmt.Sprintf("\n### Comment %d by @%s\n%s\n", i+1, *c.User.Login, *c.Body) + } + } + + return mcp.NewToolResultText(result), nil +} + +// handleAnalyzeIncident runs an analysis command on the incident +func (s *Server) handleAnalyzeIncident(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := request.GetArguments() + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + issueNum, _ := args["issue_number"].(float64) + command, _ := args["command"].(string) + intent, _ := args["intent"].(string) + + // Validate command + cmdConfig, ok := s.config.Ai.Commands[command] + if !ok { + availableCommands := make([]string, 0, len(s.config.Ai.Commands)) + for cmd := range s.config.Ai.Commands { + availableCommands = append(availableCommands, cmd) + } + return mcp.NewToolResultError(fmt.Sprintf("Invalid command: %s. Available: %v", command, availableCommands)), nil + } + + // Check if intent is required + if cmdConfig.RequireIntent && intent == "" { + return mcp.NewToolResultError(fmt.Sprintf("Command '%s' requires an intent parameter", command)), nil + } + + // Get issue information + issue := github.NewIssue(owner, repo, int(issueNum), s.githubToken) + + title, err := issue.GetTitle() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get issue: %v", err)), nil + } + + body, err := issue.GetBody() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get issue body: %v", err)), nil + } + + comments, err := issue.GetComments() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get comments: %v", err)), nil + } + + // Build user prompt + userPrompt := fmt.Sprintf("Title: %s\nBody: %s\n", *title, *body) + for _, c := range comments { + if c.User != nil && c.Body != nil && *c.User.Login != "github-actions[bot]" { + userPrompt += fmt.Sprintf("%s: %s\n", *c.User.Login, *c.Body) + } + } + + // Build system prompt + systemPrompt := cmdConfig.SystemPrompt + if cmdConfig.RequireIntent && intent != "" { + systemPrompt = systemPrompt + intent + "\n" + } + + // Get AI client + aiClient, err := s.getAIClient() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to create AI client: %v", err)), nil + } + + // Get response + prompt := &ai.Prompt{ + UserPrompt: userPrompt, + SystemPrompt: systemPrompt, + } + + response, err := aiClient.GetResponse(prompt) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("AI error: %v", err)), nil + } + + return mcp.NewToolResultText(response), nil +} + +// handlePostComment posts a comment to the GitHub Issue +func (s *Server) handlePostComment(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := request.GetArguments() + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + issueNum, _ := args["issue_number"].(float64) + body, _ := args["body"].(string) + + issue := github.NewIssue(owner, repo, int(issueNum), s.githubToken) + + if err := issue.PostComment(body); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to post comment: %v", err)), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Successfully posted comment to %s/%s#%d", owner, repo, int(issueNum))), nil +} + +// handleListCommands lists all available commands +func (s *Server) handleListCommands(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + commands := make(map[string]string) + for cmd, config := range s.config.Ai.Commands { + commands[cmd] = config.Description + } + + result, _ := json.MarshalIndent(commands, "", " ") + return mcp.NewToolResultText(fmt.Sprintf("Available commands:\n%s", string(result))), nil +} + +// getAIClient creates an AI client based on configuration +func (s *Server) getAIClient() (ai.Ai, error) { + // Check if fallback is enabled + if s.config.Ai.Fallback.Enabled && len(s.config.Ai.Fallback.Providers) > 0 { + return s.getAIClientWithFallback() + } + + return s.getSingleAIClient(s.config.Ai.Provider) +} + +// getSingleAIClient creates a single AI client for the given provider +func (s *Server) getSingleAIClient(provider string) (ai.Ai, error) { + switch provider { + case "openai": + if s.aiKey == "" { + return nil, fmt.Errorf("OpenAI API key is required") + } + return ai.NewOpenAIClient(s.aiKey, s.config.Ai.OpenAI.Model), nil + case "anthropic": + if s.aiKey == "" { + return nil, fmt.Errorf("anthropic API key is required") + } + return ai.NewAnthropicClient(s.aiKey, s.config.Ai.Anthropic.Model), nil + case "vertexai": + return ai.NewVertexAIClient(s.config.Ai.VertexAI.Project, s.config.Ai.VertexAI.Region, s.config.Ai.VertexAI.Model) + default: + return nil, fmt.Errorf("invalid provider: %s", provider) + } +} + +// getAIClientWithFallback creates a fallback client with multiple providers +func (s *Server) getAIClientWithFallback() (ai.Ai, error) { + var clients []ai.Ai + var names []string + + for _, provider := range s.config.Ai.Fallback.Providers { + client, err := s.getSingleAIClient(provider) + if err != nil { + continue + } + clients = append(clients, client) + names = append(names, provider) + } + + if len(clients) == 0 { + return nil, fmt.Errorf("no valid providers configured for fallback") + } + + fallbackConfig := ai.FallbackClientConfig{ + MaxRetries: s.config.Ai.Fallback.Retry.MaxRetries, + DelayMs: s.config.Ai.Fallback.Retry.DelayMs, + Logger: nil, // MCP server doesn't use logger + } + + return ai.NewFallbackClient(clients, names, fallbackConfig), nil +} + +// ServeStdio starts the MCP server using stdio transport +func (s *Server) ServeStdio() error { + return server.ServeStdio(s.mcpServer) +} diff --git a/internal/slack/slack.go b/internal/slack/slack.go new file mode 100644 index 0000000..da7d0a1 --- /dev/null +++ b/internal/slack/slack.go @@ -0,0 +1,131 @@ +package slack + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Client handles Slack webhook notifications +type Client struct { + WebhookURL string + Channel string + HTTPClient *http.Client +} + +// NewClient creates a new Slack client +func NewClient(webhookURL, channel string) *Client { + return &Client{ + WebhookURL: webhookURL, + Channel: channel, + HTTPClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// Message represents a Slack message +type Message struct { + Channel string `json:"channel,omitempty"` + Text string `json:"text"` + Attachments []Attachment `json:"attachments,omitempty"` +} + +// Attachment represents a Slack message attachment +type Attachment struct { + Color string `json:"color"` + Title string `json:"title,omitempty"` + TitleLink string `json:"title_link,omitempty"` + Text string `json:"text"` + Footer string `json:"footer,omitempty"` + Fields []Field `json:"fields,omitempty"` + MrkdwnIn []string `json:"mrkdwn_in,omitempty"` +} + +// Field represents a field in a Slack attachment +type Field struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +// SendCommandResponse sends a notification about a command execution result +func (c *Client) SendCommandResponse(issueTitle, issueURL, command, response string) error { + // Truncate response if too long for Slack + maxLen := 2000 + displayResponse := response + if len(response) > maxLen { + displayResponse = response[:maxLen] + "\n\n... (truncated, see full response in GitHub Issue)" + } + + msg := Message{ + Text: fmt.Sprintf("🤖 alert-menta `/%s` command executed", command), + Attachments: []Attachment{ + { + Color: "#36a64f", + Title: issueTitle, + TitleLink: issueURL, + Text: displayResponse, + Footer: "alert-menta | GitHub Issue", + MrkdwnIn: []string{"text"}, + }, + }, + } + + if c.Channel != "" { + msg.Channel = c.Channel + } + + return c.send(msg) +} + +// SendIncidentNotification sends a notification about a new incident +func (c *Client) SendIncidentNotification(issueTitle, issueURL, summary string) error { + msg := Message{ + Text: "🚨 New Incident Created", + Attachments: []Attachment{ + { + Color: "danger", + Title: issueTitle, + TitleLink: issueURL, + Text: summary, + Footer: "alert-menta", + MrkdwnIn: []string{"text"}, + }, + }, + } + + if c.Channel != "" { + msg.Channel = c.Channel + } + + return c.send(msg) +} + +// send sends a message to Slack via webhook +func (c *Client) send(msg Message) error { + payload, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.WebhookURL, bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("slack webhook returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/slack/slack_test.go b/internal/slack/slack_test.go new file mode 100644 index 0000000..d220b29 --- /dev/null +++ b/internal/slack/slack_test.go @@ -0,0 +1,140 @@ +package slack + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewClient(t *testing.T) { + client := NewClient("https://hooks.slack.com/test", "#incidents") + + if client.WebhookURL != "https://hooks.slack.com/test" { + t.Errorf("expected webhook URL to be set") + } + if client.Channel != "#incidents" { + t.Errorf("expected channel to be #incidents") + } + if client.HTTPClient == nil { + t.Errorf("expected HTTP client to be initialized") + } +} + +func TestSendCommandResponse(t *testing.T) { + var receivedMessage Message + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected Content-Type application/json") + } + + if err := json.NewDecoder(r.Body).Decode(&receivedMessage); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(server.URL, "#test-channel") + err := client.SendCommandResponse( + "Test Issue", + "https://github.com/test/repo/issues/1", + "describe", + "This is a test response", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedMessage.Channel != "#test-channel" { + t.Errorf("expected channel #test-channel, got %s", receivedMessage.Channel) + } + if len(receivedMessage.Attachments) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(receivedMessage.Attachments)) + } + if receivedMessage.Attachments[0].Title != "Test Issue" { + t.Errorf("expected title 'Test Issue', got %s", receivedMessage.Attachments[0].Title) + } +} + +func TestSendIncidentNotification(t *testing.T) { + var receivedMessage Message + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&receivedMessage); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(server.URL, "") + err := client.SendIncidentNotification( + "Production Outage", + "https://github.com/test/repo/issues/2", + "API server is down", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedMessage.Text != "🚨 New Incident Created" { + t.Errorf("unexpected message text: %s", receivedMessage.Text) + } + if receivedMessage.Attachments[0].Color != "danger" { + t.Errorf("expected danger color for incident") + } +} + +func TestSendCommandResponse_TruncatesLongResponse(t *testing.T) { + var receivedMessage Message + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&receivedMessage); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(server.URL, "") + + // Create a very long response + longResponse := "" + for i := 0; i < 300; i++ { + longResponse += "This is a long response line. " + } + + err := client.SendCommandResponse( + "Test Issue", + "https://github.com/test/repo/issues/1", + "describe", + longResponse, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(receivedMessage.Attachments[0].Text) > 2100 { + t.Errorf("response was not truncated properly") + } +} + +func TestSend_ErrorOnNon200(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := NewClient(server.URL, "") + err := client.SendCommandResponse("Test", "http://test", "describe", "test") + + if err == nil { + t.Errorf("expected error for non-200 response") + } +} diff --git a/internal/triage/config.go b/internal/triage/config.go new file mode 100644 index 0000000..9417546 --- /dev/null +++ b/internal/triage/config.go @@ -0,0 +1,59 @@ +package triage + +// Config holds the triage configuration +type Config struct { + Enabled bool `yaml:"enabled" mapstructure:"enabled"` + AutoLabel bool `yaml:"auto_label" mapstructure:"auto_label"` + AutoComment bool `yaml:"auto_comment" mapstructure:"auto_comment"` + ConfidenceThreshold float64 `yaml:"confidence_threshold" mapstructure:"confidence_threshold"` + Labels TriageLabelConfig `yaml:"labels" mapstructure:"labels"` +} + +// TriageLabelConfig holds label configurations for triage +type TriageLabelConfig struct { + Priority []LabelDefinition `yaml:"priority" mapstructure:"priority"` + Category []LabelDefinition `yaml:"category" mapstructure:"category"` +} + +// LabelDefinition defines a label and its criteria +type LabelDefinition struct { + Name string `yaml:"name" mapstructure:"name"` + Criteria string `yaml:"criteria" mapstructure:"criteria"` +} + +// Result holds the triage result +type Result struct { + Priority LabelResult `json:"priority"` + Category LabelResult `json:"category"` + Reasoning string `json:"reasoning"` +} + +// LabelResult holds a single label result with confidence +type LabelResult struct { + Label string `json:"label"` + Confidence float64 `json:"confidence"` +} + +// DefaultConfig returns a default triage configuration +func DefaultConfig() *Config { + return &Config{ + Enabled: true, + AutoLabel: true, + AutoComment: true, + ConfidenceThreshold: 0.7, + Labels: TriageLabelConfig{ + Priority: []LabelDefinition{ + {Name: "priority:critical", Criteria: "Production service outage, data loss risk"}, + {Name: "priority:high", Criteria: "User impact, requires urgent attention"}, + {Name: "priority:medium", Criteria: "Feature degradation but workaround exists"}, + {Name: "priority:low", Criteria: "Improvement request, minor issue"}, + }, + Category: []LabelDefinition{ + {Name: "type:bug", Criteria: "Bug report for existing functionality"}, + {Name: "type:feature", Criteria: "New feature request"}, + {Name: "type:docs", Criteria: "Documentation update"}, + {Name: "type:incident", Criteria: "Incident report, alert"}, + }, + }, + } +} diff --git a/internal/triage/triage.go b/internal/triage/triage.go new file mode 100644 index 0000000..ef3ff31 --- /dev/null +++ b/internal/triage/triage.go @@ -0,0 +1,163 @@ +package triage + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/3-shake/alert-menta/internal/ai" +) + +// Triager performs AI-powered issue triage +type Triager struct { + config *Config + aiClient ai.Ai +} + +// NewTriager creates a new Triager +func NewTriager(config *Config, aiClient ai.Ai) *Triager { + return &Triager{ + config: config, + aiClient: aiClient, + } +} + +// Triage analyzes an issue and returns triage results +func (t *Triager) Triage(title, body string, existingLabels []string) (*Result, error) { + prompt := t.buildPrompt(title, body, existingLabels) + + response, err := t.aiClient.GetResponse(prompt) + if err != nil { + return nil, fmt.Errorf("AI triage failed: %w", err) + } + + return t.parseResponse(response) +} + +// buildPrompt creates the triage prompt +func (t *Triager) buildPrompt(title, body string, existingLabels []string) *ai.Prompt { + var sb strings.Builder + + sb.WriteString("You are an AI assistant that triages GitHub Issues.\n") + sb.WriteString("Analyze the following Issue and determine the appropriate priority and category.\n\n") + + sb.WriteString("Available priority labels:\n") + for _, label := range t.config.Labels.Priority { + sb.WriteString(fmt.Sprintf("- %s: %s\n", label.Name, label.Criteria)) + } + + sb.WriteString("\nAvailable category labels:\n") + for _, label := range t.config.Labels.Category { + sb.WriteString(fmt.Sprintf("- %s: %s\n", label.Name, label.Criteria)) + } + + sb.WriteString("\nRespond with ONLY a valid JSON object in this exact format:\n") + sb.WriteString(`{ + "priority": {"label": "priority:xxx", "confidence": 0.95}, + "category": {"label": "type:xxx", "confidence": 0.90}, + "reasoning": "Brief explanation of why these labels were chosen" +}`) + sb.WriteString("\n\nDo not include any text before or after the JSON object.\n") + + userPrompt := fmt.Sprintf("Title: %s\n\nBody:\n%s", title, body) + if len(existingLabels) > 0 { + userPrompt += fmt.Sprintf("\n\nExisting labels: %s", strings.Join(existingLabels, ", ")) + } + + return &ai.Prompt{ + SystemPrompt: sb.String(), + UserPrompt: userPrompt, + StructuredOutput: &ai.StructuredOutputOptions{ + Enabled: true, + SchemaName: "triage_result", + Schema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "priority": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "label": map[string]interface{}{"type": "string"}, + "confidence": map[string]interface{}{"type": "number"}, + }, + "required": []string{"label", "confidence"}, + }, + "category": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "label": map[string]interface{}{"type": "string"}, + "confidence": map[string]interface{}{"type": "number"}, + }, + "required": []string{"label", "confidence"}, + }, + "reasoning": map[string]interface{}{"type": "string"}, + }, + "required": []string{"priority", "category", "reasoning"}, + }, + }, + } +} + +// parseResponse parses the AI response into a Result +func (t *Triager) parseResponse(response string) (*Result, error) { + // Clean up response - remove markdown code blocks if present + response = strings.TrimSpace(response) + if strings.HasPrefix(response, "```json") { + response = strings.TrimPrefix(response, "```json") + response = strings.TrimSuffix(response, "```") + response = strings.TrimSpace(response) + } else if strings.HasPrefix(response, "```") { + response = strings.TrimPrefix(response, "```") + response = strings.TrimSuffix(response, "```") + response = strings.TrimSpace(response) + } + + var result Result + if err := json.Unmarshal([]byte(response), &result); err != nil { + return nil, fmt.Errorf("failed to parse triage response: %w\nResponse: %s", err, response) + } + + return &result, nil +} + +// FormatComment formats the triage result as a GitHub comment +func (t *Triager) FormatComment(result *Result) string { + var sb strings.Builder + + sb.WriteString("## 🤖 Auto-Triage Result\n\n") + + sb.WriteString("| Item | Label | Confidence |\n") + sb.WriteString("|------|-------|------------|\n") + sb.WriteString(fmt.Sprintf("| Priority | `%s` | %.0f%% |\n", + result.Priority.Label, result.Priority.Confidence*100)) + sb.WriteString(fmt.Sprintf("| Category | `%s` | %.0f%% |\n", + result.Category.Label, result.Category.Confidence*100)) + + sb.WriteString("\n### Reasoning\n") + sb.WriteString(result.Reasoning) + sb.WriteString("\n\n") + + sb.WriteString("---\n") + sb.WriteString("*This is an automated triage by AI. Please correct manually if needed.*\n") + + return sb.String() +} + +// ShouldApplyLabel checks if a label should be applied based on confidence threshold +func (t *Triager) ShouldApplyLabel(confidence float64) bool { + return confidence >= t.config.ConfidenceThreshold +} + +// GetLabelsToApply returns the labels that should be applied +func (t *Triager) GetLabelsToApply(result *Result) []string { + var labels []string + + if t.ShouldApplyLabel(result.Priority.Confidence) { + labels = append(labels, result.Priority.Label) + } + + if t.ShouldApplyLabel(result.Category.Confidence) { + labels = append(labels, result.Category.Label) + } + + return labels +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 2733b00..6d1bc2a 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -16,8 +16,68 @@ import ( // Root structure of information read from config file type Config struct { - System System `yaml:"system"` - Ai Ai `yaml:"ai"` + System System `yaml:"system"` + Ai Ai `yaml:"ai"` + Notifications Notifications `yaml:"notifications"` + FirstResponse FirstResponseConfig `yaml:"first_response" mapstructure:"first_response"` + Triage TriageConfig `yaml:"triage" mapstructure:"triage"` +} + +// TriageConfig holds auto-triage configuration +type TriageConfig struct { + Enabled bool `yaml:"enabled" mapstructure:"enabled"` + AutoLabel bool `yaml:"auto_label" mapstructure:"auto_label"` + AutoComment bool `yaml:"auto_comment" mapstructure:"auto_comment"` + ConfidenceThreshold float64 `yaml:"confidence_threshold" mapstructure:"confidence_threshold"` + Labels TriageLabelConfig `yaml:"labels" mapstructure:"labels"` +} + +// TriageLabelConfig holds label configurations for triage +type TriageLabelConfig struct { + Priority []TriageLabelDef `yaml:"priority" mapstructure:"priority"` + Category []TriageLabelDef `yaml:"category" mapstructure:"category"` +} + +// TriageLabelDef defines a label and its criteria +type TriageLabelDef struct { + Name string `yaml:"name" mapstructure:"name"` + Criteria string `yaml:"criteria" mapstructure:"criteria"` +} + +// FirstResponseConfig holds first response guide configuration +type FirstResponseConfig struct { + Enabled bool `yaml:"enabled" mapstructure:"enabled"` + TriggerLabels []string `yaml:"trigger_labels" mapstructure:"trigger_labels"` + Guides []FirstResponseGuide `yaml:"guides" mapstructure:"guides"` + DefaultGuide string `yaml:"default_guide" mapstructure:"default_guide"` + SlackChannel string `yaml:"slack_channel" mapstructure:"slack_channel"` + Escalation FirstResponseEscalation `yaml:"escalation" mapstructure:"escalation"` +} + +// FirstResponseGuide holds configuration for severity-specific guides +type FirstResponseGuide struct { + Severity string `yaml:"severity" mapstructure:"severity"` + Template string `yaml:"template" mapstructure:"template"` + AutoNotify []string `yaml:"auto_notify" mapstructure:"auto_notify"` +} + +// FirstResponseEscalation holds escalation settings +type FirstResponseEscalation struct { + TimeoutMinutes int `yaml:"timeout_minutes" mapstructure:"timeout_minutes"` + NotifyTarget string `yaml:"notify_target" mapstructure:"notify_target"` +} + +// Notifications holds notification configuration +type Notifications struct { + Slack SlackConfig `yaml:"slack"` +} + +// SlackConfig holds Slack notification settings +type SlackConfig struct { + Enabled bool `yaml:"enabled"` + WebhookURL string `yaml:"webhook_url" mapstructure:"webhook_url"` + Channel string `yaml:"channel"` + NotifyOn []string `yaml:"notify_on" mapstructure:"notify_on"` } type System struct { @@ -29,16 +89,41 @@ type SystemDebug struct { } type Ai struct { - Commands map[string]Command `yaml:"commands"` - Provider string `yaml:"provider"` - OpenAI OpenAI `yaml:"openai"` - VertexAI VertexAI `yaml:"vertexai"` + Commands map[string]Command `yaml:"commands"` + Provider string `yaml:"provider"` + OpenAI OpenAI `yaml:"openai"` + VertexAI VertexAI `yaml:"vertexai"` + Anthropic AnthropicConfig `yaml:"anthropic"` + Fallback FallbackConfig `yaml:"fallback"` +} + +// FallbackConfig holds fallback provider configuration +type FallbackConfig struct { + Enabled bool `yaml:"enabled"` + Providers []string `yaml:"providers" mapstructure:"providers"` + Retry RetryConfig `yaml:"retry"` +} + +// RetryConfig holds retry settings for fallback +type RetryConfig struct { + MaxRetries int `yaml:"max_retries" mapstructure:"max_retries"` + DelayMs int `yaml:"delay_ms" mapstructure:"delay_ms"` + TimeoutMs int `yaml:"timeout_ms" mapstructure:"timeout_ms"` } type Command struct { - Description string `yaml:"description"` - SystemPrompt string `yaml:"system_prompt" mapstructure:"system_prompt"` - RequireIntent bool `yaml:"require_intent" mapstructure:"require_intent"` + Description string `yaml:"description"` + SystemPrompt string `yaml:"system_prompt" mapstructure:"system_prompt"` + RequireIntent bool `yaml:"require_intent" mapstructure:"require_intent"` + StructuredOutput *StructuredOutputConfig `yaml:"structured_output,omitempty" mapstructure:"structured_output"` +} + +// StructuredOutputConfig holds structured output settings for a command +type StructuredOutputConfig struct { + Enabled bool `yaml:"enabled" mapstructure:"enabled"` + Schema map[string]interface{} `yaml:"schema,omitempty" mapstructure:"schema"` + SchemaName string `yaml:"schema_name,omitempty" mapstructure:"schema_name"` + FallbackToText bool `yaml:"fallback_to_text" mapstructure:"fallback_to_text"` } type OpenAI struct { @@ -51,6 +136,10 @@ type VertexAI struct { Region string `yaml:"region"` } +type AnthropicConfig struct { + Model string `yaml:"model"` +} + func NewConfig(filename string) (*Config, error) { // Initialize a logger logger := log.New(