@surlymochan/evalid 0.0.0-stage → 0.3.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/CHANGELOG.md +74 -0
- package/LICENSE +21 -0
- package/README.md +315 -2
- package/RELEASE_NOTES.md +102 -0
- package/atoms/registry.json +758 -0
- package/bin/xflow.js +7208 -0
- package/docs/adoption/README.md +128 -0
- package/docs/adoption/openflow-release-hardening.md +61 -0
- package/docs/benchmark-openflow-vs-gstack-superpowers-openspec-2026-05-25.md +81 -0
- package/docs/compatibility.md +52 -0
- package/docs/competitive-benchmark.md +108 -0
- package/docs/corps-operator-guide.md +75 -0
- package/docs/demo-proof.md +126 -0
- package/docs/examples-gallery.md +115 -0
- package/docs/fixtures/tracker-item.json +7 -0
- package/docs/goal-vs-codex.md +129 -0
- package/docs/gstack-comparison.md +88 -0
- package/docs/install-upgrade.md +133 -0
- package/docs/integrations.md +49 -0
- package/docs/launch-demo.md +123 -0
- package/docs/launch-dossier.md +180 -0
- package/docs/methodology.md +149 -0
- package/docs/npm-publish-handoff.md +83 -0
- package/docs/openspec-migration.md +106 -0
- package/docs/public-benchmark.md +68 -0
- package/docs/public-release.md +145 -0
- package/docs/quality-assessment.md +139 -0
- package/docs/quickstart.md +156 -0
- package/docs/reviewer-guide.md +43 -0
- package/docs/spec-kit-benchmark.md +73 -0
- package/docs/super-assistant-comparison.md +106 -0
- package/docs/superpowers-comparison.md +82 -0
- package/docs/team-adoption.md +123 -0
- package/docs/tooling-matrix.md +91 -0
- package/docs/walkthrough.md +76 -0
- package/docs/workflow-manual.md +1125 -0
- package/package.json +83 -4
- package/prompts/pencil-draft.md +46 -0
- package/prompts/pencil-refine.md +46 -0
- package/prompts/phase-artifact.md +24 -0
- package/prompts/phase-execution.md +32 -0
- package/schemas/competitor-reconstruction-contract.schema.json +91 -0
- package/schemas/llm-design-review-aggregate.schema.json +83 -0
- package/schemas/visual-benchmark.schema.json +978 -0
- package/schemas/workflow.schema.json +100 -0
- package/scripts/run-tests.mjs +23 -0
- package/scripts/takein_digest.py +139 -0
- package/specs/README.md +118 -0
- package/specs/architecture.md +36 -0
- package/specs/product.md +33 -0
- package/specs/workflow.md +349 -0
- package/src/agent_team/atoms/codex-app-bridge.js +8 -0
- package/src/agent_team/atoms/design/competitor-reconstruction-review.js +44 -0
- package/src/agent_team/atoms/design/contract-freeze.js +8 -0
- package/src/agent_team/atoms/design/generation-contract.js +442 -0
- package/src/agent_team/atoms/design/reconstruction-pack.js +246 -0
- package/src/agent_team/atoms/design/reference-surface-lock.js +49 -0
- package/src/agent_team/atoms/patch-challenge.js +19 -0
- package/src/agent_team/atoms/pencil/accept.js +9 -0
- package/src/agent_team/atoms/pencil/draft.js +365 -0
- package/src/agent_team/atoms/pencil/refine.js +229 -0
- package/src/agent_team/atoms/phase-run-dispatch.js +8 -0
- package/src/agent_team/atoms/team-run.js +380 -0
- package/src/agent_team/atoms/visual/aesthetic-review.js +231 -0
- package/src/agent_team/atoms/visual/benchmark-repair-plan.js +291 -0
- package/src/agent_team/atoms/visual/benchmark.js +1652 -0
- package/src/agent_team/atoms/visual/design-selection.js +23 -0
- package/src/agent_team/atoms/visual/direction-synthesis.js +124 -0
- package/src/agent_team/atoms/visual/dom-rect-export.js +129 -0
- package/src/agent_team/atoms/visual/image-diff.js +186 -0
- package/src/agent_team/atoms/visual/layout-competition.js +8 -0
- package/src/agent_team/atoms/visual/page-evidence-capture.js +806 -0
- package/src/agent_team/atoms/visual/playwright-runtime.js +8 -0
- package/src/agent_team/atoms/visual/review-aggregate.js +87 -0
- package/src/agent_team/atoms/visual/review.js +77 -0
- package/src/agent_team/atoms/visual/visual-report.js +197 -0
- package/src/agent_team/atoms/visual/visual-token-export.js +417 -0
- package/src/agent_team/common.js +311 -0
- package/src/core/atoms/agent-invoke.js +684 -0
- package/src/core/atoms/event/ingest.js +30 -0
- package/src/core/atoms/experience/digest.js +55 -0
- package/src/core/atoms/gate/artifact-verify.js +83 -0
- package/src/core/atoms/gate/competitor-contract-validate.js +119 -0
- package/src/core/atoms/gate/evidence-collect.js +64 -0
- package/src/core/atoms/gate/payload-from-phase-run.js +78 -0
- package/src/core/atoms/gate/visual-benchmark-validate.js +183 -0
- package/src/core/atoms/gate/workbench.js +79 -0
- package/src/core/atoms/intake/classify.js +15 -0
- package/src/core/atoms/intake/design-skill-select.js +16 -0
- package/src/core/atoms/intake/preview-or-refine.js +19 -0
- package/src/core/atoms/intake/recommend.js +20 -0
- package/src/core/atoms/intake/research-same-category.js +19 -0
- package/src/core/atoms/intervention/create.js +30 -0
- package/src/core/atoms/memory/manage.js +27 -0
- package/src/core/atoms/memory/mount.js +13 -0
- package/src/core/atoms/mission/create.js +19 -0
- package/src/core/atoms/mission/list.js +14 -0
- package/src/core/atoms/mission/show.js +26 -0
- package/src/core/atoms/notify/checkpoint.js +46 -0
- package/src/core/atoms/phase-run/complete.js +42 -0
- package/src/core/atoms/phase-run/fail.js +40 -0
- package/src/core/atoms/phase-run/persist.js +39 -0
- package/src/core/atoms/phase-run/start.js +30 -0
- package/src/core/atoms/plugin/run.js +76 -0
- package/src/core/atoms/policy/gate.js +90 -0
- package/src/core/atoms/policy/resolve.js +11 -0
- package/src/core/atoms/storage/sync.js +57 -0
- package/src/core/atoms/workflow/advance.js +25 -0
- package/src/core/atoms/workflow/guard.js +127 -0
- package/src/core/atoms/workflow/state-load.js +37 -0
- package/src/core/bindings.js +37 -0
- package/src/core/change-artifacts.js +134 -0
- package/src/core/competitive-score.js +265 -0
- package/src/core/concurrency-policy.js +35 -0
- package/src/core/execution-log.js +108 -0
- package/src/core/intake-recommendation.js +134 -0
- package/src/core/memory-state.js +62 -0
- package/src/core/mission-state.js +780 -0
- package/src/core/pending-gates.js +66 -0
- package/src/core/phase-cost.js +116 -0
- package/src/core/phase-run-read.js +27 -0
- package/src/core/policy-overlay.js +180 -0
- package/src/core/state-store.js +201 -0
- package/src/core/workflow-executor.js +998 -0
- package/src/core/workflow-integrity.js +111 -0
- package/src/core/workflow-loader.js +163 -0
- package/src/core/workflow-rerun.js +84 -0
- package/src/core/workflow-state.js +119 -0
- package/src/server.js +1307 -0
- package/workflows/corps.yaml +479 -0
- package/workflows/yolo.yaml +233 -0
- package/xflow/README.md +76 -0
- package/xflow/aha/SKILL.md +82 -0
- package/xflow/aha/scripts/append_aha.py +50 -0
- package/xflow/archive/scripts/archive_change.py +338 -0
- package/xflow/archive/templates/merge-design.md +54 -0
- package/xflow/archive/templates/merge-workflow.md +26 -0
- package/xflow/atoms/__init__.py +6 -0
- package/xflow/atoms/a1_issue_create_or_patch.py +70 -0
- package/xflow/atoms/a2_branch_create_and_link.py +42 -0
- package/xflow/atoms/a3_repo_doctor.py +11 -0
- package/xflow/atoms/a4_project_set_status.py +11 -0
- package/xflow/atoms/a5_archive_commit_push_close.py +11 -0
- package/xflow/atoms/a6_pr_create.py +72 -0
- package/xflow/atoms/b1_change_scaffold.py +11 -0
- package/xflow/atoms/b2_status_read.py +16 -0
- package/xflow/atoms/b2b_status_write.py +20 -0
- package/xflow/atoms/b3_status_transition.py +27 -0
- package/xflow/atoms/b4_handoff_scaffold_or_refresh.py +11 -0
- package/xflow/atoms/b5_aha_append.py +11 -0
- package/xflow/atoms/b6_takein_digest.py +11 -0
- package/xflow/atoms/b6b_lookback_digest.py +11 -0
- package/xflow/atoms/b7_change_mission_bind.py +43 -0
- package/xflow/atoms/c1_mem.py +54 -0
- package/xflow/atoms/e6_gate_local_precheck.py +23 -0
- package/xflow/atoms/h1_design_lite_gate.py +74 -0
- package/xflow/atoms/i6a_tdd_run.py +104 -0
- package/xflow/atoms/i6b_tdd_proof_validate.py +165 -0
- package/xflow/atoms/i6c_tdd_quality_review.py +357 -0
- package/xflow/atoms/j1_tests_run.py +129 -0
- package/xflow/atoms/j4a_spec_consistency_structural.py +105 -0
- package/xflow/atoms/j4b_spec_delta_review.py +316 -0
- package/xflow/atoms/j4c_openspec_migration_map.py +121 -0
- package/xflow/atoms/j4d_spec_clarify_gate.py +373 -0
- package/xflow/atoms/j6_cr_review.py +443 -0
- package/xflow/atoms/k1_artifacts_complete_check.py +39 -0
- package/xflow/atoms/k2_merge_snippets_apply.py +11 -0
- package/xflow/atoms/k3_handoff_refresh.py +14 -0
- package/xflow/atoms/k4_mem_lesson_persist.py +65 -0
- package/xflow/atoms/k5_archive_publish.py +21 -0
- package/xflow/atoms/k6_aha_merge.py +42 -0
- package/xflow/atoms/tdd_lib.py +747 -0
- package/xflow/corps/SKILL.md +109 -0
- package/xflow/exec/scripts/set_issue_in_progress.py +159 -0
- package/xflow/goal/SKILL.md +150 -0
- package/xflow/handoff/SKILL.md +95 -0
- package/xflow/handoff/scripts/scaffold_handoff.py +304 -0
- package/xflow/lookback/SKILL.md +66 -0
- package/xflow/lookback/scripts/lookback_digest.py +46 -0
- package/xflow/openissue/scripts/open_issue_flow.py +438 -0
- package/xflow/plan/SKILL.md +38 -0
- package/xflow/ralph/SKILL.md +123 -0
- package/xflow/scripts/check_installed_xflow_skill_sync.sh +44 -0
- package/xflow/scripts/common.py +480 -0
- package/xflow/scripts/doctor.py +278 -0
- package/xflow/scripts/sync_installed_xflow_skill.sh +134 -0
- package/xflow/takein/SKILL.md +81 -0
- package/xflow/takein/scripts/takein_digest.py +88 -0
- package/xflow/yolo/SKILL.md +66 -0
- package/xflow/yolo/scripts/scaffold_specs_change.py +673 -0
- package/xflow/yolo/scripts/yolo_gate.py +312 -0
- package/xflow/yolo/templates/DESIGN.md +179 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PROJECT_SEARCH_ROOTS = [
|
|
12
|
+
Path.home() / "workspace/project/private",
|
|
13
|
+
Path.home() / "workspace/project/public",
|
|
14
|
+
Path.home() / "workspace/project/third-party",
|
|
15
|
+
Path.home() / "Documents/workspace/project/private",
|
|
16
|
+
Path.home() / "Documents/workspace/project/public",
|
|
17
|
+
Path.home() / "Documents/workspace/project/third-party",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ALLOWED_STAGE_TRANSITIONS: dict[str, set[str]] = {
|
|
22
|
+
"change-init": {"change-init", "design-check", "brainstorm", "proposal-freeze", "proposal-consistency-check", "plan"},
|
|
23
|
+
"design-check": {"design-check", "brainstorm", "proposal-freeze", "proposal-consistency-check", "plan"},
|
|
24
|
+
"brainstorm": {"brainstorm", "proposal-freeze", "proposal-consistency-check", "plan"},
|
|
25
|
+
"proposal-freeze": {"proposal-freeze", "proposal-consistency-check", "plan"},
|
|
26
|
+
"proposal-consistency-check": {"proposal-consistency-check", "plan"},
|
|
27
|
+
"plan": {"plan", "tdd"},
|
|
28
|
+
"openissue": {"openissue", "tdd"},
|
|
29
|
+
"tdd": {"tdd", "execute", "verify"},
|
|
30
|
+
"execute": {"execute", "tdd", "verify"},
|
|
31
|
+
"verify": {"verify", "tdd", "execute", "archive"},
|
|
32
|
+
"archive": {"archive"},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ALLOWED_STATUS_TRANSITIONS: dict[str, set[str]] = {
|
|
36
|
+
"draft": {"draft", "active", "blocked"},
|
|
37
|
+
"active": {"active", "done", "blocked"},
|
|
38
|
+
"blocked": {"blocked", "active", "done"},
|
|
39
|
+
"done": {"done"},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def now_iso() -> str:
|
|
44
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def slugify(value: str) -> str:
|
|
48
|
+
value = value.strip().lower()
|
|
49
|
+
value = re.sub(r"[^a-z0-9]+", "-", value)
|
|
50
|
+
value = re.sub(r"-{2,}", "-", value)
|
|
51
|
+
return value.strip("-") or "change"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run(cmd: list[str], *, cwd: str | Path | None = None, capture_output: bool = True) -> subprocess.CompletedProcess[str]:
|
|
55
|
+
return subprocess.run(
|
|
56
|
+
cmd,
|
|
57
|
+
cwd=str(cwd) if cwd else None,
|
|
58
|
+
check=True,
|
|
59
|
+
text=True,
|
|
60
|
+
capture_output=capture_output,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_stdout(cmd: list[str], *, cwd: str | Path | None = None) -> str:
|
|
65
|
+
return run(cmd, cwd=cwd).stdout.strip()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def gh_json(args: list[str], *, cwd: str | Path | None = None) -> Any:
|
|
69
|
+
output = run_stdout(["gh", *args], cwd=cwd)
|
|
70
|
+
return json.loads(output)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def gh_graphql(query: str, variables: dict[str, Any] | None = None) -> Any:
|
|
74
|
+
cmd = ["gh", "api", "graphql", "-f", f"query={query}"]
|
|
75
|
+
for key, value in (variables or {}).items():
|
|
76
|
+
if isinstance(value, bool):
|
|
77
|
+
cmd.extend(["-F", f"{key}={'true' if value else 'false'}"])
|
|
78
|
+
else:
|
|
79
|
+
cmd.extend(["-F", f"{key}={value}"])
|
|
80
|
+
output = run_stdout(cmd)
|
|
81
|
+
return json.loads(output)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_text(path: Path) -> str:
|
|
85
|
+
return path.read_text(encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def write_text(path: Path, content: str) -> None:
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
path.write_text(content, encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
94
|
+
return json.loads(read_text(path))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_json(path: Path, data: dict[str, Any]) -> None:
|
|
98
|
+
write_text(path, json.dumps(data, indent=2) + "\n")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_allowed_transition(mapping: dict[str, set[str]], current_value: str | None, target_value: str) -> bool:
|
|
102
|
+
if not current_value:
|
|
103
|
+
return False
|
|
104
|
+
allowed = mapping.get(current_value)
|
|
105
|
+
if not allowed:
|
|
106
|
+
return False
|
|
107
|
+
return target_value in allowed
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_allowed_stage_transition(current_stage: str | None, target_stage: str) -> bool:
|
|
111
|
+
return _is_allowed_transition(ALLOWED_STAGE_TRANSITIONS, current_stage, target_stage)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_allowed_status_transition(current_status: str | None, target_status: str) -> bool:
|
|
115
|
+
return _is_allowed_transition(ALLOWED_STATUS_TRANSITIONS, current_status, target_status)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def assert_stage_transition_allowed(
|
|
119
|
+
current_stage: str | None,
|
|
120
|
+
target_stage: str,
|
|
121
|
+
*,
|
|
122
|
+
current_status: str | None = None,
|
|
123
|
+
allow_terminal_status_override: tuple[str, ...] = (),
|
|
124
|
+
) -> None:
|
|
125
|
+
if current_status in allow_terminal_status_override:
|
|
126
|
+
return
|
|
127
|
+
if not is_allowed_stage_transition(current_stage, target_stage):
|
|
128
|
+
raise SystemExit(
|
|
129
|
+
f"Illegal Superflow stage transition: {current_stage!r} -> {target_stage!r}. "
|
|
130
|
+
"Update status.json through the canonical workflow order instead of skipping phases."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def assert_status_transition_allowed(
|
|
135
|
+
current_status: str | None,
|
|
136
|
+
target_status: str,
|
|
137
|
+
*,
|
|
138
|
+
allow_terminal_status_override: tuple[str, ...] = (),
|
|
139
|
+
) -> None:
|
|
140
|
+
if current_status in allow_terminal_status_override:
|
|
141
|
+
return
|
|
142
|
+
if not is_allowed_status_transition(current_status, target_status):
|
|
143
|
+
raise SystemExit(
|
|
144
|
+
f"Illegal Superflow status transition: {current_status!r} -> {target_status!r}. "
|
|
145
|
+
"Use the canonical workflow state progression instead of forcing status.json."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def record_stage_transition(status: dict[str, Any], target_stage: str, *, reason: str) -> None:
|
|
150
|
+
current_stage = status.get("current_stage")
|
|
151
|
+
history = status.setdefault("stage_history", [])
|
|
152
|
+
if not isinstance(history, list):
|
|
153
|
+
history = []
|
|
154
|
+
status["stage_history"] = history
|
|
155
|
+
if current_stage != target_stage:
|
|
156
|
+
history.append(
|
|
157
|
+
{
|
|
158
|
+
"from": current_stage,
|
|
159
|
+
"to": target_stage,
|
|
160
|
+
"reason": reason,
|
|
161
|
+
"at": now_iso(),
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
status["current_stage"] = target_stage
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def record_value_transition(status: dict[str, Any], field: str, target_value: str, *, reason: str) -> None:
|
|
168
|
+
current_value = status.get(field)
|
|
169
|
+
history_field = f"{field}_history"
|
|
170
|
+
history = status.setdefault(history_field, [])
|
|
171
|
+
if not isinstance(history, list):
|
|
172
|
+
history = []
|
|
173
|
+
status[history_field] = history
|
|
174
|
+
if current_value != target_value:
|
|
175
|
+
history.append(
|
|
176
|
+
{
|
|
177
|
+
"from": current_value,
|
|
178
|
+
"to": target_value,
|
|
179
|
+
"reason": reason,
|
|
180
|
+
"at": now_iso(),
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
status[field] = target_value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def find_git_root(start: str | Path) -> Path:
|
|
187
|
+
root = run_stdout(["git", "rev-parse", "--show-toplevel"], cwd=start)
|
|
188
|
+
return Path(root)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_remote_url(checkout_path: str | Path, remote: str = "origin") -> str:
|
|
192
|
+
return run_stdout(["git", "remote", "get-url", remote], cwd=checkout_path)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_current_branch(checkout_path: str | Path) -> str:
|
|
196
|
+
return run_stdout(["git", "branch", "--show-current"], cwd=checkout_path)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def remote_matches_repo(remote_url: str, repo: str) -> bool:
|
|
200
|
+
normalized = remote_url.lower()
|
|
201
|
+
owner_repo = repo.lower()
|
|
202
|
+
return normalized.endswith(f"/{owner_repo}.git") or normalized.endswith(f":{owner_repo}.git") or normalized.endswith(f"/{owner_repo}") or normalized.endswith(f":{owner_repo}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def ensure_checkout_matches_repo(checkout_path: str | Path, repo: str, remote: str = "origin") -> str:
|
|
206
|
+
remote_url = get_remote_url(checkout_path, remote)
|
|
207
|
+
if not remote_matches_repo(remote_url, repo):
|
|
208
|
+
raise SystemExit(
|
|
209
|
+
f"Checkout remote mismatch: {checkout_path} points {remote} to {remote_url!r}, expected repository {repo!r}."
|
|
210
|
+
)
|
|
211
|
+
return remote_url
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def resolve_checkout_path(repo: str, *, preferred: str | Path | None = None, cwd_hint: str | Path | None = None) -> Path:
|
|
215
|
+
candidates: list[Path] = []
|
|
216
|
+
|
|
217
|
+
if preferred:
|
|
218
|
+
preferred_path = Path(preferred).expanduser().resolve()
|
|
219
|
+
if not preferred_path.exists():
|
|
220
|
+
raise SystemExit(f"Checkout path does not exist: {preferred_path}")
|
|
221
|
+
candidates.append(preferred_path)
|
|
222
|
+
|
|
223
|
+
if cwd_hint:
|
|
224
|
+
try:
|
|
225
|
+
git_root = find_git_root(cwd_hint)
|
|
226
|
+
candidates.append(git_root)
|
|
227
|
+
except subprocess.CalledProcessError:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
repo_name = repo.split("/", 1)[1]
|
|
231
|
+
for root in PROJECT_SEARCH_ROOTS:
|
|
232
|
+
candidate = root / repo_name
|
|
233
|
+
if candidate.exists():
|
|
234
|
+
candidates.append(candidate.resolve())
|
|
235
|
+
|
|
236
|
+
unique_candidates: list[Path] = []
|
|
237
|
+
seen: set[str] = set()
|
|
238
|
+
for candidate in candidates:
|
|
239
|
+
key = str(candidate)
|
|
240
|
+
if key in seen:
|
|
241
|
+
continue
|
|
242
|
+
seen.add(key)
|
|
243
|
+
unique_candidates.append(candidate)
|
|
244
|
+
|
|
245
|
+
matches: list[Path] = []
|
|
246
|
+
for candidate in unique_candidates:
|
|
247
|
+
try:
|
|
248
|
+
remote_url = get_remote_url(candidate)
|
|
249
|
+
except subprocess.CalledProcessError:
|
|
250
|
+
continue
|
|
251
|
+
if remote_matches_repo(remote_url, repo):
|
|
252
|
+
matches.append(candidate)
|
|
253
|
+
|
|
254
|
+
if not matches:
|
|
255
|
+
raise SystemExit(
|
|
256
|
+
f"Unable to resolve a local checkout for {repo}. Pass --checkout-path explicitly or create a checkout under a standard project root."
|
|
257
|
+
)
|
|
258
|
+
if len(matches) > 1:
|
|
259
|
+
joined = "\n".join(f"- {path}" for path in matches)
|
|
260
|
+
raise SystemExit(f"Multiple local checkouts match {repo}. Pass --checkout-path explicitly:\n{joined}")
|
|
261
|
+
return matches[0]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def ensure_branch_checked_out(checkout_path: str | Path, branch_name: str) -> None:
|
|
265
|
+
current = run_stdout(["git", "branch", "--show-current"], cwd=checkout_path)
|
|
266
|
+
if current == branch_name:
|
|
267
|
+
return
|
|
268
|
+
if local_branch_exists(checkout_path, branch_name):
|
|
269
|
+
run(["git", "checkout", branch_name], cwd=checkout_path)
|
|
270
|
+
return
|
|
271
|
+
if remote_branch_exists(checkout_path, branch_name):
|
|
272
|
+
run(["git", "checkout", "-B", branch_name, "--track", f"origin/{branch_name}"], cwd=checkout_path)
|
|
273
|
+
return
|
|
274
|
+
raise SystemExit(f"Branch not found locally or on origin: {branch_name}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def local_branch_exists(checkout_path: str | Path, branch_name: str) -> bool:
|
|
278
|
+
result = subprocess.run(
|
|
279
|
+
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"],
|
|
280
|
+
cwd=str(checkout_path),
|
|
281
|
+
text=True,
|
|
282
|
+
capture_output=True,
|
|
283
|
+
)
|
|
284
|
+
return result.returncode == 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def remote_branch_exists(checkout_path: str | Path, branch_name: str) -> bool:
|
|
288
|
+
result = subprocess.run(
|
|
289
|
+
["git", "ls-remote", "--exit-code", "--heads", "origin", branch_name],
|
|
290
|
+
cwd=str(checkout_path),
|
|
291
|
+
text=True,
|
|
292
|
+
capture_output=True,
|
|
293
|
+
)
|
|
294
|
+
return result.returncode == 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def get_default_branch(repo: str) -> str:
|
|
298
|
+
data = gh_json(["repo", "view", repo, "--json", "defaultBranchRef"])
|
|
299
|
+
default_branch = data.get("defaultBranchRef", {}).get("name")
|
|
300
|
+
if not default_branch:
|
|
301
|
+
raise SystemExit(f"Could not determine default branch for {repo}")
|
|
302
|
+
return default_branch
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def upsert_issue_comment(repo: str, issue_number: int, marker: str, body: str) -> int:
|
|
306
|
+
comments = gh_json(["api", f"repos/{repo}/issues/{issue_number}/comments"])
|
|
307
|
+
existing = None
|
|
308
|
+
for comment in comments:
|
|
309
|
+
if marker in comment.get("body", ""):
|
|
310
|
+
existing = comment
|
|
311
|
+
break
|
|
312
|
+
|
|
313
|
+
comment_body = f"{marker}\n{body}".strip() + "\n"
|
|
314
|
+
if existing:
|
|
315
|
+
comment_id = existing["id"]
|
|
316
|
+
gh_json(
|
|
317
|
+
[
|
|
318
|
+
"api",
|
|
319
|
+
f"repos/{repo}/issues/comments/{comment_id}",
|
|
320
|
+
"--method",
|
|
321
|
+
"PATCH",
|
|
322
|
+
"-f",
|
|
323
|
+
f"body={comment_body}",
|
|
324
|
+
]
|
|
325
|
+
)
|
|
326
|
+
return int(comment_id)
|
|
327
|
+
|
|
328
|
+
created = gh_json(
|
|
329
|
+
[
|
|
330
|
+
"api",
|
|
331
|
+
f"repos/{repo}/issues/{issue_number}/comments",
|
|
332
|
+
"--method",
|
|
333
|
+
"POST",
|
|
334
|
+
"-f",
|
|
335
|
+
f"body={comment_body}",
|
|
336
|
+
]
|
|
337
|
+
)
|
|
338
|
+
return int(created["id"])
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def discover_project_from_issue(repo: str, issue_number: int) -> dict[str, Any] | None:
|
|
342
|
+
owner, name = repo.split("/", 1)
|
|
343
|
+
query = """
|
|
344
|
+
query($owner:String!, $name:String!, $number:Int!) {
|
|
345
|
+
repository(owner:$owner, name:$name) {
|
|
346
|
+
issue(number:$number) {
|
|
347
|
+
projectItems(first:20) {
|
|
348
|
+
nodes {
|
|
349
|
+
id
|
|
350
|
+
project {
|
|
351
|
+
id
|
|
352
|
+
number
|
|
353
|
+
title
|
|
354
|
+
closed
|
|
355
|
+
owner {
|
|
356
|
+
__typename
|
|
357
|
+
login
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
"""
|
|
366
|
+
try:
|
|
367
|
+
data = gh_graphql(query, {"owner": owner, "name": name, "number": issue_number})
|
|
368
|
+
except subprocess.CalledProcessError:
|
|
369
|
+
return None
|
|
370
|
+
nodes = (((data or {}).get("data") or {}).get("repository") or {}).get("issue", {}).get("projectItems", {}).get("nodes", [])
|
|
371
|
+
open_nodes = [node for node in nodes if not ((node.get("project") or {}).get("closed"))]
|
|
372
|
+
if len(open_nodes) == 1:
|
|
373
|
+
project = open_nodes[0]["project"]
|
|
374
|
+
return {
|
|
375
|
+
"owner": project["owner"]["login"],
|
|
376
|
+
"number": int(project["number"]),
|
|
377
|
+
"title": project["title"],
|
|
378
|
+
}
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def resolve_project_status_components(owner: str, project_number: int, status_field_name: str, option_name: str, repo: str, issue_number: int) -> tuple[str, str, str, str]:
|
|
383
|
+
project = gh_json(["project", "view", str(project_number), "--owner", owner, "--format", "json"])
|
|
384
|
+
project_id = project.get("id")
|
|
385
|
+
if not project_id:
|
|
386
|
+
raise SystemExit(f"Could not resolve project id for {owner} project {project_number}")
|
|
387
|
+
|
|
388
|
+
fields = gh_json(["project", "field-list", str(project_number), "--owner", owner, "--format", "json"])
|
|
389
|
+
status_field = next((field for field in fields if field.get("name") == status_field_name), None)
|
|
390
|
+
if not status_field:
|
|
391
|
+
raise SystemExit(f"Project field not found: {status_field_name}")
|
|
392
|
+
field_id = status_field.get("id")
|
|
393
|
+
if not field_id:
|
|
394
|
+
raise SystemExit(f"Project field id missing for {status_field_name}")
|
|
395
|
+
|
|
396
|
+
options = status_field.get("options") or []
|
|
397
|
+
option = next((entry for entry in options if entry.get("name") == option_name), None)
|
|
398
|
+
if not option:
|
|
399
|
+
available = ", ".join(entry.get("name", "?") for entry in options)
|
|
400
|
+
raise SystemExit(f"Project option not found: {option_name}. Available: {available}")
|
|
401
|
+
option_id = option.get("id")
|
|
402
|
+
if not option_id:
|
|
403
|
+
raise SystemExit(f"Project option id missing for {option_name}")
|
|
404
|
+
|
|
405
|
+
owner_name, repo_name = repo.split("/", 1)
|
|
406
|
+
items = gh_json(["project", "item-list", str(project_number), "--owner", owner, "--format", "json", "--limit", "200"])
|
|
407
|
+
item_id = None
|
|
408
|
+
for item in items:
|
|
409
|
+
content = item.get("content") or {}
|
|
410
|
+
if int(content.get("number", -1)) != int(issue_number):
|
|
411
|
+
continue
|
|
412
|
+
repository = content.get("repository") or {}
|
|
413
|
+
repo_owner = (repository.get("owner") or {}).get("login")
|
|
414
|
+
repo_name_value = repository.get("name")
|
|
415
|
+
if repo_owner == owner_name and repo_name_value == repo_name:
|
|
416
|
+
item_id = item.get("id")
|
|
417
|
+
break
|
|
418
|
+
|
|
419
|
+
if not item_id:
|
|
420
|
+
raise SystemExit(f"Project item for issue {repo}#{issue_number} not found in {owner} project {project_number}")
|
|
421
|
+
return (
|
|
422
|
+
project_id if isinstance(project_id, str) else str(project_id),
|
|
423
|
+
field_id if isinstance(field_id, str) else str(field_id),
|
|
424
|
+
option_id if isinstance(option_id, str) else str(option_id),
|
|
425
|
+
item_id if isinstance(item_id, str) else str(item_id),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def set_project_single_select_status(owner: str, project_number: int, status_field_name: str, option_name: str, repo: str, issue_number: int) -> dict[str, str]:
|
|
430
|
+
project_id, field_id, option_id, item_id = resolve_project_status_components(owner, project_number, status_field_name, option_name, repo, issue_number)
|
|
431
|
+
run(
|
|
432
|
+
[
|
|
433
|
+
"gh",
|
|
434
|
+
"project",
|
|
435
|
+
"item-edit",
|
|
436
|
+
"--id",
|
|
437
|
+
item_id,
|
|
438
|
+
"--project-id",
|
|
439
|
+
project_id,
|
|
440
|
+
"--field-id",
|
|
441
|
+
field_id,
|
|
442
|
+
"--single-select-option-id",
|
|
443
|
+
option_id,
|
|
444
|
+
]
|
|
445
|
+
)
|
|
446
|
+
return {
|
|
447
|
+
"project_id": project_id,
|
|
448
|
+
"item_id": item_id,
|
|
449
|
+
"field_id": field_id,
|
|
450
|
+
"option_id": option_id,
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def resolve_project_context(repo: str, issue_number: int, explicit_owner: str | None = None, explicit_number: int | None = None) -> dict[str, Any] | None:
|
|
455
|
+
if explicit_owner and explicit_number:
|
|
456
|
+
return {"owner": explicit_owner, "number": int(explicit_number), "source": "explicit"}
|
|
457
|
+
discovered = discover_project_from_issue(repo, issue_number)
|
|
458
|
+
if discovered:
|
|
459
|
+
discovered["source"] = "issue-project-item"
|
|
460
|
+
return discovered
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def load_status(project_root: str | Path, change_id: str) -> tuple[Path, dict[str, Any]]:
|
|
465
|
+
path = Path(project_root).expanduser().resolve() / "specs" / "changes" / change_id / "status.json"
|
|
466
|
+
if not path.exists():
|
|
467
|
+
raise SystemExit(f"Missing status.json: {path}")
|
|
468
|
+
return path, read_json(path)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def save_status(path: Path, status: dict[str, Any]) -> None:
|
|
472
|
+
status["updated_at"] = now_iso()
|
|
473
|
+
write_json(path, status)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def maybe_relative(path: str | Path, *, base: str | Path) -> str:
|
|
477
|
+
try:
|
|
478
|
+
return str(Path(path).resolve().relative_to(Path(base).resolve()))
|
|
479
|
+
except ValueError:
|
|
480
|
+
return str(Path(path).resolve())
|