Skip to content

Commit edff37a

Browse files
committed
Added contributor(s) name(s) to ROADMAP.md sync scripts and updated ROADMAP.md to include changes in its "legend map".
1 parent 4ac10dc commit edff37a

3 files changed

Lines changed: 102 additions & 69 deletions

File tree

‎ROADMAP.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ This may be subject to change.
55
`[ ]` - no work has been done
66
`[ ] 🚧` - work in progress (assigned / open PR)
77
`[x]` - lesson completed
8+
`👤 name` - who is working on issue
89

910
## Roadmap
1011
1. Introduction

‎scripts/roadmap/reconcile_roadmap.py‎

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,20 @@
1717
That covers the common paths (assigned, merged-and-closed) even for
1818
history that predates the automation.
1919
"""
20-
2120
import json
2221
import os
2322
import sys
2423
import urllib.error
2524
import urllib.request
2625

2726
sys.path.insert(0, os.path.dirname(__file__))
28-
from sync_roadmap import LINE_RE, ROADMAP_PATH, apply_updates # noqa: E402
27+
from sync_roadmap import ( # noqa: E402
28+
LINE_RE,
29+
ROADMAP_PATH,
30+
Update,
31+
apply_updates,
32+
update_from_issue,
33+
)
2934

3035

3136
def find_linked_issues() -> set[int]:
@@ -53,14 +58,6 @@ def fetch_issue(repo: str, token: str, number: int) -> dict:
5358
return json.load(resp)
5459

5560

56-
def target_state(issue: dict) -> str:
57-
if issue.get("state") == "closed":
58-
return "x"
59-
if issue.get("assignees"):
60-
return "-"
61-
return " "
62-
63-
6461
def main() -> None:
6562
repo = os.environ["GITHUB_REPOSITORY"]
6663
token = os.environ["GITHUB_TOKEN"]
@@ -70,17 +67,14 @@ def main() -> None:
7067
print("No linked issues found in ROADMAP.md.")
7168
return
7269

73-
updates: dict[int, str] = {}
70+
updates: dict[int, Update] = {}
7471
for number in sorted(issue_numbers):
7572
try:
7673
issue = fetch_issue(repo, token, number)
7774
except urllib.error.HTTPError as e:
78-
print(
79-
f"#{number}: could not fetch issue ({e.code}) — skipping",
80-
file=sys.stderr,
81-
)
75+
print(f"#{number}: could not fetch issue ({e.code}) — skipping", file=sys.stderr)
8276
continue
83-
updates[number] = target_state(issue)
77+
updates[number] = update_from_issue(issue)
8478

8579
changed = apply_updates(updates)
8680
if not changed:
@@ -93,4 +87,4 @@ def main() -> None:
9387

9488

9589
if __name__ == "__main__":
96-
main()
90+
main()

‎scripts/roadmap/sync_roadmap.py‎

Lines changed: 90 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,78 +4,100 @@
44
change) and updates the matching line in ROADMAP.md to one of three
55
states:
66
7-
[ ] Lists — #34 not started
8-
[ ] 🚧 Lists — #34 in progress (assigned / open PR)
9-
[x] Lists — #34 done (merged / issue closed)
7+
[ ] Lists — #34 not started
8+
[ ] 🚧 Lists — #34 👤 alice, bob in progress (assigned / open PR)
9+
[x] Lists — #34 done (merged / issue closed)
1010
1111
GitHub's task-list rendering only recognizes [ ] and [x] as real,
1212
interactive checkboxes — anything else (like a bare [-]) is just
1313
literal text with no checkbox widget. So the checkbox itself stays
1414
strictly boolean (done / not done), and "in progress" is layered on
1515
as a 🚧 badge in the visible text instead of a third bracket state.
1616
17+
While a lesson is in progress, the issue's assignees are listed at the
18+
end of the line after a 👤 marker (plain usernames, no @ mentions).
19+
The names are dropped again once the lesson is done or unassigned.
20+
1721
This only touches roadmap lines that already reference an issue, e.g.:
1822
1923
* [ ] Lists — #34
2024
2125
Lines with no "— #N" suffix are left alone — link an issue to a lesson
2226
by adding that suffix, and the automation picks it up from then on.
2327
"""
24-
2528
import json
2629
import os
2730
import re
31+
from typing import NamedTuple
2832

