kcreate-scaffolding-gcp-script 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
1
+ """Transporte HTTP stdlib, credenciales y operaciones contra GitHub Issues."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import time
10
+ import urllib.error
11
+ import urllib.request
12
+ import uuid
13
+
14
+ from .common import (
15
+ AMBIGUOUS_HTTP_STATUSES, API_BASE, EXIT_NOT_FOUND, EXIT_NO_TOKEN,
16
+ EXIT_RUNTIME, EXIT_TOKEN_INSUFFICIENT, MAX_COMMENT_PAGES, TITLE_RE,
17
+ USER_AGENT, SkillError, issue_title,
18
+ )
19
+ from .events import events_from_comments, transition_metadata, with_transition_metadata
20
+
21
+
22
+ def resolve_token(cli_token=None, state=None):
23
+ if cli_token:
24
+ return cli_token, "cli"
25
+ for variable in ("GITHUB_TRACKING_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"):
26
+ value = os.environ.get(variable)
27
+ if value:
28
+ return value, f"env:{variable}"
29
+ executable = shutil.which("gh")
30
+ if executable:
31
+ try:
32
+ output = subprocess.run([executable, "auth", "token"],
33
+ capture_output=True, text=True, timeout=15)
34
+ if output.returncode == 0 and output.stdout.strip():
35
+ return output.stdout.strip(), "gh:auth"
36
+ except (OSError, subprocess.SubprocessError):
37
+ pass
38
+ if state and state.get("github_token"):
39
+ return state["github_token"], "state-file"
40
+ return None, None
41
+
42
+
43
+ def check_issues_write(owner, repo, token):
44
+ try:
45
+ status, data, headers = api("GET", f"/repos/{owner}/{repo}", token)
46
+ except SkillError as error:
47
+ return False, error.code, error.message
48
+ if status == 401:
49
+ return False, "INVALID_TOKEN", "el token fue rechazado (401)"
50
+ if status == 403:
51
+ return False, "NO_REPO_ACCESS", "el token no tiene acceso al repositorio (403)"
52
+ if status == 404:
53
+ return False, "REPO_NOT_FOUND", (f"el repositorio {owner}/{repo} no existe "
54
+ "o es privado para este token (404)")
55
+ if status != 200:
56
+ return False, "API_ERROR", f"GET /repos devolvió {status}"
57
+ if data.get("has_issues") is False:
58
+ return False, "INSUFFICIENT_PERMISSION", (
59
+ "el repositorio tiene los Issues deshabilitados (has_issues=false)")
60
+ scopes = headers.get("X-OAuth-Scopes") or ""
61
+ permissions = data.get("permissions") or {}
62
+ can_write_issues = any(
63
+ permissions.get(key) is True
64
+ for key in ("triage", "push", "maintain", "admin"))
65
+ if scopes:
66
+ available = {scope.strip() for scope in scopes.split(",") if scope.strip()}
67
+ if not ({"repo", "public_repo"} & available):
68
+ return False, "INSUFFICIENT_SCOPE", (
69
+ f"el token classic tiene scopes [{scopes}] pero necesita 'repo' "
70
+ "(o 'public_repo' para repos públicos) para escribir Issues")
71
+ if can_write_issues:
72
+ return True, "OK", ("token classic con scope suficiente y rol con "
73
+ "capacidad de gestionar Issues")
74
+ if permissions:
75
+ return False, "INSUFFICIENT_PERMISSION", (
76
+ "el token tiene scope suficiente, pero el repositorio reporta "
77
+ "un rol sin permiso para gestionar Issues")
78
+ return True, "UNVERIFIED", (
79
+ "token classic con scope suficiente; la API no reportó permisos "
80
+ "efectivos, la escritura se confirmará al crear el Issue")
81
+ if can_write_issues:
82
+ return True, "UNVERIFIED", (
83
+ "token fine-grained/app con permisos de escritura sobre el "
84
+ "repositorio; el permiso granular Issues:write no es verificable "
85
+ "en preflight y se confirmará al crear el Issue")
86
+ return True, "UNVERIFIED", (
87
+ "token fine-grained/app: el permiso granular Issues:write no es "
88
+ "verificable en preflight; se confirmará al crear el Issue "
89
+ "(un 403 en la creación se clasifica como ISSUES_WRITE_FAILED)")
90
+
91
+
92
+ def _raw_api(method, path, token, body=None):
93
+ url = path if path.startswith("http://") or path.startswith("https://") \
94
+ else API_BASE + path
95
+ data = None
96
+ headers = {
97
+ "Authorization": f"Bearer {token}",
98
+ "Accept": "application/vnd.github+json",
99
+ "X-GitHub-Api-Version": "2022-11-28",
100
+ "User-Agent": USER_AGENT,
101
+ }
102
+ if body is not None:
103
+ data = json.dumps(body).encode("utf-8")
104
+ headers["Content-Type"] = "application/json"
105
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
106
+ with urllib.request.urlopen(request, timeout=30) as response:
107
+ raw = response.read().decode("utf-8", errors="replace")
108
+ parsed = json.loads(raw) if raw.strip() else {}
109
+ return response.status, parsed, response.headers
110
+
111
+
112
+ def api(method, path, token, body=None, retries=3):
113
+ idempotent = method in ("GET", "HEAD", "PUT", "PATCH", "DELETE")
114
+ attempt = 0
115
+ while True:
116
+ attempt += 1
117
+ try:
118
+ status, data, headers = _raw_api(method, path, token, body)
119
+ except urllib.error.HTTPError as error:
120
+ raw = error.read().decode("utf-8", errors="replace")
121
+ try:
122
+ payload = json.loads(raw) if raw.strip() else {}
123
+ except json.JSONDecodeError:
124
+ payload = {"message": raw[:500]}
125
+ status, data, headers = error.code, payload, error.headers
126
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as error:
127
+ if idempotent and attempt < retries:
128
+ time.sleep(min(2 ** attempt, 8) * 0.5)
129
+ continue
130
+ raise SkillError(EXIT_RUNTIME, "NETWORK_ERROR",
131
+ f"API de GitHub inalcanzable ({method} {path}): {error}",
132
+ {"hint": "si la operación pudo aplicarse en el servidor, "
133
+ "el comando reconciliará con el estado remoto o "
134
+ "indicará cómo recuperarlo"})
135
+ remaining = headers.get("X-RateLimit-Remaining") if headers else None
136
+ if status in (403, 429) and remaining == "0":
137
+ reset = int((headers.get("X-RateLimit-Reset") if headers else 0) or 0)
138
+ if attempt <= retries:
139
+ time.sleep(min(max(reset - time.time(), 1), 90))
140
+ continue
141
+ raise SkillError(EXIT_RUNTIME, "RATE_LIMITED",
142
+ "límite de la API de GitHub agotado",
143
+ {"reset_epoch": reset})
144
+ if status in AMBIGUOUS_HTTP_STATUSES and idempotent and attempt < retries:
145
+ retry_after = headers.get("Retry-After") if headers else None
146
+ delay = (int(retry_after) if (retry_after or "").isdigit()
147
+ else min(2 ** attempt, 8) * 0.5)
148
+ time.sleep(min(delay, 30))
149
+ continue
150
+ return status, data, headers
151
+
152
+
153
+ def parse_link_next(link_header):
154
+ if not link_header:
155
+ return None
156
+ for part in link_header.split(","):
157
+ segments = part.split(";")
158
+ if len(segments) >= 2 and 'rel="next"' in segments[1]:
159
+ import re
160
+ match = re.search(r"<([^>]+)>", segments[0])
161
+ if match:
162
+ return match.group(1)
163
+ return None
164
+
165
+
166
+ def fetch_issue(owner, repo, number, token):
167
+ status, data, _ = api("GET", f"/repos/{owner}/{repo}/issues/{number}", token)
168
+ if status == 404:
169
+ raise SkillError(EXIT_NOT_FOUND, "ISSUE_NOT_FOUND",
170
+ f"el Issue #{number} no existe en {owner}/{repo}")
171
+ if status != 200:
172
+ raise SkillError(EXIT_RUNTIME, "ISSUE_FETCH_FAILED",
173
+ f"GET issue devolvió {status}: {data.get('message', '')}")
174
+ return data
175
+
176
+
177
+ def fetch_all_comments(owner, repo, number, token):
178
+ url = (f"{API_BASE}/repos/{owner}/{repo}/issues/{number}/comments"
179
+ f"?per_page=100")
180
+ output, pages = [], 0
181
+ while url:
182
+ pages += 1
183
+ if pages > MAX_COMMENT_PAGES:
184
+ raise SkillError(EXIT_RUNTIME, "COMMENTS_TOO_MANY",
185
+ f"el Issue #{number} supera {MAX_COMMENT_PAGES * 100} "
186
+ "comentarios; no se puede garantizar cuál es el "
187
+ "último evento",
188
+ {"hint": "archiva el Issue y comienza uno nuevo, o "
189
+ "eleva tracking.MAX_COMMENT_PAGES con criterio"})
190
+ status, data, headers = api("GET", url, token)
191
+ if status != 200:
192
+ raise SkillError(EXIT_RUNTIME, "COMMENTS_FETCH_FAILED",
193
+ f"GET comments devolvió {status}: "
194
+ f"{data.get('message', '')}")
195
+ output.extend(data if isinstance(data, list) else [])
196
+ url = parse_link_next(headers.get("Link") if headers else None)
197
+ return output
198
+
199
+
200
+ def _classify_post_failure(status, data, what):
201
+ message = data.get("message", "")
202
+ if status == 403:
203
+ return SkillError(EXIT_TOKEN_INSUFFICIENT, "ISSUES_WRITE_FAILED",
204
+ f"no se pudo {what} ({status}): {message} — el token no "
205
+ "puede escribir en este repositorio (issues:write)")
206
+ return SkillError(EXIT_RUNTIME, "COMMENT_FAILED",
207
+ f"no se pudo {what} ({status}): {message}")
208
+
209
+
210
+ def post_comment(owner, repo, number, body, token,
211
+ baseline_ids=None, execution_id=None, sequence=None):
212
+ path = f"/repos/{owner}/{repo}/issues/{number}/comments"
213
+ transition_id = uuid.uuid4().hex
214
+ wire_body = body if execution_id is None else with_transition_metadata(
215
+ body, execution_id, sequence or 1, transition_id)
216
+
217
+ def result(comment, reconciled):
218
+ return {"comment_id": comment.get("id"),
219
+ "comment_url": comment.get("html_url"),
220
+ "transition_id": transition_id,
221
+ "reconciled": reconciled}
222
+
223
+ def matching(comments, baseline):
224
+ for comment in reversed(comments):
225
+ if baseline is not None and comment.get("id") in baseline:
226
+ continue
227
+ if execution_id is not None:
228
+ if transition_metadata(comment.get("body") or "").get(
229
+ "transition_id") == transition_id:
230
+ return comment
231
+ elif (comment.get("body") or "").strip() == body.strip():
232
+ return comment
233
+ return None
234
+
235
+ lost = None
236
+ try:
237
+ status, data, _ = api("POST", path, token, {"body": wire_body})
238
+ if status in (200, 201):
239
+ return result(data, False)
240
+ if status not in AMBIGUOUS_HTTP_STATUSES:
241
+ raise _classify_post_failure(status, data, "publicar el comentario")
242
+ lost = SkillError(EXIT_RUNTIME, "COMMENT_AMBIGUOUS",
243
+ f"el servidor devolvió {status}; el comentario pudo "
244
+ "haberse aplicado")
245
+ except SkillError as error:
246
+ if error.code != "NETWORK_ERROR":
247
+ raise
248
+ lost = error
249
+
250
+ try:
251
+ comments = fetch_all_comments(owner, repo, number, token)
252
+ except SkillError:
253
+ raise lost
254
+ comment = matching(comments, baseline_ids)
255
+ if comment:
256
+ return result(comment, True)
257
+
258
+ second_baseline = frozenset(comment.get("id") for comment in comments)
259
+ second_error = None
260
+ try:
261
+ status, data, _ = api("POST", path, token, {"body": wire_body})
262
+ if status in (200, 201):
263
+ return result(data, False)
264
+ if status not in AMBIGUOUS_HTTP_STATUSES:
265
+ raise _classify_post_failure(status, data, "publicar el comentario")
266
+ second_error = _classify_post_failure(
267
+ status, data, "confirmar el comentario tras una respuesta ambigua")
268
+ except SkillError as error:
269
+ if error.code != "NETWORK_ERROR":
270
+ raise
271
+ second_error = error
272
+
273
+ try:
274
+ second_comments = fetch_all_comments(owner, repo, number, token)
275
+ except SkillError:
276
+ raise second_error
277
+ comment = matching(second_comments, second_baseline)
278
+ if comment:
279
+ return result(comment, True)
280
+ raise second_error
281
+
282
+
283
+ def close_issue(owner, repo, number, token):
284
+ status, data, _ = api("PATCH", f"/repos/{owner}/{repo}/issues/{number}",
285
+ token, {"state": "closed"})
286
+ if status == 403:
287
+ raise SkillError(EXIT_TOKEN_INSUFFICIENT, "ISSUES_WRITE_FAILED",
288
+ f"no se pudo cerrar el Issue (403): "
289
+ f"{data.get('message', '')}")
290
+ if status != 200:
291
+ raise SkillError(EXIT_RUNTIME, "ISSUE_CLOSE_FAILED",
292
+ f"no se pudo cerrar el Issue ({status}): "
293
+ f"{data.get('message', '')}")
294
+ return data
295
+
296
+
297
+ def find_issue_by_title(owner, repo, title, token):
298
+ # v0.5.0: title ya viene construido con issue_title()
299
+ url = (f"{API_BASE}/repos/{owner}/{repo}/issues?state=all&sort=created"
300
+ "&direction=desc&per_page=100")
301
+ seen = set()
302
+ while url and url not in seen:
303
+ seen.add(url)
304
+ status, data, headers = api("GET", url, token)
305
+ if status != 200 or not isinstance(data, list):
306
+ return None
307
+ for issue in data:
308
+ if (issue.get("title") or "").strip() == title:
309
+ return issue
310
+ url = parse_link_next(headers.get("Link") if headers else None)
311
+ return None
312
+
313
+
314
+ def canonical_title_match(issue, execution_id, process_name=None):
315
+ """v0.5.0: casa el título canónico con o sin nombre de proceso; el
316
+ exec_id debe coincidir EXACTO y, si se indica process_name, también."""
317
+ match = TITLE_RE.match((issue.get("title") or "").strip())
318
+ if not match or match.group("exec_id").lower() != execution_id.lower():
319
+ return False
320
+ if process_name:
321
+ found = match.group("process_name")
322
+ if (found or "").strip().lower() != str(process_name).strip().lower():
323
+ return False
324
+ return True