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.
- package/LICENSE +21 -0
- package/README.md +429 -2
- package/bin/build.js +70 -0
- package/bin/cli.js +441 -0
- package/package.json +36 -4
- package/skill/SKILL.md +262 -0
- package/skill/references/lessons-learned.md +706 -0
- package/skill/scripts/01_init_config.sh +151 -0
- package/skill/scripts/02_print_config.sh +60 -0
- package/skill/scripts/10_build_components.sh +596 -0
- package/skill/scripts/20_validate_file_parity.sh +107 -0
- package/skill/scripts/21_validate_project_pinning.sh +105 -0
- package/skill/scripts/22_validate_no_app_name_leftover.sh +62 -0
- package/skill/scripts/lib/common.sh +788 -0
- package/skill/scripts/run_tracking.sh +93 -0
- package/skill/templates/scaffolding.config.template +81 -0
- package/vendor/github-proyect-tracking/SKILL.md +180 -0
- package/vendor/github-proyect-tracking/references/api.md +185 -0
- package/vendor/github-proyect-tracking/references/architecture.md +171 -0
- package/vendor/github-proyect-tracking/scripts/tracking.py +214 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/__init__.py +1 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/commands.py +940 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/common.py +208 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/events.py +233 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/github.py +324 -0
- package/vendor/github-proyect-tracking/scripts/tracking_core/state.py +379 -0
|
@@ -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
|