From 3fba728e7b8ae562df25e828df1a53f9231f5ffc Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:49:15 +0000 Subject: [PATCH 01/11] Initial plan From 615c591807fe2a3c101db7e6a449193a6ecd109b Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:55:20 +0000 Subject: [PATCH 02/11] Add gh devlake query command with pipelines subcommand - Created `query` parent command with three subcommands - Implemented `query pipelines` with full JSON/table output - Added `ListPipelines` method to devlake client - Created placeholder `query dora` and `query copilot` commands - Updated README command reference table - Added docs/query.md documentation Note: DORA and Copilot subcommands are placeholders that explain the architectural constraint (DevLake does not expose metrics APIs). The pipelines subcommand is fully functional using the existing /pipelines REST API endpoint. Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- README.md | 3 + cmd/query.go | 25 +++++++ cmd/query_copilot.go | 68 +++++++++++++++++ cmd/query_dora.go | 62 +++++++++++++++ cmd/query_pipelines.go | 138 ++++++++++++++++++++++++++++++++++ docs/query.md | 149 +++++++++++++++++++++++++++++++++++++ internal/devlake/client.go | 32 ++++++++ internal/devlake/types.go | 7 ++ 8 files changed, 484 insertions(+) create mode 100644 cmd/query.go create mode 100644 cmd/query_copilot.go create mode 100644 cmd/query_dora.go create mode 100644 cmd/query_pipelines.go create mode 100644 docs/query.md diff --git a/README.md b/README.md index db0dce2..2b5d079 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,9 @@ See [Token Handling](docs/token-handling.md) for env key names and multi-plugin | `gh devlake configure project list` | List all projects | [configure-project.md](docs/configure-project.md) | | `gh devlake configure project delete` | Delete a project | [configure-project.md](docs/configure-project.md) | | `gh devlake configure full` | Connections + scopes + project in one step | [configure-full.md](docs/configure-full.md) | +| `gh devlake query pipelines` | Query recent pipeline runs | [query.md](docs/query.md) | +| `gh devlake query dora` | Query DORA metrics (placeholder — requires API) | [query.md](docs/query.md) | +| `gh devlake query copilot` | Query Copilot metrics (placeholder — requires API) | [query.md](docs/query.md) | | `gh devlake start` | Start stopped or exited DevLake services | [start.md](docs/start.md) | | `gh devlake stop` | Stop running services (preserves containers and data) | [stop.md](docs/stop.md) | | `gh devlake cleanup` | Tear down local or Azure resources | [cleanup.md](docs/cleanup.md) | diff --git a/cmd/query.go b/cmd/query.go new file mode 100644 index 0000000..da0d34b --- /dev/null +++ b/cmd/query.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var queryCmd = &cobra.Command{ + Use: "query", + Short: "Query DevLake data and metrics", + Long: `Query DevLake's aggregated data and metrics. + +Retrieve DORA metrics, Copilot usage data, pipeline status, and other +metrics in a structured format (JSON by default, --format table for +human-readable output). + +Examples: + gh devlake query pipelines --project my-team + gh devlake query pipelines --limit 20 + gh devlake query pipelines --status TASK_COMPLETED`, +} + +func init() { + queryCmd.GroupID = "operate" + rootCmd.AddCommand(queryCmd) +} diff --git a/cmd/query_copilot.go b/cmd/query_copilot.go new file mode 100644 index 0000000..399f9bf --- /dev/null +++ b/cmd/query_copilot.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + queryCopilotProject string + queryCopilotTimeframe string +) + +var queryCopilotCmd = &cobra.Command{ + Use: "copilot", + Short: "Query Copilot usage metrics (requires DevLake metrics API)", + Long: `Query GitHub Copilot usage metrics for a project. + +NOTE: This command requires DevLake to expose a metrics API endpoint. +Currently, Copilot metrics are stored in the gh-copilot plugin tables +and visualized in Grafana dashboards, but not available via the REST API. + +To view Copilot metrics today, use the Grafana dashboards at your DevLake +Grafana endpoint (shown in 'gh devlake status'). + +Planned output format: +{ + "project": "my-team", + "timeframe": "30d", + "metrics": { + "totalSeats": 45, + "activeUsers": 38, + "acceptanceRate": 0.34, + "topLanguages": [ + { "language": "TypeScript", "acceptances": 1200, "suggestions": 3500 } + ], + "topEditors": [ + { "editor": "vscode", "users": 30 } + ] + } +}`, + RunE: runQueryCopilot, +} + +func init() { + queryCopilotCmd.Flags().StringVar(&queryCopilotProject, "project", "", "Project name (required)") + queryCopilotCmd.Flags().StringVar(&queryCopilotTimeframe, "timeframe", "30d", "Time window for metrics (e.g., 7d, 30d, 90d)") + queryCopilotCmd.MarkFlagRequired("project") + queryCmd.AddCommand(queryCopilotCmd) +} + +func runQueryCopilot(cmd *cobra.Command, args []string) error { + return fmt.Errorf(`Copilot metrics query is not yet implemented. + +DevLake does not currently expose a metrics API endpoint. Copilot metrics are +stored in the gh-copilot plugin's database tables and visualized in Grafana +dashboards, but not accessible via the REST API. + +To view Copilot metrics, visit your Grafana endpoint (shown in 'gh devlake status') +and navigate to the Copilot dashboards. + +Future implementation will require: + 1. Upstream DevLake metrics API endpoint for Copilot plugin + 2. OR direct database query support (requires DB credentials) + 3. OR Grafana API integration to fetch dashboard data + +Track progress at: https://github.com/DevExpGBB/gh-devlake/issues`) +} diff --git a/cmd/query_dora.go b/cmd/query_dora.go new file mode 100644 index 0000000..52b24e9 --- /dev/null +++ b/cmd/query_dora.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + queryDoraProject string + queryDoraTimeframe string +) + +var queryDoraCmd = &cobra.Command{ + Use: "dora", + Short: "Query DORA metrics (requires DevLake metrics API)", + Long: `Query DORA (DevOps Research and Assessment) metrics for a project. + +NOTE: This command requires DevLake to expose a metrics API endpoint. +Currently, DORA metrics are calculated in Grafana dashboards but not +available via the REST API. This is a placeholder for future enhancement. + +To view DORA metrics today, use the Grafana dashboards at your DevLake +Grafana endpoint (shown in 'gh devlake status'). + +Planned output format: +{ + "project": "my-team", + "timeframe": "30d", + "metrics": { + "deploymentFrequency": { "value": 4.2, "unit": "per_week", "rating": "high" }, + "leadTimeForChanges": { "value": 2.3, "unit": "hours", "rating": "elite" }, + "changeFailureRate": { "value": 0.08, "unit": "ratio", "rating": "high" }, + "meanTimeToRestore": { "value": 1.5, "unit": "hours", "rating": "elite" } + } +}`, + RunE: runQueryDora, +} + +func init() { + queryDoraCmd.Flags().StringVar(&queryDoraProject, "project", "", "Project name (required)") + queryDoraCmd.Flags().StringVar(&queryDoraTimeframe, "timeframe", "30d", "Time window for metrics (e.g., 7d, 30d, 90d)") + queryDoraCmd.MarkFlagRequired("project") + queryCmd.AddCommand(queryDoraCmd) +} + +func runQueryDora(cmd *cobra.Command, args []string) error { + return fmt.Errorf(`DORA metrics query is not yet implemented. + +DevLake does not currently expose a metrics API endpoint. DORA metrics are +calculated in Grafana dashboards using SQL queries against the domain layer. + +To view DORA metrics, visit your Grafana endpoint (shown in 'gh devlake status') +and navigate to the DORA dashboards. + +Future implementation will require: + 1. Upstream DevLake metrics API endpoint + 2. OR direct database query support (requires DB credentials) + 3. OR Grafana API integration to fetch dashboard data + +Track progress at: https://github.com/DevExpGBB/gh-devlake/issues`) +} diff --git a/cmd/query_pipelines.go b/cmd/query_pipelines.go new file mode 100644 index 0000000..5bab93c --- /dev/null +++ b/cmd/query_pipelines.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" + "github.com/spf13/cobra" +) + +var ( + queryPipelinesProject string + queryPipelinesStatus string + queryPipelinesLimit int + queryPipelinesFormat string +) + +var queryPipelinesCmd = &cobra.Command{ + Use: "pipelines", + Short: "Query recent pipeline runs", + Long: `Query recent pipeline runs for a project or across all projects. + +Retrieves pipeline execution history with status, timing, and task completion +information. Output is JSON by default; use --format table for human-readable display. + +Examples: + gh devlake query pipelines + gh devlake query pipelines --project my-team + gh devlake query pipelines --status TASK_COMPLETED --limit 10 + gh devlake query pipelines --format table`, + RunE: runQueryPipelines, +} + +func init() { + queryPipelinesCmd.Flags().StringVar(&queryPipelinesProject, "project", "", "Filter by project name") + queryPipelinesCmd.Flags().StringVar(&queryPipelinesStatus, "status", "", "Filter by status (TASK_CREATED, TASK_RUNNING, TASK_COMPLETED, TASK_FAILED)") + queryPipelinesCmd.Flags().IntVar(&queryPipelinesLimit, "limit", 20, "Maximum number of pipelines to return") + queryPipelinesCmd.Flags().StringVar(&queryPipelinesFormat, "format", "json", "Output format (json or table)") + queryCmd.AddCommand(queryPipelinesCmd) +} + +type pipelineQueryResult struct { + ID int `json:"id"` + Status string `json:"status"` + BlueprintID int `json:"blueprintId,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + BeganAt string `json:"beganAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` + FinishedTasks int `json:"finishedTasks"` + TotalTasks int `json:"totalTasks"` + Message string `json:"message,omitempty"` +} + +func runQueryPipelines(cmd *cobra.Command, args []string) error { + // Discover DevLake instance + var disc *devlake.DiscoveryResult + var client *devlake.Client + var err error + + if !outputJSON && queryPipelinesFormat != "table" { + client, disc, err = discoverClient(cfgURL) + if err != nil { + return fmt.Errorf("discovering DevLake: %w", err) + } + } else { + // Quiet discovery for JSON/table output + disc, err = devlake.Discover(cfgURL) + if err != nil { + return fmt.Errorf("discovering DevLake: %w", err) + } + client = devlake.NewClient(disc.URL) + } + + // If --project is specified, resolve it to a blueprint ID + var blueprintID int + if queryPipelinesProject != "" { + proj, err := client.GetProject(queryPipelinesProject) + if err != nil { + return fmt.Errorf("getting project %q: %w", queryPipelinesProject, err) + } + if proj.Blueprint != nil { + blueprintID = proj.Blueprint.ID + } else { + return fmt.Errorf("project %q has no blueprint", queryPipelinesProject) + } + } + + // Query pipelines + resp, err := client.ListPipelines(queryPipelinesStatus, blueprintID, 1, queryPipelinesLimit) + if err != nil { + return fmt.Errorf("listing pipelines: %w", err) + } + + // Transform to output format + results := make([]pipelineQueryResult, len(resp.Pipelines)) + for i, p := range resp.Pipelines { + results[i] = pipelineQueryResult{ + ID: p.ID, + Status: p.Status, + BlueprintID: p.BlueprintID, + CreatedAt: p.CreatedAt, + BeganAt: p.BeganAt, + FinishedAt: p.FinishedAt, + FinishedTasks: p.FinishedTasks, + TotalTasks: p.TotalTasks, + Message: p.Message, + } + } + + // Output + if outputJSON || queryPipelinesFormat == "json" { + return printJSON(results) + } + + // Table format + printBanner("DevLake — Pipeline Query") + if len(results) == 0 { + fmt.Println("\n No pipelines found.") + return nil + } + + fmt.Printf("\n Found %d pipeline(s)\n", len(results)) + fmt.Println(" " + strings.Repeat("─", 80)) + fmt.Printf(" %-6s %-15s %-10s %-20s\n", "ID", "STATUS", "TASKS", "FINISHED AT") + fmt.Println(" " + strings.Repeat("─", 80)) + for _, r := range results { + status := r.Status + tasks := fmt.Sprintf("%d/%d", r.FinishedTasks, r.TotalTasks) + finished := r.FinishedAt + if finished == "" { + finished = "(running)" + } + fmt.Printf(" %-6d %-15s %-10s %-20s\n", r.ID, status, tasks, finished) + } + fmt.Println() + + return nil +} diff --git a/docs/query.md b/docs/query.md new file mode 100644 index 0000000..e10a637 --- /dev/null +++ b/docs/query.md @@ -0,0 +1,149 @@ +# gh devlake query + +Query DevLake's aggregated data and metrics. + +## Usage + +```bash +gh devlake query [flags] +``` + +## Subcommands + +### pipelines + +Query recent pipeline runs. + +```bash +gh devlake query pipelines [flags] +``` + +**Flags:** +- `--project ` - Filter by project name +- `--status ` - Filter by status (`TASK_CREATED`, `TASK_RUNNING`, `TASK_COMPLETED`, `TASK_FAILED`) +- `--limit ` - Maximum number of pipelines to return (default: 20) +- `--format ` - Output format: `json` or `table` (default: `json`) + +**Examples:** + +```bash +# List recent pipelines as JSON +gh devlake query pipelines + +# List pipelines for a specific project +gh devlake query pipelines --project my-team + +# List only completed pipelines +gh devlake query pipelines --status TASK_COMPLETED --limit 10 + +# Display as table +gh devlake query pipelines --format table +``` + +**Output (JSON):** + +```json +[ + { + "id": 123, + "status": "TASK_COMPLETED", + "blueprintId": 1, + "createdAt": "2026-03-12T10:00:00Z", + "beganAt": "2026-03-12T10:00:05Z", + "finishedAt": "2026-03-12T10:15:30Z", + "finishedTasks": 12, + "totalTasks": 12 + } +] +``` + +**Output (Table):** + +``` +════════════════════════════════════════ + DevLake — Pipeline Query +════════════════════════════════════════ + + Found 3 pipeline(s) + ──────────────────────────────────────────────────────────────────────────────── + ID STATUS TASKS FINISHED AT + ──────────────────────────────────────────────────────────────────────────────── + 123 TASK_COMPLETED 12/12 2026-03-12T10:15:30Z + 122 TASK_COMPLETED 12/12 2026-03-12T09:15:30Z + 121 TASK_RUNNING 8/12 (running) +``` + +--- + +### dora + +Query DORA (DevOps Research and Assessment) metrics. + +```bash +gh devlake query dora --project [flags] +``` + +**Status:** 🚧 Not yet implemented + +**Reason:** DevLake does not currently expose a metrics API endpoint. DORA metrics are calculated in Grafana dashboards using SQL queries against the domain layer tables, but these calculations are not available via the REST API. + +**Workaround:** View DORA metrics in your Grafana dashboards: +```bash +gh devlake status # Shows Grafana URL +``` + +Then navigate to the DORA dashboards in Grafana. + +**Future implementation requires:** +1. Upstream DevLake metrics API endpoint +2. OR direct database query support (requires DB credentials in state files) +3. OR Grafana API integration to fetch dashboard data + +--- + +### copilot + +Query GitHub Copilot usage metrics. + +```bash +gh devlake query copilot --project [flags] +``` + +**Status:** 🚧 Not yet implemented + +**Reason:** DevLake does not currently expose a metrics API endpoint. Copilot metrics are stored in the `gh-copilot` plugin's database tables and visualized in Grafana dashboards, but not accessible via the REST API. + +**Workaround:** View Copilot metrics in your Grafana dashboards: +```bash +gh devlake status # Shows Grafana URL +``` + +Then navigate to the Copilot dashboards in Grafana. + +**Future implementation requires:** +1. Upstream DevLake metrics API endpoint for Copilot plugin +2. OR direct database query support (requires DB credentials in state files) +3. OR Grafana API integration to fetch dashboard data + +--- + +## Global Flags + +These flags are inherited from the root command: + +- `--url ` - DevLake API base URL (auto-discovered if omitted) +- `--json` - Output as JSON (suppresses banners and interactive prompts) + +## Architecture Notes + +The `query` command is designed to be extensible: + +- **Current:** The `pipelines` subcommand uses the existing `/pipelines` REST API endpoint +- **Future:** The `dora` and `copilot` subcommands are placeholders awaiting upstream API support + +When DevLake exposes metrics APIs, the existing command structure will remain the same — only the implementation will change from returning an error to fetching actual metrics. + +## See Also + +- `gh devlake status` - Check DevLake deployment and connection status +- `gh devlake configure project list` - List all projects diff --git a/internal/devlake/client.go b/internal/devlake/client.go index 3cb2617..61020ed 100644 --- a/internal/devlake/client.go +++ b/internal/devlake/client.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" ) @@ -512,3 +513,34 @@ func (c *Client) TriggerMigration() error { resp.Body.Close() return nil } + +// PipelineListResponse is the response from GET /pipelines. +type PipelineListResponse struct { + Pipelines []Pipeline `json:"pipelines"` + Count int64 `json:"count"` +} + +// ListPipelines returns pipelines with optional query parameters. +// status can be empty, "TASK_CREATED", "TASK_RUNNING", "TASK_COMPLETED", "TASK_FAILED", etc. +// blueprintID filters by blueprint (0 = no filter). +// page and pageSize control pagination (0 = use defaults). +func (c *Client) ListPipelines(status string, blueprintID, page, pageSize int) (*PipelineListResponse, error) { + path := "/pipelines?" + params := []string{} + if status != "" { + params = append(params, "status="+status) + } + if blueprintID > 0 { + params = append(params, fmt.Sprintf("blueprint_id=%d", blueprintID)) + } + if page > 0 { + params = append(params, fmt.Sprintf("page=%d", page)) + } + if pageSize > 0 { + params = append(params, fmt.Sprintf("pagesize=%d", pageSize)) + } + if len(params) > 0 { + path += strings.Join(params, "&") + } + return doGet[PipelineListResponse](c, path) +} diff --git a/internal/devlake/types.go b/internal/devlake/types.go index 9e60540..feaa397 100644 --- a/internal/devlake/types.go +++ b/internal/devlake/types.go @@ -271,7 +271,14 @@ type BlueprintScope struct { // Pipeline represents a DevLake pipeline (returned from trigger or GET). type Pipeline struct { ID int `json:"id"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + BeganAt string `json:"beganAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` Status string `json:"status"` + Message string `json:"message,omitempty"` FinishedTasks int `json:"finishedTasks"` TotalTasks int `json:"totalTasks"` + BlueprintID int `json:"blueprintId,omitempty"` + SkipOnFail bool `json:"skipOnFail,omitempty"` } From 8cab56c47eef951766c324d6883be350579b9513 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:15:56 +0000 Subject: [PATCH 03/11] Refactor query commands to use internal/query abstraction - Move query logic from cmd handlers to internal/query package - Implement DORA query with available API data + clear limitations - Implement Copilot query with available API data + clear limitations - Keep pipelines query functional through the new abstraction - All validation passes: go build, go test, go vet Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/query_copilot.go | 74 ++++++++++++++++--------------- cmd/query_dora.go | 71 +++++++++++++++++------------- cmd/query_pipelines.go | 79 +++++++++++++-------------------- internal/query/copilot.go | 78 +++++++++++++++++++++++++++++++++ internal/query/dora.go | 74 +++++++++++++++++++++++++++++++ internal/query/engine.go | 51 ++++++++++++++++++++++ internal/query/pipelines.go | 87 +++++++++++++++++++++++++++++++++++++ internal/query/registry.go | 32 ++++++++++++++ internal/query/types.go | 36 +++++++++++++++ 9 files changed, 470 insertions(+), 112 deletions(-) create mode 100644 internal/query/copilot.go create mode 100644 internal/query/dora.go create mode 100644 internal/query/engine.go create mode 100644 internal/query/pipelines.go create mode 100644 internal/query/registry.go create mode 100644 internal/query/types.go diff --git a/cmd/query_copilot.go b/cmd/query_copilot.go index 399f9bf..0bd5fb0 100644 --- a/cmd/query_copilot.go +++ b/cmd/query_copilot.go @@ -3,6 +3,8 @@ package cmd import ( "fmt" + "github.com/DevExpGBB/gh-devlake/internal/devlake" + "github.com/DevExpGBB/gh-devlake/internal/query" "github.com/spf13/cobra" ) @@ -13,56 +15,60 @@ var ( var queryCopilotCmd = &cobra.Command{ Use: "copilot", - Short: "Query Copilot usage metrics (requires DevLake metrics API)", + Short: "Query Copilot usage metrics (limited by available API data)", Long: `Query GitHub Copilot usage metrics for a project. -NOTE: This command requires DevLake to expose a metrics API endpoint. -Currently, Copilot metrics are stored in the gh-copilot plugin tables -and visualized in Grafana dashboards, but not available via the REST API. +NOTE: GitHub Copilot usage metrics (total seats, active users, acceptance rates, +language breakdowns, editor usage) are stored in _tool_gh_copilot_* tables and +visualized in Grafana dashboards, but DevLake does not expose a /metrics or +/copilot API endpoint. -To view Copilot metrics today, use the Grafana dashboards at your DevLake -Grafana endpoint (shown in 'gh devlake status'). +This command returns available connection metadata and explains what additional +API endpoints would be needed to retrieve Copilot metrics via CLI. -Planned output format: -{ - "project": "my-team", - "timeframe": "30d", - "metrics": { - "totalSeats": 45, - "activeUsers": 38, - "acceptanceRate": 0.34, - "topLanguages": [ - { "language": "TypeScript", "acceptances": 1200, "suggestions": 3500 } - ], - "topEditors": [ - { "editor": "vscode", "users": 30 } - ] - } -}`, +Copilot metrics are currently available in Grafana dashboards at your DevLake +Grafana endpoint (shown in 'gh devlake status').`, RunE: runQueryCopilot, } func init() { queryCopilotCmd.Flags().StringVar(&queryCopilotProject, "project", "", "Project name (required)") queryCopilotCmd.Flags().StringVar(&queryCopilotTimeframe, "timeframe", "30d", "Time window for metrics (e.g., 7d, 30d, 90d)") - queryCopilotCmd.MarkFlagRequired("project") queryCmd.AddCommand(queryCopilotCmd) } func runQueryCopilot(cmd *cobra.Command, args []string) error { - return fmt.Errorf(`Copilot metrics query is not yet implemented. + // Validate project flag + if queryCopilotProject == "" { + return fmt.Errorf("--project flag is required") + } -DevLake does not currently expose a metrics API endpoint. Copilot metrics are -stored in the gh-copilot plugin's database tables and visualized in Grafana -dashboards, but not accessible via the REST API. + // Discover DevLake instance + disc, err := devlake.Discover(cfgURL) + if err != nil { + return fmt.Errorf("discovering DevLake: %w", err) + } + client := devlake.NewClient(disc.URL) -To view Copilot metrics, visit your Grafana endpoint (shown in 'gh devlake status') -and navigate to the Copilot dashboards. + // Get the query definition + queryDef, err := query.Get("copilot") + if err != nil { + return fmt.Errorf("getting copilot query: %w", err) + } -Future implementation will require: - 1. Upstream DevLake metrics API endpoint for Copilot plugin - 2. OR direct database query support (requires DB credentials) - 3. OR Grafana API integration to fetch dashboard data + // Build parameters + params := map[string]interface{}{ + "project": queryCopilotProject, + "timeframe": queryCopilotTimeframe, + } -Track progress at: https://github.com/DevExpGBB/gh-devlake/issues`) + // Execute the query + engine := query.NewEngine(client) + result, err := engine.Execute(queryDef, params) + if err != nil { + return fmt.Errorf("executing copilot query: %w", err) + } + + // Output result as JSON + return printJSON(result) } diff --git a/cmd/query_dora.go b/cmd/query_dora.go index 52b24e9..0e1cc1c 100644 --- a/cmd/query_dora.go +++ b/cmd/query_dora.go @@ -3,6 +3,8 @@ package cmd import ( "fmt" + "github.com/DevExpGBB/gh-devlake/internal/devlake" + "github.com/DevExpGBB/gh-devlake/internal/query" "github.com/spf13/cobra" ) @@ -13,50 +15,59 @@ var ( var queryDoraCmd = &cobra.Command{ Use: "dora", - Short: "Query DORA metrics (requires DevLake metrics API)", + Short: "Query DORA metrics (limited by available API data)", Long: `Query DORA (DevOps Research and Assessment) metrics for a project. -NOTE: This command requires DevLake to expose a metrics API endpoint. -Currently, DORA metrics are calculated in Grafana dashboards but not -available via the REST API. This is a placeholder for future enhancement. - -To view DORA metrics today, use the Grafana dashboards at your DevLake -Grafana endpoint (shown in 'gh devlake status'). - -Planned output format: -{ - "project": "my-team", - "timeframe": "30d", - "metrics": { - "deploymentFrequency": { "value": 4.2, "unit": "per_week", "rating": "high" }, - "leadTimeForChanges": { "value": 2.3, "unit": "hours", "rating": "elite" }, - "changeFailureRate": { "value": 0.08, "unit": "ratio", "rating": "high" }, - "meanTimeToRestore": { "value": 1.5, "unit": "hours", "rating": "elite" } - } -}`, +NOTE: Full DORA metric calculations (deployment frequency, lead time, change +failure rate, MTTR) require SQL queries against DevLake's domain layer tables. +DevLake does not expose database credentials or a metrics API endpoint. + +This command returns project metadata and explains what additional API +endpoints would be needed to compute DORA metrics via CLI. + +DORA metrics are currently available in Grafana dashboards at your DevLake +Grafana endpoint (shown in 'gh devlake status').`, RunE: runQueryDora, } func init() { queryDoraCmd.Flags().StringVar(&queryDoraProject, "project", "", "Project name (required)") queryDoraCmd.Flags().StringVar(&queryDoraTimeframe, "timeframe", "30d", "Time window for metrics (e.g., 7d, 30d, 90d)") - queryDoraCmd.MarkFlagRequired("project") queryCmd.AddCommand(queryDoraCmd) } func runQueryDora(cmd *cobra.Command, args []string) error { - return fmt.Errorf(`DORA metrics query is not yet implemented. + // Validate project flag + if queryDoraProject == "" { + return fmt.Errorf("--project flag is required") + } + + // Discover DevLake instance + disc, err := devlake.Discover(cfgURL) + if err != nil { + return fmt.Errorf("discovering DevLake: %w", err) + } + client := devlake.NewClient(disc.URL) -DevLake does not currently expose a metrics API endpoint. DORA metrics are -calculated in Grafana dashboards using SQL queries against the domain layer. + // Get the query definition + queryDef, err := query.Get("dora") + if err != nil { + return fmt.Errorf("getting dora query: %w", err) + } -To view DORA metrics, visit your Grafana endpoint (shown in 'gh devlake status') -and navigate to the DORA dashboards. + // Build parameters + params := map[string]interface{}{ + "project": queryDoraProject, + "timeframe": queryDoraTimeframe, + } -Future implementation will require: - 1. Upstream DevLake metrics API endpoint - 2. OR direct database query support (requires DB credentials) - 3. OR Grafana API integration to fetch dashboard data + // Execute the query + engine := query.NewEngine(client) + result, err := engine.Execute(queryDef, params) + if err != nil { + return fmt.Errorf("executing dora query: %w", err) + } -Track progress at: https://github.com/DevExpGBB/gh-devlake/issues`) + // Output result as JSON + return printJSON(result) } diff --git a/cmd/query_pipelines.go b/cmd/query_pipelines.go index 5bab93c..558f26c 100644 --- a/cmd/query_pipelines.go +++ b/cmd/query_pipelines.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/DevExpGBB/gh-devlake/internal/devlake" + "github.com/DevExpGBB/gh-devlake/internal/query" "github.com/spf13/cobra" ) @@ -39,18 +40,6 @@ func init() { queryCmd.AddCommand(queryPipelinesCmd) } -type pipelineQueryResult struct { - ID int `json:"id"` - Status string `json:"status"` - BlueprintID int `json:"blueprintId,omitempty"` - CreatedAt string `json:"createdAt,omitempty"` - BeganAt string `json:"beganAt,omitempty"` - FinishedAt string `json:"finishedAt,omitempty"` - FinishedTasks int `json:"finishedTasks"` - TotalTasks int `json:"totalTasks"` - Message string `json:"message,omitempty"` -} - func runQueryPipelines(cmd *cobra.Command, args []string) error { // Discover DevLake instance var disc *devlake.DiscoveryResult @@ -71,66 +60,60 @@ func runQueryPipelines(cmd *cobra.Command, args []string) error { client = devlake.NewClient(disc.URL) } - // If --project is specified, resolve it to a blueprint ID - var blueprintID int + // Get the query definition + queryDef, err := query.Get("pipelines") + if err != nil { + return fmt.Errorf("getting pipelines query: %w", err) + } + + // Build parameters + params := map[string]interface{}{ + "limit": queryPipelinesLimit, + } if queryPipelinesProject != "" { - proj, err := client.GetProject(queryPipelinesProject) - if err != nil { - return fmt.Errorf("getting project %q: %w", queryPipelinesProject, err) - } - if proj.Blueprint != nil { - blueprintID = proj.Blueprint.ID - } else { - return fmt.Errorf("project %q has no blueprint", queryPipelinesProject) - } + params["project"] = queryPipelinesProject + } + if queryPipelinesStatus != "" { + params["status"] = queryPipelinesStatus } - // Query pipelines - resp, err := client.ListPipelines(queryPipelinesStatus, blueprintID, 1, queryPipelinesLimit) + // Execute the query + engine := query.NewEngine(client) + result, err := engine.Execute(queryDef, params) if err != nil { - return fmt.Errorf("listing pipelines: %w", err) + return fmt.Errorf("executing pipelines query: %w", err) } - // Transform to output format - results := make([]pipelineQueryResult, len(resp.Pipelines)) - for i, p := range resp.Pipelines { - results[i] = pipelineQueryResult{ - ID: p.ID, - Status: p.Status, - BlueprintID: p.BlueprintID, - CreatedAt: p.CreatedAt, - BeganAt: p.BeganAt, - FinishedAt: p.FinishedAt, - FinishedTasks: p.FinishedTasks, - TotalTasks: p.TotalTasks, - Message: p.Message, - } + // Cast result to slice of PipelineResult + pipelines, ok := result.([]query.PipelineResult) + if !ok { + return fmt.Errorf("unexpected result type: %T", result) } // Output if outputJSON || queryPipelinesFormat == "json" { - return printJSON(results) + return printJSON(pipelines) } // Table format printBanner("DevLake — Pipeline Query") - if len(results) == 0 { + if len(pipelines) == 0 { fmt.Println("\n No pipelines found.") return nil } - fmt.Printf("\n Found %d pipeline(s)\n", len(results)) + fmt.Printf("\n Found %d pipeline(s)\n", len(pipelines)) fmt.Println(" " + strings.Repeat("─", 80)) fmt.Printf(" %-6s %-15s %-10s %-20s\n", "ID", "STATUS", "TASKS", "FINISHED AT") fmt.Println(" " + strings.Repeat("─", 80)) - for _, r := range results { - status := r.Status - tasks := fmt.Sprintf("%d/%d", r.FinishedTasks, r.TotalTasks) - finished := r.FinishedAt + for _, p := range pipelines { + status := p.Status + tasks := fmt.Sprintf("%d/%d", p.FinishedTasks, p.TotalTasks) + finished := p.FinishedAt if finished == "" { finished = "(running)" } - fmt.Printf(" %-6d %-15s %-10s %-20s\n", r.ID, status, tasks, finished) + fmt.Printf(" %-6d %-15s %-10s %-20s\n", p.ID, status, tasks, finished) } fmt.Println() diff --git a/internal/query/copilot.go b/internal/query/copilot.go new file mode 100644 index 0000000..6eb1c5b --- /dev/null +++ b/internal/query/copilot.go @@ -0,0 +1,78 @@ +package query + +import ( + "fmt" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" +) + +func init() { + Register(copilotQueryDef) +} + +var copilotQueryDef = &QueryDef{ + Name: "copilot", + Description: "Query GitHub Copilot metrics (limited by available API data)", + Params: []QueryParam{ + {Name: "project", Type: "string", Required: true}, + {Name: "timeframe", Type: "string", Required: false, Default: "30d"}, + }, + Execute: executeCopilotQuery, +} + +// CopilotResult represents Copilot metrics that can be retrieved from available APIs. +// NOTE: Copilot usage metrics (acceptance rates, language breakdowns) are stored in +// _tool_gh_copilot_* tables but not exposed via REST API. +type CopilotResult struct { + Project string `json:"project"` + Timeframe string `json:"timeframe"` + AvailableData map[string]interface{} `json:"availableData"` + Limitations string `json:"limitations"` +} + +func executeCopilotQuery(client *devlake.Client, params map[string]interface{}) (interface{}, error) { + projectName, ok := params["project"].(string) + if !ok || projectName == "" { + return nil, fmt.Errorf("project parameter is required") + } + + timeframe := "30d" + if tf, ok := params["timeframe"].(string); ok && tf != "" { + timeframe = tf + } + + // Get project info + proj, err := client.GetProject(projectName) + if err != nil { + return nil, fmt.Errorf("getting project %q: %w", projectName, err) + } + + // Check if gh-copilot plugin is configured + connections, err := client.ListConnections("gh-copilot") + if err != nil { + // Plugin might not be available + connections = []devlake.Connection{} + } + + availableData := map[string]interface{}{ + "projectName": proj.Name, + "copilotConnectionsFound": len(connections), + } + + if len(connections) > 0 { + availableData["connections"] = connections + } + + result := CopilotResult{ + Project: projectName, + Timeframe: timeframe, + AvailableData: availableData, + Limitations: "GitHub Copilot usage metrics (total seats, active users, acceptance rates, language " + + "breakdowns, editor usage) are stored in _tool_gh_copilot_* tables and visualized in Grafana " + + "dashboards, but DevLake does not expose a /metrics or /copilot API endpoint. To retrieve " + + "Copilot metrics via CLI, DevLake would need to add a metrics API that returns aggregated " + + "Copilot usage data.", + } + + return result, nil +} diff --git a/internal/query/dora.go b/internal/query/dora.go new file mode 100644 index 0000000..c333409 --- /dev/null +++ b/internal/query/dora.go @@ -0,0 +1,74 @@ +package query + +import ( + "fmt" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" +) + +func init() { + Register(doraQueryDef) +} + +var doraQueryDef = &QueryDef{ + Name: "dora", + Description: "Query DORA metrics (limited by available API data)", + Params: []QueryParam{ + {Name: "project", Type: "string", Required: true}, + {Name: "timeframe", Type: "string", Required: false, Default: "30d"}, + }, + Execute: executeDoraQuery, +} + +// DoraResult represents DORA metrics that can be retrieved from available APIs. +// NOTE: Full DORA calculations require direct database access (which DevLake doesn't +// expose to external clients). This returns what's available via REST API. +type DoraResult struct { + Project string `json:"project"` + Timeframe string `json:"timeframe"` + AvailableData map[string]interface{} `json:"availableData"` + Limitations string `json:"limitations"` +} + +func executeDoraQuery(client *devlake.Client, params map[string]interface{}) (interface{}, error) { + projectName, ok := params["project"].(string) + if !ok || projectName == "" { + return nil, fmt.Errorf("project parameter is required") + } + + timeframe := "30d" + if tf, ok := params["timeframe"].(string); ok && tf != "" { + timeframe = tf + } + + // Get project info (this is what's available via API) + proj, err := client.GetProject(projectName) + if err != nil { + return nil, fmt.Errorf("getting project %q: %w", projectName, err) + } + + availableData := map[string]interface{}{ + "projectName": proj.Name, + "projectDescription": proj.Description, + "enabledMetrics": proj.Metrics, + } + + if proj.Blueprint != nil { + availableData["blueprintId"] = proj.Blueprint.ID + availableData["blueprintName"] = proj.Blueprint.Name + availableData["syncSchedule"] = proj.Blueprint.CronConfig + } + + result := DoraResult{ + Project: projectName, + Timeframe: timeframe, + AvailableData: availableData, + Limitations: "DORA metric calculations (deployment frequency, lead time, change failure rate, MTTR) " + + "require SQL queries against DevLake's domain layer tables. DevLake does not expose database " + + "credentials or a metrics API endpoint. These calculations are currently only available in " + + "Grafana dashboards. To compute DORA metrics via CLI, DevLake would need to add a /metrics " + + "or /dora API endpoint that returns pre-calculated values.", + } + + return result, nil +} diff --git a/internal/query/engine.go b/internal/query/engine.go new file mode 100644 index 0000000..3f69ef4 --- /dev/null +++ b/internal/query/engine.go @@ -0,0 +1,51 @@ +package query + +import ( + "fmt" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" +) + +// Engine executes queries against DevLake's REST API. +type Engine struct { + client *devlake.Client +} + +// NewEngine creates a new query engine with the given DevLake client. +func NewEngine(client *devlake.Client) *Engine { + return &Engine{ + client: client, + } +} + +// Execute runs a query with the given parameters. +func (e *Engine) Execute(queryDef *QueryDef, params map[string]interface{}) (interface{}, error) { + if queryDef == nil { + return nil, fmt.Errorf("query definition is nil") + } + if queryDef.Execute == nil { + return nil, fmt.Errorf("query %q has no execute function", queryDef.Name) + } + + // Validate required parameters + for _, param := range queryDef.Params { + if param.Required { + if _, ok := params[param.Name]; !ok { + // Check if there's a default value + if param.Default != "" { + params[param.Name] = param.Default + } else { + return nil, fmt.Errorf("required parameter %q not provided", param.Name) + } + } + } + } + + // Execute the query + return queryDef.Execute(e.client, params) +} + +// GetClient returns the underlying DevLake client. +func (e *Engine) GetClient() *devlake.Client { + return e.client +} diff --git a/internal/query/pipelines.go b/internal/query/pipelines.go new file mode 100644 index 0000000..b6663a1 --- /dev/null +++ b/internal/query/pipelines.go @@ -0,0 +1,87 @@ +package query + +import ( + "fmt" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" +) + +func init() { + Register(pipelinesQueryDef) +} + +var pipelinesQueryDef = &QueryDef{ + Name: "pipelines", + Description: "Query recent pipeline runs", + Params: []QueryParam{ + {Name: "project", Type: "string", Required: false}, + {Name: "status", Type: "string", Required: false}, + {Name: "limit", Type: "int", Required: false, Default: "20"}, + }, + Execute: executePipelinesQuery, +} + +// PipelineResult represents a single pipeline query result. +type PipelineResult struct { + ID int `json:"id"` + Status string `json:"status"` + BlueprintID int `json:"blueprintId,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + BeganAt string `json:"beganAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` + FinishedTasks int `json:"finishedTasks"` + TotalTasks int `json:"totalTasks"` + Message string `json:"message,omitempty"` +} + +func executePipelinesQuery(client *devlake.Client, params map[string]interface{}) (interface{}, error) { + // Extract parameters + var blueprintID int + if projectName, ok := params["project"].(string); ok && projectName != "" { + proj, err := client.GetProject(projectName) + if err != nil { + return nil, fmt.Errorf("getting project %q: %w", projectName, err) + } + if proj.Blueprint != nil { + blueprintID = proj.Blueprint.ID + } else { + return nil, fmt.Errorf("project %q has no blueprint", projectName) + } + } + + status := "" + if s, ok := params["status"].(string); ok { + status = s + } + + limit := 20 + if l, ok := params["limit"].(int); ok { + limit = l + } else if l, ok := params["limit"].(string); ok { + fmt.Sscanf(l, "%d", &limit) + } + + // Query pipelines via API + resp, err := client.ListPipelines(status, blueprintID, 1, limit) + if err != nil { + return nil, fmt.Errorf("listing pipelines: %w", err) + } + + // Transform to output format + results := make([]PipelineResult, len(resp.Pipelines)) + for i, p := range resp.Pipelines { + results[i] = PipelineResult{ + ID: p.ID, + Status: p.Status, + BlueprintID: p.BlueprintID, + CreatedAt: p.CreatedAt, + BeganAt: p.BeganAt, + FinishedAt: p.FinishedAt, + FinishedTasks: p.FinishedTasks, + TotalTasks: p.TotalTasks, + Message: p.Message, + } + } + + return results, nil +} diff --git a/internal/query/registry.go b/internal/query/registry.go new file mode 100644 index 0000000..c899bd6 --- /dev/null +++ b/internal/query/registry.go @@ -0,0 +1,32 @@ +package query + +import "fmt" + +// registry holds all registered queries. +var registry = make(map[string]*QueryDef) + +// Register adds a query definition to the registry. +func Register(def *QueryDef) { + if def == nil || def.Name == "" { + panic("cannot register nil or unnamed query") + } + registry[def.Name] = def +} + +// Get retrieves a query definition by name. +func Get(name string) (*QueryDef, error) { + def, ok := registry[name] + if !ok { + return nil, fmt.Errorf("query %q not found", name) + } + return def, nil +} + +// List returns all registered query names. +func List() []string { + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + return names +} diff --git a/internal/query/types.go b/internal/query/types.go new file mode 100644 index 0000000..6756b2b --- /dev/null +++ b/internal/query/types.go @@ -0,0 +1,36 @@ +// Package query provides an extensible abstraction for querying DevLake data. +// Instead of direct SQL queries (DevLake doesn't expose DB credentials), this +// package defines queries as API endpoint patterns with client-side transformations. +package query + +import ( + "github.com/DevExpGBB/gh-devlake/internal/devlake" +) + +// QueryDef describes a reusable, parameterized query against DevLake's API. +// Unlike the original SQL-based design, this uses HTTP API endpoints since +// DevLake doesn't expose database credentials to external clients. +type QueryDef struct { + Name string // e.g. "pipelines", "dora_metrics" + Description string // human-readable description + Params []QueryParam // declared parameters with types and defaults + Execute QueryExecuteFunc // function that executes the query +} + +// QueryParam describes a parameter for a query. +type QueryParam struct { + Name string // parameter name + Type string // "string", "int", "duration" + Required bool // whether the parameter is required + Default string // default value if not provided +} + +// QueryExecuteFunc is the signature for query execution functions. +// It takes a client, parameters, and returns results or an error. +type QueryExecuteFunc func(client *devlake.Client, params map[string]interface{}) (interface{}, error) + +// QueryResult wraps the output of a query execution. +type QueryResult struct { + Data interface{} // the actual result data + Metadata map[string]string // optional metadata about the query +} From 32d743a931ed68ff5c3a22b289bca106f44fe196 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:30:35 +0000 Subject: [PATCH 04/11] Address PR feedback: fix docs, engine defaults, and limit parsing - Update docs/query.md to describe DORA/Copilot as partial implementations - Fix engine.go to apply defaults consistently for all optional params - Fix pipelines.go to return error on invalid limit parsing instead of silently ignoring - All validation passes: go build, go test, go vet Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- docs/query.md | 67 +++++++++++++++++++++++++++++-------- internal/query/engine.go | 18 +++++----- internal/query/pipelines.go | 7 +++- 3 files changed, 68 insertions(+), 24 deletions(-) diff --git a/docs/query.md b/docs/query.md index e10a637..71107e1 100644 --- a/docs/query.md +++ b/docs/query.md @@ -83,20 +83,38 @@ Query DORA (DevOps Research and Assessment) metrics. gh devlake query dora --project [flags] ``` -**Status:** 🚧 Not yet implemented +**Status:** ⚠️ Partial implementation (limited by available API data) -**Reason:** DevLake does not currently expose a metrics API endpoint. DORA metrics are calculated in Grafana dashboards using SQL queries against the domain layer tables, but these calculations are not available via the REST API. +**What's available:** +- Project metadata (name, description, blueprint info) +- Clear explanation of limitations in the response -**Workaround:** View DORA metrics in your Grafana dashboards: +**What's not available:** +Full DORA metric calculations (deployment frequency, lead time for changes, change failure rate, mean time to restore) require SQL queries against DevLake's domain layer tables. DevLake does not expose database credentials or a metrics API endpoint. + +**Current output (JSON):** + +```json +{ + "project": "my-team", + "timeframe": "30d", + "availableData": { + "project": { "name": "my-team", "blueprint": {...} } + }, + "limitations": "Full DORA metrics require SQL against domain tables..." +} +``` + +**Workaround for full metrics:** View DORA metrics in your Grafana dashboards: ```bash gh devlake status # Shows Grafana URL ``` Then navigate to the DORA dashboards in Grafana. -**Future implementation requires:** +**Full implementation requires:** 1. Upstream DevLake metrics API endpoint -2. OR direct database query support (requires DB credentials in state files) +2. OR direct database query support (requires DB credentials) 3. OR Grafana API integration to fetch dashboard data --- @@ -109,20 +127,40 @@ Query GitHub Copilot usage metrics. gh devlake query copilot --project [flags] ``` -**Status:** 🚧 Not yet implemented +**Status:** ⚠️ Partial implementation (limited by available API data) -**Reason:** DevLake does not currently expose a metrics API endpoint. Copilot metrics are stored in the `gh-copilot` plugin's database tables and visualized in Grafana dashboards, but not accessible via the REST API. +**What's available:** +- Project metadata (name, description, blueprint info) +- GitHub Copilot connection information +- Clear explanation of limitations in the response + +**What's not available:** +Copilot usage metrics (total seats, active users, acceptance rates, language breakdowns, editor usage) are stored in `_tool_gh_copilot_*` database tables and visualized in Grafana dashboards, but DevLake does not expose a metrics API endpoint. + +**Current output (JSON):** + +```json +{ + "project": "my-team", + "timeframe": "30d", + "availableData": { + "project": { "name": "my-team", "blueprint": {...} }, + "connections": [...] + }, + "limitations": "Copilot metrics in _tool_gh_copilot_* tables require metrics API..." +} +``` -**Workaround:** View Copilot metrics in your Grafana dashboards: +**Workaround for full metrics:** View Copilot metrics in your Grafana dashboards: ```bash gh devlake status # Shows Grafana URL ``` Then navigate to the Copilot dashboards in Grafana. -**Future implementation requires:** +**Full implementation requires:** 1. Upstream DevLake metrics API endpoint for Copilot plugin -2. OR direct database query support (requires DB credentials in state files) +2. OR direct database query support (requires DB credentials) 3. OR Grafana API integration to fetch dashboard data --- @@ -136,12 +174,13 @@ These flags are inherited from the root command: ## Architecture Notes -The `query` command is designed to be extensible: +The `query` command uses the `internal/query/` package for extensible API-backed queries: -- **Current:** The `pipelines` subcommand uses the existing `/pipelines` REST API endpoint -- **Future:** The `dora` and `copilot` subcommands are placeholders awaiting upstream API support +- **Pipelines:** Fully functional - queries the `/pipelines` REST API endpoint with filtering and formatting +- **DORA:** Partial - returns project metadata from REST API; full metric calculations require SQL against domain tables +- **Copilot:** Partial - returns project and connection metadata from REST API; usage metrics are in database tables not exposed via API -When DevLake exposes metrics APIs, the existing command structure will remain the same — only the implementation will change from returning an error to fetching actual metrics. +All queries use the query engine abstraction (`internal/query/engine.go`) with registered query definitions. When DevLake exposes metrics APIs in the future, only the query execution functions need to change - the command structure and engine remain the same. ## See Also diff --git a/internal/query/engine.go b/internal/query/engine.go index 3f69ef4..ae239b2 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -27,16 +27,16 @@ func (e *Engine) Execute(queryDef *QueryDef, params map[string]interface{}) (int return nil, fmt.Errorf("query %q has no execute function", queryDef.Name) } - // Validate required parameters + // Apply defaults and validate parameters for _, param := range queryDef.Params { - if param.Required { - if _, ok := params[param.Name]; !ok { - // Check if there's a default value - if param.Default != "" { - params[param.Name] = param.Default - } else { - return nil, fmt.Errorf("required parameter %q not provided", param.Name) - } + if _, ok := params[param.Name]; !ok { + // Parameter not provided + if param.Default != "" { + // Apply default value + params[param.Name] = param.Default + } else if param.Required { + // Required parameter missing with no default + return nil, fmt.Errorf("required parameter %q not provided", param.Name) } } } diff --git a/internal/query/pipelines.go b/internal/query/pipelines.go index b6663a1..c5c876a 100644 --- a/internal/query/pipelines.go +++ b/internal/query/pipelines.go @@ -58,7 +58,12 @@ func executePipelinesQuery(client *devlake.Client, params map[string]interface{} if l, ok := params["limit"].(int); ok { limit = l } else if l, ok := params["limit"].(string); ok { - fmt.Sscanf(l, "%d", &limit) + var parsedLimit int + n, err := fmt.Sscanf(l, "%d", &parsedLimit) + if err != nil || n != 1 { + return nil, fmt.Errorf("invalid limit value %q: must be a valid integer", l) + } + limit = parsedLimit } // Query pipelines via API From 7f99fe507bc7de89aeb8d801945fea4b0a0e8a26 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:44:46 +0000 Subject: [PATCH 05/11] Fix URL injection vulnerability in ListPipelines - Replace manual string concatenation with url.Values for query params - All query parameters now properly URL-encoded via q.Encode() - Remove unused strings import - Prevents injection of additional query parameters via --status flag - All validation passes: go build, go test, go vet Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- internal/devlake/client.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/internal/devlake/client.go b/internal/devlake/client.go index 61020ed..9831e3a 100644 --- a/internal/devlake/client.go +++ b/internal/devlake/client.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "net/url" - "strings" "time" ) @@ -525,22 +524,22 @@ type PipelineListResponse struct { // blueprintID filters by blueprint (0 = no filter). // page and pageSize control pagination (0 = use defaults). func (c *Client) ListPipelines(status string, blueprintID, page, pageSize int) (*PipelineListResponse, error) { - path := "/pipelines?" - params := []string{} + path := "/pipelines" + q := url.Values{} if status != "" { - params = append(params, "status="+status) + q.Set("status", status) } if blueprintID > 0 { - params = append(params, fmt.Sprintf("blueprint_id=%d", blueprintID)) + q.Set("blueprint_id", fmt.Sprintf("%d", blueprintID)) } if page > 0 { - params = append(params, fmt.Sprintf("page=%d", page)) + q.Set("page", fmt.Sprintf("%d", page)) } if pageSize > 0 { - params = append(params, fmt.Sprintf("pagesize=%d", pageSize)) + q.Set("pagesize", fmt.Sprintf("%d", pageSize)) } - if len(params) > 0 { - path += strings.Join(params, "&") + if len(q) > 0 { + path += "?" + q.Encode() } return doGet[PipelineListResponse](c, path) } From 19ba19041a10b82ecda8c6df009e1429e58b864e Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:00:29 +0000 Subject: [PATCH 06/11] Address GitHub review feedback on query commands 1. Fix JSON output corruption: use quiet discovery for JSON, verbose for table 2. Add --format validation: return error for invalid format values 3. Fix nil map safety: initialize params map if nil in engine.Execute 4. Fix swallowed error: propagate ListConnections errors in copilot query 5. Add tests: query_pipelines_test.go validates JSON output and format validation All validation passes: go build, go test, go vet Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/query_pipelines.go | 20 +++-- cmd/query_pipelines_test.go | 174 ++++++++++++++++++++++++++++++++++++ internal/query/copilot.go | 5 +- internal/query/engine.go | 5 ++ 4 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 cmd/query_pipelines_test.go diff --git a/cmd/query_pipelines.go b/cmd/query_pipelines.go index 558f26c..a809fcf 100644 --- a/cmd/query_pipelines.go +++ b/cmd/query_pipelines.go @@ -41,23 +41,31 @@ func init() { } func runQueryPipelines(cmd *cobra.Command, args []string) error { + // Validate format flag + if queryPipelinesFormat != "json" && queryPipelinesFormat != "table" { + return fmt.Errorf("invalid --format value %q: must be 'json' or 'table'", queryPipelinesFormat) + } + // Discover DevLake instance - var disc *devlake.DiscoveryResult var client *devlake.Client var err error - if !outputJSON && queryPipelinesFormat != "table" { - client, disc, err = discoverClient(cfgURL) + // Use quiet discovery for JSON output, verbose for table + if outputJSON || queryPipelinesFormat == "json" { + // Quiet discovery for JSON output + disc, err := devlake.Discover(cfgURL) if err != nil { return fmt.Errorf("discovering DevLake: %w", err) } + client = devlake.NewClient(disc.URL) } else { - // Quiet discovery for JSON/table output - disc, err = devlake.Discover(cfgURL) + // Verbose discovery for table output + var disc *devlake.DiscoveryResult + client, disc, err = discoverClient(cfgURL) if err != nil { return fmt.Errorf("discovering DevLake: %w", err) } - client = devlake.NewClient(disc.URL) + _ = disc // disc is used by discoverClient for output } // Get the query definition diff --git a/cmd/query_pipelines_test.go b/cmd/query_pipelines_test.go new file mode 100644 index 0000000..462fdbd --- /dev/null +++ b/cmd/query_pipelines_test.go @@ -0,0 +1,174 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" + "github.com/DevExpGBB/gh-devlake/internal/query" +) + +func TestQueryPipelines_InvalidFormat(t *testing.T) { + queryPipelinesFormat = "invalid" + t.Cleanup(func() { queryPipelinesFormat = "json" }) + + err := runQueryPipelines(nil, nil) + if err == nil { + t.Fatal("expected error for invalid --format, got nil") + } + if !strings.Contains(err.Error(), "invalid --format value") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestQueryPipelines_JSONOutputNoBanner(t *testing.T) { + // Mock DevLake API + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ping" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/pipelines" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(devlake.PipelineListResponse{ + Pipelines: []devlake.Pipeline{ + { + ID: 123, + Status: "TASK_COMPLETED", + FinishedTasks: 10, + TotalTasks: 10, + }, + }, + Count: 1, + }) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + + // Set URL to mock server + origURL := cfgURL + cfgURL = srv.URL + t.Cleanup(func() { cfgURL = origURL }) + + // Set format to JSON + origFormat := queryPipelinesFormat + queryPipelinesFormat = "json" + t.Cleanup(func() { queryPipelinesFormat = origFormat }) + + // Capture stdout + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { os.Stdout = orig }) + + // Run the command + if err := runQueryPipelines(nil, nil); err != nil { + t.Fatalf("runQueryPipelines returned error: %v", err) + } + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + out := buf.String() + + // Verify no discovery banners in output + if strings.Contains(out, "Discovering DevLake") { + t.Error("JSON output should not contain discovery banner") + } + if strings.Contains(out, "🔍") { + t.Error("JSON output should not contain emoji banners") + } + + // Verify valid JSON + trimmed := strings.TrimSpace(out) + var pipelines []query.PipelineResult + if err := json.Unmarshal([]byte(trimmed), &pipelines); err != nil { + t.Fatalf("output is not valid JSON: %v — got: %q", err, out) + } + + if len(pipelines) != 1 { + t.Fatalf("expected 1 pipeline, got %d", len(pipelines)) + } + if pipelines[0].ID != 123 { + t.Errorf("expected ID=123, got %d", pipelines[0].ID) + } + if pipelines[0].Status != "TASK_COMPLETED" { + t.Errorf("expected status=TASK_COMPLETED, got %q", pipelines[0].Status) + } +} + +func TestQueryPipelines_GlobalJSONFlag(t *testing.T) { + // Mock DevLake API + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ping" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/pipelines" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(devlake.PipelineListResponse{ + Pipelines: []devlake.Pipeline{}, + Count: 0, + }) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + + // Set URL to mock server + origURL := cfgURL + cfgURL = srv.URL + t.Cleanup(func() { cfgURL = origURL }) + + // Set global JSON flag + origJSON := outputJSON + outputJSON = true + t.Cleanup(func() { outputJSON = origJSON }) + + // Set format to table (should be overridden by --json) + origFormat := queryPipelinesFormat + queryPipelinesFormat = "table" + t.Cleanup(func() { queryPipelinesFormat = origFormat }) + + // Capture stdout + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { os.Stdout = orig }) + + // Run the command + if err := runQueryPipelines(nil, nil); err != nil { + t.Fatalf("runQueryPipelines returned error: %v", err) + } + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + out := buf.String() + + // Verify no discovery banners in output + if strings.Contains(out, "Discovering DevLake") { + t.Error("JSON output with --json should not contain discovery banner") + } + + // Verify valid JSON + trimmed := strings.TrimSpace(out) + var pipelines []query.PipelineResult + if err := json.Unmarshal([]byte(trimmed), &pipelines); err != nil { + t.Fatalf("output is not valid JSON: %v — got: %q", err, out) + } +} diff --git a/internal/query/copilot.go b/internal/query/copilot.go index 6eb1c5b..a1fe93b 100644 --- a/internal/query/copilot.go +++ b/internal/query/copilot.go @@ -50,12 +50,11 @@ func executeCopilotQuery(client *devlake.Client, params map[string]interface{}) // Check if gh-copilot plugin is configured connections, err := client.ListConnections("gh-copilot") if err != nil { - // Plugin might not be available - connections = []devlake.Connection{} + return nil, fmt.Errorf("listing gh-copilot connections: %w", err) } availableData := map[string]interface{}{ - "projectName": proj.Name, + "projectName": proj.Name, "copilotConnectionsFound": len(connections), } diff --git a/internal/query/engine.go b/internal/query/engine.go index ae239b2..c3dfc97 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -27,6 +27,11 @@ func (e *Engine) Execute(queryDef *QueryDef, params map[string]interface{}) (int return nil, fmt.Errorf("query %q has no execute function", queryDef.Name) } + // Initialize params if nil to avoid panics when applying defaults + if params == nil { + params = make(map[string]interface{}) + } + // Apply defaults and validate parameters for _, param := range queryDef.Params { if _, ok := params[param.Name]; !ok { From d8ab7bf7d15eaaa0a51f882e385bdab5454edbbc Mon Sep 17 00:00:00 2001 From: Eldrick19 Date: Mon, 16 Mar 2026 14:11:04 -0400 Subject: [PATCH 07/11] docs: sync copilot query example Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/query.md | 377 +++++++++++++++++++++++++------------------------- 1 file changed, 189 insertions(+), 188 deletions(-) diff --git a/docs/query.md b/docs/query.md index 71107e1..cc8f48e 100644 --- a/docs/query.md +++ b/docs/query.md @@ -1,188 +1,189 @@ -# gh devlake query - -Query DevLake's aggregated data and metrics. - -## Usage - -```bash -gh devlake query [flags] -``` - -## Subcommands - -### pipelines - -Query recent pipeline runs. - -```bash -gh devlake query pipelines [flags] -``` - -**Flags:** -- `--project ` - Filter by project name -- `--status ` - Filter by status (`TASK_CREATED`, `TASK_RUNNING`, `TASK_COMPLETED`, `TASK_FAILED`) -- `--limit ` - Maximum number of pipelines to return (default: 20) -- `--format ` - Output format: `json` or `table` (default: `json`) - -**Examples:** - -```bash -# List recent pipelines as JSON -gh devlake query pipelines - -# List pipelines for a specific project -gh devlake query pipelines --project my-team - -# List only completed pipelines -gh devlake query pipelines --status TASK_COMPLETED --limit 10 - -# Display as table -gh devlake query pipelines --format table -``` - -**Output (JSON):** - -```json -[ - { - "id": 123, - "status": "TASK_COMPLETED", - "blueprintId": 1, - "createdAt": "2026-03-12T10:00:00Z", - "beganAt": "2026-03-12T10:00:05Z", - "finishedAt": "2026-03-12T10:15:30Z", - "finishedTasks": 12, - "totalTasks": 12 - } -] -``` - -**Output (Table):** - -``` -════════════════════════════════════════ - DevLake — Pipeline Query -════════════════════════════════════════ - - Found 3 pipeline(s) - ──────────────────────────────────────────────────────────────────────────────── - ID STATUS TASKS FINISHED AT - ──────────────────────────────────────────────────────────────────────────────── - 123 TASK_COMPLETED 12/12 2026-03-12T10:15:30Z - 122 TASK_COMPLETED 12/12 2026-03-12T09:15:30Z - 121 TASK_RUNNING 8/12 (running) -``` - ---- - -### dora - -Query DORA (DevOps Research and Assessment) metrics. - -```bash -gh devlake query dora --project [flags] -``` - -**Status:** ⚠️ Partial implementation (limited by available API data) - -**What's available:** -- Project metadata (name, description, blueprint info) -- Clear explanation of limitations in the response - -**What's not available:** -Full DORA metric calculations (deployment frequency, lead time for changes, change failure rate, mean time to restore) require SQL queries against DevLake's domain layer tables. DevLake does not expose database credentials or a metrics API endpoint. - -**Current output (JSON):** - -```json -{ - "project": "my-team", - "timeframe": "30d", - "availableData": { - "project": { "name": "my-team", "blueprint": {...} } - }, - "limitations": "Full DORA metrics require SQL against domain tables..." -} -``` - -**Workaround for full metrics:** View DORA metrics in your Grafana dashboards: -```bash -gh devlake status # Shows Grafana URL -``` - -Then navigate to the DORA dashboards in Grafana. - -**Full implementation requires:** -1. Upstream DevLake metrics API endpoint -2. OR direct database query support (requires DB credentials) -3. OR Grafana API integration to fetch dashboard data - ---- - -### copilot - -Query GitHub Copilot usage metrics. - -```bash -gh devlake query copilot --project [flags] -``` - -**Status:** ⚠️ Partial implementation (limited by available API data) - -**What's available:** -- Project metadata (name, description, blueprint info) -- GitHub Copilot connection information -- Clear explanation of limitations in the response - -**What's not available:** -Copilot usage metrics (total seats, active users, acceptance rates, language breakdowns, editor usage) are stored in `_tool_gh_copilot_*` database tables and visualized in Grafana dashboards, but DevLake does not expose a metrics API endpoint. - -**Current output (JSON):** - -```json -{ - "project": "my-team", - "timeframe": "30d", - "availableData": { - "project": { "name": "my-team", "blueprint": {...} }, - "connections": [...] - }, - "limitations": "Copilot metrics in _tool_gh_copilot_* tables require metrics API..." -} -``` - -**Workaround for full metrics:** View Copilot metrics in your Grafana dashboards: -```bash -gh devlake status # Shows Grafana URL -``` - -Then navigate to the Copilot dashboards in Grafana. - -**Full implementation requires:** -1. Upstream DevLake metrics API endpoint for Copilot plugin -2. OR direct database query support (requires DB credentials) -3. OR Grafana API integration to fetch dashboard data - ---- - -## Global Flags - -These flags are inherited from the root command: - -- `--url ` - DevLake API base URL (auto-discovered if omitted) -- `--json` - Output as JSON (suppresses banners and interactive prompts) - -## Architecture Notes - -The `query` command uses the `internal/query/` package for extensible API-backed queries: - -- **Pipelines:** Fully functional - queries the `/pipelines` REST API endpoint with filtering and formatting -- **DORA:** Partial - returns project metadata from REST API; full metric calculations require SQL against domain tables -- **Copilot:** Partial - returns project and connection metadata from REST API; usage metrics are in database tables not exposed via API - -All queries use the query engine abstraction (`internal/query/engine.go`) with registered query definitions. When DevLake exposes metrics APIs in the future, only the query execution functions need to change - the command structure and engine remain the same. - -## See Also - -- `gh devlake status` - Check DevLake deployment and connection status -- `gh devlake configure project list` - List all projects +# gh devlake query + +Query DevLake's aggregated data and metrics. + +## Usage + +```bash +gh devlake query [flags] +``` + +## Subcommands + +### pipelines + +Query recent pipeline runs. + +```bash +gh devlake query pipelines [flags] +``` + +**Flags:** +- `--project ` - Filter by project name +- `--status ` - Filter by status (`TASK_CREATED`, `TASK_RUNNING`, `TASK_COMPLETED`, `TASK_FAILED`) +- `--limit ` - Maximum number of pipelines to return (default: 20) +- `--format ` - Output format: `json` or `table` (default: `json`) + +**Examples:** + +```bash +# List recent pipelines as JSON +gh devlake query pipelines + +# List pipelines for a specific project +gh devlake query pipelines --project my-team + +# List only completed pipelines +gh devlake query pipelines --status TASK_COMPLETED --limit 10 + +# Display as table +gh devlake query pipelines --format table +``` + +**Output (JSON):** + +```json +[ + { + "id": 123, + "status": "TASK_COMPLETED", + "blueprintId": 1, + "createdAt": "2026-03-12T10:00:00Z", + "beganAt": "2026-03-12T10:00:05Z", + "finishedAt": "2026-03-12T10:15:30Z", + "finishedTasks": 12, + "totalTasks": 12 + } +] +``` + +**Output (Table):** + +``` +════════════════════════════════════════ + DevLake — Pipeline Query +════════════════════════════════════════ + + Found 3 pipeline(s) + ──────────────────────────────────────────────────────────────────────────────── + ID STATUS TASKS FINISHED AT + ──────────────────────────────────────────────────────────────────────────────── + 123 TASK_COMPLETED 12/12 2026-03-12T10:15:30Z + 122 TASK_COMPLETED 12/12 2026-03-12T09:15:30Z + 121 TASK_RUNNING 8/12 (running) +``` + +--- + +### dora + +Query DORA (DevOps Research and Assessment) metrics. + +```bash +gh devlake query dora --project [flags] +``` + +**Status:** ⚠️ Partial implementation (limited by available API data) + +**What's available:** +- Project metadata (name, description, blueprint info) +- Clear explanation of limitations in the response + +**What's not available:** +Full DORA metric calculations (deployment frequency, lead time for changes, change failure rate, mean time to restore) require SQL queries against DevLake's domain layer tables. DevLake does not expose database credentials or a metrics API endpoint. + +**Current output (JSON):** + +```json +{ + "project": "my-team", + "timeframe": "30d", + "availableData": { + "project": { "name": "my-team", "blueprint": {...} } + }, + "limitations": "Full DORA metrics require SQL against domain tables..." +} +``` + +**Workaround for full metrics:** View DORA metrics in your Grafana dashboards: +```bash +gh devlake status # Shows Grafana URL +``` + +Then navigate to the DORA dashboards in Grafana. + +**Full implementation requires:** +1. Upstream DevLake metrics API endpoint +2. OR direct database query support (requires DB credentials) +3. OR Grafana API integration to fetch dashboard data + +--- + +### copilot + +Query GitHub Copilot usage metrics. + +```bash +gh devlake query copilot --project [flags] +``` + +**Status:** ⚠️ Partial implementation (limited by available API data) + +**What's available:** +- Project metadata (name, description, blueprint info) +- GitHub Copilot connection information +- Clear explanation of limitations in the response + +**What's not available:** +Copilot usage metrics (total seats, active users, acceptance rates, language breakdowns, editor usage) are stored in `_tool_gh_copilot_*` database tables and visualized in Grafana dashboards, but DevLake does not expose a metrics API endpoint. + +**Current output (JSON):** + +```json +{ + "project": "my-team", + "timeframe": "30d", + "availableData": { + "projectName": "my-team", + "copilotConnectionsFound": 2, + "connections": [...] + }, + "limitations": "Copilot metrics in _tool_gh_copilot_* tables require metrics API..." +} +``` + +**Workaround for full metrics:** View Copilot metrics in your Grafana dashboards: +```bash +gh devlake status # Shows Grafana URL +``` + +Then navigate to the Copilot dashboards in Grafana. + +**Full implementation requires:** +1. Upstream DevLake metrics API endpoint for Copilot plugin +2. OR direct database query support (requires DB credentials) +3. OR Grafana API integration to fetch dashboard data + +--- + +## Global Flags + +These flags are inherited from the root command: + +- `--url ` - DevLake API base URL (auto-discovered if omitted) +- `--json` - Output as JSON (suppresses banners and interactive prompts) + +## Architecture Notes + +The `query` command uses the `internal/query/` package for extensible API-backed queries: + +- **Pipelines:** Fully functional - queries the `/pipelines` REST API endpoint with filtering and formatting +- **DORA:** Partial - returns project metadata from REST API; full metric calculations require SQL against domain tables +- **Copilot:** Partial - returns project and connection metadata from REST API; usage metrics are in database tables not exposed via API + +All queries use the query engine abstraction (`internal/query/engine.go`) with registered query definitions. When DevLake exposes metrics APIs in the future, only the query execution functions need to change - the command structure and engine remain the same. + +## See Also + +- `gh devlake status` - Check DevLake deployment and connection status +- `gh devlake configure project list` - List all projects From 79df1553a4a5fe860ed3df97f31e3e89d0f77a4c Mon Sep 17 00:00:00 2001 From: Eldrick19 Date: Mon, 16 Mar 2026 14:23:44 -0400 Subject: [PATCH 08/11] fix: address Copilot review follow-ups on query PR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 293 +++++++++++++++++++++++++++++++++++++ cmd/query.go | 50 +++---- internal/query/copilot.go | 176 ++++++++++++---------- internal/query/engine.go | 127 +++++++++------- internal/query/registry.go | 67 +++++---- internal/query/types.go | 72 ++++----- 6 files changed, 559 insertions(+), 226 deletions(-) diff --git a/README.md b/README.md index 2b5d079..d92f2d1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD # gh-devlake A [GitHub CLI extension](https://cli.github.com/manual/gh_extension) that deploys, configures, and monitors [Apache DevLake](https://github.com/apache/incubator-devlake) from the terminal. @@ -282,3 +283,295 @@ gh extension install . # Install locally for testing ## License MIT — see [LICENSE](LICENSE). +======= +# gh-devlake + +A [GitHub CLI extension](https://cli.github.com/manual/gh_extension) that deploys, configures, and monitors [Apache DevLake](https://github.com/apache/incubator-devlake) from the terminal. + +Deploy DevLake locally or on Azure, create connections to GitHub and Copilot, configure DORA project scopes, and trigger data syncs — all without touching the Config UI. + +DevLake is an open-source dev data platform that normalizes data from DevOps tools so you can compute consistent engineering metrics like DORA. + +This CLI makes that setup fast and repeatable from the terminal (instead of clicking through the Config UI) — especially when you want to re-run the same configuration across teams. + +> **Blog post:** [Beyond Copilot Dashboards: Measuring What AI Actually Changes]() — why DORA + Copilot correlation matters and what this tool enables. + +> [!NOTE] +> **GitHub Copilot plugin:** The Copilot metrics plugin is currently available in the [DevExpGBB/incubator-devlake](https://github.com/DevExpGBB/incubator-devlake) fork while the upstream PR ([apache/incubator-devlake#8728](https://github.com/apache/incubator-devlake/pull/8728)) is under review. The `deploy local` and `deploy azure` commands handle custom images automatically — no manual image builds needed. Once the PR merges, the official Apache images will include it. + + + +--- + +## Prerequisites + +| Requirement | When needed | +|-------------|-------------| +| [GitHub CLI](https://cli.github.com/) (`gh`) | Always — this is a `gh` extension | +| [Docker](https://docs.docker.com/get-docker/) | `deploy local`, `cleanup --local` | +| [Azure CLI](https://learn.microsoft.com/cli/azure/) (`az`) | `deploy azure` only | +| GitHub PAT | Required when creating connections (see Supported Plugins) | + +
+Building from source + +Requires [Go 1.22+](https://go.dev/). + +```bash +git clone https://github.com/DevExpGBB/gh-devlake.git +cd gh-devlake +go build -o gh-devlake.exe . # Windows +go build -o gh-devlake . # Linux/macOS +gh extension install . +``` + +
+ +--- + +## Quick Start + +```bash +gh extension install DevExpGBB/gh-devlake + +# Option 1: Fully guided wizard (deploy → connect → scope → project) +gh devlake init + +# Option 2: Step-by-step +gh devlake deploy local --dir ./devlake +cd devlake && docker compose up -d +# wait ~2 minutes, then: +gh devlake configure full +``` + +After setup, open Grafana at **http://localhost:3002** (admin / admin). DORA and Copilot dashboards will populate after the first sync completes. + +| Service | URL | +|---------|-----| +| Grafana | http://localhost:3002 (admin/admin) | +| Config UI | http://localhost:4000 | +| Backend API | http://localhost:8080 | + +--- + +## How DevLake Works + +Four concepts to understand — then every command makes sense: + +| Concept | What It Is | +|---------|-----------| +| **Connection** | An authenticated link to a data source (GitHub, Copilot, Jenkins). Each gets its own PAT/credentials. | +| **Scope** | *What* to collect — specific repos for GitHub, an org/enterprise for Copilot, jobs for Jenkins. | +| **Project** | Groups connections + scopes into a single view with DORA metrics enabled. | +| **Blueprint** | The sync schedule (cron). Created automatically with the project. | + + + +For a deeper explanation with diagrams, see [DevLake Concepts](docs/concepts.md). + +--- + +## Getting Started Step by Step + +### Step 1: Deploy + +```bash +gh devlake deploy local --dir ./devlake +cd devlake && docker compose up -d +``` + +Downloads Docker Compose files, generates secrets, and prepares the stack. Give it ~2 minutes after `docker compose up`. See [docs/deploy.md](docs/deploy.md) for flags and details. + +
+Deploying to Azure instead + +```bash +gh devlake deploy azure --resource-group devlake-rg --location eastus --official +``` + +Creates Container Instances, MySQL Flexible Server, and Key Vault via Bicep (~$30–50/month with `--official`). Omit flags to be prompted interactively. + +See [docs/deploy.md](docs/deploy.md) for all Azure options, custom image builds, and tear-down. + +
+ +### Step 2: Create Connections + +The CLI will prompt you for your PAT. You can also pass `--token`, use an `--env-file`, or set `GITHUB_TOKEN` in your environment. See [Token Handling](docs/token-handling.md) for the full resolution chain. + +```bash +# GitHub (repos, PRs, workflows, deployments) +gh devlake configure connection add --plugin github --org my-org + +# Copilot (usage metrics, seats, acceptance rates) +gh devlake configure connection add --plugin gh-copilot --org my-org + +# Jenkins (jobs and build data for DORA) +gh devlake configure connection add --plugin jenkins --endpoint https://jenkins.example.com --username admin --token myapitoken +``` + +The CLI tests each connection before saving. On success: + +``` + 🔑 Testing connection... + ✅ Connection test passed + ✅ Created GitHub connection (ID=1) +``` + +See [docs/configure-connection.md](docs/configure-connection.md) for all flags. + +### Step 3: Add Scopes + +Tell DevLake which repos or orgs to collect from: + +```bash +# GitHub — pick repos interactively, or pass --repos explicitly +gh devlake configure scope add --plugin github --org my-org + +# Copilot — org-level metrics +gh devlake configure scope add --plugin gh-copilot --org my-org + +# Jenkins — pick jobs interactively (or pass --jobs) +gh devlake configure scope add --plugin jenkins --org my-org +``` + +DORA patterns (deployment workflow, production environment, incident label) use sensible defaults. See [docs/configure-scope.md](docs/configure-scope.md) for overrides. + +### Step 4: Create a Project and Sync + +```bash +gh devlake configure project add +``` + +Discovers your connections and scopes, creates a DevLake project with DORA metrics enabled, sets up a daily sync blueprint, and triggers the first data collection. + +See [docs/configure-project.md](docs/configure-project.md) for flags (`--project-name`, `--cron`, `--time-after`, `--skip-sync`). + + + +> **Shortcut:** `gh devlake configure full` chains Steps 2–4 interactively. See [docs/configure-full.md](docs/configure-full.md). + +--- + +## Day-2 Operations + +
+Status checks, token rotation, adding repos, and tear-down + +```bash +gh devlake status # health + summary +gh devlake configure connection list # list connections +gh devlake configure connection update --plugin github --id 1 --token ghp_new # rotate token +gh devlake configure scope add --plugin github --org my-org # add more repos +gh devlake cleanup --local # tear down Docker +``` + +For the full guide, see [Day-2 Operations](docs/day-2.md). + +
+ +--- + +## Supported Plugins + +| Plugin | Status | What It Collects | Required PAT scopes | +|--------|--------|------------------|---------------------| +| GitHub | ✅ Available | Repos, PRs, issues, workflows, deployments (DORA) | `repo`, `read:org`, `read:user` | +| GitHub Copilot | ✅ Available | Usage metrics, seats, acceptance rates | `manage_billing:copilot`, `read:org` (+ `read:enterprise` for enterprise metrics) | +| Jenkins | ✅ Available | Jobs, builds, deployments (DORA) | Username + API token/password | +| Jira | ✅ Available | Boards, issues, sprints (change lead time, cycle time) | API token (permissions from user account) | +| GitLab | ✅ Available | Repos, MRs, pipelines, deployments (DORA) | `read_api`, `read_repository` | +| Bitbucket Cloud | ✅ Available | Repos, PRs, commits | Bitbucket username + app password | +| SonarQube | ✅ Available | Code quality, coverage, code smells (quality gates) | API token (permissions from user account) | +| Azure DevOps | ✅ Available | Repos, pipelines, deployments (DORA) | PAT with repo and pipeline access | +| ArgoCD | ✅ Available | GitOps deployments, deployment frequency (DORA) | ArgoCD auth token | + +See [Token Handling](docs/token-handling.md) for env key names and multi-plugin `.devlake.env` examples. + +--- + +## Command Reference + +| Command | Description | Docs | +|---------|-------------|------| +| `gh devlake init` | Guided 4-phase setup wizard | [init.md](docs/init.md) | +| `gh devlake status` | Health check and connection summary | [status.md](docs/status.md) | +| `gh devlake deploy local` | Local Docker Compose deploy | [deploy.md](docs/deploy.md) | +| `gh devlake deploy azure` | Azure Container Instance deploy | [deploy.md](docs/deploy.md) | +| `gh devlake configure connection` | Manage plugin connections (subcommands below) | [configure-connection.md](docs/configure-connection.md) | +| `gh devlake configure connection add` | Create a new plugin connection | [configure-connection.md](docs/configure-connection.md) | +| `gh devlake configure connection list` | List all connections | [configure-connection.md](docs/configure-connection.md) | +| `gh devlake configure connection test` | Test a saved connection | [configure-connection.md](docs/configure-connection.md) | +| `gh devlake configure connection update` | Rotate token or update settings | [configure-connection.md](docs/configure-connection.md) | +| `gh devlake configure connection delete` | Remove a connection | [configure-connection.md](docs/configure-connection.md) | +| `gh devlake configure scope` | Manage scopes on connections (subcommands below) | [configure-scope.md](docs/configure-scope.md) | +| `gh devlake configure scope add` | Add repo/org scopes to a connection | [configure-scope.md](docs/configure-scope.md) | +| `gh devlake configure scope list` | List scopes on a connection | [configure-scope.md](docs/configure-scope.md) | +| `gh devlake configure scope delete` | Remove a scope from a connection | [configure-scope.md](docs/configure-scope.md) | +| `gh devlake configure project` | Manage DevLake projects (subcommands below) | [configure-project.md](docs/configure-project.md) | +| `gh devlake configure project add` | Create a project + blueprint + first sync | [configure-project.md](docs/configure-project.md) | +| `gh devlake configure project list` | List all projects | [configure-project.md](docs/configure-project.md) | +| `gh devlake configure project delete` | Delete a project | [configure-project.md](docs/configure-project.md) | +| `gh devlake configure full` | Connections + scopes + project in one step | [configure-full.md](docs/configure-full.md) | +| `gh devlake query pipelines` | Query recent pipeline runs | [query.md](docs/query.md) | +| `gh devlake query dora` | Query DORA metadata now; full metrics remain API/DB limited | [query.md](docs/query.md) | +| `gh devlake query copilot` | Query Copilot metadata now; full metrics remain API/DB limited | [query.md](docs/query.md) | +| `gh devlake start` | Start stopped or exited DevLake services | [start.md](docs/start.md) | +| `gh devlake stop` | Stop running services (preserves containers and data) | [stop.md](docs/stop.md) | +| `gh devlake cleanup` | Tear down local or Azure resources | [cleanup.md](docs/cleanup.md) | + +### Global Flags + +| Flag | Description | +|------|-------------| +| `--url ` | DevLake API base URL (auto-discovered if omitted) | +| `--json` | Output as JSON — suppresses banners and interactive prompts. Useful for scripting and agent consumption. | + +#### `--json` output + +Read commands emit a single compact JSON line on success, or `{"error":""}` on failure. No emoji, banners, or interactive prompts are printed. + +```bash +# Human (default) +$ gh devlake status + +════════════════════════════════════════ + DevLake Status +════════════════════════════════════════ + ... + +# Machine-readable +$ gh devlake status --json +{"deployment":{"method":"local","stateFile":".devlake-local.json"},"endpoints":[{"name":"backend","url":"http://localhost:8080","healthy":true}],"connections":[{"plugin":"github","id":1,"name":"GitHub - my-org","organization":"my-org"}],"project":{"name":"my-project","blueprintId":1}} + +# Pipe into jq +$ gh devlake configure connection list --json | jq '.[].name' +"GitHub - my-org" +``` + +Commands that support `--json`: + +| Command | JSON shape | +|---------|-----------| +| `gh devlake status` | `{deployment, endpoints[], connections[], project}` | +| `gh devlake configure connection list` | `[{id, plugin, name, endpoint, organization, enterprise}]` | +| `gh devlake configure scope list` | `[{id, name, fullName}]` | +| `gh devlake configure project list` | `[{name, description, blueprintId}]` | + +Additional references: [Token Handling](docs/token-handling.md) · [State Files](docs/state-files.md) · [DevLake Concepts](docs/concepts.md) · [Day-2 Operations](docs/day-2.md) + +--- + +## Development + +```bash +go build -o gh-devlake.exe . # Build (Windows) +go build -o gh-devlake . # Build (Linux/macOS) +go test ./... -v # Run tests +gh extension install . # Install locally for testing +``` + +## License + +MIT — see [LICENSE](LICENSE). +>>>>>>> de1a4ee (fix: address Copilot review follow-ups on query PR) diff --git a/cmd/query.go b/cmd/query.go index da0d34b..953f622 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -1,25 +1,25 @@ -package cmd - -import ( - "github.com/spf13/cobra" -) - -var queryCmd = &cobra.Command{ - Use: "query", - Short: "Query DevLake data and metrics", - Long: `Query DevLake's aggregated data and metrics. - -Retrieve DORA metrics, Copilot usage data, pipeline status, and other -metrics in a structured format (JSON by default, --format table for -human-readable output). - -Examples: - gh devlake query pipelines --project my-team - gh devlake query pipelines --limit 20 - gh devlake query pipelines --status TASK_COMPLETED`, -} - -func init() { - queryCmd.GroupID = "operate" - rootCmd.AddCommand(queryCmd) -} +package cmd + +import ( + "github.com/spf13/cobra" +) + +var queryCmd = &cobra.Command{ + Use: "query", + Short: "Query DevLake data and metrics", + Long: `Query DevLake's aggregated data and metrics. + +Retrieve pipeline status plus DORA/Copilot query results in structured +JSON output. Individual subcommands may provide extra formatting options +such as query pipelines --format table for human-readable output. + +Examples: + gh devlake query pipelines --project my-team + gh devlake query pipelines --limit 20 + gh devlake query pipelines --status TASK_COMPLETED`, +} + +func init() { + queryCmd.GroupID = "operate" + rootCmd.AddCommand(queryCmd) +} diff --git a/internal/query/copilot.go b/internal/query/copilot.go index a1fe93b..e55247f 100644 --- a/internal/query/copilot.go +++ b/internal/query/copilot.go @@ -1,77 +1,99 @@ -package query - -import ( - "fmt" - - "github.com/DevExpGBB/gh-devlake/internal/devlake" -) - -func init() { - Register(copilotQueryDef) -} - -var copilotQueryDef = &QueryDef{ - Name: "copilot", - Description: "Query GitHub Copilot metrics (limited by available API data)", - Params: []QueryParam{ - {Name: "project", Type: "string", Required: true}, - {Name: "timeframe", Type: "string", Required: false, Default: "30d"}, - }, - Execute: executeCopilotQuery, -} - -// CopilotResult represents Copilot metrics that can be retrieved from available APIs. -// NOTE: Copilot usage metrics (acceptance rates, language breakdowns) are stored in -// _tool_gh_copilot_* tables but not exposed via REST API. -type CopilotResult struct { - Project string `json:"project"` - Timeframe string `json:"timeframe"` - AvailableData map[string]interface{} `json:"availableData"` - Limitations string `json:"limitations"` -} - -func executeCopilotQuery(client *devlake.Client, params map[string]interface{}) (interface{}, error) { - projectName, ok := params["project"].(string) - if !ok || projectName == "" { - return nil, fmt.Errorf("project parameter is required") - } - - timeframe := "30d" - if tf, ok := params["timeframe"].(string); ok && tf != "" { - timeframe = tf - } - - // Get project info - proj, err := client.GetProject(projectName) - if err != nil { - return nil, fmt.Errorf("getting project %q: %w", projectName, err) - } - - // Check if gh-copilot plugin is configured - connections, err := client.ListConnections("gh-copilot") - if err != nil { - return nil, fmt.Errorf("listing gh-copilot connections: %w", err) - } - - availableData := map[string]interface{}{ - "projectName": proj.Name, - "copilotConnectionsFound": len(connections), - } - - if len(connections) > 0 { - availableData["connections"] = connections - } - - result := CopilotResult{ - Project: projectName, - Timeframe: timeframe, - AvailableData: availableData, - Limitations: "GitHub Copilot usage metrics (total seats, active users, acceptance rates, language " + - "breakdowns, editor usage) are stored in _tool_gh_copilot_* tables and visualized in Grafana " + - "dashboards, but DevLake does not expose a /metrics or /copilot API endpoint. To retrieve " + - "Copilot metrics via CLI, DevLake would need to add a metrics API that returns aggregated " + - "Copilot usage data.", - } - - return result, nil -} +package query + +import ( + "fmt" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" +) + +func init() { + Register(copilotQueryDef) +} + +var copilotQueryDef = &QueryDef{ + Name: "copilot", + Description: "Query GitHub Copilot metrics (limited by available API data)", + Params: []QueryParam{ + {Name: "project", Type: "string", Required: true}, + {Name: "timeframe", Type: "string", Required: false, Default: "30d"}, + }, + Execute: executeCopilotQuery, +} + +type CopilotConnectionSummary struct { + ID int `json:"id"` + Name string `json:"name"` + Endpoint string `json:"endpoint,omitempty"` + Organization string `json:"organization,omitempty"` + Enterprise string `json:"enterprise,omitempty"` +} + +// CopilotResult represents Copilot metrics that can be retrieved from available APIs. +// NOTE: Copilot usage metrics (acceptance rates, language breakdowns) are stored in +// _tool_gh_copilot_* tables but not exposed via REST API. +type CopilotResult struct { + Project string `json:"project"` + Timeframe string `json:"timeframe"` + AvailableData map[string]interface{} `json:"availableData"` + Limitations string `json:"limitations"` +} + +func executeCopilotQuery(client *devlake.Client, params map[string]interface{}) (interface{}, error) { + projectName, ok := params["project"].(string) + if !ok || projectName == "" { + return nil, fmt.Errorf("project parameter is required") + } + + timeframe := "30d" + if tf, ok := params["timeframe"].(string); ok && tf != "" { + timeframe = tf + } + + // Get project info + proj, err := client.GetProject(projectName) + if err != nil { + return nil, fmt.Errorf("getting project %q: %w", projectName, err) + } + + // Check if gh-copilot plugin is configured + connections, err := client.ListConnections("gh-copilot") + if err != nil { + return nil, fmt.Errorf("listing gh-copilot connections: %w", err) + } + + availableData := map[string]interface{}{ + "projectName": proj.Name, + "copilotConnectionsFound": len(connections), + } + + if len(connections) > 0 { + availableData["connections"] = summarizeCopilotConnections(connections) + } + + result := CopilotResult{ + Project: projectName, + Timeframe: timeframe, + AvailableData: availableData, + Limitations: "GitHub Copilot usage metrics (total seats, active users, acceptance rates, language " + + "breakdowns, editor usage) are stored in _tool_gh_copilot_* tables and visualized in Grafana " + + "dashboards, but DevLake does not expose a /metrics or /copilot API endpoint. To retrieve " + + "Copilot metrics via CLI, DevLake would need to add a metrics API that returns aggregated " + + "Copilot usage data.", + } + + return result, nil +} + +func summarizeCopilotConnections(connections []devlake.Connection) []CopilotConnectionSummary { + result := make([]CopilotConnectionSummary, 0, len(connections)) + for _, conn := range connections { + result = append(result, CopilotConnectionSummary{ + ID: conn.ID, + Name: conn.Name, + Endpoint: conn.Endpoint, + Organization: conn.Organization, + Enterprise: conn.Enterprise, + }) + } + return result +} diff --git a/internal/query/engine.go b/internal/query/engine.go index c3dfc97..4cf10b8 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -1,56 +1,71 @@ -package query - -import ( - "fmt" - - "github.com/DevExpGBB/gh-devlake/internal/devlake" -) - -// Engine executes queries against DevLake's REST API. -type Engine struct { - client *devlake.Client -} - -// NewEngine creates a new query engine with the given DevLake client. -func NewEngine(client *devlake.Client) *Engine { - return &Engine{ - client: client, - } -} - -// Execute runs a query with the given parameters. -func (e *Engine) Execute(queryDef *QueryDef, params map[string]interface{}) (interface{}, error) { - if queryDef == nil { - return nil, fmt.Errorf("query definition is nil") - } - if queryDef.Execute == nil { - return nil, fmt.Errorf("query %q has no execute function", queryDef.Name) - } - - // Initialize params if nil to avoid panics when applying defaults - if params == nil { - params = make(map[string]interface{}) - } - - // Apply defaults and validate parameters - for _, param := range queryDef.Params { - if _, ok := params[param.Name]; !ok { - // Parameter not provided - if param.Default != "" { - // Apply default value - params[param.Name] = param.Default - } else if param.Required { - // Required parameter missing with no default - return nil, fmt.Errorf("required parameter %q not provided", param.Name) - } - } - } - - // Execute the query - return queryDef.Execute(e.client, params) -} - -// GetClient returns the underlying DevLake client. -func (e *Engine) GetClient() *devlake.Client { - return e.client -} +package query + +import ( + "fmt" + "strconv" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" +) + +// Engine executes queries against DevLake's REST API. +type Engine struct { + client *devlake.Client +} + +// NewEngine creates a new query engine with the given DevLake client. +func NewEngine(client *devlake.Client) *Engine { + return &Engine{ + client: client, + } +} + +// Execute runs a query with the given parameters. +func (e *Engine) Execute(queryDef *QueryDef, params map[string]interface{}) (interface{}, error) { + if queryDef == nil { + return nil, fmt.Errorf("query definition is nil") + } + if queryDef.Execute == nil { + return nil, fmt.Errorf("query %q has no execute function", queryDef.Name) + } + + // Initialize params if nil to avoid panics when applying defaults + if params == nil { + params = make(map[string]interface{}) + } + + // Apply defaults and validate parameters + for _, param := range queryDef.Params { + if _, ok := params[param.Name]; !ok { + // Parameter not provided + if param.Default != "" { + value, err := defaultValue(param) + if err != nil { + return nil, fmt.Errorf("parsing default for %q: %w", param.Name, err) + } + params[param.Name] = value + } else if param.Required { + // Required parameter missing with no default + return nil, fmt.Errorf("required parameter %q not provided", param.Name) + } + } + } + + // Execute the query + return queryDef.Execute(e.client, params) +} + +// GetClient returns the underlying DevLake client. +func (e *Engine) GetClient() *devlake.Client { + return e.client +} + +func defaultValue(param QueryParam) (interface{}, error) { + switch param.Type { + case "", "string", "duration": + return param.Default, nil + case "int": + return strconv.Atoi(param.Default) + default: + return param.Default, nil + } +} diff --git a/internal/query/registry.go b/internal/query/registry.go index c899bd6..7a878ab 100644 --- a/internal/query/registry.go +++ b/internal/query/registry.go @@ -1,32 +1,35 @@ -package query - -import "fmt" - -// registry holds all registered queries. -var registry = make(map[string]*QueryDef) - -// Register adds a query definition to the registry. -func Register(def *QueryDef) { - if def == nil || def.Name == "" { - panic("cannot register nil or unnamed query") - } - registry[def.Name] = def -} - -// Get retrieves a query definition by name. -func Get(name string) (*QueryDef, error) { - def, ok := registry[name] - if !ok { - return nil, fmt.Errorf("query %q not found", name) - } - return def, nil -} - -// List returns all registered query names. -func List() []string { - names := make([]string, 0, len(registry)) - for name := range registry { - names = append(names, name) - } - return names -} +package query + +import "fmt" + +// registry holds all registered queries. +var registry = make(map[string]*QueryDef) + +// Register adds a query definition to the registry. +func Register(def *QueryDef) { + if def == nil || def.Name == "" { + panic("cannot register nil or unnamed query") + } + if _, exists := registry[def.Name]; exists { + panic(fmt.Sprintf("query %q already registered", def.Name)) + } + registry[def.Name] = def +} + +// Get retrieves a query definition by name. +func Get(name string) (*QueryDef, error) { + def, ok := registry[name] + if !ok { + return nil, fmt.Errorf("query %q not found", name) + } + return def, nil +} + +// List returns all registered query names. +func List() []string { + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + return names +} diff --git a/internal/query/types.go b/internal/query/types.go index 6756b2b..6dbcaa1 100644 --- a/internal/query/types.go +++ b/internal/query/types.go @@ -1,36 +1,36 @@ -// Package query provides an extensible abstraction for querying DevLake data. -// Instead of direct SQL queries (DevLake doesn't expose DB credentials), this -// package defines queries as API endpoint patterns with client-side transformations. -package query - -import ( - "github.com/DevExpGBB/gh-devlake/internal/devlake" -) - -// QueryDef describes a reusable, parameterized query against DevLake's API. -// Unlike the original SQL-based design, this uses HTTP API endpoints since -// DevLake doesn't expose database credentials to external clients. -type QueryDef struct { - Name string // e.g. "pipelines", "dora_metrics" - Description string // human-readable description - Params []QueryParam // declared parameters with types and defaults - Execute QueryExecuteFunc // function that executes the query -} - -// QueryParam describes a parameter for a query. -type QueryParam struct { - Name string // parameter name - Type string // "string", "int", "duration" - Required bool // whether the parameter is required - Default string // default value if not provided -} - -// QueryExecuteFunc is the signature for query execution functions. -// It takes a client, parameters, and returns results or an error. -type QueryExecuteFunc func(client *devlake.Client, params map[string]interface{}) (interface{}, error) - -// QueryResult wraps the output of a query execution. -type QueryResult struct { - Data interface{} // the actual result data - Metadata map[string]string // optional metadata about the query -} +// Package query provides an extensible abstraction for querying DevLake data. +// Instead of direct SQL queries (DevLake doesn't expose DB credentials), this +// package defines queries as API endpoint patterns with client-side transformations. +package query + +import ( + "github.com/DevExpGBB/gh-devlake/internal/devlake" +) + +// QueryDef describes a reusable, parameterized query against DevLake's API. +// Unlike the original SQL-based design, this uses HTTP API endpoints since +// DevLake doesn't expose database credentials to external clients. +type QueryDef struct { + Name string // e.g. "pipelines", "dora_metrics" + Description string // human-readable description + Params []QueryParam // declared parameters with types and defaults + Execute QueryExecuteFunc // function that executes the query +} + +// QueryParam describes a parameter for a query. +type QueryParam struct { + Name string // parameter name + Type string // "string", "int", "duration" + Required bool // whether the parameter is required + Default string // default value if not provided +} + +// QueryExecuteFunc is the signature for query execution functions. +// It takes a client, parameters, and returns results or an error. +type QueryExecuteFunc func(client *devlake.Client, params map[string]interface{}) (interface{}, error) + +// QueryResult wraps the output of a query execution. +type QueryResult struct { + Data interface{} // the actual result data + Metadata map[string]string // optional metadata about the query +} From 8bd7fa1bb3ccf071afdfc23346e9faf1a55b15cd Mon Sep 17 00:00:00 2001 From: Eldrick19 Date: Mon, 16 Mar 2026 14:34:02 -0400 Subject: [PATCH 09/11] test: tighten pipeline query validation and coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/query_pipelines.go | 261 ++++++++++++------------ cmd/query_pipelines_test.go | 389 ++++++++++++++++++++---------------- 2 files changed, 347 insertions(+), 303 deletions(-) diff --git a/cmd/query_pipelines.go b/cmd/query_pipelines.go index a809fcf..61397b2 100644 --- a/cmd/query_pipelines.go +++ b/cmd/query_pipelines.go @@ -1,129 +1,132 @@ -package cmd - -import ( - "fmt" - "strings" - - "github.com/DevExpGBB/gh-devlake/internal/devlake" - "github.com/DevExpGBB/gh-devlake/internal/query" - "github.com/spf13/cobra" -) - -var ( - queryPipelinesProject string - queryPipelinesStatus string - queryPipelinesLimit int - queryPipelinesFormat string -) - -var queryPipelinesCmd = &cobra.Command{ - Use: "pipelines", - Short: "Query recent pipeline runs", - Long: `Query recent pipeline runs for a project or across all projects. - -Retrieves pipeline execution history with status, timing, and task completion -information. Output is JSON by default; use --format table for human-readable display. - -Examples: - gh devlake query pipelines - gh devlake query pipelines --project my-team - gh devlake query pipelines --status TASK_COMPLETED --limit 10 - gh devlake query pipelines --format table`, - RunE: runQueryPipelines, -} - -func init() { - queryPipelinesCmd.Flags().StringVar(&queryPipelinesProject, "project", "", "Filter by project name") - queryPipelinesCmd.Flags().StringVar(&queryPipelinesStatus, "status", "", "Filter by status (TASK_CREATED, TASK_RUNNING, TASK_COMPLETED, TASK_FAILED)") - queryPipelinesCmd.Flags().IntVar(&queryPipelinesLimit, "limit", 20, "Maximum number of pipelines to return") - queryPipelinesCmd.Flags().StringVar(&queryPipelinesFormat, "format", "json", "Output format (json or table)") - queryCmd.AddCommand(queryPipelinesCmd) -} - -func runQueryPipelines(cmd *cobra.Command, args []string) error { - // Validate format flag - if queryPipelinesFormat != "json" && queryPipelinesFormat != "table" { - return fmt.Errorf("invalid --format value %q: must be 'json' or 'table'", queryPipelinesFormat) - } - - // Discover DevLake instance - var client *devlake.Client - var err error - - // Use quiet discovery for JSON output, verbose for table - if outputJSON || queryPipelinesFormat == "json" { - // Quiet discovery for JSON output - disc, err := devlake.Discover(cfgURL) - if err != nil { - return fmt.Errorf("discovering DevLake: %w", err) - } - client = devlake.NewClient(disc.URL) - } else { - // Verbose discovery for table output - var disc *devlake.DiscoveryResult - client, disc, err = discoverClient(cfgURL) - if err != nil { - return fmt.Errorf("discovering DevLake: %w", err) - } - _ = disc // disc is used by discoverClient for output - } - - // Get the query definition - queryDef, err := query.Get("pipelines") - if err != nil { - return fmt.Errorf("getting pipelines query: %w", err) - } - - // Build parameters - params := map[string]interface{}{ - "limit": queryPipelinesLimit, - } - if queryPipelinesProject != "" { - params["project"] = queryPipelinesProject - } - if queryPipelinesStatus != "" { - params["status"] = queryPipelinesStatus - } - - // Execute the query - engine := query.NewEngine(client) - result, err := engine.Execute(queryDef, params) - if err != nil { - return fmt.Errorf("executing pipelines query: %w", err) - } - - // Cast result to slice of PipelineResult - pipelines, ok := result.([]query.PipelineResult) - if !ok { - return fmt.Errorf("unexpected result type: %T", result) - } - - // Output - if outputJSON || queryPipelinesFormat == "json" { - return printJSON(pipelines) - } - - // Table format - printBanner("DevLake — Pipeline Query") - if len(pipelines) == 0 { - fmt.Println("\n No pipelines found.") - return nil - } - - fmt.Printf("\n Found %d pipeline(s)\n", len(pipelines)) - fmt.Println(" " + strings.Repeat("─", 80)) - fmt.Printf(" %-6s %-15s %-10s %-20s\n", "ID", "STATUS", "TASKS", "FINISHED AT") - fmt.Println(" " + strings.Repeat("─", 80)) - for _, p := range pipelines { - status := p.Status - tasks := fmt.Sprintf("%d/%d", p.FinishedTasks, p.TotalTasks) - finished := p.FinishedAt - if finished == "" { - finished = "(running)" - } - fmt.Printf(" %-6d %-15s %-10s %-20s\n", p.ID, status, tasks, finished) - } - fmt.Println() - - return nil -} +package cmd + +import ( + "fmt" + "strings" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" + "github.com/DevExpGBB/gh-devlake/internal/query" + "github.com/spf13/cobra" +) + +var ( + queryPipelinesProject string + queryPipelinesStatus string + queryPipelinesLimit int + queryPipelinesFormat string +) + +var queryPipelinesCmd = &cobra.Command{ + Use: "pipelines", + Short: "Query recent pipeline runs", + Long: `Query recent pipeline runs for a project or across all projects. + +Retrieves pipeline execution history with status, timing, and task completion +information. Output is JSON by default; use --format table for human-readable display. + +Examples: + gh devlake query pipelines + gh devlake query pipelines --project my-team + gh devlake query pipelines --status TASK_COMPLETED --limit 10 + gh devlake query pipelines --format table`, + RunE: runQueryPipelines, +} + +func init() { + queryPipelinesCmd.Flags().StringVar(&queryPipelinesProject, "project", "", "Filter by project name") + queryPipelinesCmd.Flags().StringVar(&queryPipelinesStatus, "status", "", "Filter by status (TASK_CREATED, TASK_RUNNING, TASK_COMPLETED, TASK_FAILED)") + queryPipelinesCmd.Flags().IntVar(&queryPipelinesLimit, "limit", 20, "Maximum number of pipelines to return") + queryPipelinesCmd.Flags().StringVar(&queryPipelinesFormat, "format", "json", "Output format (json or table)") + queryCmd.AddCommand(queryPipelinesCmd) +} + +func runQueryPipelines(cmd *cobra.Command, args []string) error { + // Validate format flag + if queryPipelinesFormat != "json" && queryPipelinesFormat != "table" { + return fmt.Errorf("invalid --format value %q: must be 'json' or 'table'", queryPipelinesFormat) + } + if queryPipelinesLimit < 1 { + return fmt.Errorf("invalid --limit value %d: must be >= 1", queryPipelinesLimit) + } + + // Discover DevLake instance + var client *devlake.Client + var err error + + // Use quiet discovery for JSON output, verbose for table + if outputJSON || queryPipelinesFormat == "json" { + // Quiet discovery for JSON output + disc, err := devlake.Discover(cfgURL) + if err != nil { + return fmt.Errorf("discovering DevLake: %w", err) + } + client = devlake.NewClient(disc.URL) + } else { + // Verbose discovery for table output + var disc *devlake.DiscoveryResult + client, disc, err = discoverClient(cfgURL) + if err != nil { + return fmt.Errorf("discovering DevLake: %w", err) + } + _ = disc // disc is used by discoverClient for output + } + + // Get the query definition + queryDef, err := query.Get("pipelines") + if err != nil { + return fmt.Errorf("getting pipelines query: %w", err) + } + + // Build parameters + params := map[string]interface{}{ + "limit": queryPipelinesLimit, + } + if queryPipelinesProject != "" { + params["project"] = queryPipelinesProject + } + if queryPipelinesStatus != "" { + params["status"] = queryPipelinesStatus + } + + // Execute the query + engine := query.NewEngine(client) + result, err := engine.Execute(queryDef, params) + if err != nil { + return fmt.Errorf("executing pipelines query: %w", err) + } + + // Cast result to slice of PipelineResult + pipelines, ok := result.([]query.PipelineResult) + if !ok { + return fmt.Errorf("unexpected result type: %T", result) + } + + // Output + if outputJSON || queryPipelinesFormat == "json" { + return printJSON(pipelines) + } + + // Table format + printBanner("DevLake — Pipeline Query") + if len(pipelines) == 0 { + fmt.Println("\n No pipelines found.") + return nil + } + + fmt.Printf("\n Found %d pipeline(s)\n", len(pipelines)) + fmt.Println(" " + strings.Repeat("─", 80)) + fmt.Printf(" %-6s %-15s %-10s %-20s\n", "ID", "STATUS", "TASKS", "FINISHED AT") + fmt.Println(" " + strings.Repeat("─", 80)) + for _, p := range pipelines { + status := p.Status + tasks := fmt.Sprintf("%d/%d", p.FinishedTasks, p.TotalTasks) + finished := p.FinishedAt + if finished == "" { + finished = "(running)" + } + fmt.Printf(" %-6d %-15s %-10s %-20s\n", p.ID, status, tasks, finished) + } + fmt.Println() + + return nil +} diff --git a/cmd/query_pipelines_test.go b/cmd/query_pipelines_test.go index 462fdbd..9bf1182 100644 --- a/cmd/query_pipelines_test.go +++ b/cmd/query_pipelines_test.go @@ -1,174 +1,215 @@ -package cmd - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/DevExpGBB/gh-devlake/internal/devlake" - "github.com/DevExpGBB/gh-devlake/internal/query" -) - -func TestQueryPipelines_InvalidFormat(t *testing.T) { - queryPipelinesFormat = "invalid" - t.Cleanup(func() { queryPipelinesFormat = "json" }) - - err := runQueryPipelines(nil, nil) - if err == nil { - t.Fatal("expected error for invalid --format, got nil") - } - if !strings.Contains(err.Error(), "invalid --format value") { - t.Errorf("unexpected error message: %v", err) - } -} - -func TestQueryPipelines_JSONOutputNoBanner(t *testing.T) { - // Mock DevLake API - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/ping" { - w.WriteHeader(http.StatusOK) - return - } - if r.URL.Path == "/pipelines" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(devlake.PipelineListResponse{ - Pipelines: []devlake.Pipeline{ - { - ID: 123, - Status: "TASK_COMPLETED", - FinishedTasks: 10, - TotalTasks: 10, - }, - }, - Count: 1, - }) - return - } - http.NotFound(w, r) - })) - t.Cleanup(srv.Close) - - // Set URL to mock server - origURL := cfgURL - cfgURL = srv.URL - t.Cleanup(func() { cfgURL = origURL }) - - // Set format to JSON - origFormat := queryPipelinesFormat - queryPipelinesFormat = "json" - t.Cleanup(func() { queryPipelinesFormat = origFormat }) - - // Capture stdout - orig := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe: %v", err) - } - os.Stdout = w - t.Cleanup(func() { os.Stdout = orig }) - - // Run the command - if err := runQueryPipelines(nil, nil); err != nil { - t.Fatalf("runQueryPipelines returned error: %v", err) - } - - w.Close() - var buf bytes.Buffer - buf.ReadFrom(r) - out := buf.String() - - // Verify no discovery banners in output - if strings.Contains(out, "Discovering DevLake") { - t.Error("JSON output should not contain discovery banner") - } - if strings.Contains(out, "🔍") { - t.Error("JSON output should not contain emoji banners") - } - - // Verify valid JSON - trimmed := strings.TrimSpace(out) - var pipelines []query.PipelineResult - if err := json.Unmarshal([]byte(trimmed), &pipelines); err != nil { - t.Fatalf("output is not valid JSON: %v — got: %q", err, out) - } - - if len(pipelines) != 1 { - t.Fatalf("expected 1 pipeline, got %d", len(pipelines)) - } - if pipelines[0].ID != 123 { - t.Errorf("expected ID=123, got %d", pipelines[0].ID) - } - if pipelines[0].Status != "TASK_COMPLETED" { - t.Errorf("expected status=TASK_COMPLETED, got %q", pipelines[0].Status) - } -} - -func TestQueryPipelines_GlobalJSONFlag(t *testing.T) { - // Mock DevLake API - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/ping" { - w.WriteHeader(http.StatusOK) - return - } - if r.URL.Path == "/pipelines" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(devlake.PipelineListResponse{ - Pipelines: []devlake.Pipeline{}, - Count: 0, - }) - return - } - http.NotFound(w, r) - })) - t.Cleanup(srv.Close) - - // Set URL to mock server - origURL := cfgURL - cfgURL = srv.URL - t.Cleanup(func() { cfgURL = origURL }) - - // Set global JSON flag - origJSON := outputJSON - outputJSON = true - t.Cleanup(func() { outputJSON = origJSON }) - - // Set format to table (should be overridden by --json) - origFormat := queryPipelinesFormat - queryPipelinesFormat = "table" - t.Cleanup(func() { queryPipelinesFormat = origFormat }) - - // Capture stdout - orig := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("os.Pipe: %v", err) - } - os.Stdout = w - t.Cleanup(func() { os.Stdout = orig }) - - // Run the command - if err := runQueryPipelines(nil, nil); err != nil { - t.Fatalf("runQueryPipelines returned error: %v", err) - } - - w.Close() - var buf bytes.Buffer - buf.ReadFrom(r) - out := buf.String() - - // Verify no discovery banners in output - if strings.Contains(out, "Discovering DevLake") { - t.Error("JSON output with --json should not contain discovery banner") - } - - // Verify valid JSON - trimmed := strings.TrimSpace(out) - var pipelines []query.PipelineResult - if err := json.Unmarshal([]byte(trimmed), &pipelines); err != nil { - t.Fatalf("output is not valid JSON: %v — got: %q", err, out) - } -} +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" + "github.com/DevExpGBB/gh-devlake/internal/query" +) + +func TestQueryPipelines_InvalidFormat(t *testing.T) { + queryPipelinesFormat = "invalid" + t.Cleanup(func() { queryPipelinesFormat = "json" }) + + err := runQueryPipelines(nil, nil) + if err == nil { + t.Fatal("expected error for invalid --format, got nil") + } + if !strings.Contains(err.Error(), "invalid --format value") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestQueryPipelines_JSONOutputNoBanner(t *testing.T) { + queryPipelinesProject = "my-team" + queryPipelinesStatus = "TASK_COMPLETED" + queryPipelinesLimit = 10 + t.Cleanup(func() { + queryPipelinesProject = "" + queryPipelinesStatus = "" + queryPipelinesLimit = 20 + }) + + // Mock DevLake API + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ping" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/projects/my-team" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(devlake.Project{ + Name: "my-team", + Blueprint: &devlake.Blueprint{ID: 7}, + }) + return + } + if r.URL.Path == "/pipelines" { + q := r.URL.Query() + if got := q.Get("blueprintId"); got != "7" { + t.Fatalf("expected blueprintId=7, got %q", got) + } + if got := q.Get("status"); got != "TASK_COMPLETED" { + t.Fatalf("expected status=TASK_COMPLETED, got %q", got) + } + if got := q.Get("pageSize"); got != "10" { + t.Fatalf("expected pageSize=10, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(devlake.PipelineListResponse{ + Pipelines: []devlake.Pipeline{ + { + ID: 123, + Status: "TASK_COMPLETED", + FinishedTasks: 10, + TotalTasks: 10, + }, + }, + Count: 1, + }) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + + // Set URL to mock server + origURL := cfgURL + cfgURL = srv.URL + t.Cleanup(func() { cfgURL = origURL }) + + // Set format to JSON + origFormat := queryPipelinesFormat + queryPipelinesFormat = "json" + t.Cleanup(func() { queryPipelinesFormat = origFormat }) + + // Capture stdout + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { os.Stdout = orig }) + + // Run the command + if err := runQueryPipelines(nil, nil); err != nil { + t.Fatalf("runQueryPipelines returned error: %v", err) + } + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + out := buf.String() + + // Verify no discovery banners in output + if strings.Contains(out, "Discovering DevLake") { + t.Error("JSON output should not contain discovery banner") + } + if strings.Contains(out, "🔍") { + t.Error("JSON output should not contain emoji banners") + } + + // Verify valid JSON + trimmed := strings.TrimSpace(out) + var pipelines []query.PipelineResult + if err := json.Unmarshal([]byte(trimmed), &pipelines); err != nil { + t.Fatalf("output is not valid JSON: %v — got: %q", err, out) + } + + if len(pipelines) != 1 { + t.Fatalf("expected 1 pipeline, got %d", len(pipelines)) + } + if pipelines[0].ID != 123 { + t.Errorf("expected ID=123, got %d", pipelines[0].ID) + } + if pipelines[0].Status != "TASK_COMPLETED" { + t.Errorf("expected status=TASK_COMPLETED, got %q", pipelines[0].Status) + } +} + +func TestQueryPipelines_GlobalJSONFlag(t *testing.T) { + // Mock DevLake API + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ping" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/pipelines" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(devlake.PipelineListResponse{ + Pipelines: []devlake.Pipeline{}, + Count: 0, + }) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + + // Set URL to mock server + origURL := cfgURL + cfgURL = srv.URL + t.Cleanup(func() { cfgURL = origURL }) + + // Set global JSON flag + origJSON := outputJSON + outputJSON = true + t.Cleanup(func() { outputJSON = origJSON }) + + // Set format to table (should be overridden by --json) + origFormat := queryPipelinesFormat + queryPipelinesFormat = "table" + t.Cleanup(func() { queryPipelinesFormat = origFormat }) + + // Capture stdout + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { os.Stdout = orig }) + + // Run the command + if err := runQueryPipelines(nil, nil); err != nil { + t.Fatalf("runQueryPipelines returned error: %v", err) + } + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + out := buf.String() + + // Verify no discovery banners in output + if strings.Contains(out, "Discovering DevLake") { + t.Error("JSON output with --json should not contain discovery banner") + } + + // Verify valid JSON + trimmed := strings.TrimSpace(out) + var pipelines []query.PipelineResult + if err := json.Unmarshal([]byte(trimmed), &pipelines); err != nil { + t.Fatalf("output is not valid JSON: %v — got: %q", err, out) + } +} + +func TestQueryPipelines_InvalidLimit(t *testing.T) { + origLimit := queryPipelinesLimit + queryPipelinesLimit = 0 + t.Cleanup(func() { queryPipelinesLimit = origLimit }) + + err := runQueryPipelines(nil, nil) + if err == nil { + t.Fatal("expected error for invalid --limit, got nil") + } + if !strings.Contains(err.Error(), "invalid --limit value") { + t.Errorf("unexpected error message: %v", err) + } +} From 50fab2ef9b6eb4b073912acc203bd7b94dddde76 Mon Sep 17 00:00:00 2001 From: Eldrick19 Date: Mon, 16 Mar 2026 14:43:54 -0400 Subject: [PATCH 10/11] refactor: align query commands with Cobra conventions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/query.go | 16 ++-- cmd/query_copilot.go | 141 +++++++++++++++++------------------- cmd/query_dora.go | 139 +++++++++++++++++------------------ cmd/query_pipelines.go | 38 ++++------ cmd/query_pipelines_test.go | 8 +- 5 files changed, 160 insertions(+), 182 deletions(-) diff --git a/cmd/query.go b/cmd/query.go index 953f622..7a4e3d3 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -4,10 +4,11 @@ import ( "github.com/spf13/cobra" ) -var queryCmd = &cobra.Command{ - Use: "query", - Short: "Query DevLake data and metrics", - Long: `Query DevLake's aggregated data and metrics. +func newQueryCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "query", + Short: "Query DevLake data and metrics", + Long: `Query DevLake's aggregated data and metrics. Retrieve pipeline status plus DORA/Copilot query results in structured JSON output. Individual subcommands may provide extra formatting options @@ -17,9 +18,12 @@ Examples: gh devlake query pipelines --project my-team gh devlake query pipelines --limit 20 gh devlake query pipelines --status TASK_COMPLETED`, + } + cmd.GroupID = "operate" + cmd.AddCommand(newQueryPipelinesCmd(), newQueryDoraCmd(), newQueryCopilotCmd()) + return cmd } func init() { - queryCmd.GroupID = "operate" - rootCmd.AddCommand(queryCmd) + rootCmd.AddCommand(newQueryCmd()) } diff --git a/cmd/query_copilot.go b/cmd/query_copilot.go index 0bd5fb0..9c0fb1f 100644 --- a/cmd/query_copilot.go +++ b/cmd/query_copilot.go @@ -1,74 +1,67 @@ -package cmd - -import ( - "fmt" - - "github.com/DevExpGBB/gh-devlake/internal/devlake" - "github.com/DevExpGBB/gh-devlake/internal/query" - "github.com/spf13/cobra" -) - -var ( - queryCopilotProject string - queryCopilotTimeframe string -) - -var queryCopilotCmd = &cobra.Command{ - Use: "copilot", - Short: "Query Copilot usage metrics (limited by available API data)", - Long: `Query GitHub Copilot usage metrics for a project. - -NOTE: GitHub Copilot usage metrics (total seats, active users, acceptance rates, -language breakdowns, editor usage) are stored in _tool_gh_copilot_* tables and -visualized in Grafana dashboards, but DevLake does not expose a /metrics or -/copilot API endpoint. - -This command returns available connection metadata and explains what additional -API endpoints would be needed to retrieve Copilot metrics via CLI. - -Copilot metrics are currently available in Grafana dashboards at your DevLake -Grafana endpoint (shown in 'gh devlake status').`, - RunE: runQueryCopilot, -} - -func init() { - queryCopilotCmd.Flags().StringVar(&queryCopilotProject, "project", "", "Project name (required)") - queryCopilotCmd.Flags().StringVar(&queryCopilotTimeframe, "timeframe", "30d", "Time window for metrics (e.g., 7d, 30d, 90d)") - queryCmd.AddCommand(queryCopilotCmd) -} - -func runQueryCopilot(cmd *cobra.Command, args []string) error { - // Validate project flag - if queryCopilotProject == "" { - return fmt.Errorf("--project flag is required") - } - - // Discover DevLake instance - disc, err := devlake.Discover(cfgURL) - if err != nil { - return fmt.Errorf("discovering DevLake: %w", err) - } - client := devlake.NewClient(disc.URL) - - // Get the query definition - queryDef, err := query.Get("copilot") - if err != nil { - return fmt.Errorf("getting copilot query: %w", err) - } - - // Build parameters - params := map[string]interface{}{ - "project": queryCopilotProject, - "timeframe": queryCopilotTimeframe, - } - - // Execute the query - engine := query.NewEngine(client) - result, err := engine.Execute(queryDef, params) - if err != nil { - return fmt.Errorf("executing copilot query: %w", err) - } - - // Output result as JSON - return printJSON(result) -} +package cmd + +import ( + "fmt" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" + "github.com/DevExpGBB/gh-devlake/internal/query" + "github.com/spf13/cobra" +) + +var ( + queryCopilotProject string + queryCopilotTimeframe string +) + +func newQueryCopilotCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "copilot", + Short: "Query Copilot usage metrics (limited by available API data)", + Long: `Query GitHub Copilot usage metrics for a project. + +NOTE: GitHub Copilot usage metrics (total seats, active users, acceptance rates, +language breakdowns, editor usage) are stored in _tool_gh_copilot_* tables and +visualized in Grafana dashboards, but DevLake does not expose a /metrics or +/copilot API endpoint. + +This command returns available connection metadata and explains what additional +API endpoints would be needed to retrieve Copilot metrics via CLI. + +Copilot metrics are currently available in Grafana dashboards at your DevLake +Grafana endpoint (shown in 'gh devlake status').`, + RunE: runQueryCopilot, + } + cmd.Flags().StringVar(&queryCopilotProject, "project", "", "Project name (required)") + cmd.Flags().StringVar(&queryCopilotTimeframe, "timeframe", "30d", "Time window for metrics (e.g., 7d, 30d, 90d)") + return cmd +} + +func runQueryCopilot(cmd *cobra.Command, args []string) error { + if queryCopilotProject == "" { + return fmt.Errorf("--project flag is required") + } + + disc, err := devlake.Discover(cfgURL) + if err != nil { + return fmt.Errorf("discovering DevLake: %w", err) + } + client := devlake.NewClient(disc.URL) + + queryDef, err := query.Get("copilot") + if err != nil { + return fmt.Errorf("getting copilot query: %w", err) + } + + params := map[string]interface{}{ + "project": queryCopilotProject, + "timeframe": queryCopilotTimeframe, + } + + engine := query.NewEngine(client) + result, err := engine.Execute(queryDef, params) + if err != nil { + return fmt.Errorf("executing copilot query: %w", err) + } + + return printJSON(result) +} diff --git a/cmd/query_dora.go b/cmd/query_dora.go index 0e1cc1c..2a84b32 100644 --- a/cmd/query_dora.go +++ b/cmd/query_dora.go @@ -1,73 +1,66 @@ -package cmd - -import ( - "fmt" - - "github.com/DevExpGBB/gh-devlake/internal/devlake" - "github.com/DevExpGBB/gh-devlake/internal/query" - "github.com/spf13/cobra" -) - -var ( - queryDoraProject string - queryDoraTimeframe string -) - -var queryDoraCmd = &cobra.Command{ - Use: "dora", - Short: "Query DORA metrics (limited by available API data)", - Long: `Query DORA (DevOps Research and Assessment) metrics for a project. - -NOTE: Full DORA metric calculations (deployment frequency, lead time, change -failure rate, MTTR) require SQL queries against DevLake's domain layer tables. -DevLake does not expose database credentials or a metrics API endpoint. - -This command returns project metadata and explains what additional API -endpoints would be needed to compute DORA metrics via CLI. - -DORA metrics are currently available in Grafana dashboards at your DevLake -Grafana endpoint (shown in 'gh devlake status').`, - RunE: runQueryDora, -} - -func init() { - queryDoraCmd.Flags().StringVar(&queryDoraProject, "project", "", "Project name (required)") - queryDoraCmd.Flags().StringVar(&queryDoraTimeframe, "timeframe", "30d", "Time window for metrics (e.g., 7d, 30d, 90d)") - queryCmd.AddCommand(queryDoraCmd) -} - -func runQueryDora(cmd *cobra.Command, args []string) error { - // Validate project flag - if queryDoraProject == "" { - return fmt.Errorf("--project flag is required") - } - - // Discover DevLake instance - disc, err := devlake.Discover(cfgURL) - if err != nil { - return fmt.Errorf("discovering DevLake: %w", err) - } - client := devlake.NewClient(disc.URL) - - // Get the query definition - queryDef, err := query.Get("dora") - if err != nil { - return fmt.Errorf("getting dora query: %w", err) - } - - // Build parameters - params := map[string]interface{}{ - "project": queryDoraProject, - "timeframe": queryDoraTimeframe, - } - - // Execute the query - engine := query.NewEngine(client) - result, err := engine.Execute(queryDef, params) - if err != nil { - return fmt.Errorf("executing dora query: %w", err) - } - - // Output result as JSON - return printJSON(result) -} +package cmd + +import ( + "fmt" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" + "github.com/DevExpGBB/gh-devlake/internal/query" + "github.com/spf13/cobra" +) + +var ( + queryDoraProject string + queryDoraTimeframe string +) + +func newQueryDoraCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dora", + Short: "Query DORA metrics (limited by available API data)", + Long: `Query DORA (DevOps Research and Assessment) metrics for a project. + +NOTE: Full DORA metric calculations (deployment frequency, lead time, change +failure rate, MTTR) require SQL queries against DevLake's domain layer tables. +DevLake does not expose database credentials or a metrics API endpoint. + +This command returns project metadata and explains what additional API +endpoints would be needed to compute DORA metrics via CLI. + +DORA metrics are currently available in Grafana dashboards at your DevLake +Grafana endpoint (shown in 'gh devlake status').`, + RunE: runQueryDora, + } + cmd.Flags().StringVar(&queryDoraProject, "project", "", "Project name (required)") + cmd.Flags().StringVar(&queryDoraTimeframe, "timeframe", "30d", "Time window for metrics (e.g., 7d, 30d, 90d)") + return cmd +} + +func runQueryDora(cmd *cobra.Command, args []string) error { + if queryDoraProject == "" { + return fmt.Errorf("--project flag is required") + } + + disc, err := devlake.Discover(cfgURL) + if err != nil { + return fmt.Errorf("discovering DevLake: %w", err) + } + client := devlake.NewClient(disc.URL) + + queryDef, err := query.Get("dora") + if err != nil { + return fmt.Errorf("getting dora query: %w", err) + } + + params := map[string]interface{}{ + "project": queryDoraProject, + "timeframe": queryDoraTimeframe, + } + + engine := query.NewEngine(client) + result, err := engine.Execute(queryDef, params) + if err != nil { + return fmt.Errorf("executing dora query: %w", err) + } + + return printJSON(result) +} diff --git a/cmd/query_pipelines.go b/cmd/query_pipelines.go index 61397b2..2c7c78e 100644 --- a/cmd/query_pipelines.go +++ b/cmd/query_pipelines.go @@ -16,10 +16,11 @@ var ( queryPipelinesFormat string ) -var queryPipelinesCmd = &cobra.Command{ - Use: "pipelines", - Short: "Query recent pipeline runs", - Long: `Query recent pipeline runs for a project or across all projects. +func newQueryPipelinesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "pipelines", + Short: "Query recent pipeline runs", + Long: `Query recent pipeline runs for a project or across all projects. Retrieves pipeline execution history with status, timing, and task completion information. Output is JSON by default; use --format table for human-readable display. @@ -29,19 +30,16 @@ Examples: gh devlake query pipelines --project my-team gh devlake query pipelines --status TASK_COMPLETED --limit 10 gh devlake query pipelines --format table`, - RunE: runQueryPipelines, -} - -func init() { - queryPipelinesCmd.Flags().StringVar(&queryPipelinesProject, "project", "", "Filter by project name") - queryPipelinesCmd.Flags().StringVar(&queryPipelinesStatus, "status", "", "Filter by status (TASK_CREATED, TASK_RUNNING, TASK_COMPLETED, TASK_FAILED)") - queryPipelinesCmd.Flags().IntVar(&queryPipelinesLimit, "limit", 20, "Maximum number of pipelines to return") - queryPipelinesCmd.Flags().StringVar(&queryPipelinesFormat, "format", "json", "Output format (json or table)") - queryCmd.AddCommand(queryPipelinesCmd) + RunE: runQueryPipelines, + } + cmd.Flags().StringVar(&queryPipelinesProject, "project", "", "Filter by project name") + cmd.Flags().StringVar(&queryPipelinesStatus, "status", "", "Filter by status (TASK_CREATED, TASK_RUNNING, TASK_COMPLETED, TASK_FAILED)") + cmd.Flags().IntVar(&queryPipelinesLimit, "limit", 20, "Maximum number of pipelines to return") + cmd.Flags().StringVar(&queryPipelinesFormat, "format", "json", "Output format (json or table)") + return cmd } func runQueryPipelines(cmd *cobra.Command, args []string) error { - // Validate format flag if queryPipelinesFormat != "json" && queryPipelinesFormat != "table" { return fmt.Errorf("invalid --format value %q: must be 'json' or 'table'", queryPipelinesFormat) } @@ -49,35 +47,29 @@ func runQueryPipelines(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid --limit value %d: must be >= 1", queryPipelinesLimit) } - // Discover DevLake instance var client *devlake.Client var err error - // Use quiet discovery for JSON output, verbose for table if outputJSON || queryPipelinesFormat == "json" { - // Quiet discovery for JSON output disc, err := devlake.Discover(cfgURL) if err != nil { return fmt.Errorf("discovering DevLake: %w", err) } client = devlake.NewClient(disc.URL) } else { - // Verbose discovery for table output var disc *devlake.DiscoveryResult client, disc, err = discoverClient(cfgURL) if err != nil { return fmt.Errorf("discovering DevLake: %w", err) } - _ = disc // disc is used by discoverClient for output + _ = disc } - // Get the query definition queryDef, err := query.Get("pipelines") if err != nil { return fmt.Errorf("getting pipelines query: %w", err) } - // Build parameters params := map[string]interface{}{ "limit": queryPipelinesLimit, } @@ -88,25 +80,21 @@ func runQueryPipelines(cmd *cobra.Command, args []string) error { params["status"] = queryPipelinesStatus } - // Execute the query engine := query.NewEngine(client) result, err := engine.Execute(queryDef, params) if err != nil { return fmt.Errorf("executing pipelines query: %w", err) } - // Cast result to slice of PipelineResult pipelines, ok := result.([]query.PipelineResult) if !ok { return fmt.Errorf("unexpected result type: %T", result) } - // Output if outputJSON || queryPipelinesFormat == "json" { return printJSON(pipelines) } - // Table format printBanner("DevLake — Pipeline Query") if len(pipelines) == 0 { fmt.Println("\n No pipelines found.") diff --git a/cmd/query_pipelines_test.go b/cmd/query_pipelines_test.go index 9bf1182..a86641a 100644 --- a/cmd/query_pipelines_test.go +++ b/cmd/query_pipelines_test.go @@ -52,14 +52,14 @@ func TestQueryPipelines_JSONOutputNoBanner(t *testing.T) { } if r.URL.Path == "/pipelines" { q := r.URL.Query() - if got := q.Get("blueprintId"); got != "7" { - t.Fatalf("expected blueprintId=7, got %q", got) + if got := q.Get("blueprint_id"); got != "7" { + t.Fatalf("expected blueprint_id=7, got %q", got) } if got := q.Get("status"); got != "TASK_COMPLETED" { t.Fatalf("expected status=TASK_COMPLETED, got %q", got) } - if got := q.Get("pageSize"); got != "10" { - t.Fatalf("expected pageSize=10, got %q", got) + if got := q.Get("pagesize"); got != "10" { + t.Fatalf("expected pagesize=10, got %q", got) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(devlake.PipelineListResponse{ From 63a5ffa007af54216302681e92a4d27485065ab6 Mon Sep 17 00:00:00 2001 From: Eldrick19 Date: Mon, 16 Mar 2026 20:11:38 -0400 Subject: [PATCH 11/11] fix: resolve README rebase conflict --- README.md | 300 +----------------------------------------------------- 1 file changed, 5 insertions(+), 295 deletions(-) diff --git a/README.md b/README.md index d92f2d1..1304623 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -<<<<<<< HEAD # gh-devlake A [GitHub CLI extension](https://cli.github.com/manual/gh_extension) that deploys, configures, and monitors [Apache DevLake](https://github.com/apache/incubator-devlake) from the terminal. @@ -11,6 +10,9 @@ This CLI makes that setup fast and repeatable from the terminal (instead of clic > **Blog post:** [Beyond Copilot Dashboards: Measuring What AI Actually Changes]() — why DORA + Copilot correlation matters and what this tool enables. +> [!NOTE] +> **GitHub Copilot plugin:** The Copilot metrics plugin is currently available in the [DevExpGBB/incubator-devlake](https://github.com/DevExpGBB/incubator-devlake) fork while the upstream PR ([apache/incubator-devlake#8728](https://github.com/apache/incubator-devlake/pull/8728)) is under review. The `deploy local` and `deploy azure` commands handle custom images automatically — no manual image builds needed. Once the PR merges, the official Apache images will include it. + --- @@ -223,8 +225,8 @@ See [Token Handling](docs/token-handling.md) for env key names and multi-plugin | `gh devlake configure project delete` | Delete a project | [configure-project.md](docs/configure-project.md) | | `gh devlake configure full` | Connections + scopes + project in one step | [configure-full.md](docs/configure-full.md) | | `gh devlake query pipelines` | Query recent pipeline runs | [query.md](docs/query.md) | -| `gh devlake query dora` | Query DORA metrics (placeholder — requires API) | [query.md](docs/query.md) | -| `gh devlake query copilot` | Query Copilot metrics (placeholder — requires API) | [query.md](docs/query.md) | +| `gh devlake query dora` | Query DORA metadata now; full metrics remain API/DB limited | [query.md](docs/query.md) | +| `gh devlake query copilot` | Query Copilot metadata now; full metrics remain API/DB limited | [query.md](docs/query.md) | | `gh devlake start` | Start stopped or exited DevLake services | [start.md](docs/start.md) | | `gh devlake stop` | Stop running services (preserves containers and data) | [stop.md](docs/stop.md) | | `gh devlake cleanup` | Tear down local or Azure resources | [cleanup.md](docs/cleanup.md) | @@ -283,295 +285,3 @@ gh extension install . # Install locally for testing ## License MIT — see [LICENSE](LICENSE). -======= -# gh-devlake - -A [GitHub CLI extension](https://cli.github.com/manual/gh_extension) that deploys, configures, and monitors [Apache DevLake](https://github.com/apache/incubator-devlake) from the terminal. - -Deploy DevLake locally or on Azure, create connections to GitHub and Copilot, configure DORA project scopes, and trigger data syncs — all without touching the Config UI. - -DevLake is an open-source dev data platform that normalizes data from DevOps tools so you can compute consistent engineering metrics like DORA. - -This CLI makes that setup fast and repeatable from the terminal (instead of clicking through the Config UI) — especially when you want to re-run the same configuration across teams. - -> **Blog post:** [Beyond Copilot Dashboards: Measuring What AI Actually Changes]() — why DORA + Copilot correlation matters and what this tool enables. - -> [!NOTE] -> **GitHub Copilot plugin:** The Copilot metrics plugin is currently available in the [DevExpGBB/incubator-devlake](https://github.com/DevExpGBB/incubator-devlake) fork while the upstream PR ([apache/incubator-devlake#8728](https://github.com/apache/incubator-devlake/pull/8728)) is under review. The `deploy local` and `deploy azure` commands handle custom images automatically — no manual image builds needed. Once the PR merges, the official Apache images will include it. - - - ---- - -## Prerequisites - -| Requirement | When needed | -|-------------|-------------| -| [GitHub CLI](https://cli.github.com/) (`gh`) | Always — this is a `gh` extension | -| [Docker](https://docs.docker.com/get-docker/) | `deploy local`, `cleanup --local` | -| [Azure CLI](https://learn.microsoft.com/cli/azure/) (`az`) | `deploy azure` only | -| GitHub PAT | Required when creating connections (see Supported Plugins) | - -
-Building from source - -Requires [Go 1.22+](https://go.dev/). - -```bash -git clone https://github.com/DevExpGBB/gh-devlake.git -cd gh-devlake -go build -o gh-devlake.exe . # Windows -go build -o gh-devlake . # Linux/macOS -gh extension install . -``` - -
- ---- - -## Quick Start - -```bash -gh extension install DevExpGBB/gh-devlake - -# Option 1: Fully guided wizard (deploy → connect → scope → project) -gh devlake init - -# Option 2: Step-by-step -gh devlake deploy local --dir ./devlake -cd devlake && docker compose up -d -# wait ~2 minutes, then: -gh devlake configure full -``` - -After setup, open Grafana at **http://localhost:3002** (admin / admin). DORA and Copilot dashboards will populate after the first sync completes. - -| Service | URL | -|---------|-----| -| Grafana | http://localhost:3002 (admin/admin) | -| Config UI | http://localhost:4000 | -| Backend API | http://localhost:8080 | - ---- - -## How DevLake Works - -Four concepts to understand — then every command makes sense: - -| Concept | What It Is | -|---------|-----------| -| **Connection** | An authenticated link to a data source (GitHub, Copilot, Jenkins). Each gets its own PAT/credentials. | -| **Scope** | *What* to collect — specific repos for GitHub, an org/enterprise for Copilot, jobs for Jenkins. | -| **Project** | Groups connections + scopes into a single view with DORA metrics enabled. | -| **Blueprint** | The sync schedule (cron). Created automatically with the project. | - - - -For a deeper explanation with diagrams, see [DevLake Concepts](docs/concepts.md). - ---- - -## Getting Started Step by Step - -### Step 1: Deploy - -```bash -gh devlake deploy local --dir ./devlake -cd devlake && docker compose up -d -``` - -Downloads Docker Compose files, generates secrets, and prepares the stack. Give it ~2 minutes after `docker compose up`. See [docs/deploy.md](docs/deploy.md) for flags and details. - -
-Deploying to Azure instead - -```bash -gh devlake deploy azure --resource-group devlake-rg --location eastus --official -``` - -Creates Container Instances, MySQL Flexible Server, and Key Vault via Bicep (~$30–50/month with `--official`). Omit flags to be prompted interactively. - -See [docs/deploy.md](docs/deploy.md) for all Azure options, custom image builds, and tear-down. - -
- -### Step 2: Create Connections - -The CLI will prompt you for your PAT. You can also pass `--token`, use an `--env-file`, or set `GITHUB_TOKEN` in your environment. See [Token Handling](docs/token-handling.md) for the full resolution chain. - -```bash -# GitHub (repos, PRs, workflows, deployments) -gh devlake configure connection add --plugin github --org my-org - -# Copilot (usage metrics, seats, acceptance rates) -gh devlake configure connection add --plugin gh-copilot --org my-org - -# Jenkins (jobs and build data for DORA) -gh devlake configure connection add --plugin jenkins --endpoint https://jenkins.example.com --username admin --token myapitoken -``` - -The CLI tests each connection before saving. On success: - -``` - 🔑 Testing connection... - ✅ Connection test passed - ✅ Created GitHub connection (ID=1) -``` - -See [docs/configure-connection.md](docs/configure-connection.md) for all flags. - -### Step 3: Add Scopes - -Tell DevLake which repos or orgs to collect from: - -```bash -# GitHub — pick repos interactively, or pass --repos explicitly -gh devlake configure scope add --plugin github --org my-org - -# Copilot — org-level metrics -gh devlake configure scope add --plugin gh-copilot --org my-org - -# Jenkins — pick jobs interactively (or pass --jobs) -gh devlake configure scope add --plugin jenkins --org my-org -``` - -DORA patterns (deployment workflow, production environment, incident label) use sensible defaults. See [docs/configure-scope.md](docs/configure-scope.md) for overrides. - -### Step 4: Create a Project and Sync - -```bash -gh devlake configure project add -``` - -Discovers your connections and scopes, creates a DevLake project with DORA metrics enabled, sets up a daily sync blueprint, and triggers the first data collection. - -See [docs/configure-project.md](docs/configure-project.md) for flags (`--project-name`, `--cron`, `--time-after`, `--skip-sync`). - - - -> **Shortcut:** `gh devlake configure full` chains Steps 2–4 interactively. See [docs/configure-full.md](docs/configure-full.md). - ---- - -## Day-2 Operations - -
-Status checks, token rotation, adding repos, and tear-down - -```bash -gh devlake status # health + summary -gh devlake configure connection list # list connections -gh devlake configure connection update --plugin github --id 1 --token ghp_new # rotate token -gh devlake configure scope add --plugin github --org my-org # add more repos -gh devlake cleanup --local # tear down Docker -``` - -For the full guide, see [Day-2 Operations](docs/day-2.md). - -
- ---- - -## Supported Plugins - -| Plugin | Status | What It Collects | Required PAT scopes | -|--------|--------|------------------|---------------------| -| GitHub | ✅ Available | Repos, PRs, issues, workflows, deployments (DORA) | `repo`, `read:org`, `read:user` | -| GitHub Copilot | ✅ Available | Usage metrics, seats, acceptance rates | `manage_billing:copilot`, `read:org` (+ `read:enterprise` for enterprise metrics) | -| Jenkins | ✅ Available | Jobs, builds, deployments (DORA) | Username + API token/password | -| Jira | ✅ Available | Boards, issues, sprints (change lead time, cycle time) | API token (permissions from user account) | -| GitLab | ✅ Available | Repos, MRs, pipelines, deployments (DORA) | `read_api`, `read_repository` | -| Bitbucket Cloud | ✅ Available | Repos, PRs, commits | Bitbucket username + app password | -| SonarQube | ✅ Available | Code quality, coverage, code smells (quality gates) | API token (permissions from user account) | -| Azure DevOps | ✅ Available | Repos, pipelines, deployments (DORA) | PAT with repo and pipeline access | -| ArgoCD | ✅ Available | GitOps deployments, deployment frequency (DORA) | ArgoCD auth token | - -See [Token Handling](docs/token-handling.md) for env key names and multi-plugin `.devlake.env` examples. - ---- - -## Command Reference - -| Command | Description | Docs | -|---------|-------------|------| -| `gh devlake init` | Guided 4-phase setup wizard | [init.md](docs/init.md) | -| `gh devlake status` | Health check and connection summary | [status.md](docs/status.md) | -| `gh devlake deploy local` | Local Docker Compose deploy | [deploy.md](docs/deploy.md) | -| `gh devlake deploy azure` | Azure Container Instance deploy | [deploy.md](docs/deploy.md) | -| `gh devlake configure connection` | Manage plugin connections (subcommands below) | [configure-connection.md](docs/configure-connection.md) | -| `gh devlake configure connection add` | Create a new plugin connection | [configure-connection.md](docs/configure-connection.md) | -| `gh devlake configure connection list` | List all connections | [configure-connection.md](docs/configure-connection.md) | -| `gh devlake configure connection test` | Test a saved connection | [configure-connection.md](docs/configure-connection.md) | -| `gh devlake configure connection update` | Rotate token or update settings | [configure-connection.md](docs/configure-connection.md) | -| `gh devlake configure connection delete` | Remove a connection | [configure-connection.md](docs/configure-connection.md) | -| `gh devlake configure scope` | Manage scopes on connections (subcommands below) | [configure-scope.md](docs/configure-scope.md) | -| `gh devlake configure scope add` | Add repo/org scopes to a connection | [configure-scope.md](docs/configure-scope.md) | -| `gh devlake configure scope list` | List scopes on a connection | [configure-scope.md](docs/configure-scope.md) | -| `gh devlake configure scope delete` | Remove a scope from a connection | [configure-scope.md](docs/configure-scope.md) | -| `gh devlake configure project` | Manage DevLake projects (subcommands below) | [configure-project.md](docs/configure-project.md) | -| `gh devlake configure project add` | Create a project + blueprint + first sync | [configure-project.md](docs/configure-project.md) | -| `gh devlake configure project list` | List all projects | [configure-project.md](docs/configure-project.md) | -| `gh devlake configure project delete` | Delete a project | [configure-project.md](docs/configure-project.md) | -| `gh devlake configure full` | Connections + scopes + project in one step | [configure-full.md](docs/configure-full.md) | -| `gh devlake query pipelines` | Query recent pipeline runs | [query.md](docs/query.md) | -| `gh devlake query dora` | Query DORA metadata now; full metrics remain API/DB limited | [query.md](docs/query.md) | -| `gh devlake query copilot` | Query Copilot metadata now; full metrics remain API/DB limited | [query.md](docs/query.md) | -| `gh devlake start` | Start stopped or exited DevLake services | [start.md](docs/start.md) | -| `gh devlake stop` | Stop running services (preserves containers and data) | [stop.md](docs/stop.md) | -| `gh devlake cleanup` | Tear down local or Azure resources | [cleanup.md](docs/cleanup.md) | - -### Global Flags - -| Flag | Description | -|------|-------------| -| `--url ` | DevLake API base URL (auto-discovered if omitted) | -| `--json` | Output as JSON — suppresses banners and interactive prompts. Useful for scripting and agent consumption. | - -#### `--json` output - -Read commands emit a single compact JSON line on success, or `{"error":""}` on failure. No emoji, banners, or interactive prompts are printed. - -```bash -# Human (default) -$ gh devlake status - -════════════════════════════════════════ - DevLake Status -════════════════════════════════════════ - ... - -# Machine-readable -$ gh devlake status --json -{"deployment":{"method":"local","stateFile":".devlake-local.json"},"endpoints":[{"name":"backend","url":"http://localhost:8080","healthy":true}],"connections":[{"plugin":"github","id":1,"name":"GitHub - my-org","organization":"my-org"}],"project":{"name":"my-project","blueprintId":1}} - -# Pipe into jq -$ gh devlake configure connection list --json | jq '.[].name' -"GitHub - my-org" -``` - -Commands that support `--json`: - -| Command | JSON shape | -|---------|-----------| -| `gh devlake status` | `{deployment, endpoints[], connections[], project}` | -| `gh devlake configure connection list` | `[{id, plugin, name, endpoint, organization, enterprise}]` | -| `gh devlake configure scope list` | `[{id, name, fullName}]` | -| `gh devlake configure project list` | `[{name, description, blueprintId}]` | - -Additional references: [Token Handling](docs/token-handling.md) · [State Files](docs/state-files.md) · [DevLake Concepts](docs/concepts.md) · [Day-2 Operations](docs/day-2.md) - ---- - -## Development - -```bash -go build -o gh-devlake.exe . # Build (Windows) -go build -o gh-devlake . # Build (Linux/macOS) -go test ./... -v # Run tests -gh extension install . # Install locally for testing -``` - -## License - -MIT — see [LICENSE](LICENSE). ->>>>>>> de1a4ee (fix: address Copilot review follow-ups on query PR)