2933
ROADMAP_PATH = "ROADMAP.md"
3034
WIP_BADGE = "🚧"
35+
ASSIGNEE_MARKER = "👤"
3136

32-
# Internal state codes (unchanged from before): " " = not started,
33-
# "-" = in progress, "x" = done. Only how they're RENDERED changes.
37+
# Internal state codes: " " = not started, "-" = in progress, "x" = done.
38+
# Only how they're RENDERED matters to GitHub's markdown.
3439
STATE_RENDER = {
3540
" ": {"check": " ", "badge": ""},
3641
"-": {"check": " ", "badge": f"{WIP_BADGE} "},
3742
"x": {"check": "x", "badge": ""},
3843
}
3944

40-
# Matches e.g. " * [ ] 🚧 Lists — #34" and captures the checkbox,
41-
# an optional WIP badge, and the issue number — everything else on
42-
# the line is left untouched.
45+
# Matches e.g. " * [ ] 🚧 Lists — #34 👤 alice" and captures the checkbox,
46+
# an optional WIP badge, and the issue number. `rest` is everything after
47+
# the issue number (including any assignee suffix, handled separately).
4348
LINE_RE = re.compile(
4449
r"^(?P<prefix>\s*\*\s*\[)(?P<check>[ x])(?P<mid>\]\s*)"
4550
rf"(?P<badge>{WIP_BADGE}\s*)?"
4651
r"(?P<body>.*?—\s*#)(?P<issue>\d+)(?P<rest>.*)$"
4752
)
4853

54+
# The assignee suffix always sits at the very end of the line.
55+
ASSIGNEE_SUFFIX_RE = re.compile(rf"\s+{ASSIGNEE_MARKER}\s+.*$")
56+
57+
# Matches "Closes #12", "Fixes #34", "Resolves #7", etc. (repeatable, case-insensitive)
58+
ISSUE_REF_RE = re.compile(r"(?:clos(?:e[sd]?)|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", re.IGNORECASE)
59+
60+
HANDLED_ISSUE_ACTIONS = {"assigned", "unassigned", "closed", "reopened"}
61+
62+
63+
class Update(NamedTuple):
64+
state: str
65+
# None = keep whatever names are already on the line. PR events use this,
66+
# since a PR payload doesn't carry the linked issue's assignee list.
67+
assignees: list[str] | None = None
68+
4969

5070
def current_state(check: str, badge: str | None) -> str:
5171
if check == "x":
5272
return "x"
5373
return "-" if badge else " "
5474

5575

56-
# Matches "Closes #12", "Fixes #34", "Resolves #7", etc. (repeatable, case-insensitive)
57-
ISSUE_REF_RE = re.compile(
58-
r"(?:clos(?:e[sd]?)|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", re.IGNORECASE
59-
)
76+
def state_for_issue(issue: dict) -> str:
77+
if issue.get("state") == "closed":
78+
return "x"
79+
return "-" if issue.get("assignees") else " "
6080

6181

62-
def marker_for_issue_event(event: dict) -> dict[int, str]:
63-
action = event["action"]
64-
issue_number = event["issue"]["number"]
65-
66-
if action == "assigned":
67-
return {issue_number: "-"}
68-
if action == "unassigned":
69-
return {issue_number: " "}
70-
if action == "closed":
71-
return {issue_number: "x"}
72-
if action == "reopened":
73-
has_assignee = bool(event["issue"].get("assignees"))
74-
return {issue_number: "-" if has_assignee else " "}
75-
return {}
82+
def assignee_logins(issue: dict) -> list[str]:
83+
return [a["login"] for a in issue.get("assignees") or []]
84+
85+
86+
def update_from_issue(issue: dict) -> Update:
87+
"""Works for both webhook payloads and REST API issue objects."""
88+
return Update(state_for_issue(issue), assignee_logins(issue))
89+
90+
91+
def updates_for_issue_event(event: dict) -> dict[int, Update]:
92+
if event["action"] not in HANDLED_ISSUE_ACTIONS:
93+
return {}
94+
issue = event["issue"]
95+
# The issue object reflects the state AFTER the change, so unassigning
96+
# one of two people correctly leaves the line in progress.
97+
return {issue["number"]: update_from_issue(issue)}
7698

