Compare commits
15 Commits
52f50a1a06
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
4ae92b4f85
|
|||
|
4276ffbda5
|
|||
|
aff058dcc0
|
|||
|
dcaeb1f517
|
|||
|
fd40e73f1b
|
|||
| a0be405b76 | |||
|
75673974a2
|
|||
|
98bad6c9ba
|
|||
|
d024f128b5
|
|||
|
9b16a5fe86
|
|||
| 9dfe61e170 | |||
|
d97e554dfc
|
|||
|
859e35ab5c
|
|||
|
f4f859fefa
|
|||
| b491a60105 |
@@ -37,7 +37,8 @@
|
|||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"PROMETHEUS_URL": "https://prometheus.home.2rjus.net",
|
"PROMETHEUS_URL": "https://prometheus.home.2rjus.net",
|
||||||
"ALERTMANAGER_URL": "https://alertmanager.home.2rjus.net"
|
"ALERTMANAGER_URL": "https://alertmanager.home.2rjus.net",
|
||||||
|
"LOKI_URL": "http://monitoring01.home.2rjus.net:3100"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
CLAUDE.md
107
CLAUDE.md
@@ -21,9 +21,17 @@ Search and query NixOS configuration options. Uses nixpkgs as source.
|
|||||||
Search and query Home Manager configuration options. Uses home-manager repository as source.
|
Search and query Home Manager configuration options. Uses home-manager repository as source.
|
||||||
|
|
||||||
### Lab Monitoring (`lab-monitoring`)
|
### Lab Monitoring (`lab-monitoring`)
|
||||||
Query Prometheus metrics and Alertmanager alerts. Unlike other servers, this queries live HTTP APIs — no database or indexing needed.
|
Query Prometheus metrics, Alertmanager alerts, and Loki logs. Unlike other servers, this queries live HTTP APIs — no database or indexing needed.
|
||||||
- 8 tools: list/get alerts, search metrics, get metadata, PromQL query, list targets, list/create silences
|
- 8 core tools: list/get alerts, search metrics, get metadata, PromQL query, list targets, list/create silences
|
||||||
- Configurable Prometheus and Alertmanager URLs via flags or environment variables
|
- 3 optional Loki tools (when `LOKI_URL` is set): query_logs, list_labels, list_label_values
|
||||||
|
- Configurable Prometheus, Alertmanager, and Loki URLs via flags or environment variables
|
||||||
|
- Optional basic auth for Loki (`LOKI_USERNAME`/`LOKI_PASSWORD`)
|
||||||
|
|
||||||
|
### Git Explorer (`git-explorer`)
|
||||||
|
Read-only access to git repository information. Designed for deployment verification.
|
||||||
|
- 9 tools: resolve_ref, get_log, get_commit_info, get_diff_files, get_file_at_commit, is_ancestor, commits_between, list_branches, search_commits
|
||||||
|
- Uses go-git library for pure Go implementation
|
||||||
|
- All operations are read-only (never modifies repository)
|
||||||
|
|
||||||
The nixpkgs/options/hm servers share a database-backed architecture:
|
The nixpkgs/options/hm servers share a database-backed architecture:
|
||||||
- Full-text search across option/package names and descriptions
|
- Full-text search across option/package names and descriptions
|
||||||
@@ -38,14 +46,14 @@ The nixpkgs/options/hm servers share a database-backed architecture:
|
|||||||
- **Build System**: Nix flakes
|
- **Build System**: Nix flakes
|
||||||
- **Databases**: PostgreSQL and SQLite (both fully supported)
|
- **Databases**: PostgreSQL and SQLite (both fully supported)
|
||||||
- **Protocol**: MCP (Model Context Protocol) - JSON-RPC over STDIO or HTTP/SSE
|
- **Protocol**: MCP (Model Context Protocol) - JSON-RPC over STDIO or HTTP/SSE
|
||||||
- **Module Path**: `git.t-juice.club/torjus/labmcp`
|
- **Module Path**: `code.t-juice.club/torjus/labmcp`
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**Complete and maintained** - All core features implemented:
|
**Complete and maintained** - All core features implemented:
|
||||||
- Full MCP servers (6 tools each for nixpkgs/options, 8 tools for monitoring)
|
- Full MCP servers (6 tools each for nixpkgs/options, 8-11 tools for monitoring)
|
||||||
- PostgreSQL and SQLite backends with FTS (for nixpkgs/options servers)
|
- PostgreSQL and SQLite backends with FTS (for nixpkgs/options servers)
|
||||||
- Live API queries for Prometheus/Alertmanager (monitoring server)
|
- Live API queries for Prometheus/Alertmanager/Loki (monitoring server)
|
||||||
- NixOS modules for deployment
|
- NixOS modules for deployment
|
||||||
- CLI for manual operations
|
- CLI for manual operations
|
||||||
- Comprehensive test suite
|
- Comprehensive test suite
|
||||||
@@ -61,8 +69,10 @@ labmcp/
|
|||||||
│ │ └── main.go # NixOS options CLI (legacy)
|
│ │ └── main.go # NixOS options CLI (legacy)
|
||||||
│ ├── hm-options/
|
│ ├── hm-options/
|
||||||
│ │ └── main.go # Home Manager options CLI
|
│ │ └── main.go # Home Manager options CLI
|
||||||
│ └── lab-monitoring/
|
│ ├── lab-monitoring/
|
||||||
│ └── main.go # Prometheus/Alertmanager CLI
|
│ │ └── main.go # Prometheus/Alertmanager CLI
|
||||||
|
│ └── git-explorer/
|
||||||
|
│ └── main.go # Git repository explorer CLI
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── database/
|
│ ├── database/
|
||||||
│ │ ├── interface.go # Store interface (options + packages)
|
│ │ ├── interface.go # Store interface (options + packages)
|
||||||
@@ -95,17 +105,26 @@ labmcp/
|
|||||||
│ │ ├── parser.go # nix-env JSON parsing
|
│ │ ├── parser.go # nix-env JSON parsing
|
||||||
│ │ ├── types.go # Package types, channel aliases
|
│ │ ├── types.go # Package types, channel aliases
|
||||||
│ │ └── *_test.go # Parser tests
|
│ │ └── *_test.go # Parser tests
|
||||||
│ └── monitoring/
|
│ ├── monitoring/
|
||||||
│ ├── types.go # Prometheus/Alertmanager API types
|
│ │ ├── types.go # Prometheus/Alertmanager/Loki API types
|
||||||
│ ├── prometheus.go # Prometheus HTTP client
|
│ │ ├── prometheus.go # Prometheus HTTP client
|
||||||
│ ├── alertmanager.go # Alertmanager HTTP client
|
│ │ ├── alertmanager.go # Alertmanager HTTP client
|
||||||
|
│ │ ├── loki.go # Loki HTTP client
|
||||||
|
│ │ ├── handlers.go # MCP tool definitions + handlers
|
||||||
|
│ │ ├── format.go # Markdown formatting utilities
|
||||||
|
│ │ └── *_test.go # Tests (httptest-based)
|
||||||
|
│ └── gitexplorer/
|
||||||
|
│ ├── client.go # go-git repository wrapper
|
||||||
|
│ ├── types.go # Type definitions
|
||||||
│ ├── handlers.go # MCP tool definitions + handlers
|
│ ├── handlers.go # MCP tool definitions + handlers
|
||||||
│ ├── format.go # Markdown formatting utilities
|
│ ├── format.go # Markdown formatters
|
||||||
│ └── *_test.go # Tests (httptest-based)
|
│ ├── validation.go # Path validation
|
||||||
|
│ └── *_test.go # Tests
|
||||||
├── nix/
|
├── nix/
|
||||||
│ ├── module.nix # NixOS module for nixos-options
|
│ ├── module.nix # NixOS module for nixos-options
|
||||||
│ ├── hm-options-module.nix # NixOS module for hm-options
|
│ ├── hm-options-module.nix # NixOS module for hm-options
|
||||||
│ ├── lab-monitoring-module.nix # NixOS module for lab-monitoring
|
│ ├── lab-monitoring-module.nix # NixOS module for lab-monitoring
|
||||||
|
│ ├── git-explorer-module.nix # NixOS module for git-explorer
|
||||||
│ └── package.nix # Parameterized Nix package
|
│ └── package.nix # Parameterized Nix package
|
||||||
├── testdata/
|
├── testdata/
|
||||||
│ └── options-sample.json # Test fixture
|
│ └── options-sample.json # Test fixture
|
||||||
@@ -126,7 +145,7 @@ labmcp/
|
|||||||
| `search_options` | Full-text search across option names and descriptions |
|
| `search_options` | Full-text search across option names and descriptions |
|
||||||
| `get_option` | Get full details for a specific option with children |
|
| `get_option` | Get full details for a specific option with children |
|
||||||
| `get_file` | Fetch source file contents from indexed repository |
|
| `get_file` | Fetch source file contents from indexed repository |
|
||||||
| `index_revision` | Index a revision (by hash or channel name) |
|
| `index_revision` | Index a revision (options, files, and packages for nixpkgs) |
|
||||||
| `list_revisions` | List all indexed revisions |
|
| `list_revisions` | List all indexed revisions |
|
||||||
| `delete_revision` | Delete an indexed revision |
|
| `delete_revision` | Delete an indexed revision |
|
||||||
|
|
||||||
@@ -137,6 +156,7 @@ labmcp/
|
|||||||
| `search_packages` | Full-text search across package names and descriptions |
|
| `search_packages` | Full-text search across package names and descriptions |
|
||||||
| `get_package` | Get full details for a specific package by attr path |
|
| `get_package` | Get full details for a specific package by attr path |
|
||||||
| `get_file` | Fetch source file contents from nixpkgs |
|
| `get_file` | Fetch source file contents from nixpkgs |
|
||||||
|
| `index_revision` | Index a revision to make its packages searchable |
|
||||||
| `list_revisions` | List all indexed revisions |
|
| `list_revisions` | List all indexed revisions |
|
||||||
| `delete_revision` | Delete an indexed revision |
|
| `delete_revision` | Delete an indexed revision |
|
||||||
|
|
||||||
@@ -152,6 +172,23 @@ labmcp/
|
|||||||
| `list_targets` | List scrape targets with health status |
|
| `list_targets` | List scrape targets with health status |
|
||||||
| `list_silences` | List active/pending silences |
|
| `list_silences` | List active/pending silences |
|
||||||
| `create_silence` | Create a silence (confirms with user first) |
|
| `create_silence` | Create a silence (confirms with user first) |
|
||||||
|
| `query_logs` | Execute a LogQL range query against Loki (requires `LOKI_URL`) |
|
||||||
|
| `list_labels` | List available label names from Loki (requires `LOKI_URL`) |
|
||||||
|
| `list_label_values` | List values for a specific label from Loki (requires `LOKI_URL`) |
|
||||||
|
|
||||||
|
### Git Explorer Server (git-explorer)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `resolve_ref` | Resolve a git ref (branch, tag, commit) to its full commit hash |
|
||||||
|
| `get_log` | Get commit log with optional filters (author, path, limit) |
|
||||||
|
| `get_commit_info` | Get full details for a specific commit |
|
||||||
|
| `get_diff_files` | Get list of files changed between two commits |
|
||||||
|
| `get_file_at_commit` | Get file contents at a specific commit |
|
||||||
|
| `is_ancestor` | Check if one commit is an ancestor of another |
|
||||||
|
| `commits_between` | Get all commits between two refs |
|
||||||
|
| `list_branches` | List all branches in the repository |
|
||||||
|
| `search_commits` | Search commit messages for a pattern |
|
||||||
|
|
||||||
## Key Implementation Details
|
## Key Implementation Details
|
||||||
|
|
||||||
@@ -250,6 +287,24 @@ lab-monitoring alerts --state active # Filter by state
|
|||||||
lab-monitoring query 'up' # Instant PromQL query
|
lab-monitoring query 'up' # Instant PromQL query
|
||||||
lab-monitoring targets # List scrape targets
|
lab-monitoring targets # List scrape targets
|
||||||
lab-monitoring metrics node # Search metric names
|
lab-monitoring metrics node # Search metric names
|
||||||
|
lab-monitoring logs '{job="varlogs"}' # Query logs (requires LOKI_URL)
|
||||||
|
lab-monitoring logs '{job="nginx"} |= "error"' --start 2h --limit 50
|
||||||
|
lab-monitoring labels # List Loki labels
|
||||||
|
lab-monitoring labels --values job # List values for a label
|
||||||
|
```
|
||||||
|
|
||||||
|
### git-explorer
|
||||||
|
```bash
|
||||||
|
git-explorer serve # Run MCP server on STDIO
|
||||||
|
git-explorer serve --transport http # Run MCP server on HTTP
|
||||||
|
git-explorer --repo /path resolve <ref> # Resolve ref to commit hash
|
||||||
|
git-explorer --repo /path log --limit 10 # Show commit log
|
||||||
|
git-explorer --repo /path show <ref> # Show commit details
|
||||||
|
git-explorer --repo /path diff <from> <to> # Files changed between commits
|
||||||
|
git-explorer --repo /path cat <ref> <path> # File contents at commit
|
||||||
|
git-explorer --repo /path branches # List branches
|
||||||
|
git-explorer --repo /path search <query> # Search commit messages
|
||||||
|
git-explorer --version # Show version
|
||||||
```
|
```
|
||||||
|
|
||||||
### Channel Aliases
|
### Channel Aliases
|
||||||
@@ -263,6 +318,8 @@ lab-monitoring metrics node # Search metric names
|
|||||||
### Planning
|
### Planning
|
||||||
When creating implementation plans, the first step should usually be to **checkout an appropriately named feature branch** (e.g., `git checkout -b feature/lab-monitoring`). This keeps work isolated and makes PRs cleaner.
|
When creating implementation plans, the first step should usually be to **checkout an appropriately named feature branch** (e.g., `git checkout -b feature/lab-monitoring`). This keeps work isolated and makes PRs cleaner.
|
||||||
|
|
||||||
|
**After implementing a plan**, update the README.md to reflect any new or changed functionality (new servers, tools, CLI commands, configuration options, NixOS module options, etc.).
|
||||||
|
|
||||||
### Development Workflow
|
### Development Workflow
|
||||||
- **Always run `go fmt ./...` before committing Go code**
|
- **Always run `go fmt ./...` before committing Go code**
|
||||||
- **Run Go commands using `nix develop -c`** (e.g., `nix develop -c go test ./...`)
|
- **Run Go commands using `nix develop -c`** (e.g., `nix develop -c go test ./...`)
|
||||||
@@ -291,18 +348,20 @@ All three tools should pass with no issues before merging a feature branch.
|
|||||||
**IMPORTANT**: When running `nix build`, `nix run`, or similar commands, new files must be tracked by git first. Nix flakes only see git-tracked files. If you create new files, run `git add <file>` before attempting nix operations.
|
**IMPORTANT**: When running `nix build`, `nix run`, or similar commands, new files must be tracked by git first. Nix flakes only see git-tracked files. If you create new files, run `git add <file>` before attempting nix operations.
|
||||||
|
|
||||||
### Version Bumping
|
### Version Bumping
|
||||||
Version bumps should be done once per feature branch, not per commit. Rules:
|
Version bumps should be done once per feature branch, not per commit. **Only bump versions for packages that were actually changed** — different packages can have different version numbers.
|
||||||
|
|
||||||
|
Rules for determining bump type:
|
||||||
- **Patch bump** (0.1.0 → 0.1.1): Changes to Go code within `internal/` that affect a program
|
- **Patch bump** (0.1.0 → 0.1.1): Changes to Go code within `internal/` that affect a program
|
||||||
- **Minor bump** (0.1.0 → 0.2.0): Changes to Go code outside `internal/` (e.g., `cmd/`)
|
- **Minor bump** (0.1.0 → 0.2.0): Changes to Go code outside `internal/` (e.g., `cmd/`)
|
||||||
- **Major bump** (0.1.0 → 1.0.0): Breaking changes to CLI usage or MCP protocol
|
- **Major bump** (0.1.0 → 1.0.0): Breaking changes to CLI usage or MCP protocol
|
||||||
|
|
||||||
Version is defined in multiple places that must stay in sync:
|
Each package's version is defined in multiple places that must stay in sync *for that package*:
|
||||||
- `cmd/nixpkgs-search/main.go`
|
- **lab-monitoring**: `cmd/lab-monitoring/main.go` + `internal/mcp/server.go` (`DefaultMonitoringConfig`)
|
||||||
- `cmd/nixos-options/main.go`
|
- **nixpkgs-search**: `cmd/nixpkgs-search/main.go` + `internal/mcp/server.go` (`DefaultNixOSConfig`, `DefaultNixpkgsPackagesConfig`)
|
||||||
- `cmd/hm-options/main.go`
|
- **nixos-options**: `cmd/nixos-options/main.go` + `internal/mcp/server.go` (`DefaultNixOSConfig`)
|
||||||
- `cmd/lab-monitoring/main.go`
|
- **hm-options**: `cmd/hm-options/main.go` + `internal/mcp/server.go` (`DefaultHomeManagerConfig`)
|
||||||
- `internal/mcp/server.go` (in `DefaultNixOSConfig`, `DefaultHomeManagerConfig`, `DefaultNixpkgsPackagesConfig`, `DefaultMonitoringConfig`)
|
- **git-explorer**: `cmd/git-explorer/main.go` + `internal/mcp/server.go` (`DefaultGitExplorerConfig`)
|
||||||
- `nix/package.nix`
|
- **nix/package.nix**: Shared across all packages (bump to highest version when any package changes)
|
||||||
|
|
||||||
### User Preferences
|
### User Preferences
|
||||||
- User prefers PostgreSQL over SQLite (has homelab infrastructure)
|
- User prefers PostgreSQL over SQLite (has homelab infrastructure)
|
||||||
@@ -329,6 +388,7 @@ nix build .#nixpkgs-search
|
|||||||
nix build .#nixos-options
|
nix build .#nixos-options
|
||||||
nix build .#hm-options
|
nix build .#hm-options
|
||||||
nix build .#lab-monitoring
|
nix build .#lab-monitoring
|
||||||
|
nix build .#git-explorer
|
||||||
|
|
||||||
# Run directly
|
# Run directly
|
||||||
nix run .#nixpkgs-search -- options serve
|
nix run .#nixpkgs-search -- options serve
|
||||||
@@ -337,6 +397,7 @@ nix run .#nixpkgs-search -- index nixos-unstable
|
|||||||
nix run .#hm-options -- serve
|
nix run .#hm-options -- serve
|
||||||
nix run .#hm-options -- index hm-unstable
|
nix run .#hm-options -- index hm-unstable
|
||||||
nix run .#lab-monitoring -- serve
|
nix run .#lab-monitoring -- serve
|
||||||
|
nix run .#git-explorer -- --repo . serve
|
||||||
```
|
```
|
||||||
|
|
||||||
### Indexing Performance
|
### Indexing Performance
|
||||||
|
|||||||
285
README.md
285
README.md
@@ -16,11 +16,36 @@ Both servers share the same database, allowing you to index once and serve both.
|
|||||||
|
|
||||||
Search and query Home Manager configuration options across multiple home-manager revisions. Designed to help Claude (and other MCP clients) answer questions about Home Manager configuration.
|
Search and query Home Manager configuration options across multiple home-manager revisions. Designed to help Claude (and other MCP clients) answer questions about Home Manager configuration.
|
||||||
|
|
||||||
|
### Lab Monitoring (`lab-monitoring`)
|
||||||
|
|
||||||
|
Query Prometheus metrics, Alertmanager alerts, and Loki logs from your monitoring stack. Unlike other servers, this queries live HTTP APIs — no database or indexing needed.
|
||||||
|
|
||||||
|
- List and inspect alerts from Alertmanager
|
||||||
|
- Execute PromQL queries against Prometheus
|
||||||
|
- Search metric names with metadata
|
||||||
|
- View scrape target health
|
||||||
|
- Manage alert silences
|
||||||
|
- Query logs via LogQL (when Loki is configured)
|
||||||
|
|
||||||
|
### Git Explorer (`git-explorer`)
|
||||||
|
|
||||||
|
Read-only access to git repository information. Designed for deployment verification — comparing deployed flake revisions against source repositories.
|
||||||
|
|
||||||
|
- Resolve refs (branches, tags, commits) to commit hashes
|
||||||
|
- View commit logs with filtering by author, path, or range
|
||||||
|
- Get full commit details including file change statistics
|
||||||
|
- Compare commits to see which files changed
|
||||||
|
- Read file contents at any commit
|
||||||
|
- Check ancestry relationships between commits
|
||||||
|
- Search commit messages
|
||||||
|
|
||||||
|
All operations are read-only and will never modify the repository.
|
||||||
|
|
||||||
### NixOS Options (`nixos-options`) - Legacy
|
### NixOS Options (`nixos-options`) - Legacy
|
||||||
|
|
||||||
Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-search` instead, which includes this functionality plus package search.
|
Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-search` instead, which includes this functionality plus package search.
|
||||||
|
|
||||||
### Shared Features
|
### Shared Features (nixpkgs-search, hm-options, nixos-options)
|
||||||
|
|
||||||
- Full-text search across option/package names and descriptions
|
- Full-text search across option/package names and descriptions
|
||||||
- Query specific options/packages with full metadata
|
- Query specific options/packages with full metadata
|
||||||
@@ -34,19 +59,25 @@ Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-se
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the packages
|
# Build the packages
|
||||||
nix build git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search
|
nix build git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search
|
||||||
nix build git+https://git.t-juice.club/torjus/labmcp#hm-options
|
nix build git+https://code.t-juice.club/torjus/labmcp#hm-options
|
||||||
|
nix build git+https://code.t-juice.club/torjus/labmcp#lab-monitoring
|
||||||
|
nix build git+https://code.t-juice.club/torjus/labmcp#git-explorer
|
||||||
|
|
||||||
# Or run directly
|
# Or run directly
|
||||||
nix run git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search -- --help
|
nix run git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search -- --help
|
||||||
nix run git+https://git.t-juice.club/torjus/labmcp#hm-options -- --help
|
nix run git+https://code.t-juice.club/torjus/labmcp#hm-options -- --help
|
||||||
|
nix run git+https://code.t-juice.club/torjus/labmcp#lab-monitoring -- --help
|
||||||
|
nix run git+https://code.t-juice.club/torjus/labmcp#git-explorer -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
### From Source
|
### From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install git.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest
|
go install code.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest
|
||||||
go install git.t-juice.club/torjus/labmcp/cmd/hm-options@latest
|
go install code.t-juice.club/torjus/labmcp/cmd/hm-options@latest
|
||||||
|
go install code.t-juice.club/torjus/labmcp/cmd/lab-monitoring@latest
|
||||||
|
go install code.t-juice.club/torjus/labmcp/cmd/git-explorer@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -78,6 +109,24 @@ Configure in your MCP client (e.g., Claude Desktop):
|
|||||||
"env": {
|
"env": {
|
||||||
"HM_OPTIONS_DATABASE": "sqlite:///path/to/hm-options.db"
|
"HM_OPTIONS_DATABASE": "sqlite:///path/to/hm-options.db"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"lab-monitoring": {
|
||||||
|
"command": "lab-monitoring",
|
||||||
|
"args": ["serve"],
|
||||||
|
"env": {
|
||||||
|
"PROMETHEUS_URL": "http://prometheus.example.com:9090",
|
||||||
|
"ALERTMANAGER_URL": "http://alertmanager.example.com:9093",
|
||||||
|
"LOKI_URL": "http://loki.example.com:3100",
|
||||||
|
"LOKI_USERNAME": "optional-username",
|
||||||
|
"LOKI_PASSWORD": "optional-password"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-explorer": {
|
||||||
|
"command": "git-explorer",
|
||||||
|
"args": ["serve"],
|
||||||
|
"env": {
|
||||||
|
"GIT_REPO_PATH": "/path/to/your/repo"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,24 +139,40 @@ Alternatively, if you have Nix installed, you can use the flake directly without
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"nixpkgs-options": {
|
"nixpkgs-options": {
|
||||||
"command": "nix",
|
"command": "nix",
|
||||||
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "options", "serve"],
|
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "options", "serve"],
|
||||||
"env": {
|
"env": {
|
||||||
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
|
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-packages": {
|
"nixpkgs-packages": {
|
||||||
"command": "nix",
|
"command": "nix",
|
||||||
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "packages", "serve"],
|
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "packages", "serve"],
|
||||||
"env": {
|
"env": {
|
||||||
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
|
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hm-options": {
|
"hm-options": {
|
||||||
"command": "nix",
|
"command": "nix",
|
||||||
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#hm-options", "--", "serve"],
|
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#hm-options", "--", "serve"],
|
||||||
"env": {
|
"env": {
|
||||||
"HM_OPTIONS_DATABASE": "sqlite:///path/to/hm-options.db"
|
"HM_OPTIONS_DATABASE": "sqlite:///path/to/hm-options.db"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"lab-monitoring": {
|
||||||
|
"command": "nix",
|
||||||
|
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#lab-monitoring", "--", "serve"],
|
||||||
|
"env": {
|
||||||
|
"PROMETHEUS_URL": "http://prometheus.example.com:9090",
|
||||||
|
"ALERTMANAGER_URL": "http://alertmanager.example.com:9093",
|
||||||
|
"LOKI_URL": "http://loki.example.com:3100"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-explorer": {
|
||||||
|
"command": "nix",
|
||||||
|
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#git-explorer", "--", "serve"],
|
||||||
|
"env": {
|
||||||
|
"GIT_REPO_PATH": "/path/to/your/repo"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,10 +183,12 @@ Alternatively, if you have Nix installed, you can use the flake directly without
|
|||||||
All servers can run over HTTP with Server-Sent Events (SSE) for web-based MCP clients:
|
All servers can run over HTTP with Server-Sent Events (SSE) for web-based MCP clients:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start HTTP server on default address (127.0.0.1:8080)
|
# Start HTTP server on default address
|
||||||
nixpkgs-search options serve --transport http
|
nixpkgs-search options serve --transport http
|
||||||
nixpkgs-search packages serve --transport http
|
nixpkgs-search packages serve --transport http
|
||||||
hm-options serve --transport http
|
hm-options serve --transport http
|
||||||
|
lab-monitoring serve --transport http
|
||||||
|
git-explorer serve --transport http
|
||||||
|
|
||||||
# Custom address and CORS configuration
|
# Custom address and CORS configuration
|
||||||
nixpkgs-search options serve --transport http \
|
nixpkgs-search options serve --transport http \
|
||||||
@@ -208,6 +275,65 @@ nixpkgs-search packages get firefox
|
|||||||
hm-options get programs.git.enable
|
hm-options get programs.git.enable
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Lab Monitoring CLI:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List alerts (defaults to active only)
|
||||||
|
lab-monitoring alerts
|
||||||
|
lab-monitoring alerts --all # Include silenced/inhibited alerts
|
||||||
|
lab-monitoring alerts --state all # Same as --all
|
||||||
|
lab-monitoring alerts --severity critical
|
||||||
|
|
||||||
|
# Execute PromQL queries
|
||||||
|
lab-monitoring query 'up'
|
||||||
|
lab-monitoring query 'rate(http_requests_total[5m])'
|
||||||
|
|
||||||
|
# List scrape targets
|
||||||
|
lab-monitoring targets
|
||||||
|
|
||||||
|
# Search metrics
|
||||||
|
lab-monitoring metrics node
|
||||||
|
lab-monitoring metrics -n 20 cpu
|
||||||
|
|
||||||
|
# Query logs from Loki (requires LOKI_URL)
|
||||||
|
lab-monitoring logs '{job="varlogs"}'
|
||||||
|
lab-monitoring logs '{job="nginx"} |= "error"' --start 2h --limit 50
|
||||||
|
lab-monitoring logs '{job="systemd"}' --direction forward
|
||||||
|
|
||||||
|
# List Loki labels
|
||||||
|
lab-monitoring labels
|
||||||
|
lab-monitoring labels --values job
|
||||||
|
```
|
||||||
|
|
||||||
|
**Git Explorer CLI:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Resolve a ref to commit hash
|
||||||
|
git-explorer --repo /path/to/repo resolve main
|
||||||
|
git-explorer --repo /path/to/repo resolve v1.0.0
|
||||||
|
|
||||||
|
# View commit log
|
||||||
|
git-explorer --repo /path/to/repo log --limit 10
|
||||||
|
git-explorer --repo /path/to/repo log --author "John" --path src/
|
||||||
|
|
||||||
|
# Show commit details
|
||||||
|
git-explorer --repo /path/to/repo show HEAD
|
||||||
|
git-explorer --repo /path/to/repo show abc1234
|
||||||
|
|
||||||
|
# Compare commits
|
||||||
|
git-explorer --repo /path/to/repo diff HEAD~5 HEAD
|
||||||
|
|
||||||
|
# Show file at specific commit
|
||||||
|
git-explorer --repo /path/to/repo cat HEAD README.md
|
||||||
|
|
||||||
|
# List branches
|
||||||
|
git-explorer --repo /path/to/repo branches
|
||||||
|
git-explorer --repo /path/to/repo branches --remote
|
||||||
|
|
||||||
|
# Search commit messages
|
||||||
|
git-explorer --repo /path/to/repo search "fix bug"
|
||||||
|
```
|
||||||
|
|
||||||
**Delete an indexed revision:**
|
**Delete an indexed revision:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -224,6 +350,11 @@ hm-options delete release-23.11
|
|||||||
| `NIXPKGS_SEARCH_DATABASE` | Database connection string for nixpkgs-search | `sqlite://nixpkgs-search.db` |
|
| `NIXPKGS_SEARCH_DATABASE` | Database connection string for nixpkgs-search | `sqlite://nixpkgs-search.db` |
|
||||||
| `HM_OPTIONS_DATABASE` | Database connection string for hm-options | `sqlite://hm-options.db` |
|
| `HM_OPTIONS_DATABASE` | Database connection string for hm-options | `sqlite://hm-options.db` |
|
||||||
| `NIXOS_OPTIONS_DATABASE` | Database connection string for nixos-options (legacy) | `sqlite://nixos-options.db` |
|
| `NIXOS_OPTIONS_DATABASE` | Database connection string for nixos-options (legacy) | `sqlite://nixos-options.db` |
|
||||||
|
| `PROMETHEUS_URL` | Prometheus base URL for lab-monitoring | `http://localhost:9090` |
|
||||||
|
| `ALERTMANAGER_URL` | Alertmanager base URL for lab-monitoring | `http://localhost:9093` |
|
||||||
|
| `LOKI_URL` | Loki base URL for lab-monitoring (optional, enables log tools) | *(none)* |
|
||||||
|
| `LOKI_USERNAME` | Username for Loki basic auth (optional) | *(none)* |
|
||||||
|
| `LOKI_PASSWORD` | Password for Loki basic auth (optional) | *(none)* |
|
||||||
|
|
||||||
### Database Connection Strings
|
### Database Connection Strings
|
||||||
|
|
||||||
@@ -257,7 +388,7 @@ hm-options -d "sqlite://my.db" index hm-unstable
|
|||||||
| `search_options` | Search for options by name or description |
|
| `search_options` | Search for options by name or description |
|
||||||
| `get_option` | Get full details for a specific option |
|
| `get_option` | Get full details for a specific option |
|
||||||
| `get_file` | Fetch source file contents from the repository |
|
| `get_file` | Fetch source file contents from the repository |
|
||||||
| `index_revision` | Index a revision |
|
| `index_revision` | Index a revision (options, files, and packages for nixpkgs) |
|
||||||
| `list_revisions` | List all indexed revisions |
|
| `list_revisions` | List all indexed revisions |
|
||||||
| `delete_revision` | Delete an indexed revision |
|
| `delete_revision` | Delete an indexed revision |
|
||||||
|
|
||||||
@@ -268,10 +399,40 @@ hm-options -d "sqlite://my.db" index hm-unstable
|
|||||||
| `search_packages` | Search for packages by name or description |
|
| `search_packages` | Search for packages by name or description |
|
||||||
| `get_package` | Get full details for a specific package |
|
| `get_package` | Get full details for a specific package |
|
||||||
| `get_file` | Fetch source file contents from nixpkgs |
|
| `get_file` | Fetch source file contents from nixpkgs |
|
||||||
| `index_revision` | Index a revision |
|
| `index_revision` | Index a revision to make its packages searchable |
|
||||||
| `list_revisions` | List all indexed revisions |
|
| `list_revisions` | List all indexed revisions |
|
||||||
| `delete_revision` | Delete an indexed revision |
|
| `delete_revision` | Delete an indexed revision |
|
||||||
|
|
||||||
|
### Monitoring Server (lab-monitoring)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `list_alerts` | List alerts with optional filters (state, severity, receiver). Defaults to active alerts only; use state=all to include silenced/inhibited |
|
||||||
|
| `get_alert` | Get full details for a specific alert by fingerprint |
|
||||||
|
| `search_metrics` | Search metric names with substring filter, enriched with metadata |
|
||||||
|
| `get_metric_metadata` | Get type, help text, and unit for a specific metric |
|
||||||
|
| `query` | Execute an instant PromQL query |
|
||||||
|
| `list_targets` | List scrape targets with health status |
|
||||||
|
| `list_silences` | List active/pending alert silences |
|
||||||
|
| `create_silence` | Create a new alert silence (requires `--enable-silences` flag) |
|
||||||
|
| `query_logs` | Execute a LogQL range query against Loki (requires `LOKI_URL`) |
|
||||||
|
| `list_labels` | List available label names from Loki (requires `LOKI_URL`) |
|
||||||
|
| `list_label_values` | List values for a specific label from Loki (requires `LOKI_URL`) |
|
||||||
|
|
||||||
|
### Git Explorer Server (git-explorer)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `resolve_ref` | Resolve a git ref (branch, tag, commit) to its full commit hash |
|
||||||
|
| `get_log` | Get commit log with optional filters (author, path, limit) |
|
||||||
|
| `get_commit_info` | Get full details for a specific commit |
|
||||||
|
| `get_diff_files` | Get list of files changed between two commits |
|
||||||
|
| `get_file_at_commit` | Get file contents at a specific commit |
|
||||||
|
| `is_ancestor` | Check if one commit is an ancestor of another |
|
||||||
|
| `commits_between` | Get all commits between two refs |
|
||||||
|
| `list_branches` | List all branches in the repository |
|
||||||
|
| `search_commits` | Search commit messages for a pattern |
|
||||||
|
|
||||||
## NixOS Modules
|
## NixOS Modules
|
||||||
|
|
||||||
NixOS modules are provided for running the MCP servers as systemd services.
|
NixOS modules are provided for running the MCP servers as systemd services.
|
||||||
@@ -282,7 +443,7 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
|
|||||||
|
|
||||||
```nix
|
```nix
|
||||||
{
|
{
|
||||||
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
|
inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
|
||||||
|
|
||||||
outputs = { self, nixpkgs, labmcp }: {
|
outputs = { self, nixpkgs, labmcp }: {
|
||||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||||
@@ -319,7 +480,7 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
|
|||||||
|
|
||||||
```nix
|
```nix
|
||||||
{
|
{
|
||||||
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
|
inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
|
||||||
|
|
||||||
outputs = { self, nixpkgs, labmcp }: {
|
outputs = { self, nixpkgs, labmcp }: {
|
||||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||||
@@ -338,11 +499,59 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### lab-monitoring
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, labmcp }: {
|
||||||
|
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||||
|
system = "x86_64-linux";
|
||||||
|
modules = [
|
||||||
|
labmcp.nixosModules.lab-monitoring-mcp
|
||||||
|
{
|
||||||
|
services.lab-monitoring = {
|
||||||
|
enable = true;
|
||||||
|
prometheusUrl = "http://prometheus.example.com:9090";
|
||||||
|
alertmanagerUrl = "http://alertmanager.example.com:9093";
|
||||||
|
enableSilences = true; # Optional: enable create_silence tool
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### git-explorer
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, labmcp }: {
|
||||||
|
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||||
|
system = "x86_64-linux";
|
||||||
|
modules = [
|
||||||
|
labmcp.nixosModules.git-explorer-mcp
|
||||||
|
{
|
||||||
|
services.git-explorer = {
|
||||||
|
enable = true;
|
||||||
|
repoPath = "/path/to/your/git/repo";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### nixos-options (Legacy)
|
### nixos-options (Legacy)
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
{
|
{
|
||||||
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
|
inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
|
||||||
|
|
||||||
outputs = { self, nixpkgs, labmcp }: {
|
outputs = { self, nixpkgs, labmcp }: {
|
||||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||||
@@ -391,6 +600,48 @@ Both `options.http` and `packages.http` also support:
|
|||||||
- `sessionTTL` (default: `"30m"`)
|
- `sessionTTL` (default: `"30m"`)
|
||||||
- `tls.enable`, `tls.certFile`, `tls.keyFile`
|
- `tls.enable`, `tls.certFile`, `tls.keyFile`
|
||||||
|
|
||||||
|
#### lab-monitoring
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `enable` | bool | `false` | Enable the service |
|
||||||
|
| `package` | package | from flake | Package to use |
|
||||||
|
| `prometheusUrl` | string | `"http://localhost:9090"` | Prometheus base URL |
|
||||||
|
| `alertmanagerUrl` | string | `"http://localhost:9093"` | Alertmanager base URL |
|
||||||
|
| `lokiUrl` | nullOr string | `null` | Loki base URL (enables log query tools when set) |
|
||||||
|
| `lokiUsername` | nullOr string | `null` | Username for Loki basic authentication |
|
||||||
|
| `lokiPasswordFile` | nullOr path | `null` | Path to file containing Loki password (uses systemd `LoadCredential`) |
|
||||||
|
| `enableSilences` | bool | `false` | Enable the create_silence tool (write operation) |
|
||||||
|
| `http.address` | string | `"127.0.0.1:8084"` | HTTP listen address |
|
||||||
|
| `http.endpoint` | string | `"/mcp"` | HTTP endpoint path |
|
||||||
|
| `http.allowedOrigins` | list of string | `[]` | Allowed CORS origins (empty = localhost only) |
|
||||||
|
| `http.sessionTTL` | string | `"30m"` | Session timeout (Go duration format) |
|
||||||
|
| `http.tls.enable` | bool | `false` | Enable TLS |
|
||||||
|
| `http.tls.certFile` | path | `null` | TLS certificate file |
|
||||||
|
| `http.tls.keyFile` | path | `null` | TLS private key file |
|
||||||
|
| `openFirewall` | bool | `false` | Open firewall for HTTP port |
|
||||||
|
|
||||||
|
The lab-monitoring module uses `DynamicUser=true`, so no separate user/group configuration is needed.
|
||||||
|
|
||||||
|
#### git-explorer
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `enable` | bool | `false` | Enable the service |
|
||||||
|
| `package` | package | from flake | Package to use |
|
||||||
|
| `repoPath` | string | *(required)* | Path to the git repository to serve |
|
||||||
|
| `defaultRemote` | string | `"origin"` | Default remote name for ref resolution |
|
||||||
|
| `http.address` | string | `"127.0.0.1:8085"` | HTTP listen address |
|
||||||
|
| `http.endpoint` | string | `"/mcp"` | HTTP endpoint path |
|
||||||
|
| `http.allowedOrigins` | list of string | `[]` | Allowed CORS origins |
|
||||||
|
| `http.sessionTTL` | string | `"30m"` | Session timeout |
|
||||||
|
| `http.tls.enable` | bool | `false` | Enable TLS |
|
||||||
|
| `http.tls.certFile` | path | `null` | TLS certificate file |
|
||||||
|
| `http.tls.keyFile` | path | `null` | TLS private key file |
|
||||||
|
| `openFirewall` | bool | `false` | Open firewall for HTTP port |
|
||||||
|
|
||||||
|
The git-explorer module uses `DynamicUser=true` and grants read-only access to the repository path.
|
||||||
|
|
||||||
#### hm-options-mcp / nixos-options-mcp (Legacy)
|
#### hm-options-mcp / nixos-options-mcp (Legacy)
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
@@ -450,6 +701,8 @@ go test -bench=. ./internal/database/...
|
|||||||
# Build
|
# Build
|
||||||
go build ./cmd/nixpkgs-search
|
go build ./cmd/nixpkgs-search
|
||||||
go build ./cmd/hm-options
|
go build ./cmd/hm-options
|
||||||
|
go build ./cmd/lab-monitoring
|
||||||
|
go build ./cmd/git-explorer
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
- [ ] Add `list_rules` tool - list Prometheus alerting and recording rules (via `/api/v1/rules`)
|
- [ ] Add `list_rules` tool - list Prometheus alerting and recording rules (via `/api/v1/rules`)
|
||||||
- [ ] Add `get_rule_group` tool - get details for a specific rule group
|
- [ ] Add `get_rule_group` tool - get details for a specific rule group
|
||||||
- [ ] Add Loki log query support - query logs via LogQL (via `/loki/api/v1/query_range`), enabling log correlation when investigating alerts (e.g., fetch logs for a specific instance/job around the time an alert fired)
|
- [x] Add Loki log query support - query logs via LogQL (3 tools: `query_logs`, `list_labels`, `list_label_values`), opt-in via `LOKI_URL`
|
||||||
|
|
||||||
## Nice to Have
|
## Nice to Have
|
||||||
|
|
||||||
|
|||||||
459
cmd/git-explorer/main.go
Normal file
459
cmd/git-explorer/main.go
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
|
||||||
|
"code.t-juice.club/torjus/labmcp/internal/gitexplorer"
|
||||||
|
"code.t-juice.club/torjus/labmcp/internal/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const version = "0.1.0"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := &cli.App{
|
||||||
|
Name: "git-explorer",
|
||||||
|
Usage: "Read-only MCP server for git repository exploration",
|
||||||
|
Version: version,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "repo",
|
||||||
|
Aliases: []string{"r"},
|
||||||
|
Usage: "Path to git repository",
|
||||||
|
EnvVars: []string{"GIT_REPO_PATH"},
|
||||||
|
Value: ".",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "default-remote",
|
||||||
|
Usage: "Default remote name",
|
||||||
|
EnvVars: []string{"GIT_DEFAULT_REMOTE"},
|
||||||
|
Value: "origin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
serveCommand(),
|
||||||
|
resolveCommand(),
|
||||||
|
logCommand(),
|
||||||
|
showCommand(),
|
||||||
|
diffCommand(),
|
||||||
|
catCommand(),
|
||||||
|
branchesCommand(),
|
||||||
|
searchCommand(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Run(os.Args); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "serve",
|
||||||
|
Usage: "Run MCP server for git exploration",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "transport",
|
||||||
|
Aliases: []string{"t"},
|
||||||
|
Usage: "Transport type: 'stdio' or 'http'",
|
||||||
|
Value: "stdio",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "http-address",
|
||||||
|
Usage: "HTTP listen address",
|
||||||
|
Value: "127.0.0.1:8085",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "http-endpoint",
|
||||||
|
Usage: "HTTP endpoint path",
|
||||||
|
Value: "/mcp",
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "allowed-origins",
|
||||||
|
Usage: "Allowed Origin headers for CORS",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "tls-cert",
|
||||||
|
Usage: "TLS certificate file",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "tls-key",
|
||||||
|
Usage: "TLS key file",
|
||||||
|
},
|
||||||
|
&cli.DurationFlag{
|
||||||
|
Name: "session-ttl",
|
||||||
|
Usage: "Session TTL for HTTP transport",
|
||||||
|
Value: 30 * time.Minute,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
return runServe(c)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "resolve",
|
||||||
|
Usage: "Resolve a ref to a commit hash",
|
||||||
|
ArgsUsage: "<ref>",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
if c.NArg() < 1 {
|
||||||
|
return fmt.Errorf("ref argument required")
|
||||||
|
}
|
||||||
|
return runResolve(c, c.Args().First())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "log",
|
||||||
|
Usage: "Show commit log",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "ref",
|
||||||
|
Usage: "Starting ref (default: HEAD)",
|
||||||
|
Value: "HEAD",
|
||||||
|
},
|
||||||
|
&cli.IntFlag{
|
||||||
|
Name: "limit",
|
||||||
|
Aliases: []string{"n"},
|
||||||
|
Usage: "Maximum number of commits",
|
||||||
|
Value: 10,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "author",
|
||||||
|
Usage: "Filter by author",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "path",
|
||||||
|
Usage: "Filter by path",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
return runLog(c)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "show",
|
||||||
|
Usage: "Show commit details",
|
||||||
|
ArgsUsage: "<ref>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "stats",
|
||||||
|
Usage: "Include file statistics",
|
||||||
|
Value: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
ref := "HEAD"
|
||||||
|
if c.NArg() > 0 {
|
||||||
|
ref = c.Args().First()
|
||||||
|
}
|
||||||
|
return runShow(c, ref)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "diff",
|
||||||
|
Usage: "Show files changed between two commits",
|
||||||
|
ArgsUsage: "<from-ref> <to-ref>",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
if c.NArg() < 2 {
|
||||||
|
return fmt.Errorf("both from-ref and to-ref arguments required")
|
||||||
|
}
|
||||||
|
return runDiff(c, c.Args().Get(0), c.Args().Get(1))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func catCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "cat",
|
||||||
|
Usage: "Show file contents at a commit",
|
||||||
|
ArgsUsage: "<ref> <path>",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
if c.NArg() < 2 {
|
||||||
|
return fmt.Errorf("both ref and path arguments required")
|
||||||
|
}
|
||||||
|
return runCat(c, c.Args().Get(0), c.Args().Get(1))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func branchesCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "branches",
|
||||||
|
Usage: "List branches",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "remote",
|
||||||
|
Aliases: []string{"r"},
|
||||||
|
Usage: "Include remote branches",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
return runBranches(c)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "search",
|
||||||
|
Usage: "Search commit messages",
|
||||||
|
ArgsUsage: "<query>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "ref",
|
||||||
|
Usage: "Starting ref (default: HEAD)",
|
||||||
|
Value: "HEAD",
|
||||||
|
},
|
||||||
|
&cli.IntFlag{
|
||||||
|
Name: "limit",
|
||||||
|
Aliases: []string{"n"},
|
||||||
|
Usage: "Maximum number of results",
|
||||||
|
Value: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
if c.NArg() < 1 {
|
||||||
|
return fmt.Errorf("query argument required")
|
||||||
|
}
|
||||||
|
return runSearch(c, c.Args().First())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServe(c *cli.Context) error {
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
repoPath := c.String("repo")
|
||||||
|
client, err := gitexplorer.NewGitClient(repoPath, c.String("default-remote"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags)
|
||||||
|
config := mcp.DefaultGitExplorerConfig()
|
||||||
|
|
||||||
|
server := mcp.NewGenericServer(logger, config)
|
||||||
|
gitexplorer.RegisterHandlers(server, client)
|
||||||
|
|
||||||
|
transport := c.String("transport")
|
||||||
|
switch transport {
|
||||||
|
case "stdio":
|
||||||
|
logger.Printf("Starting git-explorer MCP server on stdio (repo: %s)...", repoPath)
|
||||||
|
return server.Run(ctx, os.Stdin, os.Stdout)
|
||||||
|
|
||||||
|
case "http":
|
||||||
|
httpConfig := mcp.HTTPConfig{
|
||||||
|
Address: c.String("http-address"),
|
||||||
|
Endpoint: c.String("http-endpoint"),
|
||||||
|
AllowedOrigins: c.StringSlice("allowed-origins"),
|
||||||
|
SessionTTL: c.Duration("session-ttl"),
|
||||||
|
TLSCertFile: c.String("tls-cert"),
|
||||||
|
TLSKeyFile: c.String("tls-key"),
|
||||||
|
}
|
||||||
|
httpTransport := mcp.NewHTTPTransport(server, httpConfig)
|
||||||
|
return httpTransport.Run(ctx)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getClient(c *cli.Context) (*gitexplorer.GitClient, error) {
|
||||||
|
return gitexplorer.NewGitClient(c.String("repo"), c.String("default-remote"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runResolve(c *cli.Context, ref string) error {
|
||||||
|
client, err := getClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.ResolveRef(ref)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s (%s) -> %s\n", result.Ref, result.Type, result.Commit)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLog(c *cli.Context) error {
|
||||||
|
client, err := getClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := client.GetLog(
|
||||||
|
c.String("ref"),
|
||||||
|
c.Int("limit"),
|
||||||
|
c.String("author"),
|
||||||
|
"",
|
||||||
|
c.String("path"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("No commits found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
fmt.Printf("%s %s\n", e.ShortHash, e.Subject)
|
||||||
|
fmt.Printf(" Author: %s <%s>\n", e.Author, e.Email)
|
||||||
|
fmt.Printf(" Date: %s\n\n", e.Date.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runShow(c *cli.Context, ref string) error {
|
||||||
|
client, err := getClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := client.GetCommitInfo(ref, c.Bool("stats"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("commit %s\n", info.Hash)
|
||||||
|
fmt.Printf("Author: %s <%s>\n", info.Author, info.Email)
|
||||||
|
fmt.Printf("Date: %s\n", info.Date.Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
if len(info.Parents) > 0 {
|
||||||
|
fmt.Printf("Parents: %v\n", info.Parents)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Stats != nil {
|
||||||
|
fmt.Printf("\n%d file(s) changed, %d insertions(+), %d deletions(-)\n",
|
||||||
|
info.Stats.FilesChanged, info.Stats.Additions, info.Stats.Deletions)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n%s", info.Message)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDiff(c *cli.Context, fromRef, toRef string) error {
|
||||||
|
client, err := getClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.GetDiffFiles(fromRef, toRef)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Files) == 0 {
|
||||||
|
fmt.Println("No files changed.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Comparing %s..%s\n\n", result.FromCommit[:7], result.ToCommit[:7])
|
||||||
|
|
||||||
|
for _, f := range result.Files {
|
||||||
|
status := f.Status[0:1] // First letter: A, M, D, R
|
||||||
|
path := f.Path
|
||||||
|
if f.OldPath != "" {
|
||||||
|
path = fmt.Sprintf("%s -> %s", f.OldPath, f.Path)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s (+%d -%d)\n", status, path, f.Additions, f.Deletions)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCat(c *cli.Context, ref, path string) error {
|
||||||
|
client, err := getClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := client.GetFileAtCommit(ref, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print(content.Content)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBranches(c *cli.Context) error {
|
||||||
|
client, err := getClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.ListBranches(c.Bool("remote"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Total == 0 {
|
||||||
|
fmt.Println("No branches found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range result.Branches {
|
||||||
|
marker := " "
|
||||||
|
if b.IsHead {
|
||||||
|
marker = "*"
|
||||||
|
}
|
||||||
|
remoteMarker := ""
|
||||||
|
if b.IsRemote {
|
||||||
|
remoteMarker = " (remote)"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s -> %s%s\n", marker, b.Name, b.Commit[:7], remoteMarker)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSearch(c *cli.Context, query string) error {
|
||||||
|
client, err := getClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.SearchCommits(c.String("ref"), query, c.Int("limit"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Count == 0 {
|
||||||
|
fmt.Printf("No commits matching '%s'.\n", query)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d commit(s) matching '%s':\n\n", result.Count, query)
|
||||||
|
for _, e := range result.Commits {
|
||||||
|
fmt.Printf("%s %s\n", e.ShortHash, e.Subject)
|
||||||
|
fmt.Printf(" Author: %s <%s>\n", e.Author, e.Email)
|
||||||
|
fmt.Printf(" Date: %s\n\n", e.Date.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -12,15 +12,15 @@ import (
|
|||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/homemanager"
|
"code.t-juice.club/torjus/labmcp/internal/homemanager"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/mcp"
|
"code.t-juice.club/torjus/labmcp/internal/mcp"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/options"
|
"code.t-juice.club/torjus/labmcp/internal/options"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultDatabase = "sqlite://hm-options.db"
|
defaultDatabase = "sqlite://hm-options.db"
|
||||||
version = "0.2.1"
|
version = "0.3.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import (
|
|||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/mcp"
|
"code.t-juice.club/torjus/labmcp/internal/mcp"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/monitoring"
|
"code.t-juice.club/torjus/labmcp/internal/monitoring"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.1.0"
|
const version = "0.3.1"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
@@ -35,6 +35,21 @@ func main() {
|
|||||||
EnvVars: []string{"ALERTMANAGER_URL"},
|
EnvVars: []string{"ALERTMANAGER_URL"},
|
||||||
Value: "http://localhost:9093",
|
Value: "http://localhost:9093",
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "loki-url",
|
||||||
|
Usage: "Loki base URL (optional, enables log query tools)",
|
||||||
|
EnvVars: []string{"LOKI_URL"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "loki-username",
|
||||||
|
Usage: "Username for Loki basic auth",
|
||||||
|
EnvVars: []string{"LOKI_USERNAME"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "loki-password",
|
||||||
|
Usage: "Password for Loki basic auth",
|
||||||
|
EnvVars: []string{"LOKI_PASSWORD"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
serveCommand(),
|
serveCommand(),
|
||||||
@@ -42,6 +57,8 @@ func main() {
|
|||||||
queryCommand(),
|
queryCommand(),
|
||||||
targetsCommand(),
|
targetsCommand(),
|
||||||
metricsCommand(),
|
metricsCommand(),
|
||||||
|
logsCommand(),
|
||||||
|
labelsCommand(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,12 +123,16 @@ func alertsCommand() *cli.Command {
|
|||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "state",
|
Name: "state",
|
||||||
Usage: "Filter by state: active, suppressed, unprocessed",
|
Usage: "Filter by state: active (default), suppressed, unprocessed, all",
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "severity",
|
Name: "severity",
|
||||||
Usage: "Filter by severity label",
|
Usage: "Filter by severity label",
|
||||||
},
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "all",
|
||||||
|
Usage: "Show all alerts including silenced and inhibited (shorthand for --state all)",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Action: func(c *cli.Context) error {
|
Action: func(c *cli.Context) error {
|
||||||
return runAlerts(c)
|
return runAlerts(c)
|
||||||
@@ -176,6 +197,15 @@ func runServe(c *cli.Context) error {
|
|||||||
prom := monitoring.NewPrometheusClient(c.String("prometheus-url"))
|
prom := monitoring.NewPrometheusClient(c.String("prometheus-url"))
|
||||||
am := monitoring.NewAlertmanagerClient(c.String("alertmanager-url"))
|
am := monitoring.NewAlertmanagerClient(c.String("alertmanager-url"))
|
||||||
|
|
||||||
|
var loki *monitoring.LokiClient
|
||||||
|
if lokiURL := c.String("loki-url"); lokiURL != "" {
|
||||||
|
loki = monitoring.NewLokiClient(monitoring.LokiClientOptions{
|
||||||
|
BaseURL: lokiURL,
|
||||||
|
Username: c.String("loki-username"),
|
||||||
|
Password: c.String("loki-password"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
config.InstructionsFunc = func() string {
|
config.InstructionsFunc = func() string {
|
||||||
return monitoring.AlertSummary(am)
|
return monitoring.AlertSummary(am)
|
||||||
}
|
}
|
||||||
@@ -184,7 +214,7 @@ func runServe(c *cli.Context) error {
|
|||||||
opts := monitoring.HandlerOptions{
|
opts := monitoring.HandlerOptions{
|
||||||
EnableSilences: c.Bool("enable-silences"),
|
EnableSilences: c.Bool("enable-silences"),
|
||||||
}
|
}
|
||||||
monitoring.RegisterHandlers(server, prom, am, opts)
|
monitoring.RegisterHandlers(server, prom, am, loki, opts)
|
||||||
|
|
||||||
transport := c.String("transport")
|
transport := c.String("transport")
|
||||||
switch transport {
|
switch transport {
|
||||||
@@ -214,9 +244,16 @@ func runAlerts(c *cli.Context) error {
|
|||||||
am := monitoring.NewAlertmanagerClient(c.String("alertmanager-url"))
|
am := monitoring.NewAlertmanagerClient(c.String("alertmanager-url"))
|
||||||
|
|
||||||
filters := monitoring.AlertFilters{}
|
filters := monitoring.AlertFilters{}
|
||||||
if state := c.String("state"); state != "" {
|
|
||||||
|
// Determine state filter: --all flag takes precedence, then --state, then default to active
|
||||||
|
state := c.String("state")
|
||||||
|
if c.Bool("all") {
|
||||||
|
state = "all"
|
||||||
|
}
|
||||||
|
|
||||||
switch state {
|
switch state {
|
||||||
case "active":
|
case "active", "":
|
||||||
|
// Default to active alerts only (non-silenced, non-inhibited)
|
||||||
active := true
|
active := true
|
||||||
filters.Active = &active
|
filters.Active = &active
|
||||||
silenced := false
|
silenced := false
|
||||||
@@ -229,8 +266,10 @@ func runAlerts(c *cli.Context) error {
|
|||||||
case "unprocessed":
|
case "unprocessed":
|
||||||
unprocessed := true
|
unprocessed := true
|
||||||
filters.Unprocessed = &unprocessed
|
filters.Unprocessed = &unprocessed
|
||||||
|
case "all":
|
||||||
|
// No filters - return everything
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if severity := c.String("severity"); severity != "" {
|
if severity := c.String("severity"); severity != "" {
|
||||||
filters.Filter = append(filters.Filter, fmt.Sprintf(`severity="%s"`, severity))
|
filters.Filter = append(filters.Filter, fmt.Sprintf(`severity="%s"`, severity))
|
||||||
}
|
}
|
||||||
@@ -347,6 +386,207 @@ func runMetrics(c *cli.Context, query string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logsCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "logs",
|
||||||
|
Usage: "Query logs from Loki using LogQL",
|
||||||
|
ArgsUsage: "<logql>",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "start",
|
||||||
|
Usage: "Start time: relative duration (e.g., '1h'), RFC3339, or Unix epoch",
|
||||||
|
Value: "1h",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "end",
|
||||||
|
Usage: "End time: relative duration, RFC3339, or Unix epoch",
|
||||||
|
Value: "now",
|
||||||
|
},
|
||||||
|
&cli.IntFlag{
|
||||||
|
Name: "limit",
|
||||||
|
Aliases: []string{"n"},
|
||||||
|
Usage: "Maximum number of entries",
|
||||||
|
Value: 100,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "direction",
|
||||||
|
Usage: "Sort order: 'backward' (newest first) or 'forward' (oldest first)",
|
||||||
|
Value: "backward",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
if c.NArg() < 1 {
|
||||||
|
return fmt.Errorf("LogQL expression required")
|
||||||
|
}
|
||||||
|
return runLogs(c, c.Args().First())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func labelsCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "labels",
|
||||||
|
Usage: "List labels from Loki, or values for a specific label",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "values",
|
||||||
|
Usage: "Get values for this label name instead of listing labels",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
return runLabels(c)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogs(c *cli.Context, logql string) error {
|
||||||
|
lokiURL := c.String("loki-url")
|
||||||
|
if lokiURL == "" {
|
||||||
|
return fmt.Errorf("--loki-url or LOKI_URL is required for log queries")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
loki := monitoring.NewLokiClient(monitoring.LokiClientOptions{
|
||||||
|
BaseURL: lokiURL,
|
||||||
|
Username: c.String("loki-username"),
|
||||||
|
Password: c.String("loki-password"),
|
||||||
|
})
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
start, err := parseCLITime(c.String("start"), now.Add(-time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid start time: %w", err)
|
||||||
|
}
|
||||||
|
end, err := parseCLITime(c.String("end"), now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid end time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := loki.QueryRange(ctx, logql, start, end, c.Int("limit"), c.String("direction"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("log query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalEntries := 0
|
||||||
|
for _, stream := range data.Result {
|
||||||
|
totalEntries += len(stream.Values)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalEntries == 0 {
|
||||||
|
fmt.Println("No log entries found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stream := range data.Result {
|
||||||
|
// Print stream labels
|
||||||
|
labels := ""
|
||||||
|
for k, v := range stream.Stream {
|
||||||
|
if labels != "" {
|
||||||
|
labels += ", "
|
||||||
|
}
|
||||||
|
labels += fmt.Sprintf("%s=%q", k, v)
|
||||||
|
}
|
||||||
|
fmt.Printf("--- {%s} ---\n", labels)
|
||||||
|
|
||||||
|
for _, entry := range stream.Values {
|
||||||
|
ts := formatCLITimestamp(entry[0])
|
||||||
|
fmt.Printf("[%s] %s\n", ts, entry[1])
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLabels(c *cli.Context) error {
|
||||||
|
lokiURL := c.String("loki-url")
|
||||||
|
if lokiURL == "" {
|
||||||
|
return fmt.Errorf("--loki-url or LOKI_URL is required for label queries")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
loki := monitoring.NewLokiClient(monitoring.LokiClientOptions{
|
||||||
|
BaseURL: lokiURL,
|
||||||
|
Username: c.String("loki-username"),
|
||||||
|
Password: c.String("loki-password"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if label := c.String("values"); label != "" {
|
||||||
|
values, err := loki.LabelValues(ctx, label)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list label values: %w", err)
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
fmt.Printf("No values found for label '%s'.\n", label)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, v := range values {
|
||||||
|
fmt.Println(v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
labels, err := loki.Labels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list labels: %w", err)
|
||||||
|
}
|
||||||
|
if len(labels) == 0 {
|
||||||
|
fmt.Println("No labels found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, label := range labels {
|
||||||
|
fmt.Println(label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCLITime parses a time string for CLI use. Handles "now", relative durations,
|
||||||
|
// RFC3339, and Unix epoch seconds.
|
||||||
|
func parseCLITime(s string, defaultTime time.Time) (time.Time, error) {
|
||||||
|
if s == "now" || s == "" {
|
||||||
|
return time.Now(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as relative duration
|
||||||
|
if d, err := time.ParseDuration(s); err == nil {
|
||||||
|
return time.Now().Add(-d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as RFC3339
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as Unix epoch seconds
|
||||||
|
var epoch int64
|
||||||
|
validDigits := true
|
||||||
|
for _, c := range s {
|
||||||
|
if c >= '0' && c <= '9' {
|
||||||
|
epoch = epoch*10 + int64(c-'0')
|
||||||
|
} else {
|
||||||
|
validDigits = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if validDigits && len(s) > 0 {
|
||||||
|
return time.Unix(epoch, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultTime, fmt.Errorf("cannot parse time '%s'", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCLITimestamp converts a nanosecond Unix timestamp string to a readable format.
|
||||||
|
func formatCLITimestamp(nsStr string) string {
|
||||||
|
var ns int64
|
||||||
|
for _, c := range nsStr {
|
||||||
|
if c >= '0' && c <= '9' {
|
||||||
|
ns = ns*10 + int64(c-'0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := time.Unix(0, ns)
|
||||||
|
return t.Local().Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
func containsIgnoreCase(s, substr string) bool {
|
func containsIgnoreCase(s, substr string) bool {
|
||||||
sLower := make([]byte, len(s))
|
sLower := make([]byte, len(s))
|
||||||
subLower := make([]byte, len(substr))
|
subLower := make([]byte, len(substr))
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import (
|
|||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/mcp"
|
"code.t-juice.club/torjus/labmcp/internal/mcp"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/nixos"
|
"code.t-juice.club/torjus/labmcp/internal/nixos"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultDatabase = "sqlite://nixos-options.db"
|
defaultDatabase = "sqlite://nixos-options.db"
|
||||||
version = "0.2.1"
|
version = "0.3.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ import (
|
|||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/mcp"
|
"code.t-juice.club/torjus/labmcp/internal/mcp"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/nixos"
|
"code.t-juice.club/torjus/labmcp/internal/nixos"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/packages"
|
"code.t-juice.club/torjus/labmcp/internal/packages"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultDatabase = "sqlite://nixpkgs-search.db"
|
defaultDatabase = "sqlite://nixpkgs-search.db"
|
||||||
version = "0.2.1"
|
version = "0.4.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -310,7 +310,8 @@ func runOptionsServe(c *cli.Context) error {
|
|||||||
server := mcp.NewServer(store, logger, config)
|
server := mcp.NewServer(store, logger, config)
|
||||||
|
|
||||||
indexer := nixos.NewIndexer(store)
|
indexer := nixos.NewIndexer(store)
|
||||||
server.RegisterHandlers(indexer)
|
pkgIndexer := packages.NewIndexer(store)
|
||||||
|
server.RegisterHandlersWithPackages(indexer, pkgIndexer)
|
||||||
|
|
||||||
transport := c.String("transport")
|
transport := c.String("transport")
|
||||||
switch transport {
|
switch transport {
|
||||||
|
|||||||
6
flake.lock
generated
6
flake.lock
generated
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770115704,
|
"lastModified": 1770841267,
|
||||||
"narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=",
|
"narHash": "sha256-9xejG0KoqsoKEGp2kVbXRlEYtFFcDTHjidiuX8hGO44=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e6eae2ee2110f3d31110d5c222cd395303343b08",
|
"rev": "ec7c70d12ce2fc37cb92aff673dcdca89d187bae",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
15
flake.nix
15
flake.nix
@@ -40,6 +40,13 @@
|
|||||||
mainProgram = "lab-monitoring";
|
mainProgram = "lab-monitoring";
|
||||||
description = "MCP server for Prometheus and Alertmanager monitoring";
|
description = "MCP server for Prometheus and Alertmanager monitoring";
|
||||||
};
|
};
|
||||||
|
git-explorer = pkgs.callPackage ./nix/package.nix {
|
||||||
|
src = ./.;
|
||||||
|
pname = "git-explorer";
|
||||||
|
subPackage = "cmd/git-explorer";
|
||||||
|
mainProgram = "git-explorer";
|
||||||
|
description = "Read-only MCP server for git repository exploration";
|
||||||
|
};
|
||||||
default = self.packages.${system}.nixpkgs-search;
|
default = self.packages.${system}.nixpkgs-search;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,10 +57,8 @@
|
|||||||
{
|
{
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
go_1_24
|
go
|
||||||
gopls
|
gopls
|
||||||
gotools
|
|
||||||
go-tools
|
|
||||||
golangci-lint
|
golangci-lint
|
||||||
govulncheck
|
govulncheck
|
||||||
postgresql
|
postgresql
|
||||||
@@ -84,6 +89,10 @@
|
|||||||
imports = [ ./nix/lab-monitoring-module.nix ];
|
imports = [ ./nix/lab-monitoring-module.nix ];
|
||||||
services.lab-monitoring.package = lib.mkDefault self.packages.${pkgs.system}.lab-monitoring;
|
services.lab-monitoring.package = lib.mkDefault self.packages.${pkgs.system}.lab-monitoring;
|
||||||
};
|
};
|
||||||
|
git-explorer-mcp = { pkgs, ... }: {
|
||||||
|
imports = [ ./nix/git-explorer-module.nix ];
|
||||||
|
services.git-explorer.package = lib.mkDefault self.packages.${pkgs.system}.git-explorer;
|
||||||
|
};
|
||||||
default = self.nixosModules.nixpkgs-search-mcp;
|
default = self.nixosModules.nixpkgs-search-mcp;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
23
go.mod
23
go.mod
@@ -1,24 +1,43 @@
|
|||||||
module git.t-juice.club/torjus/labmcp
|
module code.t-juice.club/torjus/labmcp
|
||||||
|
|
||||||
go 1.24
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-git/go-git/v5 v5.16.4
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/urfave/cli/v2 v2.27.5
|
github.com/urfave/cli/v2 v2.27.5
|
||||||
modernc.org/sqlite v1.34.4
|
modernc.org/sqlite v1.34.4
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
dario.cat/mergo v1.0.0 // indirect
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.1 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||||
|
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||||
golang.org/x/sys v0.22.0 // indirect
|
golang.org/x/crypto v0.37.0 // indirect
|
||||||
|
golang.org/x/net v0.39.0 // indirect
|
||||||
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
modernc.org/libc v1.55.3 // indirect
|
modernc.org/libc v1.55.3 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
|||||||
102
go.sum
102
go.sum
@@ -1,36 +1,134 @@
|
|||||||
|
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||||
|
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||||
|
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
|
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||||
|
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
|
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||||
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||||
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||||
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
|
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||||
|
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
|
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||||
|
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||||
|
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||||
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
|
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||||
|
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||||
|
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||||
|
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
|||||||
570
internal/gitexplorer/client.go
Normal file
570
internal/gitexplorer/client.go
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNotFound is returned when a ref, commit, or file is not found.
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
// ErrFileTooLarge is returned when a file exceeds the size limit.
|
||||||
|
ErrFileTooLarge = errors.New("file too large")
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitClient provides read-only access to a git repository.
|
||||||
|
type GitClient struct {
|
||||||
|
repo *git.Repository
|
||||||
|
defaultRemote string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitClient opens a git repository at the given path.
|
||||||
|
func NewGitClient(repoPath string, defaultRemote string) (*GitClient, error) {
|
||||||
|
repo, err := git.PlainOpen(repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaultRemote == "" {
|
||||||
|
defaultRemote = "origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GitClient{
|
||||||
|
repo: repo,
|
||||||
|
defaultRemote: defaultRemote,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRef resolves a ref (branch, tag, or commit hash) to a commit hash.
|
||||||
|
func (c *GitClient) ResolveRef(ref string) (*ResolveResult, error) {
|
||||||
|
result := &ResolveResult{Ref: ref}
|
||||||
|
|
||||||
|
// Try to resolve as a revision
|
||||||
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Commit = hash.String()
|
||||||
|
|
||||||
|
// Determine the type of ref
|
||||||
|
// Check if it's a branch
|
||||||
|
if _, err := c.repo.Reference(plumbing.NewBranchReferenceName(ref), true); err == nil {
|
||||||
|
result.Type = "branch"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a remote branch
|
||||||
|
if _, err := c.repo.Reference(plumbing.NewRemoteReferenceName(c.defaultRemote, ref), true); err == nil {
|
||||||
|
result.Type = "branch"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a tag
|
||||||
|
if _, err := c.repo.Reference(plumbing.NewTagReferenceName(ref), true); err == nil {
|
||||||
|
result.Type = "tag"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to commit
|
||||||
|
result.Type = "commit"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLog returns the commit log starting from the given ref.
|
||||||
|
func (c *GitClient) GetLog(ref string, limit int, author string, since string, path string) ([]LogEntry, error) {
|
||||||
|
if limit <= 0 || limit > Limits.MaxLogEntries {
|
||||||
|
limit = Limits.MaxLogEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the ref to a commit hash
|
||||||
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
logOpts := &git.LogOptions{
|
||||||
|
From: *hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add path filter if specified
|
||||||
|
if path != "" {
|
||||||
|
if err := ValidatePath(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logOpts.PathFilter = func(p string) bool {
|
||||||
|
return strings.HasPrefix(p, path) || p == path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := c.repo.Log(logOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get log: %w", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
var entries []LogEntry
|
||||||
|
err = iter.ForEach(func(commit *object.Commit) error {
|
||||||
|
// Apply author filter
|
||||||
|
if author != "" {
|
||||||
|
authorLower := strings.ToLower(author)
|
||||||
|
if !strings.Contains(strings.ToLower(commit.Author.Name), authorLower) &&
|
||||||
|
!strings.Contains(strings.ToLower(commit.Author.Email), authorLower) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply since filter
|
||||||
|
if since != "" {
|
||||||
|
// Parse since as a ref and check if this commit is reachable
|
||||||
|
sinceHash, err := c.repo.ResolveRevision(plumbing.Revision(since))
|
||||||
|
if err == nil {
|
||||||
|
// Stop if we've reached the since commit
|
||||||
|
if commit.Hash == *sinceHash {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first line of commit message as subject
|
||||||
|
subject := commit.Message
|
||||||
|
if idx := strings.Index(subject, "\n"); idx != -1 {
|
||||||
|
subject = subject[:idx]
|
||||||
|
}
|
||||||
|
subject = strings.TrimSpace(subject)
|
||||||
|
|
||||||
|
entries = append(entries, LogEntry{
|
||||||
|
Hash: commit.Hash.String(),
|
||||||
|
ShortHash: commit.Hash.String()[:7],
|
||||||
|
Author: commit.Author.Name,
|
||||||
|
Email: commit.Author.Email,
|
||||||
|
Date: commit.Author.When,
|
||||||
|
Subject: subject,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(entries) >= limit {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// io.EOF is expected when we hit the limit
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("failed to iterate log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommitInfo returns full details about a commit.
|
||||||
|
func (c *GitClient) GetCommitInfo(ref string, includeStats bool) (*CommitInfo, error) {
|
||||||
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
commit, err := c.repo.CommitObject(*hash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &CommitInfo{
|
||||||
|
Hash: commit.Hash.String(),
|
||||||
|
Author: commit.Author.Name,
|
||||||
|
Email: commit.Author.Email,
|
||||||
|
Date: commit.Author.When,
|
||||||
|
Committer: commit.Committer.Name,
|
||||||
|
CommitDate: commit.Committer.When,
|
||||||
|
Message: commit.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, parent := range commit.ParentHashes {
|
||||||
|
info.Parents = append(info.Parents, parent.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeStats {
|
||||||
|
stats, err := c.getCommitStats(commit)
|
||||||
|
if err == nil {
|
||||||
|
info.Stats = stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCommitStats computes file change statistics for a commit.
|
||||||
|
func (c *GitClient) getCommitStats(commit *object.Commit) (*FileStats, error) {
|
||||||
|
stats, err := commit.Stats()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &FileStats{
|
||||||
|
FilesChanged: len(stats),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range stats {
|
||||||
|
result.Additions += s.Addition
|
||||||
|
result.Deletions += s.Deletion
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDiffFiles returns the files changed between two commits.
|
||||||
|
func (c *GitClient) GetDiffFiles(fromRef, toRef string) (*DiffResult, error) {
|
||||||
|
fromHash, err := c.repo.ResolveRevision(plumbing.Revision(fromRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, fromRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
toHash, err := c.repo.ResolveRevision(plumbing.Revision(toRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, toRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
fromCommit, err := c.repo.CommitObject(*fromHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get from commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
toCommit, err := c.repo.CommitObject(*toHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get to commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patch, err := fromCommit.Patch(toCommit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get patch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &DiffResult{
|
||||||
|
FromCommit: fromHash.String(),
|
||||||
|
ToCommit: toHash.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, filePatch := range patch.FilePatches() {
|
||||||
|
if i >= Limits.MaxDiffFiles {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
from, to := filePatch.Files()
|
||||||
|
|
||||||
|
df := DiffFile{}
|
||||||
|
|
||||||
|
// Determine status and paths
|
||||||
|
switch {
|
||||||
|
case from == nil && to != nil:
|
||||||
|
df.Status = "added"
|
||||||
|
df.Path = to.Path()
|
||||||
|
case from != nil && to == nil:
|
||||||
|
df.Status = "deleted"
|
||||||
|
df.Path = from.Path()
|
||||||
|
case from != nil && to != nil && from.Path() != to.Path():
|
||||||
|
df.Status = "renamed"
|
||||||
|
df.Path = to.Path()
|
||||||
|
df.OldPath = from.Path()
|
||||||
|
default:
|
||||||
|
df.Status = "modified"
|
||||||
|
if to != nil {
|
||||||
|
df.Path = to.Path()
|
||||||
|
} else if from != nil {
|
||||||
|
df.Path = from.Path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count additions and deletions
|
||||||
|
for _, chunk := range filePatch.Chunks() {
|
||||||
|
content := chunk.Content()
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
switch chunk.Type() {
|
||||||
|
case 1: // Add
|
||||||
|
df.Additions += len(lines)
|
||||||
|
case 2: // Delete
|
||||||
|
df.Deletions += len(lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Files = append(result.Files, df)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileAtCommit returns the content of a file at a specific commit.
|
||||||
|
func (c *GitClient) GetFileAtCommit(ref, path string) (*FileContent, error) {
|
||||||
|
if err := ValidatePath(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
commit, err := c.repo.CommitObject(*hash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := commit.File(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: file '%s'", ErrNotFound, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
if file.Size > Limits.MaxFileContent {
|
||||||
|
return nil, fmt.Errorf("%w: %d bytes (max %d)", ErrFileTooLarge, file.Size, Limits.MaxFileContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := file.Contents()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FileContent{
|
||||||
|
Path: path,
|
||||||
|
Commit: hash.String(),
|
||||||
|
Size: file.Size,
|
||||||
|
Content: content,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAncestor checks if ancestor is an ancestor of descendant.
|
||||||
|
func (c *GitClient) IsAncestor(ancestorRef, descendantRef string) (*AncestryResult, error) {
|
||||||
|
ancestorHash, err := c.repo.ResolveRevision(plumbing.Revision(ancestorRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ancestorRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
descendantHash, err := c.repo.ResolveRevision(plumbing.Revision(descendantRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, descendantRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
ancestorCommit, err := c.repo.CommitObject(*ancestorHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get ancestor commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
descendantCommit, err := c.repo.CommitObject(*descendantHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get descendant commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isAncestor, err := ancestorCommit.IsAncestor(descendantCommit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check ancestry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AncestryResult{
|
||||||
|
Ancestor: ancestorHash.String(),
|
||||||
|
Descendant: descendantHash.String(),
|
||||||
|
IsAncestor: isAncestor,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitsBetween returns commits between two refs (exclusive of from, inclusive of to).
|
||||||
|
func (c *GitClient) CommitsBetween(fromRef, toRef string, limit int) (*CommitRange, error) {
|
||||||
|
if limit <= 0 || limit > Limits.MaxLogEntries {
|
||||||
|
limit = Limits.MaxLogEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
fromHash, err := c.repo.ResolveRevision(plumbing.Revision(fromRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, fromRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
toHash, err := c.repo.ResolveRevision(plumbing.Revision(toRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, toRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := c.repo.Log(&git.LogOptions{
|
||||||
|
From: *toHash,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get log: %w", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
result := &CommitRange{
|
||||||
|
FromCommit: fromHash.String(),
|
||||||
|
ToCommit: toHash.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = iter.ForEach(func(commit *object.Commit) error {
|
||||||
|
// Stop when we reach the from commit (exclusive)
|
||||||
|
if commit.Hash == *fromHash {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := commit.Message
|
||||||
|
if idx := strings.Index(subject, "\n"); idx != -1 {
|
||||||
|
subject = subject[:idx]
|
||||||
|
}
|
||||||
|
subject = strings.TrimSpace(subject)
|
||||||
|
|
||||||
|
result.Commits = append(result.Commits, LogEntry{
|
||||||
|
Hash: commit.Hash.String(),
|
||||||
|
ShortHash: commit.Hash.String()[:7],
|
||||||
|
Author: commit.Author.Name,
|
||||||
|
Email: commit.Author.Email,
|
||||||
|
Date: commit.Author.When,
|
||||||
|
Subject: subject,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(result.Commits) >= limit {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("failed to iterate log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Count = len(result.Commits)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBranches returns all branches in the repository.
|
||||||
|
func (c *GitClient) ListBranches(includeRemote bool) (*BranchList, error) {
|
||||||
|
result := &BranchList{}
|
||||||
|
|
||||||
|
// Get HEAD to determine current branch
|
||||||
|
head, err := c.repo.Head()
|
||||||
|
if err == nil && head.Name().IsBranch() {
|
||||||
|
result.Current = head.Name().Short()
|
||||||
|
}
|
||||||
|
|
||||||
|
// List local branches
|
||||||
|
branchIter, err := c.repo.Branches()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list branches: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = branchIter.ForEach(func(ref *plumbing.Reference) error {
|
||||||
|
if len(result.Branches) >= Limits.MaxBranches {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
branch := Branch{
|
||||||
|
Name: ref.Name().Short(),
|
||||||
|
Commit: ref.Hash().String(),
|
||||||
|
IsRemote: false,
|
||||||
|
IsHead: ref.Name().Short() == result.Current,
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Branches = append(result.Branches, branch)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("failed to iterate branches: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List remote branches if requested
|
||||||
|
if includeRemote {
|
||||||
|
refs, err := c.repo.References()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list references: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = refs.ForEach(func(ref *plumbing.Reference) error {
|
||||||
|
if len(result.Branches) >= Limits.MaxBranches {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref.Name().IsRemote() {
|
||||||
|
branch := Branch{
|
||||||
|
Name: ref.Name().Short(),
|
||||||
|
Commit: ref.Hash().String(),
|
||||||
|
IsRemote: true,
|
||||||
|
IsHead: false,
|
||||||
|
}
|
||||||
|
result.Branches = append(result.Branches, branch)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("failed to iterate references: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Total = len(result.Branches)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchCommits searches commit messages for a pattern.
|
||||||
|
func (c *GitClient) SearchCommits(ref, query string, limit int) (*SearchResult, error) {
|
||||||
|
if limit <= 0 || limit > Limits.MaxSearchResult {
|
||||||
|
limit = Limits.MaxSearchResult
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := c.repo.Log(&git.LogOptions{
|
||||||
|
From: *hash,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get log: %w", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
result := &SearchResult{
|
||||||
|
Query: query,
|
||||||
|
}
|
||||||
|
queryLower := strings.ToLower(query)
|
||||||
|
|
||||||
|
// We need to scan more commits to find matches
|
||||||
|
scanned := 0
|
||||||
|
maxScan := limit * 100 // Scan up to 100x the limit
|
||||||
|
|
||||||
|
err = iter.ForEach(func(commit *object.Commit) error {
|
||||||
|
scanned++
|
||||||
|
if scanned > maxScan {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in message (case-insensitive)
|
||||||
|
if !strings.Contains(strings.ToLower(commit.Message), queryLower) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := commit.Message
|
||||||
|
if idx := strings.Index(subject, "\n"); idx != -1 {
|
||||||
|
subject = subject[:idx]
|
||||||
|
}
|
||||||
|
subject = strings.TrimSpace(subject)
|
||||||
|
|
||||||
|
result.Commits = append(result.Commits, LogEntry{
|
||||||
|
Hash: commit.Hash.String(),
|
||||||
|
ShortHash: commit.Hash.String()[:7],
|
||||||
|
Author: commit.Author.Name,
|
||||||
|
Email: commit.Author.Email,
|
||||||
|
Date: commit.Author.When,
|
||||||
|
Subject: subject,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(result.Commits) >= limit {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("failed to search commits: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Count = len(result.Commits)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
446
internal/gitexplorer/client_test.go
Normal file
446
internal/gitexplorer/client_test.go
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestRepo creates a temporary git repository with some commits for testing.
|
||||||
|
func createTestRepo(t *testing.T) (string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir, err := os.MkdirTemp("", "gitexplorer-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = os.RemoveAll(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := git.PlainInit(dir, false)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to init repo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to get worktree: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial file and commit
|
||||||
|
readme := filepath.Join(dir, "README.md")
|
||||||
|
if err := os.WriteFile(readme, []byte("# Test Repo\n"), 0644); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to write README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := wt.Add("README.md"); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to add README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := &object.Signature{
|
||||||
|
Name: "Test User",
|
||||||
|
Email: "test@example.com",
|
||||||
|
When: time.Now().Add(-2 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = wt.Commit("Initial commit", &git.CommitOptions{Author: sig})
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to create initial commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a second file and commit
|
||||||
|
subdir := filepath.Join(dir, "src")
|
||||||
|
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to create subdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainFile := filepath.Join(subdir, "main.go")
|
||||||
|
if err := os.WriteFile(mainFile, []byte("package main\n\nfunc main() {}\n"), 0644); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to write main.go: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := wt.Add("src/main.go"); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to add main.go: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig.When = time.Now().Add(-1 * time.Hour)
|
||||||
|
_, err = wt.Commit("Add main.go", &git.CommitOptions{Author: sig})
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to create second commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update README and commit
|
||||||
|
if err := os.WriteFile(readme, []byte("# Test Repo\n\nThis is a test repository.\n"), 0644); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to update README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := wt.Add("README.md"); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to add updated README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig.When = time.Now()
|
||||||
|
_, err = wt.Commit("Update README", &git.CommitOptions{Author: sig})
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to create third commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGitClient(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
t.Fatal("client is nil")
|
||||||
|
}
|
||||||
|
if client.defaultRemote != "origin" {
|
||||||
|
t.Errorf("defaultRemote = %q, want %q", client.defaultRemote, "origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with invalid path
|
||||||
|
_, err = NewGitClient("/nonexistent/path", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRef(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test resolving HEAD
|
||||||
|
result, err := client.ResolveRef("HEAD")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveRef(HEAD) failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Commit == "" {
|
||||||
|
t.Error("commit hash is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test resolving master branch
|
||||||
|
result, err = client.ResolveRef("master")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveRef(master) failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Type != "branch" {
|
||||||
|
t.Errorf("type = %q, want %q", result.Type, "branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test resolving invalid ref
|
||||||
|
_, err = client.ResolveRef("nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent ref")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLog(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full log
|
||||||
|
entries, err := client.GetLog("HEAD", 10, "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLog failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Errorf("got %d entries, want 3", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check order (newest first)
|
||||||
|
if entries[0].Subject != "Update README" {
|
||||||
|
t.Errorf("first entry subject = %q, want %q", entries[0].Subject, "Update README")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with limit
|
||||||
|
entries, err = client.GetLog("HEAD", 1, "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLog with limit failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("got %d entries, want 1", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with author filter
|
||||||
|
entries, err = client.GetLog("HEAD", 10, "Test User", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLog with author failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Errorf("got %d entries, want 3", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err = client.GetLog("HEAD", 10, "nonexistent", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLog with nonexistent author failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("got %d entries, want 0", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with path filter
|
||||||
|
entries, err = client.GetLog("HEAD", 10, "", "", "src")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLog with path failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("got %d entries, want 1 (only src/main.go commit)", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCommitInfo(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := client.GetCommitInfo("HEAD", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCommitInfo failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Author != "Test User" {
|
||||||
|
t.Errorf("author = %q, want %q", info.Author, "Test User")
|
||||||
|
}
|
||||||
|
if info.Email != "test@example.com" {
|
||||||
|
t.Errorf("email = %q, want %q", info.Email, "test@example.com")
|
||||||
|
}
|
||||||
|
if len(info.Parents) != 1 {
|
||||||
|
t.Errorf("parents = %d, want 1", len(info.Parents))
|
||||||
|
}
|
||||||
|
if info.Stats == nil {
|
||||||
|
t.Error("stats is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test without stats
|
||||||
|
info, err = client.GetCommitInfo("HEAD", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCommitInfo without stats failed: %v", err)
|
||||||
|
}
|
||||||
|
if info.Stats != nil {
|
||||||
|
t.Error("stats should be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDiffFiles(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.GetDiffFiles("HEAD~2", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDiffFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Files) < 1 {
|
||||||
|
t.Error("expected at least one changed file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that we have the expected files
|
||||||
|
foundReadme := false
|
||||||
|
foundMain := false
|
||||||
|
for _, f := range result.Files {
|
||||||
|
if f.Path == "README.md" {
|
||||||
|
foundReadme = true
|
||||||
|
}
|
||||||
|
if f.Path == "src/main.go" {
|
||||||
|
foundMain = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundReadme {
|
||||||
|
t.Error("expected README.md in diff")
|
||||||
|
}
|
||||||
|
if !foundMain {
|
||||||
|
t.Error("expected src/main.go in diff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFileAtCommit(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := client.GetFileAtCommit("HEAD", "README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFileAtCommit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.Path != "README.md" {
|
||||||
|
t.Errorf("path = %q, want %q", content.Path, "README.md")
|
||||||
|
}
|
||||||
|
if content.Content == "" {
|
||||||
|
t.Error("content is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test nested file
|
||||||
|
content, err = client.GetFileAtCommit("HEAD", "src/main.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFileAtCommit for nested file failed: %v", err)
|
||||||
|
}
|
||||||
|
if content.Path != "src/main.go" {
|
||||||
|
t.Errorf("path = %q, want %q", content.Path, "src/main.go")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test nonexistent file
|
||||||
|
_, err = client.GetFileAtCommit("HEAD", "nonexistent.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test path traversal
|
||||||
|
_, err = client.GetFileAtCommit("HEAD", "../../../etc/passwd")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for path traversal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAncestor(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First commit is ancestor of HEAD
|
||||||
|
result, err := client.IsAncestor("HEAD~2", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsAncestor failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.IsAncestor {
|
||||||
|
t.Error("HEAD~2 should be ancestor of HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEAD is not ancestor of first commit
|
||||||
|
result, err = client.IsAncestor("HEAD", "HEAD~2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsAncestor failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.IsAncestor {
|
||||||
|
t.Error("HEAD should not be ancestor of HEAD~2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitsBetween(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.CommitsBetween("HEAD~2", "HEAD", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CommitsBetween failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 2 commits (HEAD~1 and HEAD, exclusive of HEAD~2)
|
||||||
|
if result.Count != 2 {
|
||||||
|
t.Errorf("count = %d, want 2", result.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListBranches(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.ListBranches(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListBranches failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Total < 1 {
|
||||||
|
t.Error("expected at least one branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
foundMaster := false
|
||||||
|
for _, b := range result.Branches {
|
||||||
|
if b.Name == "master" {
|
||||||
|
foundMaster = true
|
||||||
|
if !b.IsHead {
|
||||||
|
t.Error("master should be HEAD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMaster {
|
||||||
|
t.Error("expected master branch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCommits(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.SearchCommits("HEAD", "README", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchCommits failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Count < 1 {
|
||||||
|
t.Error("expected at least one match for 'README'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with no matches
|
||||||
|
result, err = client.SearchCommits("HEAD", "nonexistent-query-xyz", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchCommits for no match failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Count != 0 {
|
||||||
|
t.Errorf("count = %d, want 0", result.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
195
internal/gitexplorer/format.go
Normal file
195
internal/gitexplorer/format.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatResolveResult formats a ResolveResult as markdown.
|
||||||
|
func FormatResolveResult(r *ResolveResult) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("**Ref:** %s\n", r.Ref))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Type:** %s\n", r.Type))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Commit:** %s\n", r.Commit))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatLogEntries formats a slice of LogEntry as markdown.
|
||||||
|
func FormatLogEntries(entries []LogEntry) string {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return "No commits found."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## Commit Log (%d commits)\n\n", len(entries)))
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
sb.WriteString(fmt.Sprintf("### %s %s\n", e.ShortHash, e.Subject))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", e.Author, e.Email))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Date:** %s\n\n", e.Date.Format("2006-01-02 15:04:05")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatCommitInfo formats a CommitInfo as markdown.
|
||||||
|
func FormatCommitInfo(info *CommitInfo) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## Commit Details\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("**Hash:** %s\n", info.Hash))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", info.Author, info.Email))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Date:** %s\n", info.Date.Format("2006-01-02 15:04:05")))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Committer:** %s\n", info.Committer))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Commit Date:** %s\n", info.CommitDate.Format("2006-01-02 15:04:05")))
|
||||||
|
|
||||||
|
if len(info.Parents) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("**Parents:** %s\n", strings.Join(info.Parents, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Stats != nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("**Changes:** %d file(s), +%d -%d\n",
|
||||||
|
info.Stats.FilesChanged, info.Stats.Additions, info.Stats.Deletions))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n### Message\n\n")
|
||||||
|
sb.WriteString("```\n")
|
||||||
|
sb.WriteString(info.Message)
|
||||||
|
if !strings.HasSuffix(info.Message, "\n") {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("```\n")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDiffResult formats a DiffResult as markdown.
|
||||||
|
func FormatDiffResult(r *DiffResult) string {
|
||||||
|
if len(r.Files) == 0 {
|
||||||
|
return "No files changed."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## Files Changed (%d files)\n\n", len(r.Files)))
|
||||||
|
sb.WriteString(fmt.Sprintf("**From:** %s\n", r.FromCommit[:7]))
|
||||||
|
sb.WriteString(fmt.Sprintf("**To:** %s\n\n", r.ToCommit[:7]))
|
||||||
|
|
||||||
|
sb.WriteString("| Status | Path | Changes |\n")
|
||||||
|
sb.WriteString("|--------|------|--------|\n")
|
||||||
|
|
||||||
|
for _, f := range r.Files {
|
||||||
|
path := f.Path
|
||||||
|
if f.OldPath != "" {
|
||||||
|
path = fmt.Sprintf("%s → %s", f.OldPath, f.Path)
|
||||||
|
}
|
||||||
|
changes := fmt.Sprintf("+%d -%d", f.Additions, f.Deletions)
|
||||||
|
sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", f.Status, path, changes))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatFileContent formats a FileContent as markdown.
|
||||||
|
func FormatFileContent(c *FileContent) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## File: %s\n\n", c.Path))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Commit:** %s\n", c.Commit[:7]))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Size:** %d bytes\n\n", c.Size))
|
||||||
|
|
||||||
|
// Determine language hint from extension
|
||||||
|
ext := ""
|
||||||
|
if idx := strings.LastIndex(c.Path, "."); idx != -1 {
|
||||||
|
ext = c.Path[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("```%s\n", ext))
|
||||||
|
sb.WriteString(c.Content)
|
||||||
|
if !strings.HasSuffix(c.Content, "\n") {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("```\n")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatAncestryResult formats an AncestryResult as markdown.
|
||||||
|
func FormatAncestryResult(r *AncestryResult) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## Ancestry Check\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("**Ancestor:** %s\n", r.Ancestor[:7]))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Descendant:** %s\n", r.Descendant[:7]))
|
||||||
|
|
||||||
|
if r.IsAncestor {
|
||||||
|
sb.WriteString("\n✓ **Yes**, the first commit is an ancestor of the second.\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\n✗ **No**, the first commit is not an ancestor of the second.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatCommitRange formats a CommitRange as markdown.
|
||||||
|
func FormatCommitRange(r *CommitRange) string {
|
||||||
|
if r.Count == 0 {
|
||||||
|
return "No commits in range."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## Commits Between (%d commits)\n\n", r.Count))
|
||||||
|
sb.WriteString(fmt.Sprintf("**From:** %s (exclusive)\n", r.FromCommit[:7]))
|
||||||
|
sb.WriteString(fmt.Sprintf("**To:** %s (inclusive)\n\n", r.ToCommit[:7]))
|
||||||
|
|
||||||
|
for _, e := range r.Commits {
|
||||||
|
sb.WriteString(fmt.Sprintf("- **%s** %s (%s)\n", e.ShortHash, e.Subject, e.Author))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBranchList formats a BranchList as markdown.
|
||||||
|
func FormatBranchList(r *BranchList) string {
|
||||||
|
if r.Total == 0 {
|
||||||
|
return "No branches found."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## Branches (%d total)\n\n", r.Total))
|
||||||
|
|
||||||
|
if r.Current != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("**Current branch:** %s\n\n", r.Current))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("| Branch | Commit | Type |\n")
|
||||||
|
sb.WriteString("|--------|--------|------|\n")
|
||||||
|
|
||||||
|
for _, b := range r.Branches {
|
||||||
|
branchType := "local"
|
||||||
|
if b.IsRemote {
|
||||||
|
branchType = "remote"
|
||||||
|
}
|
||||||
|
marker := ""
|
||||||
|
if b.IsHead {
|
||||||
|
marker = " ✓"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("| %s%s | %s | %s |\n", b.Name, marker, b.Commit[:7], branchType))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatSearchResult formats a SearchResult as markdown.
|
||||||
|
func FormatSearchResult(r *SearchResult) string {
|
||||||
|
if r.Count == 0 {
|
||||||
|
return fmt.Sprintf("No commits found matching '%s'.", r.Query)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## Search Results for '%s' (%d matches)\n\n", r.Query, r.Count))
|
||||||
|
|
||||||
|
for _, e := range r.Commits {
|
||||||
|
sb.WriteString(fmt.Sprintf("### %s %s\n", e.ShortHash, e.Subject))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", e.Author, e.Email))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Date:** %s\n\n", e.Date.Format("2006-01-02 15:04:05")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
440
internal/gitexplorer/handlers.go
Normal file
440
internal/gitexplorer/handlers.go
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.t-juice.club/torjus/labmcp/internal/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterHandlers registers all git-explorer tool handlers on the MCP server.
|
||||||
|
func RegisterHandlers(server *mcp.Server, client *GitClient) {
|
||||||
|
server.RegisterTool(resolveRefTool(), makeResolveRefHandler(client))
|
||||||
|
server.RegisterTool(getLogTool(), makeGetLogHandler(client))
|
||||||
|
server.RegisterTool(getCommitInfoTool(), makeGetCommitInfoHandler(client))
|
||||||
|
server.RegisterTool(getDiffFilesTool(), makeGetDiffFilesHandler(client))
|
||||||
|
server.RegisterTool(getFileAtCommitTool(), makeGetFileAtCommitHandler(client))
|
||||||
|
server.RegisterTool(isAncestorTool(), makeIsAncestorHandler(client))
|
||||||
|
server.RegisterTool(commitsBetweenTool(), makeCommitsBetweenHandler(client))
|
||||||
|
server.RegisterTool(listBranchesTool(), makeListBranchesHandler(client))
|
||||||
|
server.RegisterTool(searchCommitsTool(), makeSearchCommitsHandler(client))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool definitions
|
||||||
|
|
||||||
|
func resolveRefTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "resolve_ref",
|
||||||
|
Description: "Resolve a git ref (branch, tag, or commit hash) to its full commit hash",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Git ref to resolve (e.g., 'main', 'v1.0.0', 'HEAD', commit hash)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"ref"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "get_log",
|
||||||
|
Description: "Get commit log starting from a ref, with optional filters",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Starting ref for the log (default: HEAD)",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
Type: "integer",
|
||||||
|
Description: fmt.Sprintf("Maximum number of commits to return (default: 20, max: %d)", Limits.MaxLogEntries),
|
||||||
|
Default: 20,
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Filter by author name or email (substring match)",
|
||||||
|
},
|
||||||
|
"since": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Stop log at this ref (exclusive)",
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Filter commits that affect this path",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCommitInfoTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "get_commit_info",
|
||||||
|
Description: "Get full details for a specific commit including message, author, and optionally file statistics",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Commit ref (hash, branch, tag, or HEAD)",
|
||||||
|
},
|
||||||
|
"include_stats": {
|
||||||
|
Type: "boolean",
|
||||||
|
Description: "Include file change statistics (default: true)",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"ref"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDiffFilesTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "get_diff_files",
|
||||||
|
Description: "Get list of files changed between two commits with change type and line counts",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"from_ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Starting commit ref (the older commit)",
|
||||||
|
},
|
||||||
|
"to_ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Ending commit ref (the newer commit)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"from_ref", "to_ref"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFileAtCommitTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "get_file_at_commit",
|
||||||
|
Description: fmt.Sprintf("Get the contents of a file at a specific commit (max %dKB)", Limits.MaxFileContent/1024),
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Commit ref (hash, branch, tag, or HEAD)",
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Path to the file relative to repository root",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"ref", "path"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAncestorTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "is_ancestor",
|
||||||
|
Description: "Check if one commit is an ancestor of another",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"ancestor": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Potential ancestor commit ref",
|
||||||
|
},
|
||||||
|
"descendant": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Potential descendant commit ref",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"ancestor", "descendant"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitsBetweenTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "commits_between",
|
||||||
|
Description: "Get all commits between two refs (from is exclusive, to is inclusive)",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"from_ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Starting commit ref (exclusive - commits after this)",
|
||||||
|
},
|
||||||
|
"to_ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Ending commit ref (inclusive - up to and including this)",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
Type: "integer",
|
||||||
|
Description: fmt.Sprintf("Maximum number of commits (default: %d)", Limits.MaxLogEntries),
|
||||||
|
Default: Limits.MaxLogEntries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"from_ref", "to_ref"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listBranchesTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "list_branches",
|
||||||
|
Description: "List all branches in the repository",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"include_remote": {
|
||||||
|
Type: "boolean",
|
||||||
|
Description: "Include remote-tracking branches (default: false)",
|
||||||
|
Default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchCommitsTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "search_commits",
|
||||||
|
Description: "Search commit messages for a pattern (case-insensitive)",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"query": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Search pattern to match in commit messages",
|
||||||
|
},
|
||||||
|
"ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Starting ref for the search (default: HEAD)",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
Type: "integer",
|
||||||
|
Description: fmt.Sprintf("Maximum number of results (default: 20, max: %d)", Limits.MaxSearchResult),
|
||||||
|
Default: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"query"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler constructors
|
||||||
|
|
||||||
|
func makeResolveRefHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
ref, _ := args["ref"].(string)
|
||||||
|
if ref == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.ResolveRef(ref)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatResolveResult(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGetLogHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
ref := "HEAD"
|
||||||
|
if r, ok := args["ref"].(string); ok && r != "" {
|
||||||
|
ref = r
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 20
|
||||||
|
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||||
|
limit = int(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
author, _ := args["author"].(string)
|
||||||
|
since, _ := args["since"].(string)
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
|
||||||
|
entries, err := client.GetLog(ref, limit, author, since, path)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatLogEntries(entries))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGetCommitInfoHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
ref, _ := args["ref"].(string)
|
||||||
|
if ref == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
includeStats := true
|
||||||
|
if s, ok := args["include_stats"].(bool); ok {
|
||||||
|
includeStats = s
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := client.GetCommitInfo(ref, includeStats)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatCommitInfo(info))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGetDiffFilesHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
fromRef, _ := args["from_ref"].(string)
|
||||||
|
if fromRef == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("from_ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
toRef, _ := args["to_ref"].(string)
|
||||||
|
if toRef == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("to_ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.GetDiffFiles(fromRef, toRef)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatDiffResult(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGetFileAtCommitHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
ref, _ := args["ref"].(string)
|
||||||
|
if ref == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("path is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := client.GetFileAtCommit(ref, path)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatFileContent(content))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeIsAncestorHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
ancestor, _ := args["ancestor"].(string)
|
||||||
|
if ancestor == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("ancestor is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
descendant, _ := args["descendant"].(string)
|
||||||
|
if descendant == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("descendant is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.IsAncestor(ancestor, descendant)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatAncestryResult(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCommitsBetweenHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
fromRef, _ := args["from_ref"].(string)
|
||||||
|
if fromRef == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("from_ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
toRef, _ := args["to_ref"].(string)
|
||||||
|
if toRef == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("to_ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := Limits.MaxLogEntries
|
||||||
|
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||||
|
limit = int(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.CommitsBetween(fromRef, toRef, limit)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatCommitRange(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeListBranchesHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
includeRemote := false
|
||||||
|
if r, ok := args["include_remote"].(bool); ok {
|
||||||
|
includeRemote = r
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.ListBranches(includeRemote)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatBranchList(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSearchCommitsHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
query, _ := args["query"].(string)
|
||||||
|
if query == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("query is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := "HEAD"
|
||||||
|
if r, ok := args["ref"].(string); ok && r != "" {
|
||||||
|
ref = r
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 20
|
||||||
|
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||||
|
limit = int(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.SearchCommits(ref, query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatSearchResult(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
121
internal/gitexplorer/types.go
Normal file
121
internal/gitexplorer/types.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveResult contains the result of resolving a ref to a commit.
|
||||||
|
type ResolveResult struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
Type string `json:"type"` // "branch", "tag", "commit"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogEntry represents a single commit in the log.
|
||||||
|
type LogEntry struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
ShortHash string `json:"short_hash"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitInfo contains full details about a commit.
|
||||||
|
type CommitInfo struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Committer string `json:"committer"`
|
||||||
|
CommitDate time.Time `json:"commit_date"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Parents []string `json:"parents"`
|
||||||
|
Stats *FileStats `json:"stats,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStats contains statistics about file changes.
|
||||||
|
type FileStats struct {
|
||||||
|
FilesChanged int `json:"files_changed"`
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
Deletions int `json:"deletions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffFile represents a file changed between two commits.
|
||||||
|
type DiffFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
OldPath string `json:"old_path,omitempty"` // For renames
|
||||||
|
Status string `json:"status"` // "added", "modified", "deleted", "renamed"
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
Deletions int `json:"deletions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffResult contains the list of files changed between two commits.
|
||||||
|
type DiffResult struct {
|
||||||
|
FromCommit string `json:"from_commit"`
|
||||||
|
ToCommit string `json:"to_commit"`
|
||||||
|
Files []DiffFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileContent represents the content of a file at a specific commit.
|
||||||
|
type FileContent struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AncestryResult contains the result of an ancestry check.
|
||||||
|
type AncestryResult struct {
|
||||||
|
Ancestor string `json:"ancestor"`
|
||||||
|
Descendant string `json:"descendant"`
|
||||||
|
IsAncestor bool `json:"is_ancestor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitRange represents commits between two refs.
|
||||||
|
type CommitRange struct {
|
||||||
|
FromCommit string `json:"from_commit"`
|
||||||
|
ToCommit string `json:"to_commit"`
|
||||||
|
Commits []LogEntry `json:"commits"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch represents a git branch.
|
||||||
|
type Branch struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
IsRemote bool `json:"is_remote"`
|
||||||
|
IsHead bool `json:"is_head"`
|
||||||
|
Upstream string `json:"upstream,omitempty"`
|
||||||
|
AheadBy int `json:"ahead_by,omitempty"`
|
||||||
|
BehindBy int `json:"behind_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BranchList contains the list of branches.
|
||||||
|
type BranchList struct {
|
||||||
|
Branches []Branch `json:"branches"`
|
||||||
|
Current string `json:"current"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResult represents a commit matching a search query.
|
||||||
|
type SearchResult struct {
|
||||||
|
Commits []LogEntry `json:"commits"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limits defines the maximum values for various operations.
|
||||||
|
var Limits = struct {
|
||||||
|
MaxFileContent int64 // Maximum file size in bytes
|
||||||
|
MaxLogEntries int // Maximum commit log entries
|
||||||
|
MaxBranches int // Maximum branches to list
|
||||||
|
MaxDiffFiles int // Maximum files in diff
|
||||||
|
MaxSearchResult int // Maximum search results
|
||||||
|
}{
|
||||||
|
MaxFileContent: 100 * 1024, // 100KB
|
||||||
|
MaxLogEntries: 100,
|
||||||
|
MaxBranches: 500,
|
||||||
|
MaxDiffFiles: 1000,
|
||||||
|
MaxSearchResult: 100,
|
||||||
|
}
|
||||||
57
internal/gitexplorer/validation.go
Normal file
57
internal/gitexplorer/validation.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrPathTraversal is returned when a path attempts to traverse outside the repository.
|
||||||
|
ErrPathTraversal = errors.New("path traversal not allowed")
|
||||||
|
// ErrAbsolutePath is returned when an absolute path is provided.
|
||||||
|
ErrAbsolutePath = errors.New("absolute paths not allowed")
|
||||||
|
// ErrNullByte is returned when a path contains null bytes.
|
||||||
|
ErrNullByte = errors.New("null bytes not allowed in path")
|
||||||
|
// ErrEmptyPath is returned when a path is empty.
|
||||||
|
ErrEmptyPath = errors.New("path cannot be empty")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidatePath validates a file path for security.
|
||||||
|
// It rejects:
|
||||||
|
// - Absolute paths
|
||||||
|
// - Paths containing null bytes
|
||||||
|
// - Paths that attempt directory traversal (contain "..")
|
||||||
|
// - Empty paths
|
||||||
|
func ValidatePath(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return ErrEmptyPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for null bytes
|
||||||
|
if strings.Contains(path, "\x00") {
|
||||||
|
return ErrNullByte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for absolute paths
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
return ErrAbsolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the path and check for traversal
|
||||||
|
cleaned := filepath.Clean(path)
|
||||||
|
|
||||||
|
// Check if cleaned path starts with ".."
|
||||||
|
if strings.HasPrefix(cleaned, "..") {
|
||||||
|
return ErrPathTraversal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for ".." components in the path
|
||||||
|
parts := strings.Split(cleaned, string(filepath.Separator))
|
||||||
|
if slices.Contains(parts, "..") {
|
||||||
|
return ErrPathTraversal
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
91
internal/gitexplorer/validation_test.go
Normal file
91
internal/gitexplorer/validation_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidatePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
// Valid paths
|
||||||
|
{
|
||||||
|
name: "simple file",
|
||||||
|
path: "README.md",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested file",
|
||||||
|
path: "internal/gitexplorer/types.go",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file with dots",
|
||||||
|
path: "file.test.go",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current dir prefix",
|
||||||
|
path: "./README.md",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deeply nested",
|
||||||
|
path: "a/b/c/d/e/f/g.txt",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Invalid paths
|
||||||
|
{
|
||||||
|
name: "empty path",
|
||||||
|
path: "",
|
||||||
|
wantErr: ErrEmptyPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute path unix",
|
||||||
|
path: "/etc/passwd",
|
||||||
|
wantErr: ErrAbsolutePath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent dir traversal simple",
|
||||||
|
path: "../secret.txt",
|
||||||
|
wantErr: ErrPathTraversal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent dir traversal nested",
|
||||||
|
path: "foo/../../../etc/passwd",
|
||||||
|
wantErr: ErrPathTraversal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent dir traversal in middle",
|
||||||
|
path: "foo/bar/../../../secret",
|
||||||
|
wantErr: ErrPathTraversal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null byte",
|
||||||
|
path: "file\x00.txt",
|
||||||
|
wantErr: ErrNullByte,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null byte in middle",
|
||||||
|
path: "foo/bar\x00baz/file.txt",
|
||||||
|
wantErr: ErrNullByte,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double dot only",
|
||||||
|
path: "..",
|
||||||
|
wantErr: ErrPathTraversal,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidatePath(tt.path)
|
||||||
|
if err != tt.wantErr {
|
||||||
|
t.Errorf("ValidatePath(%q) = %v, want %v", tt.path, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/nixos"
|
"code.t-juice.club/torjus/labmcp/internal/nixos"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/options"
|
"code.t-juice.club/torjus/labmcp/internal/options"
|
||||||
)
|
)
|
||||||
|
|
||||||
// revisionPattern validates revision strings to prevent injection attacks.
|
// revisionPattern validates revision strings to prevent injection attacks.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestHomeManagerRevision is a known release branch for testing.
|
// TestHomeManagerRevision is a known release branch for testing.
|
||||||
|
|||||||
@@ -8,18 +8,35 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/options"
|
"code.t-juice.club/torjus/labmcp/internal/options"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/packages"
|
"code.t-juice.club/torjus/labmcp/internal/packages"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterHandlers registers all tool handlers on the server for options mode.
|
// RegisterHandlers registers all tool handlers on the server for options mode.
|
||||||
|
// Used by legacy nixos-options and hm-options servers (no package indexing).
|
||||||
func (s *Server) RegisterHandlers(indexer options.Indexer) {
|
func (s *Server) RegisterHandlers(indexer options.Indexer) {
|
||||||
|
s.registerOptionsHandlers(indexer, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterHandlersWithPackages registers all tool handlers for options mode
|
||||||
|
// with additional package indexing support. When pkgIndexer is non-nil,
|
||||||
|
// index_revision will also index packages, and list_revisions will show package counts.
|
||||||
|
func (s *Server) RegisterHandlersWithPackages(indexer options.Indexer, pkgIndexer *packages.Indexer) {
|
||||||
|
s.registerOptionsHandlers(indexer, pkgIndexer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerOptionsHandlers is the shared implementation for RegisterHandlers and RegisterHandlersWithPackages.
|
||||||
|
func (s *Server) registerOptionsHandlers(indexer options.Indexer, pkgIndexer *packages.Indexer) {
|
||||||
s.tools["search_options"] = s.handleSearchOptions
|
s.tools["search_options"] = s.handleSearchOptions
|
||||||
s.tools["get_option"] = s.handleGetOption
|
s.tools["get_option"] = s.handleGetOption
|
||||||
s.tools["get_file"] = s.handleGetFile
|
s.tools["get_file"] = s.handleGetFile
|
||||||
s.tools["index_revision"] = s.makeIndexHandler(indexer)
|
s.tools["index_revision"] = s.makeIndexHandler(indexer, pkgIndexer)
|
||||||
|
if pkgIndexer != nil {
|
||||||
|
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
|
||||||
|
} else {
|
||||||
s.tools["list_revisions"] = s.handleListRevisions
|
s.tools["list_revisions"] = s.handleListRevisions
|
||||||
|
}
|
||||||
s.tools["delete_revision"] = s.handleDeleteRevision
|
s.tools["delete_revision"] = s.handleDeleteRevision
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +45,7 @@ func (s *Server) RegisterPackageHandlers(pkgIndexer *packages.Indexer) {
|
|||||||
s.tools["search_packages"] = s.handleSearchPackages
|
s.tools["search_packages"] = s.handleSearchPackages
|
||||||
s.tools["get_package"] = s.handleGetPackage
|
s.tools["get_package"] = s.handleGetPackage
|
||||||
s.tools["get_file"] = s.handleGetFile
|
s.tools["get_file"] = s.handleGetFile
|
||||||
|
s.tools["index_revision"] = s.makePackageIndexHandler(pkgIndexer)
|
||||||
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
|
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
|
||||||
s.tools["delete_revision"] = s.handleDeleteRevision
|
s.tools["delete_revision"] = s.handleDeleteRevision
|
||||||
}
|
}
|
||||||
@@ -246,7 +264,8 @@ func (s *Server) handleGetFile(ctx context.Context, args map[string]interface{})
|
|||||||
}
|
}
|
||||||
|
|
||||||
// makeIndexHandler creates the index_revision handler with the indexer.
|
// makeIndexHandler creates the index_revision handler with the indexer.
|
||||||
func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
|
// If pkgIndexer is non-nil, it will also index packages after options and files.
|
||||||
|
func (s *Server) makeIndexHandler(indexer options.Indexer, pkgIndexer *packages.Indexer) ToolHandler {
|
||||||
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||||
revision, _ := args["revision"].(string)
|
revision, _ := args["revision"].(string)
|
||||||
if revision == "" {
|
if revision == "" {
|
||||||
@@ -278,6 +297,17 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
|
|||||||
s.logger.Printf("Warning: file indexing failed: %v", err)
|
s.logger.Printf("Warning: file indexing failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index packages if package indexer is available
|
||||||
|
var packageCount int
|
||||||
|
if pkgIndexer != nil {
|
||||||
|
pkgResult, pkgErr := pkgIndexer.IndexPackages(ctx, result.Revision.ID, result.Revision.GitHash)
|
||||||
|
if pkgErr != nil {
|
||||||
|
s.logger.Printf("Warning: package indexing failed: %v", pkgErr)
|
||||||
|
} else {
|
||||||
|
packageCount = pkgResult.PackageCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", result.Revision.GitHash))
|
sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", result.Revision.GitHash))
|
||||||
if result.Revision.ChannelName != "" {
|
if result.Revision.ChannelName != "" {
|
||||||
@@ -285,6 +315,9 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
|
|||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("Options: %d\n", result.OptionCount))
|
sb.WriteString(fmt.Sprintf("Options: %d\n", result.OptionCount))
|
||||||
sb.WriteString(fmt.Sprintf("Files: %d\n", fileCount))
|
sb.WriteString(fmt.Sprintf("Files: %d\n", fileCount))
|
||||||
|
if packageCount > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Packages: %d\n", packageCount))
|
||||||
|
}
|
||||||
// Handle Duration which may be time.Duration or interface{}
|
// Handle Duration which may be time.Duration or interface{}
|
||||||
if dur, ok := result.Duration.(time.Duration); ok {
|
if dur, ok := result.Duration.(time.Duration); ok {
|
||||||
sb.WriteString(fmt.Sprintf("Duration: %s\n", dur.Round(time.Millisecond)))
|
sb.WriteString(fmt.Sprintf("Duration: %s\n", dur.Round(time.Millisecond)))
|
||||||
@@ -296,6 +329,85 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// makePackageIndexHandler creates an index_revision handler for the packages-only server.
|
||||||
|
// It creates a revision record if needed, then indexes packages.
|
||||||
|
func (s *Server) makePackageIndexHandler(pkgIndexer *packages.Indexer) ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||||
|
revision, _ := args["revision"].(string)
|
||||||
|
if revision == "" {
|
||||||
|
return ErrorContent(fmt.Errorf("revision is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := packages.ValidateRevision(revision); err != nil {
|
||||||
|
return ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve channel aliases to git ref
|
||||||
|
ref := pkgIndexer.ResolveRevision(revision)
|
||||||
|
|
||||||
|
// Check if revision already exists
|
||||||
|
rev, err := s.store.GetRevision(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(fmt.Errorf("failed to check revision: %w", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rev == nil {
|
||||||
|
// Also try by channel name
|
||||||
|
rev, err = s.store.GetRevisionByChannel(ctx, revision)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(fmt.Errorf("failed to check revision: %w", err)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rev == nil {
|
||||||
|
// Create a new revision record
|
||||||
|
commitDate, _ := pkgIndexer.GetCommitDate(ctx, ref)
|
||||||
|
rev = &database.Revision{
|
||||||
|
GitHash: ref,
|
||||||
|
ChannelName: pkgIndexer.GetChannelName(revision),
|
||||||
|
CommitDate: commitDate,
|
||||||
|
}
|
||||||
|
if err := s.store.CreateRevision(ctx, rev); err != nil {
|
||||||
|
return ErrorContent(fmt.Errorf("failed to create revision: %w", err)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if packages are already indexed for this revision
|
||||||
|
if rev.PackageCount > 0 {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Revision already indexed: %s\n", rev.GitHash))
|
||||||
|
if rev.ChannelName != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Channel: %s\n", rev.ChannelName))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Packages: %d\n", rev.PackageCount))
|
||||||
|
sb.WriteString(fmt.Sprintf("Indexed at: %s\n", rev.IndexedAt.Format("2006-01-02 15:04")))
|
||||||
|
return CallToolResult{
|
||||||
|
Content: []Content{TextContent(sb.String())},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index packages
|
||||||
|
pkgResult, err := pkgIndexer.IndexPackages(ctx, rev.ID, rev.GitHash)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(fmt.Errorf("package indexing failed: %w", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", rev.GitHash))
|
||||||
|
if rev.ChannelName != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Channel: %s\n", rev.ChannelName))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Packages: %d\n", pkgResult.PackageCount))
|
||||||
|
if dur, ok := pkgResult.Duration.(time.Duration); ok {
|
||||||
|
sb.WriteString(fmt.Sprintf("Duration: %s\n", dur.Round(time.Millisecond)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return CallToolResult{
|
||||||
|
Content: []Content{TextContent(sb.String())},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleListRevisions handles the list_revisions tool.
|
// handleListRevisions handles the list_revisions tool.
|
||||||
func (s *Server) handleListRevisions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
func (s *Server) handleListRevisions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||||
revisions, err := s.store.ListRevisions(ctx)
|
revisions, err := s.store.ListRevisions(ctx)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerMode indicates which type of tools the server should expose.
|
// ServerMode indicates which type of tools the server should expose.
|
||||||
@@ -45,7 +45,7 @@ type ServerConfig struct {
|
|||||||
func DefaultNixOSConfig() ServerConfig {
|
func DefaultNixOSConfig() ServerConfig {
|
||||||
return ServerConfig{
|
return ServerConfig{
|
||||||
Name: "nixos-options",
|
Name: "nixos-options",
|
||||||
Version: "0.2.1",
|
Version: "0.4.0",
|
||||||
DefaultChannel: "nixos-stable",
|
DefaultChannel: "nixos-stable",
|
||||||
SourceName: "nixpkgs",
|
SourceName: "nixpkgs",
|
||||||
Mode: ModeOptions,
|
Mode: ModeOptions,
|
||||||
@@ -57,7 +57,9 @@ If the current project contains a flake.lock file, you can index the exact nixpk
|
|||||||
|
|
||||||
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
|
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
|
||||||
|
|
||||||
This ensures option documentation matches the nixpkgs version the project actually uses.`,
|
This ensures option documentation matches the nixpkgs version the project actually uses.
|
||||||
|
|
||||||
|
Note: index_revision also indexes packages when available, so both options and packages become searchable.`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ This ensures option documentation matches the nixpkgs version the project actual
|
|||||||
func DefaultNixpkgsPackagesConfig() ServerConfig {
|
func DefaultNixpkgsPackagesConfig() ServerConfig {
|
||||||
return ServerConfig{
|
return ServerConfig{
|
||||||
Name: "nixpkgs-packages",
|
Name: "nixpkgs-packages",
|
||||||
Version: "0.2.1",
|
Version: "0.4.0",
|
||||||
DefaultChannel: "nixos-stable",
|
DefaultChannel: "nixos-stable",
|
||||||
SourceName: "nixpkgs",
|
SourceName: "nixpkgs",
|
||||||
Mode: ModePackages,
|
Mode: ModePackages,
|
||||||
@@ -73,7 +75,9 @@ func DefaultNixpkgsPackagesConfig() ServerConfig {
|
|||||||
|
|
||||||
If the current project contains a flake.lock file, you can search packages from the exact nixpkgs revision used by the project:
|
If the current project contains a flake.lock file, you can search packages from the exact nixpkgs revision used by the project:
|
||||||
1. Read the flake.lock file to find the nixpkgs "rev" field
|
1. Read the flake.lock file to find the nixpkgs "rev" field
|
||||||
2. Ensure the revision is indexed (packages are indexed separately from options)
|
2. Call index_revision with that git hash to index packages for that specific version
|
||||||
|
|
||||||
|
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
|
||||||
|
|
||||||
This ensures package information matches the nixpkgs version the project actually uses.`,
|
This ensures package information matches the nixpkgs version the project actually uses.`,
|
||||||
}
|
}
|
||||||
@@ -83,7 +87,7 @@ This ensures package information matches the nixpkgs version the project actuall
|
|||||||
func DefaultMonitoringConfig() ServerConfig {
|
func DefaultMonitoringConfig() ServerConfig {
|
||||||
return ServerConfig{
|
return ServerConfig{
|
||||||
Name: "lab-monitoring",
|
Name: "lab-monitoring",
|
||||||
Version: "0.1.0",
|
Version: "0.3.1",
|
||||||
Mode: ModeCustom,
|
Mode: ModeCustom,
|
||||||
Instructions: `Lab Monitoring MCP Server - Query Prometheus metrics and Alertmanager alerts.
|
Instructions: `Lab Monitoring MCP Server - Query Prometheus metrics and Alertmanager alerts.
|
||||||
|
|
||||||
@@ -92,8 +96,9 @@ Tools for querying your monitoring stack:
|
|||||||
- List and inspect alerts from Alertmanager
|
- List and inspect alerts from Alertmanager
|
||||||
- View scrape target health status
|
- View scrape target health status
|
||||||
- Manage alert silences
|
- Manage alert silences
|
||||||
|
- Query logs via LogQL (when Loki is configured)
|
||||||
|
|
||||||
All queries are executed against live Prometheus and Alertmanager HTTP APIs.`,
|
All queries are executed against live Prometheus, Alertmanager, and Loki HTTP APIs.`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +106,7 @@ All queries are executed against live Prometheus and Alertmanager HTTP APIs.`,
|
|||||||
func DefaultHomeManagerConfig() ServerConfig {
|
func DefaultHomeManagerConfig() ServerConfig {
|
||||||
return ServerConfig{
|
return ServerConfig{
|
||||||
Name: "hm-options",
|
Name: "hm-options",
|
||||||
Version: "0.2.1",
|
Version: "0.3.0",
|
||||||
DefaultChannel: "hm-stable",
|
DefaultChannel: "hm-stable",
|
||||||
SourceName: "home-manager",
|
SourceName: "home-manager",
|
||||||
Mode: ModeOptions,
|
Mode: ModeOptions,
|
||||||
@@ -117,6 +122,27 @@ This ensures option documentation matches the home-manager version the project a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultGitExplorerConfig returns the default configuration for the git-explorer server.
|
||||||
|
func DefaultGitExplorerConfig() ServerConfig {
|
||||||
|
return ServerConfig{
|
||||||
|
Name: "git-explorer",
|
||||||
|
Version: "0.1.0",
|
||||||
|
Mode: ModeCustom,
|
||||||
|
Instructions: `Git Explorer MCP Server - Read-only access to git repository information.
|
||||||
|
|
||||||
|
Tools for exploring git repositories:
|
||||||
|
- Resolve refs (branches, tags, commits) to commit hashes
|
||||||
|
- View commit logs with filtering by author, path, or range
|
||||||
|
- Get full commit details including file change statistics
|
||||||
|
- Compare commits to see which files changed
|
||||||
|
- Read file contents at any commit
|
||||||
|
- Check ancestry relationships between commits
|
||||||
|
- Search commit messages
|
||||||
|
|
||||||
|
All operations are read-only and will never modify the repository.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Server is an MCP server that handles JSON-RPC requests.
|
// Server is an MCP server that handles JSON-RPC requests.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
store database.Store
|
store database.Store
|
||||||
@@ -405,7 +431,7 @@ func (s *Server) getOptionToolDefinitions() []Tool {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "index_revision",
|
Name: "index_revision",
|
||||||
Description: fmt.Sprintf("Index a %s revision to make its options searchable", sourceRepo),
|
Description: s.indexRevisionDescription(sourceRepo),
|
||||||
InputSchema: InputSchema{
|
InputSchema: InputSchema{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
Properties: map[string]Property{
|
Properties: map[string]Property{
|
||||||
@@ -442,6 +468,15 @@ func (s *Server) getOptionToolDefinitions() []Tool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// indexRevisionDescription returns the description for the index_revision tool,
|
||||||
|
// adjusted based on whether packages are also indexed.
|
||||||
|
func (s *Server) indexRevisionDescription(sourceRepo string) string {
|
||||||
|
if s.config.SourceName == "nixpkgs" {
|
||||||
|
return fmt.Sprintf("Index a %s revision to make its options and packages searchable", sourceRepo)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Index a %s revision to make its options searchable", sourceRepo)
|
||||||
|
}
|
||||||
|
|
||||||
// getPackageToolDefinitions returns the tool definitions for packages mode.
|
// getPackageToolDefinitions returns the tool definitions for packages mode.
|
||||||
func (s *Server) getPackageToolDefinitions() []Tool {
|
func (s *Server) getPackageToolDefinitions() []Tool {
|
||||||
exampleChannels := "'nixos-unstable', 'nixos-24.05'"
|
exampleChannels := "'nixos-unstable', 'nixos-24.05'"
|
||||||
@@ -525,6 +560,20 @@ func (s *Server) getPackageToolDefinitions() []Tool {
|
|||||||
Required: []string{"path"},
|
Required: []string{"path"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "index_revision",
|
||||||
|
Description: "Index a nixpkgs revision to make its packages searchable",
|
||||||
|
InputSchema: InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]Property{
|
||||||
|
"revision": {
|
||||||
|
Type: "string",
|
||||||
|
Description: fmt.Sprintf("Git hash (full or short) or channel name (e.g., %s)", exampleChannels),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"revision"},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "list_revisions",
|
Name: "list_revisions",
|
||||||
Description: "List all indexed nixpkgs revisions",
|
Description: "List all indexed nixpkgs revisions",
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/nixos"
|
"code.t-juice.club/torjus/labmcp/internal/nixos"
|
||||||
|
"code.t-juice.club/torjus/labmcp/internal/packages"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServerInitialize(t *testing.T) {
|
func TestServerInitialize(t *testing.T) {
|
||||||
@@ -145,6 +146,110 @@ func TestServerNotification(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPackagesServerToolsList(t *testing.T) {
|
||||||
|
store := setupTestStore(t)
|
||||||
|
server := NewServer(store, nil, DefaultNixpkgsPackagesConfig())
|
||||||
|
|
||||||
|
pkgIndexer := packages.NewIndexer(store)
|
||||||
|
server.RegisterPackageHandlers(pkgIndexer)
|
||||||
|
|
||||||
|
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
|
||||||
|
|
||||||
|
resp := runRequest(t, server, input)
|
||||||
|
|
||||||
|
if resp.Error != nil {
|
||||||
|
t.Fatalf("Unexpected error: %v", resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := resp.Result.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected map result, got %T", resp.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
tools, ok := result["tools"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected tools array, got %T", result["tools"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 6 tools (search_packages, get_package, get_file, index_revision, list_revisions, delete_revision)
|
||||||
|
if len(tools) != 6 {
|
||||||
|
t.Errorf("Expected 6 tools, got %d", len(tools))
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedTools := map[string]bool{
|
||||||
|
"search_packages": false,
|
||||||
|
"get_package": false,
|
||||||
|
"get_file": false,
|
||||||
|
"index_revision": false,
|
||||||
|
"list_revisions": false,
|
||||||
|
"delete_revision": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tool := range tools {
|
||||||
|
toolMap := tool.(map[string]interface{})
|
||||||
|
name := toolMap["name"].(string)
|
||||||
|
if _, ok := expectedTools[name]; ok {
|
||||||
|
expectedTools[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, found := range expectedTools {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Tool %q not found in tools list", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptionsServerWithPackagesToolsList(t *testing.T) {
|
||||||
|
store := setupTestStore(t)
|
||||||
|
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||||
|
|
||||||
|
indexer := nixos.NewIndexer(store)
|
||||||
|
pkgIndexer := packages.NewIndexer(store)
|
||||||
|
server.RegisterHandlersWithPackages(indexer, pkgIndexer)
|
||||||
|
|
||||||
|
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
|
||||||
|
|
||||||
|
resp := runRequest(t, server, input)
|
||||||
|
|
||||||
|
if resp.Error != nil {
|
||||||
|
t.Fatalf("Unexpected error: %v", resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := resp.Result.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected map result, got %T", resp.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
tools, ok := result["tools"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected tools array, got %T", result["tools"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should still have 6 tools (same as options-only)
|
||||||
|
if len(tools) != 6 {
|
||||||
|
t.Errorf("Expected 6 tools, got %d", len(tools))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify index_revision is present
|
||||||
|
found := false
|
||||||
|
for _, tool := range tools {
|
||||||
|
toolMap := tool.(map[string]interface{})
|
||||||
|
if toolMap["name"].(string) == "index_revision" {
|
||||||
|
found = true
|
||||||
|
// For nixpkgs source, description should mention packages
|
||||||
|
desc := toolMap["description"].(string)
|
||||||
|
if !strings.Contains(desc, "packages") {
|
||||||
|
t.Errorf("index_revision description should mention packages, got: %s", desc)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("index_revision tool not found in tools list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetFilePathValidation(t *testing.T) {
|
func TestGetFilePathValidation(t *testing.T) {
|
||||||
store := setupTestStore(t)
|
store := setupTestStore(t)
|
||||||
server := setupTestServer(t, store)
|
server := setupTestServer(t, store)
|
||||||
|
|||||||
@@ -294,6 +294,126 @@ func formatMetricSearch(names []string, metadata map[string][]PromMetadata) stri
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxLabelValues = 100
|
||||||
|
const maxLineLength = 500
|
||||||
|
|
||||||
|
// formatLogStreams formats Loki log query results as grouped markdown.
|
||||||
|
func formatLogStreams(data *LokiQueryData) string {
|
||||||
|
if data == nil || len(data.Result) == 0 {
|
||||||
|
return "No log results."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
totalEntries := 0
|
||||||
|
for _, s := range data.Result {
|
||||||
|
totalEntries += len(s.Values)
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("**%d stream(s), %d total log entries**\n\n", len(data.Result), totalEntries))
|
||||||
|
|
||||||
|
for _, stream := range data.Result {
|
||||||
|
// Stream labels header
|
||||||
|
var labels []string
|
||||||
|
for k, v := range stream.Stream {
|
||||||
|
labels = append(labels, fmt.Sprintf("%s=%q", k, v))
|
||||||
|
}
|
||||||
|
sort.Strings(labels)
|
||||||
|
sb.WriteString(fmt.Sprintf("## {%s}\n\n", strings.Join(labels, ", ")))
|
||||||
|
|
||||||
|
if len(stream.Values) == 0 {
|
||||||
|
sb.WriteString("No entries.\n\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("| Timestamp | Log Line |\n")
|
||||||
|
sb.WriteString("| --- | --- |\n")
|
||||||
|
|
||||||
|
truncated := false
|
||||||
|
for i, entry := range stream.Values {
|
||||||
|
if i >= maxRows {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := formatNanosecondTimestamp(entry[0])
|
||||||
|
line := entry[1]
|
||||||
|
if len(line) > maxLineLength {
|
||||||
|
line = line[:maxLineLength] + "..."
|
||||||
|
}
|
||||||
|
// Escape pipe characters in log lines for markdown table
|
||||||
|
line = strings.ReplaceAll(line, "|", "\\|")
|
||||||
|
// Replace newlines with spaces for table compatibility
|
||||||
|
line = strings.ReplaceAll(line, "\n", " ")
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("| %s | %s |\n", ts, line))
|
||||||
|
}
|
||||||
|
|
||||||
|
if truncated {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n*Showing %d of %d entries (truncated)*\n", maxRows, len(stream.Values)))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLabels formats a list of label names as a bullet list.
|
||||||
|
func formatLabels(labels []string) string {
|
||||||
|
if len(labels) == 0 {
|
||||||
|
return "No labels found."
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(labels)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("**%d label(s)**\n\n", len(labels)))
|
||||||
|
|
||||||
|
for _, label := range labels {
|
||||||
|
sb.WriteString(fmt.Sprintf("- `%s`\n", label))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLabelValues formats label values as a bullet list.
|
||||||
|
func formatLabelValues(label string, values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return fmt.Sprintf("No values found for label '%s'.", label)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(values)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("**%d value(s) for label `%s`**\n\n", len(values), label))
|
||||||
|
|
||||||
|
truncated := false
|
||||||
|
for i, v := range values {
|
||||||
|
if i >= maxLabelValues {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- `%s`\n", v))
|
||||||
|
}
|
||||||
|
|
||||||
|
if truncated {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n*Showing %d of %d values (truncated)*\n", maxLabelValues, len(values)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatNanosecondTimestamp converts a nanosecond Unix timestamp string to RFC3339.
|
||||||
|
func formatNanosecondTimestamp(nsStr string) string {
|
||||||
|
var ns int64
|
||||||
|
for _, c := range nsStr {
|
||||||
|
if c >= '0' && c <= '9' {
|
||||||
|
ns = ns*10 + int64(c-'0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := time.Unix(0, ns)
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
// formatMetricMetadata formats metadata for a single metric.
|
// formatMetricMetadata formats metadata for a single metric.
|
||||||
func formatMetricMetadata(name string, metas []PromMetadata) string {
|
func formatMetricMetadata(name string, metas []PromMetadata) string {
|
||||||
if len(metas) == 0 {
|
if len(metas) == 0 {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/mcp"
|
"code.t-juice.club/torjus/labmcp/internal/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertSummary queries Alertmanager for active (non-silenced) alerts and returns
|
// AlertSummary queries Alertmanager for active (non-silenced) alerts and returns
|
||||||
@@ -62,7 +62,7 @@ type HandlerOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegisterHandlers registers all monitoring tool handlers on the MCP server.
|
// RegisterHandlers registers all monitoring tool handlers on the MCP server.
|
||||||
func RegisterHandlers(server *mcp.Server, prom *PrometheusClient, am *AlertmanagerClient, opts HandlerOptions) {
|
func RegisterHandlers(server *mcp.Server, prom *PrometheusClient, am *AlertmanagerClient, loki *LokiClient, opts HandlerOptions) {
|
||||||
server.RegisterTool(listAlertsTool(), makeListAlertsHandler(am))
|
server.RegisterTool(listAlertsTool(), makeListAlertsHandler(am))
|
||||||
server.RegisterTool(getAlertTool(), makeGetAlertHandler(am))
|
server.RegisterTool(getAlertTool(), makeGetAlertHandler(am))
|
||||||
server.RegisterTool(searchMetricsTool(), makeSearchMetricsHandler(prom))
|
server.RegisterTool(searchMetricsTool(), makeSearchMetricsHandler(prom))
|
||||||
@@ -73,6 +73,11 @@ func RegisterHandlers(server *mcp.Server, prom *PrometheusClient, am *Alertmanag
|
|||||||
if opts.EnableSilences {
|
if opts.EnableSilences {
|
||||||
server.RegisterTool(createSilenceTool(), makeCreateSilenceHandler(am))
|
server.RegisterTool(createSilenceTool(), makeCreateSilenceHandler(am))
|
||||||
}
|
}
|
||||||
|
if loki != nil {
|
||||||
|
server.RegisterTool(queryLogsTool(), makeQueryLogsHandler(loki))
|
||||||
|
server.RegisterTool(listLabelsTool(), makeListLabelsHandler(loki))
|
||||||
|
server.RegisterTool(listLabelValuesTool(), makeListLabelValuesHandler(loki))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool definitions
|
// Tool definitions
|
||||||
@@ -86,8 +91,8 @@ func listAlertsTool() mcp.Tool {
|
|||||||
Properties: map[string]mcp.Property{
|
Properties: map[string]mcp.Property{
|
||||||
"state": {
|
"state": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
Description: "Filter by alert state: 'active', 'suppressed', or 'unprocessed'",
|
Description: "Filter by alert state: 'active', 'suppressed', 'unprocessed', or 'all' (default: active)",
|
||||||
Enum: []string{"active", "suppressed", "unprocessed"},
|
Enum: []string{"active", "suppressed", "unprocessed", "all"},
|
||||||
},
|
},
|
||||||
"severity": {
|
"severity": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@@ -231,9 +236,10 @@ func makeListAlertsHandler(am *AlertmanagerClient) mcp.ToolHandler {
|
|||||||
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
filters := AlertFilters{}
|
filters := AlertFilters{}
|
||||||
|
|
||||||
if state, ok := args["state"].(string); ok && state != "" {
|
state, _ := args["state"].(string)
|
||||||
switch state {
|
switch state {
|
||||||
case "active":
|
case "active", "":
|
||||||
|
// Default to active alerts only (non-silenced, non-inhibited)
|
||||||
active := true
|
active := true
|
||||||
filters.Active = &active
|
filters.Active = &active
|
||||||
silenced := false
|
silenced := false
|
||||||
@@ -246,7 +252,8 @@ func makeListAlertsHandler(am *AlertmanagerClient) mcp.ToolHandler {
|
|||||||
case "unprocessed":
|
case "unprocessed":
|
||||||
unprocessed := true
|
unprocessed := true
|
||||||
filters.Unprocessed = &unprocessed
|
filters.Unprocessed = &unprocessed
|
||||||
}
|
case "all":
|
||||||
|
// No filters - return everything
|
||||||
}
|
}
|
||||||
|
|
||||||
if severity, ok := args["severity"].(string); ok && severity != "" {
|
if severity, ok := args["severity"].(string); ok && severity != "" {
|
||||||
@@ -485,3 +492,189 @@ func makeCreateSilenceHandler(am *AlertmanagerClient) mcp.ToolHandler {
|
|||||||
func parseJSON(s string, v interface{}) error {
|
func parseJSON(s string, v interface{}) error {
|
||||||
return json.Unmarshal([]byte(s), v)
|
return json.Unmarshal([]byte(s), v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loki tool definitions
|
||||||
|
|
||||||
|
func queryLogsTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "query_logs",
|
||||||
|
Description: "Execute a LogQL range query against Loki to search and retrieve log entries",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"logql": {
|
||||||
|
Type: "string",
|
||||||
|
Description: `LogQL query expression (e.g., '{job="varlogs"}', '{job="nginx"} |= "error"')`,
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Start time: relative duration (e.g., '1h', '30m'), RFC3339 timestamp, or Unix epoch seconds. Default: 1h ago",
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "End time: relative duration (e.g., '5m'), RFC3339 timestamp, or Unix epoch seconds. Default: now",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
Type: "integer",
|
||||||
|
Description: "Maximum number of log entries to return (default: 100)",
|
||||||
|
Default: 100,
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Sort order for log entries: 'backward' (newest first) or 'forward' (oldest first)",
|
||||||
|
Enum: []string{"backward", "forward"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"logql"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listLabelsTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "list_labels",
|
||||||
|
Description: "List available label names from Loki",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listLabelValuesTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "list_label_values",
|
||||||
|
Description: "List values for a specific label from Loki",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"label": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Label name to get values for (e.g., 'job', 'instance')",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"label"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loki handler constructors
|
||||||
|
|
||||||
|
func makeQueryLogsHandler(loki *LokiClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
logql, _ := args["logql"].(string)
|
||||||
|
if logql == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("logql is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
start := now.Add(-time.Hour)
|
||||||
|
end := now
|
||||||
|
|
||||||
|
if startStr, ok := args["start"].(string); ok && startStr != "" {
|
||||||
|
parsed, err := parseTimeArg(startStr, now.Add(-time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("invalid start time: %w", err)), nil
|
||||||
|
}
|
||||||
|
start = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if endStr, ok := args["end"].(string); ok && endStr != "" {
|
||||||
|
parsed, err := parseTimeArg(endStr, now)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("invalid end time: %w", err)), nil
|
||||||
|
}
|
||||||
|
end = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 100
|
||||||
|
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||||
|
limit = int(l)
|
||||||
|
}
|
||||||
|
if limit > 5000 {
|
||||||
|
limit = 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
direction := "backward"
|
||||||
|
if d, ok := args["direction"].(string); ok && d != "" {
|
||||||
|
if d != "backward" && d != "forward" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("direction must be 'backward' or 'forward'")), nil
|
||||||
|
}
|
||||||
|
direction = d
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := loki.QueryRange(ctx, logql, start, end, limit, direction)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("log query failed: %w", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(formatLogStreams(data))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeListLabelsHandler(loki *LokiClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
labels, err := loki.Labels(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("failed to list labels: %w", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(formatLabels(labels))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeListLabelValuesHandler(loki *LokiClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
label, _ := args["label"].(string)
|
||||||
|
if label == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("label is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := loki.LabelValues(ctx, label)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("failed to list label values: %w", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(formatLabelValues(label, values))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTimeArg parses a time argument that can be:
|
||||||
|
// - A relative duration (e.g., "1h", "30m", "2h30m") — interpreted as that duration ago from now
|
||||||
|
// - An RFC3339 timestamp (e.g., "2024-01-15T10:30:00Z")
|
||||||
|
// - A Unix epoch in seconds (e.g., "1705312200")
|
||||||
|
// If parsing fails, returns the provided default time.
|
||||||
|
func parseTimeArg(s string, defaultTime time.Time) (time.Time, error) {
|
||||||
|
// Try as relative duration first
|
||||||
|
if d, err := time.ParseDuration(s); err == nil {
|
||||||
|
return time.Now().Add(-d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as RFC3339
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as Unix epoch seconds
|
||||||
|
var epoch int64
|
||||||
|
validDigits := true
|
||||||
|
for _, c := range s {
|
||||||
|
if c >= '0' && c <= '9' {
|
||||||
|
epoch = epoch*10 + int64(c-'0')
|
||||||
|
} else {
|
||||||
|
validDigits = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if validDigits && len(s) > 0 {
|
||||||
|
return time.Unix(epoch, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultTime, fmt.Errorf("cannot parse time '%s': use relative duration (e.g., '1h'), RFC3339, or Unix epoch seconds", s)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/mcp"
|
"code.t-juice.club/torjus/labmcp/internal/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupTestServer creates a test MCP server with monitoring handlers backed by test HTTP servers.
|
// setupTestServer creates a test MCP server with monitoring handlers backed by test HTTP servers.
|
||||||
func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc) (*mcp.Server, func()) {
|
func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc, lokiHandler ...http.HandlerFunc) (*mcp.Server, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
promSrv := httptest.NewServer(promHandler)
|
promSrv := httptest.NewServer(promHandler)
|
||||||
@@ -26,11 +26,22 @@ func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc) (*mc
|
|||||||
|
|
||||||
prom := NewPrometheusClient(promSrv.URL)
|
prom := NewPrometheusClient(promSrv.URL)
|
||||||
am := NewAlertmanagerClient(amSrv.URL)
|
am := NewAlertmanagerClient(amSrv.URL)
|
||||||
RegisterHandlers(server, prom, am, HandlerOptions{EnableSilences: true})
|
|
||||||
|
var loki *LokiClient
|
||||||
|
var lokiSrv *httptest.Server
|
||||||
|
if len(lokiHandler) > 0 && lokiHandler[0] != nil {
|
||||||
|
lokiSrv = httptest.NewServer(lokiHandler[0])
|
||||||
|
loki = NewLokiClient(LokiClientOptions{BaseURL: lokiSrv.URL})
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterHandlers(server, prom, am, loki, HandlerOptions{EnableSilences: true})
|
||||||
|
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
promSrv.Close()
|
promSrv.Close()
|
||||||
amSrv.Close()
|
amSrv.Close()
|
||||||
|
if lokiSrv != nil {
|
||||||
|
lokiSrv.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return server, cleanup
|
return server, cleanup
|
||||||
@@ -70,6 +81,92 @@ func TestHandler_ListAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_ListAlertsDefaultsToActive(t *testing.T) {
|
||||||
|
// Test that list_alerts with no state param defaults to active filters
|
||||||
|
server, cleanup := setupTestServer(t,
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
// Default should apply active filters
|
||||||
|
if q.Get("active") != "true" {
|
||||||
|
t.Errorf("expected default active=true, got %s", q.Get("active"))
|
||||||
|
}
|
||||||
|
if q.Get("silenced") != "false" {
|
||||||
|
t.Errorf("expected default silenced=false, got %s", q.Get("silenced"))
|
||||||
|
}
|
||||||
|
if q.Get("inhibited") != "false" {
|
||||||
|
t.Errorf("expected default inhibited=false, got %s", q.Get("inhibited"))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
result := callTool(t, server, "list_alerts", map[string]interface{}{})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_ListAlertsStateAll(t *testing.T) {
|
||||||
|
// Test that list_alerts with state=all applies no filters
|
||||||
|
server, cleanup := setupTestServer(t,
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
// state=all should not set any filter params
|
||||||
|
if q.Get("active") != "" {
|
||||||
|
t.Errorf("expected no active param for state=all, got %s", q.Get("active"))
|
||||||
|
}
|
||||||
|
if q.Get("silenced") != "" {
|
||||||
|
t.Errorf("expected no silenced param for state=all, got %s", q.Get("silenced"))
|
||||||
|
}
|
||||||
|
if q.Get("inhibited") != "" {
|
||||||
|
t.Errorf("expected no inhibited param for state=all, got %s", q.Get("inhibited"))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{
|
||||||
|
"annotations": {},
|
||||||
|
"endsAt": "2024-01-01T01:00:00Z",
|
||||||
|
"fingerprint": "fp1",
|
||||||
|
"receivers": [{"name": "default"}],
|
||||||
|
"startsAt": "2024-01-01T00:00:00Z",
|
||||||
|
"status": {"inhibitedBy": [], "silencedBy": [], "state": "active"},
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"generatorURL": "",
|
||||||
|
"labels": {"alertname": "ActiveAlert", "severity": "critical"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"annotations": {},
|
||||||
|
"endsAt": "2024-01-01T01:00:00Z",
|
||||||
|
"fingerprint": "fp2",
|
||||||
|
"receivers": [{"name": "default"}],
|
||||||
|
"startsAt": "2024-01-01T00:00:00Z",
|
||||||
|
"status": {"inhibitedBy": [], "silencedBy": ["s1"], "state": "suppressed"},
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"generatorURL": "",
|
||||||
|
"labels": {"alertname": "SilencedAlert", "severity": "warning"}
|
||||||
|
}
|
||||||
|
]`))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
result := callTool(t, server, "list_alerts", map[string]interface{}{
|
||||||
|
"state": "all",
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Content[0].Text, "2 alert") {
|
||||||
|
t.Errorf("expected output to contain '2 alert', got: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandler_GetAlert(t *testing.T) {
|
func TestHandler_GetAlert(t *testing.T) {
|
||||||
server, cleanup := setupTestServer(t,
|
server, cleanup := setupTestServer(t,
|
||||||
nil,
|
nil,
|
||||||
@@ -305,8 +402,9 @@ func TestHandler_ToolCount(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
tools := listTools(t, server)
|
tools := listTools(t, server)
|
||||||
|
// Without Loki: 7 base + 1 silence = 8
|
||||||
if len(tools) != 8 {
|
if len(tools) != 8 {
|
||||||
t.Errorf("expected 8 tools with silences enabled, got %d", len(tools))
|
t.Errorf("expected 8 tools with silences enabled (no Loki), got %d", len(tools))
|
||||||
for _, tool := range tools {
|
for _, tool := range tools {
|
||||||
t.Logf(" tool: %s", tool.Name)
|
t.Logf(" tool: %s", tool.Name)
|
||||||
}
|
}
|
||||||
@@ -325,6 +423,37 @@ func TestHandler_ToolCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_ToolCountWithLoki(t *testing.T) {
|
||||||
|
server, cleanup := setupTestServer(t,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {},
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {},
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {},
|
||||||
|
)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
tools := listTools(t, server)
|
||||||
|
// With Loki: 7 base + 1 silence + 3 loki = 11
|
||||||
|
if len(tools) != 11 {
|
||||||
|
t.Errorf("expected 11 tools with silences and Loki enabled, got %d", len(tools))
|
||||||
|
for _, tool := range tools {
|
||||||
|
t.Logf(" tool: %s", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Loki tools are present
|
||||||
|
lokiTools := map[string]bool{"query_logs": false, "list_labels": false, "list_label_values": false}
|
||||||
|
for _, tool := range tools {
|
||||||
|
if _, ok := lokiTools[tool.Name]; ok {
|
||||||
|
lokiTools[tool.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, found := range lokiTools {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected %s tool when Loki enabled", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandler_ToolCountWithoutSilences(t *testing.T) {
|
func TestHandler_ToolCountWithoutSilences(t *testing.T) {
|
||||||
promSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
promSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||||
amSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
amSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||||
@@ -337,7 +466,7 @@ func TestHandler_ToolCountWithoutSilences(t *testing.T) {
|
|||||||
|
|
||||||
prom := NewPrometheusClient(promSrv.URL)
|
prom := NewPrometheusClient(promSrv.URL)
|
||||||
am := NewAlertmanagerClient(amSrv.URL)
|
am := NewAlertmanagerClient(amSrv.URL)
|
||||||
RegisterHandlers(server, prom, am, HandlerOptions{EnableSilences: false})
|
RegisterHandlers(server, prom, am, nil, HandlerOptions{EnableSilences: false})
|
||||||
|
|
||||||
tools := listTools(t, server)
|
tools := listTools(t, server)
|
||||||
if len(tools) != 7 {
|
if len(tools) != 7 {
|
||||||
@@ -384,6 +513,110 @@ func listTools(t *testing.T, server *mcp.Server) []mcp.Tool {
|
|||||||
return listResult.Tools
|
return listResult.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_QueryLogs(t *testing.T) {
|
||||||
|
server, cleanup := setupTestServer(t,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/loki/api/v1/query_range" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"resultType": "streams",
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"stream": {"job": "varlogs", "filename": "/var/log/syslog"},
|
||||||
|
"values": [
|
||||||
|
["1704067200000000000", "Jan 1 00:00:00 host kernel: test message"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
result := callTool(t, server, "query_logs", map[string]interface{}{
|
||||||
|
"logql": `{job="varlogs"}`,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Content[0].Text, "varlogs") {
|
||||||
|
t.Errorf("expected output to contain 'varlogs', got: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Content[0].Text, "test message") {
|
||||||
|
t.Errorf("expected output to contain 'test message', got: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_ListLabels(t *testing.T) {
|
||||||
|
server, cleanup := setupTestServer(t,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/loki/api/v1/labels" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status": "success",
|
||||||
|
"data": ["job", "instance", "filename"]
|
||||||
|
}`))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
result := callTool(t, server, "list_labels", map[string]interface{}{})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Content[0].Text, "3 label") {
|
||||||
|
t.Errorf("expected output to contain '3 label', got: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Content[0].Text, "job") {
|
||||||
|
t.Errorf("expected output to contain 'job', got: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_ListLabelValues(t *testing.T) {
|
||||||
|
server, cleanup := setupTestServer(t,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/loki/api/v1/label/job/values" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status": "success",
|
||||||
|
"data": ["varlogs", "nginx", "systemd"]
|
||||||
|
}`))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
result := callTool(t, server, "list_label_values", map[string]interface{}{
|
||||||
|
"label": "job",
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Content[0].Text, "3 value") {
|
||||||
|
t.Errorf("expected output to contain '3 value', got: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Content[0].Text, "nginx") {
|
||||||
|
t.Errorf("expected output to contain 'nginx', got: %s", result.Content[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// callTool is a test helper that calls a tool through the MCP server.
|
// callTool is a test helper that calls a tool through the MCP server.
|
||||||
func callTool(t *testing.T, server *mcp.Server, name string, args map[string]interface{}) mcp.CallToolResult {
|
func callTool(t *testing.T, server *mcp.Server, name string, args map[string]interface{}) mcp.CallToolResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
137
internal/monitoring/loki.go
Normal file
137
internal/monitoring/loki.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package monitoring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LokiClientOptions configures the Loki client.
|
||||||
|
type LokiClientOptions struct {
|
||||||
|
BaseURL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LokiClient is an HTTP client for the Loki API.
|
||||||
|
type LokiClient struct {
|
||||||
|
baseURL string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLokiClient creates a new Loki API client.
|
||||||
|
func NewLokiClient(opts LokiClientOptions) *LokiClient {
|
||||||
|
return &LokiClient{
|
||||||
|
baseURL: strings.TrimRight(opts.BaseURL, "/"),
|
||||||
|
username: opts.Username,
|
||||||
|
password: opts.Password,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryRange executes a LogQL range query against Loki.
|
||||||
|
func (c *LokiClient) QueryRange(ctx context.Context, logql string, start, end time.Time, limit int, direction string) (*LokiQueryData, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("query", logql)
|
||||||
|
params.Set("start", fmt.Sprintf("%d", start.UnixNano()))
|
||||||
|
params.Set("end", fmt.Sprintf("%d", end.UnixNano()))
|
||||||
|
if limit > 0 {
|
||||||
|
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||||
|
}
|
||||||
|
if direction != "" {
|
||||||
|
params.Set("direction", direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := c.get(ctx, "/loki/api/v1/query_range", params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query range failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data LokiQueryData
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse query data: %w", err)
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels returns all available label names from Loki.
|
||||||
|
func (c *LokiClient) Labels(ctx context.Context) ([]string, error) {
|
||||||
|
body, err := c.get(ctx, "/loki/api/v1/labels", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("labels failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var labels []string
|
||||||
|
if err := json.Unmarshal(body, &labels); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse labels: %w", err)
|
||||||
|
}
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LabelValues returns all values for a given label name from Loki.
|
||||||
|
func (c *LokiClient) LabelValues(ctx context.Context, label string) ([]string, error) {
|
||||||
|
path := fmt.Sprintf("/loki/api/v1/label/%s/values", url.PathEscape(label))
|
||||||
|
body, err := c.get(ctx, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("label values failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var values []string
|
||||||
|
if err := json.Unmarshal(body, &values); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse label values: %w", err)
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get performs a GET request and returns the "data" field from the Loki response envelope.
|
||||||
|
// Loki uses the same {"status":"success","data":...} format as Prometheus.
|
||||||
|
func (c *LokiClient) get(ctx context.Context, path string, params url.Values) (json.RawMessage, error) {
|
||||||
|
u := c.baseURL + path
|
||||||
|
if len(params) > 0 {
|
||||||
|
u += "?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.username != "" {
|
||||||
|
req.SetBasicAuth(c.username, c.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck // cleanup on exit
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var promResp PromResponse
|
||||||
|
if err := json.Unmarshal(body, &promResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if promResp.Status != "success" {
|
||||||
|
return nil, fmt.Errorf("loki error (%s): %s", promResp.ErrorType, promResp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return promResp.Data, nil
|
||||||
|
}
|
||||||
221
internal/monitoring/loki_test.go
Normal file
221
internal/monitoring/loki_test.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package monitoring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLokiClient_QueryRange(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/loki/api/v1/query_range" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("query") != `{job="varlogs"}` {
|
||||||
|
t.Errorf("unexpected query param: %s", r.URL.Query().Get("query"))
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("direction") != "backward" {
|
||||||
|
t.Errorf("unexpected direction: %s", r.URL.Query().Get("direction"))
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("limit") != "10" {
|
||||||
|
t.Errorf("unexpected limit: %s", r.URL.Query().Get("limit"))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"resultType": "streams",
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"stream": {"job": "varlogs", "filename": "/var/log/syslog"},
|
||||||
|
"values": [
|
||||||
|
["1234567890000000000", "line 1"],
|
||||||
|
["1234567891000000000", "line 2"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
|
||||||
|
start := time.Unix(0, 1234567890000000000)
|
||||||
|
end := time.Unix(0, 1234567899000000000)
|
||||||
|
data, err := client.QueryRange(context.Background(), `{job="varlogs"}`, start, end, 10, "backward")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.ResultType != "streams" {
|
||||||
|
t.Errorf("expected resultType=streams, got %s", data.ResultType)
|
||||||
|
}
|
||||||
|
if len(data.Result) != 1 {
|
||||||
|
t.Fatalf("expected 1 stream, got %d", len(data.Result))
|
||||||
|
}
|
||||||
|
if data.Result[0].Stream["job"] != "varlogs" {
|
||||||
|
t.Errorf("expected job=varlogs, got %s", data.Result[0].Stream["job"])
|
||||||
|
}
|
||||||
|
if len(data.Result[0].Values) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(data.Result[0].Values))
|
||||||
|
}
|
||||||
|
if data.Result[0].Values[0][1] != "line 1" {
|
||||||
|
t.Errorf("expected first line='line 1', got %s", data.Result[0].Values[0][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLokiClient_QueryRangeError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status": "error",
|
||||||
|
"errorType": "bad_data",
|
||||||
|
"error": "invalid LogQL query"
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
|
||||||
|
_, err := client.QueryRange(context.Background(), "invalid{", time.Now().Add(-time.Hour), time.Now(), 100, "backward")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !contains(err.Error(), "invalid LogQL query") {
|
||||||
|
t.Errorf("expected error to contain 'invalid LogQL query', got: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLokiClient_Labels(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/loki/api/v1/labels" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status": "success",
|
||||||
|
"data": ["job", "instance", "filename"]
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
|
||||||
|
labels, err := client.Labels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(labels) != 3 {
|
||||||
|
t.Fatalf("expected 3 labels, got %d", len(labels))
|
||||||
|
}
|
||||||
|
if labels[0] != "job" {
|
||||||
|
t.Errorf("expected first label=job, got %s", labels[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLokiClient_LabelValues(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/loki/api/v1/label/job/values" {
|
||||||
|
t.Errorf("unexpected path: %s, expected /loki/api/v1/label/job/values", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status": "success",
|
||||||
|
"data": ["varlogs", "nginx", "systemd"]
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
|
||||||
|
values, err := client.LabelValues(context.Background(), "job")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(values) != 3 {
|
||||||
|
t.Fatalf("expected 3 values, got %d", len(values))
|
||||||
|
}
|
||||||
|
if values[0] != "varlogs" {
|
||||||
|
t.Errorf("expected first value=varlogs, got %s", values[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLokiClient_BasicAuth(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
t.Error("expected basic auth to be set")
|
||||||
|
}
|
||||||
|
if user != "myuser" {
|
||||||
|
t.Errorf("expected username=myuser, got %s", user)
|
||||||
|
}
|
||||||
|
if pass != "mypass" {
|
||||||
|
t.Errorf("expected password=mypass, got %s", pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status": "success",
|
||||||
|
"data": ["job"]
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewLokiClient(LokiClientOptions{
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
Username: "myuser",
|
||||||
|
Password: "mypass",
|
||||||
|
})
|
||||||
|
labels, err := client.Labels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(labels) != 1 || labels[0] != "job" {
|
||||||
|
t.Errorf("unexpected labels: %v", labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLokiClient_NoAuthWhenNoCredentials(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, _, ok := r.BasicAuth(); ok {
|
||||||
|
t.Error("expected no basic auth header, but it was set")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"status": "success",
|
||||||
|
"data": ["job"]
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
|
||||||
|
labels, err := client.Labels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(labels) != 1 || labels[0] != "job" {
|
||||||
|
t.Errorf("unexpected labels: %v", labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLokiClient_HTTPError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("internal error"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
|
||||||
|
_, err := client.QueryRange(context.Background(), `{job="test"}`, time.Now().Add(-time.Hour), time.Now(), 100, "backward")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !contains(err.Error(), "500") {
|
||||||
|
t.Errorf("expected error to contain status code, got: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,3 +118,20 @@ type Matcher struct {
|
|||||||
IsRegex bool `json:"isRegex"`
|
IsRegex bool `json:"isRegex"`
|
||||||
IsEqual *bool `json:"isEqual,omitempty"`
|
IsEqual *bool `json:"isEqual,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loki API response types
|
||||||
|
|
||||||
|
// LokiQueryData represents the data field for Loki query results.
|
||||||
|
type LokiQueryData struct {
|
||||||
|
ResultType string `json:"resultType"`
|
||||||
|
Result []LokiStream `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LokiStream represents a single log stream with its entries.
|
||||||
|
type LokiStream struct {
|
||||||
|
Stream map[string]string `json:"stream"`
|
||||||
|
Values []LokiEntry `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LokiEntry represents a log entry as [nanosecond_timestamp, log_line].
|
||||||
|
type LokiEntry [2]string
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
"git.t-juice.club/torjus/labmcp/internal/options"
|
"code.t-juice.club/torjus/labmcp/internal/options"
|
||||||
)
|
)
|
||||||
|
|
||||||
// revisionPattern validates revision strings to prevent injection attacks.
|
// revisionPattern validates revision strings to prevent injection attacks.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNixpkgsRevision is the revision from flake.lock used for testing.
|
// TestNixpkgsRevision is the revision from flake.lock used for testing.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package options
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IndexResult contains the results of an indexing operation.
|
// IndexResult contains the results of an indexing operation.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"code.t-juice.club/torjus/labmcp/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// revisionPattern validates revision strings to prevent injection attacks.
|
// revisionPattern validates revision strings to prevent injection attacks.
|
||||||
|
|||||||
141
nix/git-explorer-module.nix
Normal file
141
nix/git-explorer-module.nix
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.git-explorer;
|
||||||
|
|
||||||
|
mkHttpFlags = httpCfg: lib.concatStringsSep " " ([
|
||||||
|
"--transport http"
|
||||||
|
"--http-address '${httpCfg.address}'"
|
||||||
|
"--http-endpoint '${httpCfg.endpoint}'"
|
||||||
|
"--session-ttl '${httpCfg.sessionTTL}'"
|
||||||
|
] ++ lib.optionals (httpCfg.allowedOrigins != []) (
|
||||||
|
map (origin: "--allowed-origins '${origin}'") httpCfg.allowedOrigins
|
||||||
|
) ++ lib.optionals httpCfg.tls.enable [
|
||||||
|
"--tls-cert '${httpCfg.tls.certFile}'"
|
||||||
|
"--tls-key '${httpCfg.tls.keyFile}'"
|
||||||
|
]);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.git-explorer = {
|
||||||
|
enable = lib.mkEnableOption "Git Explorer MCP server";
|
||||||
|
|
||||||
|
package = lib.mkPackageOption pkgs "git-explorer" { };
|
||||||
|
|
||||||
|
repoPath = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Path to the git repository to serve.";
|
||||||
|
};
|
||||||
|
|
||||||
|
defaultRemote = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "origin";
|
||||||
|
description = "Default remote name for ref resolution.";
|
||||||
|
};
|
||||||
|
|
||||||
|
http = {
|
||||||
|
address = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "127.0.0.1:8085";
|
||||||
|
description = "HTTP listen address for the MCP server.";
|
||||||
|
};
|
||||||
|
|
||||||
|
endpoint = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "/mcp";
|
||||||
|
description = "HTTP endpoint path for MCP requests.";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowedOrigins = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
description = "Allowed Origin headers for CORS.";
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionTTL = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "30m";
|
||||||
|
description = "Session TTL for HTTP transport.";
|
||||||
|
};
|
||||||
|
|
||||||
|
tls = {
|
||||||
|
enable = lib.mkEnableOption "TLS for HTTP transport";
|
||||||
|
|
||||||
|
certFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Path to TLS certificate file.";
|
||||||
|
};
|
||||||
|
|
||||||
|
keyFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Path to TLS private key file.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to open the firewall for the MCP HTTP server.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = !cfg.http.tls.enable || (cfg.http.tls.certFile != null && cfg.http.tls.keyFile != null);
|
||||||
|
message = "services.git-explorer.http.tls: both certFile and keyFile must be set when TLS is enabled";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.git-explorer = {
|
||||||
|
description = "Git Explorer MCP Server";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
GIT_REPO_PATH = cfg.repoPath;
|
||||||
|
GIT_DEFAULT_REMOTE = cfg.defaultRemote;
|
||||||
|
};
|
||||||
|
|
||||||
|
script = let
|
||||||
|
httpFlags = mkHttpFlags cfg.http;
|
||||||
|
in ''
|
||||||
|
exec ${cfg.package}/bin/git-explorer serve ${httpFlags}
|
||||||
|
'';
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
DynamicUser = true;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "5s";
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = "read-only";
|
||||||
|
PrivateTmp = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
RestrictNamespaces = true;
|
||||||
|
RestrictRealtime = true;
|
||||||
|
RestrictSUIDSGID = true;
|
||||||
|
MemoryDenyWriteExecute = true;
|
||||||
|
LockPersonality = true;
|
||||||
|
|
||||||
|
# Read-only access to repo path
|
||||||
|
ReadOnlyPaths = [ cfg.repoPath ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.firewall = lib.mkIf cfg.openFirewall (let
|
||||||
|
addressParts = lib.splitString ":" cfg.http.address;
|
||||||
|
port = lib.toInt (lib.last addressParts);
|
||||||
|
in {
|
||||||
|
allowedTCPPorts = [ port ];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,6 +33,24 @@ in
|
|||||||
description = "Alertmanager base URL.";
|
description = "Alertmanager base URL.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
lokiUrl = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Loki base URL. When set, enables log query tools (query_logs, list_labels, list_label_values).";
|
||||||
|
};
|
||||||
|
|
||||||
|
lokiUsername = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Username for Loki basic authentication.";
|
||||||
|
};
|
||||||
|
|
||||||
|
lokiPasswordFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Path to a file containing the password for Loki basic authentication. Recommended over storing secrets in the Nix store.";
|
||||||
|
};
|
||||||
|
|
||||||
enableSilences = lib.mkOption {
|
enableSilences = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
@@ -104,12 +122,19 @@ in
|
|||||||
environment = {
|
environment = {
|
||||||
PROMETHEUS_URL = cfg.prometheusUrl;
|
PROMETHEUS_URL = cfg.prometheusUrl;
|
||||||
ALERTMANAGER_URL = cfg.alertmanagerUrl;
|
ALERTMANAGER_URL = cfg.alertmanagerUrl;
|
||||||
|
} // lib.optionalAttrs (cfg.lokiUrl != null) {
|
||||||
|
LOKI_URL = cfg.lokiUrl;
|
||||||
|
} // lib.optionalAttrs (cfg.lokiUsername != null) {
|
||||||
|
LOKI_USERNAME = cfg.lokiUsername;
|
||||||
};
|
};
|
||||||
|
|
||||||
script = let
|
script = let
|
||||||
httpFlags = mkHttpFlags cfg.http;
|
httpFlags = mkHttpFlags cfg.http;
|
||||||
silenceFlag = lib.optionalString cfg.enableSilences "--enable-silences";
|
silenceFlag = lib.optionalString cfg.enableSilences "--enable-silences";
|
||||||
in ''
|
in ''
|
||||||
|
${lib.optionalString (cfg.lokiPasswordFile != null) ''
|
||||||
|
export LOKI_PASSWORD="$(< "$CREDENTIALS_DIRECTORY/loki-password")"
|
||||||
|
''}
|
||||||
exec ${cfg.package}/bin/lab-monitoring serve ${httpFlags} ${silenceFlag}
|
exec ${cfg.package}/bin/lab-monitoring serve ${httpFlags} ${silenceFlag}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
@@ -118,7 +143,9 @@ in
|
|||||||
DynamicUser = true;
|
DynamicUser = true;
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
|
} // lib.optionalAttrs (cfg.lokiPasswordFile != null) {
|
||||||
|
LoadCredential = [ "loki-password:${cfg.lokiPasswordFile}" ];
|
||||||
|
} // {
|
||||||
# Hardening
|
# Hardening
|
||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
|
|
||||||
buildGoModule {
|
buildGoModule {
|
||||||
inherit pname src;
|
inherit pname src;
|
||||||
version = "0.2.1";
|
version = "0.4.0";
|
||||||
|
|
||||||
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY=";
|
vendorHash = "sha256-XrTtiaQT5br+0ZXz8//rc04GZn/HlQk7l8Nx/+Uil/I=";
|
||||||
|
|
||||||
subPackages = [ subPackage ];
|
subPackages = [ subPackage ];
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ buildGoModule {
|
|||||||
|
|
||||||
meta = with lib; {
|
meta = with lib; {
|
||||||
inherit description mainProgram;
|
inherit description mainProgram;
|
||||||
homepage = "https://git.t-juice.club/torjus/labmcp";
|
homepage = "https://code.t-juice.club/torjus/labmcp";
|
||||||
license = licenses.mit;
|
license = licenses.mit;
|
||||||
maintainers = [ ];
|
maintainers = [ ];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user