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,208 @@
1
+ """Constantes, errores y utilidades compartidas por el CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+
10
+ VERSION = "0.6.0"
11
+ API_BASE = "https://api.github.com"
12
+ USER_AGENT = f"github-proyect-tracking/{VERSION}"
13
+
14
+ STATE_DIR_ENV = "GITHUB_TRACKING_STATE"
15
+ SCHEMA_VERSION = 2
16
+ SUPPORTED_SCHEMA_VERSIONS = (1, SCHEMA_VERSION)
17
+ PROCESS_STATES = ("RUNNING", "ERROR", "FINALIZED", "CANCELADO",
18
+ "CLEARED", "ABORTED")
19
+
20
+ STATUS_INICIANDO = "INICIANDO"
21
+ STATUS_ERROR = "ERROR"
22
+ STATUS_COMPLETADO = "COMPLETADO"
23
+ STATUS_FINALIZADO = "FINALIZADO"
24
+ STATUS_CANCELADO = "CANCELADO"
25
+
26
+ EXIT_OK = 0
27
+ EXIT_RUNTIME = 1
28
+ EXIT_VALIDATION = 2
29
+ EXIT_TOKEN_INSUFFICIENT = 3
30
+ EXIT_NO_TOKEN = 4
31
+ EXIT_NO_PROCESS = 5
32
+ EXIT_NOT_FOUND = 6
33
+ EXIT_RESUME_REQUIRED = 7
34
+ EXIT_CONFLICT = 8
35
+
36
+ MAX_STEP_LEN = 200
37
+ MAX_DETAILS_LEN = 60000
38
+ MAX_PLAN_STEPS = 100
39
+ MAX_COMMENT_PAGES = 100
40
+ LOCK_STALE_SECONDS = 120
41
+ LOCK_WAIT_SECONDS = 10
42
+ AMBIGUOUS_HTTP_STATUSES = {500, 502, 503, 504}
43
+
44
+ REPO_URL_RE = re.compile(
45
+ r"^https://(?:www\.)?github\.com/"
46
+ r"(?P<owner>[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)/"
47
+ r"(?P<repo>[A-Za-z0-9._-]+?)(?:\.git)?/?$",
48
+ re.IGNORECASE,
49
+ )
50
+ # Título dual (v0.5.0): con o sin nombre de proceso.
51
+ # [Process Tracking][Mi Skill] Execution ID: exec-...
52
+ # [Process Tracking] Execution ID: exec-... (formato <=0.4.x)
53
+ TITLE_RE = re.compile(
54
+ r"^\[Process Tracking\]"
55
+ r"(?:\[(?P<process_name>"
56
+ r"(?:[^\[\]\r\n]|\[RED[Aa]ctado:[^\[\]]*\]){1,200}"
57
+ r")\])?"
58
+ r"\s*(?:-\s*)?Execution ID:\s*(?P<exec_id>\S+?)\s*$",
59
+ re.IGNORECASE,
60
+ )
61
+ MAX_PROCESS_NAME_LEN = 100
62
+
63
+ SECRET_REDACTIONS = [
64
+ (re.compile(r"(?i)\b(authorization|proxy-authorization)\s*[:=]\s*"
65
+ r"(?:bearer|token|basic)?\s*[A-Za-z0-9._\-+/=~]{8,}"),
66
+ "[REDactado:authorization]"),
67
+ (re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._\-+/=]{8,}"), "[REDactado:bearer]"),
68
+ (re.compile(r"github_pat_[A-Za-z0-9_]{22,}"), "[REDACTADO:github-pat]"),
69
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}\b"), "[REDactado:github-token]"),
70
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDactado:aws-key]"),
71
+ (re.compile(r"(?i)\b(password|passwd|pwd|secret|api[_-]?key|apikey|"
72
+ r"client[_-]?secret)\b\s*[=:]\s*['\"]?[A-Za-z0-9._\-+/=~]{6,}"),
73
+ "[REDactado:secret]"),
74
+ (re.compile(r"(?i)([a-z][a-z0-9+.\-]*://)[^/@\s]+@"),
75
+ r"\1[REDactado]@"),
76
+ ]
77
+
78
+
79
+ def normalize_process_name(name):
80
+ """Normaliza el nombre del proceso (skill padre) para título y cuerpo
81
+ del Issue. Orden V2-aprendido: validar la entrada CRUDA (longitud y
82
+ corchetes) ANTES de sanitizar — los marcadores [REDactado:...] que
83
+ introduce la sanitización son legítimos y TITLE_RE los acepta."""
84
+ if name is None:
85
+ return None
86
+ text = str(name).strip()
87
+ if not text:
88
+ return None
89
+ if len(text) > MAX_PROCESS_NAME_LEN:
90
+ raise SkillError(
91
+ EXIT_VALIDATION, "VALIDATION_ERROR",
92
+ f"el nombre del proceso no puede exceder {MAX_PROCESS_NAME_LEN} "
93
+ f"caracteres (recibido: {len(text)})")
94
+ if any(ch in text for ch in "[]\r\n"):
95
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
96
+ "el nombre del proceso no puede contener '[', ']' "
97
+ "ni saltos de línea (rompe el título canónico)")
98
+ clean, _ = sanitize_text(text)
99
+ clean = clean.strip()
100
+ if not clean:
101
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
102
+ "el nombre del proceso quedó vacío tras sanitizar")
103
+ return clean
104
+
105
+
106
+ def issue_title(execution_id, process_name=None):
107
+ """Título canónico del Issue con el nombre del proceso (v0.5.0).
108
+ El nombre debe venir YA normalizado (normalize_process_name); aquí no
109
+ se re-valida porque los marcadores [REDactado:...] son legítimos."""
110
+ if process_name:
111
+ return f"[Process Tracking][{process_name}] Execution ID: {execution_id}"
112
+ return f"[Process Tracking] Execution ID: {execution_id}"
113
+
114
+
115
+
116
+ class SkillError(Exception):
117
+ """Error operativo que se serializa como un único objeto JSON."""
118
+
119
+ def __init__(self, exit_code, code, message, extra=None):
120
+ super().__init__(message)
121
+ self.exit_code = exit_code
122
+ self.code = code
123
+ self.message = message
124
+ self.extra = extra or {}
125
+
126
+ def to_json(self):
127
+ error = {"code": self.code, "message": self.message}
128
+ error.update(self.extra)
129
+ return {"ok": False, "error": error}
130
+
131
+
132
+ def emit(obj):
133
+ print(json.dumps(obj, indent=2, ensure_ascii=False), flush=True)
134
+
135
+
136
+ def now_iso():
137
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
138
+
139
+
140
+ def new_execution_id():
141
+ return (f"exec-{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-"
142
+ f"{uuid.uuid4().hex[:12]}")
143
+
144
+
145
+ def ordered_unique(items):
146
+ seen, output = set(), []
147
+ for item in items:
148
+ if item and item not in seen:
149
+ seen.add(item)
150
+ output.append(item)
151
+ return output
152
+
153
+
154
+ def validate_repo_url(url):
155
+ if not isinstance(url, str):
156
+ return None
157
+ match = REPO_URL_RE.match(url.strip())
158
+ if not match:
159
+ return None
160
+ owner, repo = match.group("owner").lower(), match.group("repo").lower()
161
+ if ".." in repo or repo.startswith(".") or repo.endswith("."):
162
+ return None
163
+ return owner, repo
164
+
165
+
166
+ def redact_url(url):
167
+ if not isinstance(url, str):
168
+ return repr(url)
169
+ output = re.sub(r"(?i)([a-z][a-z0-9+.\-]*://)[^/@\s]+@",
170
+ r"\1[REDactado]@", url)
171
+ output = re.sub(r"#.*$", "#[REDactado]", output)
172
+ return re.sub(r"\?.*$", "?[REDactado]", output)
173
+
174
+
175
+ def sanitize_text(text):
176
+ if not text:
177
+ return (text or ""), 0
178
+ output, total = text, 0
179
+ for regex, replacement in SECRET_REDACTIONS:
180
+ output, count = regex.subn(replacement, output)
181
+ total += count
182
+ return output, total
183
+
184
+
185
+ def _step_name_unsafe(name):
186
+ if not isinstance(name, str) or not name.strip():
187
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
188
+ "nombre de paso requerido (no vacío)")
189
+ if len(name) > MAX_STEP_LEN:
190
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
191
+ f"nombre de paso demasiado largo (>{MAX_STEP_LEN} caracteres)")
192
+ if "\n" in name or "\r" in name:
193
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
194
+ "el nombre del paso no puede contener saltos de línea")
195
+ return name
196
+
197
+
198
+ def normalize_step_name(name):
199
+ normalized = _step_name_unsafe(name).strip()
200
+ if re.search(r"(?i)\bdetalles\b\s*:", normalized):
201
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
202
+ "el nombre del paso no puede contener 'Detalles:' en "
203
+ "ninguna capitalización (rompe el parser de reanudación)")
204
+ clean, _ = sanitize_text(normalized)
205
+ if not clean.strip():
206
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
207
+ "el nombre del paso quedó vacío tras sanitizar")
208
+ return clean.strip()
@@ -0,0 +1,233 @@
1
+ """Contrato visible de comentarios, metadatos y planner de reanudación."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ from .common import (
9
+ EXIT_VALIDATION, STATUS_CANCELADO, STATUS_COMPLETADO, STATUS_ERROR,
10
+ STATUS_FINALIZADO, STATUS_INICIANDO, SkillError, now_iso, ordered_unique,
11
+ )
12
+
13
+ COMMENT_RE = re.compile(
14
+ r"\[STATUS:\s*(?P<status>INICIANDO|ERROR|COMPLETADO|FINALIZADO|CANCELADO)\]\s*-\s*"
15
+ r"(?:Paso:\s*(?P<step>[^\r\n]*?)\s*(?:-\s*Detalles:\s*(?P<details>.*))?[\r\n]*$"
16
+ r"|(?P<final>El\s+proceso\s+concluy[oó]\s+exitosamente\.?)"
17
+ r"|(?P<canceled>El\s+proceso\s+fue\s+cerrado\s+anticipadamente.*))",
18
+ re.IGNORECASE | re.DOTALL | re.MULTILINE,
19
+ )
20
+ PLAN_LINE_RE = re.compile(r"^\s*\d+\.\s+(.+?)\s*$", re.MULTILINE)
21
+ TRANSITION_RE = re.compile(
22
+ r"\n<!--\s*github-proyect-tracking:\s*(?P<meta>\{.*\})\s*-->\s*$",
23
+ re.DOTALL,
24
+ )
25
+
26
+
27
+ def fmt_iniciando(step):
28
+ return f"[STATUS: INICIANDO] - Paso: {step}"
29
+
30
+
31
+ def fmt_error(step, details):
32
+ return f"[STATUS: ERROR] - Paso: {step} - Detalles: {details}"
33
+
34
+
35
+ def fmt_completado(step):
36
+ return f"[STATUS: COMPLETADO] - Paso: {step}"
37
+
38
+
39
+ def fmt_finalizado():
40
+ return "[STATUS: FINALIZADO] - El proceso concluyó exitosamente."
41
+
42
+
43
+ def fmt_cancelado(pending):
44
+ if pending:
45
+ items = ", ".join(pending[:10]) + ("…" if len(pending) > 10 else "")
46
+ return ("[STATUS: CANCELADO] - El proceso fue cerrado anticipadamente "
47
+ f"(--force). Pasos pendientes: {items}.")
48
+ return "[STATUS: CANCELADO] - El proceso fue cerrado anticipadamente (--force)."
49
+
50
+
51
+ def transition_metadata(body):
52
+ match = TRANSITION_RE.search(body or "")
53
+ if not match:
54
+ return {}
55
+ try:
56
+ data = json.loads(match.group("meta"))
57
+ except json.JSONDecodeError:
58
+ return {}
59
+ return data if isinstance(data, dict) else {}
60
+
61
+
62
+ def with_transition_metadata(body, execution_id, sequence, transition_id):
63
+ metadata = json.dumps({
64
+ "execution_id": execution_id,
65
+ "sequence": sequence,
66
+ "transition_id": transition_id,
67
+ }, ensure_ascii=False, separators=(",", ":"))
68
+ return f"{body}\n<!-- github-proyect-tracking: {metadata} -->"
69
+
70
+
71
+ def parse_status_comment(body):
72
+ events = []
73
+ visible_body = TRANSITION_RE.sub("", body or "")
74
+ for match in COMMENT_RE.finditer(visible_body):
75
+ events.append({
76
+ "status": (match.group("status") or "").upper(),
77
+ "step": match.group("step").strip() if match.group("step") else None,
78
+ "details": (match.group("details") or "").strip()
79
+ if match.group("details") is not None else None,
80
+ })
81
+ return events
82
+
83
+
84
+ def events_from_comments(comments):
85
+ events = []
86
+ for comment in comments or []:
87
+ body = comment.get("body") or ""
88
+ metadata = transition_metadata(body)
89
+ for event in parse_status_comment(body):
90
+ event["comment_id"] = comment.get("id")
91
+ event["created_at"] = comment.get("created_at")
92
+ event["transition_id"] = metadata.get("transition_id")
93
+ event["execution_id"] = metadata.get("execution_id")
94
+ event["sequence"] = metadata.get("sequence")
95
+ events.append(event)
96
+ events.sort(key=lambda event: (
97
+ event.get("created_at") or "", event.get("comment_id") or 0))
98
+ return events
99
+
100
+
101
+ def _normalized_inputs(process_inputs):
102
+ """Normaliza los parámetros de entrada a pares (clave, valor) ordenados,
103
+ con ambos convertidos a cadena. Devuelve [] si no hay inputs."""
104
+ if not process_inputs:
105
+ return []
106
+ if not isinstance(process_inputs, dict):
107
+ return []
108
+ entries = []
109
+ for key in sorted(process_inputs, key=str):
110
+ value = process_inputs[key]
111
+ entries.append((str(key).strip(), str(value)))
112
+ return [(k, v) for k, v in entries if k]
113
+
114
+
115
+ def build_issue_body(url, execution_id, plan, process_name=None,
116
+ process_inputs=None):
117
+ lines = [
118
+ "Proceso rastreado por el skill `github-proyect-tracking`.",
119
+ "",
120
+ f"- **Execution ID:** `{execution_id}`",
121
+ ]
122
+ if process_name:
123
+ lines.append(f"- **Proceso:** {process_name}")
124
+ lines += [
125
+ f"- **Proyecto:** {url}",
126
+ f"- **Iniciado (UTC):** {now_iso()}",
127
+ ]
128
+
129
+ # v0.6.0: parámetros de entrada del proceso — la única forma de saber
130
+ # exactamente qué tarea estaba realizando esta ejecución.
131
+ entries = _normalized_inputs(process_inputs)
132
+ if entries:
133
+ lines += ["", "## Parámetros de entrada", ""]
134
+ lines += [f"- **{key}:** `{value}`" for key, value in entries]
135
+
136
+ lines += [
137
+ "",
138
+ "## Plan de pasos",
139
+ ]
140
+ if plan:
141
+ lines += [f"{index}. {step}" for index, step in enumerate(plan, 1)]
142
+ else:
143
+ lines.append("_(sin plan explícito — los pasos los define el agente "
144
+ "durante la ejecución)_")
145
+ lines += [
146
+ "",
147
+ "> El avance se registra como comentarios con el contrato `[STATUS: ...]`.",
148
+ "> El ÚLTIMO comentario es la fuente de verdad para reanudar.",
149
+ ]
150
+ return "\n".join(lines) + "\n"
151
+
152
+
153
+ def parse_plan_from_body(body):
154
+ match = re.search(r"##\s*Plan de pasos(.*?)(?=\n##\s|\Z)", body or "",
155
+ re.DOTALL | re.IGNORECASE)
156
+ if not match:
157
+ return []
158
+ return PLAN_LINE_RE.findall(match.group(1))
159
+
160
+
161
+ def _validate_resume_plan(plan):
162
+ plan = list(plan or [])
163
+ if len(plan) != len(set(plan)):
164
+ raise SkillError(
165
+ EXIT_VALIDATION, "PLAN_AMBIGUOUS",
166
+ "el plan contiene pasos duplicados y no permite una reanudación "
167
+ "determinista",
168
+ {"hint": "edita el cuerpo del Issue para asignar un nombre único a "
169
+ "cada paso; no se modificó ningún evento"})
170
+ return plan
171
+
172
+
173
+ def plan_resume(events, plan, issue_state="open"):
174
+ plan = _validate_resume_plan(plan)
175
+ completed = ordered_unique(
176
+ event["step"] for event in events
177
+ if event["status"] == STATUS_COMPLETADO and event.get("step"))
178
+ output = {
179
+ "completed_steps": completed,
180
+ "issue_state": issue_state,
181
+ "plan": plan,
182
+ "event_count": len(events),
183
+ }
184
+ if issue_state == "closed":
185
+ if events and events[-1]["status"] == STATUS_FINALIZADO:
186
+ return {**output, "action": "NONE", "outcome": "FINALIZADO",
187
+ "reason": "El proceso ya finalizó y el Issue está cerrado."}
188
+ if events and events[-1]["status"] == STATUS_CANCELADO:
189
+ return {**output, "action": "NONE", "outcome": "CANCELADO",
190
+ "reason": "El proceso fue CANCELADO (--force) y el Issue "
191
+ "está cerrado."}
192
+ return {**output, "action": "ABORTED_EXTERNALLY", "resume_from_step": None,
193
+ "reason": "El Issue fue cerrado sin comentario de cierre; "
194
+ "consultar al usuario antes de reabrir o iniciar uno nuevo."}
195
+ if not events:
196
+ return {**output, "action": "START_FIRST",
197
+ "resume_from_step": plan[0] if plan else None,
198
+ "reason": "El Issue no tiene comentarios: comenzar desde el primer paso."}
199
+ last = events[-1]
200
+ output["last_status"] = last["status"]
201
+ if last["status"] == STATUS_FINALIZADO:
202
+ return {**output, "action": "FINISH_CLEANUP", "resume_from_step": None,
203
+ "outcome": "FINALIZADO",
204
+ "reason": "Último comentario FINALIZADO pero el Issue sigue abierto: "
205
+ "ejecutar finalize (idempotente) para cerrarlo y limpiar memoria."}
206
+ if last["status"] == STATUS_CANCELADO:
207
+ return {**output, "action": "FINISH_CLEANUP", "outcome": "CANCELADO",
208
+ "reason": "El proceso fue CANCELADO (--force); el Issue sigue "
209
+ "abierto: ejecutar finalize (idempotente) para "
210
+ "cerrarlo y limpiar memoria."}
211
+ if last["status"] in (STATUS_ERROR, STATUS_INICIANDO):
212
+ reason = ("El último paso registró ERROR: re-ejecutar ese paso (o el que "
213
+ "generó el error) y continuar con los siguientes."
214
+ if last["status"] == STATUS_ERROR else
215
+ "El último paso quedó en INICIANDO sin COMPLETADO: re-ejecutar ese paso.")
216
+ result = {**output, "action": "RERUN_STEP",
217
+ "resume_from_step": last.get("step"), "reason": reason}
218
+ if last["status"] == STATUS_ERROR:
219
+ result["failed_step"] = last.get("step")
220
+ result["error_details"] = (last.get("details") or "")[:2000]
221
+ return result
222
+ done = set(completed)
223
+ remaining = [step for step in plan if step not in done]
224
+ if remaining:
225
+ return {**output, "action": "NEXT_STEP", "resume_from_step": remaining[0],
226
+ "remaining_steps": remaining,
227
+ "reason": "Paso completado: continuar con el siguiente paso del plan."}
228
+ if plan:
229
+ return {**output, "action": "ALL_STEPS_DONE", "resume_from_step": None,
230
+ "reason": "Todos los pasos del plan están COMPLETADO: ejecutar finalize."}
231
+ return {**output, "action": "NEXT_STEP_AGENT_DEFINED", "resume_from_step": None,
232
+ "reason": "Sin plan explícito: el agente define el siguiente paso "
233
+ "o ejecuta finalize."}