2121from __future__ import annotations
2222
2323import json
24+ import os
2425import subprocess
2526import sys
2627from datetime import datetime , timezone
3940IssueAction = Literal ["created" , "updated" , "reopened" , "closed" , "no_op" ]
4041
4142
42- # Runner type: takes a list of args, returns (rc, stdout, stderr).
43- GhRunner = Callable [[Sequence [str ]], "tuple[int, str, str]" ]
43+ # GitHub rejects issue bodies over 65,536 characters. Leave headroom for
44+ # the truncation notice.
45+ MAX_BODY_CHARS = 60_000
46+ GH_TIMEOUT_SECONDS = 120
47+
48+ # Runner type: takes a list of args plus an optional ``stdin`` keyword
49+ # (used for ``--body-file -``), returns (rc, stdout, stderr).
50+ GhRunner = Callable [..., "tuple[int, str, str]" ]
51+
52+
53+ def make_gh_runner (gh_token : str | None = None ) -> GhRunner :
54+ """Build the default subprocess runner. When *gh_token* is given it is
55+ exported as ``GH_TOKEN`` for every ``gh`` call, so the token the caller
56+ supplied is the one used for writes (not whatever ambient login or
57+ ``GITHUB_TOKEN`` happens to exist)."""
58+ env = {** os .environ }
59+ if gh_token :
60+ env ["GH_TOKEN" ] = gh_token
61+
62+ def _run (args : Sequence [str ], stdin : str | None = None ) -> "tuple[int, str, str]" :
63+ try :
64+ result = subprocess .run (
65+ ["gh" , * args ],
66+ input = stdin ,
67+ capture_output = True ,
68+ text = True ,
69+ env = env ,
70+ timeout = GH_TIMEOUT_SECONDS ,
71+ )
72+ except subprocess .TimeoutExpired :
73+ return 124 , "" , f"gh { ' ' .join (args [:2 ])} timed out after { GH_TIMEOUT_SECONDS } s"
74+ return result .returncode , result .stdout , result .stderr
75+
76+ return _run
4477
4578
46- def _default_gh_runner (args : Sequence [str ]) -> "tuple[int, str, str]" :
47- """Default subprocess runner: invokes ``gh`` with the given args."""
48- result = subprocess .run (
49- ["gh" , * args ],
50- capture_output = True ,
51- text = True ,
52- )
53- return result .returncode , result .stdout , result .stderr
79+ def _default_gh_runner (args : Sequence [str ], stdin : str | None = None ) -> "tuple[int, str, str]" :
80+ """Default runner using the ambient ``gh`` authentication."""
81+ return make_gh_runner ()(args , stdin = stdin )
82+
83+
84+ def _truncate_body (body : str ) -> str :
85+ if len (body ) <= MAX_BODY_CHARS :
86+ return body
87+ notice = "\n " .join ([
88+ "" ,
89+ "" ,
90+ "---" ,
91+ "" ,
92+ "_Report truncated to fit GitHub's issue size limit. "
93+ "The full report is in the drift-check workflow run summary._" ,
94+ "" ,
95+ ])
96+ return body [: MAX_BODY_CHARS - len (notice )] + notice
5497
5598
5699def _has_blocking_findings (findings : Iterable [Finding ]) -> bool :
57100 """Anything that should make the sticky issue exist (open). ``info`` is
58- intentionally excluded — info findings do not justify a sticky issue."""
101+ intentionally excluded; info findings do not justify a sticky issue."""
59102 return any (f .severity in ("error" , "warn" ) for f in findings )
60103
61104
@@ -95,23 +138,28 @@ def _find_sticky_issue(
95138 repo : str ,
96139 runner : GhRunner ,
97140) -> Optional [dict ]:
98- """Search by marker. Returns the first matching issue (open OR
99- closed) , or None.
141+ """Search by marker. Returns the first issue (open OR closed) whose
142+ body actually contains the marker , or None.
100143
101- The ``gh issue list --search`` matches the body, which is what we
102- want — title may be edited by humans, marker is structural."""
144+ ``gh issue list --search`` is a tokenized full-text search, so it can
145+ return near-misses; each candidate's body is checked for the exact
146+ marker. Title is not used because humans may edit it; the marker is
147+ structural."""
103148 rc , out , err = runner ([
104149 "issue" , "list" ,
105150 "--repo" , repo ,
106151 "--state" , "all" ,
107152 "--search" , STICKY_MARKER ,
108- "--json" , "number,state,title,url" ,
109- "--limit" , "5 " ,
153+ "--json" , "number,state,title,url,body " ,
154+ "--limit" , "20 " ,
110155 ])
111156 if rc != 0 :
112157 raise RuntimeError (f"gh issue list failed: { err .strip () or out .strip ()} " )
113158 items = json .loads (out or "[]" )
114- return items [0 ] if items else None
159+ for item in items :
160+ if STICKY_MARKER in (item .get ("body" ) or "" ):
161+ return item
162+ return None
115163
116164
117165def upsert_sticky_issue (
@@ -122,12 +170,14 @@ def upsert_sticky_issue(
122170 repo : str = "TMHSDigital/Developer-Tools-Directory" ,
123171 runner : GhRunner | None = None ,
124172 now : datetime | None = None ,
173+ gh_token : str | None = None ,
125174) -> "tuple[IssueAction, Optional[str]]" :
126175 """Run the sticky-issue state machine.
127176
128177 Returns ``(action, issue_url)``. ``issue_url`` is None for ``no_op``.
178+ *gh_token*, when given, authenticates the default runner.
129179 """
130- runner = runner or _default_gh_runner
180+ runner = runner or make_gh_runner ( gh_token )
131181 findings = list (findings )
132182 snapshots = list (snapshots )
133183
@@ -144,7 +194,7 @@ def upsert_sticky_issue(
144194 return "closed" , existing ["url" ]
145195 return "no_op" , existing ["url" ] if existing else None
146196
147- body = _build_issue_body (snapshots , findings , meta_commit , now = now )
197+ body = _truncate_body ( _build_issue_body (snapshots , findings , meta_commit , now = now ) )
148198
149199 if existing is None :
150200 url = _gh_create (repo , STICKY_TITLE , body , runner )
@@ -171,8 +221,8 @@ def _gh_create(repo: str, title: str, body: str, runner: GhRunner) -> str:
171221 "issue" , "create" ,
172222 "--repo" , repo ,
173223 "--title" , title ,
174- "--body" , body ,
175- ])
224+ "--body-file " , "-" ,
225+ ], stdin = body )
176226 if rc != 0 :
177227 raise RuntimeError (f"gh issue create failed: { err .strip () or out .strip ()} " )
178228 # gh prints the issue URL on stdout.
@@ -183,8 +233,8 @@ def _gh_edit(repo: str, number: int, body: str, runner: GhRunner) -> None:
183233 rc , out , err = runner ([
184234 "issue" , "edit" , str (number ),
185235 "--repo" , repo ,
186- "--body" , body ,
187- ])
236+ "--body-file " , "-" ,
237+ ], stdin = body )
188238 if rc != 0 :
189239 raise RuntimeError (f"gh issue edit failed: { err .strip () or out .strip ()} " )
190240
@@ -211,7 +261,7 @@ def _gh_comment(repo: str, number: int, body: str, runner: GhRunner) -> None:
211261 rc , out , err = runner ([
212262 "issue" , "comment" , str (number ),
213263 "--repo" , repo ,
214- "--body" , body ,
215- ])
264+ "--body-file " , "-" ,
265+ ], stdin = body )
216266 if rc != 0 :
217267 raise RuntimeError (f"gh issue comment failed: { err .strip () or out .strip ()} " )
0 commit comments