Skip to content

Commit e598d0f

Browse files
authored
Add azure devops PR related implementations (#1142)
* reset * Fixes
1 parent c0345eb commit e598d0f

File tree

7 files changed

+106
-42
lines changed

7 files changed

+106
-42
lines changed

patchwork/common/client/scm.py

Lines changed: 91 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,26 @@
44
import hashlib
55
import itertools
66
import time
7+
from difflib import unified_diff
78
from enum import Enum
89
from itertools import chain
910
from pathlib import Path
10-
from urllib.parse import urlparse
1111

1212
import git
1313
import gitlab.const
14-
from attrs import define
1514
from azure.devops.connection import Connection
1615
from azure.devops.released.client_factory import ClientFactory
1716
from azure.devops.released.core.core_client import CoreClient
1817
from azure.devops.released.git.git_client import GitClient
19-
from azure.devops.v7_1.git.models import GitPullRequest, GitPullRequestSearchCriteria, TeamProjectReference, GitRepository
18+
from azure.devops.v7_1.git.models import GitPullRequest, GitPullRequestSearchCriteria, TeamProjectReference, \
19+
GitRepository, Comment, GitPullRequestCommentThread, GitTargetVersionDescriptor, GitBaseVersionDescriptor
2020
from github import Auth, Consts, Github, GithubException, PullRequest
2121
from github.GithubException import UnknownObjectException
2222
from gitlab import Gitlab, GitlabAuthenticationError, GitlabError
2323
from gitlab.v4.objects import ProjectMergeRequest
2424
from giturlparse import GitUrlParsed, parse
2525
from msrest.authentication import BasicAuthentication
26-
from typing_extensions import Protocol, TypedDict
26+
from typing_extensions import Protocol, TypedDict, Iterator
2727

2828
from patchwork.logger import logger
2929

@@ -35,14 +35,6 @@ def get_slug_from_remote_url(remote_url: str) -> str:
3535
return slug
3636

3737

38-
@define
39-
class Comment:
40-
path: str
41-
body: str
42-
start_line: int | None
43-
end_line: int
44-
45-
4638
class IssueText(TypedDict):
4739
title: str
4840
body: str
@@ -386,18 +378,67 @@ def url(self) -> str:
386378
def set_pr_description(self, body: str) -> None:
387379
final_body = PullRequestProtocol._apply_pr_template(self, body)
388380
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)
381+
self._pr = 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)
390382

391383
def create_comment(
392384
self, body: str, path: str | None = None, start_line: int | None = None, end_line: int | None = None
393385
) -> str | None:
394-
...
386+
try:
387+
comment_body = Comment(content=body)
388+
comment_thread_body = GitPullRequestCommentThread(comments=[comment_body])
389+
comment_thread = self.git_client.create_thread(comment_thread_body, repository_id=self._pr.repository.id, pull_request_id=self.id, project=self._pr.repository.project.id)
390+
return body
391+
except Exception as e:
392+
logger.error(e)
393+
return None
394+
395+
def __iter_comments(self) -> Iterator[tuple[GitPullRequestCommentThread, list[Comment]]]:
396+
threads = self.git_client.get_threads(repository_id=self._pr.repository.id, pull_request_id=self.id, project=self._pr.repository.project.id)
397+
for thread in threads:
398+
comments = self.git_client.get_comments(repository_id=self._pr.repository.id, pull_request_id=self.id, thread_id=thread.id, project=self._pr.repository.project.id)
399+
yield thread, comments
395400

396401
def reset_comments(self) -> None:
397-
...
402+
for thread, comments in self.__iter_comments():
403+
comment_ids_to_delete = []
404+
for comment in comments:
405+
if comment.content.startswith(_COMMENT_MARKER):
406+
comment_ids_to_delete.append(comment.id)
407+
if len(comment_ids_to_delete) == len(comments):
408+
for comment_id in comment_ids_to_delete:
409+
self.git_client.delete_comment(repository_id=self._pr.repository.id, pull_request_id=self.id, thread_id=thread.id, comment_id=comment_id, project=self._pr.repository.project.id)
398410

