3 # Documentation: https://tails.boum.org/contribute/working_together/GitLab/#api
8 from pathlib import Path
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()
66 "import-from-Redmine",
105 "sascha.markus_gmail.com",
137 if __name__ == "__main__":
140 parser = argparse.ArgumentParser()
144 "--blocked", action="store_true", help="Only consider blocked users"
147 "--active", action="store_true", help="Only consider active users"
152 help="Only consider deactivated users",
155 "--min-creation-age",
156 type=django.utils.dateparse.parse_duration,
158 help="Only consider users created at least this duration ago",
162 type=django.utils.dateparse.parse_duration,
164 help="Only consider users inactive since this duration",
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",
172 "--max-sign-in-count",
175 help="Only consider users who have not signed-in more often than this",
178 "--max-contribution-events",
181 help="Only consider users who have not acted on issues or MRs more often than this",
184 "--min-contribution-events",
187 help="Only consider users who have acted on issues or MRs at least this often",
192 default="contributors-team",
193 help="Only consider users who are not members of this group",
198 help="Only consider users who satisfy this search criterion",
203 help="Only consider users whose email address ends with this string",
210 help="Action to take on selected users, among: deactivate, block, deactivate-or-block, delete, delete-user-and-contributions",
213 # General behavior control
214 parser.add_argument("--debug", action="store_true", help="debug output")
218 help="Don't actually update anything, just print",
221 args = parser.parse_args()
223 if args.deactivated and args.active:
224 sys.exit("Cannot use --deactivated and --active at the same time")
227 logging.basicConfig(level=logging.DEBUG, format=LOG_FORMAT)
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]
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:
246 "Max post-sign-up activity: %s", args.max_post_sign_up_activity
249 if args.not_in_group is not None:
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
256 group_members_ids = [m.id for m in group.members_all.list(get_all=True)]
258 group_members_ids = []
259 log.debug("Group members: %s", group_members_ids)
262 "exclude_internal": True,
263 "two_factor": "disabled",
266 user_filters["blocked"] = True
268 user_filters["active"] = True
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)
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":
285 "User %s is not deactivated (state: %s) ⇒ skipping",
291 if dateutil.parser.isoparse(user.created_at) < max_creation_date:
293 "User %s was created more than %s ago",
295 args.min_creation_age,
299 "User %s was created less than %s ago ⇒ skipping",
301 args.min_creation_age,
305 if user.last_activity_on is None:
306 log.debug("User %s was never active", user_desc)
308 dateutil.parser.isoparse(user.last_activity_on + "T00Z")
312 "User %s is inactive since at least %s",
318 "User %s was active in the last %s ⇒ skipping",
325 user.last_activity_on is not None
326 and args.max_post_sign_up_activity is not None
328 created_at = dateutil.parser.isoparse(user.created_at)
329 last_activity_on = dateutil.parser.isoparse(
330 user.last_activity_on + "T00Z"
332 if last_activity_on < created_at + args.max_post_sign_up_activity:
334 "User %s has not been active for more than %s after sign-up",
336 args.max_post_sign_up_activity,
340 "User %s has been active for more than %s after sign-up ⇒ skipping",
342 args.max_post_sign_up_activity,
346 if user.username in LEGIT_USERS:
348 "User %s is legit ⇒ skipping",
353 if user.id in group_members_ids:
355 "User %s is in group %s ⇒ skipping",
361 if args.email_ends_with is not None:
362 if user.email.endswith(args.email_ends_with):
364 "User %s has an email address that ends with %s",
366 args.email_ends_with,
370 "User %s has no email address that ends with %s",
372 args.email_ends_with,
376 user_obj = gl.users.get(user.id)
378 if user_obj.sign_in_count <= args.max_sign_in_count:
380 "User %s has signed-in %i <= %i times",
382 user_obj.sign_in_count,
383 args.max_sign_in_count,
387 "User %s has signed-in %i > %i times ⇒ skipping",
389 user_obj.sign_in_count,
390 args.max_sign_in_count,
394 events = user_obj.events.list(all=True)
395 contribution_events = [
399 in ["Note", "DiscussionNote", "Issue", "merge_request"]
401 if len(contribution_events) <= args.max_contribution_events:
403 "User %s has done less than %i contributions",
405 args.max_contribution_events,
409 "User %s has done at least %i contributions ⇒ skipping",
411 args.max_contribution_events,
414 if len(contribution_events) >= args.min_contribution_events:
416 "User %s has done at least %i contributions",
418 args.min_contribution_events,
422 "User %s has done less than %i contributions ⇒ skipping",
424 args.min_contribution_events,
428 # If we reached this point, perform args.action
430 if args.action == "deactivate":
431 if user.state == "blocked":
433 "User %s is already blocked, cannot deactivate", user_desc
435 elif user.state == "deactivated":
436 log.debug("User %s is already deactivated", user_desc)
439 "Deactivating user %s (previous state: %s)",
445 elif args.action == "block":
446 if user.state == "blocked":
447 log.debug("User %s is already blocked", user_desc)
450 "Blocking user %s (previous state: %s)",
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)
461 "Deactivating user %s (previous state: %s)",
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:
472 "Deactivating user %s (previous state: %s) failed, so blocking them",
477 elif args.action == "delete":
479 "Deleting user %s (previous state: %s): https://gitlab.tails.boum.org/%s",
486 elif args.action == "delete-user-and-contributions":
488 "Deleting user %s and contributions (previous state: %s): https://gitlab.tails.boum.org/%s",
494 user.delete(hard_delete=True)
496 sys.exit("Unsupported action: %s" % args.action)