Skip to content

Prune inactive org members - #1801

Open
dprotaso wants to merge 2 commits into
mainfrom
prune-inactive-members-2026-10
Open

dprotaso wants to merge 2 commits into
mainfrom
prune-inactive-members-2026-10

Conversation

@dprotaso

@dprotaso dprotaso commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Remove 34 inactive members from the Knative orgs and their team entries. No qualifying activity was found in either org from Oct 29, 2025 to Oct 29, 2026.

Keep admins and service accounts, plus these explicit exceptions:

  • caniszczyk: head of CNCF.
  • jberkus: helps with elections.

The audit checks commits, issues, PRs, reviews, and issue/PR conversation comments using GitHub GraphQL. Activity in either org counts. GitHub can miss some commits and inline review comments, so please review before merging.

Needs attention: discovery Approvers and kn-plugin-source-kamelet Approvers become empty.

Both Peribolos schema checks and git diff --check pass.

Audit script

Save as audit.py alongside the two original YAML files, then run python3 audit.py with an authenticated gh. It only writes local reports. Re-runs use a rolling 12-month window.

Save this as exemptions.json alongside it:

{
  "caniszczyk": "Head of CNCF",
  "jberkus": "Helps with elections"
}
Python activity audit script (expand)
#!/usr/bin/env python3
"""Read-only GitHub organization membership activity audit. Uses authenticated gh."""
import concurrent.futures, datetime as dt, json, pathlib, re, subprocess, time
ROOT = pathlib.Path(__file__).parent
ORGS = ('knative', 'knative-extensions')
EXEMPTIONS = json.loads((ROOT/'exemptions.json').read_text()) if (ROOT/'exemptions.json').exists() else {}
NOW = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
START = NOW.replace(year=NOW.year-1)
iso = lambda d: d.isoformat().replace('+00:00', 'Z')

def api(query):
    for attempt in range(4):
        p = subprocess.run(['gh','api','graphql','-f','query='+query], capture_output=True, text=True)
        try:
            data = json.loads(p.stdout)
            if p.returncode or data.get('errors'):
                raise RuntimeError(p.stderr + json.dumps(data.get('errors')))
            return data['data']
        except (ValueError, RuntimeError):
            if attempt == 3: raise
            time.sleep(2**attempt)

def q(value): return json.dumps(value)

members = {}
for org in ORGS:
    role = None
    for line in (ROOT / (org+'.yaml')).read_text().splitlines():
        if line == '    repos:': break
        if line in ('    admins:', '    members:'): role = line.strip().rstrip(':')
        m = re.fullmatch(r'    - ([\w-]+)', line)
        if m and role:
            name = m[1]
            entry = members.setdefault(name.lower(), {'login':name, 'roles':{}})
            entry['roles'][org] = role
org_ids = api('{ a:organization(login:"knative"){id} b:organization(login:"knative-extensions"){id} }')
ids = [org_ids[x]['id'] for x in ('a','b')]
COUNTS = ['totalCommitContributions','totalIssueContributions','totalPullRequestContributions','totalPullRequestReviewContributions']

def audit(entry):
    result = dict(entry)
    login = entry['login']
    try:
        collections = ' '.join('o%d: contributionsCollection(from:%s,to:%s,organizationID:%s) {%s}' % (i,q(iso(START)),q(iso(NOW)),q(oid),' '.join(COUNTS)) for i,oid in enumerate(ids))
        user = api('{user(login:%s){login %s}}' % (q(login),collections))['user']
        if user is None: raise RuntimeError('Account not found')
        result['resolved_login'] = user['login']
        result['contributions'] = {org:user['o'+str(i)] for i,org in enumerate(ORGS)}
        result['automation_or_service_account'] = bool(re.search(r'robot|automation|\[bot\]|^cncf-ci$|^thelinuxfoundation$',login,re.I))
        if any(user['o'+str(i)][key] for i in range(2) for key in COUNTS):
            result['status'] = 'active'
            result['evidence'] = 'GitHub contribution counts in audit window'
        else:
            # User issueComments includes both issue and PR conversation comments.
            # Order by updatedAt so an old comment edited recently cannot hide newer comments.
            cursor = None
            for page in range(100):
                after = ',after:'+q(cursor) if cursor else ''
                connection = api('{user(login:%s){issueComments(first:100,orderBy:{field:UPDATED_AT,direction:DESC}%s){nodes{createdAt updatedAt url repository{nameWithOwner owner{login}}} pageInfo{hasNextPage endCursor}}}}' % (q(login),after))['user']['issueComments']
                nodes = connection['nodes']
                evidence = next((n for n in nodes if n['repository']['owner']['login'].lower() in ORGS and iso(START) <= n['createdAt'] <= iso(NOW)),None)
                if evidence:
                    result.update(status='active',evidence=evidence['url'], comment_created_at=evidence['createdAt'])
                    break
                if not connection['pageInfo']['hasNextPage'] or (nodes and nodes[-1]['updatedAt'] < iso(START)):
                    result.update(status='candidate', evidence='No qualifying contributions or conversation comments found')
                    break
                cursor = connection['pageInfo']['endCursor']
            else:
                result.update(status='uncertain',evidence='Comment pagination safety limit reached')
        if result.get('automation_or_service_account'):
            result['activity_status'] = result['status']
            result['status'] = 'service-account-review'
        elif 'admins' in entry['roles'].values() and result['status']=='candidate':
            result['status'] = 'admin-review'
    except Exception as exc:
        result.update(status='uncertain',evidence=str(exc))
    if login.lower() in EXEMPTIONS:
        result['activity_status'] = result.get('activity_status', result['status'])
        result['status'] = 'keep-exempt'
        result['exemption_reason'] = EXEMPTIONS[login.lower()]
        result['evidence'] = EXEMPTIONS[login.lower()]
    print(login+': '+result['status'], flush=True)
    (ROOT/'results').mkdir(exist_ok=True)
    (ROOT/'results'/(login+'.json')).write_text(json.dumps(result,indent=2)+'\n')
    return result

