Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement inactive user management #713

Merged
merged 15 commits into from
Jan 12, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/org-inactive-user-management.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: 'Delete Inactive Users in Github Organization'

on:
schedule:
stephanme marked this conversation as resolved.
Show resolved Hide resolved
- cron: '0 0 1 * *'
workflow_dispatch:
push:
branches:
- "add-inactive-user-removal-automation"

jobs:
org-config-generation-check:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v4
with:
python-version: 3.9
- uses: actions/checkout@v3
with:
path: community
- name: Clean inactive github org users
id: uds
run: |
python -m pip install --upgrade pip
pip install -r community/org/requirements.txt
python community/org/org_user_management.py
stephanme marked this conversation as resolved.
Show resolved Hide resolved
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
- name: Create Pull Request
if: ${{ steps.uds.outputs.inactive_users_pr_description }}
uses: peter-evans/create-pull-request@v4
with:
path: community
add-paths: org/contributors.yml
commit-message: Delete inactive users
branch: delete-inactive-users
title: 'Inactive users to be deleted'
body: ${{ steps.uds.outputs.inactive_users_pr_description }}
9 changes: 9 additions & 0 deletions org/org_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ def load_from_project(self):
if wg:
self.working_groups.append(wg)

def get_contributors(self) -> Set[str]:
return set(self.contributors)

def get_community_members_with_role(self) -> Set[str]:
result = set(self.toc)
for wg in self.working_groups:
result |= OrgGenerator._wg_github_users(wg)
return result

def generate_org_members(self):
org_members = set(self.org_cfg["orgs"]["cloudfoundry"]["members"]) # just in case, should be empty list
org_members |= self.contributors
Expand Down
169 changes: 169 additions & 0 deletions org/org_user_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import requests
import argparse
import datetime
import yaml
import os
import uuid

from org_management import OrgGenerator

_SCRIPT_PATH = os.path.dirname(os.path.abspath(__file__))


class InactiveUserHandler:
def __init__(
self,
github_org: [str],
github_org_id: [str],
activity_date: [str],
github_token: [str],
):
self.github_org = github_org
self.github_org_id = github_org_id
self.activity_date = activity_date
self.github_token = github_token

def _get_request_headrs(self):
return {"Authorization": f"Bearer {self.github_token}"}

def _process_request_result(self, request):
if request.status_code == 200 or request.status_code == 201:
return request.json()
else:
raise Exception(f"Request execution failed with status code of {request.status_code}. {request.status_code}")

def _execute_query(self, query):
request = requests.post("https://api.github.com/graphql", json={"query": query}, headers=self._get_request_headrs())
return self._process_request_result(request)

def _build_query(self, after_cursor_value=None):
after_cursor = '"{}"'.format(after_cursor_value) if after_cursor_value else "null"
query = """
{
organization(login: \"%s\") {
membersWithRole(first: 50, after:%s) {
pageInfo {
hasNextPage
endCursor
}
nodes {
login
contributionsCollection(organizationID: \"%s\", from: \"%s\") {
hasAnyContributions
}
}
}
}
}
""" % (
self.github_org,
after_cursor,
self.github_org_id,
self.activity_date,
)
return query

def get_inactive_users(self):
inactive_users = set()
has_next_page = True
after_cursor_value = None
while has_next_page:
result = self._execute_query(self._build_query(after_cursor_value))
for user_node in result["data"]["organization"]["membersWithRole"]["nodes"]:
user = user_node["login"]
activity = user_node["contributionsCollection"]["hasAnyContributions"]
print(f"The user '{user}' has activity value {activity} contributions")
if not activity:
print(f"Adding user '{user}' as inactive")
inactive_users.add(user)

has_next_page = result["data"]["organization"]["membersWithRole"]["pageInfo"]["hasNextPage"]
after_cursor_value = result["data"]["organization"]["membersWithRole"]["pageInfo"]["endCursor"]

return inactive_users

def _load_yaml_file(self, path):
with open(path, "r") as stream:
return yaml.safe_load(stream)

def _write_yaml_file(self, path, data):
with open(path, "w") as f:
yaml.dump(data, f)

def delete_inactive_contributors(self, users_to_delete):
path = f"{_SCRIPT_PATH}/contributors.yml"
contributors_yaml = self._load_yaml_file(path)
contributors_yaml["contributors"] = [c for c in contributors_yaml["contributors"] if c not in users_to_delete]
self._write_yaml_file(path, contributors_yaml)

def get_inactive_users_msg(self, users_to_delete, tagusers):
rfc = (
"https://github.com/cloudfoundry/community/blob/main/toc/rfc/"
"rfc-0025-define-criteria-and-removal-process-for-inactive-members.md"
)
rfc_revocation_rules = (
"https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0025-define-"
"criteria-and-removal-process-for-inactive-members.md#remove-the-membership-to-the-cloud-foundry-github-organization"
)
user_tagging_prefix = "@" if tagusers else ""
users_as_list = "\n".join(str(user_tagging_prefix + s) for s in users_to_delete)
return (
f"According to the rolues for inactivity defined in [RFC-0025]({rfc}) following users will be deleted:\n"
f"{users_as_list}\nAccording to the [revocation policy in the RFC]({rfc_revocation_rules}), users have"
" two weeks to refute this revocation, if they wish."
)

