# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Unsubscribe from closed, inactive GitHub notification threads.
Processes notification threads in two phases:
1. **REST notification threads** β Fetches all Issue/PullRequest notification
threads via `/notifications`, inspects each for closed + stale status,
and unsubscribes via `DELETE` + `PATCH`.
2. **GraphQL threadless subscriptions** β Searches for closed issues/PRs the
user is involved in but that lack notification threads, and unsubscribes
via the `updateSubscription` mutation.
Requires the `gh` CLI to be installed and authenticated with a token that
has the `notifications` scope (classic PAT) or equivalent fine-grained
permissions.
"""
from __future__ import annotations
import calendar
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from functools import partial
import arrow
from ..dep_report import parse_iso_datetime
from ..tabular import render_markdown_table
from .actions import ReportAction
from .gh import gh_api_json, iter_graphql_nodes, run_gh_command
from .pr_body import render_template
from .token import validate_classic_pat_scope
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
from typing import Any
GRAPHQL_PAGE_SIZE = 25
"""Per-page count for GraphQL search results."""
NOTIFICATION_PAGE_SIZE = 50
"""Per-page count for REST `/notifications` results."""
NOTIFICATION_SUBJECT_TYPES = frozenset({"Issue", "PullRequest"})
"""Notification subject types to process."""
THREADLESS_SEARCH_QUERY = """
query($searchQuery: String!, $cursor: String, $pageSize: Int!) {
search(query: $searchQuery, type: ISSUE, first: $pageSize, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
... on Issue {
id
number
repository { nameWithOwner }
title
updatedAt
url
viewerSubscription
}
... on PullRequest {
id
number
repository { nameWithOwner }
title
updatedAt
url
viewerSubscription
}
}
}
}
"""
UNSUBSCRIBE_MUTATION = """
mutation($id: ID!) {
updateSubscription(input: {subscribableId: $id, state: UNSUBSCRIBED}) {
subscribable { id }
}
}
"""
[docs]
@dataclass(frozen=True)
class DetailRow:
"""Per-item detail for the markdown report table."""
action: ReportAction
html_url: str
number: int | None
repo: str
title: str
updated_at: datetime | None
[docs]
@dataclass
class Phase1Result:
"""Accumulated counts and details from REST notification phase."""
batch_size: int = 0
cutoff: datetime | None = None
newest_updated: datetime | None = None
oldest_updated: datetime | None = None
rows: list[DetailRow] = field(default_factory=list)
threads_failed: int = 0
threads_inspected: int = 0
threads_skipped_open: int = 0
threads_skipped_recent: int = 0
threads_skipped_unknown: int = 0
threads_total: int = 0
threads_unsubscribed: int = 0
[docs]
@dataclass
class Phase2Result:
"""Accumulated counts and details from GraphQL threadless phase."""
batch_size: int = 0
cutoff: datetime | None = None
graphql_failed: int = 0
graphql_not_subscribed: int = 0
graphql_skipped_recent: int = 0
graphql_total: int = 0
graphql_unsubscribed: int = 0
rows: list[DetailRow] = field(default_factory=list)
search_query: str = ""
skipped: bool = False
skip_reason: str = ""
[docs]
@dataclass
class UnsubscribeResult:
"""Accumulated results from both unsubscribe phases."""
dry_run: bool = False
months: int = 3
phase1: Phase1Result = field(default_factory=Phase1Result)
phase2: Phase2Result = field(default_factory=Phase2Result)
def _compute_cutoff(months: int) -> datetime:
"""Compute a cutoff datetime by subtracting `months` from now.
Uses `calendar.monthrange` for day-clamping to avoid invalid dates
(e.g., subtracting 1 month from March 31 yields February 28/29).
:param months: Number of months to subtract.
:return: Timezone-aware UTC datetime.
"""
now = datetime.now(timezone.utc)
year = now.year
month = now.month - months
while month < 1:
month += 12
year -= 1
max_day = calendar.monthrange(year, month)[1]
day = min(now.day, max_day)
return now.replace(year=year, month=month, day=day)
def _format_link(row: DetailRow) -> str:
"""Render a markdown link for a detail row.
Produces `` [`repo#number`](url) `` when a number is available,
otherwise just the repo name.
"""
if row.number is not None and row.html_url:
return f"[`{row.repo}#{row.number}`]({row.html_url})"
return row.repo
def _fetch_notification_threads(
batch_size: int,
) -> tuple[int, list[dict[str, Any]]]:
"""Fetch Issue/PullRequest notification threads via REST API.
Returns the total count of matching threads and a batch sorted
oldest-first, truncated to `batch_size`.
:param batch_size: Maximum number of threads to return.
:return: Tuple of `(total_count, truncated_batch)`. Each thread dict
contains `id`, `subject_url`, `subject_type`, `repo`,
`title`.
"""
# The --jq filter selects Issue/PullRequest types and extracts fields.
jq_filter = (
".[] | select(.subject.type == "
+ " or .subject.type == ".join(
f'"{t}"' for t in sorted(NOTIFICATION_SUBJECT_TYPES)
)
+ ")"
" | {id, repo: .repository.full_name,"
" subject_type: .subject.type, subject_url: .subject.url,"
" title: .subject.title}"
)
try:
output = run_gh_command([
"api",
"--method",
"GET",
"/notifications",
"--paginate",
"--jq",
jq_filter,
"-f",
"all=true",
"-f",
f"per_page={NOTIFICATION_PAGE_SIZE}",
])
except RuntimeError as exc:
logging.warning("Failed to fetch notification threads: %s", exc)
return 0, []
threads = []
for line in output.strip().splitlines():
line = line.strip()
if not line:
continue
try:
threads.append(json.loads(line))
except json.JSONDecodeError:
logging.warning(f"Skipping malformed notification line: {line!r}")
total = len(threads)
# The REST notifications list is documented to sort by most recently
# updated first, so a reverse yields oldest-first processing. Combined
# with the batch-size truncation this is best-effort: the render_report
# backlog warning covers the case where the oldest threads stay out of
# reach because the API sorts by notification update, not issue activity.
threads.reverse()
return total, threads[:batch_size]
def _get_thread_details(subject_url: str) -> dict[str, Any] | None:
"""Fetch details for a notification thread's subject.
:param subject_url: The API URL of the thread's subject (issue or PR).
:return: Dict with `state`, `updated_at`, `html_url`, `number`,
or `None` if the subject is inaccessible.
"""
details = gh_api_json([
"api",
subject_url,
"--jq",
"{state, updated_at, html_url, number}",
])
if details is None:
logging.debug(f"Subject inaccessible or malformed: {subject_url}")
return details
def _unsubscribe_rest_thread(thread_id: str) -> bool:
"""Unsubscribe from a notification thread and mark it read.
Performs two API calls:
1. ``DELETE /notifications/threads/{id}/subscription``
2. ``PATCH /notifications/threads/{id}``
:param thread_id: The notification thread ID.
:return: `True` if both calls succeeded, `False` otherwise.
"""
try:
run_gh_command([
"api",
"--method",
"DELETE",
f"/notifications/threads/{thread_id}/subscription",
])
except RuntimeError:
logging.warning(f"Failed to delete subscription for thread {thread_id}.")
return False
try:
run_gh_command([
"api",
"--method",
"PATCH",
f"/notifications/threads/{thread_id}",
])
except RuntimeError:
logging.warning(f"Failed to mark thread {thread_id} as read.")
return False
return True
def _validate_notifications_token() -> None:
"""Validate that the current token can access the notifications API.
Delegates to {func}`validate_classic_pat_scope` for generic checks,
then warns if the token has more scopes than needed.
:raises RuntimeError: If validation fails.
"""
scope_list = validate_classic_pat_scope("notifications")
# Notifications-specific: warn about extra scopes.
if scope_list != ["notifications"]:
scopes_header = ", ".join(scope_list)
logging.warning(
"GH_TOKEN has more scopes than needed: '%s'."
" Only 'notifications' is required.",
scopes_header,
)
def _get_authenticated_username() -> str:
"""Get the login of the authenticated GitHub user.
:return: The username string.
:raises RuntimeError: If the API call fails.
"""
return run_gh_command(["api", "/user", "--jq", ".login"]).strip()
def _iter_closed_items(
search_query: str,
batch_size: int,
) -> Iterator[dict[str, Any]]:
"""Iterate over closed issues/PRs matching a GraphQL search query.
Uses cursor-based GraphQL pagination. Yields all items regardless
of `viewerSubscription`; callers filter as needed.
:param search_query: The GitHub search query string.
:param batch_size: Maximum total items to yield.
:yields: Dicts with `id`, `number`, `title`, `repository`,
`updatedAt`, `url`, `viewerSubscription`.
"""
return iter_graphql_nodes(
THREADLESS_SEARCH_QUERY,
("search",),
{"searchQuery": search_query},
page_size_var="pageSize",
page_size=GRAPHQL_PAGE_SIZE,
max_nodes=batch_size,
)
def _graphql_unsubscribe(node_id: str) -> bool:
"""Unsubscribe from an issue or PR via GraphQL mutation.
:param node_id: The global node ID of the subscribable.
:return: `True` if the mutation succeeded, `False` otherwise.
"""
try:
run_gh_command([
"api",
"graphql",
"-f",
f"query={UNSUBSCRIBE_MUTATION}",
"-f",
f"id={node_id}",
])
except RuntimeError:
logging.warning(f"GraphQL unsubscribe failed for node {node_id}.")
return False
return True
def _render_detail_table(rows: list[DetailRow]) -> str:
"""Render a details table with header and data rows.
:param rows: Detail rows to render.
:return: Details heading and table, or empty string if no rows.
"""
if not rows:
return ""
table = render_markdown_table(
(
"\U0001f4ac Title",
"\U0001f517 Link",
"\U0001f550 Last activity",
"\u26a1 Action",
),
(
(
row.title,
_format_link(row),
arrow.get(row.updated_at).humanize() if row.updated_at else "-",
row.action.value,
)
for row in rows
),
)
return f"### \U0001f4dd Details\n\n{table}"
[docs]
def render_report(result: UnsubscribeResult) -> str:
"""Render a markdown report from unsubscribe results.
Pure function that produces the same markdown structure as the
downstream `unsubscribe.yaml` workflow's `$GITHUB_STEP_SUMMARY`.
:param result: Structured results from both phases.
:return: Markdown report string.
"""
mode = "dry-run" if result.dry_run else "live"
p1 = result.phase1
p2 = result.phase2
# Phase 1 summary line.
cutoff_str = p1.cutoff.isoformat() if p1.cutoff else "-"
if result.dry_run:
summary_line = (
f"\U0001f50d **Candidates found:** {len(p1.rows)}"
f" \u2014 cutoff: `{cutoff_str}`"
f" (inactive for more than {result.months} months, dry-run)"
)
else:
summary_line = (
f"\U0001f515 **Unsubscribed:** {p1.threads_unsubscribed}"
f" | \u26a0\ufe0f **Failed:** {p1.threads_failed}"
f" \u2014 cutoff: `{cutoff_str}`"
f" (inactive for more than {result.months} months)"
)
# Phase 1 batch details rows.
remaining = p1.threads_total - p1.threads_inspected
oldest_str = p1.oldest_updated.isoformat() if p1.oldest_updated else "-"
newest_str = p1.newest_updated.isoformat() if p1.newest_updated else "-"
batch_details_rows = "\n".join([
f"| \U0001f514 Total notifications | {p1.threads_total} |",
f"| \U0001f4e6 Batch size | {p1.batch_size} |",
f"| \U0001f50e Inspected | {p1.threads_inspected} |",
f"| \U0001f4cb Remaining backlog | {remaining} |",
f"| \u23ea Oldest activity | {oldest_str} |",
f"| \u23e9 Newest activity | {newest_str} |",
f"| \u2702\ufe0f Cutoff | {cutoff_str} |",
])
# Phase 1 state breakdown rows.
stale_count = p1.threads_unsubscribed + p1.threads_failed
state_breakdown_rows = "\n".join([
f"| \U0001f7e2 Open | {p1.threads_skipped_open} |",
(f"| \U0001f7e1 Closed (active since cutoff) | {p1.threads_skipped_recent} |"),
f"| \U0001f534 Closed (inactive, eligible) | {stale_count} |",
f"| \u26aa Unknown | {p1.threads_skipped_unknown} |",
])
# Backlog warning (empty string if not applicable).
backlog_warning = ""
if (
p1.oldest_updated is not None
and p1.cutoff is not None
and remaining > 0
and p1.oldest_updated >= p1.cutoff
):
backlog_warning = "\n".join([
"> [!WARNING]",
(
"> Oldest activity seen in this batch:"
f" `{oldest_str}` (cutoff: `{cutoff_str}`)."
),
("> The notification API does not sort by issue activity, so the current"),
(
f"> batch of {p1.threads_inspected} threads did not reach"
" any old-enough candidates."
),
(
f"> Consider increasing `batch-size`"
f" (currently {p1.batch_size})"
" or running manually"
),
"> with a larger batch to clear the backlog faster.",
])
# Phase 1 details section (includes --- separator).
p1_detail_table = _render_detail_table(p1.rows)
details_section = f"---\n\n{p1_detail_table}" if p1_detail_table else ""
# Phase 2 content.
if p2.skipped:
phase2_content = f"> [!WARNING]\n> {p2.skip_reason}"
else:
# Phase 2 summary line.
p2_cutoff_str = p2.cutoff.isoformat() if p2.cutoff else "-"
if result.dry_run:
p2_summary = (
f"\U0001f50d **Candidates found:** {len(p2.rows)}"
f" \u2014 cutoff: `{p2_cutoff_str}`"
f" (inactive for more than {result.months} months, dry-run)"
)
else:
p2_summary = (
f"\U0001f515 **Unsubscribed:** {p2.graphql_unsubscribed}"
f" | \u26a0\ufe0f **Failed:** {p2.graphql_failed}"
f" \u2014 cutoff: `{p2_cutoff_str}`"
f" (inactive for more than {result.months} months)"
)
# Phase 2 search details table.
subscribed_count = p2.graphql_unsubscribed + p2.graphql_failed
p2_table = "### \U0001f4ca Search details\n\n" + render_markdown_table(
("Metric", "Value"),
(
("\U0001f50e Search query", f"`{p2.search_query}`"),
("\U0001f514 Total results", p2.graphql_total),
("\U0001f4e6 Batch size", p2.batch_size),
("\u2705 Still subscribed", subscribed_count),
("\u23ed\ufe0f Not subscribed", p2.graphql_not_subscribed),
("\U0001f7e1 Active since cutoff", p2.graphql_skipped_recent),
),
)
p2_detail_table = _render_detail_table(p2.rows)
p2_parts = [p2_summary, "", p2_table]
if p2_detail_table:
p2_parts.extend(["", p2_detail_table])
phase2_content = "\n".join(p2_parts)
return render_template(
"unsubscribe-phase1",
"unsubscribe-phase2",
mode=mode,
summary_line=summary_line,
batch_details_rows=batch_details_rows,
state_breakdown_rows=state_breakdown_rows,
backlog_warning=backlog_warning,
details_section=details_section,
phase2_content=phase2_content,
)
[docs]
def unsubscribe_threads(
months: int,
batch_size: int,
dry_run: bool,
) -> UnsubscribeResult:
"""Unsubscribe from closed, inactive notification threads.
Runs two phases:
1. **REST notification threads** β Fetches notification threads, inspects
each subject for closed + stale status, and unsubscribes.
2. **GraphQL threadless subscriptions** β Searches for closed issues/PRs
the user is involved in and unsubscribes via mutation.
:param months: Inactivity threshold in months.
:param batch_size: Maximum threads/items to process per phase.
:param dry_run: If `True`, report what would be done without acting.
:return: Structured results from both phases.
"""
result = UnsubscribeResult(dry_run=dry_run, months=months)
cutoff = _compute_cutoff(months)
prefix = "[dry-run] " if dry_run else ""
logging.info(f"Cutoff date: {cutoff.strftime('%Y-%m-%d')} ({months} months ago).")
# Phase 1: REST notification threads.
logging.info("Phase 1: Processing REST notification threads...")
p1 = result.phase1
p1.cutoff = cutoff
p1.batch_size = batch_size
total, threads = _fetch_notification_threads(batch_size)
p1.threads_total = total
for thread in threads:
p1.threads_inspected += 1
thread_id = thread["id"]
subject_url = thread["subject_url"]
thread_repo = thread.get("repo", "")
thread_title = thread.get("title", "")
details = _get_thread_details(subject_url)
if details is None:
p1.threads_skipped_unknown += 1
logging.info(f" Thread {thread_id}: subject inaccessible, skipping.")
continue
state = details.get("state", "unknown")
updated_at = parse_iso_datetime(details.get("updated_at", ""))
# Track oldest/newest across all items with valid timestamps.
if updated_at is not None:
if p1.oldest_updated is None or updated_at < p1.oldest_updated:
p1.oldest_updated = updated_at
if p1.newest_updated is None or updated_at > p1.newest_updated:
p1.newest_updated = updated_at
if state == "unknown" or updated_at is None:
p1.threads_skipped_unknown += 1
logging.info(f" Thread {thread_id}: state={state}, skipping.")
continue
if state != "closed":
p1.threads_skipped_open += 1
logging.info(f" Thread {thread_id}: state={state}, skipping.")
continue
if updated_at >= cutoff:
p1.threads_skipped_recent += 1
logging.info(f" Thread {thread_id}: updated recently, skipping.")
continue
# Closed + stale: candidate for unsubscription. The three outcomes below
# differ only in the action recorded, so the row is built once here.
html_url = details.get("html_url", subject_url)
row = partial(
DetailRow,
html_url=html_url,
number=details.get("number"),
repo=thread_repo,
title=thread_title,
updated_at=updated_at,
)
logging.info(f" {prefix}Unsubscribing from thread {thread_id} ({html_url}).")
if dry_run:
p1.threads_unsubscribed += 1
p1.rows.append(row(action=ReportAction.DRY_RUN))
continue
if _unsubscribe_rest_thread(str(thread_id)):
p1.threads_unsubscribed += 1
p1.rows.append(row(action=ReportAction.UNSUBSCRIBED))
else:
p1.threads_failed += 1
p1.rows.append(row(action=ReportAction.FAILED))
# Phase 2: GraphQL threadless subscriptions.
logging.info("Phase 2: Processing GraphQL threadless subscriptions...")
p2 = result.phase2
p2.cutoff = cutoff
p2.batch_size = batch_size
try:
username = _get_authenticated_username()
except RuntimeError as exc:
logging.warning(
"Failed to get authenticated username. Skipping Phase 2: %s", exc
)
p2.skipped = True
p2.skip_reason = "Failed to get authenticated username. Skipping Phase 2."
return result
cutoff_date = cutoff.strftime("%Y-%m-%d")
p2.search_query = f"involves:{username} is:closed updated:<{cutoff_date}"
try:
for item in _iter_closed_items(p2.search_query, batch_size):
p2.graphql_total += 1
node_id = item["id"]
repo = item.get("repository", {}).get("nameWithOwner", "unknown")
number = item.get("number")
# Filter: only act on items the user is subscribed to.
if item.get("viewerSubscription") != "SUBSCRIBED":
p2.graphql_not_subscribed += 1
continue
# Parse updatedAt and url from GraphQL result.
gql_updated_at = parse_iso_datetime(item.get("updatedAt", ""))
# Re-validate staleness client-side, mirroring phase 1: the
# search query's `updated:<` filter is day-granular and served
# by GitHub's search index, which can lag. Trusting it alone
# could unsubscribe an item phase 1's stricter check would
# keep. An unparsable timestamp is not a green light either.
if gql_updated_at is None or gql_updated_at >= cutoff:
p2.graphql_skipped_recent += 1
continue
row = partial(
DetailRow,
html_url=item.get("url", ""),
number=number,
repo=repo,
title=item.get("title", ""),
updated_at=gql_updated_at,
)
logging.info(f" {prefix}Unsubscribing from {repo}#{number} (GraphQL).")
if dry_run:
p2.graphql_unsubscribed += 1
p2.rows.append(row(action=ReportAction.DRY_RUN))
continue
if _graphql_unsubscribe(node_id):
p2.graphql_unsubscribed += 1
p2.rows.append(row(action=ReportAction.UNSUBSCRIBED))
else:
p2.graphql_failed += 1
p2.rows.append(row(action=ReportAction.FAILED))
except RuntimeError as exc:
logging.warning(
"GraphQL search failed. Phase 2 may be incomplete. "
"Fine-grained PATs may not support GraphQL search: %s",
exc,
)
p2.skipped = True
p2.skip_reason = (
"GraphQL search failed. Fine-grained PATs may not support GraphQL search."
)
return result