Skip to content

Commit c723034

Browse files
committed
add azure devops for pr creation
1 parent 94c2d57 commit c723034

File tree

6 files changed

+611
-427
lines changed

6 files changed

+611
-427
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ jobs:
7373
path: dist/
7474

7575
- name: Sign the dists with Sigstore
76-
uses: sigstore/gh-action-sigstore-python@v3
76+
uses: sigstore/gh-action-sigstore-python@v3.0.0
7777
with:
7878
inputs: >-
7979
./dist/*.tar.gz

patchwork/common/client/scm.py

Lines changed: 192 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,23 @@
66
import time
77
from enum import Enum
88
from itertools import chain
9+
from pathlib import Path
10+
from urllib.parse import urlparse
911

12+
import git
1013
import gitlab.const
1114
from attrs import define
15+
from azure.devops.connection import Connection
16+
from azure.devops.released.client_factory import ClientFactory
17+
from azure.devops.released.core.core_client import CoreClient
18+
from azure.devops.released.git.git_client import GitClient
19+
from azure.devops.v7_1.git.models import GitPullRequest, GitPullRequestSearchCriteria, TeamProjectReference, GitRepository
1220
from github import Auth, Consts, Github, GithubException, PullRequest
1321
from github.GithubException import UnknownObjectException
1422
from gitlab import Gitlab, GitlabAuthenticationError, GitlabError
1523
from gitlab.v4.objects import ProjectMergeRequest
1624
from giturlparse import GitUrlParsed, parse
25+
from msrest.authentication import BasicAuthentication
1726
from typing_extensions import Protocol, TypedDict
1827

1928
from patchwork.logger import logger
@@ -53,12 +62,13 @@ class PullRequestTexts(TypedDict):
5362

5463

5564
class PullRequestState(Enum):
56-
OPEN = (["open"], ["opened"])
57-
CLOSED = (["closed"], ["closed", "merged"])
65+
OPEN = (["open"], ["opened"], ["active"])
66+
CLOSED = (["closed"], ["closed", "merged"], ["completed", "abandoned", "notSet"])
5867

59-
def __init__(self, github_state: list[str], gitlab_state: list[str]):
68+
def __init__(self, github_state: list[str], gitlab_state: list[str], azure_devops_state: list[str]):
6069
self.github_state: list[str] = github_state
6170
self.gitlab_state: list[str] = gitlab_state
71+
self.azure_devops_state: list[str] = azure_devops_state
6272

6373

6474
_COMMENT_MARKER = "<!-- PatchWork comment marker -->"
@@ -112,6 +122,11 @@ def _apply_pr_template(pr: "PullRequestProtocol", body: str) -> str:
112122
# chunk_link_format = file_link_format + "_{start_line}_{end_line}"
113123
chunk_link_format = file_link_format + ""
114124
anchor_hash = hashlib.sha1
125+
elif isinstance(pr, AzureDevopsPullRequest):
126+
backup_link_format = "{url}?_a=files"
127+
file_link_format = backup_link_format + "&path=/{path}"
128+
chunk_link_format = file_link_format + ""
129+
anchor_hash = hashlib.md5
115130
else:
116131
return pr.url()
117132

@@ -135,7 +150,7 @@ def _apply_pr_template(pr: "PullRequestProtocol", body: str) -> str:
135150
format_to_use = chunk_link_format
136151

137152
replacement_value = format_to_use.format(
138-
url=pr.url(), diff_anchor=diff_anchor, start_line=start, end_line=end
153+
url=pr.url(), path=path, diff_anchor=diff_anchor, start_line=start, end_line=end
139154
)
140155
template = template[:start_idx] + replacement_value + template[end_idx + 2 :]
141156
start_idx, end_idx = PullRequestProtocol._get_template_indexes(template)
@@ -352,6 +367,37 @@ def texts(self) -> PullRequestTexts:
352367
diffs={file.filename: file.patch for file in self._pr.get_files() if file.patch is not None},
353368
)
354369

370+
class AzureDevopsPullRequest(PullRequestProtocol):
371+
def __init__(self, pr: GitPullRequest, git_client: GitClient, pr_base_url: str):
372+
self._pr: GitPullRequest = pr
373+
self.git_client: GitClient = git_client
374+
self.pr_base_url = pr_base_url
375+
376+
@property
377+
def id(self) -> int:
378+
return self._pr.pull_request_id
379+
380+
def url(self) -> str:
381+
final_pr_url = self.pr_base_url
382+
if not final_pr_url.endswith("/"):
383+
final_pr_url += "/"
384+
return final_pr_url + str(self.id)
385+
386+
def set_pr_description(self, body: str) -> None:
387+
final_body = PullRequestProtocol._apply_pr_template(self, body)
388+
body = GitPullRequest(description=final_body)
389+
self.git_client.update_pull_request(body, repository_id=self._pr.repository.id, pull_request_id=self._pr.pull_request_id, project=self._pr.repository.project.id)
390+
391+
def create_comment(
392+
self, body: str, path: str | None = None, start_line: int | None = None, end_line: int | None = None
393+
) -> str | None:
394+
...
395+
396+
def reset_comments(self) -> None:
397+
...
398+
399+
def texts(self) -> PullRequestTexts:
400+
...
355401

356402
class GithubClient(ScmPlatformClientProtocol):
357403
DEFAULT_URL = Consts.DEFAULT_BASE_URL
@@ -608,3 +654,145 @@ def create_issue_comment(
608654

609655
obj = self.gitlab.projects.get(slug).issues.create({"title": title, "description": issue_text})
610656
return obj["web_url"]
657+
658+
659+
class AzureDevopsClient(ScmPlatformClientProtocol):
660+
DEFAULT_URL = "https://dev.azure.com/"
661+
662+
def __init__(self, access_token: str, url: str = DEFAULT_URL, remote: str = "origin"):
663+
self.credentials = BasicAuthentication('', access_token)
664+
self.__url = url
665+
self.__remote = remote
666+
git_repo = git.Repo(Path.cwd(), search_parent_directories=True)
667+
original_remote_url = git_repo.remotes[remote].url
668+
parsed_repo: GitUrlParsed = parse(original_remote_url)
669+
self.__org_name = parsed_repo.owner
670+
self.__project_name = parsed_repo.groups_path.replace("/_git", "")
671+
self.__repo_name = parsed_repo.repo
672+
673+
def __pr_resource_html_url(self):
674+
url = self.__url
675+
if not url.endswith("/"):
676+
url += "/"
677+
return f"{url}{self.__org_name}/{self.__project_name}/_git/{self.__repo_name}/pullrequest/"
678+
679+
680+
@functools.cached_property
681+
def clients(self) -> ClientFactory:
682+
url = self.__url
683+
if not url.endswith("/"):
684+
url += "/"
685+
686+
conn = Connection(base_url=f"{url}{self.__org_name}", creds=self.credentials)
687+
return conn.clients
688+
689+
@functools.cached_property
690+
def git_client(self) -> GitClient:
691+
return self.clients.get_git_client()
692+
693+
@functools.cached_property
694+
def core_client(self) -> CoreClient:
695+
return self.clients.get_core_client()
696+
697+
@functools.cached_property
698+
def project(self) -> TeamProjectReference:
699+
projs = self.core_client.get_projects()
700+
proj = next((proj for proj in projs if proj.name == self.__project_name), None)
701+
if proj is None:
702+
raise ValueError(f"Unable to determine project name from remote {self.__remote} url. Parsed project name: {self.__project_name}")
703+
return proj
704+
705+
@functools.cached_property
706+
def repo(self) -> GitRepository:
707+
repos = self.git_client.get_repositories(project=self.project.id)
708+
git_repo = next((r for r in repos if r.name == self.__repo_name), None)
709+
if git_repo is None:
710+
raise ValueError(f"Unable to determine repository name from remote {self.__remote} url. Parsed repository name: {self.__repo_name}")
711+
return git_repo
712+
713+
def set_url(self, url: str) -> None:
714+
self.__url = url
715+
716+
def test(self) -> bool:
717+
response = self.core_client.get_projects()
718+
return next(iter(response), None) is not None
719+
720+
def get_slug_and_id_from_url(self, url: str) -> tuple[str, int] | None:
721+
...
722+
723+
def find_issue_by_url(self, url: str) -> IssueText | None:
724+
...
725+
726+
def find_issue_by_id(self, slug: str, issue_id: int) -> IssueText | None:
727+
...
728+
729+
def get_pr_by_url(self, url: str) -> PullRequestProtocol | None:
730+
...
731+
732+
def find_pr_by_id(self, slug: str, pr_id: int) -> PullRequestProtocol | None:
733+
...
734+
735+
def find_prs(
736+
self,
737+
slug: str,
738+
state: PullRequestState | None = None,
739+
original_branch: str | None = None,
740+
feature_branch: str | None = None,
741+
limit: int | None = None,
742+
) -> list[PullRequestProtocol]:
743+
kwargs_list = dict(status=[None], target_ref_name=[None], source_ref_name=[None])
744+
745+
if state is not None:
746+
kwargs_list["status"] = state.gitlab_state # type: ignore
747+
if original_branch is not None:
748+
kwargs_list["target_ref_name"] = [f"refs/heads/{original_branch}"] # type: ignore
749+
if feature_branch is not None:
750+
kwargs_list["source_ref_name"] = [f"refs/heads/{feature_branch}"] # type: ignore
751+
752+
page_list = []
753+
keys = kwargs_list.keys()
754+
for instance in itertools.product(*kwargs_list.values()):
755+
kwargs = dict(((key, value) for key, value in zip(keys, instance) if value is not None))
756+
git_pr_search = GitPullRequestSearchCriteria(
757+
repository_id=self.repo.id,
758+
**kwargs,
759+
)
760+
pr_instances = self.git_client.get_pull_requests(
761+
project=self.project.id,
762+
repository_id=self.repo.id,
763+
search_criteria=git_pr_search
764+
)
765+
page_list.append(pr_instances)
766+
767+
rv_list = []
768+
for mr in itertools.islice(itertools.chain(*page_list), limit):
769+
rv_list.append(AzureDevopsPullRequest(mr, self.git_client, self.__pr_resource_html_url()))
770+
771+
return rv_list
772+
773+
def create_pr(
774+
self,
775+
slug: str,
776+
title: str,
777+
body: str,
778+
original_branch: str,
779+
feature_branch: str,
780+
) -> PullRequestProtocol:
781+
# before creating a PR, check if one already exists
782+
pr_body = GitPullRequest(
783+
source_ref_name=f"refs/heads/{feature_branch}",
784+
target_ref_name=f"refs/heads/{original_branch}",
785+
title=title,
786+
description=body,
787+
# should be web tag definition
788+
# labels="patchwork",
789+
)
790+
pr_instance = self.git_client.create_pull_request(pr_body, repository_id=self.repo.id, project=self.project.id)
791+
mr = AzureDevopsPullRequest(pr_instance, self.git_client, self.__pr_resource_html_url()) # type: ignore
792+
return mr
793+
794+
def create_issue_comment(
795+
self, slug: str, issue_text: str, title: str | None = None, issue_id: int | None = None
796+
) -> str:
797+
...
798+

patchwork/steps/CreatePR/CreatePR.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
GithubClient,
88
GitlabClient,
99
ScmPlatformClientProtocol,
10-
get_slug_from_remote_url,
10+
get_slug_from_remote_url, AzureDevopsClient,
1111
)
1212
from patchwork.logger import logger
1313
from patchwork.step import Step, StepStatus
@@ -30,6 +30,8 @@ def __init__(self, inputs: dict):
3030
self.scm_client = GithubClient(inputs["github_api_key"])
3131
elif "gitlab_api_key" in inputs.keys():
3232
self.scm_client = GitlabClient(inputs["gitlab_api_key"])
33+
elif "azuredevops_api_key" in inputs.keys():
34+
self.scm_client = AzureDevopsClient(inputs["azuredevops_api_key"])
3335
else:
3436
logger.warning(
3537
f'Missing required input data: "github_api_key" or "gitlab_api_key",'

patchwork/steps/CreatePR/typed.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class CreatePRInputs(__CreatePRRequiredInputs, total=False):
1616
scm_url: Annotated[str, StepTypeConfig(is_config=True)]
1717
gitlab_api_key: Annotated[str, StepTypeConfig(is_config=True)]
1818
github_api_key: Annotated[str, StepTypeConfig(is_config=True)]
19+
azuredevops_api_key: Annotated[str, StepTypeConfig(is_config=True)]
1920

2021

2122
class CreatePROutputs(TypedDict):

0 commit comments

Comments
 (0)