7799

78-
def marker_for_pr_event(event: dict) -> dict[int, str]:
100+
def updates_for_pr_event(event: dict) -> dict[int, Update]:
79101
action = event["action"]
80102
pr = event["pull_request"]
81103
body = pr.get("body") or ""
@@ -84,63 +106,79 @@ def marker_for_pr_event(event: dict) -> dict[int, str]:
84106
return {}
85107

86108
if action in ("opened", "ready_for_review"):
87-
marker = "-"
109+
update = Update("-")
88110
elif action == "closed":
89-
marker = "x" if pr.get("merged") else "-"
111+
update = Update("x") if pr.get("merged") else Update("-")
90112
else:
91113
return {}
92114

93-
return {n: marker for n in issue_numbers}
115+
return {n: update for n in issue_numbers}
94116

95117

96118
def load_event() -> dict:
97119
with open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8") as f:
98120
return json.load(f)
99121

100122

101-
def compute_updates() -> dict[int, str]:
123+
def compute_updates() -> dict[int, Update]:
102124
event_name = os.environ["GITHUB_EVENT_NAME"]
103125
event = load_event()
104126

105127
if event_name == "issues":
106-
return marker_for_issue_event(event)
107-
if event_name == "pull_request" or event_name == "pull_request_target":
108-
return marker_for_pr_event(event)
128+
return updates_for_issue_event(event)
129+
if event_name in ("pull_request", "pull_request_target"):
130+
return updates_for_pr_event(event)
109131
return {}
110132

111133

112-
def apply_updates(updates: dict[int, str]) -> bool:
134+
def render_suffix(update: Update, existing_suffix: str) -> str:
135+
if update.state != "-":
136+
return "" # names are only shown while a lesson is in progress
137+
if update.assignees is None:
138+
return existing_suffix
139+
if not update.assignees:
140+
return ""
141+
return f" {ASSIGNEE_MARKER} {', '.join(update.assignees)}"
142+
143+
144+
def apply_updates(updates: dict[int, Update]) -> bool:
113145
with open(ROADMAP_PATH, encoding="utf-8") as f:
114146
lines = f.readlines()
115147

116148
changed = False
117149
for i, line in enumerate(lines):
118-
m = LINE_RE.match(line.rstrip("\n"))
150+
stripped = line.rstrip("\r\n")
151+
eol = line[len(stripped):]
152+
153+
m = LINE_RE.match(stripped)
119154
if not m:
120155
continue
121156

122157
issue = int(m.group("issue"))
123158
if issue not in updates:
124159
continue
125160

126-
new_state = updates[issue]
161+
update = updates[issue]
127162
old_state = current_state(m.group("check"), m.group("badge"))
128-
if old_state == new_state:
163+
render = STATE_RENDER[update.state]
164+
165+
rest = m.group("rest")
166+
existing = ASSIGNEE_SUFFIX_RE.search(rest)
167+
rest_base = ASSIGNEE_SUFFIX_RE.sub("", rest)
168+
suffix = render_suffix(update, existing.group(0) if existing else "")
169+
if suffix:
170+
rest_base = rest_base.rstrip() # no double space before the 👤
171+
172+
new_line = (
173+
m.group("prefix") + render["check"] + m.group("mid") + render["badge"]
174+
+ m.group("body") + m.group("issue") + rest_base + suffix + eol
175+
)
176+
if new_line == line:
129177
continue
130178

131-
render = STATE_RENDER[new_state]
132-
lines[i] = (
133-
m.group("prefix")
134-
+ render["check"]
135-
+ m.group("mid")
136-
+ render["badge"]
137-
+ m.group("body")
138-
+ m.group("issue")
139-
+ m.group("rest")
140-
+ "\n"
141-
)
179+
lines[i] = new_line
142180
changed = True
143-
print(f"#{issue}: [{old_state}] -> [{new_state}]")
181+
print(f"#{issue}: [{old_state}] -> [{update.state}]{suffix}")
144182

145183
if changed:
146184
with open(ROADMAP_PATH, "w", encoding="utf-8") as f:
@@ -166,4 +204,4 @@ def main() -> None:
166204

167205

168206
if __name__ == "__main__":
169-
main()
207+
main()

0 commit comments

Comments
 (0)