Skip to content

Commit 24d074f

Browse files
authored
Merge pull request #713 from cloudfoundry/add-inactive-user-removal-automation
Implement inactive user management
2 parents f97f5f3 + dacd3c8 commit 24d074f

File tree

5 files changed

+248
-1
lines changed

5 files changed

+248
-1
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: 'Delete Inactive Users in Github Organization'
2+
3+
on:
4+
schedule:
5+
- cron: '0 0 1 * *'
6+
workflow_dispatch:
7+
push:
8+
branches:
9+
- "add-inactive-user-removal-automation"
10+
11+
jobs:
12+
org-config-generation-check:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/setup-python@v4
16+
with:
17+
python-version: 3.9
18+
- uses: actions/checkout@v3
19+
with:
20+
path: community
21+
- name: Clean inactive github org users
22+
id: uds
23+
run: |
24+
python -m pip install --upgrade pip
25+
pip install -r community/org/requirements.txt
26+
python community/org/org_user_management.py
27+
env:
28+
GH_TOKEN: ${{ secrets.GH_TOKEN }}
29+
INACTIVE_USER_MANAGEMENT_TAG_USERS: ${{ secrets.INACTIVE_USER_MANAGEMENT_TAG_USERS }}
30+
- name: Create Pull Request
31+
if: ${{ steps.uds.outputs.inactive_users_pr_description }}
32+
uses: peter-evans/create-pull-request@v4
33+
with:
34+
path: community
35+
add-paths: org/contributors.yml
36+
commit-message: Delete inactive users
37+
branch: delete-inactive-users
38+
title: 'Inactive users to be deleted'
39+
body: ${{ steps.uds.outputs.inactive_users_pr_description }}

org/org_management.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ def load_from_project(self):
8585
if wg:
8686
self.working_groups.append(wg)
8787

88+
def get_contributors(self) -> Set[str]:
89+
return set(self.contributors)
90+
91+
def get_community_members_with_role(self) -> Set[str]:
92+
result = set(self.toc)
93+
for wg in self.working_groups:
94+
result |= OrgGenerator._wg_github_users(wg)
95+
return result
96+
8897
def generate_org_members(self):
8998
org_members = set(self.org_cfg["orgs"]["cloudfoundry"]["members"]) # just in case, should be empty list
9099
org_members |= self.contributors

