Skip to content

Commit 27f68e3

Browse files
committed
build: update license workflow
1 parent d365749 commit 27f68e3

3 files changed

Lines changed: 284 additions & 0 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
#!/usr/bin/env python3
2+
"""Generate the LICENCE.txt copyright block from merged pull requests."""
3+
4+
from __future__ import annotations
5+
6+
import json
7+
import os
8+
import re
9+
import sys
10+
import urllib.error
11+
import urllib.request
12+
from collections import defaultdict
13+
from datetime import datetime, timezone
14+
from pathlib import Path
15+
from typing import TypedDict
16+
17+
18+
BASE_BRANCH = "develop"
19+
MIN_MERGED_PULL_REQUESTS = 2
20+
MIN_ADDITIONS = 100
21+
MANUAL_CONTRIBUTORS = {"Suehiro": 2016}
22+
EXCLUDED_LOGINS = {"lint-action", "ppitulaj"}
23+
24+
QUERY = """
25+
query($owner: String!, $name: String!, $branch: String!, $cursor: String) {
26+
repository(owner: $owner, name: $name) {
27+
pullRequests(
28+
first: 100
29+
after: $cursor
30+
states: [MERGED]
31+
baseRefName: $branch
32+
) {
33+
pageInfo { hasNextPage endCursor }
34+
nodes {
35+
additions
36+
mergedAt
37+
author { __typename login }
38+
}
39+
}
40+
}
41+
}
42+
"""
43+
44+
45+
class Stats(TypedDict):
46+
login: str
47+
pull_requests: int
48+
additions: int
49+
first_merged_at: str
50+
51+
52+
def graphql(token: str, variables: dict[str, object]) -> dict:
53+
request = urllib.request.Request(
54+
"https://api.github.com/graphql",
55+
data=json.dumps({"query": QUERY, "variables": variables}).encode(),
56+
headers={
57+
"Accept": "application/vnd.github+json",
58+
"Authorization": f"Bearer {token}",
59+
"Content-Type": "application/json",
60+
"User-Agent": "ikemen-go-licence-generator",
61+
},
62+
method="POST",
63+
)
64+
65+
try:
66+
with urllib.request.urlopen(request, timeout=60) as response:
67+
result = json.load(response)
68+
except urllib.error.HTTPError as exc:
69+
details = exc.read().decode("utf-8", errors="replace")
70+
raise RuntimeError(f"GitHub API returned HTTP {exc.code}: {details}") from exc
71+
except urllib.error.URLError as exc:
72+
raise RuntimeError(f"GitHub API request failed: {exc}") from exc
73+
74+
if result.get("errors"):
75+
raise RuntimeError(f"GitHub API returned errors: {result['errors']}")
76+
return result["data"]
77+
78+
79+
def fetch_stats(token: str, owner: str, repository_name: str) -> dict[str, Stats]:
80+
stats: dict[str, Stats] = {}
81+
excluded = {login.casefold() for login in EXCLUDED_LOGINS}
82+
cursor: str | None = None
83+
84+
while True:
85+
data = graphql(
86+
token,
87+
{
88+
"owner": owner,
89+
"name": repository_name,
90+
"branch": BASE_BRANCH,
91+
"cursor": cursor,
92+
},
93+
)
94+
repository = data.get("repository")
95+
if repository is None:
96+
raise RuntimeError(f"Repository {owner}/{repository_name} was not found")
97+
98+
pull_requests = repository["pullRequests"]
99+
for pull_request in pull_requests["nodes"]:
100+
author = pull_request["author"]
101+
merged_at = pull_request["mergedAt"]
102+
if author is None or merged_at is None:
103+
continue
104+
105+
login = author["login"]
106+
key = login.casefold()
107+
if (
108+
author["__typename"] == "Bot"
109+
or key.endswith("[bot]")
110+
or key in excluded
111+
):
112+
continue
113+
114+
contributor = stats.setdefault(
115+
key,
116+
{
117+
"login": login,
118+
"pull_requests": 0,
119+
"additions": 0,
120+
"first_merged_at": merged_at,
121+
},
122+
)
123+
contributor["login"] = login
124+
contributor["pull_requests"] += 1
125+
contributor["additions"] += pull_request["additions"]
126+
contributor["first_merged_at"] = min(
127+
contributor["first_merged_at"],
128+
merged_at,
129+
)
130+
131+
page_info = pull_requests["pageInfo"]
132+
if not page_info["hasNextPage"]:
133+
return stats
134+
cursor = page_info["endCursor"]
135+
136+
137+
def qualifies(contributor: Stats) -> bool:
138+
return (
139+
contributor["pull_requests"] >= MIN_MERGED_PULL_REQUESTS
140+
and contributor["additions"] >= MIN_ADDITIONS
141+
)
142+
143+
144+
def generate_notice(stats: dict[str, Stats]) -> tuple[str, list[Stats], list[Stats]]:
145+
included = [contributor for contributor in stats.values() if qualifies(contributor)]
146+
skipped = [contributor for contributor in stats.values() if not qualifies(contributor)]
147+
names_by_year: dict[int, list[str]] = defaultdict(list)
148+
149+
for login, year in MANUAL_CONTRIBUTORS.items():
150+
names_by_year[year].append(login)
151+
152+
manual_logins = {login.casefold() for login in MANUAL_CONTRIBUTORS}
153+
for contributor in included:
154+
if contributor["login"].casefold() not in manual_logins:
155+
year = int(contributor["first_merged_at"][:4])
156+
names_by_year[year].append(contributor["login"])
157+
158+
lines = []
159+
for year in sorted(names_by_year):
160+
names = sorted(set(names_by_year[year]), key=str.casefold)
161+
lines.append(f"Copyright (c) {year} {', '.join(names)}")
162+
163+
current_year = datetime.now(timezone.utc).year
164+
lines.append(f"Copyright (c) 2016-{current_year} Ikemen GO contributors")
165+
return "\n".join(lines), included, skipped
166+
167+
168+
def update_licence(notice: str) -> bool:
169+
path = Path("LICENCE.txt")
170+
text = path.read_text(encoding="utf-8")
171+
pattern = re.compile(
172+
r"\AMIT License\r?\n\r?\n"
173+
r"(?:Copyright \(c\)[^\r\n]*\r?\n)+"
174+
r"\r?\n(?=Permission is hereby granted)",
175+
)
176+
updated, replacements = pattern.subn(
177+
f"MIT License\n\n{notice}\n\n",
178+
text,
179+
count=1,
180+
)
181+
if replacements != 1:
182+
raise RuntimeError("Could not find the copyright block in LICENCE.txt")
183+
if updated == text:
184+
return False
185+
186+
path.write_text(updated, encoding="utf-8", newline="\n")
187+
return True
188+
189+
190+
def print_audit(included: list[Stats], skipped: list[Stats]) -> None:
191+
print(
192+
f"Policy: >= {MIN_MERGED_PULL_REQUESTS} merged PRs to {BASE_BRANCH} "
193+
f"and >= {MIN_ADDITIONS} cumulative additions."
194+
)
195+
for heading, contributors in (
196+
("Included", included),
197+
("Covered only by the collective notice", skipped),
198+
):
199+
print(f"\n{heading}:")
200+
for contributor in sorted(contributors, key=lambda item: item["login"].casefold()):
201+
year = contributor["first_merged_at"][:4]
202+
print(
203+
f" {contributor['login']}: {contributor['pull_requests']} PRs, "
204+
f"{contributor['additions']} additions, first merged {year}"
205+
)
206+
207+
208+
def main() -> int:
209+
token = os.environ.get("GITHUB_TOKEN")
210+
repository = os.environ.get("GITHUB_REPOSITORY", "ikemen-engine/Ikemen-GO")
211+
if not token:
212+
print("GITHUB_TOKEN is required", file=sys.stderr)
213+
return 2
214+
215+
try:
216+
owner, repository_name = repository.split("/", 1)
217+
stats = fetch_stats(token, owner, repository_name)
218+
notice, included, skipped = generate_notice(stats)
219+
changed = update_licence(notice)
220+
except (RuntimeError, ValueError, KeyError) as exc:
221+
print(exc, file=sys.stderr)
222+
return 1
223+
224+
print_audit(included, skipped)
225+
print("\nLICENCE.txt updated." if changed else "\nLICENCE.txt is already current.")
226+
return 0
227+
228+
229+
if __name__ == "__main__":
230+
raise SystemExit(main())

