Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Forgejo Issues support #896

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions ogr/services/forgejo/comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright Contributors to the Packit project.
# SPDX-License-Identifier: MIT

import datetime

from pyforgejo.types.comment import Comment as FComment

from ogr.abstract import Comment, IssueComment, PRComment


class ForgejoComment(Comment):
def _from_raw_comment(self, raw_comment: FComment) -> None:
self._raw_comment = raw_comment

@property
def body(self) -> str:
return self._raw_comment.body

@property
def edited(self) -> datetime.datetime:
return self._raw_comment.updated_at


class ForgejoIssueComment(ForgejoComment, IssueComment):
def __str__(self):
return "Forgejo" + super().__str__()


class ForgejoPRComment(ForgejoComment, PRComment):
def __str__(self):
return "Forgejo" + super().__str__()
159 changes: 158 additions & 1 deletion ogr/services/forgejo/issue.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,167 @@
# Copyright Contributors to the Packit project.
# SPDX-License-Identifier: MIT
from datetime import datetime
from typing import Optional, Union

import pyforgejo.types.issue as _issue

from ogr.abstract import Issue, IssueComment, IssueStatus
from ogr.exceptions import IssueTrackerDisabled, OperationNotSupported
from ogr.services import forgejo
from ogr.services.base import BaseIssue
from ogr.services.forgejo.comments import ForgejoIssueComment


class ForgejoIssue(BaseIssue):
def __init__(self, raw_issue, project: "forgejo.ForgejoProject"):
project: "forgejo.ForgejoProject"

def __init__(self, raw_issue: _issue, project: "forgejo.ForgejoProject"):
super().__init__(raw_issue, project)
self._raw_issue = raw_issue

@property
def _index(self):
return self._raw_issue.number

@property
def title(self) -> str:
return self._raw_issue.title

@title.setter
def title(self, new_title: str) -> None:
self._issue_api_call(
self.project.service.api.issue.edit_issue,
title=new_title,
)

@property
def id(self) -> int:
return self._raw_issue.id

@property
def url(self) -> str:
return self._raw_issue.url

@property
def description(self) -> str:
return self._raw_issue.body

@property
def author(self) -> str:
return self._raw_issue.user.login

@property
def created(self) -> datetime:
return self._raw_issue.created_at

@property
def status(self) -> IssueStatus:
return IssueStatus[self._raw_issue.state]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this maps as it should?

Copy link
Author

@p0tat0chip p0tat0chip Mar 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did manually test this, looked at generated openapi spec from forgejo and had similar doubts, I will check again and add some checks

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you cover this with tests after, it should be fine.


@property
def assignees(self) -> list:
try:
return self._raw_issue.assignees
except AttributeError:
return None

@staticmethod
def create(
project: "forgejo.ForgejoProject",
title: str,
body: str,
private: Optional[bool] = None,
labels: Optional[list[str]] = None,
assignees: Optional[list[str]] = None,
) -> "Issue":

if private:
raise NotImplementedError()
if not project.has_issues:
raise IssueTrackerDisabled()

issue = project.service.api.issue.create_issue(
owner=project.namespace,
repo=project.repo,
title=title,
body=body,
labels=labels,
assignees=assignees,
)
return ForgejoIssue(issue, project)

@staticmethod
def get(project: "forgejo.ForgejoProject", issue_id: int) -> _issue:
if not project.has_issues:
raise IssueTrackerDisabled
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
raise IssueTrackerDisabled
raise IssueTrackerDisabled()


try:
issue = project.service.api.issue.get_issue(
owner=project.namespace, repo=project.repo, index=issue_id,
)
except Exception as ex:
raise OperationNotSupported(f"Issue {issue_id} not found") from ex
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

operation not supported when the issue is not found?

return ForgejoIssue(issue, project)

@staticmethod
def get_list(
project: "forgejo.ForgejoProject",
status: IssueStatus = IssueStatus.open,
author: Optional[str] = None,
assignee: Optional[str] = None,
labels: Optional[list[str]] = None,
) -> list["Issue"]:
if not project.has_issues:
raise IssueTrackerDisabled
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
raise IssueTrackerDisabled
raise IssueTrackerDisabled()


parameters: dict[str, Union[str, list[str], bool]] = {
"state": status if status != IssueStatus.open else "open",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really sure if this produces what you think it does… looking at the definition of IssueStatus, it is an IntEnum, I think you'll find out when you start testing it.

"type": "issues",
}
if author:
parameters["created_by"] = author
if assignee:
parameters["assigned_by"] = assignee
if labels:
parameters["labels"] = labels

issues = project.service.api.issue.list_issues(
owner=project.namespace, repo=project.repo, **parameters,
)
return [ForgejoIssue(issue, project) for issue in issues]
Comment on lines +128 to +131
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part here is a bit tricky… See packit/packit#2543 (comment) and related notes…

  1. You don't get a full list, cause it is paginated
  2. Once you start fetching everything page-by-page, don't construct a list, implement this as an iterator (I'll be committing similar code for ForgejoProject today, so you should be able to see basic outline of how it looks like)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally missed it, will do the changes


def close(self) -> "Issue":
self._issue_api_call(
self.project.service.api.issue.edit_issue,
state="closed",
)
return self

def add_label(self, *labels: str) -> None:
self._issue_api_call(
self.project.service.api.issue.add_label,
labels=list(labels),
)

def comment(self, body: str) -> IssueComment:
comment = self._issue_api_call(
self.project.service.api.issue.create_comment,
body=body,
)
return ForgejoIssueComment(self, comment)

def _get_all_comments(self) -> list[IssueComment]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here with the pagination

comments = self._issue_api_call(self.project.service.api.issue.get_comments)
return [
ForgejoIssueComment(raw_comment=comment, parent=self)
for comment in comments
]

def _issue_api_call(self, method, **kwargs):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh… OK… I would probably even go with something like:

Suggested change
def _issue_api_call(self, method, **kwargs):
def _api_call(self, method, **kwargs):

Copy link
Author

@p0tat0chip p0tat0chip Mar 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m still new to this and learning the ropes, so I appreciate your patience. I’ll implement the suggested changes and do my best to improve, though there might still be a few rookie mistakes along the way. Thanks for understanding! Thanks for the detailed review means a lot !

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No problem 😁

params = {
"owner": self.project.namespace,
"repo": self.project.repo,
"index": self._index,
}
params.update(kwargs)
return method(**params)
33 changes: 33 additions & 0 deletions ogr/services/forgejo/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@


from functools import cached_property
from typing import Optional

from ogr.abstract import Issue, IssueStatus
from ogr.services import forgejo
from ogr.services.base import BaseGitProject
from ogr.services.forgejo import ForgejoIssue
from ogr.utils import indirect


class ForgejoProject(BaseGitProject):
Expand All @@ -28,3 +32,32 @@ def forgejo_repo(self):
owner=namespace,
repo=self.repo,
)

@property
def has_issues(self):
return self.forgejo_repo.has_issues

@indirect(ForgejoIssue.get_list)
def get_issue_list(
self,
status: IssueStatus = IssueStatus.open,
author: Optional[str] = None,
assignee: Optional[str] = None,
labels: Optional[list[str]] = None,
) -> list["Issue"]:
pass

@indirect(ForgejoIssue.create)
def create_issue(
self,
title: str,
body: str,
private: Optional[bool] = None,
labels: Optional[list[str]] = None,
assignees: Optional[list[str]] = None,
) -> Issue:
pass

@indirect(ForgejoIssue.get)
def get_issue(self, issue_id: int) -> "Issue":
pass