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.
- package/LICENSE +21 -0
- package/README.md +456 -2
- package/bin/build.js +42 -0
- package/bin/cli.js +317 -0
- package/package.json +35 -4
- package/skill/SKILL.md +171 -0
- package/skill/references/api.md +163 -0
- package/skill/references/architecture.md +171 -0
- package/skill/scripts/tracking.py +196 -0
- package/skill/scripts/tracking_core/__init__.py +1 -0
- package/skill/scripts/tracking_core/commands.py +846 -0
- package/skill/scripts/tracking_core/common.py +163 -0
- package/skill/scripts/tracking_core/events.py +204 -0
- package/skill/scripts/tracking_core/github.py +315 -0
- package/skill/scripts/tracking_core/state.py +379 -0
|
@@ -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())
|