diff --git a/github_copilot/README.md b/github_copilot/README.md new file mode 100644 index 00000000..f4874b25 --- /dev/null +++ b/github_copilot/README.md @@ -0,0 +1,10 @@ +# GitHub Copilot Registration Pruning + +This automation searches daily for all users in a GitHub organization that are actively using Copilot. +If Copilot was not used in a preceding period, it automatically unregisters them, and then notifies them. +Users can then optionally ask for their subscription to be reinstated. + +## Slack Usage + +- `/ak prune-idle-copilot-seats` invokes the automation immidetly. +- `/ak find-idle-copilot-seats` just displays the potentially idle seats. diff --git a/github_copilot/autokitteh.yaml b/github_copilot/autokitteh.yaml new file mode 100644 index 00000000..13476e22 --- /dev/null +++ b/github_copilot/autokitteh.yaml @@ -0,0 +1,27 @@ +version: v1 + +project: + name: github_copilot + vars: + - name: GITHUB_ORG + value: "yourorg" + - name: IDLE_USAGE_THRESHOLD + value: "72h" + # If set, only manage GitHub Copilot subscriptions to the specified users, separated by commas. + - name: LOGINS + value: "" + - name: LOG_CHANNEL + value: "github-copilot-registrations" + connections: + - name: mygithub + integration: github + - name: myslack + integration: slack + triggers: + - name: check_daily + schedule: "@daily" + call: triggers.star:on_schedule + - name: check_now + connection: myslack + event_type: app_mention + call: triggers.star:on_slack_app_mention diff --git a/github_copilot/helpers.star b/github_copilot/helpers.star new file mode 100644 index 00000000..e3b23f13 --- /dev/null +++ b/github_copilot/helpers.star @@ -0,0 +1,41 @@ +load("@github", "mygithub") +load("@slack", "myslack") + +def _email_to_slack_user_id(email): + resp = myslack.users_lookup_by_email(email) + return resp.user.id if resp.ok else "" + +def github_username_to_slack_user_id(github_username, github_owner_org): + resp = mygithub.get_user(github_username, owner = github_owner_org) + github_user_link = "<%s|%s>" % (resp.htmlurl, github_username) + + if resp.type == "Bot": + return "" + + # Try to match by the email address first. + if resp.email: + slack_user_id = _email_to_slack_user_id(resp.email) + if slack_user_id: + return slack_user_id + + # Otherwise, try to match by the user's full name. + if not resp.name: + return "" + + gh_full_name = resp.name.lower() + for user in _slack_users(): + slack_names = (user.profile.real_name.lower(), user.profile.real_name_normalized.lower()) + if gh_full_name in slack_names: + return user.id + + return "" + +def _slack_users(cursor = ""): + resp = myslack.users_list(cursor, limit = 100) + if not resp.ok: + return [] + + users = resp.members + if resp.response_metadata.next_cursor: + users += _slack_users(resp.response_metadata.next_cursor) + return users diff --git a/github_copilot/msg.json b/github_copilot/msg.json new file mode 100644 index 00000000..bb04a2c5 --- /dev/null +++ b/github_copilot/msg.json @@ -0,0 +1,46 @@ +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "You have been removed from the Copilot program due to inactivity", + "emoji": true + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Reinstate" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": ":repeat:", + "emoji": true + }, + "value": "reinstate", + "action_id": "button-action" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "OK" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": ":relieved:", + "emoji": true + }, + "value": "ok", + "action_id": "button-action" + } + } + ] +} diff --git a/github_copilot/seats.star b/github_copilot/seats.star new file mode 100644 index 00000000..7fe76e19 --- /dev/null +++ b/github_copilot/seats.star @@ -0,0 +1,82 @@ +load("env", "GITHUB_ORG", "IDLE_USAGE_THRESHOLD", "LOGINS", "LOG_CHANNEL") +load("@github", "mygithub") +load("@slack", "myslack") +load("helpers.star", "github_username_to_slack_user_id") +load("msg.json", "blocks") + +logins = LOGINS.split(",") +idle_usage_threshold = time.parse_duration(IDLE_USAGE_THRESHOLD) + +def prune_idle_seats(): + seats = find_idle_seats() + new_idle_seats = [] + for seat in seats: + new_idle_seats.append(seat) + + print("new idle: {}".format(seat)) + start("seats.star:engage_seat", {"seat": seat}) + return new_idle_seats + +def _get_all_seats(): + # TODO: pagination. + return mygithub.list_copilot_seats(GITHUB_ORG).seats + +def find_idle_seats(): + seats = _get_all_seats() + + t, idle_seats = time.now(), [] + for seat in seats: + if logins and (seat.assignee.login not in logins): + print("skipping %s" % seat.assignee.login) + continue + + delta = t - seat.last_activity_at + is_idle = delta >= idle_usage_threshold + + print("{}: {} - {} = {} {} {}".format( + seat.assignee.login, + t, + seat.last_activity_at, + delta, + ">=" if is_idle else "<", + idle_usage_threshold, + )) + + if is_idle: + idle_seats.append(seat) + + return idle_seats + +def engage_seat(seat): + log = lambda msg: myslack.chat_post_message(LOG_CHANNEL, "{}: {}".format(seat.assignee.login, msg)) + + log("engaging") + + github_login = seat.assignee.login + slack_id = github_username_to_slack_user_id(github_login, GITHUB_ORG) + if not slack_id: + print("No slack user found for github user %s" % github_login) + return + + mygithub.remove_copilot_users(GITHUB_ORG, [github_login]) + + myslack.chat_post_message(slack_id, blocks=blocks) + + s = subscribe('myslack', 'data.type == "block_actions" && data.user.id == "{}"'.format(slack_id)) + + say = lambda msg: myslack.chat_post_message(slack_id, msg) + + value = next_event(s)["actions"][0].value + + if value == 'ok': + say("Okey dokey!") + log("ok") + return + + if value == 'reinstate': + log("reinstate") + mygithub.add_copilot_users(GITHUB_ORG, [github_login]) + say("You have been reinstated to the Copilot program.") + return + + log("wierd response: {}".format(value)) diff --git a/github_copilot/triggers.star b/github_copilot/triggers.star new file mode 100644 index 00000000..4fbea5e1 --- /dev/null +++ b/github_copilot/triggers.star @@ -0,0 +1,23 @@ +load("@slack", "myslack") +load("seats.star", "prune_idle_seats", "find_idle_seats") + +def on_schedule(): + print(prune_idle_seats()) + +def on_slack_app_mention(data): + parts = data.text.split(" ") + if len(parts) < 2: + myslack.chat_post_message(data.channel, "unrecorgnized command", thread_ts=data.ts) + return + + cmd = parts[1].strip() + + reply = lambda msg: myslack.chat_post_message(data.channel, msg, thread_ts=data.ts) + logins = lambda seats: [seat.assignee.login for seat in seats] + + if cmd == "prune-idle-copilot-seats": + seats = prune_idle_seats() + reply("engaged {} new idle seats: {}".format(len(seats), logins(seats))) + elif cmd == "find-idle-copilot-seats": + seats = find_idle_seats() + reply("found {} idle seats: {}".format(len(seats), logins(seats)))