diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..0ffc101 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [tiangolo] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cd972a0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + commit-message: + prefix: ⬆ + # Python + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + commit-message: + prefix: ⬆ diff --git a/.github/workflows/issue-manager.yml b/.github/workflows/issue-manager.yml index 4f30a59..309e4d1 100644 --- a/.github/workflows/issue-manager.yml +++ b/.github/workflows/issue-manager.yml @@ -2,7 +2,7 @@ name: Issue Manager on: schedule: - - cron: "0 0 * * *" + - cron: "0 0 * * *" issue_comment: types: - created @@ -22,8 +22,12 @@ jobs: config: > { "answered":{ - "delay": 10, + "delay": 300, "users": ["mariacamilagl"], "message": "This issue shall be closed." + }, + "more-info-needed": { + "delay": 120, + "remove_label_on_close": true } } diff --git a/.github/workflows/latest-changes.yml b/.github/workflows/latest-changes.yml new file mode 100644 index 0000000..c5625bc --- /dev/null +++ b/.github/workflows/latest-changes.yml @@ -0,0 +1,29 @@ +name: Latest Changes + +on: + pull_request_target: + branches: + - master + types: + - closed + workflow_dispatch: + inputs: + number: + description: PR number + required: true + debug_enabled: + description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' + required: false + default: 'false' + +jobs: + latest-changes: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.ISSUE_MANAGER_LATEST_CHANGES }} + - uses: docker://tiangolo/latest-changes:0.2.0 + # - uses: tiangolo/latest-changes@main + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile index dc37b72..d8b60d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,11 @@ -FROM python:3.7 +FROM python:3.10 -RUN pip install PyGithub "pydantic==1.5.1" +COPY ./requirements.txt /code/requirements.txt -COPY ./app /app +RUN pip install -r /code/requirements.txt -CMD ["python", "/app/main.py"] +COPY ./app /code/app + +ENV PYTHONPATH=/code/app + +CMD ["python", "/code/app/main.py"] diff --git a/README.md b/README.md index a787d3e..f7164bb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Issue Manager -Automatically close issues that have a **label**, after a **custom delay**, if no one replies back. +Automatically close issues or Pull Requests that have a **label**, after a **custom delay**, if no one replies back. ## How to use @@ -17,32 +17,35 @@ on: issue_comment: types: - created - - edited issues: types: - labeled + pull_request_target: + types: + - labeled + workflow_dispatch: jobs: issue-manager: runs-on: ubuntu-latest steps: - - uses: tiangolo/issue-manager@0.2.0 + - uses: tiangolo/issue-manager@0.4.0 with: - token: ${{ secrets.GITHUB_TOKEN }} - config: '{"answered": {}}' + token: ${{ secrets.GITHUB_TOKEN }} + config: '{"answered": {}}' ``` -Then, you can answer an issue and add the label from the config, in this case, `answered`. +Then, you can answer an issue or PR and add the label from the config, in this case, `answered`. After 10 days, if no one has added a new comment, the GitHub action will write: ```markdown -Assuming the original issue was solved, it will be automatically closed now. +Assuming the original need was handled, this will be automatically closed now. ``` And then it will close the issue. -But if someone adds a comment _after_ you added the label, it will remove the label. +But if someone adds a comment _after_ you added the label, this GitHub Action will remove the label so that you can come back and check it instead of closing it. ## Config @@ -68,7 +71,7 @@ Imagine this JSON config: { "answered": { "delay": "P3DT12H30M5S", - "message": "It seems the issue was answered, I'll close this now." + "message": "It seems the issue was answered, closing this now." }, "validated": { "delay": 300, @@ -77,6 +80,10 @@ Imagine this JSON config: "waiting": { "delay": 691200, "message": "Closing after 8 days of waiting for the additional info requested." + }, + "needs-tests": { + "delay": 691200, + "message": "This PR will be closed after waiting 8 days for tests to be added. Please create a new one with tests." } } ``` @@ -90,7 +97,7 @@ In this case, if: ...the GitHub action would close the issue with a message of: ```markdown -It seems the issue was answered, I'll close this now. +It seems the issue was answered, closing this now. ``` But if there was a new comment created _after_ the label was added, by default, it would remove the label. @@ -113,11 +120,11 @@ And also, if there was a new comment created _after_ the label was added, by def --- -And in the last case, if: +Then, if: * the issue has a label `waiting` * the label was added _after_ the last comment -* the last comment was addded more than `691200` seconds (10 days) ago +* the last comment was addded more than `691200` seconds (8 days) ago ...the GitHub action would close the issue with: @@ -127,51 +134,46 @@ Closing after 10 days of waiting for the additional info requested. And again, by default, removing the label if there was a new comment written after adding the label. -### Delay - -The delay can be configured using [anything supported by Pydantic's `datetime`](https://pydantic-docs.helpmanual.io/usage/types/#datetime-types). +--- -So, it can be an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) period format (like `P3DT12H30M5S`), or the amount of seconds between the two dates (like `691200`, or 10 days) plus other options. +And finally, if: -### Users and HTML comments +* a PR has a label `needs-tests` +* the label was added _after_ the last comment +* the last comment was addded more than `691200` seconds (8 days) ago -Before supporting labels, this GitHub action used HTML comments, so, you would write a comment like: +...the GitHub action would close the PR with: ```markdown -Ah, you have to use a JSON string in the config. - - +This PR will be closed after waiting 8 days for tests to be added. Please create a new one with tests. ``` -Then the comment would only show: +**Note**: in this last example the process is applied to a PR instead of an issue. The same logic applies to both issues and PRs. If you want a label to only apply to issues, you should use that label only with issues, and the same with PRs. -```markdown -Ah, you have to use a JSON string in the config. -``` +### Delay -And the GitHub action would read the label/keyword from that HTML comment. +The delay can be configured using [anything supported by Pydantic's `datetime`](https://pydantic-docs.helpmanual.io/usage/types/#datetime-types). + +So, it can be an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) period format (like `P3DT12H30M5S`), or the number of seconds between the two dates (like `691200`, or 10 days) plus other options. -To support external users adding these comments (even if they can't add labels to your repo), you can add a config `users` with a list of usernames allowed to add these HTML keyword comments. +### Remove label on comment -In this case, the GitHub action will only close the issue if: +You can also pass a config `remove_label_on_comment` per keyword. By default, it's `true`. -* the _last_ comment has the keyword/label -* it was written by a user in the `users` list in the `config` (or the owner of the repo) -* the time delay since the last comment is enough +When someone adds a comment _after_ the label was added, then this GitHub action won't close the issue. -### Remove label +On top of not closing the issue, by default, it will remove the label. You can disable removing the label by setting `remove_label_on_comment` to `false`. -You can also pass a config `remove_label` per keyword. By default it's `true`. +### Remove label on close -When someone adds a comment _after_ the label was added, then this GitHub action won't close the issue. +After this GitHub action closes an issue it can also automatically remove the label from the issue when you pass the config `remove_label_on_close` set to `true`. -On top of not closing the issue, by default, it will remove the label. You can disable removing the label by setting `remove_label` to `false`. +By default it is false, and doesn't remove the label from the issue. ### Defaults By default, any config has: -* `users`: No users, only the repository owner (only applies to HTML comments). * `delay`: A delay of 10 days. * `message`: A message of: @@ -179,7 +181,8 @@ By default, any config has: Assuming the original issue was solved, it will be automatically closed now. ``` -* `remove_label`: True. If someone adds a comment after you added the label, it will remove the label from the issue. +* `remove_label_on_comment`: True. If someone adds a comment after you added the label, it will remove the label from the issue. +* `remove_label_on_close`: False. After this GitHub action closes the issue it would also remove the label from the issue. ### Config in the action @@ -204,34 +207,38 @@ on: issues: types: - labeled + pull_request_target: + types: + - labeled + workflow_dispatch: jobs: issue-manager: runs-on: ubuntu-latest steps: - - uses: tiangolo/issue-manager@0.2.0 + - uses: tiangolo/issue-manager@0.4.0 with: - token: ${{ secrets.GITHUB_TOKEN }} - config: > - { - "answered": { - "delay": "P3DT12H30M5S", - "message": "It seems the issue was answered, I'll close this now." - }, - "validated": { - "delay": 300, - "message": "The issue could not be validated after 5 minutes. Closing now." - }, - "waiting": { - "delay": 691200, - "message": "Closing after 8 days of waiting for the additional info requested." - } + token: ${{ secrets.GITHUB_TOKEN }} + config: > + { + "answered": { + "delay": "P3DT12H30M5S", + "message": "It seems the issue was answered, closing this now." + }, + "validated": { + "delay": 300, + "message": "The issue could not be validated after 5 minutes. Closing now." + }, + "waiting": { + "delay": 691200, + "message": "Closing after 8 days of waiting for the additional info requested." } + } ``` ### Edit your own config -If you have [Visual Studio Code](https://code.visualstudio.com) or other modern editor, you can create your JSON config by creating a JSON file, e.g. `config.json`. +If you have [Visual Studio Code](https://code.visualstudio.com) or another modern editor, you can create your JSON config by creating a JSON file, e.g. `config.json`. Then writing the contents of your config in that file, and then copying the results. @@ -245,13 +252,13 @@ You can start your JSON config file with: } ``` -And then after you write a keyword and start its config, like `"answered": {}`, it will autocomplete the internal config keys, like `delay`, `users`, `message`. And will validate its contents. +And then after you write a keyword and start its config, like `"answered": {}`, it will autocomplete the internal config keys, like `delay`, `message`. And will validate its contents. It's fine to leave the `$schema` in the `config` on the `.yml` file, it will be discarded and won't be used as a label. ### A complete example -**Note**: you probably don't need all the configs, the examples above should suffice for most cases. But if you want to allow other users to use keywords/labels in HTML comments, or want to make the GitHub action _not_ remove the labels if someone adds a new comment, this can help as an example: +**Note**: you probably don't need all the configs, the examples above should suffice for most cases. But if you want to make the GitHub action _not_ remove the labels if someone adds a new comment, this can help as an example: ```yml name: Issue Manager @@ -266,45 +273,40 @@ on: issues: types: - labeled + pull_request_target: + types: + - labeled + workflow_dispatch: jobs: issue-manager: runs-on: ubuntu-latest steps: - - uses: tiangolo/issue-manager@0.2.0 + - uses: tiangolo/issue-manager@0.4.0 with: - token: ${{ secrets.GITHUB_TOKEN }} - config: > - { - "$schema": "https://raw.githubusercontent.com/tiangolo/issue-manager/master/schema.json", - "answered": { - "users": [ - "tiangolo", - "dmontagu" - ], - "delay": "P3DT12H30M5S", - "message": "It seems the issue was answered, I'll close this now.", - "remove_label": false - }, - "validated": { - "users": [ - "tiangolo", - "samuelcolvin" - ], - "delay": 300, - "message": "The issue could not be validated after 5 minutes. Closing now.", - "remove_label": true - }, - "waiting": { - "users": [ - "tomchristie", - "dmontagu" - ], - "delay": 691200, - "message": "Closing after 8 days of waiting for the additional info requested.", - "remove_label": true - } + token: ${{ secrets.GITHUB_TOKEN }} + config: > + { + "$schema": "https://raw.githubusercontent.com/tiangolo/issue-manager/master/schema.json", + "answered": { + "delay": "P3DT12H30M5S", + "message": "It seems the issue was answered, closing this now.", + "remove_label_on_comment": false, + "remove_label_on_close": false + }, + "validated": { + "delay": 300, + "message": "The issue could not be validated after 5 minutes. Closing now.", + "remove_label_on_comment": true, + "remove_label_on_close": false + }, + "waiting": { + "delay": 691200, + "message": "Closing after 8 days of waiting for the additional info requested.", + "remove_label_on_comment": true, + "remove_label_on_close": true } + } ``` ## GitHub Action triggers @@ -322,6 +324,10 @@ on: issues: types: - labeled + pull_request_target: + types: + - labeled + workflow_dispatch: ``` * The `cron` option means that the GitHub action will be run every day at 00:00 UTC. @@ -329,14 +335,17 @@ on: * This way, if there's a new comment, it can immediately remove any label that was added before the new comment. * The `issues` option with a type of `label` will run it with each specific issue when you add a label. * This way you can add a label to an issue that was answered long ago, and if the configured delay since the last comment is enough the GitHub action will close the issue right away. +* The `pull_request_target` option with a type of `label` will run it with each specific Pull Request made to your repo when you add a label. + * This way you can add a label to a PR that was answered long ago, or that was waiting for more comments from the author, etc. And if the configured delay since the last comment is enough the GitHub action will close the issue right away. +* The `workflow_dispatch` option allows you to run the action manually from the GitHub Actions tab for your repo. ## Motivation ### Closing early -When I answer an issue, I like to give the original user some time to respond, and give them the chance to close the issue before doing it myself. +When I answer an issue, I like to give the original user some time to respond and give them the chance to close the issue before doing it myself. -Or some times, I have to request additional info. +Or sometimes, I have to request additional info. Sometimes, my answer didn't respond the real question/problem, and if I closed the issue immediately, it would end up feeling "impolite" to the user. @@ -346,7 +355,7 @@ Moreover, if I closed the issue prematurely, there's a smaller chance that I (or But then, if I leave the issue open after giving an answer, in many cases, the issue will keep open until I come back to close it, after many days. -Then, after that time (10 days, 30 days) and after seeing that there are no new comments, I write "I assume the problem is solved, I'll close this issue now". +Then, after that time (10 days, 30 days) and after seeing that there are no new comments, I write "I assume the problem is solved, closing this issue now". But that requires me going through all the open issues again, one by one, check where I (or someone else) have already answered, typing that message, etc. @@ -354,7 +363,7 @@ But that requires me going through all the open issues again, one by one, check One option would be to use a tool that closes stale issues, like [probot/stale](https://github.com/probot/stale), or the [Close Stale Issues Action](https://github.com/marketplace/actions/close-stale-issues). -But if the user came back explaining that my answer didn't respond to his/her problem, or giving the extra info requested, but I couldn't respond on time, the issue would still go "stale" and be closed. +But if the user came back explaining that my answer didn't respond to his/her problem or giving the extra info requested, but I couldn't respond on time, the issue would still go "stale" and be closed. ## What Issue Manager does @@ -369,14 +378,38 @@ Then, this action, by running every night (or however you configure it) will, fo * Then, if all that matches, it will add a comment with a message (configurable). * And then it will close the issue. -Also, all that with the optional alternative using HTML comments. - It will also run after each comment or label added, with the specific issue that has the new comment or label (if you used the example configurations from above). ## Release Notes ### Latest Changes +### 0.4.1 + +#### Fixes + +* 🐛 Fix datetime comparison. PR [#19](https://github.com/tiangolo/issue-manager/pull/19) by [@tiangolo](https://github.com/tiangolo). + +#### Internal + +* 🔧 Add funding. PR [#18](https://github.com/tiangolo/issue-manager/pull/18) by [@tiangolo](https://github.com/tiangolo). +* 👷 Update dependabot. PR [#17](https://github.com/tiangolo/issue-manager/pull/17) by [@tiangolo](https://github.com/tiangolo). +* 👷 Add latest-changes GitHub Action. PR [#16](https://github.com/tiangolo/issue-manager/pull/16) by [@tiangolo](https://github.com/tiangolo). + +### 0.4.0 + +* ✨ Add support for managing PRs and remove support for HTML comments to avoid rate limits. PR [#12](https://github.com/tiangolo/issue-manager/pull/12) by [@tiangolo](https://github.com/tiangolo). +* 👷 Add Latest Changes GitHub Action. PR [#13](https://github.com/tiangolo/issue-manager/pull/13) by [@tiangolo](https://github.com/tiangolo). + +### 0.3.0 + +* Add option to remove a label automatically after closing the issue. PR [#10](https://github.com/tiangolo/issue-manager/pull/10). + +### 0.2.1 + +* Avoid crashing when a label has been edited _after_ added to the issue. PR [#9](https://github.com/tiangolo/issue-manager/pull/9). +* Fix using single quote (`'`) in README examples. PR [#6](https://github.com/tiangolo/issue-manager/pull/6) by [@svlandeg](https://github.com/svlandeg). + ### 0.2.0 * Add support for running immediately with each specific issue after a new comment or label is added. diff --git a/app/main.py b/app/main.py index 7cd89cf..4a5ea63 100644 --- a/app/main.py +++ b/app/main.py @@ -1,21 +1,21 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import logging from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set from github import Github from github.Issue import Issue from github.IssueComment import IssueComment from github.IssueEvent import IssueEvent -from github.NamedUser import NamedUser -from pydantic import BaseModel, BaseSettings, SecretStr, validator +from pydantic import BaseModel, SecretStr, validator +from pydantic_settings import BaseSettings class KeywordMeta(BaseModel): delay: timedelta = timedelta(days=10) - users: List[str] = [] - message: str = "Assuming the original issue was solved, it will be automatically closed now." - remove_label: bool = True + message: str = "Assuming the original need was handled, this will be automatically closed now." + remove_label_on_comment: bool = True + remove_label_on_close: bool = False class Settings(BaseSettings): @@ -38,6 +38,7 @@ class PartialGitHubEventIssue(BaseModel): class PartialGitHubEvent(BaseModel): issue: Optional[PartialGitHubEventIssue] = None + pull_request: Optional[PartialGitHubEventIssue] = None def get_last_comment(issue: Issue) -> Optional[IssueComment]: @@ -61,10 +62,10 @@ def get_labeled_events(events: List[IssueEvent]) -> List[IssueEvent]: def get_last_event_for_label( *, labeled_events: List[IssueEvent], label: str -) -> IssueEvent: +) -> Optional[IssueEvent]: last_event: Optional[IssueEvent] = None for event in labeled_events: - if event.label.name == label: + if event.label and event.label.name == label: if not last_event: last_event = event continue @@ -73,58 +74,62 @@ def get_last_event_for_label( return last_event -def close_issue(*, issue: Issue, keyword_meta: KeywordMeta) -> None: +def close_issue( + *, issue: Issue, keyword_meta: KeywordMeta, keyword: str, label_strs: Set[str] +) -> None: logging.info( f"Clossing issue: #{issue.number} with message: {keyword_meta.message}" ) issue.create_comment(keyword_meta.message) issue.edit(state="closed") + if keyword_meta.remove_label_on_close: + if keyword in label_strs: + issue.remove_from_labels(keyword) -def process_issue(*, issue: Issue, settings: Settings, owner: NamedUser) -> None: +def process_issue(*, issue: Issue, settings: Settings) -> None: logging.info(f"Processing issue: #{issue.number}") - label_strs = set([l.name for l in issue.get_labels()]) + label_strs = set([label.name for label in issue.get_labels()]) events = list(issue.get_events()) labeled_events = get_labeled_events(events) last_comment = get_last_comment(issue) + now = datetime.now(timezone.utc) for keyword, keyword_meta in settings.input_config.items(): # Check closable delay, if enough time passed and the issue could be closed - closable_delay = False - if ( + closable_delay = ( last_comment is None - or (datetime.utcnow() - keyword_meta.delay) > last_comment.created_at - ): - closable_delay = True + or (now - keyword_meta.delay) > last_comment.created_at + ) # Check label, optionally removing it if there's a comment after adding it if keyword in label_strs: logging.info(f'Keyword: "{keyword}" in issue labels') keyword_event = get_last_event_for_label( labeled_events=labeled_events, label=keyword ) - if last_comment and last_comment.created_at > keyword_event.created_at: + if ( + last_comment + and keyword_event + and last_comment.created_at > keyword_event.created_at + ): logging.info( f"Not closing as the last comment was written after adding the " f'label: "{keyword}"' ) - if keyword_meta.remove_label: + if keyword_meta.remove_label_on_comment: logging.info(f'Removing label: "{keyword}"') issue.remove_from_labels(keyword) elif closable_delay: - close_issue(issue=issue, keyword_meta=keyword_meta) + close_issue( + issue=issue, + keyword_meta=keyword_meta, + keyword=keyword, + label_strs=label_strs, + ) break - # Check HTML comments by allowed users - if ( - last_comment - and f"" in last_comment.body - and closable_delay - and last_comment.user.login in keyword_meta.users + [owner.login] - ): - logging.info( - f'Last comment by user: "{last_comment.user.login}" had HTML keyword ' - f'comment: "{keyword}" and there\'s a closable delay.' - ) - close_issue(issue=issue, keyword_meta=keyword_meta) - break + else: + logging.info( + f"Not clossing issue: #{issue.number} as the delay hasn't been reached: {keyword_meta.delay}" + ) if __name__ == "__main__": @@ -133,20 +138,27 @@ def process_issue(*, issue: Issue, settings: Settings, owner: NamedUser) -> None logging.info(f"Using config: {settings.json()}") g = Github(settings.input_token.get_secret_value()) repo = g.get_repo(settings.github_repository) - owner: NamedUser = repo.owner github_event: Optional[PartialGitHubEvent] = None if settings.github_event_path.is_file(): contents = settings.github_event_path.read_text() github_event = PartialGitHubEvent.parse_raw(contents) if ( settings.github_event_name == "issues" + or settings.github_event_name == "pull_request_target" or settings.github_event_name == "issue_comment" ): - if github_event and github_event.issue: - issue = repo.get_issue(github_event.issue.number) - if issue.state == "open": - process_issue(issue=issue, settings=settings, owner=owner) + if github_event: + issue_number: Optional[int] = None + if github_event.issue: + issue_number = github_event.issue.number + elif github_event.pull_request: + issue_number = github_event.pull_request.number + if issue_number is not None: + issue = repo.get_issue(issue_number) + if issue.state == "open": + process_issue(issue=issue, settings=settings) else: - for issue in repo.get_issues(state="open"): - process_issue(issue=issue, settings=settings, owner=owner) - logging.info(f"Finished") + for keyword, keyword_meta in settings.input_config.items(): + for issue in repo.get_issues(state="open", labels=[keyword]): + process_issue(issue=issue, settings=settings) + logging.info("Finished") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..65e67cf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PyGithub +pydantic>=2.5.3,<3.0.0 +pydantic-settings>=2.1.0,<3.0.0 diff --git a/schema.json b/schema.json index 09f022a..ba584e2 100644 --- a/schema.json +++ b/schema.json @@ -24,10 +24,15 @@ "default": "Assuming the original issue was solved, it will be automatically closed now.", "type": "string" }, - "remove_label": { - "title": "Remove Label", + "remove_label_on_comment": { + "title": "Remove Label On Comment", "default": true, "type": "boolean" + }, + "remove_label_on_close": { + "title": "Remove Label On Close", + "default": false, + "type": "boolean" } } }