Skip to content

Commit 9ca0270

Browse files
committed
feat(cli): add 'openkb feedback' to file a prefilled GitHub issue
Submitting feedback from a CLI tool with no backend is awkward — auto-creating issues requires a maintainer-owned token (security nightmare to ship in source), running an OpenKB-owned API server is overkill for an OSS CLI, and asking users to authenticate with their own gh CLI excludes anyone who hasn't installed it. Workaround: build a GitHub issue URL with title / body / labels prefilled in query params, and open the user's browser. The user goes through GitHub's normal flow with their own account — no backend, no secrets, no auth dance. Usage: openkb feedback # interactive (stdin) openkb feedback "openkb add hangs" # one-liner openkb feedback --type bug "..." # tags 'bug' label openkb feedback --print-url "..." # SSH / no-browser env openkb feedback --no-diagnostics "..." # skip env info The auto-collected diagnostics are deliberately minimal — openkb version, Python version, OS, whether a KB is initialised in cwd. No paths, no env vars, no API keys. Users can disable with --no-diagnostics if even that's too much. Implementation is ~90 lines in cli.py plus 17 regression tests covering URL shape, type→label mapping, title truncation, the --no-diagnostics body shape, and the webbrowser-not-called contract for --print-url.
1 parent 62153db commit 9ca0270

3 files changed

Lines changed: 354 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ A single source might touch 10-15 wiki pages. Knowledge accumulates: each docume
156156
| `openkb lint` | Run structural + knowledge health checks |
157157
| `openkb list` | List indexed documents and concepts |
158158
| `openkb status` | Show knowledge base stats |
159+
| <code>openkb&nbsp;feedback&nbsp;["msg"]</code> | File feedback by opening a prefilled GitHub issue (use `--type bug/feature/question`, `--print-url` for SSH / sandbox) |
159160

160161
<!-- | `openkb lint --fix` | Auto-fix what it can | -->
161162

