Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 478 additions & 0 deletions engine/src/sdlc_engine/adf_work.py

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions engine/src/sdlc_engine/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .commit_message import CommitMessageError, CommitMessageService
from .db import LocalIndex, format_rows
from .issues import IssueSyncService
from .adf_work import AdfWorkService
from .local_sessions import LocalSessionService
from .pointer import PointerError, PointerStore
from .project import Project
Expand Down Expand Up @@ -467,6 +468,35 @@ def cmd_local(args: argparse.Namespace) -> int:
return 2


def cmd_work(args: argparse.Namespace) -> int:
"""Work helpers that are engine-backed (init from ADF, etc.)."""
action = args.work_cmd
if action == "init-from-adf":
svc = AdfWorkService(_project(args))
try:
result = svc.init_from_adf(
args.path,
work_type=args.type,
title=args.title or "",
work_id=args.work_id or "",
claim=not args.no_claim,
dry_run=args.dry_run,
)
except (OSError, ValueError, PermissionError, FileExistsError) as exc:
print(str(exc), file=sys.stderr)
return 1
prefix = "[dry-run] would create" if result.dry_run else "Created"
print(f"{prefix} {result.work_id} from {result.adf_path}")
print(f" title: {result.title}")
print(f" canvas: {result.canvas_path}")
print(f" requirement: {result.requirement_path}")
if result.source_issue:
print(f" source issue: {result.source_issue}")
print(f" next: {result.next_command}")
return 0
return 2


def cmd_viewer(args: argparse.Namespace) -> int:
"""Launch the ADF WYSIWYG viewer (binds localhost by default)."""
try:
Expand Down Expand Up @@ -693,6 +723,23 @@ def build_parser() -> argparse.ArgumentParser:
lp.add_argument("--dry-run", action="store_true")
lp.set_defaults(func=cmd_local)

work = sub.add_parser(
"work",
help="Create / bootstrap Work IDs (engine-backed helpers)",
)
work_sub = work.add_subparsers(dest="work_cmd", required=True)
wia = work_sub.add_parser(
"init-from-adf",
help="Create draft REASONS canvas + requirement from a local ADF file",
)
wia.add_argument("--path", required=True, help="Path to .adf.json / ADF JSON file")
wia.add_argument("--type", default="feature", help="feature|spike|bug|refactor|chore|...")
wia.add_argument("--title", help="Override title (default: first ADF heading or filename)")
wia.add_argument("--work-id", help="Explicit Work ID (default: auto FEAT-NNN-slug)")
wia.add_argument("--no-claim", action="store_true", help="Create artifacts without claiming")
wia.add_argument("--dry-run", action="store_true")
wia.set_defaults(func=cmd_work)

