Skip to content

Commit a4bddca

Browse files
Add CI scripts: diff, sort, reconcile, removal detection (#104)
## Summary Four new/updated scripts for the registry CI pipeline: - **`scripts/diff-groups.py`** — diffs YAML between base and PR branch, outputs Markdown plan comment (no AWS/Google creds needed) - **`scripts/sort-members.py`** — sorts members.yaml by group priority (drift → styret → pkom → kodesmia → region → javazone → other) with section headers - **`scripts/reconcile-groups.py`** — two-phase reconciliation: `--plan` queries live Google Workspace + outputs plan, `--apply` executes (suspend accounts, remove memberships). Safety limit of 10 suspensions without `--force`. - **`scripts/provision-groups.py`** — now accepts `--prev-*` flags to detect removals at merge time and post warnings to Slack **Companion PR:** javaBin/registry feat/ci-plan-sort-reconcile (workflows that call these scripts) ## Test plan - [ ] Platform CI passes - [ ] Merge this first, then registry PR - [ ] Test PR plan comment on a registry PR - [ ] Test sort on a PR with members - [ ] Test reconcile plan mode manually
1 parent 22f0f73 commit a4bddca

4 files changed

Lines changed: 816 additions & 24 deletions

File tree

scripts/diff-groups.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
#!/usr/bin/env python3
2+
"""Diff groups/ YAML files between base and PR branch, output a Markdown plan comment.
3+
4+
Compares members.yaml, groups.yaml, and access.yaml to show what will change
5+
when the PR is merged. Pure YAML diffing — no AWS or Google credentials needed.
6+
7+
Usage:
8+
diff-groups.py --base-members BASE --pr-members PR \
9+
--base-groups BASE --pr-groups PR \
10+
--base-access BASE --pr-access PR
11+
12+
Output: Markdown to stdout, suitable for a GitHub PR comment.
13+
"""
14+
15+
import argparse
16+
import sys
17+
18+
import yaml
19+
20+
21+
def load_yaml(path):
22+
try:
23+
with open(path) as f:
24+
return yaml.safe_load(f) or {}
25+
except FileNotFoundError:
26+
return {}
27+
28+
29+
def diff_members(base_data, pr_data):
30+
base_members = {m["personal_email"].lower(): m for m in (base_data.get("members") or []) if m.get("personal_email")}
31+
pr_members = {m["personal_email"].lower(): m for m in (pr_data.get("members") or []) if m.get("personal_email")}
32+
33+
added = [pr_members[e] for e in sorted(pr_members.keys() - base_members.keys())]
34+
removed = [base_members[e] for e in sorted(base_members.keys() - pr_members.keys())]
35+
36+
changed = []
37+
for email in sorted(base_members.keys() & pr_members.keys()):
38+
b, p = base_members[email], pr_members[email]
39+
diffs = []
40+
for field in ["firstname", "lastname", "javabin_google_email", "alias"]:
41+
bv, pv = b.get(field, ""), p.get(field, "")
42+
if bv != pv:
43+
diffs.append(f"`{field}`: {bv!r}{pv!r}")
44+
b_groups = set(b.get("memberships") or [])
45+
p_groups = set(p.get("memberships") or [])
46+
if b_groups != p_groups:
47+
added_g = p_groups - b_groups
48+
removed_g = b_groups - p_groups
49+
parts = []
50+
if added_g:
51+
parts.append(f"+{', '.join(sorted(added_g))}")
52+
if removed_g:
53+
parts.append(f"-{', '.join(sorted(removed_g))}")
54+
diffs.append(f"`memberships`: {' '.join(parts)}")
55+
if diffs:
56+
changed.append({"member": p, "diffs": diffs})
57+
58+
return added, removed, changed
59+
60+
61+
def diff_groups(base_data, pr_data):
62+
base_groups = {g["name"]: g for g in (base_data.get("groups") or [])}
63+
pr_groups = {g["name"]: g for g in (pr_data.get("groups") or [])}
64+
65+
added = [pr_groups[n] for n in sorted(pr_groups.keys() - base_groups.keys())]
66+
removed = [base_groups[n] for n in sorted(base_groups.keys() - pr_groups.keys())]
67+
68+
changed = []
69+
for name in sorted(base_groups.keys() & pr_groups.keys()):
70+
b, p = base_groups[name], pr_groups[name]
71+
diffs = []
72+
for field in ["google", "cognito", "identity_center", "permission_set"]:
73+
bv, pv = b.get(field), p.get(field)
74+
if bv != pv:
75+
diffs.append(f"`{field}`: {bv!r}{pv!r}")
76+
if diffs:
77+
changed.append({"name": name, "diffs": diffs})
78+
79+
return added, removed, changed
80+
81+
82+
def diff_access(base_data, pr_data):
83+
base_ag = {a["name"]: set(a.get("groups", [])) for a in (base_data.get("access_groups") or [])}
84+
pr_ag = {a["name"]: set(a.get("groups", [])) for a in (pr_data.get("access_groups") or [])}
85+
86+
lines = []
87+
all_names = sorted(set(base_ag) | set(pr_ag))
88+
for name in all_names:
89+
bg = base_ag.get(name, set())
90+
pg = pr_ag.get(name, set())
91+
if bg != pg:
92+
added = pg - bg
93+
removed = bg - pg
94+
parts = []
95+
if added:
96+
parts.append(f"+{', '.join(sorted(added))}")
97+
if removed:
98+
parts.append(f"-{', '.join(sorted(removed))}")
99+
if name not in base_ag:
100+
parts = ["new"]
101+
elif name not in pr_ag:
102+
parts = ["removed"]
103+
lines.append(f"- **{name}**: {' '.join(parts)}")
104+
return lines
105+
106+
107+
def resolve_membership_changes(base_members_data, pr_members_data, base_groups_data, pr_groups_data):
108+
"""Show per-group membership additions/removals."""
109+
all_group_names = sorted(
110+
{g["name"] for g in (base_groups_data.get("groups") or [])}
111+
| {g["name"] for g in (pr_groups_data.get("groups") or [])}
112+
)
113+
114+
base_members = base_members_data.get("members") or []
115+
pr_members = pr_members_data.get("members") or []
116+
117+
def members_of(members_list, group_name):
118+
return {
119+
m["javabin_google_email"]
120+
for m in members_list
121+
if m.get("javabin_google_email") and group_name in (m.get("memberships") or [])
122+
}
123+
124+
lines = []
125+
for gname in all_group_names:
126+
base_set = members_of(base_members, gname)
127+
pr_set = members_of(pr_members, gname)
128+
added = sorted(pr_set - base_set)
129+
removed = sorted(base_set - pr_set)
130+
if added or removed:
131+
parts = []
132+
for e in added:
133+
parts.append(f" - +{e}")
134+
for e in removed:
135+
parts.append(f" - -{e} *(additive-only — will not auto-remove)*")
136+
lines.append(f"- **{gname}**:")
137+
lines.extend(parts)
138+
return lines
139+
140+
141+
def format_markdown(added, removed, changed, group_added, group_removed, group_changed,
142+
access_lines, membership_lines):
143+
lines = ["<!-- groups-plan -->", "## Provisioning Plan", ""]
144+
145+
has_content = False
146+
147+
if added:
148+
has_content = True
149+
lines.append(f"### New Members ({len(added)})")
150+
lines.append("| Name | Email | Groups |")
151+
lines.append("|------|-------|--------|")
152+
for m in added:
153+
name = f"{m.get('firstname', '')} {m.get('lastname', '')}"
154+
groups = ", ".join(m.get("memberships") or [])
155+
lines.append(f"| {name} | {m.get('javabin_google_email', '')} | {groups} |")
156+
lines.append("")
157+
158+
if removed:
159+
has_content = True
160+
lines.append(f"### Removed Members ({len(removed)}) — manual reconcile required")
161+
lines.append("| Name | Email | Groups |")
162+
lines.append("|------|-------|--------|")
163+
for m in removed:
164+
name = f"{m.get('firstname', '')} {m.get('lastname', '')}"
165+
groups = ", ".join(m.get("memberships") or [])
166+
lines.append(f"| {name} | {m.get('javabin_google_email', '')} | {groups} |")
167+
lines.append("")
168+
169+
if changed:
170+
has_content = True
171+
lines.append(f"### Changed Members ({len(changed)})")
172+
for c in changed:
173+
m = c["member"]
174+
name = f"{m.get('firstname', '')} {m.get('lastname', '')}"
175+
lines.append(f"- **{name}** ({m.get('javabin_google_email', '')})")
176+
for d in c["diffs"]:
177+
lines.append(f" - {d}")
178+
lines.append("")
179+
180+
if group_added or group_removed or group_changed:
181+
has_content = True
182+
lines.append("### Group Definition Changes")
183+
for g in group_added:
184+
lines.append(f"- **{g['name']}**: new group ({g.get('google', '')})")
185+
for g in group_removed:
186+
lines.append(f"- **{g['name']}**: removed")
187+
for g in group_changed:
188+
lines.append(f"- **{g['name']}**: {', '.join(g['diffs'])}")
189+
lines.append("")
190+
191+
if access_lines:
192+
has_content = True
193+
lines.append("### Access Group Changes")
194+
lines.extend(access_lines)
195+
lines.append("")
196+
197+
if membership_lines:
198+
has_content = True
199+
lines.append("### Group Membership Changes")
200+
lines.extend(membership_lines)
201+
lines.append("")
202+
203+
if not has_content:
204+
lines.append("No provisioning changes detected.")
205+
lines.append("")
206+
207+
return "\n".join(lines)
208+
209+
210+
def main():
211+
parser = argparse.ArgumentParser(description="Diff groups/ YAML for PR plan comment")
212+
parser.add_argument("--base-members", required=True)
213+
parser.add_argument("--pr-members", required=True)
214+
parser.add_argument("--base-groups", required=True)
215+
parser.add_argument("--pr-groups", required=True)
216+
parser.add_argument("--base-access", required=True)
217+
parser.add_argument("--pr-access", required=True)
218+
args = parser.parse_args()
219+
220+
base_members = load_yaml(args.base_members)
221+
pr_members = load_yaml(args.pr_members)
222+
base_groups = load_yaml(args.base_groups)
223+
pr_groups = load_yaml(args.pr_groups)
224+
base_access = load_yaml(args.base_access)
225+
pr_access = load_yaml(args.pr_access)
226+
227+
added, removed, changed = diff_members(base_members, pr_members)
228+
group_added, group_removed, group_changed = diff_groups(base_groups, pr_groups)
229+
access_lines = diff_access(base_access, pr_access)
230+
membership_lines = resolve_membership_changes(base_members, pr_members, base_groups, pr_groups)
231+
232+
md = format_markdown(added, removed, changed, group_added, group_removed, group_changed,
233+
access_lines, membership_lines)
234+
print(md)
235+
236+
237+
if __name__ == "__main__":
238+
main()

0 commit comments

Comments
 (0)