Repository: WeblateOrg/weblate

Issue:
Default (infer) "Repository browser" link template from "Source code repository"
### Describe the problem

Unless you explicitly specify "Repository browser" link template, repository browser links will never be generated (they'll remain as text). But we can infer them (at least for popular services like GitHub, GitLab, Bitbucket).

### Describe the solution you'd like

Either fill the "Repository browser" field automatically, or default here: https://github.com/WeblateOrg/weblate/blob/5bfcb775baf855ab89dc373a4901d20dfbe0fb1f/weblate/trans/models/component.py#L1155

### Describe alternatives you've considered

_No response_

### Screenshots

![image](https://user-images.githubusercontent.com/39462442/178154398-ebfe2783-a2b6-4000-96e8-bac6e32a5b87.png)


### Additional context

Inspired by this comment https://github.com/WeblateOrg/weblate/issues/7738#issuecomment-1146638364


Discussion:
Currently, these are configured separately, so it's up to the admin to configure it or not. In case it will be automatically generated from VCS location, it should honor the `vcs.view` permission when rendering - to avoid exposing VCS location when not desired. I think it's safe to guard existing repository browser links with this permission.
@nijel Can you please add some implementation details here. Thanks 👍 
The following code needs to be expanded to fall back to repo URL-based template if repoweb is not set:

https://github.com/WeblateOrg/weblate/blob/5bfcb775baf855ab89dc373a4901d20dfbe0fb1f/weblate/trans/models/component.py#L1155

* The get_repoweb_link needs to take `User` parameter to check `vcs.view` permission, all callers have to be adjusted to pass it
* The template will be generated for known services based on the repository URL
* If VCS is using merge requests integration (GitHub/GitLab/...) it should get the service type from that
* Otherwise detection should be based on repository URL
I started working it and I have following findings:

- I am assuming the `repo URL-based template` refers to link of the file in vcs on github/gitlab/.. etc!

- I went through `vcs/git.py` to look for merge requests integration check but I couldn't find any relevant code, where I can see if a VCS is using merge requests integration?
 
- For now, I created a draft on 1 assumption and returning template as path to the repo url + branch.
The integration is selected by the `vcs` attribute, I'd probably go with something generic like this:

```python
   template = getattr(self, f"get_{self.vcs}_repoweb_template", self.get_git_repoweb_template)()

def get_git_repoweb_template(self):
   # Do a repo URL based detection here
   if self.repo looks like GitHub:
        return self.get_github_repoweb_template()
   ...
   return None

def get_github_repoweb_template(self):
   # Extract domain, owner and slug from the repo URL
   return f"https://{domain}/{owner}/{slug}/blob/{{branch}}/{{filename}}#L{{line}}"
```

For parsing the URLs and detecting the services, you might utilize URL detection in the hooks (though that one is for the opposite direction):

https://github.com/WeblateOrg/weblate/blob/b56a360873b9afe851f2909ebb9db42877a6296f/weblate/trans/views/hooks.py#L42-L80
I added the url detection for services, I was not sure on how the URL will look like for services other than github, so kept them as to do for now. Please let me know if it is the right direction and how can I test the functionality. I went through the tests, i am assuming now I have to test something like this? https://github.com/WeblateOrg/weblate/blob/main/weblate/trans/tests/test_templatetags.py#L129-L149

Resolving patch:
diff --git a/weblate/accounts/notifications.py b/weblate/accounts/notifications.py
--- a/weblate/accounts/notifications.py
+++ b/weblate/accounts/notifications.py
@@ -481,7 +481,7 @@ def get_context(
         context = super().get_context(change, subscription, extracontext, changes)
         if change:
             context["details"]["filelink"] = change.component.get_repoweb_link(
-                change.details.get("filename"), "1"
+                change.details.get("filename"), "1", user=context["user"]
             )
         return context
 
diff --git a/weblate/trans/models/component.py b/weblate/trans/models/component.py
--- a/weblate/trans/models/component.py
+++ b/weblate/trans/models/component.py
@@ -154,6 +154,25 @@
 
 LOCKING_ALERTS = {"MergeFailure", "UpdateFailure", "PushFailure", "ParseError"}
 
+BITBUCKET_GIT_REPOS_REGEXP = [
+    r"(?:ssh|https):\/\/(?:(?:git@|)bitbucket.org)\/([^/]*)\/([^/]*)",
+    r"git@bitbucket.org:([^/]*)\/([^/]*)",
+]
+
+GITHUB_REPOS_REGEXP = [
+    r"(?:git|https):\/\/(?:github.com)\/([^/]*)\/([^/]*)",
+    r"git@github.com:([^/]*)\/([^/]*)",
+]
+
+PAGURE_REPOS_REGEXP = [r"(?:ssh|https):\/\/(?:(?:git@|)pagure.io)\/([^/]*)\/([^/]*)"]
+
+AZURE_REPOS_REGEXP = [
+    r"(?:https):\/\/(?:dev.azure.com)\/([^/]*)\/([^/]*)\/(?:_git)\/([^/]*)",
+    r"(?:https):\/\/(?:([^/]*).visualstudio.com)\/([^/]*)\/(?:_git)\/([^/]*)",
+    r"(?:[^/]*)\@vs-ssh.visualstudio.com:v3\/([^/]*)\/([^/]*)\/([^/]*)",
+    r"(?:git@ssh.dev.azure.com:v3)\/([^/]*)\/([^/]*)\/([^/]*)",
+]
+
 
 def perform_on_link(func):
     """Decorator to handle repository link."""
@@ -1155,7 +1174,11 @@ def get_export_url(self):
         return self.git_export
 
     def get_repoweb_link(
-        self, filename: str, line: str, template: Optional[str] = None
+        self,
+        filename: str,
+        line: str,
+        template: Optional[str] = None,
+        user=None,
     ):
         """Generate link to source code browser for given file and line.
 
@@ -1163,9 +1186,18 @@ def get_repoweb_link(
         here.
         """
         if not template:
-            template = self.repoweb
+            if self.repoweb:
+                template = self.repoweb
+            elif user and user.has_perm("vcs.view", self):
+                template = getattr(
+                    self,
+                    f"get_{self.vcs}_repoweb_template",
+                    self.get_git_repoweb_template,
+                )()
         if self.is_repo_link:
-            return self.linked_component.get_repoweb_link(filename, line, template)
+            return self.linked_component.get_repoweb_link(
+                filename, line, template, user=self.acting_user
+            )
         if not template:
             if filename.startswith("https://"):
                 return filename
@@ -1179,6 +1211,106 @@ def get_repoweb_link(
             component=self,
         )
 
+    def get_git_repoweb_template(self):
+        """Method to return the template link for a specific vcs."""
+        parsed_url = urlparse(self.repo)
+
+        if (
+            self.repo.startswith("git@bitbucket.org")
+            or parsed_url.hostname == "bitbucket.org"
+        ):
+            return self.get_bitbucket_git_repoweb_template()
+
+        if (
+            self.repo.startswith("git@github.com")
+            or parsed_url.hostname == "github.com"
+        ):
+            return self.get_github_repoweb_template()
+
+        if parsed_url.hostname == "pagure.io":
+            return self.get_pagure_repoweb_template()
+
+        if (
+            self.repo.startswith("git@ssh.dev.azure.com:v3")
+            or parsed_url.hostname == "dev.azure.com"
+            or parsed_url.hostname.split(".", 1)[-1] == "visualstudio.com"
+        ):
+            return self.get_azure_repoweb_template()
+
+        return None
+
+    def get_clean_slug(self, slug):
+        if slug.endswith(".git"):
+            slug = slug[:-4]
+        return slug
+
+    def get_bitbucket_git_repoweb_template(self):
+        owner, slug, matches = None, None, None
+        domain = "bitbucket.org"
+        if re.match(BITBUCKET_GIT_REPOS_REGEXP[0], self.repo):
+            matches = re.search(BITBUCKET_GIT_REPOS_REGEXP[0], self.repo)
+        elif re.match(BITBUCKET_GIT_REPOS_REGEXP[1], self.repo):
+            matches = re.search(BITBUCKET_GIT_REPOS_REGEXP[1], self.repo)
+        if matches:
+            owner = matches.group(1)
+            slug = self.get_clean_slug(matches.group(2))
+        if owner and slug:
+            return (
+                f"https://{domain}/{owner}/{slug}/blob/{{branch}}/{{filename}}#{{line}}"
+            )
+
+        return None
+
+    def get_github_repoweb_template(self):
+        owner, slug, matches = None, None, None
+        domain = "github.com"
+        if re.match(GITHUB_REPOS_REGEXP[0], self.repo):
+            matches = re.search(GITHUB_REPOS_REGEXP[0], self.repo)
+        elif re.match(GITHUB_REPOS_REGEXP[1], self.repo):
+            matches = re.search(GITHUB_REPOS_REGEXP[1], self.repo)
+        if matches:
+            owner = matches.group(1)
+            slug = self.get_clean_slug(matches.group(2))
+        if owner and slug:
+            return f"https://{domain}/{owner}/{slug}/blob/{{branch}}/{{filename}}#L{{line}}"  # noqa
+
+        return None
+
+    def get_pagure_repoweb_template(self):
+        owner, slug = None, None
+        domain = "pagure.io"
+        if re.match(PAGURE_REPOS_REGEXP[0], self.repo):
+            matches = re.search(PAGURE_REPOS_REGEXP[0], self.repo)
+            owner = matches.group(1)
+            slug = matches.group(2)
+
+        if owner and slug:
+            return f"https://{domain}/{owner}/{slug}/blob/{{branch}}/f/{{filename}}/#_{{line}}"  # noqa
+
+        return None
+
+    def get_azure_repoweb_template(self):
+        organization, project, repository, matches = None, None, None, None
+        domain = "dev.azure.com"
+        if re.match(AZURE_REPOS_REGEXP[0], self.repo):
+            matches = re.search(AZURE_REPOS_REGEXP[0], self.repo)
+        elif re.match(AZURE_REPOS_REGEXP[1], self.repo):
+            matches = re.search(AZURE_REPOS_REGEXP[1], self.repo)
+        elif re.match(AZURE_REPOS_REGEXP[2], self.repo):
+            matches = re.search(AZURE_REPOS_REGEXP[2], self.repo)
+        elif re.match(AZURE_REPOS_REGEXP[3], self.repo):
+            matches = re.search(AZURE_REPOS_REGEXP[3], self.repo)
+
+        if matches:
+            organization = matches.group(1)
+            project = matches.group(2)
+            repository = matches.group(3)
+
+        if organization and project and repository:
+            return f"https://{domain}/{organization}/{project}/_git/{repository}/blob/{{branch}}/{{filename}}#L{{line}}"  # noqa
+
+        return None
+
     def error_text(self, error):
         """Returns text message for a RepositoryException."""
         message = error.get_message()


Test patch:
diff --git a/weblate/trans/tests/test_component.py b/weblate/trans/tests/test_component.py
--- a/weblate/trans/tests/test_component.py
+++ b/weblate/trans/tests/test_component.py
@@ -586,6 +586,69 @@ def test_unlink(self):
         component.repo = component.linked_component.repo
         component.save()
 
+    def test_repo_link_generation_bitbucket(self):
+        """Test changing repo attribute to check repo generation links."""
+        component = self.create_component()
+        component.repo = "ssh://git@bitbucket.org/marcus/project-x.git"
+        result = component.get_bitbucket_git_repoweb_template()
+        self.assertEqual(
+            result,
+            "https://bitbucket.org/marcus/project-x/blob/{branch}/{filename}#{line}",  # noqa
+        )
+        component.repo = "git@bitbucket.org:marcus/project-x.git"
+        result = component.get_bitbucket_git_repoweb_template()
+        self.assertEqual(
+            result,
+            "https://bitbucket.org/marcus/project-x/blob/{branch}/{filename}#{line}",  # noqa
+        )
+
+    def test_repo_link_generation_github(self):
+        """Test changing repo attribute to check repo generation links."""
+        component = self.create_component()
+        component.repo = "git://github.com/marcus/project-x.git"
+        result = component.get_github_repoweb_template()
+        self.assertEqual(
+            result,
+            "https://github.com/marcus/project-x/blob/{branch}/{filename}#L{line}",
+        )
+        component.repo = "git@github.com:marcus/project-x.git"
+        result = component.get_github_repoweb_template()
+        self.assertEqual(
+            result,
+            "https://github.com/marcus/project-x/blob/{branch}/{filename}#L{line}",
+        )
+
+    def test_repo_link_generation_pagure(self):
+        """Test changing repo attribute to check repo generation links."""
+        component = self.create_component()
+        component.repo = "https://pagure.io/f/ATEST"
+        result = component.get_pagure_repoweb_template()
+        self.assertEqual(
+            result, "https://pagure.io/f/ATEST/blob/{branch}/f/{filename}/#_{line}"
+        )
+
+    def test_repo_link_generation_azure(self):
+        """Test changing repo attribute to check repo generation links."""
+        component = self.create_component()
+        component.repo = "f@vs-ssh.visualstudio.com:v3/f/c/ATEST"
+        result = component.get_azure_repoweb_template()
+        self.assertEqual(
+            result,
+            "https://dev.azure.com/f/c/_git/ATEST/blob/{branch}/{filename}#L{line}",
+        )
+        component.repo = "git@ssh.dev.azure.com:v3/f/c/ATEST"
+        result = component.get_azure_repoweb_template()
+        self.assertEqual(
+            result,
+            "https://dev.azure.com/f/c/_git/ATEST/blob/{branch}/{filename}#L{line}",
+        )
+        component.repo = "https://f.visualstudio.com/c/_git/ATEST"
+        result = component.get_azure_repoweb_template()
+        self.assertEqual(
+            result,
+            "https://dev.azure.com/f/c/_git/ATEST/blob/{branch}/{filename}#L{line}",
+        )
+
     def test_change_project(self):
         component = self.create_component()