Skip to content

Commit

Permalink
Merge pull request #713 from cloudfoundry/add-inactive-user-removal-a…
Browse files Browse the repository at this point in the history
…utomation

Implement inactive user management
  • Loading branch information
beyhan authored Jan 12, 2024
2 parents f97f5f3 + dacd3c8 commit 24d074f
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 1 deletion.
39 changes: 39 additions & 0 deletions .github/workflows/org-inactive-user-management.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: 'Delete Inactive Users in Github Organization'

on:
schedule:
- 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
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
INACTIVE_USER_MANAGEMENT_TAG_USERS: ${{ secrets.INACTIVE_USER_MANAGEMENT_TAG_USERS }}
- 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
170 changes: 170 additions & 0 deletions org/org_user_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
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)
users_to_delete_lower = [user.lower() for user in users_to_delete]
contributors_yaml["contributors"] = [c for c in contributors_yaml["contributors"] if c.lower() not in users_to_delete_lower]
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

0 comments on commit 24d074f

Please sign in to comment.