Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/skills/gh-devlake-roadmap/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
name: gh-devlake-roadmap
description: Look up the gh-devlake CLI roadmap, milestones, issues, version plans, and release priorities. Use when the user asks about roadmap, priorities, what's planned, what version something is in, or what issues exist for a milestone.
---

# gh-devlake Roadmap Lookup

## Repository

- **Owner:** DevExpGBB
- **Repo:** gh-devlake
- **Project Board:** https://github.com/orgs/DevExpGbb/projects/21 (Project #21)
- **Milestones URL:** https://github.com/DevExpGBB/gh-devlake/milestones

## How to Look Up Roadmap Information

Use the GitHub MCP tools to query issues and milestones from `DevExpGBB/gh-devlake`.

### List milestones and their issues

Use `mcp_github_list_issues` with the repo `DevExpGBB/gh-devlake` to get issues.
Filter by milestone name to see what's in each release.

To find all milestones, use the `gh` CLI:
```
gh api repos/DevExpGBB/gh-devlake/milestones --jq '.[] | "\(.title): \(.description) (\(.open_issues) open, \(.closed_issues) closed)"'
```

To find issues for a specific milestone:
```
gh issue list --repo DevExpGBB/gh-devlake --milestone "v0.3.4" --json number,title,state,labels
```

### Issue labels

| Label | Meaning |
|-------|---------|
| `enhancement` | New feature or request |
| `bug` | Something isn't working |
| `refactor` | Code restructure, no behavior change |
| `documentation` | Docs, skills, instructions |

## Versioning Scheme

Semantic versioning: `MAJOR.MINOR.PATCH`

- **0.3.x** — Current development line. Incremental features, restructuring, and lifecycle commands.
- PATCH bumps for features that don't change the CLI's plugin surface area (same set of supported DevOps tools).
- **0.4.x** — Multi-tool expansion. New plugin types (GitLab, Azure DevOps) that expand the CLI's supported tool surface.
- **MINOR bumps** only when genuinely new categories of capability arrive (new DevOps tool plugins, new token resolution chains).
- **MAJOR bump (1.0)** — Reserved for production-ready stability declaration.

## Current Release Plan

| Version | Theme | Status |
|---------|-------|--------|
| v0.3.3 | Enterprise Support | In progress — scope ID fix, connection testing, rate limit, enterprise threading |
| v0.3.4 | CLI Restructure | Planned — singular commands, --plugin flag, list command, CLI versioning |
| v0.3.5 | Connection Lifecycle | Planned — delete and test commands |
| v0.3.6 | Connection Update + Skill | Planned — update command, this roadmap skill |
| v0.4.0 | Multi-Tool Expansion | Future — GitLab, Azure DevOps, per-plugin token chains |

## CLI Command Architecture (Option A)

Connection lifecycle commands live under `configure connection`:
```
gh devlake configure connection create --plugin gh-copilot ...
gh devlake configure connection delete --plugin gh-copilot --id 2
gh devlake configure connection update --plugin gh-copilot --id 2 --token ghp_new
gh devlake configure connection list
gh devlake configure connection test --plugin gh-copilot --id 2
```

Each command operates on one plugin at a time. Interactive mode prompts for plugin selection.

## Key Design Decisions