.github/workflows/lint-code-style.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ on:
22
workflow_dispatch:
33
push:
44
branches: [ develop, 'release/**' ]
5+
paths-ignore: [ LICENCE.txt ]
56
#pull_request_target:
67
# branches: [ develop, 'release/**' ]
78

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
on:
2+
workflow_dispatch:
3+
workflow_run:
4+
workflows: [ lint-code-style ]
5+
branches: [ develop ]
6+
types: [ completed ]
7+
8+
permissions:
9+
contents: read
10+
pull-requests: read
11+
12+
name: update-licence
13+
14+
concurrency:
15+
group: update-licence-develop
16+
cancel-in-progress: true
17+
18+
jobs:
19+
update-licence:
20+
name: update contributors
21+
if: >-
22+
github.event_name == 'workflow_dispatch' ||
23+
github.event.workflow_run.conclusion == 'success'
24+
runs-on: ubuntu-latest
25+
steps:
26+
- name: Check out develop
27+
uses: actions/checkout@v7
28+
with:
29+
ref: develop
30+
fetch-depth: 0
31+
token: ${{ secrets.IKEMEN_TOKEN }}
32+
33+
- name: Update LICENCE.txt
34+
env:
35+
GITHUB_TOKEN: ${{ github.token }}
36+
run: python3 .github/scripts/update_licence_contributors.py
37+
38+
- name: Commit LICENCE.txt
39+
shell: bash
40+
run: |
41+
set -euo pipefail
42+
43+
if git diff --quiet -- LICENCE.txt; then
44+
echo "LICENCE.txt is already current."
45+
exit 0
46+
fi
47+
48+
git config user.name "github-actions[bot]"
49+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
50+
git add LICENCE.txt
51+
git commit -m "docs: update licence contributors"
52+
git pull --rebase origin develop
53+
git push origin HEAD:develop

0 commit comments

Comments
 (0)