openkb/cli.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,3 +1133,149 @@ def status(ctx):
11331133
click.echo("No knowledge base found. Run `openkb init` first.")
11341134
return
11351135
print_status(kb_dir)
1136+
1137+
1138+
# ---------------------------------------------------------------------------
1139+
# feedback
1140+
# ---------------------------------------------------------------------------
1141+
1142+
_FEEDBACK_REPO = "VectifyAI/OpenKB"
1143+
_FEEDBACK_TYPES = ("bug", "feature", "question", "other")
1144+
_FEEDBACK_LABEL_MAP = {
1145+
"bug": "bug",
1146+
"feature": "enhancement",
1147+
"question": "question",
1148+
"other": "",
1149+
}
1150+
1151+
1152+
def _openkb_version() -> str:
1153+
"""Return the installed openkb package version, or 'unknown' if it
1154+
can't be resolved (e.g. running from an editable checkout that isn't
1155+
on PYTHONPATH as a distribution).
1156+
"""
1157+
try:
1158+
from importlib.metadata import version
1159+
return version("openkb")
1160+
except Exception:
1161+
return "unknown"
1162+
1163+
1164+
def _collect_feedback_diagnostics(ctx) -> dict[str, str]:
1165+
"""Auto-collect non-sensitive environment info to attach to a feedback
1166+
issue. Kept deliberately small — no paths, no API keys, no usernames.
1167+
"""
1168+
import platform
1169+
1170+
kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override") if ctx.obj else None)
1171+
return {
1172+
"openkb": _openkb_version(),
1173+
"python": platform.python_version(),
1174+
"platform": f"{platform.system()} {platform.release()}",
1175+
"kb_initialised": "yes" if kb_dir else "no",
1176+
}
1177+
1178+
1179+
def _build_feedback_url(
1180+
message: str, feedback_type: str, diagnostics: dict[str, str],
1181+
) -> str:
1182+
"""Build a GitHub issue URL with title / body / labels prefilled."""
1183+
from urllib.parse import urlencode
1184+
1185+
first_line = message.splitlines()[0] if message else ""
1186+
truncated = first_line[:60] + ("…" if len(first_line) > 60 else "")
1187+
title_prefix = f"[{feedback_type}] " if feedback_type != "other" else ""
1188+
title = f"{title_prefix}{truncated}" if truncated else f"{title_prefix}Feedback from CLI"
1189+
1190+
if diagnostics:
1191+
diag_block = "\n".join(f"- **{k}**: {v}" for k, v in diagnostics.items())
1192+
body = (
1193+
f"{message}\n\n"
1194+
"---\n\n"
1195+
"<details>\n"
1196+
"<summary>Diagnostics (auto-collected by <code>openkb feedback</code>)</summary>\n\n"
1197+
f"{diag_block}\n"
1198+
"</details>\n"
1199+
)
1200+
else:
1201+
body = message
1202+
1203+
params = {"title": title, "body": body}
1204+
label = _FEEDBACK_LABEL_MAP.get(feedback_type, "")
1205+
if label:
1206+
params["labels"] = label
1207+
1208+
return f"https://github.com/{_FEEDBACK_REPO}/issues/new?{urlencode(params)}"
1209+
1210+
1211+
@cli.command()
1212+
@click.argument("message", required=False)
1213+
@click.option(
1214+
"--type", "feedback_type",
1215+
type=click.Choice(_FEEDBACK_TYPES),
1216+
default=None,
1217+
help="Feedback type — sets the GitHub issue label.",
1218+
)
1219+
@click.option(
1220+
"--print-url", is_flag=True, default=False,
1221+
help="Print the prefilled GitHub issue URL instead of opening a browser.",
1222+
)
1223+
@click.option(
1224+
"--no-diagnostics", is_flag=True, default=False,
1225+
help="Don't attach the diagnostics block (openkb version, Python, OS, KB present).",
1226+
)
1227+
@click.pass_context
1228+
def feedback(ctx, message, feedback_type, print_url, no_diagnostics):
1229+
"""Submit feedback by opening a prefilled GitHub issue.
1230+
1231+
Examples:
1232+
1233+
\b
1234+
openkb feedback # interactive
1235+
openkb feedback "openkb add hangs on .docx" # one-line bug report
1236+
openkb feedback --type feature "..." # tags the issue 'enhancement'
1237+
openkb feedback --print-url "..." # SSH / sandbox-friendly
1238+
1239+
The command does not send anything to OpenKB maintainers directly —
1240+
it opens GitHub in your browser with title, body, and label prefilled.
1241+
You log in with your own GitHub account and submit the issue.
1242+
"""
1243+
if not message:
1244+
click.echo(
1245+
"What's your feedback? End with an empty line + Ctrl-D "
1246+
"(Unix) or Ctrl-Z+Enter (Windows). Ctrl-C cancels."
1247+
)
1248+
message = sys.stdin.read().strip()
1249+
1250+
if not message:
1251+
click.echo("No feedback provided. Aborted.")
1252+
ctx.exit(1)
1253+
return
1254+
1255+
if feedback_type is None:
1256+
feedback_type = click.prompt(
1257+
"Type",
1258+
default="other",
1259+
type=click.Choice(_FEEDBACK_TYPES),
1260+
show_default=True,
1261+
show_choices=True,
1262+
)
1263+
1264+
diagnostics = {} if no_diagnostics else _collect_feedback_diagnostics(ctx)
1265+
url = _build_feedback_url(message, feedback_type, diagnostics)
1266+
1267+
if print_url:
1268+
click.echo(url)
1269+
return
1270+
1271+
click.echo("Opening GitHub in your browser to file the issue.")
1272+
click.echo("If nothing happens, copy this URL into a browser yourself:")
1273+
click.echo(f" {url}")
1274+
1275+
import webbrowser
1276+
try:
1277+
webbrowser.open(url)
1278+
except Exception as exc:
1279+
# webbrowser.open rarely raises but be defensive — the printed URL
1280+
# above is the fallback path.
1281+
click.echo(f" (browser auto-open failed: {exc})", err=True)

