/ github / Issue.py
Issue.py
  1  ############################ Copyrights and license ############################
  2  #                                                                              #
  3  # Copyright 2012 Andrew Bettison <andrewb@zip.com.au>                          #
  4  # Copyright 2012 Philip Kimmey <philip@rover.com>                              #
  5  # Copyright 2012 Vincent Jacques <vincent@vincent-jacques.net>                 #
  6  # Copyright 2012 Zearin <zearin@gonk.net>                                      #
  7  # Copyright 2013 AKFish <akfish@gmail.com>                                     #
  8  # Copyright 2013 David Farr <david.farr@sap.com>                               #
  9  # Copyright 2013 Stuart Glaser <stuglaser@gmail.com>                           #
 10  # Copyright 2013 Vincent Jacques <vincent@vincent-jacques.net>                 #
 11  # Copyright 2014 Vincent Jacques <vincent@vincent-jacques.net>                 #
 12  # Copyright 2015 Raja Reddy Karri <klnrajareddy@gmail.com>                     #
 13  # Copyright 2016 @tmshn <tmshn@r.recruit.co.jp>                                #
 14  # Copyright 2016 Jannis Gebauer <ja.geb@me.com>                                #
 15  # Copyright 2016 Matt Babineau <babineaum@users.noreply.github.com>            #
 16  # Copyright 2016 Peter Buckley <dx-pbuckley@users.noreply.github.com>          #
 17  # Copyright 2017 Nicolas Agustín Torres <nicolastrres@gmail.com>               #
 18  # Copyright 2017 Simon <spam@esemi.ru>                                         #
 19  # Copyright 2018 Shinichi TAMURA <shnch.tmr@gmail.com>                         #
 20  # Copyright 2018 Steve Kowalik <steven@wedontsleep.org>                        #
 21  # Copyright 2018 Wan Liuyang <tsfdye@gmail.com>                                #
 22  # Copyright 2018 per1234 <accounts@perglass.com>                               #
 23  # Copyright 2018 sfdye <tsfdye@gmail.com>                                      #
 24  # Copyright 2019 Nick Campbell <nicholas.j.campbell@gmail.com>                 #
 25  # Copyright 2020 Huan-Cheng Chang <changhc84@gmail.com>                        #
 26  #                                                                              #
 27  # This file is part of PyGithub.                                               #
 28  # http://pygithub.readthedocs.io/                                              #
 29  #                                                                              #
 30  # PyGithub is free software: you can redistribute it and/or modify it under    #
 31  # the terms of the GNU Lesser General Public License as published by the Free  #
 32  # Software Foundation, either version 3 of the License, or (at your option)    #
 33  # any later version.                                                           #
 34  #                                                                              #
 35  # PyGithub is distributed in the hope that it will be useful, but WITHOUT ANY  #
 36  # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    #
 37  # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more #
 38  # details.                                                                     #
 39  #                                                                              #
 40  # You should have received a copy of the GNU Lesser General Public License     #
 41  # along with PyGithub. If not, see <http://www.gnu.org/licenses/>.             #
 42  #                                                                              #
 43  ################################################################################
 44  from __future__ import annotations
 45  
 46  import urllib.parse
 47  from datetime import datetime
 48  from typing import TYPE_CHECKING, Any
 49  
 50  import github.GithubObject
 51  import github.IssueComment
 52  import github.IssueEvent
 53  import github.IssuePullRequest
 54  import github.Label
 55  import github.Milestone
 56  import github.NamedUser
 57  import github.PullRequest
 58  import github.Reaction
 59  import github.Repository
 60  import github.TimelineEvent
 61  from github import Consts
 62  from github.GithubObject import (
 63      Attribute,
 64      CompletableGithubObject,
 65      NotSet,
 66      Opt,
 67      is_defined,
 68      is_optional,
 69      is_optional_list,
 70      is_undefined,
 71  )
 72  from github.PaginatedList import PaginatedList
 73  
 74  if TYPE_CHECKING:
 75      from github.IssueComment import IssueComment
 76      from github.IssueEvent import IssueEvent
 77      from github.IssuePullRequest import IssuePullRequest
 78      from github.Label import Label
 79      from github.Milestone import Milestone
 80      from github.NamedUser import NamedUser
 81      from github.PullRequest import PullRequest
 82      from github.Reaction import Reaction
 83      from github.Repository import Repository
 84      from github.TimelineEvent import TimelineEvent
 85  
 86  
 87  class Issue(CompletableGithubObject):
 88      """
 89      This class represents Issues. The reference can be found here https://docs.github.com/en/rest/reference/issues
 90      """
 91  
 92      def _initAttributes(self) -> None:
 93          self._id: Attribute[int] = NotSet
 94          self._active_lock_reason: Attribute[str | None] = NotSet
 95          self._assignee: Attribute[NamedUser | None] = NotSet
 96          self._assignees: Attribute[list[NamedUser]] = NotSet
 97          self._body: Attribute[str] = NotSet
 98          self._closed_at: Attribute[datetime] = NotSet
 99          self._closed_by: Attribute[NamedUser] = NotSet