@staticmethod
def _get_bool_env_var(env_var_name, default):
return os.getenv(env_var_name, default).lower() == "true"


if __name__ == "__main__":
one_year_back = (datetime.datetime.now() - datetime.timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ")

parser = argparse.ArgumentParser(description="Cloud Foundry Org Inactive User Handler")
parser.add_argument("-goid", "--githuborgid", default="O_kgDOAAl8sg", help="Cloud Foundry Github org ID")
parser.add_argument("-go", "--githuborg", default="cloudfoundry", help="Cloud Foundry Github org name")
parser.add_argument("-sd", "--sincedate", default=one_year_back, help="Since when to analyze in format 'Y-m-dTH:M:SZ'")
parser.add_argument(
"-gt", "--githubtoken", default=os.environ.get("GH_TOKEN"), help="Github API access token. Supported also as env var 'GH_TOKEN'"
)
parser.add_argument(
"-dr",
"--dryrun",
action="store_true",
help="Dry run execution. Supported also as env var 'INACTIVE_USER_MANAGEMENT_DRY_RUN'",
)
parser.add_argument(
"-tu",
"--tagusers",
action="store_true",
help="Tag users to be notified. Supported also as env var 'INACTIVE_USER_MANAGEMENT_TAG_USERS'",
)
args = parser.parse_args()

print("Get information about community users")
generator = OrgGenerator()
generator.load_from_project()
community_members_with_role = generator.get_community_members_with_role()

print("Analyzing Cloud Foundry org user activity.")
userHandler = InactiveUserHandler(args.githuborg, args.githuborgid, args.sincedate, args.githubtoken)
inactive_users = userHandler.get_inactive_users()

print(f"Inactive users length is {len(inactive_users)} and inactive users are {inactive_users}")
users_to_delete = inactive_users - community_members_with_role
tagusers = args.tagusers or InactiveUserHandler._get_bool_env_var("INACTIVE_USER_MANAGEMENT_TAG_USERS", "False")
inactive_users_msg = userHandler.get_inactive_users_msg(users_to_delete, tagusers)
if args.dryrun or InactiveUserHandler._get_bool_env_var("INACTIVE_USER_MANAGEMENT_DRY_RUN", "False"):
print(f"Dry-run mode.\nInactive_users_msg is: {inactive_users_msg}")
print(f"Following users will be deleted: {inactive_users}")
elif users_to_delete:
userHandler.delete_inactive_contributors(users_to_delete)
with open(os.environ["GITHUB_OUTPUT"], "a") as env:
separator = uuid.uuid1()
step_output_name = "inactive_users_pr_description"
print(f"{step_output_name}<<{separator}\n{inactive_users_msg}\n{separator}", file=env)

inactive_users_with_role = community_members_with_role.intersection(inactive_users)
print(f"Inactive users with role length is {len(inactive_users_with_role)} and users are {inactive_users_with_role}")
28 changes: 28 additions & 0 deletions org/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ Limitations:
- The branchprotector doesn't support wildcards for branch rules. I.e. every version branch gets its own rule.
- The branchprotector doesn't delete unneeded branch protection rules e.g. when a version branch got deleted.

### Inactive User Management
Inactive users according to the criteria defined in
[rfc-0025-define-criteria-and-removal-process-for-inactive-members](https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0025-define-criteria-and-removal-process-for-inactive-members.md) are identified by an automation which opens a pull-request to delete those.


## Development

Requires Python 3.9.
Expand All @@ -82,6 +87,7 @@ How to run locally:
cd ./org
pip install -r requirements.txt
python -m org_management --help
python -m org_user_management --help
```

Usage:
Expand All @@ -98,6 +104,28 @@ optional arguments:
output file for generated branch protection rules
```

```
python -m org_user_management --help
usage: org_user_management.py [-h] [-goid GITHUBORGID] [-go GITHUBORG] [-sd SINCEDATE] [-gt GITHUBTOKEN] [-dr DRYRUN] [-tu TAGUSERS]

Cloud Foundry Org Inactive User Handler

options:
-h, --help show this help message and exit
-goid GITHUBORGID, --githuborgid GITHUBORGID
Cloud Foundry Github org ID
-go GITHUBORG, --githuborg GITHUBORG
Cloud Foundry Github org name
-sd SINCEDATE, --sincedate SINCEDATE
Since when to analyze in format 'Y-m-dTH:M:SZ'
-gt GITHUBTOKEN, --githubtoken GITHUBTOKEN
Github API access token. Supported also as env var 'GH_TOKEN'
-dr DRYRUN, --dryrun DRYRUN
Dry run execution. Supported also as env var 'INACTIVE_USER_MANAGEMENT_DRY_RUN'
-tu TAGUSERS, --tagusers TAGUSERS
Tag users to be notified. Supported also as env var 'INACTIVE_USER_MANAGEMENT_TAG_USERS'
```

How to run tests:
```
cd ./org
Expand Down
3 changes: 2 additions & 1 deletion org/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pyyaml
jsonschema
jsonschema
requests