Repository: WeblateOrg/weblate

Issue:
Support Commit Hooks for Azure Repositories connected via ssh
**Is your feature request related to a problem? If so, please describe.**
Right now it's only possible to send commit hooks from Azure Repositories that are cloned via HTTP(S) but not the ones which are cloned via SSH like for example it is supported for hooks from GitHub.

**Describe the solution you'd like**
The hooks.py view should use the same mechanism to provide possible repositories that it used e.g. for GitHub (GITHUB_REPOS variable) by constructing possible URLs.

**Describe alternatives you've considered**
Cloning would be possible via HTTP(S) but SSH based access is more convenient e.g. in terms of expiry date since Azure DevOps PAT tokens (used to clone the git repository) can only have a max token lifetime of one year.


Discussion:
I already opened a StackOverflow question to provide a list of all possible Azure Repositories URL's to help with implementation:
https://stackoverflow.com/questions/64952871/list-of-all-possible-azure-repos-urls
The matching falls back to repos ending with same URL, that should cover most cases. But exposing correct URLs could help in some situations.
@nijel Did I get it right that normally ssh based access should work already?

I configured the repository with the following url in Weblate:
git@ssh.dev.azure.com:v3/{OrganizationName}/{ProjectName}/{RepositoryName}

And the Webhook from Azure Repos sends the following repository URL (The value of "/resource/repository/remoteUrl" in the request body):
https://dev.azure.com/{OrganizationName}/{ProjectName}/_git/{RepositoryName}

And unfortunately I get
```json
{
    "status": "failure",
    "message": "No matching repositories found!"
}
```

I have no knowledge of how Azure constructs URLs, but it does work for most of the other services which have similar URLs on SSH and HTTPS.

You describe that Azure does this differently and injects `_git` somewhere in the HTTPS URL what is not present in the SSH URL, so this logic will not help there.

Resolving patch:
diff --git a/weblate/trans/views/hooks.py b/weblate/trans/views/hooks.py
--- a/weblate/trans/views/hooks.py
+++ b/weblate/trans/views/hooks.py
@@ -71,6 +71,14 @@
     "ssh://git@{server}/{project}.git",
 )
 
+AZURE_REPOS = (
+    "https://dev.azure.com/{organization}/{project}/_git/{repository}",
+    "https://dev.azure.com/{organization}/{projectId}/_git/{repositoryId}",
+    "git@ssh.dev.azure.com:v3/{organization}/{project}/{repository}",
+    "https://{organization}.visualstudio.com/{project}/_git/{repository}",
+    "{organization}@vs-ssh.visualstudio.com:v3/{organization}/{project}/{repository}",
+)
+
 HOOK_HANDLERS = {}
 
 
@@ -465,11 +473,51 @@ def pagure_hook_helper(data, request):
 def azure_hook_helper(data, request):
     if data.get("eventType") != "git.push":
         return None
+
     http_url = data["resource"]["repository"]["remoteUrl"]
+    branch = re.sub(r"^refs/heads/", "", data["resource"]["refUpdates"][0]["name"])
+    project = data["resource"]["repository"]["project"]["name"]
+    projectid = data["resource"]["repository"]["project"]["id"]
+    repository = data["resource"]["repository"]["name"]
+    repositoryid = data["resource"]["repository"]["id"]
+
+    match = re.match(
+        r"^https?:\/\/dev\.azure\.com\/"
+        r"(?P<organization>[a-zA-Z0-9]+[a-zA-Z0-9-]*[a-zA-Z0-9]*)",
+        http_url,
+    )
+
+    # Fallback to support old url structure {organization}.visualstudio.com
+    if match is None:
+        match = re.match(
+            r"^https?:\/\/"
+            r"(?P<organization>[a-zA-Z0-9]+[a-zA-Z0-9-]*[a-zA-Z0-9]*)"
+            r"\.visualstudio\.com",
+            http_url,
+        )
+    organization = None
+
+    if match is not None:
+        organization = match.group("organization")
+
+    if organization is not None:
+        repos = [
+            repo.format(
+                organization=organization,
+                project=project,
+                projectId=projectid,
+                repository=repository,
+                repositoryId=repositoryid,
+            )
+            for repo in AZURE_REPOS
+        ]
+    else:
+        repos = [http_url]
+
     return {
         "service_long_name": "Azure",
         "repo_url": http_url,
-        "repos": [http_url],
-        "branch": data["resource"]["refUpdates"][0]["name"].rsplit("/")[-1],
-        "full_name": data["resource"]["repository"]["name"],
+        "repos": repos,
+        "branch": branch,
+        "full_name": repository,
     }