if __name__ == '__main__':
    with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
        results = sorted(pool.map(audit,members.values()),key=lambda x:x['login'].lower())
    report = {'started_at':iso(NOW),'cutoff':iso(START),'organizations':ORGS,'results':results}
    (ROOT/'audit.json').write_text(json.dumps(report,indent=2)+'\n')
    lines = ['# Knative membership inactivity dry run','',f'Window: **{iso(START)} through {iso(NOW)}** (12 calendar months).','',
        'Read-only: no membership, team, or Peribolos changes were made.', '',
        'Activity in either organization retains the account in both. Counts include GitHub-recognized commits, issues opened, PRs opened, and PR reviews; issue/PR conversation comments are checked separately when contribution counts are zero. Comment creation, not edits, counts.', '',
        '## Limitations and safeguards','',
        '- Only activity visible to the authenticated GitHub account is covered. Deleted repositories/comments and unlinked commits cannot be verified.',
        '- GitHub commit contribution counts generally cover default/gh-pages branches and depend on GitHub attribution rules; they do not prove the absence of all commits on other branches.',
        '- Standalone inline PR review comments not represented by a submitted review or conversation comment may be missed. Candidates require human review, not automatic removal.',
        '- Admins and likely service/automation accounts are separated for explicit review; account classification is name-based, not authoritative.',
        '- Team memberships/references were not changed or validated. Removing an org member may require cleaning team entries too.',
        '- Evidence is a qualifying activity, not necessarily the most recent activity. No historical last-active date is claimed for candidates.', '', '## Summary','']
    for status in ('candidate','admin-review','service-account-review','keep-exempt','active','uncertain'):
        subset = [r for r in results if r['status']==status]
        lines += [f'- **{status}**: {len(subset)}']
    for status in ('candidate','admin-review','service-account-review','keep-exempt','uncertain','active'):
        lines += ['',f'## {status}','','| Account | Configured membership | Evidence |','| --- | --- | --- |']
        for r in results:
            if r['status']!=status: continue
            roles = ', '.join(org+' ('+role+')' for org,role in r['roles'].items())
            evidence = r['evidence'].replace('|','\\|').replace('\n',' ')
            if r.get('contributions') and (status=='active' or r.get('activity_status')=='active'):
                counts = {key:sum(c[key] for c in r['contributions'].values()) for key in COUNTS}
                evidence += '; '+', '.join(k.replace('total','').replace('Contributions','')+': '+str(v) for k,v in counts.items())
            lines += [f"| [{r['login']}](https://github.com/{r['login']}) | {roles} | {evidence} |"]
    (ROOT/'report.md').write_text('\n'.join(lines)+'\n')
    print('Report: '+str(ROOT/'report.md'))

@knative-prow

knative-prow Bot commented Oct 30, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: dprotaso

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@knative-prow knative-prow Bot added approved Indicates a PR has been approved by an approver from all required OWNERS files. size/M Denotes a PR that changes 30-99 lines, ignoring generated files. labels Oct 30, 2026
@dprotaso dprotaso changed the title Prune inactive regular members from Knative organizations Prune inactive org members Oct 30, 2026
@knative-prow knative-prow Bot added size/L Denotes a PR that changes 100-499 lines, ignoring generated files. and removed size/M Denotes a PR that changes 30-99 lines, ignoring generated files. labels Oct 30, 2026
@dprotaso

Copy link
Copy Markdown
Member Author

/hold

@knative-prow knative-prow Bot added the do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. label Oct 30, 2026
@evankanderson

Copy link
Copy Markdown
Member

Does removal of discovery (eventing) and kamelet plugin (client) approvers simply mean that ownership falls back to the respective WGs? The OWNERS files suggest that, so empty groups should be okay.

@evankanderson

Copy link
Copy Markdown
Member

/lgtm

@knative-prow knative-prow Bot added the lgtm Indicates that a PR is ready to be merged. label Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files. do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. lgtm Indicates that a PR is ready to be merged. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants