main
py 256 lines 7.78 KB
Raw
1 #!/usr/bin/env python3
2 from __future__ import annotations
3
4 import argparse
5 import json
6 import shutil
7 import subprocess # nosec B404
8 from dataclasses import asdict, dataclass
9 from pathlib import Path
10
11
12 @dataclass
13 class CopilotFailure:
14 failure_class: str
15 retryable: bool
16 actionable: bool
17 diagnostic: str
18 exit_code: int | None = None
19
20
21 TOKEN_PATTERNS = (
22 "bad credentials",
23 "invalid token",
24 "expired token",
25 "token expired",
26 "authentication failed",
27 "unauthorized",
28 "copilot_github_token",
29 )
30 INACCESSIBLE_PATTERNS = (
31 "401",
32 "403",
33 "copilot is not available",
34 "copilot unavailable",
35 "permission",
36 "not subscribed",
37 "subscription",
38 "access to copilot",
39 "forbidden",
40 )
41 CONTEXT_PATTERNS = (
42 "context length",
43 "context too large",
44 "maximum context",
45 "token limit",
46 "too many tokens",
47 "prompt is too long",
48 )
49 TIMEOUT_PATTERNS = ("timed out", "timeout", "deadline exceeded")
50 TRANSIENT_PATTERNS = (
51 "rate limit",
52 "429",
53 "500",
54 "502",
55 "503",
56 "504",
57 "econnreset",
58 "network",
59 "temporarily unavailable",
60 "temporary failure",
61 )
62
63
64 def classify_log(log_text: str, exit_code: int | None = None) -> CopilotFailure:
65 normalized = log_text.lower()
66 if "copilot is not available" in normalized or "command not found" in normalized:
67 return CopilotFailure(
68 "copilot_inaccessible",
69 retryable=False,
70 actionable=True,
71 diagnostic="Copilot CLI is unavailable in the runner; install Copilot CLI or verify runner access.",
72 exit_code=exit_code,
73 )
74 if any(pattern in normalized for pattern in TOKEN_PATTERNS):
75 return CopilotFailure(
76 "copilot_token_failure",
77 retryable=False,
78 actionable=True,
79 diagnostic="Copilot authentication/token failure; renew COPILOT_GH_TOKEN.",
80 exit_code=exit_code,
81 )
82 if any(pattern in normalized for pattern in CONTEXT_PATTERNS):
83 return CopilotFailure(
84 "context_too_large",
85 retryable=False,
86 actionable=True,
87 diagnostic="Copilot prompt/context exceeded supported size; reduce analysis context before rerun.",
88 exit_code=exit_code,
89 )
90 if any(pattern in normalized for pattern in TIMEOUT_PATTERNS):
91 return CopilotFailure(
92 "timeout",
93 retryable=True,
94 actionable=False,
95 diagnostic="Copilot analysis timed out; retry is allowed.",
96 exit_code=exit_code,
97 )
98 if any(pattern in normalized for pattern in TRANSIENT_PATTERNS):
99 return CopilotFailure(
100 "transient_error",
101 retryable=True,
102 actionable=False,
103 diagnostic="Copilot analysis hit a transient service/network failure; retry is allowed.",
104 exit_code=exit_code,
105 )
106 if any(pattern in normalized for pattern in INACCESSIBLE_PATTERNS):
107 return CopilotFailure(
108 "copilot_token_failure",
109 retryable=False,
110 actionable=True,
111 diagnostic="Copilot authentication/access failure; renew COPILOT_GH_TOKEN or verify Copilot permissions.",
112 exit_code=exit_code,
113 )
114 return CopilotFailure(
115 "other",
116 retryable=True,
117 actionable=False,
118 diagnostic="Copilot analysis failed with an unclassified error; retry is allowed before failing the run.",
119 exit_code=exit_code,
120 )
121
122
123 def issue_title() -> str:
124 return "Renew GitHub Copilot token for weekly analysis workflow"
125
126
127 def issue_body(report: CopilotFailure, *, week: str, run_id: str) -> str:
128 return "\n".join(
129 [
130 "The weekly analysis workflow cannot run because GitHub Copilot authentication or access failed.",
131 "",
132 f"- Week: `{week}`",
133 f"- Run ID: `{run_id}`",
134 f"- Failure class: `{report.failure_class}`",
135 f"- Diagnostic: {report.diagnostic}",
136 "",
137 "Please renew or replace the `COPILOT_GH_TOKEN` secret, verify Copilot access, then rerun the workflow.",
138 ]
139 )
140
141
142 def run_gh(args: list[str]) -> subprocess.CompletedProcess[str]:
143 gh_path = shutil.which("gh")
144 if gh_path is None:
145 raise RuntimeError("GitHub CLI executable not found on PATH")
146 return subprocess.run([gh_path, *args], check=False, capture_output=True, text=True) # nosec B603
147
148
149 def issue_url(repo: str, number: str) -> str:
150 return f"https://github.com/{repo}/issues/{number}"
151
152
153 def create_or_update_token_issue(
154 report: CopilotFailure, *, repo: str, assignee: str, week: str, run_id: str
155 ) -> str:
156 title = issue_title()
157 body = issue_body(report, week=week, run_id=run_id)
158 search = run_gh(
159 [
160 "issue",
161 "list",
162 "--repo",
163 repo,
164 "--state",
165 "open",
166 "--search",
167 title,
168 "--json",
169 "number,title",
170 "--limit",
171 "10",
172 ]
173 )
174 if search.returncode == 0:
175 try:
176 issues = json.loads(search.stdout)
177 except json.JSONDecodeError:
178 issues = []
179 for issue in issues:
180 if issue.get("title") == title and issue.get("number"):
181 number = str(issue["number"])
182 comment = run_gh(["issue", "comment", number, "--repo", repo, "--body", body])
183 if comment.returncode != 0:
184 raise RuntimeError(
185 comment.stderr.strip() or "failed to update Copilot token issue"
186 )
187 return issue_url(repo, number)
188
189 created = run_gh(
190 [
191 "issue",
192 "create",
193 "--repo",
194 repo,
195 "--title",
196 title,
197 "--body",
198 body,
199 "--assignee",
200 assignee,
201 "--label",
202 "type:bug",
203 ]
204 )
205 if created.returncode != 0:
206 raise RuntimeError(created.stderr.strip() or "failed to create Copilot token issue")
207 created_output = created.stdout.strip()
208 if created_output.startswith("https://"):
209 return created_output
210 return created_output or issue_url(repo, "unknown")
211
212
213 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
214 parser = argparse.ArgumentParser(description="Classify Copilot CLI analysis failures.")
215 parser.add_argument("--log", required=True, type=Path)
216 parser.add_argument("--exit-code", type=int)
217 parser.add_argument("--report-json", type=Path)
218 parser.add_argument("--create-token-issue", action="store_true")
219 parser.add_argument("--repo", default="")
220 parser.add_argument("--assignee", default="jmservera")
221 parser.add_argument("--week", default="")
222 parser.add_argument("--run-id", default="")
223 return parser.parse_args(argv)
224
225
226 def main(argv: list[str] | None = None) -> int:
227 args = parse_args(argv)
228 log_text = args.log.read_text(encoding="utf-8", errors="replace") if args.log.exists() else ""
229 report = classify_log(log_text, args.exit_code)
230 payload = asdict(report)
231 payload["log_path"] = args.log.as_posix()
232
233 if args.create_token_issue and report.failure_class in {
234 "copilot_token_failure",
235 "copilot_inaccessible",
236 }:
237 payload["issue"] = create_or_update_token_issue(
238 report,
239 repo=args.repo,
240 assignee=args.assignee,
241 week=args.week,
242 run_id=args.run_id,
243 )
244
245 if args.report_json:
246 args.report_json.parent.mkdir(parents=True, exist_ok=True)
247 args.report_json.write_text(
248 json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
249 )
250
251 print(report.failure_class)
252 return 0
253
254
255 if __name__ == "__main__":
256 raise SystemExit(main())