org/org_user_management.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import requests
2+
import argparse
3+
import datetime
4+
import yaml
5+
import os
6+
import uuid
7+
8+
from org_management import OrgGenerator
9+
10+
_SCRIPT_PATH = os.path.dirname(os.path.abspath(__file__))
11+
12+
13+
class InactiveUserHandler:
14+
def __init__(
15+
self,
16+
github_org: [str],
17+
github_org_id: [str],
18+
activity_date: [str],
19+
github_token: [str],
20+
):
21+
self.github_org = github_org
22+
self.github_org_id = github_org_id
23+
self.activity_date = activity_date
24+
self.github_token = github_token
25+
26+
def _get_request_headrs(self):
27+
return {"Authorization": f"Bearer {self.github_token}"}
28+
29+
def _process_request_result(self, request):
30+
if request.status_code == 200 or request.status_code == 201:
31+
return request.json()
32+
else:
33+
raise Exception(f"Request execution failed with status code of {request.status_code}. {request.status_code}")
34+
35+
def _execute_query(self, query):
36+
request = requests.post("https://api.github.com/graphql", json={"query": query}, headers=self._get_request_headrs())
37+
return self._process_request_result(request)
38+
39+
def _build_query(self, after_cursor_value=None):
40+
after_cursor = '"{}"'.format(after_cursor_value) if after_cursor_value else "null"
41+
query = """
42+
{
43+
organization(login: \"%s\") {
44+
membersWithRole(first: 50, after:%s) {
45+
pageInfo {
46+
hasNextPage
47+
endCursor
48+
}
49+
nodes {
50+
login
51+
contributionsCollection(organizationID: \"%s\", from: \"%s\") {
52+
hasAnyContributions
53+
}
54+
}
55+
}
56+
}
57+
}
58+
""" % (
59+
self.github_org,
60+
after_cursor,
61+
self.github_org_id,
62+
self.activity_date,
63+
)
64+
return query
65+
66+
def get_inactive_users(self):
67+
inactive_users = set()
68+
has_next_page = True
69+
after_cursor_value = None
70+
while has_next_page:
71+
result = self._execute_query(self._build_query(after_cursor_value))
72+
for user_node in result["data"]["organization"]["membersWithRole"]["nodes"]:
73+
user = user_node["login"]
74+
activity = user_node["contributionsCollection"]["hasAnyContributions"]
75+
print(f"The user '{user}' has activity value {activity} contributions")
76+
if not activity:
77+
print(f"Adding user '{user}' as inactive")
78+
inactive_users.add(user)
79+
80+
has_next_page = result["data"]["organization"]["membersWithRole"]["pageInfo"]["hasNextPage"]
81+
after_cursor_value = result["data"]["organization"]["membersWithRole"]["pageInfo"]["endCursor"]
82+
83+
return inactive_users
84+
85+
def _load_yaml_file(self, path):
86+
with open(path, "r") as stream:
87+
return yaml.safe_load(stream)
88+
89+
def _write_yaml_file(self, path, data):
90+
with open(path, "w") as f:
91+
yaml.dump(data, f)
92+
93+
def delete_inactive_contributors(self, users_to_delete):
94+
path = f"{_SCRIPT_PATH}/contributors.yml"
95+
contributors_yaml = self._load_yaml_file(path)
96+
users_to_delete_lower = [user.lower() for user in users_to_delete]
97+
contributors_yaml["contributors"] = [c for c in contributors_yaml["contributors"] if c.lower() not in users_to_delete_lower]
98+
self._write_yaml_file(path, contributors_yaml)
99+
100+
def get_inactive_users_msg(self, users_to_delete, tagusers):
101+
rfc = (
102+
"https://github.com/cloudfoundry/community/blob/main/toc/rfc/"
103+
"rfc-0025-define-criteria-and-removal-process-for-inactive-members.md"
104+
)
105+
rfc_revocation_rules = (
106+
"https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0025-define-"
107+
"criteria-and-removal-process-for-inactive-members.md#remove-the-membership-to-the-cloud-foundry-github-organization"
108+
)
109+
user_tagging_prefix = "@" if tagusers else ""
110+
users_as_list = "\n".join(str(user_tagging_prefix + s) for s in users_to_delete)
111+
return (
112+
f"According to the rolues for inactivity defined in [RFC-0025]({rfc}) following users will be deleted:\n"
113+
f"{users_as_list}\nAccording to the [revocation policy in the RFC]({rfc_revocation_rules}), users have"
114+
" two weeks to refute this revocation, if they wish."
115+
)
116+
117+
@staticmethod
118+
def _get_bool_env_var(env_var_name, default):
119+
return os.getenv(env_var_name, default).lower() == "true"
120+
121+
122+
if __name__ == "__main__":
123+
one_year_back = (datetime.datetime.now() - datetime.timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ")
124+
125+
parser = argparse.ArgumentParser(description="Cloud Foundry Org Inactive User Handler")
126+
parser.add_argument("-goid", "--githuborgid", default="O_kgDOAAl8sg", help="Cloud Foundry Github org ID")
127+
parser.add_argument("-go", "--githuborg", default="cloudfoundry", help="Cloud Foundry Github org name")
128+
parser.add_argument("-sd", "--sincedate", default=one_year_back, help="Since when to analyze in format 'Y-m-dTH:M:SZ'")
129+
parser.add_argument(
130+
"-gt", "--githubtoken", default=os.environ.get("GH_TOKEN"), help="Github API access token. Supported also as env var 'GH_TOKEN'"
131+
)
132+
parser.add_argument(
133+
"-dr",
134+
"--dryrun",
135+
action="store_true",
136+
help="Dry run execution. Supported also as env var 'INACTIVE_USER_MANAGEMENT_DRY_RUN'",
137+
)
138+
parser.add_argument(
139+
"-tu",
140+
"--tagusers",
141+
action="store_true",
142+
help="Tag users to be notified. Supported also as env var 'INACTIVE_USER_MANAGEMENT_TAG_USERS'",
143+
)
144+
args = parser.parse_args()
145+
146+
print("Get information about community users")
147+
generator = OrgGenerator()
148+
generator.load_from_project()
149+
community_members_with_role = generator.get_community_members_with_role()
150+
151+
print("Analyzing Cloud Foundry org user activity.")
152+
userHandler = InactiveUserHandler(args.githuborg, args.githuborgid, args.sincedate, args.githubtoken)
153+
inactive_users = userHandler.get_inactive_users()
154+
155+
print(f"Inactive users length is {len(inactive_users)} and inactive users are {inactive_users}")
156+
users_to_delete = inactive_users - community_members_with_role
157+
tagusers = args.tagusers or InactiveUserHandler._get_bool_env_var("INACTIVE_USER_MANAGEMENT_TAG_USERS", "False")
158+
inactive_users_msg = userHandler.get_inactive_users_msg(users_to_delete, tagusers)
159+
if args.dryrun or InactiveUserHandler._get_bool_env_var("INACTIVE_USER_MANAGEMENT_DRY_RUN", "False"):
160+
print(f"Dry-run mode.\nInactive_users_msg is: {inactive_users_msg}")
161+
print(f"Following users will be deleted: {inactive_users}")
162+
elif users_to_delete:
163+
userHandler.delete_inactive_contributors(users_to_delete)
164+
with open(os.environ["GITHUB_OUTPUT"], "a") as env:
165+
separator = uuid.uuid1()
166+
step_output_name = "inactive_users_pr_description"
167+
print(f"{step_output_name}<<{separator}\n{inactive_users_msg}\n{separator}", file=env)
168+
169+
inactive_users_with_role = community_members_with_role.intersection(inactive_users)
170+
print(f"Inactive users with role length is {len(inactive_users_with_role)} and users are {inactive_users_with_role}")

org/readme.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ Limitations:
7373
- The branchprotector doesn't support wildcards for branch rules. I.e. every version branch gets its own rule.
7474
- The branchprotector doesn't delete unneeded branch protection rules e.g. when a version branch got deleted.
7575

76+
### Inactive User Management
77+
Inactive users according to the criteria defined in
78+
[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.
79+
80+
7681
## Development
7782

7883
Requires Python 3.9.
@@ -82,6 +87,7 @@ How to run locally:
8287
cd ./org
8388
pip install -r requirements.txt
8489
python -m org_management --help
90+
python -m org_user_management --help
8591
```
8692

8793
Usage:
@@ -98,6 +104,28 @@ optional arguments:
98104
output file for generated branch protection rules
99105
```
100106

107+
```
108+
python -m org_user_management --help
109+
usage: org_user_management.py [-h] [-goid GITHUBORGID] [-go GITHUBORG] [-sd SINCEDATE] [-gt GITHUBTOKEN] [-dr DRYRUN] [-tu TAGUSERS]
110+
111+
Cloud Foundry Org Inactive User Handler
112+
113+
options:
114+
-h, --help show this help message and exit
115+
-goid GITHUBORGID, --githuborgid GITHUBORGID
116+
Cloud Foundry Github org ID
117+
-go GITHUBORG, --githuborg GITHUBORG
118+
Cloud Foundry Github org name
119+
-sd SINCEDATE, --sincedate SINCEDATE
120+
Since when to analyze in format 'Y-m-dTH:M:SZ'
121+
-gt GITHUBTOKEN, --githubtoken GITHUBTOKEN
122+
Github API access token. Supported also as env var 'GH_TOKEN'
123+
-dr DRYRUN, --dryrun DRYRUN
124+
Dry run execution. Supported also as env var 'INACTIVE_USER_MANAGEMENT_DRY_RUN'
125+
-tu TAGUSERS, --tagusers TAGUSERS
126+
Tag users to be notified. Supported also as env var 'INACTIVE_USER_MANAGEMENT_TAG_USERS'
127+
```
128+
101129
How to run tests:
102130
```
103131
cd ./org

org/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pyyaml
2-
jsonschema
2+
jsonschema
3+
requests

0 commit comments

Comments
 (0)