projectmemory-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,234 @@
1
+ """Issue commands: pm issue list/show/add/edit/resolve/reopen/delete."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import questionary
7
+ import typer
8
+
9
+ from projectmemory_cli import output
10
+ from projectmemory_cli.api.client import APIClient
11
+ from projectmemory_cli.api.errors import APIError
12
+ from projectmemory_cli.models.api import Issue
13
+ from projectmemory_cli.project_context import guard_project_session
14
+
15
+ app = typer.Typer(help="Issue commands.", no_args_is_help=True)
16
+
17
+
18
+ def _require_client() -> APIClient:
19
+ from projectmemory_cli import credentials
20
+ token = credentials.get_token()
21
+ if not token:
22
+ output.error("Not logged in. Run: pm login")
23
+ raise SystemExit(2)
24
+ return APIClient.from_env()
25
+
26
+
27
+ def _session_client(client: APIClient, session_id: int) -> APIClient:
28
+ return APIClient(base_url=client.base_url, token=client.token, session_id=session_id)
29
+
30
+
31
+ def _display_issue(issue: Issue) -> None:
32
+ status = "✓ resolved" if issue.is_resolved else "✗ open"
33
+ na = " [dim]→ next action[/dim]" if issue.is_next_action else ""
34
+ output.info(f" {status} [bold]#{issue.id}[/bold] {issue.description}{na}")
35
+ if issue.resolution_notes:
36
+ output.hint(f" Resolution: {issue.resolution_notes[:80]}")
37
+ if issue.github_issue:
38
+ gh = issue.github_issue
39
+ output.hint(f" GitHub #{gh.get('number', '?')}: {gh.get('state', '')}")
40
+
41
+
42
+ @app.command("list")
43
+ def issue_list(
44
+ status: str = typer.Option("open", "--status", "-s", help="open|resolved|all"),
45
+ open_only: bool = typer.Option(False, "--open", help="Show open issues."),
46
+ resolved: bool = typer.Option(False, "--resolved", help="Show resolved issues."),
47
+ all_issues: bool = typer.Option(False, "--all", help="Show all issues."),
48
+ search: Optional[str] = typer.Option(None, "--search"),
49
+ json: bool = typer.Option(False, "--json"),
50
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
51
+ ) -> None:
52
+ """List issues for the current project."""
53
+ output.configure(json_mode=json)
54
+ client = _require_client()
55
+ project, session = guard_project_session(client, project_arg=project_arg)
56
+
57
+ if all_issues:
58
+ status = "all"
59
+ elif resolved:
60
+ status = "resolved"
61
+ elif open_only:
62
+ status = "open"
63
+
64
+ params: dict = {"status": status}
65
+ if search:
66
+ params["search"] = search
67
+
68
+ try:
69
+ data = client.get(f"/api/cli/projects/{project.id}/issues/", params=params)
70
+ except APIError as exc:
71
+ output.error(str(exc))
72
+ raise SystemExit(exc.exit_code)
73
+
74
+ if json:
75
+ output.print_json(data)
76
+ return
77
+
78
+ issues = [Issue.model_validate(i) for i in data.get("issues", [])]
79
+ if not issues:
80
+ output.hint("No issues.")
81
+ return
82
+
83
+ headers = ["#", "Status", "Description", "Next Action"]
84
+ rows = [
85
+ [str(i.id), "resolved" if i.is_resolved else "open", i.description[:60], "✓" if i.is_next_action else ""]
86
+ for i in issues
87
+ ]
88
+ output.table(headers, rows, title=f"{project.name} — Issues")
89
+ counts = data.get("counts", {})
90
+ if counts:
91
+ output.hint(f"open: {counts.get('open', 0)} resolved: {counts.get('resolved', 0)}")
92
+
93
+
94
+ @app.command("show")
95
+ def issue_show(
96
+ issue_id: int = typer.Argument(...),
97
+ json: bool = typer.Option(False, "--json"),
98
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
99
+ ) -> None:
100
+ """Show issue details."""
101
+ output.configure(json_mode=json)
102
+ client = _require_client()
103
+ guard_project_session(client, project_arg=project_arg)
104
+ try:
105
+ data = client.get(f"/api/cli/issues/{issue_id}/")
106
+ except APIError as exc:
107
+ output.error(str(exc))
108
+ raise SystemExit(exc.exit_code)
109
+ if json:
110
+ output.print_json(data)
111
+ return
112
+ issue = Issue.model_validate(data.get("issue", {}))
113
+ output.blank()
114
+ _display_issue(issue)
115
+ output.blank()
116
+
117
+
118
+ @app.command("add")
119
+ def issue_add(
120
+ description: Optional[str] = typer.Argument(None, help="Issue description."),
121
+ next_action: bool = typer.Option(False, "--next", help="Set as Next Action."),
122
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
123
+ ) -> None:
124
+ """Report a new issue."""
125
+ client = _require_client()
126
+ project, session = guard_project_session(client, project_arg=project_arg)
127
+ sc = _session_client(client, session.id)
128
+
129
+ if not description:
130
+ description = questionary.text("Issue description:").ask()
131
+ if not description:
132
+ raise SystemExit(7)
133
+
134
+ payload: dict = {"description": description}
135
+ if next_action:
136
+ payload["set_as_next_action"] = True
137
+
138
+ try:
139
+ data = sc.post(f"/api/cli/projects/{project.id}/issues/", json=payload, project_name=project.name)
140
+ except APIError as exc:
141
+ output.error(str(exc))
142
+ raise SystemExit(exc.exit_code)
143
+ issue = Issue.model_validate(data.get("issue", {}))
144
+ output.success(f"Issue #{issue.id} created.")
145
+ if next_action:
146
+ output.success("Set as Next Action.")
147
+
148
+
149
+ @app.command("edit")
150
+ def issue_edit(
151
+ issue_id: int = typer.Argument(...),
152
+ description: Optional[str] = typer.Argument(None),
153
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
154
+ ) -> None:
155
+ """Edit an issue description."""
156
+ client = _require_client()
157
+ project, session = guard_project_session(client, project_arg=project_arg)
158
+ sc = _session_client(client, session.id)
159
+
160
+ if not description:
161
+ description = questionary.text("New description:").ask()
162
+ if not description:
163
+ raise SystemExit(7)
164
+
165
+ try:
166
+ sc.patch(f"/api/cli/issues/{issue_id}/", json={"description": description}, project_name=project.name)
167
+ except APIError as exc:
168
+ output.error(str(exc))
169
+ raise SystemExit(exc.exit_code)
170
+ output.success(f"Issue #{issue_id} updated.")
171
+
172
+
173
+ @app.command("resolve")
174
+ def issue_resolve(
175
+ issue_id: int = typer.Argument(...),
176
+ notes: Optional[str] = typer.Option(None, "--resolution", "--notes", "-n", help="Resolution notes."),
177
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
178
+ ) -> None:
179
+ """Mark an issue as resolved."""
180
+ client = _require_client()
181
+ project, session = guard_project_session(client, project_arg=project_arg)
182
+ sc = _session_client(client, session.id)
183
+
184
+ if notes is None:
185
+ notes = questionary.text("Resolution notes (optional):").ask() or ""
186
+
187
+ try:
188
+ sc.post(f"/api/cli/issues/{issue_id}/resolve/", json={"resolution_notes": notes}, project_name=project.name)
189
+ except APIError as exc:
190
+ output.error(str(exc))
191
+ raise SystemExit(exc.exit_code)
192
+ output.success(f"Issue #{issue_id} resolved.")
193
+
194
+
195
+ @app.command("reopen")
196
+ def issue_reopen(
197
+ issue_id: int = typer.Argument(...),
198
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
199
+ ) -> None:
200
+ """Reopen a resolved issue."""
201
+ client = _require_client()
202
+ project, session = guard_project_session(client, project_arg=project_arg)
203
+ sc = _session_client(client, session.id)
204
+ try:
205
+ sc.post(f"/api/cli/issues/{issue_id}/reopen/", project_name=project.name)
206
+ except APIError as exc:
207
+ output.error(str(exc))
208
+ raise SystemExit(exc.exit_code)
209
+ output.success(f"Issue #{issue_id} reopened.")
210
+
211
+
212
+ @app.command("delete")
213
+ def issue_delete(
214
+ issue_id: int = typer.Argument(...),
215
+ yes: bool = typer.Option(False, "--yes", "-y"),
216
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
217
+ ) -> None:
218
+ """Delete an issue."""
219
+ client = _require_client()
220
+ project, session = guard_project_session(client, project_arg=project_arg)
221
+ sc = _session_client(client, session.id)
222
+
223
+ if not yes:
224
+ confirmed = typer.confirm(f"Delete issue #{issue_id}?", default=False)
225
+ if not confirmed:
226
+ output.info("Cancelled.")
227
+ raise SystemExit(7)
228
+
229
+ try:
230
+ sc.delete(f"/api/cli/issues/{issue_id}/", project_name=project.name)
231
+ except APIError as exc:
232
+ output.error(str(exc))
233
+ raise SystemExit(exc.exit_code)
234
+ output.success(f"Issue #{issue_id} deleted.")
@@ -0,0 +1,235 @@
1
+ """Memory commands: pm memory show, pm memory status set, pm memory context set, pm memory update."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import questionary
11
+ import typer
12
+
13
+ from projectmemory_cli import config, output
14
+ from projectmemory_cli.api.client import APIClient
15
+ from projectmemory_cli.api.errors import APIError
16
+ from projectmemory_cli.models.api import Memory
17
+ from projectmemory_cli.project_context import guard_project_session
18
+
19
+ app = typer.Typer(help="Project Memory commands.", no_args_is_help=True)
20
+
21
+
22
+ def _require_client() -> APIClient:
23
+ from projectmemory_cli import credentials
24
+ token = credentials.get_token()
25
+ if not token:
26
+ output.error("Not logged in. Run: pm login")
27
+ raise SystemExit(2)
28
+ return APIClient.from_env()
29
+
30
+
31
+ def _open_in_editor(initial: str = "") -> str:
32
+ editor = config.get_editor()
33
+ with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
34
+ f.write(initial)
35
+ fname = f.name
36
+ try:
37
+ subprocess.call([editor, fname])
38
+ return Path(fname).read_text().strip()
39
+ finally:
40
+ Path(fname).unlink(missing_ok=True)
41
+
42
+
43
+ def _display_memory(memory: Memory, project_name: str = "") -> None:
44
+ if project_name:
45
+ output.project_banner(project_name)
46
+ output.blank()
47
+
48
+ output.header("Next Action")
49
+ na = memory.next_action
50
+ if na.title:
51
+ output.info(f" {na.title}")
52
+ if na.linked_source:
53
+ src = na.linked_source
54
+ output.hint(f" Linked {src.type} #{src.id}")
55
+ else:
56
+ output.hint(" (none)")
57
+
58
+ output.blank()
59
+ output.header("Current Status")
60
+ if memory.current_status.title:
61
+ output.info(f" {memory.current_status.title}")
62
+ else:
63
+ output.hint(" (none)")
64
+
65
+ output.blank()
66
+ output.header("Important Context")
67
+ if memory.important_context.title:
68
+ output.info(f" {memory.important_context.title}")
69
+ else:
70
+ output.hint(" (none)")
71
+
72
+ output.blank()
73
+
74
+
75
+ @app.command("show")
76
+ def memory_show(
77
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
78
+ json: bool = typer.Option(False, "--json"),
79
+ ) -> None:
80
+ """Display the current Project Memory."""
81
+ client = _require_client()
82
+ try:
83
+ project, session = guard_project_session(client, project_arg=project_arg)
84
+ except SystemExit:
85
+ raise
86
+
87
+ try:
88
+ data = client.get(f"/api/cli/projects/{project.id}/memory/")
89
+ except APIError as exc:
90
+ output.error(str(exc))
91
+ raise SystemExit(exc.exit_code)
92
+
93
+ if json:
94
+ output.print_json(data)
95
+ return
96
+
97
+ memory = Memory.model_validate(data.get("memory", {}))
98
+ _display_memory(memory, project_name=project.name)
99
+ if project.last_memory_updated_at:
100
+ output.kv("Last memory updated", output.format_ago(project.last_memory_updated_at))
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # pm memory status set
105
+ # ---------------------------------------------------------------------------
106
+
107
+ status_app = typer.Typer(help="Current Status commands.", no_args_is_help=False)
108
+ app.add_typer(status_app, name="status")
109
+
110
+
111
+ @status_app.command("set")
112
+ def memory_status_set(
113
+ text: Optional[str] = typer.Argument(None, help="Status text."),
114
+ editor: bool = typer.Option(False, "--editor", "-e", help="Open in editor."),
115
+ stdin: bool = typer.Option(False, "--stdin", help="Read from stdin."),
116
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
117
+ ) -> None:
118
+ """Set the Current Status."""
119
+ client = _require_client()
120
+ project, session = guard_project_session(client, project_arg=project_arg)
121
+ session_client = APIClient(base_url=client.base_url, token=client.token, session_id=session.id)
122
+
123
+ if stdin:
124
+ text = sys.stdin.read().strip()
125
+ elif editor:
126
+ text = _open_in_editor(text or "")
127
+ elif not text:
128
+ text = questionary.text("Current Status:").ask() or ""
129
+
130
+ if not text:
131
+ output.info("No changes made.")
132
+ raise SystemExit(0)
133
+
134
+ try:
135
+ session_client.patch(
136
+ f"/api/cli/projects/{project.id}/memory/",
137
+ json={"current_status": {"title": text}},
138
+ project_name=project.name,
139
+ )
140
+ except APIError as exc:
141
+ output.error(str(exc))
142
+ raise SystemExit(exc.exit_code)
143
+ output.success(f"Current Status updated.")
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # pm memory context set
148
+ # ---------------------------------------------------------------------------
149
+
150
+ context_app = typer.Typer(help="Important Context commands.", no_args_is_help=False)
151
+ app.add_typer(context_app, name="context")
152
+
153
+
154
+ @context_app.command("set")
155
+ def memory_context_set(
156
+ text: Optional[str] = typer.Argument(None, help="Context text."),
157
+ editor: bool = typer.Option(False, "--editor", "-e", help="Open in editor."),
158
+ stdin: bool = typer.Option(False, "--stdin", help="Read from stdin."),
159
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
160
+ ) -> None:
161
+ """Set the Important Context."""
162
+ client = _require_client()
163
+ project, session = guard_project_session(client, project_arg=project_arg)
164
+ session_client = APIClient(base_url=client.base_url, token=client.token, session_id=session.id)
165
+
166
+ if stdin:
167
+ text = sys.stdin.read().strip()
168
+ elif editor:
169
+ text = _open_in_editor(text or "")
170
+ elif not text:
171
+ text = questionary.text("Important Context:").ask() or ""
172
+
173
+ if not text:
174
+ output.info("No changes made.")
175
+ raise SystemExit(0)
176
+
177
+ try:
178
+ session_client.patch(
179
+ f"/api/cli/projects/{project.id}/memory/",
180
+ json={"important_context": {"title": text}},
181
+ project_name=project.name,
182
+ )
183
+ except APIError as exc:
184
+ output.error(str(exc))
185
+ raise SystemExit(exc.exit_code)
186
+ output.success("Important Context updated.")
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # pm memory update (interactive menu)
191
+ # ---------------------------------------------------------------------------
192
+
193
+ @app.command("update")
194
+ def memory_update(
195
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
196
+ ) -> None:
197
+ """Interactively update Project Memory fields."""
198
+ client = _require_client()
199
+ project, session = guard_project_session(client, project_arg=project_arg)
200
+ session_client = APIClient(base_url=client.base_url, token=client.token, session_id=session.id)
201
+
202
+ choice = questionary.select(
203
+ "Update:",
204
+ choices=[
205
+ questionary.Choice("Next Action", value="next"),
206
+ questionary.Choice("Current Status", value="status"),
207
+ questionary.Choice("Important Context", value="context"),
208
+ questionary.Choice("Open Issues", value="issues"),
209
+ ],
210
+ ).ask()
211
+
212
+ if not choice:
213
+ raise SystemExit(7)
214
+
215
+ if choice == "next":
216
+ from projectmemory_cli.commands.next_action import next_set
217
+ next_set(text=None, project_arg=project_arg)
218
+ elif choice == "status":
219
+ memory_status_set(text=None, editor=False, stdin=False, project_arg=project_arg)
220
+ elif choice == "context":
221
+ memory_context_set(text=None, editor=False, stdin=False, project_arg=project_arg)
222
+ elif choice == "issues":
223
+ from projectmemory_cli.commands.issues import issue_add, issue_list
224
+ action = questionary.select(
225
+ "Open Issues:",
226
+ choices=[
227
+ questionary.Choice("List open issues", value="list"),
228
+ questionary.Choice("Add issue", value="add"),
229
+ questionary.Choice("Cancel", value="cancel"),
230
+ ],
231
+ ).ask()
232
+ if action == "list":
233
+ issue_list(status="open", open_only=True, resolved=False, all_issues=False, search=None, json=False, project_arg=project_arg)
234
+ elif action == "add":
235
+ issue_add(description=None, next_action=False, project_arg=project_arg)