tests/test_feedback.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
"""Tests for `openkb feedback` — the prefilled-GitHub-issue feedback flow."""
2+
from __future__ import annotations
3+
4+
from unittest.mock import patch
5+
from urllib.parse import parse_qs, urlparse
6+
7+
import pytest
8+
from click.testing import CliRunner
9+
10+
from openkb.cli import (
11+
_build_feedback_url,
12+
_collect_feedback_diagnostics,
13+
_FEEDBACK_REPO,
14+
cli,
15+
)
16+
17+
18+
# ---------------------------------------------------------------------------
19+
# _build_feedback_url
20+
# ---------------------------------------------------------------------------
21+
22+
23+
def _parse(url: str) -> dict:
24+
"""Parse a prefilled-issue URL into its query-param dict (single values)."""
25+
parts = urlparse(url)
26+
qs = parse_qs(parts.query)
27+
# parse_qs yields lists; flatten the singletons we care about.
28+
return {k: v[0] for k, v in qs.items()}
29+
30+
31+
def test_build_url_points_at_correct_repo_issue_new():
32+
url = _build_feedback_url("hello", "bug", {})
33+
parts = urlparse(url)
34+
assert parts.scheme == "https"
35+
assert parts.netloc == "github.com"
36+
assert parts.path == f"/{_FEEDBACK_REPO}/issues/new"
37+
38+
39+
def test_build_url_title_includes_type_prefix():
40+
url = _build_feedback_url("attach fails on docx", "bug", {})
41+
params = _parse(url)
42+
assert params["title"] == "[bug] attach fails on docx"
43+
44+
45+
def test_build_url_title_omits_prefix_for_other_type():
46+
"""'other' is the catch-all; don't pollute the title with [other]."""
47+
url = _build_feedback_url("just a comment", "other", {})
48+
params = _parse(url)
49+
assert params["title"] == "just a comment"
50+
51+
52+
def test_build_url_title_truncated_at_60_chars():
53+
long_msg = "a" * 200
54+
url = _build_feedback_url(long_msg, "bug", {})
55+
params = _parse(url)
56+
# 60 chars + ellipsis + prefix
57+
assert params["title"] == "[bug] " + ("a" * 60) + "…"
58+
59+
60+
def test_build_url_title_uses_first_line_only():
61+
"""A multi-line message should only use line 1 for the title."""
62+
url = _build_feedback_url("short title\n\ndetailed body here", "feature", {})
63+
params = _parse(url)
64+
assert params["title"] == "[feature] short title"
65+
66+
67+
def test_build_url_label_set_for_bug():
68+
url = _build_feedback_url("x", "bug", {})
69+
params = _parse(url)
70+
assert params["labels"] == "bug"
71+
72+
73+
def test_build_url_label_mapped_for_feature():
74+
"""Feature → 'enhancement' (GitHub's conventional label)."""
75+
url = _build_feedback_url("x", "feature", {})
76+
params = _parse(url)
77+
assert params["labels"] == "enhancement"
78+
79+
80+
def test_build_url_no_label_for_other():
81+
url = _build_feedback_url("x", "other", {})
82+
params = _parse(url)
83+
assert "labels" not in params
84+
85+
86+
def test_build_url_diagnostics_attached_when_provided():
87+
url = _build_feedback_url(
88+
"x", "bug",
89+
{"openkb": "1.2.3", "python": "3.12.0", "platform": "Linux 6.0"},
90+
)
91+
params = _parse(url)
92+
assert "Diagnostics" in params["body"]
93+
assert "**openkb**: 1.2.3" in params["body"]
94+
assert "**python**: 3.12.0" in params["body"]
95+
assert "**platform**: Linux 6.0" in params["body"]
96+
97+
98+
def test_build_url_no_diagnostics_block_when_empty():
99+
"""Empty dict means user passed --no-diagnostics; body is just the message."""
100+
url = _build_feedback_url("just the message", "bug", {})
101+
params = _parse(url)
102+
assert params["body"] == "just the message"
103+
assert "Diagnostics" not in params["body"]
104+
assert "<details>" not in params["body"]
105+
106+
107+
# ---------------------------------------------------------------------------
108+
# _collect_feedback_diagnostics
109+
# ---------------------------------------------------------------------------
110+
111+
112+
def test_collect_diagnostics_returns_minimal_non_sensitive_set(tmp_path):
113+
"""Diagnostics should be the small known set — no paths, no env vars."""
114+
115+
class _Ctx:
116+
obj = None
117+
118+
with patch("openkb.cli._find_kb_dir", return_value=None):
119+
info = _collect_feedback_diagnostics(_Ctx())
120+
121+
assert set(info.keys()) == {"openkb", "python", "platform", "kb_initialised"}
122+
assert info["kb_initialised"] == "no"
123+
# Defensive: no path-like values that would leak the user's home dir.
124+
for v in info.values():
125+
assert "/Users/" not in v
126+
assert "/home/" not in v
127+
128+
129+
def test_collect_diagnostics_flags_kb_present(tmp_path):
130+
class _Ctx:
131+
obj = None
132+
133+
with patch("openkb.cli._find_kb_dir", return_value=tmp_path):
134+
info = _collect_feedback_diagnostics(_Ctx())
135+
136+
assert info["kb_initialised"] == "yes"
137+
138+
139+
# ---------------------------------------------------------------------------
140+
# CLI: openkb feedback
141+
# ---------------------------------------------------------------------------
142+
143+
144+
def test_feedback_one_liner_print_url_does_not_open_browser():
145+
"""--print-url is the SSH / sandbox path: it must NOT call webbrowser.open."""
146+
runner = CliRunner()
147+
with patch("webbrowser.open") as mock_open:
148+
result = runner.invoke(
149+
cli, ["feedback", "--type", "bug", "--print-url", "openkb crashes on .ppt"],
150+
)
151+
152+
assert result.exit_code == 0, result.output
153+
assert "https://github.com/VectifyAI/OpenKB/issues/new" in result.output
154+
mock_open.assert_not_called()
155+
156+
157+
def test_feedback_one_liner_default_opens_browser_with_url():
158+
runner = CliRunner()
159+
with patch("webbrowser.open") as mock_open:
160+
result = runner.invoke(cli, ["feedback", "--type", "bug", "test message"])
161+
162+
assert result.exit_code == 0, result.output
163+
mock_open.assert_called_once()
164+
called_url = mock_open.call_args[0][0]
165+
assert called_url.startswith("https://github.com/VectifyAI/OpenKB/issues/new?")
166+
# The fallback URL should also be printed so the user has a copy if the
167+
# auto-open fails.
168+
assert called_url in result.output
169+
170+
171+
def test_feedback_empty_message_aborts_with_exit_1():
172+
"""Interactive mode: if user submits nothing, abort cleanly (no issue URL)."""
173+
runner = CliRunner()
174+
# input="" simulates Ctrl-D on an empty stdin.
175+
result = runner.invoke(cli, ["feedback", "--type", "bug"], input="")
176+
assert result.exit_code == 1
177+
assert "No feedback provided" in result.output
178+
179+
180+
def test_feedback_no_diagnostics_strips_block_from_body():
181+
runner = CliRunner()
182+
with patch("webbrowser.open") as mock_open:
183+
result = runner.invoke(
184+
cli,
185+
["feedback", "--type", "bug", "--print-url", "--no-diagnostics", "just this"],
186+
)
187+
188+
assert result.exit_code == 0
189+
url = result.output.strip().splitlines()[-1]
190+
assert "Diagnostics" not in url
191+
assert "details" not in url # no <details> block
192+
mock_open.assert_not_called()
193+
194+
195+
def test_feedback_prompts_for_type_when_not_given_via_flag():
196+
"""If --type isn't on the command line, prompt the user."""
197+
runner = CliRunner()
198+
with patch("webbrowser.open") as mock_open:
199+
result = runner.invoke(
200+
cli, ["feedback", "--print-url", "missing-type prompt test"],
201+
input="feature\n",
202+
)
203+
204+
assert result.exit_code == 0
205+
# The URL should be tagged with the chosen type (mapped to 'enhancement').
206+
url_line = [ln for ln in result.output.splitlines() if "issues/new" in ln][-1]
207+
assert "labels=enhancement" in url_line

0 commit comments

Comments
 (0)