Make it easier to reason about state transitions
[tails.git] / bin / gitlab-users-cleanup
blob3d5a14cdc795a7b489c5c2deb2f02d89a1587adc
1 #! /usr/bin/python3
3 # Documentation: https://tails.boum.org/contribute/working_together/GitLab/#api
5 import datetime
6 import logging
7 import os
8 from pathlib import Path
9 import sys
11 import dateutil.parser
12 import gitlab  # type: ignore
13 import django.utils.dateparse  # type: ignore
15 PYTHON_GITLAB_CONFIG_FILE = os.getenv(
16     "PYTHON_GITLAB_CONFIG_FILE", default=Path.home() / ".python-gitlab.cfg"
19 PYTHON_GITLAB_NAME = os.getenv("GITLAB_NAME", default="Tails")
21 LOG_FORMAT = "%(asctime)-15s %(levelname)s %(message)s"
22 log = logging.getLogger()
24 LEGIT_USERS = [
25     "adrelanos",
26     "ajitari",
27     "alster",
28     "amartinr",
29     "anarcat",
30     "anonymousblabla",
31     "AnthonyV",
32     "AtomiKe",
33     "Ballen",
34     "bertagaz",
35     "BitingBird",
36     "boklm",
37     "Brettermaik",
38     "cacahuatl",
39     "cacukin",
40     "cailmdaley",
41     "chouchou",
42     "cipherpunks",
43     "co6",
44     "counterflow",
45     "cypherpunks",
46     "CyrilBrulebois",
47     "Dary",
48     "davidiw",
49     "dawuud",
50     "dgoulet",
51     "dkg",
52     "diva",
53     "Dr_Whax",
54     "elouann",
55     "enrico",
56     "espiv",
57     "flapflap",
58     "Gaff",
59     "garrettr",
60     "geb",
61     "goupille",
62     "Guillaume",
63     "hellais",
64     "huertanix",
65     "hyas",
66     "import-from-Redmine",
67     "infinity0",
68     "ioerror",
69     "irregulator",
70     "iry",
71     "Joelsven1",
72     "johanbluecreek",
73     "johnnyvon",
74     "juris",
75     "lrnpenguin",
76     "ma1",
77     "maker",
78     "marcelisa2",
79     "matsa",
80     "mercedes508",
81     "micah",
82     "mjenglish",
83     "Moorm",
84     "mrphs",
85     "naar",
86     "nathan",
87     "natmaka",
88     "nc89",
89     "olabini",
90     "OneST8",
91     "ovitters",
92     "pablonatalino",
93     "png",
94     "pragmatic45",
95     "Rajie",
96     "rlrevell",
97     "rojiro",
98     "romeopapa",
99     "runa",
100     "runasand",
101     "s7r",
102     "saintmichael",
103     "sam_krd",
104     "SammyTheDuck",
105     "sascha.markus_gmail.com",
106     "sbrocca",
107     "sdibella",
108     "Seb35",
109     "senkdavid",
110     "singuliere",
111     "sjmurdoch",
112     "Sosthene",
113     "spriver",
114     "sriedel",
115     "sst",
116     "Standard8",
117     "sycamoreone",
118     "synthe",
119     "taggart",
120     "Tanejarahul",
121     "tchou",
122     "TheNerdyAnarchist",
123     "tim",
124     "ttailor",
125     "underite",
126     "usul",
127     "uzairfarooq",
128     "winterfairy",
129     "xin",
130     "xirzon",
131     "xmunoz",
132     "yawning",
133     "zooko",
134     "zopedi",
137 if __name__ == "__main__":
138     import argparse
140     parser = argparse.ArgumentParser()
142     # Filters
143     parser.add_argument(
144         "--blocked", action="store_true", help="Only consider blocked users"
145     )
146     parser.add_argument(
147         "--active", action="store_true", help="Only consider active users"
148     )
149     parser.add_argument(
150         "--deactivated",
151         action="store_true",
152         help="Only consider deactivated users",
153     )
154     parser.add_argument(
155         "--min-creation-age",
156         type=django.utils.dateparse.parse_duration,
157         default="57 days",
158         help="Only consider users created at least this duration ago",
159     )
160     parser.add_argument(
161         "--min-inactivity",
162         type=django.utils.dateparse.parse_duration,
163         default="56 days",
164         help="Only consider users inactive since this duration",
165     )
166     parser.add_argument(
167         "--max-post-sign-up-activity",
168         type=django.utils.dateparse.parse_duration,
169         help="Don't consider users who have been active for at least this duration after signing-up",
170     )
171     parser.add_argument(
172         "--max-sign-in-count",
173         type=int,
174         default=7,
175         help="Only consider users who have not signed-in more often than this",
176     )
177     parser.add_argument(
178         "--max-contribution-events",
179         type=int,
180         default=10,
181         help="Only consider users who have not acted on issues or MRs more often than this",
182     )
183     parser.add_argument(
184         "--min-contribution-events",
185         type=int,
186         default=0,
187         help="Only consider users who have acted on issues or MRs at least this often",
188     )
189     parser.add_argument(
190         "--not-in-group",
191         type=str,
192         default="contributors-team",
193         help="Only consider users who are not members of this group",
194     )
195     parser.add_argument(
196         "--search",
197         type=str,
198         help="Only consider users who satisfy this search criterion",
199     )
200     parser.add_argument(
201         "--email-ends-with",
202         type=str,
203         help="Only consider users whose email address ends with this string",
204     )
206     # Actions
207     parser.add_argument(
208         "--action",
209         type=str,
210         help="Action to take on selected users, among: deactivate, block, deactivate-or-block, delete, delete-user-and-contributions",
211     )
213     # General behavior control
214     parser.add_argument("--debug", action="store_true", help="debug output")
215     parser.add_argument(
216         "--dry-run",
217         action="store_true",
218         help="Don't actually update anything, just print",
219     )
221     args = parser.parse_args()
223     if args.deactivated and args.active:
224         sys.exit("Cannot use --deactivated and --active at the same time")
226     if args.debug:
227         logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
228     else:
229         logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
231     gl = gitlab.Gitlab.from_config(
232         PYTHON_GITLAB_NAME, config_files=[PYTHON_GITLAB_CONFIG_FILE]
233     )
234     gl.auth()
236     now = datetime.datetime.now(tz=datetime.timezone.utc)
238     max_creation_date = now - args.min_creation_age
239     log.debug("Max creation date: %s", max_creation_date)
241     max_activity_date = now - args.min_inactivity
242     log.debug("Max activity date: %s", max_activity_date)
244     if args.max_post_sign_up_activity is not None:
245         log.debug(
246             "Max post-sign-up activity: %s", args.max_post_sign_up_activity
247         )
249     if args.not_in_group is not None:
250         group = [
251             g
252             for g in gl.groups.list(all=True)
253             # Disambiguate between groups whose names share a common prefix
254             if g.full_path == args.not_in_group
255         ][0]
256         group_members_ids = [m.id for m in group.members_all.list(get_all=True)]
257     else:
258         group_members_ids = []
259     log.debug("Group members: %s", group_members_ids)
261     user_filters = {
262         "exclude_internal": True,
263         "two_factor": "disabled",
264     }
265     if args.blocked:
266         user_filters["blocked"] = True
267     if args.active:
268         user_filters["active"] = True
269     if args.deactivated:
270         user_filters["active"] = False
271     if args.search is not None:
272         user_filters["search"] = args.search
274     users = gl.users.list(all=True, **user_filters)
276     log.debug("Users: %s", users)
278     for user in users:
279         user_desc = f"{user.username} (id={user.id})"
281         # Filter out users we don't want to act upon
283         if args.deactivated and user.state != "deactivated":
284             log.debug(
285                 "User %s is not deactivated (state: %s) ⇒ skipping",
286                 user_desc,
287                 user.state,
288             )
289             continue
291         if dateutil.parser.isoparse(user.created_at) < max_creation_date:
292             log.debug(
293                 "User %s was created more than %s ago",
294                 user_desc,
295                 args.min_creation_age,
296             )
297         else:
298             log.debug(
299                 "User %s was created less than %s ago ⇒ skipping",
300                 user_desc,
301                 args.min_creation_age,
302             )
303             continue
305         if user.last_activity_on is None:
306             log.debug("User %s was never active", user_desc)
307         elif (
308             dateutil.parser.isoparse(user.last_activity_on + "T00Z")
309             < max_activity_date
310         ):
311             log.debug(
312                 "User %s is inactive since at least %s",
313                 user_desc,
314                 args.min_inactivity,
315             )
316         else:
317             log.debug(
318                 "User %s was active in the last %s ⇒ skipping",
319                 user_desc,
320                 args.min_inactivity,
321             )
322             continue
324         if (
325             user.last_activity_on is not None
326             and args.max_post_sign_up_activity is not None
327         ):
328             created_at = dateutil.parser.isoparse(user.created_at)
329             last_activity_on = dateutil.parser.isoparse(
330                 user.last_activity_on + "T00Z"
331             )
332             if last_activity_on < created_at + args.max_post_sign_up_activity:
333                 log.debug(
334                     "User %s has not been active for more than %s after sign-up",
335                     user_desc,
336                     args.max_post_sign_up_activity,
337                 )
338             else:
339                 log.debug(
340                     "User %s has been active for more than %s after sign-up ⇒ skipping",
341                     user_desc,
342                     args.max_post_sign_up_activity,
343                 )
344                 continue
346         if user.username in LEGIT_USERS:
347             log.debug(
348                 "User %s is legit ⇒ skipping",
349                 user_desc,
350             )
351             continue
353         if user.id in group_members_ids:
354             log.debug(
355                 "User %s is in group %s ⇒ skipping",
356                 user_desc,
357                 args.not_in_group,
358             )
359             continue
361         if args.email_ends_with is not None:
362             if user.email.endswith(args.email_ends_with):
363                 log.debug(
364                     "User %s has an email address that ends with %s",
365                     user_desc,
366                     args.email_ends_with,
367                 )
368             else:
369                 log.debug(
370                     "User %s has no email address that ends with %s",
371                     user_desc,
372                     args.email_ends_with,
373                 )
374                 continue
376         user_obj = gl.users.get(user.id)
378         if user_obj.sign_in_count <= args.max_sign_in_count:
379             log.debug(
380                 "User %s has signed-in %i <= %i times",
381                 user_desc,
382                 user_obj.sign_in_count,
383                 args.max_sign_in_count,
384             )
385         else:
386             log.debug(
387                 "User %s has signed-in %i > %i times ⇒ skipping",
388                 user_desc,
389                 user_obj.sign_in_count,
390                 args.max_sign_in_count,
391             )
392             continue
394         events = user_obj.events.list(all=True)
395         contribution_events = [
396             e
397             for e in events
398             if e.target_type
399             in ["Note", "DiscussionNote", "Issue", "merge_request"]
400         ]
401         if len(contribution_events) <= args.max_contribution_events:
402             log.debug(
403                 "User %s has done less than %i contributions",
404                 user_desc,
405                 args.max_contribution_events,
406             )
407         else:
408             log.debug(
409                 "User %s has done at least %i contributions ⇒ skipping",
410                 user_desc,
411                 args.max_contribution_events,
412             )
413             continue
414         if len(contribution_events) >= args.min_contribution_events:
415             log.debug(
416                 "User %s has done at least %i contributions",
417                 user_desc,
418                 args.min_contribution_events,
419             )
420         else:
421             log.debug(
422                 "User %s has done less than %i contributions ⇒ skipping",
423                 user_desc,
424                 args.min_contribution_events,
425             )
426             continue
428         # If we reached this point, perform args.action
430         if args.action == "deactivate":
431             if user.state == "blocked":
432                 log.debug(
433                     "User %s is already blocked, cannot deactivate", user_desc
434                 )
435             elif user.state == "deactivated":
436                 log.debug("User %s is already deactivated", user_desc)
437             else:
438                 log.info(
439                     "Deactivating user %s (previous state: %s)",
440                     user_desc,
441                     user.state,
442                 )
443                 if not args.dry_run:
444                     user.deactivate()
445         elif args.action == "block":
446             if user.state == "blocked":
447                 log.debug("User %s is already blocked", user_desc)
448             else:
449                 log.info(
450                     "Blocking user %s (previous state: %s)",
451                     user_desc,
452                     user.state,
453                 )
454                 if not args.dry_run:
455                     user.block()
456         elif args.action == "deactivate-or-block":
457             if user.state in ["blocked", "deactivated"]:
458                 log.debug("User %s is already %s", user_desc, user.state)
459             else:
460                 log.info(
461                     "Deactivating user %s (previous state: %s)",
462                     user_desc,
463                     user.state,
464                 )
465                 if not args.dry_run:
466                     try:
467                         user.deactivate()
468                     # The GitLab API forbids deactivating a user who
469                     # has been active in the past 90 days, so block them.
470                     except gitlab.exceptions.GitlabDeactivateError:
471                         log.info(
472                             "Deactivating user %s (previous state: %s) failed, so blocking them",
473                             user_desc,
474                             user.state,
475                         )
476                         user.block()
477         elif args.action == "delete":
478             log.info(
479                 "Deleting user %s (previous state: %s): https://gitlab.tails.boum.org/%s",
480                 user_desc,
481                 user.state,
482                 user.username,
483             )
484             if not args.dry_run:
485                 user.delete()
486         elif args.action == "delete-user-and-contributions":
487             log.info(
488                 "Deleting user %s and contributions (previous state: %s): https://gitlab.tails.boum.org/%s",
489                 user_desc,
490                 user.state,
491                 user.username,
492             )
493             if not args.dry_run:
494                 user.delete(hard_delete=True)
495         else:
496             sys.exit("Unsupported action: %s" % args.action)