11package  gitlab
22
33import  (
4+ 	"bytes" 
5+ 	"cmp" 
46	"context" 
57	"encoding/json" 
68	"errors" 
79	"fmt" 
810	"net/url" 
11+ 	"os/exec" 
12+ 	"regexp" 
913	"strings" 
1014
1115	"github.com/charmbracelet/lipgloss" 
@@ -24,11 +28,17 @@ type AuthenticationToken struct {
2428	forge.AuthenticationToken 
2529
2630	// AuthType specifies the kind of authentication method used. 
27- 	// This c 
31+ 	// 
32+ 	// If AuthTypeGitLabCLI, AccessToken is not used. 
2833	AuthType  AuthType  `json:"auth_type,omitempty"`  // required 
2934
3035	// AccessToken is the GitLab access token. 
31- 	AccessToken  string  `json:"access_token,omitempty"`  // required 
36+ 	AccessToken  string  `json:"access_token,omitempty"` 
37+ 
38+ 	// Hostname is the hostname of the GitLab instance. 
39+ 	// 
40+ 	// Used only for AuthTypeGitLabCLI. 
41+ 	Hostname  string  `json:"hostname,omitempty"` 
3242}
3343
3444var  _  forge.AuthenticationToken  =  (* AuthenticationToken )(nil )
@@ -43,6 +53,9 @@ const (
4353	// AuthTypeOAuth2 states that OAuth2 authentication was used. 
4454	AuthTypeOAuth2 
4555
56+ 	// AuthTypeGitLabCLI states that GitLab CLI authentication was used. 
57+ 	AuthTypeGitLabCLI 
58+ 
4659	// AuthTypeEnvironmentVariable states 
4760	// that the token was set via an environment variable. 
4861	// 
@@ -57,8 +70,10 @@ func (a AuthType) MarshalText() ([]byte, error) {
5770		return  []byte ("pat" ), nil 
5871	case  AuthTypeOAuth2 :
5972		return  []byte ("oauth2" ), nil 
73+ 	case  AuthTypeGitLabCLI :
74+ 		return  []byte ("gitlab-cli" ), nil 
6075	case  AuthTypeEnvironmentVariable :
61- 		return  nil , fmt . Errorf ("should never save AuthTypeEnvironmentVariable" )
76+ 		return  nil , errors . New ("should never save AuthTypeEnvironmentVariable" )
6277	default :
6378		return  nil , fmt .Errorf ("unknown auth type: %d" , a )
6479	}
@@ -71,6 +86,8 @@ func (a *AuthType) UnmarshalText(b []byte) error {
7186		* a  =  AuthTypePAT 
7287	case  "oauth2" :
7388		* a  =  AuthTypeOAuth2 
89+ 	case  "gitlab-cli" :
90+ 		* a  =  AuthTypeGitLabCLI 
7491	default :
7592		return  fmt .Errorf ("unknown auth type: %q" , b )
7693	}
@@ -84,6 +101,8 @@ func (a AuthType) String() string {
84101		return  "Personal Access Token" 
85102	case  AuthTypeOAuth2 :
86103		return  "OAuth2" 
104+ 	case  AuthTypeGitLabCLI :
105+ 		return  "GitLab CLI" 
87106	case  AuthTypeEnvironmentVariable :
88107		return  "Environment Variable" 
89108	default :
@@ -124,9 +143,15 @@ func (f *Forge) AuthenticationFlow(ctx context.Context, view ui.View) (forge.Aut
124143		return  nil , fmt .Errorf ("get OAuth endpoint: %w" , err )
125144	}
126145
146+ 	hostname , err  :=  urlHostname (f .URL ())
147+ 	if  err  !=  nil  {
148+ 		return  nil , fmt .Errorf ("get hostname: %w" , err )
149+ 	}
150+ 
127151	auth , err  :=  selectAuthenticator (view , authenticatorOptions {
128152		Endpoint : oauthEndpoint ,
129153		ClientID : f .Options .ClientID ,
154+ 		Hostname : hostname ,
130155	})
131156	if  err  !=  nil  {
132157		return  nil , fmt .Errorf ("select authenticator: %w" , err )
@@ -144,6 +169,27 @@ func (f *Forge) SaveAuthenticationToken(stash secret.Stash, t forge.Authenticati
144169		return  nil 
145170	}
146171
172+ 	// Validate before saving: 
173+ 	switch  ght .AuthType  {
174+ 	case  AuthTypePAT , AuthTypeOAuth2 :
175+ 		if  ght .AccessToken  ==  ""  {
176+ 			return  errors .New ("access token is required" )
177+ 		}
178+ 	case  AuthTypeGitLabCLI :
179+ 		if  ght .Hostname  ==  ""  {
180+ 			return  errors .New ("hostname is required" )
181+ 		}
182+ 		if  ght .AccessToken  !=  ""  {
183+ 			return  errors .New ("access token must not be set for GitLab CLI" )
184+ 		}
185+ 
186+ 	case  AuthTypeEnvironmentVariable :
187+ 		return  errors .New ("should never save AuthTypeEnvironmentVariable" )
188+ 
189+ 	default :
190+ 		return  fmt .Errorf ("unknown auth type: %d" , ght .AuthType )
191+ 	}
192+ 
147193	bs , err  :=  json .Marshal (ght )
148194	if  err  !=  nil  {
149195		return  fmt .Errorf ("marshal token: %w" , err )
@@ -216,15 +262,31 @@ var _authenticationMethods = []struct {
216262			return  & PATAuthenticator {}
217263		},
218264	},
219- 	// TODO: GitLab CLI 
265+ 	{
266+ 		Title :       "GitLab CLI" ,
267+ 		Description : glDesc ,
268+ 		Build : func (a  authenticatorOptions ) authenticator  {
269+ 			// Offer this option only if the user 
270+ 			// has the GitLab CLI installed. 
271+ 			glExe , err  :=  exec .LookPath ("glab" )
272+ 			if  err  !=  nil  {
273+ 				return  nil 
274+ 			}
275+ 
276+ 			return  & CLIAuthenticator {
277+ 				CLI :      newGitLabCLI (glExe ),
278+ 				Hostname : a .Hostname ,
279+ 			}
280+ 		},
281+ 	},
220282}
221283
222284// authenticatorOptions presents the user with multiple authentication methods, 
223285// prompts them to choose one, and executes the chosen method. 
224286type  authenticatorOptions  struct  {
225287	Endpoint  oauth2.Endpoint  // required 
226- 
227- 	ClientID  string  // required 
288+ 	 ClientID   string            // required 
289+ 	Hostname  string            // required 
228290}
229291
230292func  selectAuthenticator (view  ui.View , a  authenticatorOptions ) (authenticator , error ) {
@@ -272,6 +334,14 @@ func patDesc(focused bool) string {
272334	)
273335}
274336
337+ func  glDesc (focused  bool ) string  {
338+ 	return  text .Dedentf (` 
339+ 	Re-use an existing GitLab CLI (%[1]s) session. 
340+ 	You must be logged into glab with 'glab auth login' for this to work. 
341+ 	You can use this if you're just experimenting and don't want to set up a token yet. 
342+ 	` , urlStyle (focused ).Render ("https://gitlab.com/gitlab-org/cli" ))
343+ }
344+ 
275345func  urlStyle (focused  bool ) lipgloss.Style  {
276346	s  :=  ui .NewStyle ()
277347	if  focused  {
@@ -355,3 +425,101 @@ func (a *DeviceFlowAuthenticator) Authenticate(ctx context.Context, view ui.View
355425		AuthType :    AuthTypeOAuth2 ,
356426	}, nil 
357427}
428+ 
429+ // CLIAuthenticator implements GitLab CLI authentication flow. 
430+ // This doesn't do anything special besides checking if the user is logged in. 
431+ type  CLIAuthenticator  struct  {
432+ 	CLI       gitlabCLI  // required 
433+ 	Hostname  string     // required 
434+ }
435+ 
436+ // Authenticate checks if the user is authenticated with GitHub CLI. 
437+ // The returned AuthenticationToken is saved to the stash. 
438+ func  (a  * CLIAuthenticator ) Authenticate (ctx  context.Context , _  ui.View ) (* AuthenticationToken , error ) {
439+ 	ok , err  :=  a .CLI .Status (ctx , a .Hostname )
440+ 	if  err  !=  nil  {
441+ 		return  nil , fmt .Errorf ("check glab status: %w" , err )
442+ 	}
443+ 	if  ! ok  {
444+ 		return  nil , errors .New ("glab is not authenticated" )
445+ 	}
446+ 
447+ 	return  & AuthenticationToken {
448+ 		AuthType : AuthTypeGitLabCLI ,
449+ 		Hostname : a .Hostname ,
450+ 	}, nil 
451+ }
452+ 
453+ type  gitlabCLI  interface  {
454+ 	Status (context.Context , string ) (bool , error )
455+ 	Token (context.Context , string ) (string , error )
456+ }
457+ 
458+ type  glabCLI  struct  {
459+ 	GL      string                 // path to the glab executable 
460+ 	runCmd  func (* exec.Cmd ) error  // for testing 
461+ }
462+ 
463+ func  newGitLabCLI (gl  string ) * glabCLI  {
464+ 	gl  =  cmp .Or (gl , "glab" )
465+ 	return  & glabCLI {
466+ 		GL :     gl ,
467+ 		runCmd : (* exec .Cmd ).Run ,
468+ 	}
469+ }
470+ 
471+ // Status reports whether the user is authenticated with GitLab CLI. 
472+ func  (gc  * glabCLI ) Status (ctx  context.Context , host  string ) (ok  bool , err  error ) {
473+ 	// This command exits with non-zero status if not authenticated. 
474+ 	cmd  :=  exec .CommandContext (ctx , gc .GL , "auth" , "status" , "--hostname" , host )
475+ 	if  err  :=  gc .runCmd (cmd ); err  !=  nil  {
476+ 		var  exitErr  * exec.ExitError 
477+ 		if  errors .As (err , & exitErr ) {
478+ 			return  false , nil 
479+ 		}
480+ 
481+ 		return  false , fmt .Errorf ("gl auth status: %w" , err )
482+ 	}
483+ 	return  true , nil 
484+ }
485+ 
486+ var  _tokenRe  =  regexp .MustCompile (`(?m)^\W+Token:\s+(\w+)\s*$` )
487+ 
488+ // Token returns the authentication token from the GitLab CLI. 
489+ func  (gc  * glabCLI ) Token (ctx  context.Context , host  string ) (string , error ) {
490+ 	// Token is printed to stderr on its own line in the form: 
491+ 	//    ✓ Token: 1234567890abcdef 
492+ 	var  stderr  bytes.Buffer 
493+ 	cmd  :=  exec .CommandContext (ctx , gc .GL ,
494+ 		"auth" , "status" , "--hostname" , host , "--show-token" )
495+ 	cmd .Stderr  =  & stderr 
496+ 	if  err  :=  gc .runCmd (cmd ); err  !=  nil  {
497+ 		var  exitErr  * exec.ExitError 
498+ 		if  errors .As (err , & exitErr ) {
499+ 			return  "" , errors .Join (
500+ 				errors .New ("glab is not authenticated" ),
501+ 				fmt .Errorf ("stderr: %s" , stderr .String ()),
502+ 			)
503+ 		}
504+ 
505+ 		return  "" , fmt .Errorf ("gl auth status: %w" , err )
506+ 	}
507+ 
508+ 	matches  :=  _tokenRe .FindSubmatch (stderr .Bytes ())
509+ 	if  len (matches ) <  2  {
510+ 		return  "" , errors .Join (
511+ 			errors .New ("token not found in glab output" ),
512+ 			fmt .Errorf ("stderr: %s" , stderr .String ()),
513+ 		)
514+ 	}
515+ 
516+ 	return  string (matches [1 ]), nil 
517+ }
518+ 
519+ func  urlHostname (urlstr  string ) (string , error ) {
520+ 	u , err  :=  url .Parse (urlstr )
521+ 	if  err  !=  nil  {
522+ 		return  "" , fmt .Errorf ("parse URL: %w" , err )
523+ 	}
524+ 	return  u .Hostname (), nil 
525+ }
0 commit comments