diff --git a/.github/skills/devlake-dev-architecture/SKILL.md b/.github/skills/devlake-dev-architecture/SKILL.md index 205421a..f973624 100644 --- a/.github/skills/devlake-dev-architecture/SKILL.md +++ b/.github/skills/devlake-dev-architecture/SKILL.md @@ -36,7 +36,10 @@ gh devlake │ │ ├── add # Add repo/org scopes to a connection │ │ ├── list # List scopes on a connection │ │ └── delete # Remove a scope from a connection -│ └── project # Create project + blueprint + trigger sync +│ └── project # Manage DevLake projects +│ ├── add # Create project + blueprint + trigger sync +│ ├── list # List all projects +│ └── delete # Delete a project ├── status # Health check + connection summary └── cleanup # Tear down (local or Azure) ``` diff --git a/.github/skills/devlake-dev-planning/SKILL.md b/.github/skills/devlake-dev-planning/SKILL.md index cc0f6d0..f2606fb 100644 --- a/.github/skills/devlake-dev-planning/SKILL.md +++ b/.github/skills/devlake-dev-planning/SKILL.md @@ -52,15 +52,7 @@ Semantic versioning: `MAJOR.MINOR.PATCH` ## Current Release Plan -| Version | Theme | Status | -|---------|-------|--------| -| v0.3.3 | Enterprise Support | Shipped — scope ID fix, connection testing, rate limit, enterprise threading | -| v0.3.4 | CLI Restructure | Shipped — singular commands, --plugin flag, list command | -| v0.3.5 | Connection Lifecycle | Shipped — delete, test, and update commands | -| v0.3.6 | Skills & Polish | Shipped — roadmap skill, skill rename/consolidation | -| v0.4.0 | Multi-Tool Expansion | Future — GitLab, Azure DevOps, per-plugin token chains | - -> **Note:** Always query GitHub milestones for the latest status — this table is a snapshot. +> **Note:** Always query GitHub milestones, current and upcoming releases, and issues for the latest status — this table is a snapshot. ## CLI Command Architecture (Option A) diff --git a/AGENTS.md b/AGENTS.md index 900c69e..678906c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,31 @@ internal/ - **Discovery chain**: explicit `--url` → state file → well-known ports - **Generic API helpers**: `doPost[T]`, `doGet[T]`, `doPut[T]`, `doPatch[T]` in `internal/devlake/client.go` +### Command Tree + +``` +gh devlake +├── init # Interactive wizard (deploy + configure full) +├── deploy +│ ├── local # Docker Compose on this machine +│ └── azure # Azure Container Apps +├── configure +│ ├── full # connection + scope + project in one session +│ ├── connection # Manage plugin connections (CRUD) +│ │ ├── add # Create a new connection +│ │ ├── list # List all connections +│ │ ├── update # Update token/settings +│ │ ├── delete # Remove a connection +│ │ └── test # Test a saved connection +│ ├── scope # Add scopes to existing connections +│ └── project # Manage DevLake projects +│ ├── add # Create project + blueprint + trigger sync +│ ├── list # List all projects +│ └── delete # Delete a project +├── status # Health check + connection summary +└── cleanup # Tear down (local or Azure) +``` + ### Plugin System Plugins are defined via `ConnectionDef` structs in `cmd/connection_types.go`. Each entry declares the plugin slug, endpoint, rate limits, prompt labels, and PAT resolution keys. To add a new DevOps tool, add a `ConnectionDef` to `connectionRegistry` — token resolution, org prompts, and connection creation all derive from these fields automatically. See the `devlake-dev-integration` skill for full details. diff --git a/README.md b/README.md index 5f5ca49..7e94950 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ DORA patterns (deployment workflow, production environment, incident label) use ### Step 4: Create a Project and Sync ```bash -gh devlake configure project +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. @@ -211,7 +211,10 @@ See [Token Handling](docs/token-handling.md) for env key names and multi-plugin | `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` | Create project + blueprint + first sync | [configure-project.md](docs/configure-project.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 cleanup` | Tear down local or Azure resources | [cleanup.md](docs/cleanup.md) | diff --git a/cmd/configure_full.go b/cmd/configure_full.go index 727eb6b..3b356bb 100644 --- a/cmd/configure_full.go +++ b/cmd/configure_full.go @@ -26,7 +26,7 @@ Equivalent to 'gh devlake init' but skips the deploy phase. For scripted/CI use, chain individual commands instead: gh devlake configure connection add --plugin github --org my-org gh devlake configure scope --plugin github --org my-org --repos owner/repo1 - gh devlake configure project --project-name my-project`, + gh devlake configure project add --project-name my-project`, RunE: runConfigureFull, } diff --git a/cmd/configure_project_add.go b/cmd/configure_project_add.go new file mode 100644 index 0000000..f135def --- /dev/null +++ b/cmd/configure_project_add.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "time" + + "github.com/spf13/cobra" +) + +func newProjectAddCmd() *cobra.Command { + var opts ProjectOpts + cmd := &cobra.Command{ + Use: "add", + Short: "Create a DevLake project and start data collection", + Long: `Creates a DevLake project that groups data from your connections. + +A project ties together existing scopes (repos, orgs) from your connections +into a single view with DORA metrics. It creates a sync schedule (blueprint) +that collects data on a cron schedule (daily by default). + +Prerequisites: run 'gh devlake configure scope' first to add scopes. + +This command will: + 1. Discover existing scopes on your connections + 2. Let you choose which scopes to include + 3. Create the project with DORA metrics enabled + 4. Configure a sync blueprint + 5. Trigger the first data collection + +Example: + gh devlake configure project add + gh devlake configure project add --project-name my-team`, + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectAdd(cmd, args, &opts) + }, + } + + cmd.Flags().StringVar(&opts.ProjectName, "project-name", "", "DevLake project name") + cmd.Flags().StringVar(&opts.TimeAfter, "time-after", "", "Only collect data after this date (default: 6 months ago)") + cmd.Flags().StringVar(&opts.Cron, "cron", "0 0 * * *", "Blueprint cron schedule") + cmd.Flags().BoolVar(&opts.SkipSync, "skip-sync", false, "Skip triggering the first data sync") + cmd.Flags().BoolVar(&opts.Wait, "wait", true, "Wait for pipeline to complete") + cmd.Flags().DurationVar(&opts.Timeout, "timeout", 5*time.Minute, "Max time to wait for pipeline") + + return cmd +} + +func runProjectAdd(cmd *cobra.Command, args []string, opts *ProjectOpts) error { + return runConfigureProjects(cmd, args, opts) +} diff --git a/cmd/configure_project_delete.go b/cmd/configure_project_delete.go new file mode 100644 index 0000000..9135582 --- /dev/null +++ b/cmd/configure_project_delete.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/DevExpGBB/gh-devlake/internal/prompt" +) + +func newProjectDeleteCmd() *cobra.Command { + var projectDeleteName string + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a DevLake project", + Long: `Deletes a DevLake project by name. + +If --name is not specified, prompts interactively. + +⚠️ Deleting a project also removes its blueprint and sync schedule. + +Examples: + gh devlake configure project delete + gh devlake configure project delete --name my-project`, + RunE: func(cmd *cobra.Command, args []string) error { + return runProjectDelete(cmd, args, projectDeleteName) + }, + } + cmd.Flags().StringVar(&projectDeleteName, "name", "", "Name of the project to delete") + return cmd +} + +func runProjectDelete(cmd *cobra.Command, args []string, projectDeleteName string) error { + printBanner("DevLake — Delete Project") + + // ── Discover DevLake ── + client, _, err := discoverClient(cfgURL) + if err != nil { + return err + } + + // ── Resolve project name ── + name := projectDeleteName + if name == "" { + // Interactive: list projects and let the user pick one + projects, err := client.ListProjects() + if err != nil { + return fmt.Errorf("listing projects: %w", err) + } + if len(projects) == 0 { + fmt.Println("\n No projects found.") + fmt.Println() + return nil + } + + labels := make([]string, len(projects)) + for i, p := range projects { + labels[i] = p.Name + } + + fmt.Println() + chosen := prompt.Select("Select a project to delete", labels) + if chosen == "" { + fmt.Println("\n Deletion cancelled.") + fmt.Println() + return nil + } + name = chosen + } + + // ── Confirm deletion ── + fmt.Printf("\n⚠️ This will delete project %q.\n", name) + fmt.Println(" The associated blueprint and sync schedule will also be removed.") + fmt.Println() + if !prompt.Confirm("Are you sure you want to delete this project?") { + fmt.Println("\n Deletion cancelled.") + fmt.Println() + return nil + } + + // ── Delete project ── + fmt.Printf("\n🗑️ Deleting project %q...\n", name) + if err := client.DeleteProject(name); err != nil { + return fmt.Errorf("failed to delete project: %w", err) + } + fmt.Println(" ✅ Project deleted") + + fmt.Println("\n" + strings.Repeat("─", 50)) + fmt.Printf("✅ Project %q deleted\n", name) + fmt.Println(strings.Repeat("─", 50)) + fmt.Println() + + return nil +} diff --git a/cmd/configure_project_list.go b/cmd/configure_project_list.go new file mode 100644 index 0000000..89ed067 --- /dev/null +++ b/cmd/configure_project_list.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/DevExpGBB/gh-devlake/internal/devlake" +) + +func newProjectListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all DevLake projects", + Long: `Lists all DevLake projects. + +Example: + gh devlake configure project list`, + RunE: runProjectList, + } + return cmd +} + +// projectListItem is the JSON representation of a single project entry. +type projectListItem struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + BlueprintID int `json:"blueprintId,omitempty"` +} + +func runProjectList(cmd *cobra.Command, args []string) error { + // ── Discover DevLake ── + var client *devlake.Client + if outputJSON { + disc, err := devlake.Discover(cfgURL) + if err != nil { + return err + } + client = devlake.NewClient(disc.URL) + } else { + c, _, err := discoverClient(cfgURL) + if err != nil { + return err + } + client = c + } + + // ── Fetch projects ── + projects, err := client.ListProjects() + if err != nil { + return fmt.Errorf("listing projects: %w", err) + } + + // ── JSON output path ── + if outputJSON { + items := make([]projectListItem, len(projects)) + for i, p := range projects { + item := projectListItem{ + Name: p.Name, + Description: p.Description, + } + if p.Blueprint != nil { + item.BlueprintID = p.Blueprint.ID + } + items[i] = item + } + return printJSON(items) + } + + // ── Render table ── + printBanner("DevLake — List Projects") + fmt.Println() + if len(projects) == 0 { + fmt.Println(" No projects found.") + fmt.Println() + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "Name\tDescription\tBlueprint ID") + fmt.Fprintln(w, strings.Repeat("─", 30)+"\t"+strings.Repeat("─", 40)+"\t"+strings.Repeat("─", 12)) + for _, p := range projects { + blueprintID := "" + if p.Blueprint != nil { + blueprintID = fmt.Sprintf("%d", p.Blueprint.ID) + } + desc := p.Description + if len(desc) > 40 { + desc = desc[:37] + "..." + } + fmt.Fprintf(w, "%s\t%s\t%s\n", p.Name, desc, blueprintID) + } + w.Flush() + fmt.Println() + + return nil +} diff --git a/cmd/configure_project_test.go b/cmd/configure_project_test.go new file mode 100644 index 0000000..c10822b --- /dev/null +++ b/cmd/configure_project_test.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "testing" +) + +func TestProjectAddCmd_Registered(t *testing.T) { + cmd := newConfigureProjectsCmd() + found := false + for _, sub := range cmd.Commands() { + if sub.Use == "add" { + found = true + break + } + } + if !found { + t.Error("'add' subcommand not registered under project command") + } +} + +func TestProjectListCmd_Registered(t *testing.T) { + cmd := newConfigureProjectsCmd() + found := false + for _, sub := range cmd.Commands() { + if sub.Use == "list" { + found = true + break + } + } + if !found { + t.Error("'list' subcommand not registered under project command") + } +} + +func TestProjectDeleteCmd_Registered(t *testing.T) { + cmd := newConfigureProjectsCmd() + found := false + for _, sub := range cmd.Commands() { + if sub.Use == "delete" { + found = true + break + } + } + if !found { + t.Error("'delete' subcommand not registered under project command") + } +} + +func TestProjectCmd_NoRunE(t *testing.T) { + cmd := newConfigureProjectsCmd() + if cmd.RunE != nil { + t.Error("project parent command should not have RunE — it should only print help") + } + if cmd.Run != nil { + t.Error("project parent command should not have Run — it should only print help") + } +} + +func TestNewProjectAddCmd_Flags(t *testing.T) { + cmd := newProjectAddCmd() + if cmd.Use != "add" { + t.Errorf("expected Use %q, got %q", "add", cmd.Use) + } + flags := []string{"project-name", "time-after", "cron", "skip-sync", "wait", "timeout"} + for _, f := range flags { + if cmd.Flags().Lookup(f) == nil { + t.Errorf("expected flag --%s to be registered on project add cmd", f) + } + } +} + +func TestNewProjectDeleteCmd_Flags(t *testing.T) { + cmd := newProjectDeleteCmd() + if cmd.Use != "delete" { + t.Errorf("expected Use %q, got %q", "delete", cmd.Use) + } + if cmd.Flags().Lookup("name") == nil { + t.Error("expected flag --name to be registered on project delete cmd") + } +} + +func TestNewProjectListCmd_NoFlags(t *testing.T) { + cmd := newProjectListCmd() + if cmd.Use != "list" { + t.Errorf("expected Use %q, got %q", "list", cmd.Use) + } +} diff --git a/cmd/configure_projects.go b/cmd/configure_projects.go index 21bbef8..23b1944 100644 --- a/cmd/configure_projects.go +++ b/cmd/configure_projects.go @@ -27,40 +27,16 @@ type ProjectOpts struct { } func newConfigureProjectsCmd() *cobra.Command { - var opts ProjectOpts cmd := &cobra.Command{ Use: "project", Aliases: []string{"projects"}, - Short: "Create a DevLake project and start data collection", - Long: `Creates a DevLake project that groups data from your connections. - -A project ties together existing scopes (repos, orgs) from your connections -into a single view with DORA metrics. It creates a sync schedule (blueprint) -that collects data on a cron schedule (daily by default). - -Prerequisites: run 'gh devlake configure scope' first to add scopes. - -This command will: - 1. Discover existing scopes on your connections - 2. Let you choose which scopes to include - 3. Create the project with DORA metrics enabled - 4. Configure a sync blueprint - 5. Trigger the first data collection - -Example: - gh devlake configure project - gh devlake configure project --project-name my-team`, - RunE: func(cmd *cobra.Command, args []string) error { - return runConfigureProjects(cmd, args, &opts) - }, + Short: "Manage DevLake projects", + Long: `Manage DevLake projects. + +Use subcommands to add, list, or delete projects.`, } - cmd.Flags().StringVar(&opts.ProjectName, "project-name", "", "DevLake project name") - cmd.Flags().StringVar(&opts.TimeAfter, "time-after", "", "Only collect data after this date (default: 6 months ago)") - cmd.Flags().StringVar(&opts.Cron, "cron", "0 0 * * *", "Blueprint cron schedule") - cmd.Flags().BoolVar(&opts.SkipSync, "skip-sync", false, "Skip triggering the first data sync") - cmd.Flags().BoolVar(&opts.Wait, "wait", true, "Wait for pipeline to complete") - cmd.Flags().DurationVar(&opts.Timeout, "timeout", 5*time.Minute, "Max time to wait for pipeline") + cmd.AddCommand(newProjectAddCmd(), newProjectListCmd(), newProjectDeleteCmd()) return cmd } diff --git a/docs/configure-project.md b/docs/configure-project.md index 6aa4d37..7f9c01a 100644 --- a/docs/configure-project.md +++ b/docs/configure-project.md @@ -1,20 +1,24 @@ # configure project -Create a DevLake project, configure its blueprint, and trigger the first data sync. +Manage DevLake projects — create, list, and delete. A **project** groups existing connection scopes into a single analytics view with DORA metrics enabled. A **blueprint** is the sync schedule attached to the project. See [concepts.md](concepts.md). **Prerequisites:** Run [`configure scope`](configure-scope.md) first to add scopes to your connections. -## Usage +--- + +## configure project add + +Create a DevLake project, configure its blueprint, and trigger the first data sync. + +### Usage ```bash -gh devlake configure project [flags] +gh devlake configure project add [flags] ``` -Aliases: `projects` - -## Flags +### Flags | Flag | Default | Description | |------|---------|-------------| @@ -25,7 +29,7 @@ Aliases: `projects` | `--wait` | `true` | Wait for the first pipeline to complete | | `--timeout` | `5m` | Max time to wait for pipeline completion | -## What It Does +### What It Does 1. Discovers your DevLake instance 2. Lists all connections (from state file + API) @@ -37,7 +41,7 @@ Aliases: `projects` 8. Monitors pipeline progress until completion or `--timeout` 9. Updates the state file with project + blueprint info -## Pipeline Output +### Pipeline Output ``` [10s] Status: TASK_RUNNING | Tasks: 2/8 @@ -49,7 +53,7 @@ Aliases: `projects` The first sync may take 5–30 minutes depending on data volume and how far back `--time-after` reaches. -## Cron Schedule Reference +### Cron Schedule Reference | Schedule | Cron Expression | |----------|-----------------| @@ -58,32 +62,99 @@ The first sync may take 5–30 minutes depending on data volume and how far back | Hourly | `0 * * * *` | | Weekly on Sunday | `0 0 * * 0` | -## Examples +### Examples ```bash # Create a project (interactive — discovers connections, prompts for project name) -gh devlake configure project +gh devlake configure project add # Custom project name -gh devlake configure project --project-name my-team +gh devlake configure project add --project-name my-team # Sync from 1 year ago -gh devlake configure project --project-name my-team --time-after 2025-01-01 +gh devlake configure project add --project-name my-team --time-after 2025-01-01 # Create project without triggering sync yet -gh devlake configure project --skip-sync +gh devlake configure project add --skip-sync # Longer timeout for large repos -gh devlake configure project --timeout 30m +gh devlake configure project add --timeout 30m ``` -## Notes +### Notes - If a project with `--project-name` already exists, the command reuses its blueprint ID rather than creating a duplicate. - If `--time-after` is omitted, defaults to 6 months before today. - `--wait false` returns immediately after triggering the sync. Check pipeline status at `GET /pipelines/{id}` or via [`status`](status.md). - The project name defaults to the first org found in the state file, or `my-project` if none is found. +--- + +## configure project list + +List all DevLake projects. + +### Usage + +```bash +gh devlake configure project list +``` + +### Output + +``` +Name Description Blueprint ID +────────────── ──────────────────────────────────────── ──────────── +my-team DevLake metrics for my-team (github) 1 +platform DevLake metrics for platform 2 +``` + +Supports `--json` for machine-readable output: + +```bash +gh devlake configure project list --json +``` + +```json +[{"name":"my-team","description":"DevLake metrics for my-team (github)","blueprintId":1}] +``` + +--- + +## configure project delete + +Delete a DevLake project by name. + +### Usage + +```bash +gh devlake configure project delete [--name ] +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--name` | *(interactive)* | Name of the project to delete | + +**Flag mode:** `--name` is required. + +**Interactive mode:** Lists all projects, prompts to select one, then prompts for confirmation. + +### Examples + +```bash +# Non-interactive +gh devlake configure project delete --name my-project + +# Interactive +gh devlake configure project delete +``` + +> **Warning:** Deleting a project removes its associated blueprint and sync schedule. Historical pipeline data for that project will also be removed. + +--- + ## Related - [concepts.md](concepts.md) diff --git a/internal/devlake/client.go b/internal/devlake/client.go index b196204..853294c 100644 --- a/internal/devlake/client.go +++ b/internal/devlake/client.go @@ -354,6 +354,37 @@ func (c *Client) ListScopes(plugin string, connID int) (*ScopeListResponse, erro return doGet[ScopeListResponse](c, fmt.Sprintf("/plugins/%s/connections/%d/scopes?pageSize=100&page=1", plugin, connID)) } +// ListProjects returns all DevLake projects. +func (c *Client) ListProjects() ([]Project, error) { + result, err := doGet[ProjectListResponse](c, "/projects") + if err != nil { + return nil, err + } + return result.Projects, nil +} + +// DeleteProject deletes a project by name. +func (c *Client) DeleteProject(name string) error { + url := fmt.Sprintf("%s/projects/%s", c.BaseURL, name) + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("project not found: %s", name) + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("DELETE /projects/%s returned %d: %s", name, resp.StatusCode, body) + } + return nil +} + // DeleteScope removes a scope from a plugin connection. func (c *Client) DeleteScope(plugin string, connID int, scopeID string) error { url := fmt.Sprintf("%s/plugins/%s/connections/%d/scopes/%s", c.BaseURL, plugin, connID, url.PathEscape(scopeID)) diff --git a/internal/devlake/types.go b/internal/devlake/types.go index 04d5a3b..b866795 100644 --- a/internal/devlake/types.go +++ b/internal/devlake/types.go @@ -74,6 +74,12 @@ type Project struct { Blueprint *Blueprint `json:"blueprint,omitempty"` } +// ProjectListResponse is the response from GET /projects. +type ProjectListResponse struct { + Count int `json:"count"` + Projects []Project `json:"projects"` +} + // ProjectMetric enables a metric plugin for a project. type ProjectMetric struct { PluginName string `json:"pluginName"`