From b5e7edd385697358204ec0b714757f808bac17a7 Mon Sep 17 00:00:00 2001 From: Mike Hanley Date: Fri, 19 Jun 2026 13:31:29 -0500 Subject: [PATCH 1/2] fix: allow fixing a specific issue --- src/projectmem/cli.py | 15 ++++-- src/projectmem/commands/fix.py | 54 ++++++++++++++++++--- src/projectmem/mcp_server.py | 53 ++++++++++++++------- tests/test_log.py | 86 ++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 27 deletions(-) diff --git a/src/projectmem/cli.py b/src/projectmem/cli.py index b94b65f..1d1e409 100644 --- a/src/projectmem/cli.py +++ b/src/projectmem/cli.py @@ -106,10 +106,19 @@ def attempt( @app.command() def fix( text: str, - at: str | None = typer.Option(None, "--at", help="Location (e.g. file:line, class.method)"), + at: str | None = typer.Option( + None, + "--at", + help="Location (e.g. file:line, class.method)", + ), + issue: str | None = typer.Option( + None, + "--issue", + help="Close a specific issue ID instead of the active issue (e.g. 0042).", + ), ) -> None: - """Record a fix and close the current issue.""" - fix_command.run(text, location=at) + """Record a fix and close the active issue, or a specific issue with --issue.""" + fix_command.run(text, location=at, issue=issue) @app.command() diff --git a/src/projectmem/commands/fix.py b/src/projectmem/commands/fix.py index 6f92968..330b5d1 100644 --- a/src/projectmem/commands/fix.py +++ b/src/projectmem/commands/fix.py @@ -15,14 +15,51 @@ from projectmem.summary import regenerate_summary -def run(text: str, location: str | None = None) -> Event: - """Close the current issue with a fix. Returns the created fix Event. +def _normalize_issue_id(issue_id: str | None) -> str | None: + """Normalize issue IDs so `1`, `001`, and `0001` all become `0001`.""" + if issue_id is None: + return None + + cleaned = issue_id.strip().lstrip("#") + if not cleaned: + return None + if cleaned.isdigit(): + return cleaned.zfill(4) + return cleaned + + +def _issue_exists(events: list[Event], issue_id: str) -> bool: + """Return True if an issue event exists for the requested issue ID.""" + return any(event.type == "issue" and event.issue_id == issue_id for event in events) + + +def run( + text: str, + location: str | None = None, + issue: str | None = None, +) -> Event: + """Close an issue with a fix. Returns the created fix Event. - Clears the current-issue marker so subsequent `pjm attempt` calls do not - silently re-attach to the just-closed issue (the L-027a misattribution bug). + When `issue` is omitted, this preserves the existing behavior: + close the current issue and clear the current-issue marker. + + When `issue` is provided, the fix is attached to that specific issue. + The current-issue marker is only cleared if it points at the same issue. """ events = read_events() - issue_id = read_current_issue() or current_issue_id(events) + requested_issue_id = _normalize_issue_id(issue) + active_issue_id = read_current_issue() or current_issue_id(events) + + if requested_issue_id is not None: + issue_id = requested_issue_id + if not _issue_exists(events, issue_id): + raise ProjectMemError( + f"Issue #{issue_id} was not found. " + "Run `pjm search ` or `pjm brief` to find the issue ID." + ) + else: + issue_id = active_issue_id + if issue_id is None: raise ProjectMemError("No open issue found. Run `pjm log ` first.") @@ -34,7 +71,12 @@ def run(text: str, location: str | None = None) -> Event: location=location, ) append_event(event) - clear_current_issue() + + # Preserve old behavior when no specific issue was requested. For targeted + # fixes, only clear the active marker if it matches the issue being fixed. + if requested_issue_id is None or active_issue_id == issue_id: + clear_current_issue() + regenerate_summary() typer.echo(f"Fixed issue #{issue_id}") return event diff --git a/src/projectmem/mcp_server.py b/src/projectmem/mcp_server.py index 01b50bc..8ce9d9a 100644 --- a/src/projectmem/mcp_server.py +++ b/src/projectmem/mcp_server.py @@ -154,7 +154,7 @@ def wrapper(*args, **kwargs): "directly via filesystem write:\n" " - On a bug discovery → log_issue(summary, location).\n" " - After each fix attempt → record_attempt(summary, outcome).\n" - " - After confirmation → record_fix(summary).\n" + " - After confirmation → use record_fix(summary) for the active issue. If fixing a specific older issue, use record_fix(summary, issue_id=\"\") and replace with the actual Projectmem issue ID.\n" " - On a design choice → add_decision(summary).\n" " - On a gotcha / setup detail → add_note(summary).\n" "Editing .projectmem/summary.md or .projectmem/PROJECT_MAP.md\n" @@ -509,26 +509,43 @@ def record_attempt( @mcp.tool() @safe_tool def record_fix( - summary: Annotated[str, Field( - description="One-line description of the confirmed fix (e.g., " - "'guarded submit handler with isSubmitting ref')." - )], - location: Annotated[Optional[str], Field( - description="Optional file path or component where the fix was " - "applied." - )] = None, + summary: Annotated[ + str, + Field( + description=( + "One-line description of the confirmed fix " + "(e.g., 'guarded submit handler with isSubmitting ref')." + ) + ), + ], + location: Annotated[ + Optional[str], + Field( + description="Optional file path or component where the fix was applied." + ), + ] = None, + issue_id: Annotated[ + Optional[str], + Field( + description=( + "Optional zero-padded issue ID (e.g., '0042') to close. " + "When omitted, closes the active issue. Numeric strings without " + "padding are accepted." + ) + ), + ] = None, ) -> str: - """Record a confirmed fix and close the current issue. + """Record a confirmed fix and close an issue. + + Only call AFTER you have evidence the fix works: test passes, error is gone, + or the user confirmed. - Only call AFTER you have evidence the fix works (test passes, error - gone, user confirmed). Closing the issue clears the active-issue - marker so the next record_attempt won't silently latch onto this - closed issue (L-027a). + If `issue_id` is provided, the fix is attached to that specific issue. + If `issue_id` is omitted, the active issue is closed. - Side effects: appends a `fix` event, closes the active issue, and - clears the active-issue marker so the next record_attempt won't - silently latch onto a closed issue.""" - event = fix.run(summary, location=location) + Side effects: appends a `fix` event and updates summary.md. The active-issue + marker is cleared only when the active issue is the issue being fixed.""" + event = fix.run(summary, location=location, issue=issue_id) return f"Fixed issue #{event.issue_id}: {summary}" diff --git a/tests/test_log.py b/tests/test_log.py index df7b637..e9aac80 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -34,6 +34,92 @@ def test_log_attempt_and_fix_write_events(tmp_path, monkeypatch): assert events[1]["outcome"] == "failed" +def test_fix_issue_closes_target_without_clearing_newer_active_issue( + tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["log", "old issue"], catch_exceptions=False) + runner.invoke(app, ["log", "new issue"], catch_exceptions=False) + + result = runner.invoke( + app, + ["fix", "fixed old issue", "--issue", "1"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "Fixed issue #0001" in result.stdout + assert ( + tmp_path / ".projectmem" / ".current_issue" + ).read_text(encoding="utf-8") == "0002" + + events = [ + json.loads(line) + for line in (tmp_path / ".projectmem" / "events.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + assert events[-1]["type"] == "fix" + assert events[-1]["issue_id"] == "0001" + + +def test_plain_fix_still_closes_active_issue(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["log", "active issue"], catch_exceptions=False) + + result = runner.invoke(app, ["fix", "fixed active"], catch_exceptions=False) + + assert result.exit_code == 0 + assert "Fixed issue #0001" in result.stdout + assert not (tmp_path / ".projectmem" / ".current_issue").exists() + + +def test_fix_issue_rejects_unknown_issue(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["log", "known issue"], catch_exceptions=False) + + result = runner.invoke(app, ["fix", "missing issue", "--issue", "42"]) + + assert result.exit_code == 1 + assert result.exception is not None + assert "Issue #0042 was not found" in str(result.exception) + + +def test_mcp_record_fix_accepts_target_issue_id(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["log", "old issue"], catch_exceptions=False) + runner.invoke(app, ["log", "new issue"], catch_exceptions=False) + + from projectmem.mcp_server import record_fix + + result = record_fix("fixed old issue through MCP", issue_id="1") + + assert result == "Fixed issue #0001: fixed old issue through MCP" + assert ( + tmp_path / ".projectmem" / ".current_issue" + ).read_text(encoding="utf-8") == "0002" + + last = json.loads( + (tmp_path / ".projectmem" / "events.jsonl") + .read_text(encoding="utf-8") + .splitlines()[-1] + ) + assert last["type"] == "fix" + assert last["issue_id"] == "0001" + + def test_attempt_without_open_issue_fails(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) runner = CliRunner() From 390a90c8b932ebbdf08214007a53023ce6a27a91 Mon Sep 17 00:00:00 2001 From: hanley-development Date: Fri, 19 Jun 2026 04:29:53 -0500 Subject: [PATCH 2/2] fix: link story map file-only locations --- src/projectmem/commands/visualize.py | 63 +++++++++-- tests/test_visualize.py | 162 +++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 tests/test_visualize.py diff --git a/src/projectmem/commands/visualize.py b/src/projectmem/commands/visualize.py index 580859d..a8a349b 100644 --- a/src/projectmem/commands/visualize.py +++ b/src/projectmem/commands/visualize.py @@ -23,7 +23,8 @@ def run( mem_dir = require_mem_dir(root) # 1. Build the graph data - graph_data = build_graph_data(events) + project_root = mem_dir.parent + graph_data = build_graph_data(events, root=project_root) # 2. Read PROJECT_MAP.md for the Project Map tab map_path = project_map_path(root) @@ -67,6 +68,47 @@ def run( webbrowser.open(viz_path.as_uri()) +def _location_path_for_graph( + location: str | None, + root: Path | None = None, +) -> str | None: + """Return a project-relative path for Story Map linking, if path-like.""" + if not location: + return None + + raw = location.strip().strip('"').strip("'") + if not raw: + return None + + if ":" in raw: + head, tail = raw.split(":", 1) + if tail.strip().split(":", 1)[0].isdigit(): + raw = head + + normalized = raw.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + normalized = normalized.strip("/") + + if not normalized: + return None + + root_path = root or Path.cwd() + candidate = root_path / normalized + if candidate.is_file(): + return normalized + if candidate.is_dir(): + return normalized.rstrip("/") + "/" + + name = Path(normalized).name + is_file_like = "." in name and " " not in normalized + has_path_separator = "/" in normalized + if is_file_like and has_path_separator: + return normalized + + return None + + def build_project_map_graph(map_text: str) -> dict[str, Any]: nodes = [] links = [] @@ -144,7 +186,10 @@ def build_timeline_data(events: list[Event]) -> list[dict[str, Any]]: return timeline -def build_graph_data(events: list[Event]) -> dict[str, Any]: +def build_graph_data( + events: list[Event], + root: Path | None = None, +) -> dict[str, Any]: nodes = [] links = [] @@ -170,11 +215,11 @@ def add_file(path: str): for event in events: for f in event.files: add_file(f) - if event.location and ":" in event.location: - f = event.location.split(":")[0] - add_file(f) + location_file = _location_path_for_graph(event.location, root=root) + if location_file: + add_file(location_file) if event.outcome == "failed": - failure_counts[f] = failure_counts.get(f, 0) + 1 + failure_counts[location_file] = failure_counts.get(location_file, 0) + 1 # Update failure counts in nodes for node in nodes: @@ -206,9 +251,9 @@ def add_file(path: str): links.append({"source": event_id, "target": f, "type": "mention"}) # Link event to its location file - if event.location and ":" in event.location: - f = event.location.split(":")[0] - links.append({"source": event_id, "target": f, "type": "at"}) + location_file = _location_path_for_graph(event.location, root=root) + if location_file: + links.append({"source": event_id, "target": location_file, "type": "at"}) # 3. Calculate ROI stats raw_events = [e.__dict__ for e in events] diff --git a/tests/test_visualize.py b/tests/test_visualize.py new file mode 100644 index 0000000..ad1a35d --- /dev/null +++ b/tests/test_visualize.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from pathlib import Path + +from projectmem.commands.visualize import _location_path_for_graph, build_graph_data +from projectmem.models import Event + + +def test_location_path_for_graph_accepts_file_path_without_line(tmp_path: Path) -> None: + source = tmp_path / "src" / "agent_portability_kit" / "importers" / "neutral.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + + result = _location_path_for_graph( + "src/agent_portability_kit/importers/neutral.py", + root=tmp_path, + ) + + assert result == "src/agent_portability_kit/importers/neutral.py" + + +def test_location_path_for_graph_accepts_file_path_with_line(tmp_path: Path) -> None: + source = tmp_path / "src" / "projectmem" / "cli.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + + result = _location_path_for_graph("src/projectmem/cli.py:42", root=tmp_path) + + assert result == "src/projectmem/cli.py" + + +def test_location_path_for_graph_normalizes_windows_separators(tmp_path: Path) -> None: + source = tmp_path / "src" / "projectmem" / "cli.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + + result = _location_path_for_graph(r".\src\projectmem\cli.py", root=tmp_path) + + assert result == "src/projectmem/cli.py" + + +def test_location_path_for_graph_accepts_existing_directory(tmp_path: Path) -> None: + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + + result = _location_path_for_graph("tests", root=tmp_path) + + assert result == "tests/" + + +def test_location_path_for_graph_rejects_descriptive_locations(tmp_path: Path) -> None: + assert _location_path_for_graph("projectmem pre-commit hook", root=tmp_path) is None + assert _location_path_for_graph("docs current-state", root=tmp_path) is None + assert _location_path_for_graph("deploy pipeline", root=tmp_path) is None + + +def _node_ids(graph: dict) -> set[str]: + return {node["id"] for node in graph["nodes"]} + + +def _link_tuples(graph: dict) -> set[tuple[str, str, str]]: + return { + (link["source"], link["target"], link["type"]) + for link in graph["links"] + } + + +def test_build_graph_data_links_path_like_location_without_line(tmp_path: Path) -> None: + source = tmp_path / "src" / "agent_portability_kit" / "importers" / "neutral.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + event = Event( + id="evt_24214d5d29b2483e8da2", + type="attempt", + issue_id="0008", + location="src/agent_portability_kit/importers/neutral.py", + outcome="worked", + summary=( + "Tightened neutral import to reject missing required fields, extra " + "closed-shape keys, wrong MCP string types, and forbidden " + "transport-branch keys" + ), + ) + + graph = build_graph_data([event], root=tmp_path) + + assert "evt_24214d5d29b2483e8da2" in _node_ids(graph) + assert "src/agent_portability_kit/importers/neutral.py" in _node_ids(graph) + assert ( + "evt_24214d5d29b2483e8da2", + "src/agent_portability_kit/importers/neutral.py", + "at", + ) in _link_tuples(graph) + + +def test_build_graph_data_keeps_descriptive_location_unlinked(tmp_path: Path) -> None: + event = Event( + id="evt_descriptive", + type="note", + location="projectmem pre-commit hook", + summary="Windows CP1252 terminal output can fail on box drawing characters", + ) + + graph = build_graph_data([event], root=tmp_path) + + assert "evt_descriptive" in _node_ids(graph) + assert "projectmem pre-commit hook" not in _node_ids(graph) + assert graph["links"] == [] + + +def test_build_graph_data_counts_failed_attempts_for_path_like_location( + tmp_path: Path, +) -> None: + source = tmp_path / "src" / "projectmem" / "commands" / "visualize.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + event = Event( + id="evt_failed", + type="attempt", + location="src/projectmem/commands/visualize.py", + outcome="failed", + summary="Tried linking only file:line locations and left file-only events floating", + ) + + graph = build_graph_data([event], root=tmp_path) + + file_node = next( + node + for node in graph["nodes"] + if node["id"] == "src/projectmem/commands/visualize.py" + ) + assert file_node["failures"] == 1 + + +def test_build_graph_data_still_links_explicit_files(tmp_path: Path) -> None: + event = Event( + id="evt_files", + type="fix", + files=["README.md", "src/projectmem/cli.py"], + summary="Backfilled commit touched README and CLI", + ) + + graph = build_graph_data([event], root=tmp_path) + + assert ("evt_files", "README.md", "mention") in _link_tuples(graph) + assert ("evt_files", "src/projectmem/cli.py", "mention") in _link_tuples(graph) + + +def test_build_graph_data_still_links_location_with_line(tmp_path: Path) -> None: + source = tmp_path / "src" / "projectmem" / "cli.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + event = Event( + id="evt_line", + type="issue", + location="src/projectmem/cli.py:210", + summary="Visualize command needs graph payload fix", + ) + + graph = build_graph_data([event], root=tmp_path) + + assert ("evt_line", "src/projectmem/cli.py", "at") in _link_tuples(graph)