Skip to content
52 changes: 48 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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."
}
}
}
```
Expand Down Expand Up @@ -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."
}
}
}
```
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 63 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<!-- reminder -->"


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)
Expand All @@ -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):
Expand All @@ -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())
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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 "
Expand All @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
}
Expand Down