Merge branch 'stable' into devel
[tails.git] / bin / ux-debt-changes
blob41e897144ce9fd846cd3c40c81ac4bdc61c95f8a
1 #! /usr/bin/python3
3 # Documentation: https://tails.net/contribute/working_together/GitLab/#api
5 import functools
6 import sys
7 import logging
8 import os
9 import datetime
11 try:
12     import dateutil.parser
13 except ImportError:
14     sys.exit("You need to install python3-dateutil to use this program.")
16 try:
17     import requests
18 except ImportError:
19     sys.exit("You need to install python3-requests to use this program.")
21 try:
22     from cachecontrol import CacheControlAdapter  # type: ignore
23     from cachecontrol.heuristics import OneDayCache  # type: ignore
24 except ImportError:
25     sys.exit("You need to install python3-cachecontrol to use this program.")
27 try:
28     import gitlab  # type: ignore
29 except ImportError:
30     sys.exit("You need to install python3-gitlab to use this program.")
32 from pathlib import Path
34 PYTHON_GITLAB_CONFIG_FILE = os.getenv('PYTHON_GITLAB_CONFIG_FILE',
35                                       default=Path.home() /
36                                       '.python-gitlab.cfg')
38 PYTHON_GITLAB_NAME = os.getenv('GITLAB_NAME', default='Tails')
40 GROUP_NAME = 'tails'
41 PROJECT_NAME = GROUP_NAME + '/' + 'tails'
43 LABEL = 'UX:debt'
45 ALL_REPORTS = ['added', 'removed', 'solved', 'rejected']
47 LOG_FORMAT = "%(asctime)-15s %(levelname)s %(message)s"
48 log = logging.getLogger()
51 class GitLabWrapper(gitlab.Gitlab):
52     @classmethod
53     def from_config(cls, gitlab_name, config_files):
54         # adapter = CacheControlAdapter(heuristic=ExpiresAfter(days=1))
55         adapter = CacheControlAdapter(heuristic=OneDayCache())
56         session = requests.Session()
57         session.mount('https://', adapter)
59         config = gitlab.config.GitlabConfigParser(gitlab_id=gitlab_name,
60                                                   config_files=config_files)
62         return cls(config.url,
63                    private_token=config.private_token,
64                    oauth_token=config.oauth_token,
65                    job_token=config.job_token,
66                    ssl_verify=config.ssl_verify,
67                    timeout=config.timeout,
68                    http_username=config.http_username,
69                    http_password=config.http_password,
70                    api_version=config.api_version,
71                    per_page=config.per_page,
72                    pagination=config.pagination,
73                    order_by=config.order_by,
74                    session=session)
76     @functools.lru_cache(maxsize=None)
77     def project(self, project_id):
78         return self.projects.get(project_id)
80     @functools.lru_cache(maxsize=None)
81     def project_from_name(self, project_name):
82         project = [
83             p for p in self.projects.list(all=True)
84             # Disambiguate between projects whose names share a common prefix
85             if p.path_with_namespace == project_name
86         ][0]
87         assert isinstance(project, gitlab.v4.objects.Project)
88         return project
91 class UxDebtChangesGenerator(object):
92     def __init__(self, gl, group, project_name: str, after: datetime.datetime):
93         self.gl = gl
94         self.group = group
95         self.project = self.gl.project_from_name(project_name)
96         self.after = datetime.datetime(after.year,
97                                        after.month,
98                                        after.day,
99                                        tzinfo=datetime.timezone.utc)
101     def closed_issues(self, reason: str) -> list:
102         closed_issues = []
103         closed_issues_events = self.project.events.list(as_list=False,
104                                                         target_type='issue',
105                                                         action='closed',
106                                                         after=self.after)
108         gl_closed_issues_with_duplicates = [
109             event.target_iid for event in closed_issues_events
110         ]
111         gl_closed_issues = []
112         for issue in gl_closed_issues_with_duplicates:
113             if issue not in gl_closed_issues:
114                 gl_closed_issues.append(issue)
116         for issue in gl_closed_issues:
117             issue = self.project.issues.get(issue)
118             # Ignore issues that have been reopened since
119             if issue.state != 'closed':
120                 continue
121             if LABEL not in issue.labels:
122                 continue
123             if reason == 'resolved':
124                 if 'Rejected' in issue.labels:
125                     continue
126             elif reason == 'rejected':
127                 if 'Rejected' not in issue.labels:
128                     continue
129             else:
130                 raise NotImplementedError("Unsupported reason %s" % reason)
131             closed_issues.append({
132                 "title": issue.title,
133                 "web_url": issue.web_url,
134             })
136         return closed_issues
138     def label_added(self):
139         issues = []
140         for issue in self.project.issues.list(state='opened', labels=[LABEL]):
141             if LABEL not in issue.labels:
142                 continue
143             events = issue.resourcelabelevents.list()
144             for event in events:
145                 if event.action != 'add' or event.label['name'] != 'UX:debt' or event.label != None:
146                     continue
147                 event_created_at = dateutil.parser.isoparse(event.created_at)
148                 if event_created_at < self.after:
149                     continue
150                 issues.append({
151                     "title": issue.title,
152                     "web_url": issue.web_url,
153                 })
154         return issues
156     def label_removed(self):
157         issues = []
158         for issue in self.project.issues.list(state='opened'):
159             if LABEL in issue.labels:
160                 continue
161             events = issue.resourcelabelevents.list()
162             for event in events:
163                 if event.action != 'remove' or event.label['name'] != 'UX:debt':
164                     continue
165                 event_created_at = dateutil.parser.isoparse(event.created_at)
166                 if event_created_at < self.after:
167                     continue
168                 issues.append({
169                     "title": issue.title,
170                     "web_url": issue.web_url,
171                 })
172         return issues
175 if __name__ == '__main__':
176     import argparse
177     parser = argparse.ArgumentParser()
178     parser.add_argument(
179         '--since',
180         type=datetime.date.fromisoformat,
181         required=True,
182         help="Consider changes after this date, in the format YYYY-MM-DD")
183     parser.add_argument(
184         "--report",
185         dest='reports',
186         action='append',
187         help="Only run the specified report (among %s)\n" % ALL_REPORTS +
188         "Can be specified multiple times to run several reports.")
189     parser.add_argument("--debug", action="store_true", help="debug output")
190     args = parser.parse_args()
192     if args.debug:
193         logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
194     else:
195         logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
197     gl = GitLabWrapper.from_config(PYTHON_GITLAB_NAME,
198                                    config_files=[PYTHON_GITLAB_CONFIG_FILE])
199     gl.auth()
201     group = gl.groups.list(search=GROUP_NAME)[0]
202     assert isinstance(group, gitlab.v4.objects.Group)
204     reports = args.reports or ALL_REPORTS
205     log.debug("Preparing these reports: %s", reports)
207     changes_generator = UxDebtChangesGenerator(gl, group, PROJECT_NAME,
208                                                args.since)
210     if 'added' in reports:
211         print("Issues that had the UX:debt label added")
212         print("=======================================")
213         print()
214         for issue in changes_generator.label_added():
215             print(f'- {issue["title"]}')
216             print(f'  {issue["web_url"]}')
217             print()
219     if 'removed' in reports:
220         print("Issues that had the UX:debt label removed")
221         print("=========================================")
222         print()
223         for issue in changes_generator.label_removed():
224             print(f'- {issue["title"]}')
225             print(f'  {issue["web_url"]}')
226             print()
228     if 'solved' in reports:
229         print("Solved issues")
230         print("=============")
231         print()
232         for closed_issue in changes_generator.closed_issues(reason='resolved'):
233             print(f'- {closed_issue["title"]}')
234             print(f'  {closed_issue["web_url"]}')
235             print()
237     if 'rejected' in reports:
238         print("Rejected issues")
239         print("===============")
240         print()
241         for closed_issue in changes_generator.closed_issues(reason='rejected'):
242             print(f'- {closed_issue["title"]}')
243             print(f'  {closed_issue["web_url"]}')
244             print()