Skip to content

Commit 9a49db1

Browse files
Copilotewega
andauthored
Per-plugin token resolution chain (#28)
* Initial plan * Implement per-plugin token resolution chain Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> * Add explanatory comments per review feedback Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
1 parent 9e777a4 commit 9a49db1

6 files changed

Lines changed: 288 additions & 17 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,13 @@ gh devlake configure connection --org my-org --endpoint https://github.example.c
211211
| `--env-file` | `.devlake.env` | Path to env file containing `GITHUB_PAT` |
212212
| `--skip-cleanup` | `false` | Don't delete `.devlake.env` after setup |
213213

214-
**Token resolution order:**
214+
**Token resolution order (per plugin):**
215215
1. `--token` flag
216-
2. `.devlake.env` file (`GITHUB_PAT=` or `GITHUB_TOKEN=` or `GH_TOKEN=`)
217-
3. `$GITHUB_TOKEN` / `$GH_TOKEN` environment variables
216+
2. `.devlake.env` file — plugin-specific key first, e.g.:
217+
- GitHub / GitHub Copilot: `GITHUB_PAT=`, `GITHUB_TOKEN=`, or `GH_TOKEN=`
218+
- GitLab: `GITLAB_TOKEN=`
219+
- Azure DevOps: `AZURE_DEVOPS_PAT=`
220+
3. Plugin-specific environment variable (same keys as above, without `GITHUB_PAT`)
218221
4. Interactive masked prompt (terminal only)
219222

220223
**What it does:**

cmd/configure_connections.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func runConfigureConnections(cmd *cobra.Command, args []string) error {
9191

9292
// ── Resolve token ──
9393
fmt.Println("\n🔑 Resolving PAT...")
94-
tokResult, err := token.Resolve(connToken, connEnvFile, def.ScopeHint)
94+
tokResult, err := token.Resolve(def.Plugin, connToken, connEnvFile, def.ScopeHint)
9595
if err != nil {
9696
return err
9797
}

cmd/configure_full.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,10 @@ func runConnectionsInternal(defs []*ConnectionDef, org, enterprise, tokenVal, en
167167
// ── Resolve token ──
168168
fmt.Println("\n🔑 Resolving GitHub PAT...")
169169
scopeHint := aggregateScopeHints(defs)
170-
tokResult, err := token.Resolve(tokenVal, envFile, scopeHint)
170+
// Both available plugins (github, gh-copilot) share the same GitHub PAT,
171+
// so resolving with the first plugin's name is correct. When multi-tool
172+
// support lands (v0.4.0), this will need per-plugin token resolution.
173+
tokResult, err := token.Resolve(defs[0].Plugin, tokenVal, envFile, scopeHint)
171174
if err != nil {
172175
return nil, "", "", err
173176
}

cmd/connection_types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ type ConnectionDef struct {
1919
SupportsTest bool
2020
RequiredScopes []string // PAT scopes needed for this plugin
2121
ScopeHint string // short hint for error messages
22+
EnvVarNames []string // environment variable names (informational; resolution logic lives in token.Resolve)
23+
EnvFileKeys []string // .devlake.env keys (informational; resolution logic lives in token.Resolve)
2224
}
2325

2426
// MenuLabel returns the label for interactive menus.
@@ -125,6 +127,8 @@ var connectionRegistry = []*ConnectionDef{
125127
SupportsTest: true,
126128
RequiredScopes: []string{"repo", "read:org", "read:user"},
127129
ScopeHint: "repo, read:org, read:user",
130+
EnvVarNames: []string{"GITHUB_TOKEN", "GH_TOKEN"},
131+
EnvFileKeys: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
128132
},
129133
{
130134
Plugin: "gh-copilot",
@@ -136,16 +140,22 @@ var connectionRegistry = []*ConnectionDef{
136140
SupportsTest: true,
137141
RequiredScopes: []string{"manage_billing:copilot", "read:org"},
138142
ScopeHint: "manage_billing:copilot, read:org (+ read:enterprise for enterprise metrics)",
143+
EnvVarNames: []string{"GITHUB_TOKEN", "GH_TOKEN"},
144+
EnvFileKeys: []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"},
139145
},
140146
{
141147
Plugin: "gitlab",
142148
DisplayName: "GitLab",
143149
Available: false,
150+
EnvVarNames: []string{"GITLAB_TOKEN"},
151+
EnvFileKeys: []string{"GITLAB_TOKEN"},
144152
},
145153
{
146154
Plugin: "azure-devops",
147155
DisplayName: "Azure DevOps",
148156
Available: false,
157+
EnvVarNames: []string{"AZURE_DEVOPS_PAT"},
158+
EnvFileKeys: []string{"AZURE_DEVOPS_PAT"},
149159
},
150160
}
151161

internal/token/resolve.go

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
// Package token resolves a GitHub Personal Access Token from multiple sources.
1+
// Package token resolves a Personal Access Token from multiple sources.
22
//
33
// Priority order:
4-
// 1. Explicit flag value (--token / --github-token)
5-
// 2. .devlake.env file (GITHUB_PAT=...)
6-
// 3. Environment variables ($GITHUB_TOKEN / $GH_TOKEN)
4+
// 1. Explicit flag value (--token)
5+
// 2. .devlake.env file (plugin-specific key, e.g. GITLAB_TOKEN=...)
6+
// 3. Plugin-specific environment variable (e.g. $GITLAB_TOKEN)
77
// 4. Interactive masked prompt (terminal)
88
package token
99

@@ -25,9 +25,10 @@ type ResolveResult struct {
2525
}
2626

2727
// Resolve attempts to find a PAT using the priority chain.
28+
// plugin determines which env vars and env file keys to check (e.g. "github", "gitlab", "azure-devops").
2829
// scopeHint is displayed in the interactive prompt to guide the user on required scopes.
2930
// Returns an error only if no token can be obtained.
30-
func Resolve(flagValue, envFilePath, scopeHint string) (*ResolveResult, error) {
31+
func Resolve(plugin, flagValue, envFilePath, scopeHint string) (*ResolveResult, error) {
3132
// 1. Explicit flag
3233
if flagValue != "" {
3334
return &ResolveResult{Token: flagValue, Source: "flag"}, nil
@@ -38,38 +39,78 @@ func Resolve(flagValue, envFilePath, scopeHint string) (*ResolveResult, error) {
3839
envFilePath = ".devlake.env"
3940
}
4041
if vals, err := envfile.Load(envFilePath); err == nil {
41-
for _, key := range []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"} {
42+
for _, key := range pluginEnvFileKeys(plugin) {
4243
if v, ok := vals[key]; ok && v != "" {
4344
return &ResolveResult{Token: v, Source: "envfile", EnvFilePath: envFilePath}, nil
4445
}
4546
}
4647
}
4748

4849
// 3. Environment variables
49-
for _, key := range []string{"GITHUB_TOKEN", "GH_TOKEN"} {
50+
for _, key := range pluginEnvVarKeys(plugin) {
5051
if v := os.Getenv(key); v != "" {
5152
return &ResolveResult{Token: v, Source: "environment"}, nil
5253
}
5354
}
5455

5556
// 4. Interactive masked prompt
57+
displayName := pluginDisplayName(plugin)
5658
if !term.IsTerminal(int(syscall.Stdin)) {
57-
return nil, fmt.Errorf("no GitHub PAT found and stdin is not a terminal.\n" +
58-
"Provide a token via --token, .devlake.env file, or $GITHUB_TOKEN")
59+
envVarExample := pluginEnvVarKeys(plugin)[0]
60+
return nil, fmt.Errorf("no %s PAT found and stdin is not a terminal.\n"+
61+
"Provide a token via --token, .devlake.env file, or $%s", displayName, envVarExample)
5962
}
6063

61-
tok, err := promptMasked(scopeHint)
64+
tok, err := promptMasked(displayName, scopeHint)
6265
if err != nil {
6366
return nil, err
6467
}
6568
return &ResolveResult{Token: tok, Source: "prompt"}, nil
6669
}
6770

68-
func promptMasked(scopeHint string) (string, error) {
71+
// pluginEnvFileKeys returns the ordered .devlake.env key names to check for the given plugin.
72+
func pluginEnvFileKeys(plugin string) []string {
73+
switch plugin {
74+
case "gitlab":
75+
return []string{"GITLAB_TOKEN"}
76+
case "azure-devops":
77+
return []string{"AZURE_DEVOPS_PAT"}
78+
default: // "github", "gh-copilot", or unknown
79+
return []string{"GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"}
80+
}
81+
}
82+
83+
// pluginEnvVarKeys returns the ordered environment variable names to check for the given plugin.
84+
func pluginEnvVarKeys(plugin string) []string {
85+
switch plugin {
86+
case "gitlab":
87+
return []string{"GITLAB_TOKEN"}
88+
case "azure-devops":
89+
return []string{"AZURE_DEVOPS_PAT"}
90+
default: // "github", "gh-copilot", or unknown
91+
return []string{"GITHUB_TOKEN", "GH_TOKEN"}
92+
}
93+
}
94+
95+
// pluginDisplayName returns a human-readable label for prompt messages.
96+
func pluginDisplayName(plugin string) string {
97+
switch plugin {
98+
case "gh-copilot":
99+
return "GitHub Copilot"
100+
case "gitlab":
101+
return "GitLab"
102+
case "azure-devops":
103+
return "Azure DevOps"
104+
default:
105+
return "GitHub"
106+
}
107+
}
108+
109+
func promptMasked(displayName, scopeHint string) (string, error) {
69110
if scopeHint != "" {
70111
fmt.Fprintf(os.Stderr, "Required PAT scopes: %s\n", scopeHint)
71112
}
72-
fmt.Fprint(os.Stderr, "GitHub Personal Access Token: ")
113+
fmt.Fprintf(os.Stderr, "%s Personal Access Token: ", displayName)
73114
raw, err := term.ReadPassword(int(syscall.Stdin))
74115
fmt.Fprintln(os.Stderr) // newline after masked input
75116
if err != nil {

0 commit comments

Comments
 (0)