44change) and updates the matching line in ROADMAP.md to one of three
55states:
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
1111GitHub's task-list rendering only recognizes [ ] and [x] as real,
1212interactive checkboxes — anything else (like a bare [-]) is just
1313literal text with no checkbox widget. So the checkbox itself stays
1414strictly boolean (done / not done), and "in progress" is layered on
1515as 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+
1721This only touches roadmap lines that already reference an issue, e.g.:
1822
1923 * [ ] Lists — #34
2024
2125Lines with no "— #N" suffix are left alone — link an issue to a lesson
2226by adding that suffix, and the automation picks it up from then on.
2327"""
24-
2528import json
2629import os
2730import re
31+ from typing import NamedTuple
2832
2933ROADMAP_PATH = "ROADMAP.md"
3034WIP_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 .
3439STATE_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) .
4348LINE_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
5070def 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
96118def 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
168206if __name__ == "__main__" :
169- main ()
207+ main ()
0 commit comments