399411
def texts(self) -> PullRequestTexts:
400-
...
412+
target_branch = self._pr.last_merge_source_commit.commit_id
413+
feature_branch = self._pr.last_merge_target_commit.commit_id
414+
415+
repo = git.Repo(path=Path.cwd(), search_parent_directories=True)
416+
for remote in repo.remotes:
417+
remote.fetch()
418+
target_commit = repo.commit(target_branch)
419+
feature_commit = repo.commit(feature_branch)
420+
421+
diff_index = feature_commit.diff(target_commit)
422+
diffs = dict()
423+
for diff in diff_index:
424+
a_path = diff.a_path
425+
b_path = diff.b_path
426+
a_blob = diff.a_blob.data_stream.read().decode("utf-8")
427+
b_blob = diff.b_blob.data_stream.read().decode("utf-8")
428+
diff_lines = unified_diff(a_blob.splitlines(keepends=True), b_blob.splitlines(keepends=True), a_path, b_path)
429+
diff_content = "".join(diff_lines)
430+
diffs[a_path] = diff_content
431+
432+
comments: list[PullRequestComment] = []
433+
for _, raw_comments in self.__iter_comments():
434+
for raw_comment in raw_comments:
435+
comments.append(dict(user=raw_comment.author.display_name, body=raw_comment.content))
436+
return dict(
437+
title=self._pr.title or "",
438+
body=self._pr.description or "",
439+
comments=comments,
440+
diffs=diffs,
441+
)
401442

402443
class GithubClient(ScmPlatformClientProtocol):
403444
DEFAULT_URL = Consts.DEFAULT_BASE_URL
@@ -450,11 +491,11 @@ def find_issue_by_id(self, slug: str, issue_id: int) -> IssueText | None:
450491
logger.warn(f"Failed to get issue: {e}")
451492
return None
452493

453-
def get_pr_by_url(self, url: str) -> PullRequestProtocol | None:
494+
def get_pr_by_url(self, url: str) -> GithubPullRequest | None:
454495
slug, pr_id = self.get_slug_and_id_from_url(url)
455496
return self.find_pr_by_id(slug, pr_id)
456497

457-
def find_pr_by_id(self, slug: str, pr_id: int) -> PullRequestProtocol | None:
498+
def find_pr_by_id(self, slug: str, pr_id: int) -> GithubPullRequest | None:
458499
repo = self.github.get_repo(slug)
459500
try:
460501
pr = repo.get_pull(pr_id)
@@ -508,7 +549,7 @@ def create_pr(
508549
body: str,
509550
original_branch: str,
510551
feature_branch: str,
511-
) -> PullRequestProtocol:
552+
) -> GithubPullRequest:
512553
# before creating a PR, check if one already exists
513554
repo = self.github.get_repo(slug)
514555
gh_pr = repo.create_pull(title=title, body=body, base=original_branch, head=feature_branch)
@@ -579,11 +620,11 @@ def find_issue_by_id(self, slug: str, issue_id: int) -> IssueText | None:
579620
logger.warn(f"Failed to get issue: {e}")
580621
return None
581622

582-
def get_pr_by_url(self, url: str) -> PullRequestProtocol | None:
623+
def get_pr_by_url(self, url: str) -> GitlabMergeRequest | None:
583624
slug, pr_id = self.get_slug_and_id_from_url(url)
584625
return self.find_pr_by_id(slug, pr_id)
585626

586-
def find_pr_by_id(self, slug: str, pr_id: int) -> PullRequestProtocol | None:
627+
def find_pr_by_id(self, slug: str, pr_id: int) -> GitlabMergeRequest | None:
587628
project = self.gitlab.projects.get(slug)
588629
try:
589630
mr = project.mergerequests.get(pr_id)
@@ -599,7 +640,7 @@ def find_prs(
599640
original_branch: str | None = None,
600641
feature_branch: str | None = None,
601642
limit: int | None = None,
602-
) -> list[PullRequestProtocol]:
643+
) -> list[GitlabMergeRequest]:
603644
project = self.gitlab.projects.get(slug)
604645
kwargs_list = dict(iterator=[True], state=[None], target_branch=[None], source_branch=[None])
605646

