diff --git a/README.md b/README.md index c7dd9a5..c57817e 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,11 @@ Imagine this JSON config: }, "waiting": { "delay": 691200, - "message": "Closing after 8 days of waiting for the additional info requested." + "message": "Closing after 8 days of waiting for the additional info requested.", + "reminder": { + "before": "P3D", + "message": "Heads-up: this will be closed in ~3 days unless there’s new activity." + } }, "needs-tests": { "delay": 691200, @@ -130,7 +134,13 @@ Then, if: * the label was added _after_ the last comment * the last comment was addded more than `691200` seconds (8 days) ago -...the GitHub action would close the issue with: +...the GitHub action would send a reminder on day 5 (because the delay is 8 days and the reminder is set to 3 days before closing): + +```markdown +Heads-up: this will be closed in ~3 days unless there’s new activity. +``` + +...and if there is still no activity, it would finally close the issue with: ```markdown Closing after 10 days of waiting for the additional info requested. @@ -174,6 +184,30 @@ After this GitHub action closes an issue it can also automatically remove the la By default it is false, and doesn't remove the label from the issue. +### Reminder + +Each label can also define an optional reminder with: + +* `before`: How long before the issue/PR would be closed to send the reminder. + Must be shorter than the main `delay`. + Supports ISO 8601 durations (e.g. `P3D`) or seconds. +* `message`: The text to post as a comment. + +The reminder is just a comment, it does not close the issue or PR. + +Example: + +```json +"waiting": { + "delay": 691200, + "message": "Closing after 8 days of waiting for the additional info requested.", + "reminder": { + "before": "P3D", + "message": "Heads-up: this will be closed in ~3 days unless there’s new activity." + } +} + + ### Defaults By default, any config has: @@ -187,6 +221,7 @@ Assuming the original issue was solved, it will be automatically closed now. * `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. +* `reminder`: None. No reminder will be sent unless explicitly configured. ### Config in the action @@ -239,7 +274,11 @@ jobs: }, "waiting": { "delay": 691200, - "message": "Closing after 8 days of waiting for the additional info requested." + "message": "Closing after 8 days of waiting for the additional info requested.", + "reminder": { + "before": "P3D", + "message": "Heads-up: this will be closed in ~3 days unless there’s new activity." + } } } ``` @@ -316,7 +355,11 @@ jobs: "delay": 691200, "message": "Closing after 8 days of waiting for the additional info requested.", "remove_label_on_comment": true, - "remove_label_on_close": true + "remove_label_on_close": true, + "reminder": { + "before": "P3D", + "message": "Heads-up: this will be closed in ~3 days unless there’s new activity." + } } } ``` @@ -402,6 +445,7 @@ Then, this action, by running every night (or however you configure it) will, fo * Check if the issue has one of the configured labels. * Check if the label was added _after_ the last comment. * If not, remove the label (configurable). +* If a reminder is configured and its time has arrived, post the reminder comment. * Check if the current date-time is more than the configured *delay* to wait for the user to reply back (configurable). * Then, if all that matches, it will add a comment with a message (configurable). * And then it will close the issue. diff --git a/app/main.py b/app/main.py index d3667e9..2137812 100644 --- a/app/main.py +++ b/app/main.py @@ -2,13 +2,25 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Dict, List, Optional, Set +from typing_extensions import Literal from github import Github +from github.PaginatedList import PaginatedList +from github.IssueComment import IssueComment from github.Issue import Issue from github.IssueEvent import IssueEvent from pydantic import BaseModel, SecretStr, validator from pydantic_settings import BaseSettings +REMINDER_MARKER = "" + + +class Reminder(BaseModel): + message: str = ( + "This will be closed automatically soon if there's no further activity." + ) + before: timedelta = timedelta(days=1) + class KeywordMeta(BaseModel): delay: timedelta = timedelta(days=10) @@ -17,6 +29,7 @@ class KeywordMeta(BaseModel): ) remove_label_on_comment: bool = True remove_label_on_close: bool = False + reminder: Optional[Reminder] = None class Settings(BaseSettings): @@ -42,9 +55,26 @@ class PartialGitHubEvent(BaseModel): pull_request: Optional[PartialGitHubEventIssue] = None +def filter_comments( + comments: PaginatedList[IssueComment], include: Literal["regular", "reminder"] +) -> list[IssueComment]: + if include == "regular": + return [ + comment + for comment in comments + if not comment.body.startswith(REMINDER_MARKER) + ] + elif include == "reminder": + return [ + comment for comment in comments if comment.body.startswith(REMINDER_MARKER) + ] + else: + raise ValueError(f"Unsupported value of include ({include})") + + def get_last_interaction_date(issue: Issue) -> Optional[datetime]: last_date: Optional[datetime] = None - comments = list(issue.get_comments()) + comments = filter_comments(issue.get_comments(), include="regular") if issue.pull_request: pr = issue.as_pull_request() commits = list(pr.get_commits()) @@ -87,6 +117,19 @@ def get_last_event_for_label( return last_event +def get_last_reminder_date(issue: Issue) -> Optional[datetime]: + """Get date of last reminder message was sent""" + last_date: Optional[datetime] = None + comments = filter_comments(issue.get_comments(), include="reminder") + comment_dates = [comment.created_at for comment in comments] + for item_date in comment_dates: + if not last_date: + last_date = item_date + elif item_date > last_date: + last_date = item_date + return last_date + + def close_issue( *, issue: Issue, keyword_meta: KeywordMeta, keyword: str, label_strs: Set[str] ) -> None: @@ -106,6 +149,7 @@ def process_issue(*, issue: Issue, settings: Settings) -> None: events = list(issue.get_events()) labeled_events = get_labeled_events(events) last_date = get_last_interaction_date(issue) + last_reminder_date = get_last_reminder_date(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 @@ -116,6 +160,19 @@ def process_issue(*, issue: Issue, settings: Settings) -> None: keyword_event = get_last_event_for_label( labeled_events=labeled_events, label=keyword ) + # Check if we need to send a reminder + need_send_reminder = False + if keyword_meta.reminder and keyword_event: + scheduled_close_date = keyword_event.created_at + keyword_meta.delay + remind_time = ( # Time point after which we should send reminder + scheduled_close_date - keyword_meta.reminder.before + ) + need_send_reminder = ( + (now > remind_time) # It's time to send reminder + and ( # .. and it hasn't been sent yet + not last_reminder_date or (last_reminder_date < remind_time) + ) + ) if last_date and keyword_event and last_date > keyword_event.created_at: logging.info( f"Not closing as the last comment was written after adding the " @@ -124,6 +181,11 @@ def process_issue(*, issue: Issue, settings: Settings) -> None: if keyword_meta.remove_label_on_comment: logging.info(f'Removing label: "{keyword}"') issue.remove_from_labels(keyword) + elif need_send_reminder and keyword_meta.reminder: + message = keyword_meta.reminder.message + logging.info(f"Sending reminder: #{issue.number} with message: {message}") + issue.create_comment(f"{REMINDER_MARKER}\n{message}") + break elif closable_delay: close_issue( issue=issue, diff --git a/schema.json b/schema.json index ba584e2..70fdab5 100644 --- a/schema.json +++ b/schema.json @@ -33,6 +33,23 @@ "title": "Remove Label On Close", "default": false, "type": "boolean" + }, + "reminder": { + "title": "Reminder", + "type": "object", + "properties": { + "before": { + "title": "Before", + "default": 86400.0, + "type": "number", + "format": "time-delta" + }, + "message": { + "title": "Message", + "default": "This will be closed automatically soon if there's no further activity.", + "type": "string" + } + } } } }