cm = sub.add_parser(
"commit-message",
help="Collect staged/unstaged/ahead-of-base diff for drafting a commit message (does not commit)",
Expand Down
69 changes: 69 additions & 0 deletions engine/src/sdlc_engine/installer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from pathlib import Path
from typing import Any

from sdlc_engine.adf_work import AdfWorkService
from sdlc_engine.db import LocalIndex
from sdlc_engine.project import Project
from sdlc_engine.viewer.store import AdfStore, AdfStoreError

from .detect import detect_target
from .guide import checklist, ingest_command, load_config, probe_guide, save_config
Expand Down Expand Up @@ -544,6 +546,73 @@ def api_adf_restart() -> Any:
out["error"] = result["error"]
return jsonify(out), (200 if result.get("ok") else 400)

@app.post("/api/adf/browse")
def api_adf_browse() -> Any:
body = request.get_json(silent=True) or {}
target = _target_from_body(body)
path = str(body.get("path") or "").strip()
store = AdfStore(target)
store.ensure_dir()
try:
listing = store.browse(path or str(store.adf_dir))
except AdfStoreError as exc:
return jsonify({"ok": False, "error": str(exc), "target": str(target)}), 400
return jsonify(
{
"ok": True,
"target": str(target),
"home": str(target),
"adf_dir": str(store.adf_dir),
**listing,
}
)

@app.post("/api/adf/init-work")
def api_adf_init_work() -> Any:
body = request.get_json(silent=True) or {}
target = _target_from_body(body)
adf_path = str(body.get("path") or "").strip()
if not adf_path:
return jsonify({"ok": False, "error": "path required"}), 400
work_type = str(body.get("type") or "feature").strip() or "feature"
title = str(body.get("title") or "").strip()
work_id = str(body.get("work_id") or "").strip()
claim = body.get("claim", True) is not False
dry_run = bool(body.get("dry_run"))
svc = AdfWorkService(Project.resolve(target))
try:
result = svc.init_from_adf(
adf_path,
work_type=work_type,
title=title,
work_id=work_id,
claim=claim,
dry_run=dry_run,
)
except (OSError, ValueError, PermissionError, FileExistsError) as exc:
return jsonify({"ok": False, "error": str(exc), "target": str(target)}), 400
return jsonify(
{
"ok": True,
"target": str(target),
"work_id": result.work_id,
"title": result.title,
"adf_path": result.adf_path,
"canvas_path": result.canvas_path,
"requirement_path": result.requirement_path,
"feature_dir": result.feature_dir,
"source_issue": result.source_issue,
"next_command": result.next_command,
"dry_run": result.dry_run,
"cli": (
f"./scripts/sdlc.sh work init-from-adf --path {adf_path}"
+ (f" --title {title!r}" if title else "")
+ (f" --work-id {work_id}" if work_id else "")
+ (f" --type {work_type}" if work_type != "feature" else "")
),
}
)

return app


Expand Down
180 changes: 179 additions & 1 deletion engine/src/sdlc_engine/installer/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@
.status-line { margin-bottom: 0.55rem; font-family: var(--mono); font-size: 0.78rem; color: var(--muted); }
.status-line.ok { color: var(--ok); }
.status-line.bad { color: var(--bad); }
.browser-list {
border: 1px solid var(--line); background: #fff; max-height: 16rem; overflow: auto;
margin: 0.5rem 0 0.75rem;
}
.browser-row {
display: flex; gap: 0.55rem; width: 100%; text-align: left;
border: 0; border-bottom: 1px solid var(--line); background: transparent;
padding: 0.45rem 0.65rem; cursor: pointer; font: 500 0.88rem var(--mono); color: var(--ink);
}
.browser-row:hover { background: #eef6f1; }
.browser-row.selected { background: color-mix(in srgb, var(--accent) 14%, white); }
.browser-row.invalid { color: var(--muted); cursor: default; }
.browser-row .kind {
flex: 0 0 2.4rem; font-size: 0.72rem; font-weight: 700; color: var(--muted);
}
footer { margin-top: 1.25rem; color: var(--muted); font-size: 0.85rem; }
footer code { font-family: var(--mono); font-size: 0.8rem; }
@keyframes rise {
Expand Down Expand Up @@ -388,7 +403,7 @@
<section class="tab-pane" id="pane-adf">
<div class="panel">
<h2>ADF Viewer</h2>
<p class="meta">Editing and Jira sync live in the ADF Viewer. This tab only manages the process and opens the URL (default port 5050).</p>
<p class="meta">Start/stop the viewer for editing and Jira sync. Below: browse a local ADF and init a draft REASONS canvas.</p>
<div class="row" style="margin-bottom: 0.65rem;">
<div style="flex: 1 1 10rem;">
<label class="field" for="adf-host">Host</label>
Expand All @@ -412,6 +427,45 @@
<pre class="cmd" id="adf-cmd">—</pre>
<pre class="log" id="adf-log">No action yet.</pre>
</div>

<div class="panel">
<h2>Init SPDD work from ADF</h2>
<p class="meta">Browse to an <code>.adf.json</code>, then create a draft REASONS canvas + requirement and hand off to analysis.</p>
<div class="row" style="margin-bottom: 0.45rem;">
<input id="adf-browse-path" type="text" spellcheck="false" placeholder="Browse path (defaults to target/adf)" />
<button type="button" class="btn-secondary" id="btn-adf-browse">Browse</button>
<button type="button" class="btn-ghost" id="btn-adf-browse-home">Target root</button>
<button type="button" class="btn-ghost" id="btn-adf-browse-adf">adf/</button>
</div>
<div class="browser-list" id="adf-browser-list"></div>
<div class="meta" id="adf-selected">No ADF selected.</div>
<div class="row" style="margin-top: 0.75rem;">
<div style="flex: 1 1 8rem;">
<label class="field" for="adf-work-type">Type</label>
<select id="adf-work-type">
<option value="feature">feature</option>
<option value="spike">spike</option>
<option value="bug">bug</option>
<option value="refactor">refactor</option>
<option value="chore">chore</option>
</select>
</div>
<div style="flex: 2 1 14rem;">
<label class="field" for="adf-work-title">Title override (optional)</label>
<input id="adf-work-title" type="text" spellcheck="false" placeholder="Defaults to first heading / filename" />
</div>
<div style="flex: 2 1 14rem;">
<label class="field" for="adf-work-id">Work ID override (optional)</label>
<input id="adf-work-id" type="text" spellcheck="false" placeholder="FEAT-013-slug" />
</div>
</div>
<div class="actions" style="margin-top: 0.75rem;">
<button type="button" class="btn-secondary" id="btn-adf-init-dry">Dry run</button>
<button type="button" class="btn-primary" id="btn-adf-init">Init SPDD work</button>
</div>
<div class="status-line" id="adf-init-status">Ready.</div>
<pre class="log" id="adf-init-log">No init yet.</pre>
</div>
</section>

<footer>
Expand Down Expand Up @@ -888,9 +942,125 @@
await adfAction("/api/adf/start", "Start viewer", { openAfter: true });
}

let adfBrowsePath = "";
let adfSelectedPath = "";
let adfHome = "";
let adfDir = "";

function setAdfSelected(path) {
adfSelectedPath = path || "";
$("adf-selected").textContent = adfSelectedPath
? ("Selected: " + adfSelectedPath)
: "No ADF selected.";
}

async function loadAdfBrowse(path) {
const res = await fetch("/api/adf/browse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(adfBody({ path: path || "" })),
});
const data = await res.json();
if (!data.ok) {
$("adf-init-status").textContent = data.error || "Browse failed";
$("adf-init-status").className = "status-line bad";
$("adf-init-log").textContent = JSON.stringify(data, null, 2);
return;
}
adfBrowsePath = data.path || "";
adfHome = data.home || target();
adfDir = data.adf_dir || "";
$("adf-browse-path").value = adfBrowsePath;
const list = $("adf-browser-list");
list.innerHTML = "";
if (data.parent) {
const up = document.createElement("button");
up.type = "button";
up.className = "browser-row";
up.innerHTML = '<span class="kind">DIR</span><span>..</span>';
up.onclick = () => loadAdfBrowse(data.parent);
list.appendChild(up);
}
for (const d of data.dirs || []) {
const row = document.createElement("button");
row.type = "button";
row.className = "browser-row";
row.innerHTML = '<span class="kind">DIR</span><span></span>';
row.querySelector("span:last-child").textContent = d.name + "/";
row.onclick = () => loadAdfBrowse(d.path);
list.appendChild(row);
}
for (const f of data.files || []) {
const row = document.createElement("button");
row.type = "button";
row.className = "browser-row" + (f.valid ? "" : " invalid")
+ (f.path === adfSelectedPath ? " selected" : "");
row.innerHTML = '<span class="kind">ADF</span><span></span>';
row.querySelector("span:last-child").textContent = f.name + (f.valid ? "" : " (invalid)");
if (f.valid) {
row.onclick = () => {
setAdfSelected(f.path);
loadAdfBrowse(adfBrowsePath);
};
}
list.appendChild(row);
}
if (!(data.dirs || []).length && !(data.files || []).length) {
const empty = document.createElement("div");
empty.className = "meta";
empty.style.padding = "0.65rem";
empty.textContent = "No ADF candidates in this folder.";
list.appendChild(empty);
}
$("adf-init-status").textContent = "Browsing " + adfBrowsePath;
$("adf-init-status").className = "status-line";
}

async function initFromAdf(dryRun) {
if (!adfSelectedPath) {
$("adf-init-status").textContent = "Select an ADF file first.";
$("adf-init-status").className = "status-line bad";
return;
}
const label = dryRun ? "Dry run" : "Init SPDD work";
$("adf-init-status").textContent = label + "…";
$("adf-init-status").className = "status-line";
setBusy(["btn-adf-init", "btn-adf-init-dry", "btn-adf-browse"], true);
try {
const res = await fetch("/api/adf/init-work", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(adfBody({
path: adfSelectedPath,
type: $("adf-work-type").value,
title: $("adf-work-title").value.trim(),
work_id: $("adf-work-id").value.trim(),
dry_run: !!dryRun,
})),
});
const data = await res.json();
$("adf-init-log").textContent = JSON.stringify(data, null, 2);
if (!data.ok) {
$("adf-init-status").textContent = data.error || (label + " failed");
$("adf-init-status").className = "status-line bad";
return;
}
$("adf-init-status").textContent = (dryRun ? "Would create " : "Created ")
+ data.work_id + " — next: " + data.next_command;
$("adf-init-status").className = "status-line ok";
} catch (err) {
$("adf-init-status").textContent = String(err);
$("adf-init-status").className = "status-line bad";
$("adf-init-log").textContent = String(err);
} finally {
setBusy(["btn-adf-init", "btn-adf-init-dry", "btn-adf-browse"], false);
}
}