@@ -630,7 +671,7 @@ def create_pr(
630671
body: str,
631672
original_branch: str,
632673
feature_branch: str,
633-
) -> PullRequestProtocol:
674+
) -> GitlabMergeRequest:
634675
# before creating a PR, check if one already exists
635676
project = self.gitlab.projects.get(slug)
636677
gl_mr = project.mergerequests.create(
@@ -714,23 +755,41 @@ def set_url(self, url: str) -> None:
714755
self.__url = url
715756

716757
def test(self) -> bool:
717-
response = self.core_client.get_projects()
718-
return next(iter(response), None) is not None
758+
try:
759+
proj = self.project
760+
return True
761+
except ValueError:
762+
return False
719763

720764
def get_slug_and_id_from_url(self, url: str) -> tuple[str, int] | None:
721-
...
765+
url_parts = url.split("/")
766+
if len(url_parts) == 1:
767+
logger.error(f"Invalid URL: {url}")
768+
return None
769+
770+
try:
771+
resource_id = int(url_parts[-1])
772+
except ValueError:
773+
logger.error(f"Invalid URL: {url}")
774+
return None
775+
776+
slug = "/".join(url_parts[-6:-3])
777+
778+
return slug, resource_id
722779

723780
def find_issue_by_url(self, url: str) -> IssueText | None:
724781
...
725782

726783
def find_issue_by_id(self, slug: str, issue_id: int) -> IssueText | None:
727784
...
728785

729-
def get_pr_by_url(self, url: str) -> PullRequestProtocol | None:
730-
...
786+
def get_pr_by_url(self, url: str) -> AzureDevopsPullRequest | None:
787+
slug, resource_id = self.get_slug_and_id_from_url(url)
788+
return self.find_pr_by_id(slug, resource_id)
731789

732-
def find_pr_by_id(self, slug: str, pr_id: int) -> PullRequestProtocol | None:
733-
...
790+
def find_pr_by_id(self, slug: str, pr_id: int) -> AzureDevopsPullRequest | None:
791+
pr = self.git_client.get_pull_request(repository_id=self.repo.id, pull_request_id=pr_id, project=self.project.id)
792+
return AzureDevopsPullRequest(pr, self.git_client, self.__pr_resource_html_url())
734793

735794
def find_prs(
736795
self,
@@ -739,7 +798,7 @@ def find_prs(
739798
original_branch: str | None = None,
740799
feature_branch: str | None = None,
741800
limit: int | None = None,
742-
) -> list[PullRequestProtocol]:
801+
) -> list[AzureDevopsPullRequest]:
743802
kwargs_list = dict(status=[None], target_ref_name=[None], source_ref_name=[None])
744803

745804
if state is not None:
@@ -777,7 +836,7 @@ def create_pr(
777836
body: str,
778837
original_branch: str,
779838
feature_branch: str,
780-
) -> PullRequestProtocol:
839+
) -> AzureDevopsPullRequest:
781840
# before creating a PR, check if one already exists
782841
pr_body = GitPullRequest(
783842
source_ref_name=f"refs/heads/{feature_branch}",

patchwork/steps/CreatePRComment/CreatePRComment.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from patchwork.common.client.scm import GithubClient, GitlabClient
1+
from patchwork.common.client.scm import GithubClient, GitlabClient, AzureDevopsClient
22
from patchwork.logger import logger
33
from patchwork.step import Step, StepStatus
44

@@ -15,6 +15,8 @@ def __init__(self, inputs: dict):
1515
self.scm_client = GithubClient(inputs["github_api_key"])
1616
elif "gitlab_api_key" in inputs.keys():
1717
self.scm_client = GitlabClient(inputs["gitlab_api_key"])
18+
elif "azuredevops_api_key" in inputs.keys():
19+
self.scm_client = AzureDevopsClient(inputs["azuredevops_api_key"])
1820
else:
1921
raise ValueError(f'Missing required input data: "github_api_key" or "gitlab_api_key"')
2022

patchwork/steps/CreatePRComment/typed.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ class __CreatePRCommentRequiredInputs(TypedDict):
1111
class CreatePRCommentInputs(__CreatePRCommentRequiredInputs, total=False):
1212
noisy_comments: Annotated[bool, StepTypeConfig(is_config=True)]
1313
scm_url: Annotated[str, StepTypeConfig(is_config=True)]
14-
gitlab_api_key: Annotated[str, StepTypeConfig(is_config=True, or_op=["github_api_key"])]
15-
github_api_key: Annotated[str, StepTypeConfig(is_config=True, or_op=["gitlab_api_key"])]
14+
gitlab_api_key: Annotated[str, StepTypeConfig(is_config=True, or_op=["github_api_key", "azuredevops_api_key"])]
15+
github_api_key: Annotated[str, StepTypeConfig(is_config=True, or_op=["gitlab_api_key", "azuredevops_api_key"])]
16+
azuredevops_api_key: Annotated[str, StepTypeConfig(is_config=True, or_op=["gitlab_api_key", "github_api_key"])]
1617

1718

1819
class CreatePRCommentOutputs(TypedDict):

patchwork/steps/ReadPRDiffs/ReadPRDiffs.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing_extensions import List
22

3-
from patchwork.common.client.scm import GithubClient, GitlabClient
3+
from patchwork.common.client.scm import GithubClient, GitlabClient, AzureDevopsClient
44
from patchwork.step import Step
55
from patchwork.steps.ReadPRDiffs.typed import ReadPRDiffsInputs, ReadPRDiffsOutputs
66

@@ -26,17 +26,16 @@ def filter_by_extension(file, extensions):
2626

2727

2828
class ReadPRDiffs(Step, input_class=ReadPRDiffsInputs, output_class=ReadPRDiffsOutputs):
29-
required_keys = {"pr_url"}
3029

3130
def __init__(self, inputs: dict):
3231
super().__init__(inputs)
33-
if not all(key in inputs.keys() for key in self.required_keys):
34-
raise ValueError(f'Missing required data: "{self.required_keys}"')
3532

3633
if "github_api_key" in inputs.keys():
3734
self.scm_client = GithubClient(inputs["github_api_key"])
3835
elif "gitlab_api_key" in inputs.keys():
3936
self.scm_client = GitlabClient(inputs["gitlab_api_key"])
37+
elif "azuredevops_api_key" in inputs.keys():
38+
self.scm_client = AzureDevopsClient(inputs["azuredevops_api_key"])
4039
else:
4140
raise ValueError(f'Missing required input data: "github_api_key" or "gitlab_api_key"')
4241

patchwork/steps/ReadPRDiffs/typed.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ class __ReadPRDiffsRequiredInputs(TypedDict):
1010

1111
class ReadPRDiffsInputs(__ReadPRDiffsRequiredInputs, total=False):
1212
scm_url: Annotated[str, StepTypeConfig(is_config=True)]
13-
gitlab_api_key: Annotated[str, StepTypeConfig(is_config=True, or_op=["github_api_key"])]
14-
github_api_key: Annotated[str, StepTypeConfig(is_config=True, or_op=["gitlab_api_key"])]
13+
gitlab_api_key: Annotated[str, StepTypeConfig(is_config=True, or_op=["github_api_key", "azuredevops_api_key"])]
14+
github_api_key: Annotated[str, StepTypeConfig(is_config=True, or_op=["gitlab_api_key", "azuredevops_api_key"])]
15+
azuredevops_api_key: Annotated[str, StepTypeConfig(is_config=True, or_op=["gitlab_api_key", "github_api_key"])]
1516

1617

1718
class ReadPRDiffsOutputs(TypedDict):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "patchwork-cli"
3-
version = "0.0.85"
3+
version = "0.0.86"
44
description = ""
55
authors = ["patched.codes"]
66
license = "AGPL"

tests/common/test_app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def test_default_list_option_callback(runner):
4848
== """\
4949
AutoFix
5050
DependencyUpgrade
51+
GenerateCodeUsageExample
5152
GenerateDiagram
5253
GenerateDocstring
5354
GenerateREADME
@@ -68,6 +69,7 @@ def test_config_list_option_callback(runner, config_dir, patchflow_file):
6869
== f"""\
6970
AutoFix
7071
DependencyUpgrade
72+
GenerateCodeUsageExample
7173
GenerateDiagram
7274
GenerateDocstring
7375
GenerateREADME

0 commit comments

Comments
 (0)