100          self._comments: Attribute[int] = NotSet
101          self._comments_url: Attribute[str] = NotSet
102          self._created_at: Attribute[datetime] = NotSet
103          self._events_url: Attribute[str] = NotSet
104          self._html_url: Attribute[str] = NotSet
105          self._labels: Attribute[list[Label]] = NotSet
106          self._labels_url: Attribute[str] = NotSet
107          self._locked: Attribute[bool] = NotSet
108          self._milestone: Attribute[Milestone] = NotSet
109          self._number: Attribute[int] = NotSet
110          self._pull_request: Attribute[IssuePullRequest] = NotSet
111          self._repository: Attribute[Repository] = NotSet
112          self._state: Attribute[str] = NotSet
113          self._state_reason: Attribute[str | None] = NotSet
114          self._title: Attribute[str] = NotSet
115          self._updated_at: Attribute[datetime] = NotSet
116          self._url: Attribute[str] = NotSet
117          self._user: Attribute[NamedUser] = NotSet
118  
119      def __repr__(self) -> str:
120          return self.get__repr__({"number": self._number.value, "title": self._title.value})
121  
122      @property
123      def assignee(self) -> NamedUser | None:
124          self._completeIfNotSet(self._assignee)
125          return self._assignee.value
126  
127      @property
128      def assignees(self) -> list[NamedUser]:
129          self._completeIfNotSet(self._assignees)
130          return self._assignees.value
131  
132      @property
133      def body(self) -> str:
134          self._completeIfNotSet(self._body)
135          return self._body.value
136  
137      @property
138      def closed_at(self) -> datetime:
139          self._completeIfNotSet(self._closed_at)
140          return self._closed_at.value
141  
142      @property
143      def closed_by(self) -> NamedUser | None:
144          self._completeIfNotSet(self._closed_by)
145          return self._closed_by.value
146  
147      @property
148      def comments(self) -> int:
149          self._completeIfNotSet(self._comments)
150          return self._comments.value
151  
152      @property
153      def comments_url(self) -> str:
154          self._completeIfNotSet(self._comments_url)
155          return self._comments_url.value
156  
157      @property
158      def created_at(self) -> datetime:
159          self._completeIfNotSet(self._created_at)
160          return self._created_at.value
161  
162      @property
163      def events_url(self) -> str:
164          self._completeIfNotSet(self._events_url)
165          return self._events_url.value
166  
167      @property
168      def html_url(self) -> str:
169          self._completeIfNotSet(self._html_url)
170          return self._html_url.value
171  
172      @property
173      def id(self) -> int:
174          self._completeIfNotSet(self._id)
175          return self._id.value
176  
177      @property
178      def labels(self) -> list[Label]:
179          self._completeIfNotSet(self._labels)
180          return self._labels.value
181  
182      @property
183      def labels_url(self) -> str:
184          self._completeIfNotSet(self._labels_url)
185          return self._labels_url.value
186  
187      @property
188      def milestone(self) -> Milestone:
189          self._completeIfNotSet(self._milestone)
190          return self._milestone.value
191  
192      @property
193      def number(self) -> int:
194          self._completeIfNotSet(self._number)
195          return self._number.value
196  
197      @property
198      def pull_request(self) -> IssuePullRequest | None:
199          self._completeIfNotSet(self._pull_request)
200          return self._pull_request.value
201  
202      @property
203      def repository(self) -> Repository:
204          self._completeIfNotSet(self._repository)
205          if is_undefined(self._repository):
206              # The repository was not set automatically, so it must be looked up by url.
207              repo_url = "/".join(self.url.split("/")[:-2])
208              self._repository = github.GithubObject._ValuedAttribute(
209                  github.Repository.Repository(self._requester, self._headers, {"url": repo_url}, completed=False)
210              )
211          return self._repository.value
212  
213      @property
214      def state(self) -> str:
215          self._completeIfNotSet(self._state)
216          return self._state.value
217  
218      @property
219      def state_reason(self) -> str | None:
220          self._completeIfNotSet(self._state_reason)
221          return self._state_reason.value
222  
223      @property
224      def title(self) -> str:
225          self._completeIfNotSet(self._title)
226          return self._title.value
227  
228      @property
229      def updated_at(self) -> datetime:
230          self._completeIfNotSet(self._updated_at)
231          return self._updated_at.value
232  
233      @property
234      def url(self) -> str:
235          self._completeIfNotSet(self._url)
236          return self._url.value
237  
238      @property
239      def user(self) -> NamedUser:
240          self._completeIfNotSet(self._user)
241          return self._user.value
242  
243      @property
244      def locked(self) -> bool:
245          self._completeIfNotSet(self._locked)
246          return self._locked.value
247  
248      @property
249      def active_lock_reason(self) -> str | None:
250          self._completeIfNotSet(self._active_lock_reason)
251          return self._active_lock_reason.value
252  
253      def as_pull_request(self) -> PullRequest:
254          """
255          :calls: `GET /repos/{owner}/{repo}/pulls/{number} <https://docs.github.com/en/rest/reference/pulls>`_
256          """
257          headers, data = self._requester.requestJsonAndCheck("GET", "/pulls/".join(self.url.rsplit("/issues/", 1)))
258          return github.PullRequest.PullRequest(self._requester, headers, data, completed=True)
259  
260      def add_to_assignees(self, *assignees: NamedUser | str) -> None:
261          """
262          :calls: `POST /repos/{owner}/{repo}/issues/{number}/assignees <https://docs.github.com/en/rest/reference/issues#assignees>`_
263          """
264          assert all(isinstance(element, (github.NamedUser.NamedUser, str)) for element in assignees), assignees
265          post_parameters = {
266              "assignees": [
267                  assignee.login if isinstance(assignee, github.NamedUser.NamedUser) else assignee
268                  for assignee in assignees
269              ]
270          }
271          headers, data = self._requester.requestJsonAndCheck("POST", f"{self.url}/assignees", input=post_parameters)
272          self._useAttributes(data)
273  
274      def add_to_labels(self, *labels: Label | str) -> None:
275          """
276          :calls: `POST /repos/{owner}/{repo}/issues/{number}/labels <https://docs.github.com/en/rest/reference/issues#labels>`_
277          """
278          assert all(isinstance(element, (github.Label.Label, str)) for element in labels), labels
279          post_parameters = [label.name if isinstance(label, github.Label.Label) else label for label in labels]
280          headers, data = self._requester.requestJsonAndCheck("POST", f"{self.url}/labels", input=post_parameters)
281  
282      def create_comment(self, body: str) -> IssueComment:
283          """
284          :calls: `POST /repos/{owner}/{repo}/issues/{number}/comments <https://docs.github.com/en/rest/reference/issues#comments>`_
285          """
286          assert isinstance(body, str), body
287          post_parameters = {
288              "body": body,
289          }
290          headers, data = self._requester.requestJsonAndCheck("POST", f"{self.url}/comments", input=post_parameters)
291          return github.IssueComment.IssueComment(self._requester, headers, data, completed=True)
292  
293      def delete_labels(self) -> None:
294          """
295          :calls: `DELETE /repos/{owner}/{repo}/issues/{number}/labels <https://docs.github.com/en/rest/reference/issues#labels>`_
296          """
297          headers, data = self._requester.requestJsonAndCheck("DELETE", f"{self.url}/labels")
298  
299      def edit(
300          self,
301          title: Opt[str] = NotSet,
302          body: Opt[str] = NotSet,
303          assignee: Opt[str | NamedUser | None] = NotSet,
304          state: Opt[str] = NotSet,
305          milestone: Opt[Milestone | None] = NotSet,
306          labels: Opt[list[str]] = NotSet,
307          assignees: Opt[list[NamedUser | str]] = NotSet,
308          state_reason: Opt[str] = NotSet,
309      ) -> None:
310          """
311          :calls: `PATCH /repos/{owner}/{repo}/issues/{number} <https://docs.github.com/en/rest/reference/issues>`_
312          :param assignee: deprecated, use `assignees` instead. `assignee=None` means to remove current assignee.
313          :param milestone: `milestone=None` means to remove current milestone.
314          """
315          assert is_optional(title, str), title
316          assert is_optional(body, str), body
317          assert assignee is None or is_optional(assignee, (github.NamedUser.NamedUser, str)), assignee
318          assert is_optional_list(assignees, (github.NamedUser.NamedUser, str)), assignees
319          assert is_optional(state, str), state
320          assert milestone is None or is_optional(milestone, github.Milestone.Milestone), milestone
321          assert is_optional_list(labels, str), labels
322  
323          post_parameters = NotSet.remove_unset_items(
324              {
325                  "title": title,
326                  "body": body,
327                  "state": state,
328                  "state_reason": state_reason,
329                  "labels": labels,
330                  "assignee": assignee._identity
331                  if isinstance(assignee, github.NamedUser.NamedUser)
332                  else (assignee or ""),
333                  "milestone": milestone._identity
334                  if isinstance(milestone, github.Milestone.Milestone)
335                  else (milestone or ""),
336              }
337          )
338  
339          if is_defined(assignees):
340              post_parameters["assignees"] = [
341                  element._identity if isinstance(element, github.NamedUser.NamedUser) else element
342                  for element in assignees
343              ]
344  
345          headers, data = self._requester.requestJsonAndCheck("PATCH", self.url, input=post_parameters)
346          self._useAttributes(data)
347  
348      def lock(self, lock_reason: str) -> None:
349          """
350          :calls: `PUT /repos/{owner}/{repo}/issues/{issue_number}/lock <https://docs.github.com/en/rest/reference/issues>`_
351          """
352          assert isinstance(lock_reason, str), lock_reason
353          put_parameters = {"lock_reason": lock_reason}
354          headers, data = self._requester.requestJsonAndCheck(
355              "PUT",
356              f"{self.url}/lock",
357              input=put_parameters,
358              headers={"Accept": Consts.mediaTypeLockReasonPreview},
359          )
360  
361      def unlock(self) -> None:
362          """
363          :calls: `DELETE /repos/{owner}/{repo}/issues/{issue_number}/lock <https://docs.github.com/en/rest/reference/issues>`_
364          """
365          headers, data = self._requester.requestJsonAndCheck("DELETE", f"{self.url}/lock")
366  
367      def get_comment(self, id: int) -> IssueComment:
368          """
369          :calls: `GET /repos/{owner}/{repo}/issues/comments/{id} <https://docs.github.com/en/rest/reference/issues#comments>`_
370          """
371          assert isinstance(id, int), id
372          headers, data = self._requester.requestJsonAndCheck("GET", f"{self._parentUrl(self.url)}/comments/{id}")
373          return github.IssueComment.IssueComment(self._requester, headers, data, completed=True)
374  
375      def get_comments(self, since: Opt[datetime] = NotSet) -> PaginatedList[IssueComment]:
376          """
377          :calls: `GET /repos/{owner}/{repo}/issues/{number}/comments <https://docs.github.com/en/rest/reference/issues#comments>`_
378          """
379          url_parameters = {}
380          if is_defined(since):
381              assert isinstance(since, datetime), since
382              url_parameters["since"] = since.strftime("%Y-%m-%dT%H:%M:%SZ")
383  
384          return PaginatedList(
385              github.IssueComment.IssueComment,
386              self._requester,
387              f"{self.url}/comments",
388              url_parameters,
389          )
390  
391      def get_events(self) -> PaginatedList[IssueEvent]:
392          """
393          :calls: `GET /repos/{owner}/{repo}/issues/{issue_number}/events <https://docs.github.com/en/rest/reference/issues#events>`_
394          """
395          return PaginatedList(
396              github.IssueEvent.IssueEvent,
397              self._requester,
398              f"{self.url}/events",
399              None,
400              headers={"Accept": Consts.mediaTypeLockReasonPreview},
401          )
402  
403      def get_labels(self) -> PaginatedList[Label]:
404          """
405          :calls: `GET /repos/{owner}/{repo}/issues/{number}/labels <https://docs.github.com/en/rest/reference/issues#labels>`_
406          """
407          return PaginatedList(github.Label.Label, self._requester, f"{self.url}/labels", None)
408  
409      def remove_from_assignees(self, *assignees: NamedUser | str) -> None:
410          """
411          :calls: `DELETE /repos/{owner}/{repo}/issues/{number}/assignees <https://docs.github.com/en/rest/reference/issues#assignees>`_
412          """
413          assert all(isinstance(element, (github.NamedUser.NamedUser, str)) for element in assignees), assignees
414          post_parameters = {
415              "assignees": [
416                  assignee.login if isinstance(assignee, github.NamedUser.NamedUser) else assignee
417                  for assignee in assignees
418              ]
419          }
420          headers, data = self._requester.requestJsonAndCheck("DELETE", f"{self.url}/assignees", input=post_parameters)
421          self._useAttributes(data)
422  
423      def remove_from_labels(self, label: Label | str) -> None:
424          """
425          :calls: `DELETE /repos/{owner}/{repo}/issues/{number}/labels/{name} <https://docs.github.com/en/rest/reference/issues#labels>`_
426          """
427          assert isinstance(label, (github.Label.Label, str)), label
428          if isinstance(label, github.Label.Label):
429              label = label._identity
430          else:
431              label = urllib.parse.quote(label)
432          headers, data = self._requester.requestJsonAndCheck("DELETE", f"{self.url}/labels/{label}")
433  
434      def set_labels(self, *labels: Label | str) -> None:
435          """
436          :calls: `PUT /repos/{owner}/{repo}/issues/{number}/labels <https://docs.github.com/en/rest/reference/issues#labels>`_
437          """
438          assert all(isinstance(element, (github.Label.Label, str)) for element in labels), labels
439          post_parameters = [label.name if isinstance(label, github.Label.Label) else label for label in labels]
440          headers, data = self._requester.requestJsonAndCheck("PUT", f"{self.url}/labels", input=post_parameters)
441  
442      def get_reactions(self) -> PaginatedList[Reaction]:
443          """
444          :calls: `GET /repos/{owner}/{repo}/issues/{number}/reactions <https://docs.github.com/en/rest/reference/reactions#list-reactions-for-an-issue>`_
445          """
446          return PaginatedList(
447              github.Reaction.Reaction,
448              self._requester,
449              f"{self.url}/reactions",
450              None,
451              headers={"Accept": Consts.mediaTypeReactionsPreview},
452          )
453  
454      def create_reaction(self, reaction_type: str) -> Reaction:
455          """
456          :calls: `POST /repos/{owner}/{repo}/issues/{number}/reactions <https://docs.github.com/en/rest/reference/reactions>`_
457          """
458          assert isinstance(reaction_type, str), reaction_type
459          post_parameters = {
460              "content": reaction_type,
461          }
462          headers, data = self._requester.requestJsonAndCheck(
463              "POST",
464              f"{self.url}/reactions",
465              input=post_parameters,
466              headers={"Accept": Consts.mediaTypeReactionsPreview},
467          )
468          return github.Reaction.Reaction(self._requester, headers, data, completed=True)
469  
470      def delete_reaction(self, reaction_id: int) -> bool:
471          """
472          :calls: `DELETE /repos/{owner}/{repo}/issues/{issue_number}/reactions/{reaction_id} <https://docs.github.com/en/rest/reference/reactions#delete-an-issue-reaction>`_
473          """
474          assert isinstance(reaction_id, int), reaction_id
475          status, _, _ = self._requester.requestJson(
476              "DELETE",
477              f"{self.url}/reactions/{reaction_id}",
478              headers={"Accept": Consts.mediaTypeReactionsPreview},
479          )
480          return status == 204
481  
482      def get_timeline(self) -> PaginatedList[TimelineEvent]:
483          """
484          :calls: `GET /repos/{owner}/{repo}/issues/{number}/timeline <https://docs.github.com/en/rest/reference/issues#list-timeline-events-for-an-issue>`_
485          """
486          return PaginatedList(
487              github.TimelineEvent.TimelineEvent,
488              self._requester,
489              f"{self.url}/timeline",
490              None,
491              headers={"Accept": Consts.issueTimelineEventsPreview},
492          )
493  
494      @property
495      def _identity(self) -> int:
496          return self.number
497  
498      def _useAttributes(self, attributes: dict[str, Any]) -> None:
499          if "active_lock_reason" in attributes:  # pragma no branch
500              self._active_lock_reason = self._makeStringAttribute(attributes["active_lock_reason"])
501          if "assignee" in attributes:  # pragma no branch
502              self._assignee = self._makeClassAttribute(github.NamedUser.NamedUser, attributes["assignee"])
503          if "assignees" in attributes:  # pragma no branch
504              self._assignees = self._makeListOfClassesAttribute(github.NamedUser.NamedUser, attributes["assignees"])
505          elif "assignee" in attributes:
506              if attributes["assignee"] is not None:
507                  self._assignees = self._makeListOfClassesAttribute(github.NamedUser.NamedUser, [attributes["assignee"]])
508              else:
509                  self._assignees = self._makeListOfClassesAttribute(github.NamedUser.NamedUser, [])
510          if "body" in attributes:  # pragma no branch
511              self._body = self._makeStringAttribute(attributes["body"])
512          if "closed_at" in attributes:  # pragma no branch
513              self._closed_at = self._makeDatetimeAttribute(attributes["closed_at"])
514          if "closed_by" in attributes:  # pragma no branch
515              self._closed_by = self._makeClassAttribute(github.NamedUser.NamedUser, attributes["closed_by"])
516          if "comments" in attributes:  # pragma no branch
517              self._comments = self._makeIntAttribute(attributes["comments"])
518          if "comments_url" in attributes:  # pragma no branch
519              self._comments_url = self._makeStringAttribute(attributes["comments_url"])
520          if "created_at" in attributes:  # pragma no branch
521              self._created_at = self._makeDatetimeAttribute(attributes["created_at"])
522          if "events_url" in attributes:  # pragma no branch
523              self._events_url = self._makeStringAttribute(attributes["events_url"])
524          if "html_url" in attributes:  # pragma no branch
525              self._html_url = self._makeStringAttribute(attributes["html_url"])
526          if "id" in attributes:  # pragma no branch
527              self._id = self._makeIntAttribute(attributes["id"])
528          if "labels" in attributes:  # pragma no branch
529              self._labels = self._makeListOfClassesAttribute(github.Label.Label, attributes["labels"])
530          if "labels_url" in attributes:  # pragma no branch
531              self._labels_url = self._makeStringAttribute(attributes["labels_url"])
532          if "locked" in attributes:  # pragma no branch
533              self._locked = self._makeBoolAttribute(attributes["locked"])
534          if "milestone" in attributes:  # pragma no branch
535              self._milestone = self._makeClassAttribute(github.Milestone.Milestone, attributes["milestone"])
536          if "number" in attributes:  # pragma no branch
537              self._number = self._makeIntAttribute(attributes["number"])
538          if "pull_request" in attributes:  # pragma no branch
539              self._pull_request = self._makeClassAttribute(
540                  github.IssuePullRequest.IssuePullRequest, attributes["pull_request"]
541              )
542          if "repository" in attributes:  # pragma no branch
543              self._repository = self._makeClassAttribute(github.Repository.Repository, attributes["repository"])
544          if "state" in attributes:  # pragma no branch
545              self._state = self._makeStringAttribute(attributes["state"])
546          if "state_reason" in attributes:  # pragma no branch
547              self._state_reason = self._makeStringAttribute(attributes["state_reason"])
548          if "title" in attributes:  # pragma no branch
549              self._title = self._makeStringAttribute(attributes["title"])
550          if "updated_at" in attributes:  # pragma no branch
551              self._updated_at = self._makeDatetimeAttribute(attributes["updated_at"])
552          if "url" in attributes:  # pragma no branch
553              self._url = self._makeStringAttribute(attributes["url"])
554          if "user" in attributes:  # pragma no branch
555              self._user = self._makeClassAttribute(github.NamedUser.NamedUser, attributes["user"])