* Introduce a script which can be used to help triage brave browser repos. Two current modes: - team report (--team <comma separated list of GitHub IDs>: list count issues and pull requests assigned - stale issue automation (--stale): ID and close issues based on configurable criteria. Lots of configuration parameters available, including a --dry-run which is read only. Requires a GitHub API token to be created. Change made to script/lib/github.py allows us to inspect response headers for the purpose of paging results.
435 lines
16 KiB
Python
Executable File
435 lines
16 KiB
Python
Executable File
#!/usr/bin/env python
|
|
# Copyright (c) 2026 The Brave Authors. All rights reserved.
|
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
# You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
# example usages:
|
|
# GITHUB_TOKEN=abc python script/triage.py --team bsclifton,person2,person3
|
|
# GITHUB_TOKEN=abc python script/triage.py --stale --leave-comment --close-issue
|
|
# --ignore-comments --ignore-priority
|
|
|
|
from datetime import datetime, timezone
|
|
import calendar
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
from lib.helpers import BRAVE_REPO, BRAVE_CORE_REPO
|
|
from lib.github import (GitHub, parse_user_logins)
|
|
|
|
|
|
class TriageConfig():
|
|
is_verbose = False
|
|
is_dryrun = False
|
|
github_token = None
|
|
# team args
|
|
parsed_team = []
|
|
# stale args
|
|
leave_comment = False
|
|
close_issue = False
|
|
ignore_comments = False
|
|
ignore_priority = False
|
|
limit = -1
|
|
stale_months = 36
|
|
ghost_only = False
|
|
issues_expected = 30
|
|
max_comments = 9
|
|
max_reactions = 4
|
|
|
|
def initialize(self, args):
|
|
try:
|
|
self.is_verbose = args.verbose
|
|
self.is_dryrun = args.dry_run
|
|
self.github_token = os.environ.get('GITHUB_TOKEN')
|
|
|
|
# team report
|
|
self.parsed_team = parse_user_logins(self.github_token,
|
|
args.team,
|
|
verbose=self.is_verbose)
|
|
# stale script
|
|
self.leave_comment = args.leave_comment
|
|
self.close_issue = args.close_issue
|
|
self.ignore_comments = args.ignore_comments
|
|
self.ignore_priority = args.ignore_priority
|
|
if args.limit:
|
|
self.limit = int(args.limit)
|
|
if args.months:
|
|
self.stale_months = int(args.months)
|
|
self.ghost_only = args.ghost_only
|
|
if args.issues_expected:
|
|
self.issues_expected = int(args.issues_expected)
|
|
if args.max_comments:
|
|
self.max_comments = int(args.max_comments)
|
|
if args.max_reactions:
|
|
self.max_reactions = int(args.max_reactions)
|
|
|
|
if self.is_verbose:
|
|
display = vars(self).copy()
|
|
if display.get('github_token'):
|
|
display['github_token'] = '***'
|
|
print('[INFO] config: ' + str(display))
|
|
return 0
|
|
except Exception as e:
|
|
print(
|
|
'[ERROR] error returned from GitHub API while initializing ' +
|
|
'config: ' + str(e))
|
|
return 1
|
|
|
|
|
|
config = TriageConfig()
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(
|
|
description='automation for triaging `brave-browser` issues')
|
|
|
|
# team report
|
|
parser.add_argument(
|
|
'--team',
|
|
help='run the "team" script. Comma separated list of GitHub logins',
|
|
default=None)
|
|
|
|
# stale script
|
|
parser.add_argument('--stale',
|
|
help='run the "stale" script',
|
|
action='store_true',
|
|
default=None)
|
|
parser.add_argument('--leave-comment',
|
|
help='leave a comment when running "stale" script',
|
|
action='store_true',
|
|
default=False)
|
|
parser.add_argument('--close-issue',
|
|
help='close issue when running "stale" script',
|
|
action='store_true',
|
|
default=False)
|
|
parser.add_argument('--ignore-comments',
|
|
help='consider issues which have more than 0 comments',
|
|
action='store_true',
|
|
default=False)
|
|
parser.add_argument(
|
|
'--ignore-priority',
|
|
help='consider issues which already have priority label',
|
|
action='store_true',
|
|
default=False)
|
|
parser.add_argument(
|
|
'--limit',
|
|
help='maximum number of issues to modify (if applicable)',
|
|
default=None)
|
|
parser.add_argument(
|
|
'--months',
|
|
help=
|
|
'number of months without activity before an issue is considered stale '
|
|
+ '(default: 36)',
|
|
default=None)
|
|
parser.add_argument(
|
|
'--ghost-only',
|
|
help=
|
|
'only consider issues created by deleted accounts when running "stale" '
|
|
+ 'script',
|
|
action='store_true',
|
|
default=False)
|
|
parser.add_argument(
|
|
'--issues-expected',
|
|
help='minimum number of stale issues to collect before processing ' +
|
|
'(default: 30)',
|
|
default=None)
|
|
parser.add_argument(
|
|
'--max-comments',
|
|
help='maximum number of comments an issue can have to be considered ' +
|
|
'stale (default: 9)',
|
|
default=None)
|
|
parser.add_argument(
|
|
'--max-reactions',
|
|
help='maximum number of reactions an issue can have to be considered '
|
|
+ 'stale (default: 4)',
|
|
default=None)
|
|
|
|
# general
|
|
parser.add_argument('-v',
|
|
'--verbose',
|
|
action='store_true',
|
|
help='prints the output of the GitHub API calls')
|
|
parser.add_argument(
|
|
'-n',
|
|
'--dry-run',
|
|
action='store_true',
|
|
help=
|
|
'don\'t perform any changes; just show which actions would be taken')
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
def get_issues_for_team_member(login):
|
|
try:
|
|
# most efficient API for searching for count
|
|
get_data = {
|
|
'q': 'repo:' + BRAVE_REPO + ' is:issue is:open assignee:' + login
|
|
}
|
|
response = GitHub(
|
|
config.github_token).search().issues().get(params=get_data)
|
|
total_count = int(response['total_count'])
|
|
except Exception:
|
|
# this can happen if account is set as private
|
|
# if person has > 100, we'd need to page to find count
|
|
get_data = {'assignee': login, 'state': 'open', 'per_page': 100}
|
|
repo = GitHub(config.github_token).repos(BRAVE_REPO)
|
|
response = repo.issues().get(params=get_data)
|
|
total_count = len(response)
|
|
# TODO: put count of P1 and P2 issues!
|
|
print(
|
|
'- ' + str(total_count) +
|
|
' issues assigned. https://github.com/brave/brave-browser/issues?q=is%3Aissue%20state%3Aopen%20assignee%3A'
|
|
+ login)
|
|
|
|
|
|
def get_prs_for_team_member(login):
|
|
try:
|
|
# Most efficient API for searching for count
|
|
get_data = {
|
|
'q': 'repo:' + BRAVE_CORE_REPO + ' is:pr is:open assignee:' + login
|
|
}
|
|
response = GitHub(
|
|
config.github_token).search().issues().get(params=get_data)
|
|
total_count = int(response['total_count'])
|
|
except Exception:
|
|
# This condition can be hit if the GitHub account is set as private.
|
|
# NOTE: If person has > 100 open issues, we'd need to implement paging.
|
|
get_data = {'assignee': login, 'state': 'open', 'per_page': 100}
|
|
repo = GitHub(config.github_token).repos(BRAVE_CORE_REPO)
|
|
response = repo.issues().get(params=get_data)
|
|
total_count = len(response)
|
|
print('- ' + str(total_count) +
|
|
' open PRs. https://github.com/brave/brave-core/pulls/assigned/' +
|
|
login)
|
|
|
|
|
|
def get_stale_issues(page=1):
|
|
get_data = {
|
|
'state': 'open',
|
|
'sort': 'updated',
|
|
'direction': 'asc',
|
|
'per_page': 100,
|
|
'page': page
|
|
}
|
|
repo = GitHub(config.github_token).repos(BRAVE_REPO)
|
|
|
|
get_headers = dict()
|
|
response = repo.issues().get(params=get_data, headers=get_headers)
|
|
|
|
link_header = get_headers['ResponseHeaders']['Link']
|
|
has_pages_remaining = link_header and link_header.find('rel="next"') != -1
|
|
|
|
stale_issues = []
|
|
now = datetime.now(timezone.utc)
|
|
cutoff_month = now.month - (config.stale_months % 12)
|
|
cutoff_year = now.year - (config.stale_months // 12)
|
|
if cutoff_month <= 0:
|
|
cutoff_month += 12
|
|
cutoff_year -= 1
|
|
cutoff_day = min(now.day,
|
|
calendar.monthrange(cutoff_year, cutoff_month)[1])
|
|
cutoff_date = now.replace(year=cutoff_year,
|
|
month=cutoff_month,
|
|
day=cutoff_day)
|
|
|
|
for issue in response:
|
|
# Search only issues opened by ghost (deleted account).
|
|
if config.ghost_only:
|
|
if issue['user'] is not None and issue['user']['login'] != 'ghost':
|
|
print('[INFO] Issue ' + str(issue['number']) +
|
|
' has a valid author (not ghost account); skipping.' +
|
|
issue['html_url'])
|
|
continue
|
|
# By default, skip any issues with comments.
|
|
# Override by providing --ignore-comments
|
|
if not config.ignore_comments and issue['comments'] > 0:
|
|
print('[INFO] Issue ' + str(issue['number']) + ' has comments (' +
|
|
str(issue['comments']) + '); skipping.' + issue['html_url'])
|
|
continue
|
|
# By default, skip any issues with priority set.
|
|
# Override by providing --ignore-priority
|
|
if not config.ignore_priority:
|
|
can_add_issue = True
|
|
priority_found = ''
|
|
for label in issue['labels']:
|
|
if label['name'].startswith('priority'):
|
|
can_add_issue = False
|
|
priority_found = label['name']
|
|
break
|
|
if not can_add_issue:
|
|
print('[INFO] Issue ' + str(issue['number']) +
|
|
' has a priority label on it (' + priority_found +
|
|
'); skipping. ' + issue['html_url'])
|
|
continue
|
|
# safeguard: Don't triage issues with too many comments.
|
|
if issue['comments'] > config.max_comments:
|
|
print('[INFO] Skipping issue ' + str(issue['number']) +
|
|
' as it has more than ' + str(config.max_comments) +
|
|
' comments. ' + issue['html_url'])
|
|
continue
|
|
# safeguard: Don't triage issues with too many reactions.
|
|
if issue['reactions']['total_count'] > config.max_reactions:
|
|
print('[INFO] Skipping issue ' + str(issue['number']) +
|
|
' as it has more than ' + str(config.max_reactions) +
|
|
' reactions. ' + issue['html_url'])
|
|
continue
|
|
# Label safeguards:
|
|
skip_issue = False
|
|
for label in issue['labels']:
|
|
# Don't close P1/P2 issues
|
|
if label['name'] in ['priority/P1', 'priority/P2']:
|
|
print('[INFO] Skipping issue as it\'s flagged as P1 or P2. ' +
|
|
issue['html_url'])
|
|
skip_issue = True
|
|
break
|
|
# don't close code health issues
|
|
if label['name'] in ['dev-concern', 'ci-concern']:
|
|
skip_issue = True
|
|
print('[INFO] Skipping issue as it\'s a code health issue. ' +
|
|
issue['html_url'])
|
|
break
|
|
if skip_issue:
|
|
continue
|
|
|
|
issue_updated_at = datetime.fromisoformat(issue['updated_at'].replace(
|
|
"Z", "+00:00"))
|
|
if issue_updated_at < cutoff_date:
|
|
stale_issues.append(issue['number'])
|
|
if config.is_verbose:
|
|
print('[INFO] ' + issue['title'] + ' (' + str(issue['comments']) +
|
|
' comments) ' + issue['html_url'])
|
|
|
|
return stale_issues, has_pages_remaining
|
|
|
|
|
|
def process_stale_issue(issue_number):
|
|
repo = GitHub(config.github_token).repos(BRAVE_REPO)
|
|
response = repo.issues(issue_number).get()
|
|
|
|
print('\nProcessing ' + str(response['html_url']))
|
|
patch_data = {'labels': []}
|
|
|
|
# Add the stale label, remove specific labels if closing
|
|
labels_to_remove = {
|
|
'priority/P3', 'priority/P4', 'priority/P5', 'help wanted',
|
|
'good first issue'
|
|
} if config.close_issue else set()
|
|
for label in response['labels']:
|
|
if label['name'] not in labels_to_remove:
|
|
patch_data['labels'].append(label['name'])
|
|
patch_data['labels'].append('closed/stale')
|
|
|
|
# (optional) Close the issue
|
|
if config.close_issue:
|
|
patch_data['state'] = 'closed'
|
|
|
|
# (optional) Add a comment
|
|
post_data = {}
|
|
if config.leave_comment:
|
|
post_data['body'] = 'This issue hasn\'t had an update in a while - '
|
|
post_data['body'] += 'it\'s met the criteria for being stale. '
|
|
post_data['body'] += 'The issue is going to be closed for now. If '
|
|
post_data['body'] += 'this is still a problem, please leave a comment'
|
|
if config.close_issue:
|
|
post_data['body'] += ' or re-open the issue'
|
|
post_data['body'] += '. Thanks!'
|
|
|
|
if config.is_dryrun:
|
|
print('- Would leave comment')
|
|
if config.is_verbose:
|
|
print('[INFO] would call `repo.issues(' + str(issue_number) +
|
|
').comments.post(' + str(post_data) + ')`')
|
|
else:
|
|
print('- Leaving comment')
|
|
if config.is_verbose:
|
|
print('[INFO] calling `repo.issues(' + str(issue_number) +
|
|
').comments.post(' + str(post_data) + ')`')
|
|
response = repo.issues(issue_number).comments.post(data=post_data)
|
|
|
|
if config.is_dryrun:
|
|
print('- Would add stale label')
|
|
if config.close_issue:
|
|
removed = labels_to_remove & {
|
|
label['name']
|
|
for label in response['labels']
|
|
}
|
|
if removed:
|
|
print('- Would remove labels: ' + ', '.join(sorted(removed)))
|
|
print('- Would close issue')
|
|
if config.is_verbose:
|
|
print('[INFO] would call `repo.issues(' + str(issue_number) +
|
|
').patch(' + str(patch_data) + ')`')
|
|
return
|
|
|
|
# Update the issue
|
|
try:
|
|
if config.is_verbose:
|
|
print('[INFO] calling `repo.issues(' + str(issue_number) +
|
|
').patch(' + str(patch_data) + ')`')
|
|
response = repo.issues(issue_number).patch(data=patch_data)
|
|
print('- Added stale label')
|
|
if config.close_issue:
|
|
print('- Closed issue')
|
|
except Exception as e:
|
|
print('[ERROR] ' + str(e))
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
if args.verbose:
|
|
print('[INFO] args: ' + str(args))
|
|
|
|
global config
|
|
result = config.initialize(args)
|
|
if result != 0:
|
|
return result
|
|
|
|
if len(config.parsed_team) > 0:
|
|
print('Team report')
|
|
print('-----------')
|
|
for login in config.parsed_team:
|
|
print('\n' + login + ':')
|
|
get_issues_for_team_member(login)
|
|
get_prs_for_team_member(login)
|
|
return 0
|
|
|
|
if args.stale:
|
|
print('Stale issue cleanup')
|
|
print('-------------------')
|
|
|
|
# Paging implemented to ensure we get a minimum number of issues
|
|
# AFTER filtering is applied.
|
|
issues_found = 0
|
|
page = 1
|
|
issues, has_pages_remaining = get_stale_issues()
|
|
issues_found += len(issues)
|
|
print('(page 1) Got ' + str(len(issues)) + ' issues (' +
|
|
str(issues_found) + ' total).\n')
|
|
|
|
# Code will continue making requests until it finds the minimum number
|
|
# of issues or there are no more result pages.
|
|
while issues_found < config.issues_expected and has_pages_remaining:
|
|
page = page + 1
|
|
more_issues, has_pages_remaining = get_stale_issues(page)
|
|
print('(page ' + str(page) + ') Got ' + str(len(more_issues)) +
|
|
' more issues (' + str(issues_found) + ' total).\n')
|
|
issues_found += len(more_issues)
|
|
issues += more_issues
|
|
|
|
print('\nFound ' + str(len(issues)) + ' actionable issues.')
|
|
issue_count = 0
|
|
for issue_number in issues:
|
|
process_stale_issue(issue_number)
|
|
issue_count += 1
|
|
if config.limit > 0 and issue_count >= config.limit:
|
|
print('\nLimit of ' + str(config.limit) + ' hit. Exiting')
|
|
return 0
|
|
return 0
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|