Repository: git-town/git-town

Issue:
support for Azure DevOps
Would like to see Azure Repos supported for opening a new pull request. 

If you have contribution guidelines for adding a new provider or any pointers for someone new to the project I'd appreciate it. Perhaps I could contribute this in the near future then. 

Hints from issue comments:
@kevgo: Great idea! Adding support for additional Git hosting providers is relatively easy. Just replicate how #1518 added support for Gitea. Most of the action happens in https://github.com/git-town/git-town/tree/master/src/drivers. Please let me know if you need help with this!@whitebear-gh: Would be very nice to have support for Devops, but the number of changes needed is extremely huge for non-golang developer :D @sheldonhull: I started looking into it but I couldn't find the pull requests mentioned in their Azure Repos SDK and the alternative was to configure everything raw via rest apis. While opposed to that I found it more effort to dig through than I had time. 

I still use git town heavily but not for the pull request creation.

I did a side project for that maybe you'll find useful. https://github.com/sheldonhull/az-pr

It's a wrapper around the cli so nothing fancy but it's been what I've been using as it's "good enough". @kevgo: If you share the URL through which one creates a new pull request on Azure DevOps (in the browser), I can create this feature for you.@sheldonhull: I created this other tool for wrapping up the azure-cli with [az-pr](https://github.com/sheldonhull/az-pr/blob/e01952b00e11b57ebb78a72c91ba941523ef7883/cmd/new.go#L284-L295) and you can see the invocation here.

The azure rest api is good, but there's things like project collection, auth with azure cli, project name, and work-item linking that are all part of that and seemed clunky to do with the rest API directly. 

[docs](https://learn.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/create?view=azure-devops-rest-7.1&tabs=HTTP)

If you have a bit of direction on how you'd handle this I'd be more than happy to try and contribute when I get a chance. I might be overthinking some of it ;-) 

In the meantime, the az-pr I linked works well just wrapping up the azure cli. @kevgo: az-pr is a nice tool that does a lot of useful things! I love the badges 😜.

[Here](https://learn.microsoft.com/en-us/azure/devops/repos/git/pull-requests?view=azure-devops&tabs=browser) is the page that describes how to create a PR using the browser. This is the way Git Town would support creating PRs on Azure DevOps inititially. There are a lot of options for the PR to create (link work items, make it a draft PR, etc) and Git Town doesn't want to re-invent the Azure DevOps UI.@sheldonhull: Sorry I assumed you meant you wanted the rest api for the creation of a new PR. The azure-cli is best for the full options, but if you meant just the API call for creation then I understand that part generally. Or did you mean it would just open a browser link after pushing for the creation of the PR, without trying to create and then open?

If config could be set globally in my gitconfig like: `git config set azure-repos-org foo` then that solves setting up, and if it needs project name then normally that might be extracted out of the url, though the url scheme differs based on the type of azure devops instance/service you are using. Simply providing the config setup that asks for the org + project name on first time config would be great and solve most concerns I have about ease of use.

Let me know if you think that's a start. I'd be more than happy to collaborate on it to help such a great project. One of the most used CLI tools, and I recommend it to everyone :-)@kevgo: When running `git town new-pull-request`, Git Town opens a browser tab with an address like this one: https://github.com/git-town/git-town/compare/public...main?expand=1. Git Town populates as much as it can through the URL (base and head repo, branch, parent branch). The user can then use the web UI (of the hosting service) to configure other options for the PR (tagging people, tickets, milestones, etc) and click on "Create pull request" to create the PR.

This way, Git Town isn't limited to a common subset of the features offered by all supported hosting services, and Git Town doesn't need to update each time a hosting service adds another feature.

I'd love to collaborate with you on adding Azure DevOps support to Git Town. I'm happy to create a draft PR with the feature that you can test and fine-tune with your Azure DevOps setup. To get started, can you please create a test branch in your repo, and use the Azure DevOps UI to create a pull request? What's the URL of the page to create a PR (similar to the GitHub URL I linked above)?

PS: thanks for all your support of Git Town over the years! :bow: @oludaara: thought this issue has been fixed so why add it now to ODHack
@kevgo: @oludaara Thanks for reaching out! Azure DevOps support hasn’t been implemented yet. There was an [early incomplete prototype](https://github.com/git-town/git-town/compare/kg-simplify-config-dire?expand=1), but it's over two years old and quite out of date. Moving this forward would require someone with access to an Azure DevOps environment to pick it up. I don’t have access myself. Are you interested in helping with this?@oludaara: i'll love to work on it but creating a azure acct that has my credit card details for an open source is wild.....that's the only limitation noted now@oludaara: is there any incentive if i apply via only-dust and use my personal azure account
@kevgo: There are plenty of great reasons to contribute:

- Your name will be listed as an official contributor to Git Town
- You'll be mentioned in the release notes
- Your account will be tagged in the official release
- Your GitHub profile gets more visibility, which is great for showcasing your work to potential employers or collaborators
- You get hands-on coaching from very experienced developers

Contributions are a win-win: you help improve the project and in return become a better programmer and build your reputation in the dev community.
@oludaara: You dropped some valid points
So ill take the task

But for now I can't really tell when I'll be done tho.....because of the ODHack@kevgo: That's cool, feel free to share a pull request whenever you have it, even if it isn't fully done. Happy to give you guidance along the way.@oludaara: One more thing can you assign this to me via OnlyDust@kevgo: I have just assigned you. Happy hacking!@kevgo: Turns out I have an Azure DevOps account and test repo that I could use to finish the prototype in `kg-azure-devops`: https://dev.azure.com/kevingoslar/_git/tikibase. Set up SSH access keys: https://dev.azure.com/kevingoslar/_usersSettings/keys@kevgo: Feature is implemented, need to test on Windows because Azure DevOps doesn't support `ssh-ed25519`.@kevgo: This is now available as part of [Git Town 22](https://github.com/git-town/git-town/releases/tag/v22.0.0). 

Solution patch:
diff --git a/features/config/setup/azuredevops.feature b/features/config/setup/azuredevops.feature
new file mode 100644
index 000000000..59574475e
--- /dev/null
+++ b/features/config/setup/azuredevops.feature
@@ -0,0 +1,36 @@
+@messyoutput
+Feature: enter Azure DevOps
+
+  Background:
+    Given a Git repo with origin
+
+  Scenario: auto-detected Azure Devops platform
+    And my repo's "origin" remote is "git@ssh.dev.azure.com:v3/kevingoslar/tikibase/tikibase"
+    When I run "git-town init" and enter into the dialog:
+      | DIALOG             | KEYS  | DESCRIPTION                                 |
+      | welcome            | enter |                                             |
+      | aliases            | enter |                                             |
+      | main branch        | enter |                                             |
+      | perennial branches |       | no input here since the dialog doesn't show |
+      | origin hostname    | enter |                                             |
+      | forge type         | enter | auto-detect                                 |
+      | enter all          | enter |                                             |
+      | config storage     | enter | git metadata                                |
+    Then Git Town runs no commands
+    And local Git setting "git-town.forge-type" still doesn't exist
+
+  Scenario: select Azure DevOps manually
+    When I run "git-town init" and enter into the dialog:
+      | DIALOG             | KEYS       | DESCRIPTION                                 |
+      | welcome            | enter      |                                             |
+      | aliases            | enter      |                                             |
+      | main branch        | enter      |                                             |
+      | perennial branches |            | no input here since the dialog doesn't show |
+      | origin hostname    | enter      |                                             |
+      | forge type         | down enter |                                             |
+      | enter all          | enter      |                                             |
+      | config storage     | enter      | git metadata                                |
+    Then Git Town runs the commands
+      | COMMAND                                    |
+      | git config git-town.forge-type azuredevops |
+    And local Git setting "git-town.forge-type" is now "azuredevops"
diff --git a/features/config/setup/forgejo_token.feature b/features/config/setup/forgejo_token.feature
index 1637a4a9d..9d00e6c87 100644
--- a/features/config/setup/forgejo_token.feature
+++ b/features/config/setup/forgejo_token.feature
@@ -26,17 +26,17 @@ Feature: enter the Forgejo API token
 
   Scenario: select Forgejo manually
     When I run "git-town init" and enter into the dialog:
-      | DIALOG             | KEYS                   | DESCRIPTION                                 |
-      | welcome            | enter                  |                                             |
-      | aliases            | enter                  |                                             |
-      | main branch        | enter                  |                                             |
-      | perennial branches |                        | no input here since the dialog doesn't show |
-      | origin hostname    | enter                  |                                             |
-      | forge type         | down down down enter   |                                             |
-      | forgejo token      | c o d e - t o k  enter |                                             |
-      | token scope        | enter                  |                                             |
-      | enter all          | enter                  |                                             |
-      | config storage     | enter                  |                                             |
+      | DIALOG             | KEYS                      | DESCRIPTION                                 |
+      | welcome            | enter                     |                                             |
+      | aliases            | enter                     |                                             |
+      | main branch        | enter                     |                                             |
+      | perennial branches |                           | no input here since the dialog doesn't show |
+      | origin hostname    | enter                     |                                             |
+      | forge type         | down down down down enter |                                             |
+      | forgejo token      | c o d e - t o k  enter    |                                             |
+      | token scope        | enter                     |                                             |
+      | enter all          | enter                     |                                             |
+      | config storage     | enter                     |                                             |
     Then Git Town runs the commands
       | COMMAND                                    |
       | git config git-town.forgejo-token code-tok |
diff --git a/features/config/setup/gitea_token.feature b/features/config/setup/gitea_token.feature
index cd4f71d12..c4c8482e6 100644
--- a/features/config/setup/gitea_token.feature
+++ b/features/config/setup/gitea_token.feature
@@ -26,17 +26,17 @@ Feature: enter the Gitea API token
 
   Scenario: select Gitea manually
     When I run "git-town init" and enter into the dialog:
-      | DIALOG             | KEYS                      | DESCRIPTION                                 |
-      | welcome            | enter                     |                                             |
-      | aliases            | enter                     |                                             |
-      | main branch        | enter                     |                                             |
-      | perennial branches |                           | no input here since the dialog doesn't show |
-      | origin hostname    | enter                     |                                             |
-      | forge type         | down down down down enter |                                             |
-      | gitea token        | g i t e a - t o k enter   |                                             |
-      | token scope        | enter                     |                                             |
-      | enter all          | enter                     |                                             |
-      | config storage     | enter                     | git metadata                                |
+      | DIALOG             | KEYS                           | DESCRIPTION                                 |
+      | welcome            | enter                          |                                             |
+      | aliases            | enter                          |                                             |
+      | main branch        | enter                          |                                             |
+      | perennial branches |                                | no input here since the dialog doesn't show |
+      | origin hostname    | enter                          |                                             |
+      | forge type         | down down down down down enter |                                             |
+      | gitea token        | g i t e a - t o k enter        |                                             |
+      | token scope        | enter                          |                                             |
+      | enter all          | enter                          |                                             |
+      | config storage     | enter                          | git metadata                                |
     Then Git Town runs the commands
       | COMMAND                                   |
       | git config git-town.gitea-token gitea-tok |
diff --git a/features/config/setup/github_token.feature b/features/config/setup/github_token.feature
index a6a201bd0..f1ac9dafe 100644
--- a/features/config/setup/github_token.feature
+++ b/features/config/setup/github_token.feature
@@ -28,18 +28,18 @@ Feature: enter the GitHub API token
 
   Scenario: manually selected GitHub
     When I run "git-town init" and enter into the dialog:
-      | DIALOG                | KEYS                           | DESCRIPTION                                 |
-      | welcome               | enter                          |                                             |
-      | aliases               | enter                          |                                             |
-      | main branch           | enter                          |                                             |
-      | perennial branches    |                                | no input here since the dialog doesn't show |
-      | origin hostname       | enter                          |                                             |
-      | forge type            | down down down down down enter |                                             |
-      | github connector type | enter                          |                                             |
-      | github token          | g h - t o k enter              |                                             |
-      | token scope           | enter                          |                                             |
-      | enter all             | enter                          |                                             |
-      | config storage        | enter                          |                                             |
+      | DIALOG                | KEYS                                | DESCRIPTION                                 |
+      | welcome               | enter                               |                                             |
+      | aliases               | enter                               |                                             |
+      | main branch           | enter                               |                                             |
+      | perennial branches    |                                     | no input here since the dialog doesn't show |
+      | origin hostname       | enter                               |                                             |
+      | forge type            | down down down down down down enter |                                             |
+      | github connector type | enter                               |                                             |
+      | github token          | g h - t o k enter                   |                                             |
+      | token scope           | enter                               |                                             |
+      | enter all             | enter                               |                                             |
+      | config storage        | enter                               |                                             |
     Then Git Town runs the commands
       | COMMAND                                  |
       | git config git-town.github-token gh-tok  |
diff --git a/features/config/setup/remove_git_metadata.feature b/features/config/setup/remove_git_metadata.feature
index 9d2c4fd10..e95b659c3 100644
--- a/features/config/setup/remove_git_metadata.feature
+++ b/features/config/setup/remove_git_metadata.feature
@@ -53,7 +53,7 @@ Feature: remove existing configuration in Git metadata
       | main branch                 | enter                                                                       |                     |
       | perennial branches          | down space enter                                                            |                     |
       | origin hostname             | backspace backspace backspace backspace enter                               | remove the override |
-      | forge type                  | up up up up up enter                                                        | remove the override |
+      | forge type                  | up up up up up up enter                                                     | remove the override |
       | enter all                   | down enter                                                                  |                     |
       | perennial regex             | backspace backspace backspace backspace enter                               |                     |
       | feature regex               | backspace backspace backspace backspace backspace backspace enter           |                     |
diff --git a/features/config/setup/remove_hosting_override.feature b/features/config/setup/remove_hosting_override.feature
index dfb4a6e1e..602956883 100644
--- a/features/config/setup/remove_hosting_override.feature
+++ b/features/config/setup/remove_hosting_override.feature
@@ -5,15 +5,15 @@ Feature: remove an existing forge type override
     Given a Git repo with origin
     And local Git setting "git-town.forge-type" is "github"
     When I run "git-town init" and enter into the dialog:
-      | DIALOG             | KEYS                 | DESCRIPTION                                 |
-      | welcome            | enter                |                                             |
-      | aliases            | enter                |                                             |
-      | main branch        | enter                |                                             |
-      | perennial branches |                      | no input here since the dialog doesn't show |
-      | origin hostname    | enter                |                                             |
-      | forge type         | up up up up up enter |                                             |
-      | enter all          | enter                |                                             |
-      | config storage     | enter                |                                             |
+      | DIALOG             | KEYS                    | DESCRIPTION                                 |
+      | welcome            | enter                   |                                             |
+      | aliases            | enter                   |                                             |
+      | main branch        | enter                   |                                             |
+      | perennial branches |                         | no input here since the dialog doesn't show |
+      | origin hostname    | enter                   |                                             |
+      | forge type         | up up up up up up enter |                                             |
+      | enter all          | enter                   |                                             |
+      | config storage     | enter                   |                                             |
 
   Scenario: result
     Then Git Town runs the commands
diff --git a/internal/cli/dialog/forge_type.go b/internal/cli/dialog/forge_type.go
index ed4cca1d0..7bf65fd4b 100644
--- a/internal/cli/dialog/forge_type.go
+++ b/internal/cli/dialog/forge_type.go
@@ -37,6 +37,10 @@ func ForgeType(args Args[forgedomain.ForgeType]) (Option[forgedomain.ForgeType],
 			Data: None[forgedomain.ForgeType](),
 			Text: messages.AutoDetect,
 		},
+		{
+			Data: Some(forgedomain.ForgeTypeAzureDevOps),
+			Text: "Azure DevOps",
+		},
 		{
 			Data: Some(forgedomain.ForgeTypeBitbucket),
 			Text: "Bitbucket",
diff --git a/internal/forge/azuredevops/doc.go b/internal/forge/azuredevops/doc.go
new file mode 100644
index 000000000..e6f87a0b8
--- /dev/null
+++ b/internal/forge/azuredevops/doc.go
@@ -0,0 +1,2 @@
+// Package azuredevops connects to Azure Devops.
+package azuredevops
diff --git a/internal/forge/azuredevops/new.go b/internal/forge/azuredevops/new.go
new file mode 100644
index 000000000..471e9a984
--- /dev/null
+++ b/internal/forge/azuredevops/new.go
@@ -0,0 +1,28 @@
+package azuredevops
+
+import (
+	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
+	"github.com/git-town/git-town/v21/internal/git/giturl"
+	. "github.com/git-town/git-town/v21/pkg/prelude"
+)
+
+// Detect indicates whether the current repository is hosted on Azure DevOps.
+func Detect(remoteURL giturl.Parts) bool {
+	return remoteURL.Host == "dev.azure.com" || remoteURL.Host == "ssh.dev.azure.com"
+}
+
+type NewConnectorArgs struct {
+	ProposalOverride Option[forgedomain.ProposalOverride]
+	RemoteURL        giturl.Parts
+}
+
+// NewConnector provides the correct connector for talking to Azure DevOps.
+func NewConnector(args NewConnectorArgs) WebConnector {
+	return WebConnector{
+		Data: forgedomain.Data{
+			Hostname:     args.RemoteURL.Host,
+			Organization: args.RemoteURL.Org,
+			Repository:   args.RemoteURL.Repo,
+		},
+	}
+}
diff --git a/internal/forge/azuredevops/web_connector.go b/internal/forge/azuredevops/web_connector.go
new file mode 100644
index 000000000..9ac3aba1e
--- /dev/null
+++ b/internal/forge/azuredevops/web_connector.go
@@ -0,0 +1,49 @@
+package azuredevops
+
+import (
+	"fmt"
+	"net/url"
+
+	"github.com/git-town/git-town/v21/internal/browser"
+	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
+	"github.com/git-town/git-town/v21/internal/subshell/subshelldomain"
+)
+
+var (
+	webConnector WebConnector
+	_            forgedomain.Connector = webConnector
+)
+
+// WebConnector provides connectivity to Azure DevOps through the web browser.
+type WebConnector struct {
+	forgedomain.Data
+}
+
+func (self WebConnector) BrowseRepository(runner subshelldomain.Runner) error {
+	browser.Open(self.RepositoryURL(), runner)
+	return nil
+}
+
+func (self WebConnector) CreateProposal(data forgedomain.CreateProposalArgs) error {
+	browser.Open(self.NewProposalURL(data), data.FrontendRunner)
+	return nil
+}
+
+func (self WebConnector) DefaultProposalMessage(data forgedomain.ProposalData) string {
+	return forgedomain.CommitBody(data, fmt.Sprintf("%s (#%d)", data.Title, data.Number))
+}
+
+func (self WebConnector) NewProposalURL(data forgedomain.CreateProposalArgs) string {
+	// https://dev.azure.com/kevingoslar/tikibase/_git/tikibase/pullrequestcreate?sourceRef=kg-test&targetRef=main
+	return fmt.Sprintf("%s/_git/%s/pullrequestcreate?sourceRef=%s&targetRef=%s",
+		self.RepositoryURL(),
+		url.QueryEscape(self.Repository),
+		url.QueryEscape(data.Branch.String()),
+		url.QueryEscape(data.ParentBranch.String()),
+	)
+}
+
+func (self WebConnector) RepositoryURL() string {
+	// https://dev.azure.com/kevingoslar/_git/tikibase
+	return fmt.Sprintf("https://dev.azure.com/%s/_git/%s", self.Organization, self.Repository)
+}
diff --git a/internal/forge/detect.go b/internal/forge/detect.go
index f2fc47eea..fceecaff8 100644
--- a/internal/forge/detect.go
+++ b/internal/forge/detect.go
@@ -1,6 +1,7 @@
 package forge
 
 import (
+	"github.com/git-town/git-town/v21/internal/forge/azuredevops"
 	"github.com/git-town/git-town/v21/internal/forge/bitbucketcloud"
 	"github.com/git-town/git-town/v21/internal/forge/bitbucketdatacenter"
 	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
@@ -17,6 +18,7 @@ func Detect(remoteURL giturl.Parts, userOverride Option[forgedomain.ForgeType])
 		return userOverride
 	}
 	detectors := map[forgedomain.ForgeType]func(giturl.Parts) bool{
+		forgedomain.ForgeTypeAzureDevOps:         azuredevops.Detect,
 		forgedomain.ForgeTypeBitbucket:           bitbucketcloud.Detect,
 		forgedomain.ForgeTypeBitbucketDatacenter: bitbucketdatacenter.Detect,
 		forgedomain.ForgeTypeForgejo:             forgejo.Detect,
diff --git a/internal/forge/forgedomain/forge_type.go b/internal/forge/forgedomain/forge_type.go
index e70d3f2db..2e3c83429 100644
--- a/internal/forge/forgedomain/forge_type.go
+++ b/internal/forge/forgedomain/forge_type.go
@@ -14,6 +14,7 @@ type ForgeType string
 func (self ForgeType) String() string { return string(self) }
 
 const (
+	ForgeTypeAzureDevOps         ForgeType = "azuredevops"
 	ForgeTypeBitbucket           ForgeType = "bitbucket"
 	ForgeTypeBitbucketDatacenter ForgeType = "bitbucket-datacenter"
 	ForgeTypeForgejo             ForgeType = "forgejo"
@@ -39,6 +40,7 @@ func ParseForgeType(name string) (Option[ForgeType], error) {
 // forgeTypes provides all legal values for ForgeType
 func forgeTypes() []ForgeType {
 	return []ForgeType{
+		ForgeTypeAzureDevOps,
 		ForgeTypeBitbucket,
 		ForgeTypeBitbucketDatacenter,
 		ForgeTypeForgejo,
diff --git a/internal/forge/forgedomain/proposal.go b/internal/forge/forgedomain/proposal.go
index bd826b50e..b0169b8a2 100644
--- a/internal/forge/forgedomain/proposal.go
+++ b/internal/forge/forgedomain/proposal.go
@@ -43,7 +43,7 @@ func (self *Proposal) UnmarshalJSON(b []byte) error {
 		var data BitbucketCloudProposalData
 		err = json.Unmarshal(mapping["data"], &data)
 		self.Data = data
-	case ForgeTypeBitbucketDatacenter, ForgeTypeForgejo, ForgeTypeGitHub, ForgeTypeGitLab, ForgeTypeGitea:
+	case ForgeTypeAzureDevOps, ForgeTypeBitbucketDatacenter, ForgeTypeForgejo, ForgeTypeGitHub, ForgeTypeGitLab, ForgeTypeGitea:
 		var data ProposalData
 		err = json.Unmarshal(mapping["data"], &data)
 		self.Data = data
diff --git a/internal/forge/new_connector.go b/internal/forge/new_connector.go
index c56df2329..971c6edfd 100644
--- a/internal/forge/new_connector.go
+++ b/internal/forge/new_connector.go
@@ -2,6 +2,7 @@ package forge
 
 import (
 	"github.com/git-town/git-town/v21/internal/cli/print"
+	"github.com/git-town/git-town/v21/internal/forge/azuredevops"
 	"github.com/git-town/git-town/v21/internal/forge/bitbucketcloud"
 	"github.com/git-town/git-town/v21/internal/forge/bitbucketdatacenter"
 	"github.com/git-town/git-town/v21/internal/forge/forgedomain"
@@ -27,6 +28,11 @@ func NewConnector(args NewConnectorArgs) (Option[forgedomain.Connector], error)
 	var connector forgedomain.Connector
 	var err error
 	switch forgeType {
+	case forgedomain.ForgeTypeAzureDevOps:
+		connector = azuredevops.NewConnector(azuredevops.NewConnectorArgs{
+			ProposalOverride: proposalOverride,
+			RemoteURL:        remoteURL,
+		})
 	case forgedomain.ForgeTypeBitbucket:
 		connector = bitbucketcloud.NewConnector(bitbucketcloud.NewConnectorArgs{
 			AppPassword:      args.BitbucketAppPassword,
diff --git a/internal/setup/enter.go b/internal/setup/enter.go
index 625506b21..def4f4fcc 100644
--- a/internal/setup/enter.go
+++ b/internal/setup/enter.go
@@ -63,6 +63,8 @@ EnterForgeData:
 	gitlabToken := None[forgedomain.GitLabToken]()
 	if forgeType, hasForgeType := actualForgeType.Get(); hasForgeType {
 		switch forgeType {
+		case forgedomain.ForgeTypeAzureDevOps:
+			// the Azure DevOps connector doesn't have connectivity to the API implemented for now
 		case forgedomain.ForgeTypeBitbucket, forgedomain.ForgeTypeBitbucketDatacenter:
 			bitbucketUsername, exit, err = enterBitbucketUserName(data)
 			if err != nil || exit {
@@ -676,6 +678,8 @@ func existsAndChanged[T fmt.Stringer](input, existing T) bool {
 func shouldAskForScope(args enterTokenScopeArgs) bool {
 	if forgeType, hasForgeType := args.determinedForgeType.Get(); hasForgeType {
 		switch forgeType {
+		case forgedomain.ForgeTypeAzureDevOps:
+			return false
 		case forgedomain.ForgeTypeBitbucket, forgedomain.ForgeTypeBitbucketDatacenter:
 			return existsAndChanged(args.bitbucketUsername, args.existingConfig.BitbucketUsername) &&
 				existsAndChanged(args.bitbucketAppPassword, args.existingConfig.BitbucketAppPassword)
@@ -753,6 +757,8 @@ type testForgeAuthArgs struct {
 func tokenScopeDialog(args enterTokenScopeArgs) (configdomain.ConfigScope, dialogdomain.Exit, error) {
 	if forgeType, hasForgeType := args.determinedForgeType.Get(); hasForgeType {
 		switch forgeType {
+		case forgedomain.ForgeTypeAzureDevOps:
+			return configdomain.ConfigScopeLocal, false, nil
 		case forgedomain.ForgeTypeBitbucket, forgedomain.ForgeTypeBitbucketDatacenter:
 			existingScope := determineExistingScope(args.data.Snapshot, configdomain.KeyBitbucketUsername, args.data.Config.NormalConfig.BitbucketUsername)
 			return dialog.TokenScope(existingScope, args.inputs)
diff --git a/internal/setup/save.go b/internal/setup/save.go
index d949823c4..5b34f3ca4 100644
--- a/internal/setup/save.go
+++ b/internal/setup/save.go
@@ -22,6 +22,8 @@ func Save(userInput UserInput, unvalidatedConfig config.UnvalidatedConfig, data
 	)
 	if forgeType, hasForgeType := userInput.DeterminedForgeType.Get(); hasForgeType {
 		switch forgeType {
+		case forgedomain.ForgeTypeAzureDevOps:
+			// no API token for now
 		case forgedomain.ForgeTypeBitbucket, forgedomain.ForgeTypeBitbucketDatacenter:
 			fc.Check(
 				saveBitbucketUsername(userInput.Data.BitbucketUsername, unvalidatedConfig.GitLocal.BitbucketUsername, userInput.Scope, frontend),
diff --git a/website/src/preferences/forge-type.md b/website/src/preferences/forge-type.md
index 3dfa15480..d56872f2b 100644
--- a/website/src/preferences/forge-type.md
+++ b/website/src/preferences/forge-type.md
@@ -19,6 +19,7 @@ You can use one of these values for the forge type setting:
 - `bitbucket`
 - `bitbucket-datacenter`
 - `forgejo`
+- `azuredevops` (experimental)
 
 ## config file
 



Test patch:
diff --git a/internal/forge/detect_test.go b/internal/forge/detect_test.go
index 4c1b29717..8122a8065 100644
--- a/internal/forge/detect_test.go
+++ b/internal/forge/detect_test.go
@@ -13,6 +13,15 @@ import (
 func TestDetect(t *testing.T) {
 	t.Parallel()
 
+	t.Run("Azure DevOps", func(t *testing.T) {
+		t.Parallel()
+		url, has := giturl.Parse("username@ssh.dev.azure.com:v3/kevingoslar/tikibase/tikibase").Get()
+		must.True(t, has)
+		have := forge.Detect(url, None[forgedomain.ForgeType]())
+		want := Some(forgedomain.ForgeTypeAzureDevOps)
+		must.Eq(t, want, have)
+	})
+
 	t.Run("BitBucket SAAS, no override", func(t *testing.T) {
 		t.Parallel()
 		url, has := giturl.Parse("username@bitbucket.org:git-town/docs.git").Get()
diff --git a/internal/forge/forgedomain/forge_type_test.go b/internal/forge/forgedomain/forge_type_test.go
index 24bf7a91c..3c9c92ea7 100644
--- a/internal/forge/forgedomain/forge_type_test.go
+++ b/internal/forge/forgedomain/forge_type_test.go
@@ -15,6 +15,9 @@ func TestParseForgeType(t *testing.T) {
 		t.Parallel()
 		tests := map[string]Option[forgedomain.ForgeType]{
 			"":                     None[forgedomain.ForgeType](),
+			"azuredevops":          Some(forgedomain.ForgeTypeAzureDevOps),
+			"AzureDevOps":          Some(forgedomain.ForgeTypeAzureDevOps),
+			"AZUREDEVOPS":          Some(forgedomain.ForgeTypeAzureDevOps),
 			"bitbucket":            Some(forgedomain.ForgeTypeBitbucket),
 			"BitBucket":            Some(forgedomain.ForgeTypeBitbucket),
 			"BITBUCKET":            Some(forgedomain.ForgeTypeBitbucket),