Test patch:
diff --git a/weblate/trans/tests/test_hooks.py b/weblate/trans/tests/test_hooks.py
--- a/weblate/trans/tests/test_hooks.py
+++ b/weblate/trans/tests/test_hooks.py
@@ -739,8 +739,136 @@
 }
 """
 
+AZURE_PAYLOAD_FALLBACK = """
+{
+  "subscriptionId": "e40cce28-7b73-4d33-ada2-2f5bd5e070ce",
+  "notificationId": 18,
+  "id": "108f81f3-fc7a-4aef-b990-14a8a31de20f",
+  "eventType": "git.push",
+  "publisherId": "tfs",
+  "resource": {
+    "refUpdates": [
+      {
+        "name": "refs/heads/feat/localization",
+        "oldObjectId": "9e219f8adc6d2f42e9228d33aeacb227e74439de",
+        "newObjectId": "7d85491a4f0289f2ffcf70939b7c7160e8ce2865"
+      }
+    ],
+    "repository": {
+      "id": "278d5cd2-584d-4b63-824a-2ba458937249",
+      "name": "ATEST",
+      "url": "https://dev.azure.com/f/_apis/git/repositories/278d5cd2-584d-4b63",
+      "project": {
+        "id": "be9b3917-87e6-42a4-a549-2bc06a7a878f",
+        "name": "p",
+        "url": "https://dev.azure.com/f/_apis/projects/be9b3917-87e6-42a4"
+      },
+      "defaultBranch": "refs/heads/master",
+      "remoteUrl": "https://devops.azure.com/f/p/_git/ATEST"
+    },
+    "pushId": 1,
+    "date": "2014-05-02T19:17:13.3309587Z",
+    "url": "https://dev.azure.com/f/_apis/git/repositories/278d5cd2-584d-4b63/pushes/1"
+  },
+  "resourceVersion": "1.0",
+  "resourceContainers": {
+    "collection": {
+      "id": "ce901e71-c714-4dcc-a641-7e73281fd0d5"
+    },
+    "account": {
+      "id": "f60924c9-19b8-461e-9c85-fab350512c61"
+    },
+    "project": {
+      "id": "be9b3917-87e6-42a4-a549-2bc06a7a878f"
+    }
+  },
+  "createdDate": "2014-05-02T20:45:11.5664246Z"
+}
+"""
 
-AZURE_PAYLOAD = r"""
+AZURE_PAYLOAD_NEW = """
+{
+  "subscriptionId": "e40cce28-7b73-4d33-ada2-2f5bd5e070ce",
+  "notificationId": 18,
+  "id": "108f81f3-fc7a-4aef-b990-14a8a31de20f",
+  "eventType": "git.push",
+  "publisherId": "tfs",
+  "message": {
+    "text": "Jamal Hartnett pushed updates to ATEST:master.",
+    "html": "Jamal Hartnett pushed updates to ATEST:master.",
+    "markdown": "Jamal Hartnett pushed updates to `ATEST`:`master`."
+  },
+  "detailedMessage": {
+    "text": "Jamal Hartnett pushed a commit to ATEST:master.",
+    "html": "Jamal Hartnett pushed a commit to ",
+    "markdown": "Jamal Hartnett pushed a commit to [ATEST])"
+  },
+  "resource": {
+    "commits": [
+      {
+        "commitId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74",
+        "author": {
+          "name": "Jamal Hartnett",
+          "email": "fabrikamfiber4@hotmail.com",
+          "date": "2015-02-25T19:01:00Z"
+        },
+        "committer": {
+          "name": "Jamal Hartnett",
+          "email": "fabrikamfiber4@hotmail.com",
+          "date": "2015-02-25T19:01:00Z"
+        },
+        "comment": "Fixed bug in web.config file",
+        "url": "https://f.visualstudio.com/c/_git/ATEST/commit/33b55f7cb7e7e2453239"
+      }
+    ],
+    "refUpdates": [
+      {
+        "name": "refs/heads/feat/localization",
+        "oldObjectId": "9e219f8adc6d2f42e9228d33aeacb227e74439de",
+        "newObjectId": "7d85491a4f0289f2ffcf70939b7c7160e8ce2865"
+      }
+    ],
+    "repository": {
+      "id": "278d5cd2-584d-4b63-824a-2ba458937249",
+      "name": "ATEST",
+      "url": "https://dev.azure.com/f/_apis/git/repositories/278d5cd2-584d-4b63",
+      "project": {
+        "id": "be9b3917-87e6-42a4-a549-2bc06a7a878f",
+        "name": "p",
+        "url": "https://dev.azure.com/f/_apis/projects/be9b3917-87e6-42a4",
+        "state": "wellFormed",
+        "visibility": "unchanged",
+        "lastUpdateTime": "0001-01-01T00:00:00"
+      },
+      "defaultBranch": "refs/heads/master",
+      "remoteUrl": "https://dev.azure.com/f/p/_git/ATEST"
+    },
+    "pushedBy": {
+      "displayName": "Jamal Hartnett",
+      "id": "00067FFED5C7AF52@Live.com",
+      "uniqueName": "fabrikamfiber4@hotmail.com"
+    },
+    "pushId": 1,
+    "date": "2014-05-02T19:17:13.3309587Z",
+    "url": "https://dev.azure.com/f/_apis/git/repositories/278d5cd2-584d-4b63/pushes/1"
+  },
+  "resourceVersion": "1.0",
+  "resourceContainers": {
+    "collection": {
+      "id": "ce901e71-c714-4dcc-a641-7e73281fd0d5"
+    },
+    "account": {
+      "id": "f60924c9-19b8-461e-9c85-fab350512c61"
+    },
+    "project": {
+      "id": "be9b3917-87e6-42a4-a549-2bc06a7a878f"
+    }
+  },
+  "createdDate": "2014-05-02T20:45:11.5664246Z"
+}
+"""
+
+AZURE_PAYLOAD_OLD = """
 {
   "subscriptionId": "00000000-0000-0000-0000-000000000000",
   "notificationId": 1,
@@ -787,8 +915,8 @@
       "name": "ATEST",
       "url": "https://f.visualstudio.com/c/_apis/git/repositories/278d5cd2-584d-4b63",
       "project": {
-        "id": "6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c",
-        "name": "ATEST",
+        "id": "be9b3917-87e6-42a4-a549-2bc06a7a878f",
+        "name": "c",
         "url": "https://f.visualstudio.com/c/_apis/projects/6ce954b1-ce1f-45d1",
         "state": "wellFormed",
         "visibility": "unchanged",
@@ -1562,15 +1690,58 @@ class AzureBackendTest(HookBackendTestCase):
     def test_ping(self):
         self.assert_hook('{"diagnostics": "ping"}', None)
 
-    def test_git(self):
+    def test_git_old(self):
         url = "https://f.visualstudio.com/c/_git/ATEST"
         self.assert_hook(
-            AZURE_PAYLOAD,
+            AZURE_PAYLOAD_OLD,
             {
                 "branch": "master",
                 "full_name": "ATEST",
                 "repo_url": url,
-                "repos": [url],
+                "repos": [
+                    "https://dev.azure.com/f/c/_git/ATEST",
+                    (
+                        "https://dev.azure.com/f/be9b3917-87e6-42a4-a549-2bc06a7a878f/"
+                        "_git/278d5cd2-584d-4b63-824a-2ba458937249"
+                    ),
+                    "git@ssh.dev.azure.com:v3/f/c/ATEST",
+                    "https://f.visualstudio.com/c/_git/ATEST",
+                    "f@vs-ssh.visualstudio.com:v3/f/c/ATEST",
+                ],
+                "service_long_name": "Azure",
+            },
+        )
+
+    def test_git_new(self):
+        self.assert_hook(
+            AZURE_PAYLOAD_NEW,
+            {
+                "branch": "feat/localization",
+                "full_name": "ATEST",
+                "repo_url": "https://dev.azure.com/f/p/_git/ATEST",
+                "repos": [
+                    "https://dev.azure.com/f/p/_git/ATEST",
+                    (
+                        "https://dev.azure.com/f/be9b3917-87e6-42a4-a549-2bc06a7a878f/"
+                        "_git/278d5cd2-584d-4b63-824a-2ba458937249"
+                    ),
+                    "git@ssh.dev.azure.com:v3/f/p/ATEST",
+                    "https://f.visualstudio.com/p/_git/ATEST",
+                    "f@vs-ssh.visualstudio.com:v3/f/p/ATEST",
+                ],
+                "service_long_name": "Azure",
+            },
+        )
+
+    def test_git_fallback(self):
+        http_url = "https://devops.azure.com/f/p/_git/ATEST"
+        self.assert_hook(
+            AZURE_PAYLOAD_FALLBACK,
+            {
+                "branch": "feat/localization",
+                "full_name": "ATEST",
+                "repo_url": http_url,
+                "repos": [http_url],
                 "service_long_name": "Azure",
             },
         )