github-proyect-tracking 0.0.0-stage → 0.4.0

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,163 @@
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.4.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
+ TITLE_RE = re.compile(
51
+ r"^\[Process Tracking\]\s*(?:-\s*)?Execution ID:\s*(?P<exec_id>\S+?)\s*$",
52
+ re.IGNORECASE,
53
+ )
54
+
55
+ SECRET_REDACTIONS = [
56
+ (re.compile(r"(?i)\b(authorization|proxy-authorization)\s*[:=]\s*"
57
+ r"(?:bearer|token|basic)?\s*[A-Za-z0-9._\-+/=~]{8,}"),
58
+ "[REDactado:authorization]"),
59
+ (re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._\-+/=]{8,}"), "[REDactado:bearer]"),
60
+ (re.compile(r"github_pat_[A-Za-z0-9_]{22,}"), "[REDACTADO:github-pat]"),
61
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}\b"), "[REDactado:github-token]"),
62
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDactado:aws-key]"),
63
+ (re.compile(r"(?i)\b(password|passwd|pwd|secret|api[_-]?key|apikey|"
64
+ r"client[_-]?secret)\b\s*[=:]\s*['\"]?[A-Za-z0-9._\-+/=~]{6,}"),
65
+ "[REDactado:secret]"),
66
+ (re.compile(r"(?i)([a-z][a-z0-9+.\-]*://)[^/@\s]+@"),
67
+ r"\1[REDactado]@"),
68
+ ]
69
+
70
+
71
+ class SkillError(Exception):
72
+ """Error operativo que se serializa como un único objeto JSON."""
73
+
74
+ def __init__(self, exit_code, code, message, extra=None):
75
+ super().__init__(message)
76
+ self.exit_code = exit_code
77
+ self.code = code
78
+ self.message = message
79
+ self.extra = extra or {}
80
+
81
+ def to_json(self):
82
+ error = {"code": self.code, "message": self.message}
83
+ error.update(self.extra)
84
+ return {"ok": False, "error": error}
85
+
86
+
87
+ def emit(obj):
88
+ print(json.dumps(obj, indent=2, ensure_ascii=False), flush=True)
89
+
90
+
91
+ def now_iso():
92
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
93
+
94
+
95
+ def new_execution_id():
96
+ return (f"exec-{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-"
97
+ f"{uuid.uuid4().hex[:12]}")
98
+
99
+
100
+ def ordered_unique(items):
101
+ seen, output = set(), []
102
+ for item in items:
103
+ if item and item not in seen:
104
+ seen.add(item)
105
+ output.append(item)
106
+ return output
107
+
108
+
109
+ def validate_repo_url(url):
110
+ if not isinstance(url, str):
111
+ return None
112
+ match = REPO_URL_RE.match(url.strip())
113
+ if not match:
114
+ return None
115
+ owner, repo = match.group("owner").lower(), match.group("repo").lower()
116
+ if ".." in repo or repo.startswith(".") or repo.endswith("."):
117
+ return None
118
+ return owner, repo
119
+
120
+
121
+ def redact_url(url):
122
+ if not isinstance(url, str):
123
+ return repr(url)
124
+ output = re.sub(r"(?i)([a-z][a-z0-9+.\-]*://)[^/@\s]+@",
125
+ r"\1[REDactado]@", url)
126
+ output = re.sub(r"#.*$", "#[REDactado]", output)
127
+ return re.sub(r"\?.*$", "?[REDactado]", output)
128
+
129
+
130
+ def sanitize_text(text):
131
+ if not text:
132
+ return (text or ""), 0
133
+ output, total = text, 0
134
+ for regex, replacement in SECRET_REDACTIONS:
135
+ output, count = regex.subn(replacement, output)
136
+ total += count
137
+ return output, total
138
+
139
+
140
+ def _step_name_unsafe(name):
141
+ if not isinstance(name, str) or not name.strip():
142
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
143
+ "nombre de paso requerido (no vacío)")
144
+ if len(name) > MAX_STEP_LEN:
145
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
146
+ f"nombre de paso demasiado largo (>{MAX_STEP_LEN} caracteres)")
147
+ if "\n" in name or "\r" in name:
148
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
149
+ "el nombre del paso no puede contener saltos de línea")
150
+ return name
151
+
152
+
153
+ def normalize_step_name(name):
154
+ normalized = _step_name_unsafe(name).strip()
155
+ if re.search(r"(?i)\bdetalles\b\s*:", normalized):
156
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
157
+ "el nombre del paso no puede contener 'Detalles:' en "
158
+ "ninguna capitalización (rompe el parser de reanudación)")
159
+ clean, _ = sanitize_text(normalized)
160
+ if not clean.strip():
161
+ raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
162
+ "el nombre del paso quedó vacío tras sanitizar")
163
+ return clean.strip()
@@ -0,0 +1,204 @@
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 build_issue_body(url, execution_id, plan):
102
+ lines = [
103
+ "Proceso rastreado por el skill `github-proyect-tracking`.",
104
+ "",
105
+ f"- **Execution ID:** `{execution_id}`",
106
+ f"- **Proyecto:** {url}",
107
+ f"- **Iniciado (UTC):** {now_iso()}",
108
+ "",
109
+ "## Plan de pasos",
110
+ ]
111
+ if plan:
112
+ lines += [f"{index}. {step}" for index, step in enumerate(plan, 1)]
113
+ else:
114
+ lines.append("_(sin plan explícito — los pasos los define el agente "
115
+ "durante la ejecución)_")
116
+ lines += [
117
+ "",
118
+ "> El avance se registra como comentarios con el contrato `[STATUS: ...]`.",
119
+ "> El ÚLTIMO comentario es la fuente de verdad para reanudar.",
120
+ ]
121
+ return "\n".join(lines) + "\n"
122
+
123
+
124
+ def parse_plan_from_body(body):
125
+ match = re.search(r"##\s*Plan de pasos(.*?)(?=\n##\s|\Z)", body or "",
126
+ re.DOTALL | re.IGNORECASE)
127
+ if not match:
128
+ return []
129
+ return PLAN_LINE_RE.findall(match.group(1))
130
+
131
+
132
+ def _validate_resume_plan(plan):
133
+ plan = list(plan or [])
134
+ if len(plan) != len(set(plan)):
135
+ raise SkillError(
136
+ EXIT_VALIDATION, "PLAN_AMBIGUOUS",
137
+ "el plan contiene pasos duplicados y no permite una reanudación "
138
+ "determinista",
139
+ {"hint": "edita el cuerpo del Issue para asignar un nombre único a "
140
+ "cada paso; no se modificó ningún evento"})
141
+ return plan
142
+
143
+
144
+ def plan_resume(events, plan, issue_state="open"):
145
+ plan = _validate_resume_plan(plan)
146
+ completed = ordered_unique(
147
+ event["step"] for event in events
148
+ if event["status"] == STATUS_COMPLETADO and event.get("step"))
149
+ output = {
150
+ "completed_steps": completed,
151
+ "issue_state": issue_state,
152
+ "plan": plan,
153
+ "event_count": len(events),
154
+ }
155
+ if issue_state == "closed":
156
+ if events and events[-1]["status"] == STATUS_FINALIZADO:
157
+ return {**output, "action": "NONE", "outcome": "FINALIZADO",
158
+ "reason": "El proceso ya finalizó y el Issue está cerrado."}
159
+ if events and events[-1]["status"] == STATUS_CANCELADO:
160
+ return {**output, "action": "NONE", "outcome": "CANCELADO",
161
+ "reason": "El proceso fue CANCELADO (--force) y el Issue "
162
+ "está cerrado."}
163
+ return {**output, "action": "ABORTED_EXTERNALLY", "resume_from_step": None,
164
+ "reason": "El Issue fue cerrado sin comentario de cierre; "
165
+ "consultar al usuario antes de reabrir o iniciar uno nuevo."}
166
+ if not events:
167
+ return {**output, "action": "START_FIRST",
168
+ "resume_from_step": plan[0] if plan else None,
169
+ "reason": "El Issue no tiene comentarios: comenzar desde el primer paso."}
170
+ last = events[-1]
171
+ output["last_status"] = last["status"]
172
+ if last["status"] == STATUS_FINALIZADO:
173
+ return {**output, "action": "FINISH_CLEANUP", "resume_from_step": None,
174
+ "outcome": "FINALIZADO",
175
+ "reason": "Último comentario FINALIZADO pero el Issue sigue abierto: "
176
+ "ejecutar finalize (idempotente) para cerrarlo y limpiar memoria."}
177
+ if last["status"] == STATUS_CANCELADO:
178
+ return {**output, "action": "FINISH_CLEANUP", "outcome": "CANCELADO",
179
+ "reason": "El proceso fue CANCELADO (--force); el Issue sigue "
180
+ "abierto: ejecutar finalize (idempotente) para "
181
+ "cerrarlo y limpiar memoria."}
182
+ if last["status"] in (STATUS_ERROR, STATUS_INICIANDO):
183
+ reason = ("El último paso registró ERROR: re-ejecutar ese paso (o el que "
184
+ "generó el error) y continuar con los siguientes."
185
+ if last["status"] == STATUS_ERROR else
186
+ "El último paso quedó en INICIANDO sin COMPLETADO: re-ejecutar ese paso.")
187
+ result = {**output, "action": "RERUN_STEP",
188
+ "resume_from_step": last.get("step"), "reason": reason}
189
+ if last["status"] == STATUS_ERROR:
190
+ result["failed_step"] = last.get("step")
191
+ result["error_details"] = (last.get("details") or "")[:2000]
192
+ return result
193
+ done = set(completed)
194
+ remaining = [step for step in plan if step not in done]
195
+ if remaining:
196
+ return {**output, "action": "NEXT_STEP", "resume_from_step": remaining[0],
197
+ "remaining_steps": remaining,
198
+ "reason": "Paso completado: continuar con el siguiente paso del plan."}
199
+ if plan:
200
+ return {**output, "action": "ALL_STEPS_DONE", "resume_from_step": None,
201
+ "reason": "Todos los pasos del plan están COMPLETADO: ejecutar finalize."}
202
+ return {**output, "action": "NEXT_STEP_AGENT_DEFINED", "resume_from_step": None,
203
+ "reason": "Sin plan explícito: el agente define el siguiente paso "
204
+ "o ejecuta finalize."}
@@ -0,0 +1,315 @@
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,
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
+ url = (f"{API_BASE}/repos/{owner}/{repo}/issues?state=all&sort=created"
299
+ "&direction=desc&per_page=100")
300
+ seen = set()
301
+ while url and url not in seen:
302
+ seen.add(url)
303
+ status, data, headers = api("GET", url, token)
304
+ if status != 200 or not isinstance(data, list):
305
+ return None
306
+ for issue in data:
307
+ if (issue.get("title") or "").strip() == title:
308
+ return issue
309
+ url = parse_link_next(headers.get("Link") if headers else None)
310
+ return None
311
+
312
+
313
+ def canonical_title_match(issue, execution_id):
314
+ match = TITLE_RE.match((issue.get("title") or "").strip())
315
+ return bool(match and match.group("exec_id").lower() == execution_id.lower())