github-proyect-tracking 0.0.0-stage → 0.4.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 +456 -2
- package/bin/build.js +42 -0
- package/bin/cli.js +359 -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,846 @@
|
|
|
1
|
+
"""Implementación de comandos sobre una instantánea remota coherente."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import github
|
|
12
|
+
from .common import (
|
|
13
|
+
EXIT_CONFLICT, EXIT_NO_PROCESS, EXIT_NO_TOKEN, EXIT_NOT_FOUND, EXIT_OK,
|
|
14
|
+
EXIT_RESUME_REQUIRED, EXIT_RUNTIME, EXIT_TOKEN_INSUFFICIENT,
|
|
15
|
+
EXIT_VALIDATION, MAX_DETAILS_LEN, MAX_PLAN_STEPS, SCHEMA_VERSION,
|
|
16
|
+
STATUS_CANCELADO, STATUS_COMPLETADO, STATUS_ERROR, STATUS_FINALIZADO,
|
|
17
|
+
STATUS_INICIANDO, SkillError, emit, new_execution_id, normalize_step_name,
|
|
18
|
+
now_iso, ordered_unique, redact_url, sanitize_text, validate_repo_url,
|
|
19
|
+
)
|
|
20
|
+
from .events import (
|
|
21
|
+
build_issue_body, events_from_comments, fmt_cancelado, fmt_completado,
|
|
22
|
+
fmt_error, fmt_finalizado, fmt_iniciando, parse_plan_from_body, plan_resume,
|
|
23
|
+
_validate_resume_plan,
|
|
24
|
+
)
|
|
25
|
+
from .state import (
|
|
26
|
+
StateLock, is_unfinished, list_state_files, load_state, public_state,
|
|
27
|
+
save_state, select_state_path, state_dir, state_file_for,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_target(args):
|
|
32
|
+
url = getattr(args, "url", None)
|
|
33
|
+
if url:
|
|
34
|
+
target = validate_repo_url(url)
|
|
35
|
+
if not target:
|
|
36
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
37
|
+
f"github_project_url inválido: {redact_url(url)} "
|
|
38
|
+
"(se requiere https://github.com/{owner}/{repo})")
|
|
39
|
+
owner, repo = target
|
|
40
|
+
return owner, repo, state_file_for(owner, repo)
|
|
41
|
+
path = select_state_path()
|
|
42
|
+
probe = load_state(path)
|
|
43
|
+
return probe["owner"], probe["repo"], path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_active_state(path):
|
|
47
|
+
state = load_state(path)
|
|
48
|
+
if not (state and state.get("active_process_id")):
|
|
49
|
+
raise SkillError(EXIT_NO_PROCESS, "NO_ACTIVE_PROCESS",
|
|
50
|
+
f"no hay proceso activo en {path}; ejecuta init primero")
|
|
51
|
+
return state
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def need_token(args, state=None):
|
|
55
|
+
token, source = github.resolve_token(getattr(args, "token", None), state)
|
|
56
|
+
if not token:
|
|
57
|
+
raise SkillError(EXIT_NO_TOKEN, "NO_TOKEN",
|
|
58
|
+
"no hay token de GitHub disponible. Fuentes aceptadas: "
|
|
59
|
+
"--token, $GITHUB_TRACKING_TOKEN, $GITHUB_TOKEN, $GH_TOKEN, "
|
|
60
|
+
"'gh auth token' o el archivo de estado (--store-token).",
|
|
61
|
+
{"how_to_fix": "pide al usuario un PAT con issues:write y "
|
|
62
|
+
"reintenta con --token o GITHUB_TOKEN"})
|
|
63
|
+
return token, source
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_plan(args):
|
|
67
|
+
raw = None
|
|
68
|
+
if getattr(args, "plan", None):
|
|
69
|
+
raw = args.plan
|
|
70
|
+
elif getattr(args, "plan_file", None):
|
|
71
|
+
try:
|
|
72
|
+
raw = Path(args.plan_file).read_text(encoding="utf-8")
|
|
73
|
+
except OSError as error:
|
|
74
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
75
|
+
f"no se pudo leer --plan-file: {error}")
|
|
76
|
+
if raw is None:
|
|
77
|
+
return []
|
|
78
|
+
try:
|
|
79
|
+
plan = json.loads(raw)
|
|
80
|
+
except json.JSONDecodeError as error:
|
|
81
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
82
|
+
f"--plan no es JSON válido: {error}")
|
|
83
|
+
if not isinstance(plan, list) or not all(isinstance(step, str) for step in plan):
|
|
84
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
85
|
+
'--plan debe ser una lista JSON de cadenas, '
|
|
86
|
+
'ej. \'["Paso 1: análisis", "Paso 2: build"]\'')
|
|
87
|
+
if len(plan) > MAX_PLAN_STEPS:
|
|
88
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
89
|
+
f"el plan no puede exceder {MAX_PLAN_STEPS} pasos")
|
|
90
|
+
plan = [normalize_step_name(step) for step in plan]
|
|
91
|
+
if len(plan) != len(set(plan)):
|
|
92
|
+
duplicates = sorted({step for step in plan if plan.count(step) > 1})
|
|
93
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
94
|
+
f"el plan contiene pasos duplicados (tras normalizar): "
|
|
95
|
+
f"{duplicates}; usa nombres únicos")
|
|
96
|
+
return plan
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _remote_snapshot(owner, repo, number, token):
|
|
100
|
+
issue = github.fetch_issue(owner, repo, number, token)
|
|
101
|
+
comments = github.fetch_all_comments(owner, repo, number, token)
|
|
102
|
+
events = events_from_comments(comments)
|
|
103
|
+
return issue, events, comments
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _plan_from_any(state, issue):
|
|
107
|
+
plan = state.get("plan")
|
|
108
|
+
if plan is None:
|
|
109
|
+
plan = parse_plan_from_body(issue.get("body") or "")
|
|
110
|
+
return _validate_resume_plan(plan)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _completed_from_events(events):
|
|
114
|
+
return ordered_unique(
|
|
115
|
+
event["step"] for event in events
|
|
116
|
+
if event["status"] == STATUS_COMPLETADO and event.get("step"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_init(args):
|
|
120
|
+
target = validate_repo_url(args.url)
|
|
121
|
+
if not target:
|
|
122
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
123
|
+
f"github_project_url inválido: {redact_url(args.url)} "
|
|
124
|
+
"(se requiere https://github.com/{owner}/{repo})")
|
|
125
|
+
owner, repo = target
|
|
126
|
+
path = state_file_for(owner, repo)
|
|
127
|
+
plan = load_plan(args)
|
|
128
|
+
|
|
129
|
+
lock = StateLock(path)
|
|
130
|
+
with lock:
|
|
131
|
+
existing = load_state(path)
|
|
132
|
+
if is_unfinished(existing):
|
|
133
|
+
raise SkillError(EXIT_RESUME_REQUIRED, "RESUME_REQUIRED",
|
|
134
|
+
f"ya existe un proceso inconcluso (Issue "
|
|
135
|
+
f"#{existing['active_process_id']}, estado "
|
|
136
|
+
f"{existing['process_state']}) en la memoria interna "
|
|
137
|
+
f"para {owner}/{repo}. Ejecuta 'resume' para reanudarlo.",
|
|
138
|
+
{"state": public_state(existing), "next": ["resume"]})
|
|
139
|
+
|
|
140
|
+
token, source = github.resolve_token(args.token, existing)
|
|
141
|
+
if not token:
|
|
142
|
+
if args.prompt_token and sys.stdin.isatty():
|
|
143
|
+
token = getpass.getpass("GitHub token (issues:write): ")
|
|
144
|
+
source = "prompt"
|
|
145
|
+
else:
|
|
146
|
+
raise SkillError(EXIT_NO_TOKEN, "NO_TOKEN",
|
|
147
|
+
"no hay token de GitHub disponible para crear el Issue.",
|
|
148
|
+
{"how_to_fix": "pide al usuario un PAT con permisos "
|
|
149
|
+
"issues:write y reintenta con --token "
|
|
150
|
+
"o GITHUB_TOKEN"})
|
|
151
|
+
ok, code, detail = github.check_issues_write(owner, repo, token)
|
|
152
|
+
if not ok:
|
|
153
|
+
raise SkillError(EXIT_TOKEN_INSUFFICIENT, code, detail)
|
|
154
|
+
store = bool(args.store_token) and source in ("cli", "prompt")
|
|
155
|
+
|
|
156
|
+
execution_id = new_execution_id()
|
|
157
|
+
title = f"[Process Tracking] Execution ID: {execution_id}"
|
|
158
|
+
body = build_issue_body(args.url.strip(), execution_id, plan)
|
|
159
|
+
recovered = False
|
|
160
|
+
create_error = None
|
|
161
|
+
lock.ensure_owner()
|
|
162
|
+
try:
|
|
163
|
+
status, data, _ = github.api(
|
|
164
|
+
"POST", f"/repos/{owner}/{repo}/issues", token,
|
|
165
|
+
{"title": title, "body": body})
|
|
166
|
+
if status in github.AMBIGUOUS_HTTP_STATUSES:
|
|
167
|
+
create_error = SkillError(
|
|
168
|
+
EXIT_RUNTIME, "ISSUE_CREATE_AMBIGUOUS",
|
|
169
|
+
f"el servidor devolvió {status}; el Issue pudo haberse creado")
|
|
170
|
+
elif status not in (200, 201):
|
|
171
|
+
if status == 403:
|
|
172
|
+
raise SkillError(EXIT_TOKEN_INSUFFICIENT, "ISSUES_WRITE_FAILED",
|
|
173
|
+
f"no se pudo crear el Issue (403): "
|
|
174
|
+
f"{data.get('message', '')} — el token no puede "
|
|
175
|
+
"escribir Issues en este repositorio")
|
|
176
|
+
raise SkillError(EXIT_RUNTIME, "ISSUE_CREATE_FAILED",
|
|
177
|
+
f"no se pudo crear el Issue ({status}): "
|
|
178
|
+
f"{data.get('message', '')}")
|
|
179
|
+
except SkillError as error:
|
|
180
|
+
if error.code != "NETWORK_ERROR":
|
|
181
|
+
raise
|
|
182
|
+
create_error = error
|
|
183
|
+
if create_error is not None:
|
|
184
|
+
issue = github.find_issue_by_title(owner, repo, title, token)
|
|
185
|
+
if issue is None:
|
|
186
|
+
extra = dict(create_error.extra)
|
|
187
|
+
extra.update({
|
|
188
|
+
"execution_id": execution_id,
|
|
189
|
+
"issue_title": title,
|
|
190
|
+
"hint": f"si el Issue existe, recupéralo con: adopt --url "
|
|
191
|
+
f"{args.url} --execution-id {execution_id}; no repitas init "
|
|
192
|
+
"hasta comprobarlo",
|
|
193
|
+
})
|
|
194
|
+
raise SkillError(create_error.exit_code, create_error.code,
|
|
195
|
+
create_error.message, extra)
|
|
196
|
+
data, recovered = issue, True
|
|
197
|
+
number = data["number"]
|
|
198
|
+
|
|
199
|
+
state = {
|
|
200
|
+
"schema_version": SCHEMA_VERSION,
|
|
201
|
+
"github_project_url": f"https://github.com/{owner}/{repo}",
|
|
202
|
+
"owner": owner, "repo": repo,
|
|
203
|
+
"active_process_id": number,
|
|
204
|
+
"execution_id": execution_id,
|
|
205
|
+
"issue_url": data.get("html_url"),
|
|
206
|
+
"process_state": "RUNNING",
|
|
207
|
+
"current_step": None,
|
|
208
|
+
"completed_steps": [],
|
|
209
|
+
"plan": plan,
|
|
210
|
+
"github_token": token if store else (existing or {}).get("github_token"),
|
|
211
|
+
"created_at": now_iso(),
|
|
212
|
+
"history": (existing or {}).get("history", []),
|
|
213
|
+
}
|
|
214
|
+
lock.ensure_owner()
|
|
215
|
+
try:
|
|
216
|
+
save_state(path, state)
|
|
217
|
+
except SkillError as error:
|
|
218
|
+
raise SkillError(EXIT_RUNTIME, "STATE_SAVE_FAILED", error.message, {
|
|
219
|
+
"execution_id": execution_id, "issue_number": number,
|
|
220
|
+
"issue_url": data.get("html_url"),
|
|
221
|
+
"hint": f"el Issue se creó pero la memoria local no se guardó; "
|
|
222
|
+
f"recupera con: adopt --url {args.url} --execution-id {execution_id}"})
|
|
223
|
+
emit({
|
|
224
|
+
"ok": True, "action": "INIT",
|
|
225
|
+
"execution_id": execution_id, "issue_number": number,
|
|
226
|
+
"issue_url": data.get("html_url"), "state_file": str(path),
|
|
227
|
+
"plan": plan, "first_step": plan[0] if plan else None,
|
|
228
|
+
"recovered": recovered,
|
|
229
|
+
"token_source": source, "token_stored": bool(state["github_token"]),
|
|
230
|
+
})
|
|
231
|
+
return EXIT_OK
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cmd_resume(args):
|
|
235
|
+
owner, repo, path = resolve_target(args)
|
|
236
|
+
lock = StateLock(path)
|
|
237
|
+
with lock:
|
|
238
|
+
state = load_active_state(path)
|
|
239
|
+
number = state["active_process_id"]
|
|
240
|
+
token, _ = need_token(args, state)
|
|
241
|
+
issue, events, comments = _remote_snapshot(owner, repo, number, token)
|
|
242
|
+
plan = state.get("plan") or parse_plan_from_body(issue.get("body") or "")
|
|
243
|
+
verdict = plan_resume(events, plan, issue.get("state", "open"))
|
|
244
|
+
|
|
245
|
+
action = verdict["action"]
|
|
246
|
+
if action == "NONE":
|
|
247
|
+
state["active_process_id"] = None
|
|
248
|
+
state["process_state"] = (
|
|
249
|
+
"CANCELADO" if verdict.get("outcome") == "CANCELADO"
|
|
250
|
+
else "FINALIZED")
|
|
251
|
+
elif action == "ABORTED_EXTERNALLY":
|
|
252
|
+
state["active_process_id"] = None
|
|
253
|
+
state["process_state"] = "ABORTED"
|
|
254
|
+
state["aborted"] = {"issue_number": number,
|
|
255
|
+
"issue_url": issue.get("html_url"),
|
|
256
|
+
"at": now_iso()}
|
|
257
|
+
else:
|
|
258
|
+
last_event = events[-1] if events else None
|
|
259
|
+
state["process_state"] = (
|
|
260
|
+
"ERROR" if last_event and last_event["status"] == STATUS_ERROR
|
|
261
|
+
else "RUNNING")
|
|
262
|
+
state["completed_steps"] = verdict.get("completed_steps", [])
|
|
263
|
+
state["current_step"] = verdict.get("resume_from_step")
|
|
264
|
+
last_event = events[-1] if events else None
|
|
265
|
+
state["last_resume"] = {
|
|
266
|
+
"verdict": action,
|
|
267
|
+
"at": now_iso(),
|
|
268
|
+
"issue_number": number,
|
|
269
|
+
"event_comment_id": last_event.get("comment_id") if last_event else None,
|
|
270
|
+
"event_transition_id": last_event.get("transition_id") if last_event else None,
|
|
271
|
+
"failed_step": verdict.get("failed_step"),
|
|
272
|
+
}
|
|
273
|
+
lock.ensure_owner()
|
|
274
|
+
save_state(path, state)
|
|
275
|
+
emit({
|
|
276
|
+
"ok": True, "action": "RESUME",
|
|
277
|
+
"owner": owner, "repo": repo, "issue_number": number,
|
|
278
|
+
"issue_url": issue.get("html_url"),
|
|
279
|
+
"issue_state": issue.get("state"),
|
|
280
|
+
"comment_count": len(comments), "verdict": verdict,
|
|
281
|
+
"state_file": str(path),
|
|
282
|
+
})
|
|
283
|
+
return EXIT_OK
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def cmd_step_start(args):
|
|
287
|
+
step = normalize_step_name(args.name)
|
|
288
|
+
owner, repo, path = resolve_target(args)
|
|
289
|
+
lock = StateLock(path)
|
|
290
|
+
with lock:
|
|
291
|
+
state = load_active_state(path)
|
|
292
|
+
token, _ = need_token(args, state)
|
|
293
|
+
number = state["active_process_id"]
|
|
294
|
+
issue, events, comments = _remote_snapshot(owner, repo, number, token)
|
|
295
|
+
if issue.get("state") != "open":
|
|
296
|
+
raise SkillError(EXIT_CONFLICT, "ISSUE_CLOSED_EXTERNALLY",
|
|
297
|
+
f"el Issue #{number} fue cerrado externamente",
|
|
298
|
+
{"issue_url": issue.get("html_url")})
|
|
299
|
+
last = events[-1] if events else None
|
|
300
|
+
if last and last["status"] in (STATUS_FINALIZADO, STATUS_CANCELADO):
|
|
301
|
+
raise SkillError(EXIT_CONFLICT, "ALREADY_FINALIZED",
|
|
302
|
+
f"el último evento remoto es {last['status']}; "
|
|
303
|
+
"usa finalize para cerrar y limpiar memoria")
|
|
304
|
+
|
|
305
|
+
completed = _completed_from_events(events)
|
|
306
|
+
plan = _plan_from_any(state, issue)
|
|
307
|
+
if last and last["status"] == STATUS_ERROR:
|
|
308
|
+
resumed = state.get("last_resume") or {}
|
|
309
|
+
authorized = (
|
|
310
|
+
resumed.get("verdict") == "RERUN_STEP"
|
|
311
|
+
and resumed.get("issue_number") == number
|
|
312
|
+
and resumed.get("event_comment_id") == last.get("comment_id")
|
|
313
|
+
and resumed.get("failed_step") == last.get("step"))
|
|
314
|
+
if not authorized:
|
|
315
|
+
raise SkillError(EXIT_RESUME_REQUIRED, "RESUME_REQUIRED",
|
|
316
|
+
"el último evento remoto es ERROR; ejecuta "
|
|
317
|
+
"'resume' antes de reintentar el paso")
|
|
318
|
+
if step != last.get("step"):
|
|
319
|
+
raise SkillError(EXIT_VALIDATION, "STEP_OUT_OF_ORDER",
|
|
320
|
+
f"después de ERROR debes reintentar '{last.get('step')}', "
|
|
321
|
+
f"no '{step}'")
|
|
322
|
+
if last and last["status"] == STATUS_INICIANDO \
|
|
323
|
+
and last.get("step") and last["step"] != step:
|
|
324
|
+
raise SkillError(EXIT_VALIDATION, "STEP_IN_FLIGHT",
|
|
325
|
+
f"el paso '{last['step']}' está en curso; completa "
|
|
326
|
+
"o erra ese paso antes de iniciar otro")
|
|
327
|
+
if plan:
|
|
328
|
+
if step in completed:
|
|
329
|
+
raise SkillError(EXIT_VALIDATION, "STEP_ALREADY_DONE",
|
|
330
|
+
f"el paso '{step}' ya está COMPLETADO",
|
|
331
|
+
{"completed_steps": completed})
|
|
332
|
+
if step not in plan:
|
|
333
|
+
raise SkillError(EXIT_VALIDATION, "STEP_NOT_IN_PLAN",
|
|
334
|
+
f"el paso '{step}' no pertenece al plan",
|
|
335
|
+
{"plan": plan})
|
|
336
|
+
expected = next((item for item in plan if item not in completed), None)
|
|
337
|
+
if expected and step != expected:
|
|
338
|
+
raise SkillError(EXIT_VALIDATION, "STEP_OUT_OF_ORDER",
|
|
339
|
+
f"el siguiente paso del plan es '{expected}', "
|
|
340
|
+
f"no '{step}'",
|
|
341
|
+
{"expected_step": expected,
|
|
342
|
+
"completed_steps": completed})
|
|
343
|
+
|
|
344
|
+
baseline = frozenset(comment.get("id") for comment in comments)
|
|
345
|
+
lock.ensure_owner()
|
|
346
|
+
comment = github.post_comment(
|
|
347
|
+
owner, repo, number, fmt_iniciando(step), token,
|
|
348
|
+
baseline_ids=baseline, execution_id=state.get("execution_id"),
|
|
349
|
+
sequence=len(events) + 1)
|
|
350
|
+
state["current_step"] = step
|
|
351
|
+
state["process_state"] = "RUNNING"
|
|
352
|
+
state["completed_steps"] = completed
|
|
353
|
+
state["plan"] = plan
|
|
354
|
+
lock.ensure_owner()
|
|
355
|
+
try:
|
|
356
|
+
save_state(path, state)
|
|
357
|
+
except SkillError as error:
|
|
358
|
+
raise SkillError(EXIT_RUNTIME, "STATE_SAVE_FAILED", error.message, {
|
|
359
|
+
"comment_url": comment["comment_url"],
|
|
360
|
+
"hint": "el comentario se publicó; ejecuta resume para "
|
|
361
|
+
"re-sincronizar la memoria desde el Issue"})
|
|
362
|
+
emit({"ok": True, "action": "STEP_START", "step": step,
|
|
363
|
+
"issue_number": number,
|
|
364
|
+
"comment_url": comment["comment_url"],
|
|
365
|
+
"reconciled": comment["reconciled"],
|
|
366
|
+
"process_state": "RUNNING", "state_file": str(path)})
|
|
367
|
+
return EXIT_OK
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _step_guard_check(issue, events, number):
|
|
371
|
+
if issue.get("state") != "open":
|
|
372
|
+
raise SkillError(EXIT_CONFLICT, "ISSUE_CLOSED_EXTERNALLY",
|
|
373
|
+
f"el Issue #{number} fue cerrado externamente",
|
|
374
|
+
{"issue_url": issue.get("html_url")})
|
|
375
|
+
last = events[-1] if events else None
|
|
376
|
+
if last and last["status"] == STATUS_ERROR:
|
|
377
|
+
raise SkillError(EXIT_RESUME_REQUIRED, "RESUME_REQUIRED",
|
|
378
|
+
"el último evento remoto es ERROR; ejecuta 'resume' "
|
|
379
|
+
"antes de cualquier otra transición")
|
|
380
|
+
if last and last["status"] in (STATUS_FINALIZADO, STATUS_CANCELADO):
|
|
381
|
+
raise SkillError(EXIT_CONFLICT, "ALREADY_FINALIZED",
|
|
382
|
+
f"el último evento remoto es {last['status']}; usa "
|
|
383
|
+
"finalize para cerrar y limpiar memoria")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _require_in_flight(last, step, what):
|
|
387
|
+
if not last or last["status"] != STATUS_INICIANDO \
|
|
388
|
+
or last.get("step") != step:
|
|
389
|
+
got = (f"{last['status']} - Paso: {last.get('step')}" if last
|
|
390
|
+
else "sin eventos")
|
|
391
|
+
raise SkillError(EXIT_VALIDATION, "NO_STEP_IN_FLIGHT",
|
|
392
|
+
f"{what} requiere que el último evento remoto sea "
|
|
393
|
+
f"'[STATUS: INICIANDO] - Paso: {step}'; el actual es: {got}. "
|
|
394
|
+
"Ejecuta step-start primero (o resume si el estado es incierto)")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _save_after_comment(lock, path, state, comment):
|
|
398
|
+
lock.ensure_owner()
|
|
399
|
+
try:
|
|
400
|
+
save_state(path, state)
|
|
401
|
+
except SkillError as error:
|
|
402
|
+
raise SkillError(EXIT_RUNTIME, "STATE_SAVE_FAILED", error.message, {
|
|
403
|
+
"comment_url": comment["comment_url"],
|
|
404
|
+
"hint": "el comentario se publicó; ejecuta resume para "
|
|
405
|
+
"re-sincronizar la memoria desde el Issue"})
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def cmd_step_done(args):
|
|
409
|
+
step = normalize_step_name(args.name)
|
|
410
|
+
owner, repo, path = resolve_target(args)
|
|
411
|
+
lock = StateLock(path)
|
|
412
|
+
with lock:
|
|
413
|
+
state = load_active_state(path)
|
|
414
|
+
token, _ = need_token(args, state)
|
|
415
|
+
number = state["active_process_id"]
|
|
416
|
+
issue, events, comments = _remote_snapshot(owner, repo, number, token)
|
|
417
|
+
_step_guard_check(issue, events, number)
|
|
418
|
+
last = events[-1] if events else None
|
|
419
|
+
_require_in_flight(last, step, "step-done")
|
|
420
|
+
baseline = frozenset(comment.get("id") for comment in comments)
|
|
421
|
+
lock.ensure_owner()
|
|
422
|
+
comment = github.post_comment(
|
|
423
|
+
owner, repo, number, fmt_completado(step), token,
|
|
424
|
+
baseline_ids=baseline, execution_id=state.get("execution_id"),
|
|
425
|
+
sequence=len(events) + 1)
|
|
426
|
+
state["current_step"] = None
|
|
427
|
+
state["process_state"] = "RUNNING"
|
|
428
|
+
state["completed_steps"] = ordered_unique(
|
|
429
|
+
_completed_from_events(events) + [step])
|
|
430
|
+
_save_after_comment(lock, path, state, comment)
|
|
431
|
+
emit({"ok": True, "action": "STEP_DONE", "step": step,
|
|
432
|
+
"issue_number": number, "comment_url": comment["comment_url"],
|
|
433
|
+
"reconciled": comment["reconciled"], "process_state": "RUNNING",
|
|
434
|
+
"state_file": str(path)})
|
|
435
|
+
return EXIT_OK
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def cmd_step_error(args):
|
|
439
|
+
step = normalize_step_name(args.name)
|
|
440
|
+
if getattr(args, "details", None):
|
|
441
|
+
details = args.details
|
|
442
|
+
elif getattr(args, "details_file", None):
|
|
443
|
+
try:
|
|
444
|
+
details = Path(args.details_file).read_text(encoding="utf-8",
|
|
445
|
+
errors="replace")
|
|
446
|
+
except OSError as error:
|
|
447
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
448
|
+
f"no se pudo leer --details-file: {error}")
|
|
449
|
+
else:
|
|
450
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
451
|
+
"step-error requiere --details o --details-file "
|
|
452
|
+
"(mensaje de error o stacktrace)")
|
|
453
|
+
details = details.strip()
|
|
454
|
+
if not details:
|
|
455
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
456
|
+
"los detalles del error no pueden estar vacíos")
|
|
457
|
+
details, redacted = sanitize_text(details)
|
|
458
|
+
details = details[:MAX_DETAILS_LEN]
|
|
459
|
+
|
|
460
|
+
owner, repo, path = resolve_target(args)
|
|
461
|
+
lock = StateLock(path)
|
|
462
|
+
with lock:
|
|
463
|
+
state = load_active_state(path)
|
|
464
|
+
token, _ = need_token(args, state)
|
|
465
|
+
number = state["active_process_id"]
|
|
466
|
+
issue, events, comments = _remote_snapshot(owner, repo, number, token)
|
|
467
|
+
_step_guard_check(issue, events, number)
|
|
468
|
+
last = events[-1] if events else None
|
|
469
|
+
_require_in_flight(last, step, "step-error")
|
|
470
|
+
baseline = frozenset(comment.get("id") for comment in comments)
|
|
471
|
+
lock.ensure_owner()
|
|
472
|
+
comment = github.post_comment(
|
|
473
|
+
owner, repo, number, fmt_error(step, details), token,
|
|
474
|
+
baseline_ids=baseline, execution_id=state.get("execution_id"),
|
|
475
|
+
sequence=len(events) + 1)
|
|
476
|
+
state["current_step"] = step
|
|
477
|
+
state["process_state"] = "ERROR"
|
|
478
|
+
state["last_error"] = {"step": step, "at": now_iso()}
|
|
479
|
+
_save_after_comment(lock, path, state, comment)
|
|
480
|
+
output = {"ok": True, "action": "STEP_ERROR", "step": step,
|
|
481
|
+
"issue_number": number, "comment_url": comment["comment_url"],
|
|
482
|
+
"reconciled": comment["reconciled"],
|
|
483
|
+
"process_state": "ERROR", "stopped": True,
|
|
484
|
+
"state_file": str(path)}
|
|
485
|
+
if redacted:
|
|
486
|
+
output["redacted_secrets"] = redacted
|
|
487
|
+
emit(output)
|
|
488
|
+
return EXIT_OK
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
_PENDING_VERDICTS = ("RERUN_STEP", "NEXT_STEP", "START_FIRST",
|
|
492
|
+
"NEXT_STEP_AGENT_DEFINED")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def cmd_finalize(args):
|
|
496
|
+
owner, repo, path = resolve_target(args)
|
|
497
|
+
lock = StateLock(path)
|
|
498
|
+
with lock:
|
|
499
|
+
state = load_active_state(path)
|
|
500
|
+
number = state["active_process_id"]
|
|
501
|
+
token, _ = need_token(args, state)
|
|
502
|
+
issue, events, comments = _remote_snapshot(owner, repo, number, token)
|
|
503
|
+
plan = _plan_from_any(state, issue)
|
|
504
|
+
verdict = plan_resume(events, plan, issue.get("state", "open"))
|
|
505
|
+
action = verdict["action"]
|
|
506
|
+
completed = verdict.get("completed_steps", [])
|
|
507
|
+
if action == "ABORTED_EXTERNALLY":
|
|
508
|
+
raise SkillError(EXIT_CONFLICT, "ABORTED_EXTERNALLY",
|
|
509
|
+
"el Issue fue cerrado sin comentario de cierre; no "
|
|
510
|
+
"se marca como exitoso. Decide con el usuario: "
|
|
511
|
+
"reabrir y continuar, o clear --hard para descartar",
|
|
512
|
+
{"issue_url": issue.get("html_url"),
|
|
513
|
+
"verdict": verdict})
|
|
514
|
+
last = events[-1] if events else None
|
|
515
|
+
already_done = bool(last) and last["status"] == STATUS_FINALIZADO
|
|
516
|
+
already_cancel = bool(last) and last["status"] == STATUS_CANCELADO
|
|
517
|
+
if last and last["status"] == STATUS_ERROR and not args.force:
|
|
518
|
+
raise SkillError(EXIT_RESUME_REQUIRED, "RESUME_REQUIRED",
|
|
519
|
+
"el último evento remoto es ERROR; ejecuta 'resume' "
|
|
520
|
+
"y completa el paso fallido antes de finalizar "
|
|
521
|
+
"(o --force para cancelar)")
|
|
522
|
+
|
|
523
|
+
no_plan_complete = (not plan and action == "NEXT_STEP_AGENT_DEFINED"
|
|
524
|
+
and last and last["status"] == STATUS_COMPLETADO)
|
|
525
|
+
pending_action = action in _PENDING_VERDICTS and not no_plan_complete
|
|
526
|
+
if pending_action and not args.force:
|
|
527
|
+
raise SkillError(EXIT_CONFLICT, "FINALIZE_BLOCKED",
|
|
528
|
+
"el proceso no ha completado todos los pasos",
|
|
529
|
+
{"verdict": verdict,
|
|
530
|
+
"hint": "completa los pasos pendientes, o usa "
|
|
531
|
+
"--force para cancelar ahora "
|
|
532
|
+
"(se registrará CANCELADO, no éxito)"})
|
|
533
|
+
forced = pending_action and args.force
|
|
534
|
+
outcome = "CANCELADO" if (forced or already_cancel) else "FINALIZADO"
|
|
535
|
+
|
|
536
|
+
lock.ensure_owner()
|
|
537
|
+
comment = None
|
|
538
|
+
if forced and not already_cancel:
|
|
539
|
+
pending = verdict.get("remaining_steps") or (
|
|
540
|
+
[step for step in plan if step not in completed] if plan else [])
|
|
541
|
+
comment = github.post_comment(
|
|
542
|
+
owner, repo, number, fmt_cancelado(pending), token,
|
|
543
|
+
baseline_ids=frozenset(item.get("id") for item in comments),
|
|
544
|
+
execution_id=state.get("execution_id"), sequence=len(events) + 1)
|
|
545
|
+
elif not already_done and not already_cancel and not forced:
|
|
546
|
+
comment = github.post_comment(
|
|
547
|
+
owner, repo, number, fmt_finalizado(), token,
|
|
548
|
+
baseline_ids=frozenset(item.get("id") for item in comments),
|
|
549
|
+
execution_id=state.get("execution_id"), sequence=len(events) + 1)
|
|
550
|
+
github.close_issue(owner, repo, number, token)
|
|
551
|
+
issue = github.fetch_issue(owner, repo, number, token)
|
|
552
|
+
if issue.get("state") != "closed":
|
|
553
|
+
raise SkillError(EXIT_RUNTIME, "CLOSE_FAILED",
|
|
554
|
+
f"el Issue #{number} no quedó cerrado tras el PATCH "
|
|
555
|
+
f"(state={issue.get('state')})")
|
|
556
|
+
|
|
557
|
+
history = state.get("history") or []
|
|
558
|
+
history.append({
|
|
559
|
+
"execution_id": state.get("execution_id"),
|
|
560
|
+
"issue_number": number,
|
|
561
|
+
"issue_url": state.get("issue_url") or issue.get("html_url"),
|
|
562
|
+
"finished_at": now_iso(),
|
|
563
|
+
"forced": outcome == "CANCELADO",
|
|
564
|
+
"outcome": outcome,
|
|
565
|
+
})
|
|
566
|
+
state.update({
|
|
567
|
+
"active_process_id": None,
|
|
568
|
+
"process_state": "CANCELADO" if outcome == "CANCELADO" else "FINALIZED",
|
|
569
|
+
"current_step": None,
|
|
570
|
+
"completed_steps": completed,
|
|
571
|
+
"github_token": state.get("github_token"),
|
|
572
|
+
"history": history[-20:],
|
|
573
|
+
})
|
|
574
|
+
lock.ensure_owner()
|
|
575
|
+
try:
|
|
576
|
+
save_state(path, state)
|
|
577
|
+
except SkillError as error:
|
|
578
|
+
raise SkillError(EXIT_RUNTIME, "STATE_SAVE_FAILED", error.message, {
|
|
579
|
+
"issue_url": issue.get("html_url"),
|
|
580
|
+
"hint": "el Issue está cerrado y finalizado/cancelado; ejecuta "
|
|
581
|
+
"clear para limpiar la memoria local"})
|
|
582
|
+
emit({
|
|
583
|
+
"ok": True, "action": "FINALIZE", "issue_number": number,
|
|
584
|
+
"issue_url": issue.get("html_url"), "issue_state": "closed",
|
|
585
|
+
"outcome": outcome,
|
|
586
|
+
"forced": outcome == "CANCELADO",
|
|
587
|
+
"comment_posted": bool(comment),
|
|
588
|
+
"comment_url": comment["comment_url"] if comment else None,
|
|
589
|
+
"completed_steps": completed,
|
|
590
|
+
"active_process_id": None,
|
|
591
|
+
"state_file": str(path),
|
|
592
|
+
})
|
|
593
|
+
return EXIT_OK
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def cmd_status(args):
|
|
597
|
+
url = getattr(args, "url", None)
|
|
598
|
+
if url:
|
|
599
|
+
target = validate_repo_url(url)
|
|
600
|
+
if not target:
|
|
601
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
602
|
+
f"github_project_url inválido: {redact_url(url)}")
|
|
603
|
+
owner, repo = target
|
|
604
|
+
path = state_file_for(owner, repo)
|
|
605
|
+
state = load_state(path)
|
|
606
|
+
else:
|
|
607
|
+
try:
|
|
608
|
+
path = select_state_path()
|
|
609
|
+
except SkillError as error:
|
|
610
|
+
if error.code == "CONTEXT_AMBIGUOUS":
|
|
611
|
+
emit({"ok": True, "action": "STATUS",
|
|
612
|
+
"state": None, "state_files": [
|
|
613
|
+
item.name for item in list_state_files()],
|
|
614
|
+
"error_hint": {"code": error.code,
|
|
615
|
+
"message": error.message,
|
|
616
|
+
"active_repos": error.extra.get("active_repos")}})
|
|
617
|
+
return EXIT_OK
|
|
618
|
+
raise
|
|
619
|
+
state = load_state(path)
|
|
620
|
+
owner = state.get("owner") if state else None
|
|
621
|
+
repo = state.get("repo") if state else None
|
|
622
|
+
|
|
623
|
+
output = {"ok": True, "action": "STATUS",
|
|
624
|
+
"state_file": str(path) if path else None,
|
|
625
|
+
"state": public_state(state),
|
|
626
|
+
"state_files": [item.name for item in list_state_files()]}
|
|
627
|
+
if args.remote and state and state.get("active_process_id"):
|
|
628
|
+
token, _ = need_token(args, state)
|
|
629
|
+
number = state["active_process_id"]
|
|
630
|
+
issue = github.fetch_issue(owner, repo, number, token)
|
|
631
|
+
events = events_from_comments(
|
|
632
|
+
github.fetch_all_comments(owner, repo, number, token))
|
|
633
|
+
plan = state.get("plan") or parse_plan_from_body(issue.get("body") or "")
|
|
634
|
+
output["remote"] = {
|
|
635
|
+
"issue_number": number, "issue_state": issue.get("state"),
|
|
636
|
+
"verdict": plan_resume(events, plan, issue.get("state", "open")),
|
|
637
|
+
}
|
|
638
|
+
emit(output)
|
|
639
|
+
return EXIT_OK
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def cmd_adopt(args):
|
|
643
|
+
target = validate_repo_url(args.url)
|
|
644
|
+
if not target:
|
|
645
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
646
|
+
f"github_project_url inválido: {redact_url(args.url)}")
|
|
647
|
+
owner, repo = target
|
|
648
|
+
path = state_file_for(owner, repo)
|
|
649
|
+
execution_id = (args.execution_id or "").strip()
|
|
650
|
+
if not execution_id:
|
|
651
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
652
|
+
"--execution-id es obligatorio (está en el título del Issue)")
|
|
653
|
+
existing = load_state(path)
|
|
654
|
+
token, _ = need_token(args, existing)
|
|
655
|
+
|
|
656
|
+
query = (f'repo:{owner}/{repo} "[Process Tracking] Execution ID: {execution_id}" '
|
|
657
|
+
f'in:title is:issue')
|
|
658
|
+
status, data, _ = github.api(
|
|
659
|
+
"GET", "/search/issues?" + urllib.parse.urlencode({"q": query, "per_page": 30}),
|
|
660
|
+
token)
|
|
661
|
+
if status != 200:
|
|
662
|
+
raise SkillError(EXIT_RUNTIME, "SEARCH_FAILED",
|
|
663
|
+
f"búsqueda de Issues devolvió {status}: "
|
|
664
|
+
f"{data.get('message', '')}")
|
|
665
|
+
matches = [issue for issue in (data.get("items") or [])
|
|
666
|
+
if github.canonical_title_match(issue, execution_id)]
|
|
667
|
+
if not matches:
|
|
668
|
+
raise SkillError(EXIT_NOT_FOUND, "ISSUE_NOT_FOUND",
|
|
669
|
+
f"no se encontró un Issue con título canónico para "
|
|
670
|
+
f"Execution ID {execution_id} en {owner}/{repo}")
|
|
671
|
+
if len(matches) > 1:
|
|
672
|
+
raise SkillError(EXIT_NOT_FOUND, "ISSUE_AMBIGUOUS",
|
|
673
|
+
"múltiples Issues con el mismo Execution ID; revisa "
|
|
674
|
+
"manualmente cuál adoptar",
|
|
675
|
+
{"issues": [issue.get("html_url") for issue in matches]})
|
|
676
|
+
issue = matches[0]
|
|
677
|
+
|
|
678
|
+
lock = StateLock(path)
|
|
679
|
+
with lock:
|
|
680
|
+
existing = load_state(path)
|
|
681
|
+
if is_unfinished(existing) and not args.force:
|
|
682
|
+
raise SkillError(EXIT_RESUME_REQUIRED, "ADOPT_CONFLICT",
|
|
683
|
+
f"ya hay un proceso activo (Issue "
|
|
684
|
+
f"#{existing['active_process_id']}); usa --force "
|
|
685
|
+
"para reemplazarlo (se conservan token e historial)",
|
|
686
|
+
{"active_issue": existing["active_process_id"],
|
|
687
|
+
"next": ["resume", "adopt --force"]})
|
|
688
|
+
number = issue["number"]
|
|
689
|
+
comments = github.fetch_all_comments(owner, repo, number, token)
|
|
690
|
+
events = events_from_comments(comments)
|
|
691
|
+
plan = parse_plan_from_body(issue.get("body") or "")
|
|
692
|
+
verdict = plan_resume(events, plan, issue.get("state", "open"))
|
|
693
|
+
action = verdict["action"]
|
|
694
|
+
if issue.get("state") == "open":
|
|
695
|
+
active = number
|
|
696
|
+
process_state = ("ERROR" if events and events[-1]["status"] == STATUS_ERROR
|
|
697
|
+
else "RUNNING")
|
|
698
|
+
elif action == "NONE":
|
|
699
|
+
active = None
|
|
700
|
+
process_state = ("CANCELADO" if verdict.get("outcome") == "CANCELADO"
|
|
701
|
+
else "FINALIZED")
|
|
702
|
+
else:
|
|
703
|
+
active, process_state = None, "ABORTED"
|
|
704
|
+
|
|
705
|
+
state = {
|
|
706
|
+
"schema_version": SCHEMA_VERSION,
|
|
707
|
+
"github_project_url": f"https://github.com/{owner}/{repo}",
|
|
708
|
+
"owner": owner, "repo": repo,
|
|
709
|
+
"active_process_id": active,
|
|
710
|
+
"execution_id": execution_id,
|
|
711
|
+
"issue_url": issue.get("html_url"),
|
|
712
|
+
"process_state": process_state,
|
|
713
|
+
"current_step": verdict.get("resume_from_step"),
|
|
714
|
+
"completed_steps": verdict.get("completed_steps", []),
|
|
715
|
+
"plan": plan,
|
|
716
|
+
"github_token": (existing or {}).get("github_token"),
|
|
717
|
+
"history": (existing or {}).get("history", []),
|
|
718
|
+
"created_at": now_iso(), "adopted": True,
|
|
719
|
+
}
|
|
720
|
+
if process_state == "ABORTED":
|
|
721
|
+
state["aborted"] = {"issue_number": number,
|
|
722
|
+
"issue_url": issue.get("html_url"),
|
|
723
|
+
"at": now_iso()}
|
|
724
|
+
lock.ensure_owner()
|
|
725
|
+
save_state(path, state)
|
|
726
|
+
emit({
|
|
727
|
+
"ok": True, "action": "ADOPT", "execution_id": execution_id,
|
|
728
|
+
"issue_number": number, "issue_url": issue.get("html_url"),
|
|
729
|
+
"issue_state": issue.get("state"), "verdict": verdict,
|
|
730
|
+
"process_state": process_state, "state_file": str(path),
|
|
731
|
+
})
|
|
732
|
+
return EXIT_OK
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def cmd_clear(args):
|
|
736
|
+
url = getattr(args, "url", None)
|
|
737
|
+
if url:
|
|
738
|
+
target = validate_repo_url(url)
|
|
739
|
+
if not target:
|
|
740
|
+
raise SkillError(EXIT_VALIDATION, "VALIDATION_ERROR",
|
|
741
|
+
f"github_project_url inválido: {redact_url(url)}")
|
|
742
|
+
path = state_file_for(*target)
|
|
743
|
+
else:
|
|
744
|
+
path = select_state_path(allow_recent=False)
|
|
745
|
+
|
|
746
|
+
lock = StateLock(path)
|
|
747
|
+
with lock:
|
|
748
|
+
state = load_state(path)
|
|
749
|
+
if state is None:
|
|
750
|
+
emit({"ok": True, "action": "CLEAR", "cleared": False,
|
|
751
|
+
"detail": f"no hay estado local en {path}"})
|
|
752
|
+
return EXIT_OK
|
|
753
|
+
was_unfinished = is_unfinished(state)
|
|
754
|
+
number = state.get("active_process_id")
|
|
755
|
+
lock.ensure_owner()
|
|
756
|
+
if args.hard:
|
|
757
|
+
try:
|
|
758
|
+
path.unlink()
|
|
759
|
+
except OSError as error:
|
|
760
|
+
raise SkillError(EXIT_RUNTIME, "STATE_UNREADABLE",
|
|
761
|
+
f"no se pudo eliminar {path}: {error}")
|
|
762
|
+
action = "CLEAR_HARD"
|
|
763
|
+
else:
|
|
764
|
+
state.update({"active_process_id": None,
|
|
765
|
+
"process_state": "CLEARED", "current_step": None,
|
|
766
|
+
"cleared_at": now_iso()})
|
|
767
|
+
save_state(path, state)
|
|
768
|
+
action = "CLEAR_SOFT"
|
|
769
|
+
output = {"ok": True, "action": action, "state_file": str(path)}
|
|
770
|
+
if was_unfinished and number:
|
|
771
|
+
output["warning"] = (f"se descartó un proceso INCONCLUSO (Issue #{number}); "
|
|
772
|
+
"el Issue permanece abierto en GitHub (usa 'adopt' "
|
|
773
|
+
"para recuperarlo si fue un error)")
|
|
774
|
+
emit(output)
|
|
775
|
+
return EXIT_OK
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def cmd_doctor(args):
|
|
779
|
+
checks = []
|
|
780
|
+
|
|
781
|
+
def add(name, ok, detail, required=True):
|
|
782
|
+
checks.append({"check": name, "ok": bool(ok),
|
|
783
|
+
"detail": str(detail), "required": bool(required)})
|
|
784
|
+
|
|
785
|
+
add("python", sys.version_info >= (3, 8), sys.version.split()[0])
|
|
786
|
+
directory = state_dir()
|
|
787
|
+
try:
|
|
788
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
789
|
+
probe = directory / ".write-probe"
|
|
790
|
+
probe.write_text("ok", encoding="utf-8")
|
|
791
|
+
probe.unlink()
|
|
792
|
+
add("state-dir-writable", True,
|
|
793
|
+
f"{directory} (crea el directorio y un archivo de prueba: efecto de "
|
|
794
|
+
"escritura intencional)")
|
|
795
|
+
except OSError as error:
|
|
796
|
+
add("state-dir-writable", False, f"{directory}: {error}")
|
|
797
|
+
|
|
798
|
+
state_for_token = None
|
|
799
|
+
url = getattr(args, "url", None)
|
|
800
|
+
if url:
|
|
801
|
+
target = validate_repo_url(url)
|
|
802
|
+
if not target:
|
|
803
|
+
add("repo-url", False, f"inválida: {redact_url(url)}")
|
|
804
|
+
else:
|
|
805
|
+
path = state_file_for(*target)
|
|
806
|
+
state_for_token = load_state(path) if path.exists() else None
|
|
807
|
+
try:
|
|
808
|
+
token, source = github.resolve_token(getattr(args, "token", None),
|
|
809
|
+
state_for_token)
|
|
810
|
+
except Exception as error:
|
|
811
|
+
token, source = None, f"error: {error}"
|
|
812
|
+
add("token", bool(token),
|
|
813
|
+
f"source={source}" if token else
|
|
814
|
+
"sin token (usa --token, GITHUB_TOKEN/GH_TOKEN, --store-token o "
|
|
815
|
+
"'gh auth login')")
|
|
816
|
+
|
|
817
|
+
if token:
|
|
818
|
+
try:
|
|
819
|
+
status, data, _ = github.api("GET", "/rate_limit", token)
|
|
820
|
+
add("github-api", status == 200, f"HTTP {status}")
|
|
821
|
+
if status == 200:
|
|
822
|
+
core = (data.get("resources") or {}).get("core") or {}
|
|
823
|
+
add("rate-limit", True,
|
|
824
|
+
f"remaining={core.get('remaining')}/{core.get('limit')}",
|
|
825
|
+
required=False)
|
|
826
|
+
except SkillError as error:
|
|
827
|
+
add("github-api", False, error.message)
|
|
828
|
+
add("gh-cli", github.shutil.which("gh") is not None,
|
|
829
|
+
"presente (fuente opcional de token)" if github.shutil.which("gh")
|
|
830
|
+
else "ausente (opcional: solo fuente alternativa de token)",
|
|
831
|
+
required=False)
|
|
832
|
+
add("state-files", True,
|
|
833
|
+
", ".join(path.name for path in list_state_files()) or "ninguno",
|
|
834
|
+
required=False)
|
|
835
|
+
|
|
836
|
+
if url:
|
|
837
|
+
target = validate_repo_url(url)
|
|
838
|
+
if target:
|
|
839
|
+
add("repo-url", True, f"https://github.com/{target[0]}/{target[1]}")
|
|
840
|
+
if token:
|
|
841
|
+
ok, code, detail = github.check_issues_write(target[0], target[1], token)
|
|
842
|
+
add("issues-write", ok, f"{code}: {detail}")
|
|
843
|
+
|
|
844
|
+
emit({"ok": all(check["ok"] for check in checks if check["required"]),
|
|
845
|
+
"doctor": checks})
|
|
846
|
+
return EXIT_OK
|