Skip to content

Commit

Permalink
feat(vcs): parse API credentials from (push) repository URL
Browse files Browse the repository at this point in the history
This allows using fine-grained access tokens instead of instance scoped
access.

Fixes #12439
  • Loading branch information
nijel committed Sep 11, 2024
1 parent 467b36a commit a60d3e1
Show file tree
Hide file tree
Showing 4 changed files with 33 additions and 12 deletions.
4 changes: 4 additions & 0 deletions docs/admin/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2115,6 +2115,10 @@ The configuration dictionary consists of credentials defined for each API host.
The API host might be different from what you use in the web browser, for
example GitHub API is accessed as ``api.github.com``.

The credentials can also be overridden in :ref:`component-push` or
:ref:`component-repo` (if push URL is not configured), these take precedence
over the ones specified in the configuration file.

The following configuration is available for each host:

``username``
Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Not yet released.
**New features**

* :ref:`Searching` now supports filtering by object path.
* Merge requests credentials can now be passed in the repository URL, see :ref:`settings-credentials`.

**Improvements**

Expand Down
30 changes: 19 additions & 11 deletions weblate/vcs/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -802,12 +802,14 @@ def merge(

def parse_repo_url(
self, repo: str | None = None
) -> tuple[str | None, str, str, str]:
) -> tuple[str | None, str | None, str | None, str, str, str]:
if repo is None:
repo = self.component.repo
repo = self.component.push or self.component.repo
parsed = urllib.parse.urlparse(repo)
host = parsed.hostname
scheme: str | None = parsed.scheme
username: str | None = parsed.username
password: str | None = parsed.password
if not host:
# Assume SSH URL
host, path = repo.split(":")
Expand All @@ -829,7 +831,7 @@ def parse_repo_url(
continue
slug_parts.insert(-1, part)
slug = "/".join(slug_parts)
return (scheme, host, owner, slug)
return (scheme, username, password, host, owner, slug)

def format_url(
self, scheme: str, hostname: str, owner: str, slug: str, **extra: str
Expand Down Expand Up @@ -878,10 +880,14 @@ def get_credentials_configuration(cls):
return getattr(settings, cls.get_credentials_name())

def get_credentials(self) -> dict[str, str]:
scheme, host, owner, slug = self.parse_repo_url()
scheme, username, password, host, owner, slug = self.parse_repo_url()
hostname = self.format_api_host(host).lower()
credentials = self.get_credentials_by_hostname(hostname)

if not username or not password:
username = credentials["username"]
password = credentials["token"]

# Scheme override
if "scheme" in credentials:
scheme = credentials["scheme"]
Expand All @@ -894,8 +900,8 @@ def get_credentials(self) -> dict[str, str]:
"owner": owner,
"slug": slug,
"hostname": hostname,
"username": credentials["username"],
"token": credentials["token"],
"username": username,
"token": password,
"scheme": scheme,
}

Expand Down Expand Up @@ -1211,7 +1217,7 @@ def fork(self, credentials: dict) -> None:

def parse_repo_url(
self, repo: str | None = None
) -> tuple[str | None, str, str, str]:
) -> tuple[str | None, str | None, str | None, str, str, str]:
if repo is None:
repo = self.component.repo

Expand All @@ -1220,7 +1226,7 @@ def parse_repo_url(
if not re.match(scheme_regex, repo):
repo = f"ssh://{repo}" # assume all links without schema are ssh links

(scheme, host, owner, slug) = super().parse_repo_url(repo)
(scheme, username, password, host, owner, slug) = super().parse_repo_url(repo)

# ssh links are in a subdomain, the API link doesn't have that so remove it
if host.startswith("ssh."):
Expand All @@ -1237,7 +1243,7 @@ def parse_repo_url(
owner = owner + "/" + parts[0] # we want owner to be org/project
slug = parts[1]

return scheme, host, owner, slug
return (scheme, username, password, host, owner, slug)

def get_headers(self, credentials: dict) -> dict[str, str]:
headers = super().get_headers(credentials)
Expand Down Expand Up @@ -1358,7 +1364,9 @@ def __get_forked_id(self, credentials: dict, remote: str) -> str:
"""
cmd = ["remote", "get-url", "--push", remote]
fork_remotes = self.execute(cmd, needs_lock=False, merge_err=False).splitlines()
(_, hostname, owner, slug) = self.parse_repo_url(fork_remotes[0])
(_scheme, _username, _password, hostname, owner, slug) = self.parse_repo_url(
fork_remotes[0]
)
url = self.format_url("https", hostname, owner, slug)

# Get repo info
Expand Down Expand Up @@ -1717,7 +1725,7 @@ class GitLabRepository(GitMergeRequestBase):
)

def get_fork_path(self, repo: str) -> str:
_scheme, _host, owner, slug = self.parse_repo_url(repo)
_scheme, _username, _password, _host, owner, slug = self.parse_repo_url(repo)
return urllib.parse.quote(f"{owner}/{slug}", safe="")

def get_forked_url(self, credentials: dict) -> str:
Expand Down
10 changes: 9 additions & 1 deletion weblate/vcs/tests/test_vcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1364,11 +1364,19 @@ def test_get_fork_path(self) -> None:
),
"group1%2Fsubgroup%2Fproject",
)

def test_parse_repo_url(self) -> None:
self.assertEqual(
self.repo.parse_repo_url(
"git@gitlab.domain.com:group1/subgroup/project.git"
),
(None, "gitlab.domain.com", "group1", "subgroup/project"),
(None, None, None, "gitlab.domain.com", "group1", "subgroup/project"),
)
self.assertEqual(
self.repo.parse_repo_url(
"https://bot:glpat@gitlab.com/path/group/repo.git"
),
("https", "bot", "glpat", "gitlab.com", "path", "group/repo"),
)

@override_settings(
Expand Down

0 comments on commit a60d3e1

Please # to comment.