3 # Documentation: https://tails.net/contribute/working_together/GitLab/#api
12 import dateutil.parser
14 sys.exit("You need to install python3-dateutil to use this program.")
19 sys.exit("You need to install python3-requests to use this program.")
22 from cachecontrol import CacheControlAdapter # type: ignore
23 from cachecontrol.heuristics import OneDayCache # type: ignore
25 sys.exit("You need to install python3-cachecontrol to use this program.")
28 import gitlab # type: ignore
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',
38 PYTHON_GITLAB_NAME = os.getenv('GITLAB_NAME', default='Tails')
41 PROJECT_NAME = GROUP_NAME + '/' + 'tails'
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):
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,
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):
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
87 assert isinstance(project, gitlab.v4.objects.Project)
91 class UxDebtChangesGenerator(object):
92 def __init__(self, gl, group, project_name: str, after: datetime.datetime):
95 self.project = self.gl.project_from_name(project_name)
96 self.after = datetime.datetime(after.year,
99 tzinfo=datetime.timezone.utc)
101 def closed_issues(self, reason: str) -> list:
103 closed_issues_events = self.project.events.list(as_list=False,
108 gl_closed_issues_with_duplicates = [
109 event.target_iid for event in closed_issues_events
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':
121 if LABEL not in issue.labels:
123 if reason == 'resolved':
124 if 'Rejected' in issue.labels:
126 elif reason == 'rejected':
127 if 'Rejected' not in issue.labels:
130 raise NotImplementedError("Unsupported reason %s" % reason)
131 closed_issues.append({
132 "title": issue.title,
133 "web_url": issue.web_url,
138 def label_added(self):
140 for issue in self.project.issues.list(state='opened', labels=[LABEL]):
141 if LABEL not in issue.labels:
143 events = issue.resourcelabelevents.list()
145 if event.action != 'add' or event.label['name'] != 'UX:debt' or event.label != None:
147 event_created_at = dateutil.parser.isoparse(event.created_at)
148 if event_created_at < self.after:
151 "title": issue.title,
152 "web_url": issue.web_url,
156 def label_removed(self):
158 for issue in self.project.issues.list(state='opened'):
159 if LABEL in issue.labels:
161 events = issue.resourcelabelevents.list()
163 if event.action != 'remove' or event.label['name'] != 'UX:debt':
165 event_created_at = dateutil.parser.isoparse(event.created_at)
166 if event_created_at < self.after:
169 "title": issue.title,
170 "web_url": issue.web_url,
175 if __name__ == '__main__':
177 parser = argparse.ArgumentParser()
180 type=datetime.date.fromisoformat,
182 help="Consider changes after this date, in the format YYYY-MM-DD")
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()
193 logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
195 logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
197 gl = GitLabWrapper.from_config(PYTHON_GITLAB_NAME,
198 config_files=[PYTHON_GITLAB_CONFIG_FILE])
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,
210 if 'added' in reports:
211 print("Issues that had the UX:debt label added")
212 print("=======================================")
214 for issue in changes_generator.label_added():
215 print(f'- {issue["title"]}')
216 print(f' {issue["web_url"]}')
219 if 'removed' in reports:
220 print("Issues that had the UX:debt label removed")
221 print("=========================================")
223 for issue in changes_generator.label_removed():
224 print(f'- {issue["title"]}')
225 print(f' {issue["web_url"]}')
228 if 'solved' in reports:
229 print("Solved issues")
230 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"]}')
237 if 'rejected' in reports:
238 print("Rejected issues")
239 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"]}')