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"])