okstra 0.158.1 → 0.160.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/README.md +1 -1
- package/docs/architecture/storage-model.md +2 -0
- package/docs/architecture.md +1 -1
- package/docs/cli.md +8 -3
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-inspect.md +3 -0
- package/docs/for-ai/skills/okstra-run.md +2 -1
- package/docs/for-ai/skills/okstra-user-response.md +5 -5
- package/docs/project-structure-overview.md +5 -1
- package/docs/task-process/implementation.md +28 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-claude-exec.sh +4 -1
- package/runtime/prompts/host-orchestration/README.md +18 -0
- package/runtime/prompts/host-orchestration/implementation.md +57 -0
- package/runtime/prompts/launch.template.md +10 -1
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/context-loader.md +5 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/plan-body-verification.md +21 -2
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +2 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +3 -0
- package/runtime/python/okstra_ctl/clarification_items.py +9 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
- package/runtime/python/okstra_ctl/convergence.py +168 -11
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
- package/runtime/python/okstra_ctl/error_issue.py +640 -0
- package/runtime/python/okstra_ctl/error_report.py +56 -0
- package/runtime/python/okstra_ctl/error_zip.py +23 -10
- package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
- package/runtime/python/okstra_ctl/issue_signals.py +186 -0
- package/runtime/python/okstra_ctl/paths.py +38 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
- package/runtime/python/okstra_ctl/profile_show.py +134 -0
- package/runtime/python/okstra_ctl/recap.py +63 -0
- package/runtime/python/okstra_ctl/render_final_report.py +11 -62
- package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
- package/runtime/python/okstra_ctl/report_html/render.py +9 -8
- package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
- package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
- package/runtime/python/okstra_ctl/report_translation.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +7 -3
- package/runtime/python/okstra_ctl/run.py +41 -2
- package/runtime/python/okstra_ctl/run_audit.py +477 -0
- package/runtime/python/okstra_ctl/usage_cells.py +47 -0
- package/runtime/python/okstra_ctl/user_response.py +25 -10
- package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
- package/runtime/python/okstra_ctl/wizard.py +64 -10
- package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
- package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
- package/runtime/schemas/final-report-v1.0.schema.json +14 -0
- package/runtime/schemas/final-report-v2.0.schema.json +56 -2
- package/runtime/skills/okstra-inspect/SKILL.md +3 -1
- package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
- package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
- package/runtime/skills/okstra-run/SKILL.md +28 -10
- package/runtime/skills/okstra-user-response/SKILL.md +18 -18
- package/runtime/templates/reports/final-report.template.md +4 -0
- package/runtime/templates/reports/html/assets/base.css +14 -1
- package/runtime/templates/reports/html/base.template.html +42 -0
- package/runtime/templates/reports/html/i18n/en.json +30 -1
- package/runtime/templates/reports/html/i18n/ko.json +30 -1
- package/runtime/templates/reports/html/macros/forms.html +15 -0
- package/runtime/templates/reports/html/macros/visualizations.html +3 -2
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
- package/runtime/templates/reports/i18n/en.json +2 -0
- package/runtime/validators/validate-run.py +331 -208
- package/runtime/validators/validate_session_conformance.py +102 -32
- package/src/cli-registry.mjs +34 -0
- package/src/commands/execute/incremental-scope.mjs +10 -0
- package/src/commands/execute/worker-audit-check.mjs +35 -0
- package/src/commands/inspect/error-issue.mjs +27 -0
- package/src/commands/inspect/profile-show.mjs +29 -0
- package/src/commands/inspect/run-audit.mjs +26 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""런 이상을 GitHub 이슈 후보로 만들고(plan), 승인 뒤 등록한다(submit).
|
|
2
|
+
|
|
3
|
+
plan 은 결정적 Python 이고 submit 은 plan.json 만 읽는다 — 승인 화면에서 본
|
|
4
|
+
것과 실제 올라가는 것이 같음을 이 경계가 보장한다.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from okstra_ctl.error_zip import anonymize, build_clusters, collect_records
|
|
18
|
+
from okstra_ctl.issue_signals import (
|
|
19
|
+
FREETEXT_SOURCE, MIN_SUPPORTING_SIGNALS, SELF_SUFFICIENT_SIGNALS,
|
|
20
|
+
SIGNAL_SUPPORT, WEAK_SIGNALS, classify, compute_signals, signal_fires,
|
|
21
|
+
)
|
|
22
|
+
from okstra_ctl.paths import okstra_home
|
|
23
|
+
from okstra_ctl.run_audit import INVARIANT_APPROVAL_NOT_FORGOTTEN, audit_runs
|
|
24
|
+
|
|
25
|
+
_CONFIG_NAME = "error-issue.json"
|
|
26
|
+
DEFAULT_REPO = "Devonshin/okstra"
|
|
27
|
+
DEFAULT_THRESHOLDS = {"minCount": 3, "recentDays": 14}
|
|
28
|
+
FINGERPRINT_MARKER = "okstra-fingerprint"
|
|
29
|
+
ISSUE_LABEL = "okstra-error-feedback"
|
|
30
|
+
|
|
31
|
+
# 불변식 하나가 몇 개의 태스크에서 깨져야 okstra 의 결함으로 보는가.
|
|
32
|
+
AUDIT_MIN_TASKS = 2
|
|
33
|
+
|
|
34
|
+
# 확산돼도 okstra 의 결함이 아닌 불변식. `approval-not-forgotten` 은 사람이
|
|
35
|
+
# 승인을 아직 안 했다는 상태이지 okstra 가 뭘 잘못한 게 아니다 — 실측에서 이
|
|
36
|
+
# 하나가 28개 태스크에서 깨져 있었고, 그대로 두면 "승인해 주세요"가 공개 이슈로
|
|
37
|
+
# 올라간다. 감사 리포트에는 그대로 남고 이슈 후보에서만 빠진다.
|
|
38
|
+
#
|
|
39
|
+
# 이름은 `run_audit` 의 상수를 그대로 쓴다. 여기에 리터럴을 다시 적으면 그쪽이
|
|
40
|
+
# 이름을 바꿀 때 방출부만 따라가고 이 필터는 뚫린 채 남는다 — 스위트는 green 인
|
|
41
|
+
# 상태로 승인 대기가 공개 이슈가 된다.
|
|
42
|
+
NEVER_AN_ISSUE = frozenset({INVARIANT_APPROVAL_NOT_FORGOTTEN})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_config(home: Path) -> dict:
|
|
46
|
+
cfg = {"repo": DEFAULT_REPO, **DEFAULT_THRESHOLDS}
|
|
47
|
+
path = home / _CONFIG_NAME
|
|
48
|
+
if not path.is_file():
|
|
49
|
+
return cfg
|
|
50
|
+
try:
|
|
51
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
except (OSError, json.JSONDecodeError):
|
|
53
|
+
return cfg
|
|
54
|
+
if isinstance(loaded, dict):
|
|
55
|
+
cfg.update({k: v for k, v in loaded.items() if k in cfg})
|
|
56
|
+
return cfg
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fingerprint(cluster_key_value: str) -> str:
|
|
60
|
+
return hashlib.sha256(cluster_key_value.encode("utf-8")).hexdigest()[:12]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_ts(value) -> dt.datetime | None:
|
|
64
|
+
if not value:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
parsed = dt.datetime.fromisoformat(str(value))
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=dt.timezone.utc)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def passes_gate(cluster: dict, *, now: dt.datetime, thresholds: dict) -> bool:
|
|
74
|
+
"""발생 횟수와 최종 관측 시각 두 조건을 모두 만족해야 후보가 된다.
|
|
75
|
+
|
|
76
|
+
lastSeen 이 없거나 파싱되지 않으면 신선하다고 가정하지 않고 거부한다 —
|
|
77
|
+
시각을 모르는 클러스터를 최근 것으로 취급하면 게이트가 무의미해진다."""
|
|
78
|
+
if int(cluster.get("count", 0)) < int(thresholds["minCount"]):
|
|
79
|
+
return False
|
|
80
|
+
last_seen = _parse_ts(cluster.get("lastSeen"))
|
|
81
|
+
if last_seen is None:
|
|
82
|
+
return False
|
|
83
|
+
return (now - last_seen).days <= int(thresholds["recentDays"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _default_runner(args: list[str]) -> str:
|
|
87
|
+
proc = subprocess.run(args, capture_output=True, text=True, check=False)
|
|
88
|
+
if proc.returncode != 0:
|
|
89
|
+
raise RuntimeError(f"gh failed ({proc.returncode}): {proc.stderr.strip()}")
|
|
90
|
+
return proc.stdout
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def gh_run(args: list[str], *, runner=None) -> str:
|
|
94
|
+
"""gh 호출의 유일한 지점. 표준출력을 파싱하지 않고 그대로 돌려준다.
|
|
95
|
+
테스트는 runner 를 주입해 subprocess 를 우회한다."""
|
|
96
|
+
run = runner or _default_runner
|
|
97
|
+
return run(args)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def gh_json(args: list[str], *, runner=None):
|
|
101
|
+
"""`--json` 을 준 읽기 전용 조회 전용. 쓰기 명령(issue create/comment)은
|
|
102
|
+
JSON 이 아니라 생성된 URL 을 출력하므로 여기를 지나면 안 된다 — 지나면
|
|
103
|
+
이슈가 이미 만들어진 뒤에 크래시하고, 재실행이 중복 이슈를 만든다."""
|
|
104
|
+
out = gh_run(args, runner=runner)
|
|
105
|
+
return json.loads(out) if out.strip() else []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_existing_issue(fp: str, *, repo: str, runner=None) -> dict | None:
|
|
109
|
+
"""지문 마커를 본문에 품은 이슈를 찾는다. 열림/닫힘 모두 대상이다 —
|
|
110
|
+
닫힌 이슈를 못 보면 같은 결함으로 새 이슈를 또 만들게 된다."""
|
|
111
|
+
rows = gh_json([
|
|
112
|
+
"gh", "issue", "list", "--repo", repo, "--label", ISSUE_LABEL,
|
|
113
|
+
"--state", "all", "--limit", "200",
|
|
114
|
+
"--json", "number,state,body,comments,createdAt",
|
|
115
|
+
], runner=runner)
|
|
116
|
+
marker = f"<!-- {FINGERPRINT_MARKER}: {fp} -->"
|
|
117
|
+
for row in rows:
|
|
118
|
+
if marker in str(row.get("body", "")):
|
|
119
|
+
# gh 는 코멘트를 오래된 순으로 준다(실측: cli/cli#14089).
|
|
120
|
+
comments = row.get("comments") or []
|
|
121
|
+
last_comment_at = str(comments[-1].get("createdAt", "")) if comments else ""
|
|
122
|
+
return {
|
|
123
|
+
"number": row.get("number"),
|
|
124
|
+
"state": str(row.get("state", "")).lower(),
|
|
125
|
+
"createdAt": str(row.get("createdAt", "")),
|
|
126
|
+
"lastCommentAt": last_comment_at,
|
|
127
|
+
}
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def decide_action(existing: dict | None, *, last_seen: str) -> str:
|
|
132
|
+
"""이미 보고한 발생을 다시 알리지 않는 것이 이 판정의 목적이다.
|
|
133
|
+
|
|
134
|
+
닫힌 이슈도 같은 규칙을 쓴다. 예전에는 닫혔다는 것만으로 무조건 코멘트를
|
|
135
|
+
달아, 클러스터가 `recentDays` 안에 머무는 동안 실행할 때마다 같은 재발
|
|
136
|
+
코멘트가 쌓였다. 마지막 알림 이후의 새 발생만 알리면 재발 통지는 그대로
|
|
137
|
+
살아 있고 반복만 사라진다.
|
|
138
|
+
|
|
139
|
+
비교 기준은 마지막 코멘트, 없으면 이슈 생성 시각이다. 코멘트가 없는 이슈를
|
|
140
|
+
무조건 comment 로 떨어뜨리면 방금 만든 이슈가 본문에 이미 실린 그 발생을
|
|
141
|
+
재발로 한 번 더 알린다. 반대로 코멘트 존재 자체를 영구 빗장으로 쓰면 사람이
|
|
142
|
+
남긴 토론 코멘트 하나가 그 지문을 영원히 침묵시킨다 — 기준은 코멘트가
|
|
143
|
+
있느냐가 아니라 그 뒤에 새 발생이 있었느냐다."""
|
|
144
|
+
if existing is None:
|
|
145
|
+
return "create"
|
|
146
|
+
baseline = (_parse_ts(existing.get("lastCommentAt"))
|
|
147
|
+
or _parse_ts(existing.get("createdAt")))
|
|
148
|
+
seen = _parse_ts(last_seen)
|
|
149
|
+
if baseline is None or seen is None:
|
|
150
|
+
return "comment"
|
|
151
|
+
return "comment" if seen > baseline else "skip"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
ALLOWED_ACTIONS = frozenset({"create", "comment", "skip"})
|
|
155
|
+
|
|
156
|
+
# --- 유출 검사: 허용목록 ---------------------------------------------------
|
|
157
|
+
#
|
|
158
|
+
# 금지 목록을 늘리는 방식으로는 이 문제를 못 막는다. `/Users` 를 막으면
|
|
159
|
+
# `/Volumes` 가, 그것을 막으면 `/home` 이, 그 다음엔 소문자·URL 인코딩·상대
|
|
160
|
+
# 태스크 경로(`.project-docs/okstra/tasks/dev-9388/…`)가 남는다. 새 모양을 알게
|
|
161
|
+
# 되는 시점은 언제나 그 한 건이 이미 공개 레포에 올라간 뒤다.
|
|
162
|
+
#
|
|
163
|
+
# 그래서 반대로 건다 — 나가도 되는 모양을 정의하고 나머지는 이유와 함께
|
|
164
|
+
# 거부한다. 검사 대상은 산문 단어가 아니라 **식별자 모양**의 토큰이다. 타겟의
|
|
165
|
+
# 신원이 실제로 실려 나가는 자리가 경로와 티켓 아이디이기 때문이다.
|
|
166
|
+
# 식별자 모양은 두 가지다. 실측 81개 후보 본문으로 고른 값이다.
|
|
167
|
+
_PATH_CHAR = r"[A-Za-z0-9_.<>~@+-]"
|
|
168
|
+
_IDENTIFIER_SHAPES = (
|
|
169
|
+
# 경로 — 슬래시로 이어진 토큰. 절대·상대를 가리지 않으므로 접두 열거가
|
|
170
|
+
# 필요 없다. 역슬래시는 구분자로 보지 않는다: 본문에 실리는 JSONL 의 `\n`
|
|
171
|
+
# 이스케이프가 전부 경로로 잡혀 멀쩡한 후보가 거짓 거부된다.
|
|
172
|
+
re.compile(rf"{_PATH_CHAR}*(?:/{_PATH_CHAR}+)+"),
|
|
173
|
+
# 콜론으로 이어진 task-key 모양. 익명화는 `taskKey` 필드만 토큰으로 바꾸므로
|
|
174
|
+
# 자유 텍스트에 적힌 태스크 키는 그대로 남는다(실측 `jobs:uploadfont` 2건).
|
|
175
|
+
re.compile(r"(?<![\w:-])[A-Za-z][\w-]{2,}:[A-Za-z][\w-]{2,}"),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# 나가도 되는 식별자. 이 목록 밖의 식별자 모양은 전부 거부된다.
|
|
179
|
+
_ALLOWED_IDENTIFIERS = (
|
|
180
|
+
re.compile(r"^proj-\d+$"), # anonymize() 가 프로젝트 루트에 매기는 토큰
|
|
181
|
+
re.compile(r"^<[a-z]+>$"), # 익명화 자리표시자 <path> <email> <ip> <token> …
|
|
182
|
+
re.compile(r"^/(?:Users|home)/<user>$"), # 마스킹된 홈 접두
|
|
183
|
+
re.compile(rf"^{re.escape(FREETEXT_SOURCE)}$"), # 경로 신호의 source 라벨
|
|
184
|
+
re.compile(r"^(?:file|path):line$"), # okstra 자신의 인용 관례
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _leaks(text: str) -> list[str]:
|
|
189
|
+
"""허용목록 밖의 식별자 토큰을 돌려준다. 비어 있지 않으면 유출이다."""
|
|
190
|
+
found: list[str] = []
|
|
191
|
+
for shape in _IDENTIFIER_SHAPES:
|
|
192
|
+
for token in shape.findall(text):
|
|
193
|
+
if not token or token in found:
|
|
194
|
+
continue
|
|
195
|
+
if any(allowed.match(token) for allowed in _ALLOWED_IDENTIFIERS):
|
|
196
|
+
continue
|
|
197
|
+
found.append(token)
|
|
198
|
+
return found
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def validate_candidate(candidate: dict) -> list[str]:
|
|
202
|
+
"""등록 직전 검사. 비어 있지 않은 목록이 돌아오면 이 후보는 등록하지 않는다.
|
|
203
|
+
|
|
204
|
+
익명화가 앞에서 한 번 돌았다는 사실에 기대지 않고, 바깥으로 나가는
|
|
205
|
+
마지막 지점에서 다시 막는다."""
|
|
206
|
+
reasons: list[str] = []
|
|
207
|
+
|
|
208
|
+
evidence = candidate.get("evidence") or []
|
|
209
|
+
if not evidence:
|
|
210
|
+
reasons.append("evidence is empty — a classification with no evidence never ships")
|
|
211
|
+
|
|
212
|
+
# plan.json 은 사람이 손으로 고치는 파일이고, facet 은 원문 인용을 근거로
|
|
213
|
+
# 덧붙이라고 지시한다. 모양이 어긋난 항목은 사유로 거부한다 — 트레이스백으로
|
|
214
|
+
# 터지면 그 후보 하나가 아니라 등록 루프 전체가 끊긴다.
|
|
215
|
+
malformed = [
|
|
216
|
+
e for e in evidence
|
|
217
|
+
if not isinstance(e, dict) or not {"signal", "value", "source"} <= set(e)
|
|
218
|
+
]
|
|
219
|
+
if malformed:
|
|
220
|
+
reasons.append(
|
|
221
|
+
f"{len(malformed)} evidence entr(ies) missing one of signal/value/source"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
verdict = str(candidate.get("classification", ""))
|
|
225
|
+
# classify 와 같은 규칙을 바깥으로 나가는 마지막 지점에서 한 번 더 건다 —
|
|
226
|
+
# 꺼진 신호는 근거로 세지 않고, 약한 신호도 세지 않으며, 단독 충분 신호는
|
|
227
|
+
# 최소 수를 우회한다. signal_fires 를 빼먹으면 `taskSpread: 1` 같은 꺼진 신호가
|
|
228
|
+
# 자리를 채워, classify 가 애초에 거부했을 후보가 이 게이트를 통과한다.
|
|
229
|
+
supporting = [
|
|
230
|
+
e for e in evidence
|
|
231
|
+
if isinstance(e, dict)
|
|
232
|
+
and SIGNAL_SUPPORT.get(str(e.get("signal", ""))) == verdict
|
|
233
|
+
and str(e.get("signal", "")) not in WEAK_SIGNALS
|
|
234
|
+
and signal_fires(e)
|
|
235
|
+
]
|
|
236
|
+
alone_is_enough = any(
|
|
237
|
+
str(e.get("signal", "")) in SELF_SUFFICIENT_SIGNALS for e in supporting)
|
|
238
|
+
if evidence and not alone_is_enough and len(supporting) < MIN_SUPPORTING_SIGNALS:
|
|
239
|
+
reasons.append(
|
|
240
|
+
f"only {len(supporting)} supporting signal(s) for classification "
|
|
241
|
+
f"{verdict!r} — {MIN_SUPPORTING_SIGNALS} required"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
for rec in candidate.get("records") or []:
|
|
245
|
+
context = rec.get("context") if isinstance(rec.get("context"), dict) else {}
|
|
246
|
+
if context.get("cause") != "sandbox-denied":
|
|
247
|
+
continue
|
|
248
|
+
probes = context.get("causeEvidence") or {}
|
|
249
|
+
if not (probes.get("targetProbe") and probes.get("controlProbe")):
|
|
250
|
+
reasons.append(
|
|
251
|
+
"cause 'sandbox-denied' without both probes — unproven blocking claim"
|
|
252
|
+
)
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
# action 은 허용목록으로 검사한다. submit_plan 이 인식하지 못한 값을
|
|
256
|
+
# 코멘트로 흘리면 공개 레포에 글이 올라간다. plan.json 은 사람이 손으로
|
|
257
|
+
# 고치는 파일이므로 오타 하나가 그 경로를 연다.
|
|
258
|
+
action = str(candidate.get("action", ""))
|
|
259
|
+
if action not in ALLOWED_ACTIONS:
|
|
260
|
+
reasons.append(f"unknown action {action!r} — allowed: {sorted(ALLOWED_ACTIONS)}")
|
|
261
|
+
if action == "comment":
|
|
262
|
+
number = (candidate.get("existingIssue") or {}).get("number")
|
|
263
|
+
if not isinstance(number, int):
|
|
264
|
+
reasons.append("action 'comment' requires existingIssue.number")
|
|
265
|
+
|
|
266
|
+
title = candidate.get("title")
|
|
267
|
+
body = candidate.get("body")
|
|
268
|
+
if not isinstance(title, str) or not title.strip():
|
|
269
|
+
reasons.append("title is empty")
|
|
270
|
+
if not isinstance(body, str) or not body.strip():
|
|
271
|
+
reasons.append("body is empty")
|
|
272
|
+
|
|
273
|
+
outbound = f"{candidate.get('title', '')}\n{candidate.get('body', '')}"
|
|
274
|
+
for token in _leaks(outbound):
|
|
275
|
+
reasons.append(
|
|
276
|
+
f"leak: {token!r} is not an allowed outbound token — "
|
|
277
|
+
"only anonymized identifiers may leave this machine"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return reasons
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _cluster_records(records: list[dict], keys: list[str]) -> dict:
|
|
284
|
+
grouped: dict[str, list[dict]] = {}
|
|
285
|
+
for rec, key in zip(records, keys):
|
|
286
|
+
grouped.setdefault(key, []).append(rec)
|
|
287
|
+
return grouped
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _seen_bounds(records: list[dict]) -> tuple[str, str]:
|
|
291
|
+
stamps = sorted(str(r.get("ts", "")) for r in records if r.get("ts"))
|
|
292
|
+
return (stamps[0], stamps[-1]) if stamps else ("", "")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _render_body(*, fp, cluster, clean, verdict, supporting, conflicting,
|
|
296
|
+
thresholds, first_seen, last_seen) -> str:
|
|
297
|
+
"""공개 레포로 나가는 본문. 파생됐거나 상한이 있는 값만 싣는다.
|
|
298
|
+
|
|
299
|
+
원시 레코드는 **본문에 넣지 않는다.** `_scrub_freetext` 는 슬래시 경로만
|
|
300
|
+
붕괴시키므로, `stderrExcerpt` 는 타겟의 티켓 아이디·소스 파일명·식별자를
|
|
301
|
+
그대로 통과시킨다 — 실측 후보 81건 중 14건이 그랬고(`DEV-9044`,
|
|
302
|
+
`upload.service.ts:L103-111`, `unzip.service.ts`, `UPLOAD_CONCURRENCY`,
|
|
303
|
+
`idUploadJob`), 등록 직전 게이트는 `leaks == []` 로 통과시켰다.
|
|
304
|
+
|
|
305
|
+
모양으로는 `DEV-9426` 과 `gemini-3` 을 가를 수 없다는 것이 실측으로
|
|
306
|
+
확인됐으므로(허용목록에 티켓 모양을 넣으면 후보 81건 중 28건이 거짓 거부됨),
|
|
307
|
+
가리려 하지 않고 싣지 않는다. 증거는 사라지지 않는다 — 로컬 `plan.json` 의
|
|
308
|
+
후보 `records` 와 `okstra error-zip` 아카이브에 그대로 있고, 본문은 그 자리를
|
|
309
|
+
가리키기만 한다."""
|
|
310
|
+
sample = clean[0] if clean else {}
|
|
311
|
+
lines = [
|
|
312
|
+
f"<!-- {FINGERPRINT_MARKER}: {fp} -->",
|
|
313
|
+
"",
|
|
314
|
+
"## What happened",
|
|
315
|
+
"",
|
|
316
|
+
f"- errorType: `{cluster['errorType']}`",
|
|
317
|
+
f"- phase: `{cluster['phase']}`",
|
|
318
|
+
f"- agent: `{cluster['agent']}`",
|
|
319
|
+
f"- representative message: `{str(sample.get('message', ''))[:200]}`",
|
|
320
|
+
"",
|
|
321
|
+
"## Why this looks like an okstra defect",
|
|
322
|
+
"",
|
|
323
|
+
f"Classification: **{verdict}**",
|
|
324
|
+
"",
|
|
325
|
+
"| signal | value | source |",
|
|
326
|
+
"|---|---|---|",
|
|
327
|
+
]
|
|
328
|
+
lines += [f"| `{s['signal']}` | `{s['value']}` | `{s['source']}` |"
|
|
329
|
+
for s in supporting]
|
|
330
|
+
if conflicting:
|
|
331
|
+
lines += [
|
|
332
|
+
"",
|
|
333
|
+
"### Signals pointing the other way",
|
|
334
|
+
"",
|
|
335
|
+
"| signal | value | source |",
|
|
336
|
+
"|---|---|---|",
|
|
337
|
+
]
|
|
338
|
+
lines += [f"| `{s['signal']}` | `{s['value']}` | `{s['source']}` |"
|
|
339
|
+
for s in conflicting]
|
|
340
|
+
lines += [
|
|
341
|
+
"",
|
|
342
|
+
"## Why it was filed",
|
|
343
|
+
"",
|
|
344
|
+
f"- occurrences: {cluster['count']}",
|
|
345
|
+
f"- distinct projects: {len(cluster['projects'])}",
|
|
346
|
+
f"- first seen: {first_seen}",
|
|
347
|
+
f"- last seen: {last_seen}",
|
|
348
|
+
f"- thresholds applied: minCount={thresholds['minCount']}, "
|
|
349
|
+
f"recentDays={thresholds['recentDays']}",
|
|
350
|
+
"",
|
|
351
|
+
"## Where the raw records are",
|
|
352
|
+
"",
|
|
353
|
+
f"The {cluster['count']} underlying error records are not published here — "
|
|
354
|
+
"their free text carries the reporting project's identifiers. They stay on "
|
|
355
|
+
"the reporting machine: this candidate's `records` in the local "
|
|
356
|
+
"`error-issue plan.json`, and the anonymized archive from "
|
|
357
|
+
"`okstra error-zip`.",
|
|
358
|
+
"",
|
|
359
|
+
]
|
|
360
|
+
return "\n".join(lines)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _render_audit_body(*, fp, invariant, task_keys, projects, evidence,
|
|
364
|
+
members) -> str:
|
|
365
|
+
"""감사 후보의 공개 본문 — 경로도 태스크 아이디도 싣지 않는다.
|
|
366
|
+
|
|
367
|
+
감사 이슈가 주장하는 것은 "okstra 가 이 불변식을 N개 태스크에서 못 지킨다"
|
|
368
|
+
이고, 그 주장에 필요한 재료는 불변식 이름·건수·관측된 task-type 뿐이다.
|
|
369
|
+
|
|
370
|
+
위반의 `detail` 과 `source` 는 그 자리에 오면 안 된다. 둘 다 타겟의 디렉터리
|
|
371
|
+
관례와 티켓 아이디를 문자열로 품고 있어(실측:
|
|
372
|
+
`.project-docs/okstra/tasks/dev-9388/dev-9426/runs/…`) 공개 레포에 나가는
|
|
373
|
+
순간 그 회사의 내부 명명이 그대로 공개된다. 그 값이 필요한 사람은 로컬
|
|
374
|
+
`run-audit` 리포트를 보면 된다 — 거기엔 둘 다 그대로 남는다."""
|
|
375
|
+
by_task_type: dict[str, int] = {}
|
|
376
|
+
for member in members:
|
|
377
|
+
name = str(member.get("taskType", "")) or "(unknown)"
|
|
378
|
+
by_task_type[name] = by_task_type.get(name, 0) + 1
|
|
379
|
+
return "\n".join([
|
|
380
|
+
f"<!-- {FINGERPRINT_MARKER}: {fp} -->",
|
|
381
|
+
"",
|
|
382
|
+
"## What the audit found",
|
|
383
|
+
"",
|
|
384
|
+
f"Invariant `{invariant}` was violated {len(members)} times in "
|
|
385
|
+
f"{len(task_keys)} tasks across {len(projects)} projects.",
|
|
386
|
+
"",
|
|
387
|
+
"## Why this looks like an okstra defect",
|
|
388
|
+
"",
|
|
389
|
+
"Classification: **okstra-defect**",
|
|
390
|
+
"",
|
|
391
|
+
"| signal | value | source |",
|
|
392
|
+
"|---|---|---|",
|
|
393
|
+
*[f"| `{e['signal']}` | `{e['value']}` | `{e['source']}` |" for e in evidence],
|
|
394
|
+
"",
|
|
395
|
+
"## Observations",
|
|
396
|
+
"",
|
|
397
|
+
"| task type | violations |",
|
|
398
|
+
"|---|---:|",
|
|
399
|
+
*[f"| `{name}` | {count} |"
|
|
400
|
+
for name, count in sorted(by_task_type.items())],
|
|
401
|
+
"",
|
|
402
|
+
])
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def audit_candidates(violations: list[dict], *, min_tasks: int) -> list[dict]:
|
|
406
|
+
"""불변식 위반을 확산 기준으로 걸러 후보로 만든다.
|
|
407
|
+
|
|
408
|
+
한 태스크에서만 깨진 불변식은 그 태스크의 상태이지 okstra 의 결함이 아니다.
|
|
409
|
+
여러 태스크에서 같은 불변식이 반복해 깨질 때만 okstra 가 그 상황을 못
|
|
410
|
+
막고 있다는 뜻이 된다."""
|
|
411
|
+
grouped: dict[str, list[dict]] = {}
|
|
412
|
+
for v in violations:
|
|
413
|
+
if v["invariant"] in NEVER_AN_ISSUE:
|
|
414
|
+
continue
|
|
415
|
+
grouped.setdefault(v["invariant"], []).append(v)
|
|
416
|
+
|
|
417
|
+
out = []
|
|
418
|
+
for invariant, members in sorted(grouped.items()):
|
|
419
|
+
task_keys = sorted({m["taskKey"] for m in members if m.get("taskKey")})
|
|
420
|
+
if len(task_keys) < min_tasks:
|
|
421
|
+
continue
|
|
422
|
+
projects = sorted({m["projectRoot"] for m in members if m.get("projectRoot")})
|
|
423
|
+
# 감사 후보의 시계. `run_audit` 이 위반마다 붙인 관측 시각 중 가장 최근
|
|
424
|
+
# 것이 이 불변식의 `lastSeen` 이다 — 이것이 있어야 감사 후보도 에러 로그
|
|
425
|
+
# 후보와 같은 중복 판정을 쓸 수 있다.
|
|
426
|
+
observed = [ts for ts in (_parse_ts(m.get("observedAt")) for m in members)
|
|
427
|
+
if ts is not None]
|
|
428
|
+
last_seen = max(observed).isoformat() if observed else ""
|
|
429
|
+
# invariantViolated 를 함께 싣는다 — 확산이 한 프로젝트 안에 머무르면
|
|
430
|
+
# projectSpread 가 발화하지 않아 지지 신호가 하나로 떨어지고,
|
|
431
|
+
# validate_candidate 의 최소 근거 수에 걸려 감사 후보가 통째로 거부된다.
|
|
432
|
+
evidence = [
|
|
433
|
+
{"signal": "invariantViolated", "value": invariant, "source": "run-audit"},
|
|
434
|
+
{"signal": "taskSpread", "value": len(task_keys), "source": "run-manifest.taskKey"},
|
|
435
|
+
{"signal": "projectSpread", "value": len(projects), "source": "run-index.projectRoot"},
|
|
436
|
+
]
|
|
437
|
+
fp = fingerprint(f"run-audit|{invariant}")
|
|
438
|
+
out.append({
|
|
439
|
+
"fingerprint": fp,
|
|
440
|
+
"origin": "run-audit",
|
|
441
|
+
"clusterKey": f"run-audit|{invariant}",
|
|
442
|
+
"count": len(members),
|
|
443
|
+
"firstSeen": min(observed).isoformat() if observed else "",
|
|
444
|
+
"lastSeen": last_seen,
|
|
445
|
+
"signals": evidence,
|
|
446
|
+
"classification": "okstra-defect",
|
|
447
|
+
"evidence": evidence,
|
|
448
|
+
"conflictingSignals": [],
|
|
449
|
+
"existingIssue": None,
|
|
450
|
+
"action": "create",
|
|
451
|
+
"records": [],
|
|
452
|
+
"title": f"[run-audit] invariant {invariant} violated across "
|
|
453
|
+
f"{len(task_keys)} tasks",
|
|
454
|
+
"body": _render_audit_body(
|
|
455
|
+
fp=fp, invariant=invariant, task_keys=task_keys,
|
|
456
|
+
projects=projects, evidence=evidence, members=members),
|
|
457
|
+
})
|
|
458
|
+
return out
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def build_plan(home: Path, *, now: dt.datetime, runner=None) -> dict:
|
|
462
|
+
cfg = load_config(home)
|
|
463
|
+
thresholds = {"minCount": cfg["minCount"], "recentDays": cfg["recentDays"]}
|
|
464
|
+
records, stats = collect_records(home)
|
|
465
|
+
clusters, keys = build_clusters(records, project_field="_projectRoot")
|
|
466
|
+
grouped = _cluster_records(records, keys)
|
|
467
|
+
|
|
468
|
+
candidates = []
|
|
469
|
+
below_gate = 0
|
|
470
|
+
not_defect = 0
|
|
471
|
+
conflicted = 0
|
|
472
|
+
for cluster in clusters:
|
|
473
|
+
members = grouped.get(cluster["key"], [])
|
|
474
|
+
first_seen, last_seen = _seen_bounds(members)
|
|
475
|
+
enriched = {**cluster, "lastSeen": last_seen}
|
|
476
|
+
if not passes_gate(enriched, now=now, thresholds=thresholds):
|
|
477
|
+
below_gate += 1
|
|
478
|
+
continue
|
|
479
|
+
signals = compute_signals(members)
|
|
480
|
+
verdict, supporting, conflicting = classify(signals)
|
|
481
|
+
if verdict != "okstra-defect":
|
|
482
|
+
not_defect += 1
|
|
483
|
+
continue
|
|
484
|
+
# 반대 신호가 있는 클러스터는 버리지 않고 skip 후보로 남긴다. 조용히
|
|
485
|
+
# 버리면 targetSourcePath 가 stderr 의 `tests/` 한 조각에 발화한 것만으로
|
|
486
|
+
# 진짜 계약 위반이 사라지고, 사람은 그런 일이 있었는지도 모른다.
|
|
487
|
+
if conflicting:
|
|
488
|
+
conflicted += 1
|
|
489
|
+
fp = fingerprint(cluster["key"])
|
|
490
|
+
existing = find_existing_issue(fp, repo=cfg["repo"], runner=runner)
|
|
491
|
+
# 익명화는 여기서 한 번만 돌리고 title 과 body 가 그 결과를 공유한다.
|
|
492
|
+
# title 이 raw sample 을 쓰면 절대경로가 그대로 실려 submit 의 유출 검사에서
|
|
493
|
+
# 전량 거부된다 — 프로젝트는 보통 /Users/… 아래 있기 때문이다.
|
|
494
|
+
clean, _ = anonymize(members)
|
|
495
|
+
sample_message = str(clean[0].get("message", "")) if clean else ""
|
|
496
|
+
candidates.append({
|
|
497
|
+
"fingerprint": fp,
|
|
498
|
+
"origin": "error-log",
|
|
499
|
+
"clusterKey": cluster["key"],
|
|
500
|
+
"count": cluster["count"],
|
|
501
|
+
"firstSeen": first_seen,
|
|
502
|
+
"lastSeen": last_seen,
|
|
503
|
+
"signals": signals,
|
|
504
|
+
"classification": verdict,
|
|
505
|
+
"evidence": supporting,
|
|
506
|
+
"conflictingSignals": conflicting,
|
|
507
|
+
"existingIssue": existing,
|
|
508
|
+
"action": ("skip" if conflicting
|
|
509
|
+
else decide_action(existing, last_seen=last_seen)),
|
|
510
|
+
"records": members,
|
|
511
|
+
"title": f"[{cluster['errorType']}] {cluster['phase']}: "
|
|
512
|
+
f"{sample_message[:60]}",
|
|
513
|
+
"body": _render_body(
|
|
514
|
+
fp=fp, cluster=cluster, clean=clean, verdict=verdict,
|
|
515
|
+
supporting=supporting, conflicting=conflicting,
|
|
516
|
+
thresholds=thresholds, first_seen=first_seen, last_seen=last_seen,
|
|
517
|
+
),
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
for cand in audit_candidates(audit_runs(home, now=now), min_tasks=AUDIT_MIN_TASKS):
|
|
521
|
+
existing = find_existing_issue(cand["fingerprint"], repo=cfg["repo"],
|
|
522
|
+
runner=runner)
|
|
523
|
+
cand["existingIssue"] = existing
|
|
524
|
+
# 감사 후보도 에러 로그 후보와 같은 판정을 쓴다. 위반이 `observedAt` 을
|
|
525
|
+
# 들고 오면서 감사 경로에도 시계가 생겼고, 특수 분기 셋이 이 한 줄로
|
|
526
|
+
# 접힌다 — 분기를 따로 두는 동안 열린 이슈는 영영 침묵하고 닫힌 이슈는
|
|
527
|
+
# 사람의 토론 코멘트 하나에 영구히 잠겼다.
|
|
528
|
+
cand["action"] = decide_action(existing, last_seen=cand["lastSeen"])
|
|
529
|
+
candidates.append(cand)
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
"schemaVersion": "1.0",
|
|
533
|
+
"generatedAt": now.isoformat(),
|
|
534
|
+
"repo": cfg["repo"],
|
|
535
|
+
"thresholds": thresholds,
|
|
536
|
+
"stats": {
|
|
537
|
+
"runCount": stats["runCount"],
|
|
538
|
+
"unreachableRuns": stats["unreachableRuns"],
|
|
539
|
+
"clusterCount": len(clusters),
|
|
540
|
+
"belowGate": below_gate,
|
|
541
|
+
"notOkstraDefect": not_defect,
|
|
542
|
+
"conflicted": conflicted,
|
|
543
|
+
},
|
|
544
|
+
"candidates": candidates,
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _submit_one(cand: dict, *, repo: str, runner, result: dict) -> None:
|
|
549
|
+
action = str(cand.get("action", ""))
|
|
550
|
+
if action == "skip":
|
|
551
|
+
result["skipped"] += 1
|
|
552
|
+
return
|
|
553
|
+
reasons = validate_candidate(cand)
|
|
554
|
+
if reasons:
|
|
555
|
+
result["rejected"] += 1
|
|
556
|
+
result["rejections"].append(
|
|
557
|
+
{"fingerprint": cand.get("fingerprint"), "reasons": reasons})
|
|
558
|
+
return
|
|
559
|
+
# 허용목록 분기 — 인식하지 못한 action 이 코멘트로 흘러 공개 레포에
|
|
560
|
+
# 글이 올라가는 일이 없어야 한다. validate_candidate 가 이미 action 을
|
|
561
|
+
# 검사하므로 여기 도달하는 값은 create/comment 뿐이다.
|
|
562
|
+
if action == "create":
|
|
563
|
+
gh_run(["gh", "issue", "create", "--repo", repo,
|
|
564
|
+
"--label", ISSUE_LABEL, "--title", cand["title"],
|
|
565
|
+
"--body", cand["body"]], runner=runner)
|
|
566
|
+
result["created"] += 1
|
|
567
|
+
else:
|
|
568
|
+
number = str(cand["existingIssue"]["number"])
|
|
569
|
+
gh_run(["gh", "issue", "comment", number, "--repo", repo,
|
|
570
|
+
"--body", cand["body"]], runner=runner)
|
|
571
|
+
result["commented"] += 1
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def submit_plan(plan: dict, *, repo: str, runner=None) -> dict:
|
|
575
|
+
"""plan.json 만 보고 등록한다. 여기서 스스로 만들어내는 값은 사람이 승인한
|
|
576
|
+
화면에 없던 값이므로, 목적지(repo)까지 포함해 전부 파일에서 온다.
|
|
577
|
+
|
|
578
|
+
후보 하나의 실패는 그 후보에서 멈춘다. 루프 밖으로 예외가 나가면 앞에서
|
|
579
|
+
이미 등록된 이슈들이 요약도 없이 사라진다 — 사람은 무엇이 올라갔는지 모른 채
|
|
580
|
+
재실행하고, plan.json 의 action 은 여전히 `create` 라 같은 이슈가 한 번 더
|
|
581
|
+
올라간다. 실측 재현: 두 번째 후보의 근거 항목 하나에 `value` 가 없어
|
|
582
|
+
KeyError 로 끊겼고, 그 시점에 gh 호출은 이미 한 번 나간 뒤였다."""
|
|
583
|
+
result = {"created": 0, "commented": 0, "skipped": 0, "rejected": 0,
|
|
584
|
+
"failed": 0, "rejections": [], "failures": []}
|
|
585
|
+
for cand in plan.get("candidates", []):
|
|
586
|
+
try:
|
|
587
|
+
_submit_one(cand, repo=repo, runner=runner, result=result)
|
|
588
|
+
except Exception as exc: # noqa: BLE001 — 어떤 실패도 루프를 끊지 못한다
|
|
589
|
+
result["failed"] += 1
|
|
590
|
+
result["failures"].append({
|
|
591
|
+
"fingerprint": (cand.get("fingerprint")
|
|
592
|
+
if isinstance(cand, dict) else None),
|
|
593
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
594
|
+
})
|
|
595
|
+
return result
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def main(argv: list[str] | None = None) -> int:
|
|
599
|
+
parser = argparse.ArgumentParser(
|
|
600
|
+
prog="okstra error-issue",
|
|
601
|
+
description="런 이상을 GitHub 이슈 후보로 만들고(plan) 승인 뒤 등록한다(submit).",
|
|
602
|
+
)
|
|
603
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
604
|
+
plan_cmd = sub.add_parser("plan", help="후보와 근거를 담은 plan.json 을 만든다")
|
|
605
|
+
plan_cmd.add_argument("--out", required=True, help="plan.json 출력 경로")
|
|
606
|
+
submit_cmd = sub.add_parser("submit", help="승인된 plan.json 을 등록한다")
|
|
607
|
+
submit_cmd.add_argument("--plan", required=True, help="plan.json 경로")
|
|
608
|
+
args = parser.parse_args(argv)
|
|
609
|
+
|
|
610
|
+
home = okstra_home()
|
|
611
|
+
if args.command == "plan":
|
|
612
|
+
plan = build_plan(home, now=dt.datetime.now(dt.timezone.utc))
|
|
613
|
+
out = Path(args.out)
|
|
614
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
615
|
+
out.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
616
|
+
print(json.dumps({
|
|
617
|
+
"planPath": str(out),
|
|
618
|
+
"candidateCount": len(plan["candidates"]),
|
|
619
|
+
**plan["stats"],
|
|
620
|
+
}, ensure_ascii=False, indent=2))
|
|
621
|
+
return 0
|
|
622
|
+
|
|
623
|
+
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
|
624
|
+
repo = str(plan.get("repo", ""))
|
|
625
|
+
if not repo:
|
|
626
|
+
# 목적지는 사람이 승인한 화면에 반드시 있어야 할 값이다. 기본값으로
|
|
627
|
+
# 채우면 검토되지 않은 공개 레포에 글이 올라간다.
|
|
628
|
+
print(json.dumps({
|
|
629
|
+
"error": "plan.json has no 'repo' — refusing to pick a destination",
|
|
630
|
+
}, ensure_ascii=False, indent=2))
|
|
631
|
+
return 1
|
|
632
|
+
result = submit_plan(plan, repo=repo)
|
|
633
|
+
# 부분 요약이라도 반드시 나가야 한다 — 무엇이 이미 올라갔는지 모르는 채로
|
|
634
|
+
# 재실행하는 것이 중복 이슈를 만드는 경로다.
|
|
635
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
636
|
+
return 1 if result["rejected"] or result["failed"] else 0
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
if __name__ == "__main__":
|
|
640
|
+
raise SystemExit(main(sys.argv[1:]))
|