1. **One plugin per invocation** in flag mode. Interactive mode walks through plugins sequentially.
2. **`--plugin` flag** replaces `--skip-copilot`/`--skip-github` (positive selection, not negative exclusion).
3. **Singular command names** (`connection`, `scope`, `project`) — not plurals.
4. **Delete/update/test are subcommands**, not flags — each is a distinct action with distinct UX.
5. **Plugin-specific fields** (org, enterprise, repos) are validated per-plugin, not shared across all.
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ 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`

### Plugin System
Plugins are defined via `ConnectionDef` structs in `cmd/connection_types.go`. Each entry declares the plugin slug, endpoint, required fields (`NeedsOrg`, `NeedsEnterprise`), and PAT scopes. To add a new DevOps tool, add a `ConnectionDef` to `connectionRegistry` — no other registration needed.

**One plugin per invocation.** Flag-based commands target a single `--plugin`. Interactive mode walks through plugins sequentially. This keeps plugin-specific fields (org, enterprise, repos, tokens) self-contained.

### Copilot Scope ID Convention
The `gh-copilot` plugin computes scope IDs as: enterprise + org → `"enterprise/org"`, enterprise only → `"enterprise"`, org only → `"org"`. See `copilotScopeID()` in `cmd/configure_scopes.go`. The scope ID must match the plugin's `listGhCopilotRemoteScopes` logic exactly or blueprint references will break.

## Roadmap
See `.github/skills/gh-devlake-roadmap/SKILL.md` for version plan, milestones, and design decisions. Project board: https://github.com/orgs/DevExpGbb/projects/21

## Terminal Output & UX — CRITICAL

**The terminal IS the UI.** Every `fmt.Print` call is a UX decision. Readability, rhythm, and breathing room are non-negotiable.
Expand Down
6 changes: 6 additions & 0 deletions cmd/configure_full.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,17 @@ func runConfigureFull(cmd *cobra.Command, args []string) error {
case "gh-copilot":
scopeCopilotConnID = r.ConnectionID
scopeSkipCopilot = false
if scopeEnterprise == "" && r.Enterprise != "" {
scopeEnterprise = r.Enterprise
}
}
}
if fullOrg != "" {
scopeOrg = fullOrg
}
if fullEnterprise != "" {
scopeEnterprise = fullEnterprise
}
cfgURL = devlakeURL

if err := runConfigureProjects(cmd, args); err != nil {
Expand Down
44 changes: 29 additions & 15 deletions cmd/configure_projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Example:
}

cmd.Flags().StringVar(&scopeOrg, "org", "", "GitHub organization slug")
cmd.Flags().StringVar(&scopeEnterprise, "enterprise", "", "GitHub enterprise slug (enables enterprise-level Copilot metrics)")
cmd.Flags().StringVar(&scopeRepos, "repos", "", "Comma-separated repos (owner/repo)")
cmd.Flags().StringVar(&scopeReposFile, "repos-file", "", "Path to file with repos (one per line)")
cmd.Flags().IntVar(&scopeGHConnID, "github-connection-id", 0, "GitHub connection ID (auto-detected if omitted)")
Expand All @@ -54,20 +55,21 @@ Example:

// connChoice represents a discovered connection for the interactive picker.
type connChoice struct {
plugin string
id int
label string
plugin string
id int
label string
enterprise string // enterprise slug from state/API, if available
}

// addedConnection tracks a connection that has been scoped and is ready
// for inclusion in the final blueprint.
type addedConnection struct {
plugin string
connID int
label string
summary string // short summary shown in "Added so far" list
bpConn devlake.BlueprintConnection
repos []string // only populated for GitHub connections
plugin string
connID int
label string
summary string // short summary shown in "Added so far" list
bpConn devlake.BlueprintConnection
repos []string // only populated for GitHub connections
}

func runConfigureProjects(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -98,6 +100,12 @@ func runConfigureProjects(cmd *cobra.Command, args []string) error {
}
fmt.Printf(" Organization: %s\n", org)

// ── Resolve enterprise ──
enterprise := resolveEnterprise(state, scopeEnterprise)
if enterprise != "" {
fmt.Printf(" Enterprise: %s\n", enterprise)
}

// ── Project name ──
if scopeProjectName == "" {
def := org
Expand Down Expand Up @@ -176,7 +184,7 @@ func runConfigureProjects(cmd *cobra.Command, args []string) error {
}

// Scope the picked connection
ac, err := scopeConnection(client, picked, org)
ac, err := scopeConnection(client, picked, org, enterprise)
if err != nil {
fmt.Printf(" ⚠️ Could not scope %s: %v\n", picked.label, err)
// Remove from remaining so user doesn't loop on a failing connection
Expand Down Expand Up @@ -248,7 +256,7 @@ func runConfigureProjects(cmd *cobra.Command, args []string) error {

// scopeConnection scopes a single connection (GitHub repos or Copilot org)
// and returns an addedConnection with the BlueprintConnection entry.
func scopeConnection(client *devlake.Client, c connChoice, org string) (*addedConnection, error) {
func scopeConnection(client *devlake.Client, c connChoice, org, enterprise string) (*addedConnection, error) {
switch c.plugin {
case "github":
result, err := scopeGitHub(client, c.id, org)
Expand All @@ -267,11 +275,17 @@ func scopeConnection(client *devlake.Client, c connChoice, org string) (*addedCo
}, nil

case "gh-copilot":
conn, err := scopeCopilot(client, c.id, org)
// Prefer enterprise from the connection's own state, fall back to resolved value
ent := enterprise
if c.enterprise != "" {
ent = c.enterprise
}
conn, err := scopeCopilot(client, c.id, org, ent)
if err != nil {
return nil, err
}
summary := fmt.Sprintf("GitHub Copilot (ID: %d, org: %s)", c.id, org)
scopeID := copilotScopeID(org, ent)
summary := fmt.Sprintf("GitHub Copilot (ID: %d, scope: %s)", c.id, scopeID)
return &addedConnection{
plugin: c.plugin,
connID: c.id,
Expand Down Expand Up @@ -308,7 +322,7 @@ func discoverConnections(client *devlake.Client, state *devlake.State) []connCho
key := fmt.Sprintf("%s:%d", c.Plugin, c.ConnectionID)
seen[key] = true
label := fmt.Sprintf("%s (ID: %d, Name: %q)", pluginDisplayName(c.Plugin), c.ConnectionID, c.Name)
choices = append(choices, connChoice{plugin: c.Plugin, id: c.ConnectionID, label: label})
choices = append(choices, connChoice{plugin: c.Plugin, id: c.ConnectionID, label: label, enterprise: c.Enterprise})
}
}

Expand All @@ -325,7 +339,7 @@ func discoverConnections(client *devlake.Client, state *devlake.State) []connCho
}
seen[key] = true
label := fmt.Sprintf("%s (ID: %d, Name: %q)", pluginDisplayName(plugin), c.ID, c.Name)
choices = append(choices, connChoice{plugin: plugin, id: c.ID, label: label})
choices = append(choices, connChoice{plugin: plugin, id: c.ID, label: label, enterprise: c.Enterprise})
}
}
return choices
Expand Down
64 changes: 53 additions & 11 deletions cmd/configure_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

var (
scopeOrg string
scopeEnterprise string
scopeRepos string
scopeReposFile string
scopeGHConnID int
Expand Down Expand Up @@ -49,6 +50,7 @@ Example:
}

cmd.Flags().StringVar(&scopeOrg, "org", "", "GitHub organization slug")
cmd.Flags().StringVar(&scopeEnterprise, "enterprise", "", "GitHub enterprise slug (enables enterprise-level Copilot metrics)")
cmd.Flags().StringVar(&scopeRepos, "repos", "", "Comma-separated repos (owner/repo)")
cmd.Flags().StringVar(&scopeReposFile, "repos-file", "", "Path to file with repos (one per line)")
cmd.Flags().IntVar(&scopeGHConnID, "github-connection-id", 0, "GitHub connection ID (auto-detected if omitted)")
Expand Down Expand Up @@ -143,21 +145,22 @@ func scopeGitHub(client *devlake.Client, connID int, org string) (*scopeGitHubRe
}, nil
}

// scopeCopilot PUTs the org scope for a Copilot connection.
// scopeCopilot PUTs the org/enterprise scope for a Copilot connection.
// Returns the BlueprintConnection entry.
func scopeCopilot(client *devlake.Client, connID int, org string) (*devlake.BlueprintConnection, error) {
func scopeCopilot(client *devlake.Client, connID int, org, enterprise string) (*devlake.BlueprintConnection, error) {
fmt.Println("\n📝 Adding Copilot scope...")
err := putCopilotScope(client, connID, org)
scopeID := copilotScopeID(org, enterprise)
err := putCopilotScope(client, connID, org, enterprise)
if err != nil {
return nil, fmt.Errorf("could not add Copilot scope: %w", err)
}
fmt.Printf(" ✅ Copilot scope added: %s\n", org)
fmt.Printf(" ✅ Copilot scope added: %s\n", scopeID)

return &devlake.BlueprintConnection{
PluginName: "gh-copilot",
ConnectionID: connID,
Scopes: []devlake.BlueprintScope{
{ScopeID: org, ScopeName: org},
{ScopeID: scopeID, ScopeName: scopeID},
},
}, nil
}
Expand Down Expand Up @@ -295,6 +298,12 @@ func runConfigureScopes(cmd *cobra.Command, args []string) error {
}
fmt.Printf(" Organization: %s\n", org)

// ── Step 4: Resolve enterprise ──
enterprise := resolveEnterprise(state, scopeEnterprise)
if enterprise != "" {
fmt.Printf(" Enterprise: %s\n", enterprise)
}

projectName := scopeProjectName
if projectName == "" {
projectName = org
Expand All @@ -314,7 +323,7 @@ func runConfigureScopes(cmd *cobra.Command, args []string) error {
}

if !scopeSkipCopilot && copilotConnID > 0 {
conn, err := scopeCopilot(client, copilotConnID, org)
conn, err := scopeCopilot(client, copilotConnID, org, enterprise)
if err != nil {
fmt.Printf(" ⚠️ %v\n", err)
} else {
Expand Down Expand Up @@ -390,6 +399,21 @@ func resolveOrg(state *devlake.State, flagValue string) string {
return prompt.ReadLine("Enter your GitHub organization slug")
}

// resolveEnterprise determines the enterprise slug from flag, state, or API.
func resolveEnterprise(state *devlake.State, flagValue string) string {
if flagValue != "" {
return flagValue
}
if state != nil {
for _, c := range state.Connections {
if c.Enterprise != "" {
return c.Enterprise
}
}
}
return ""
}

// resolveRepos determines repos from flags, file, or interactive gh CLI selection.
func resolveRepos(org string) ([]string, error) {
// From --repos flag
Expand Down Expand Up @@ -491,20 +515,38 @@ func putGitHubScopes(client *devlake.Client, connID, scopeConfigID int, details
return client.PutScopes("github", connID, &devlake.ScopeBatchRequest{Data: data})
}

// putCopilotScope adds the organization scope to the Copilot connection.
func putCopilotScope(client *devlake.Client, connID int, org string) error {
// putCopilotScope adds the organization/enterprise scope to the Copilot connection.
func putCopilotScope(client *devlake.Client, connID int, org, enterprise string) error {
scopeID := copilotScopeID(org, enterprise)
data := []any{
devlake.CopilotScope{
ID: org,
ID: scopeID,
ConnectionID: connID,
Organization: org,
Name: org,
FullName: org,
Enterprise: enterprise,
Name: scopeID,
FullName: scopeID,
},
}
return client.PutScopes("gh-copilot", connID, &devlake.ScopeBatchRequest{Data: data})
}

// copilotScopeID computes the scope ID matching the plugin's convention:
// - enterprise + org → "enterprise/org"
// - enterprise only → "enterprise"
// - org only → "org"
func copilotScopeID(org, enterprise string) string {
org = strings.TrimSpace(org)
enterprise = strings.TrimSpace(enterprise)
if enterprise != "" {
if org != "" {
return enterprise + "/" + org
}
return enterprise
}
return org
}

// ensureProjectWithFlags creates a project or returns an existing one's blueprint ID.
// Uses explicit hasGitHub/hasCopilot flags instead of package globals.
func ensureProjectWithFlags(client *devlake.Client, name, org string, hasGitHub, hasCopilot bool) (int, error) {
Expand Down
Loading