async function refreshAll() {
await detect();
await Promise.all([loadSqlite(), loadBackups(), loadGuide(), loadAdf()]);
await loadAdfBrowse($("adf-browse-path").value.trim() || adfBrowsePath || "");
}

$("btn-detect").addEventListener("click", detect);
Expand Down Expand Up @@ -950,6 +1120,14 @@
$("btn-adf-stop").addEventListener("click", () => adfAction("/api/adf/stop", "Stop viewer"));
$("btn-adf-restart").addEventListener("click", () => adfAction("/api/adf/restart", "Restart viewer"));
$("btn-adf-open").addEventListener("click", openAdfViewer);
$("btn-adf-browse").addEventListener("click", () => loadAdfBrowse($("adf-browse-path").value.trim()));
$("btn-adf-browse-home").addEventListener("click", () => loadAdfBrowse(adfHome || target()));
$("btn-adf-browse-adf").addEventListener("click", () => loadAdfBrowse(adfDir || (target() + "/adf")));
$("btn-adf-init-dry").addEventListener("click", () => initFromAdf(true));
$("btn-adf-init").addEventListener("click", () => initFromAdf(false));
$("adf-browse-path").addEventListener("keydown", (e) => {
if (e.key === "Enter") loadAdfBrowse($("adf-browse-path").value.trim());
});
$("as-all").addEventListener("change", () => {
if ($("as-all").checked) {
$("as-cursor").checked = true;
Expand Down
Loading
Loading