triage_issue.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. #!/usr/bin/env python3
  2. """Produce an issue-triage verdict with tool-less Anthropic Messages API calls.
  3. Design rationale
  4. ----------------
  5. This script holds ANTHROPIC_API_KEY, but the model it calls is given NO tools. A
  6. prompt injection in untrusted issue content therefore has no mechanism to read the
  7. runner environment or filesystem (e.g. /proc/self/environ) or to post anything; the
  8. model can only return text. All repo/issue context is gathered deterministically by
  9. the workflow and this script, and passed in as data. The comment is posted by a
  10. separate job that holds the GitHub token but no Anthropic key, so the secret and the
  11. write capability are never co-located.
  12. Two calls are made:
  13. 1. The model proposes a few short keyword queries for finding duplicates. The script
  14. (not the model) sanitizes them and runs `gh search issues` with a read-only token,
  15. pinned to this repository. The worst an injection can do here is pick a poor query.
  16. 2. The model returns the verdict, given the issue, templates, releases, and the
  17. duplicate candidates (with truncated bodies).
  18. The prompts live in the workflow (TRIAGE_PROMPT, QUERY_PROMPT) so they are reviewed
  19. alongside the workflow; this script only supplies the gathered context as data.
  20. Output: a single line of JSON on stdout: {"should_comment": bool, "body": str}.
  21. Fails closed (no comment) on any API or parse error; exits non-zero on configuration
  22. errors (missing key/prompt, 4xx from the API) so they are not silently swallowed.
  23. """
  24. import json
  25. import os
  26. import pathlib
  27. import subprocess
  28. import sys
  29. import urllib.error
  30. import urllib.request
  31. API_URL = "https://api.anthropic.com/v1/messages"
  32. MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-5")
  33. MAX_QUERIES = 3
  34. MAX_QUERY_LEN = 80
  35. RESULTS_PER_QUERY = 5
  36. MAX_CANDIDATES = 8
  37. CANDIDATE_BODY_CHARS = 1500
  38. class APIError(Exception):
  39. def __init__(self, msg: str, fatal: bool = False):
  40. super().__init__(msg)
  41. self.fatal = fatal
  42. def _read(path: str) -> str:
  43. if not path:
  44. return ""
  45. try:
  46. return pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
  47. except OSError:
  48. return ""
  49. def _fail_closed(msg: str, fatal: bool = False) -> None:
  50. level = "error" if fatal else "warning"
  51. sys.stderr.write(f"::{level}::triage: {msg}\n")
  52. print(json.dumps({"should_comment": False, "body": ""}))
  53. if fatal:
  54. sys.exit(1)
  55. def _load_issue() -> dict:
  56. # Read the issue from the event payload on disk rather than env vars, which are
  57. # capped at 128 KiB each and can be exceeded by a long non-ASCII body.
  58. try:
  59. event = json.loads(_read(os.environ.get("GITHUB_EVENT_PATH", "")) or "{}")
  60. except json.JSONDecodeError:
  61. event = {}
  62. issue = event.get("issue") or {}
  63. return {
  64. "number": issue.get("number") or "",
  65. "title": issue.get("title") or "",
  66. "body": issue.get("body") or "",
  67. }
  68. def _call(api_key: str, system: str, user: str, max_tokens: int) -> str:
  69. payload = json.dumps(
  70. {
  71. "model": MODEL,
  72. "max_tokens": max_tokens,
  73. "system": system,
  74. "messages": [{"role": "user", "content": user}],
  75. }
  76. ).encode()
  77. req = urllib.request.Request(
  78. API_URL,
  79. data=payload,
  80. headers={
  81. "content-type": "application/json",
  82. "anthropic-version": "2023-06-01",
  83. "x-api-key": api_key,
  84. },
  85. )
  86. try:
  87. with urllib.request.urlopen(req, timeout=60) as resp:
  88. data = json.loads(resp.read())
  89. except urllib.error.HTTPError as exc:
  90. # Bad key, retired model, malformed request: a config problem, so fail loudly.
  91. raise APIError(f"API returned HTTP {exc.code}", fatal=400 <= exc.code < 500 and exc.code != 429)
  92. except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as exc:
  93. raise APIError(f"API call failed: {exc}")
  94. if data.get("stop_reason") == "max_tokens":
  95. raise APIError(f"model response truncated at max_tokens={max_tokens}")
  96. text = "".join(
  97. block.get("text", "")
  98. for block in data.get("content", [])
  99. if block.get("type") == "text"
  100. ).strip()
  101. if text.startswith("```"):
  102. text = text.strip("`")
  103. brace = text.find("{")
  104. text = text[brace:] if brace != -1 else text
  105. return text
  106. def _issue_xml(issue: dict) -> str:
  107. return '<issue number="{number}">\n<title>{title}</title>\n<body>\n{body}\n</body>\n</issue>'.format(**issue)
  108. def _sanitize_query(query: str) -> str:
  109. # Drop search qualifiers (repo:, org:, author:, ...) so a query cannot leave this
  110. # repo, plus quotes, negation, and boolean operators.
  111. words = []
  112. for word in query.replace('"', " ").split():
  113. word = word.lstrip("-")
  114. if not word or ":" in word or word in ("OR", "AND", "NOT"):
  115. continue
  116. words.append(word)
  117. return " ".join(words)[:MAX_QUERY_LEN].strip()
  118. def _propose_queries(api_key: str, issue: dict) -> list[str]:
  119. system = os.environ.get("QUERY_PROMPT", "").strip()
  120. queries = []
  121. if system:
  122. try:
  123. text = _call(api_key, system, _issue_xml(issue), max_tokens=256)
  124. proposed = json.loads(text).get("queries", [])
  125. if isinstance(proposed, list):
  126. queries = [q for q in proposed if isinstance(q, str)]
  127. except APIError as exc:
  128. if exc.fatal:
  129. raise
  130. sys.stderr.write(f"::warning::triage: query generation failed: {exc}\n")
  131. except (json.JSONDecodeError, AttributeError):
  132. sys.stderr.write("::warning::triage: query generation returned unparseable JSON\n")
  133. # Fall back to the title if the model proposed nothing usable.
  134. queries = [s for s in (_sanitize_query(q) for q in queries[:MAX_QUERIES]) if s]
  135. return queries or [s for s in [_sanitize_query(issue["title"])] if s]
  136. def _search(query: str) -> list[dict]:
  137. # Arguments are passed as a list (no shell); `--` stops the query being read as flags.
  138. try:
  139. result = subprocess.run(
  140. [
  141. "gh", "search", "issues",
  142. "--repo", os.environ["GITHUB_REPOSITORY"],
  143. "--limit", str(RESULTS_PER_QUERY),
  144. "--json", "number,state,title,url,body",
  145. "--", query,
  146. ],
  147. capture_output=True, text=True, timeout=30, check=True,
  148. )
  149. return json.loads(result.stdout or "[]")
  150. except (subprocess.SubprocessError, json.JSONDecodeError, KeyError, OSError) as exc:
  151. sys.stderr.write(f"::warning::triage: search failed: {exc}\n")
  152. return []
  153. def _duplicate_candidates(queries: list[str], issue_number) -> str:
  154. seen = {issue_number}
  155. candidates = []
  156. for query in queries:
  157. for hit in _search(query):
  158. if hit.get("number") in seen:
  159. continue
  160. seen.add(hit.get("number"))
  161. candidates.append(hit)
  162. entries = []
  163. for hit in candidates[:MAX_CANDIDATES]:
  164. body = (hit.get("body") or "")[:CANDIDATE_BODY_CHARS]
  165. entries.append(
  166. '<candidate number="{number}" state="{state}" url="{url}">\n<title>{title}</title>\n'
  167. "<body_excerpt>\n{body}\n</body_excerpt>\n</candidate>".format(
  168. number=hit.get("number", ""),
  169. state=hit.get("state", ""),
  170. url=hit.get("url", ""),
  171. title=hit.get("title", ""),
  172. body=body,
  173. )
  174. )
  175. return "\n".join(entries)
  176. def main() -> None:
  177. api_key = os.environ.get("ANTHROPIC_API_KEY")
  178. if not api_key:
  179. _fail_closed("ANTHROPIC_API_KEY not set", fatal=True)
  180. system = os.environ.get("TRIAGE_PROMPT", "").strip()
  181. if not system:
  182. _fail_closed("TRIAGE_PROMPT not set", fatal=True)
  183. issue = _load_issue()
  184. if not issue["number"]:
  185. _fail_closed("could not read issue from GITHUB_EVENT_PATH")
  186. return
  187. try:
  188. queries = _propose_queries(api_key, issue)
  189. except APIError as exc:
  190. _fail_closed(str(exc), fatal=exc.fatal)
  191. return
  192. user = (
  193. "{issue}\n\n"
  194. "<issue_templates>\n{templates}\n</issue_templates>\n\n"
  195. "<release_list>\n{releases}\n</release_list>\n\n"
  196. "<duplicate_candidates>\n{dups}\n</duplicate_candidates>"
  197. ).format(
  198. issue=_issue_xml(issue),
  199. templates=_read(os.environ.get("TEMPLATES_PATH", "")),
  200. releases=_read(os.environ.get("RELEASES_PATH", "")),
  201. dups=_duplicate_candidates(queries, issue["number"]),
  202. )
  203. try:
  204. text = _call(api_key, system, user, max_tokens=2048)
  205. except APIError as exc:
  206. _fail_closed(str(exc), fatal=exc.fatal)
  207. return
  208. try:
  209. verdict = json.loads(text)
  210. should = verdict.get("should_comment") is True
  211. body = verdict.get("body") if should else ""
  212. except (json.JSONDecodeError, AttributeError):
  213. _fail_closed("model did not return parseable JSON")
  214. return
  215. # A verdict to comment is only usable with a non-empty string body.
  216. if should and not (isinstance(body, str) and body.strip()):
  217. _fail_closed("model set should_comment without a usable body")
  218. return
  219. print(json.dumps({"should_comment": should, "body": body}))
  220. if __name__ == "__main__":
  221. main()