Navegador

navegador / navegador / pm.py
Blame History Raw 279 lines
1
"""
2
PM tool integration — ingest project management tickets and cross-link to code.
3
4
Issue: #53
5
6
Supports:
7
- GitHub Issues (fully implemented)
8
- Linear (stub — raises NotImplementedError)
9
- Jira (stub — raises NotImplementedError)
10
11
Tickets are stored as Rule nodes (they represent commitments/requirements)
12
and linked to code symbols by name mention similarity.
13
14
Usage::
15
16
from navegador.pm import TicketIngester
17
18
ing = TicketIngester(store)
19
20
# GitHub
21
stats = ing.ingest_github_issues("owner/repo", token="ghp_...")
22
23
# Linear (stub)
24
stats = ing.ingest_linear(api_key="lin_...", project="MyProject")
25
26
# Jira (stub)
27
stats = ing.ingest_jira(url="https://company.atlassian.net", token="...")
28
"""
29
30
from __future__ import annotations
31
32
import logging
33
import re
34
from typing import Any
35
36
from navegador.graph.schema import EdgeType, NodeLabel
37
from navegador.graph.store import GraphStore
38
39
logger = logging.getLogger(__name__)
40
41
42
# ── Ticket node label ─────────────────────────────────────────────────────────
43
# Tickets are stored under a synthetic "Ticket" label that maps to Rule in the
44
# schema — they represent requirements and commitments from the PM tool.
45
_TICKET_LABEL = NodeLabel.Rule
46
47
48
class TicketIngester:
49
"""
50
Ingests project management tickets into the knowledge graph.
51
52
Each ticket becomes a Rule node with::
53
54
name — "#<number>: <title>" (GitHub) or the ticket ID
55
description — ticket body / description
56
domain — repo name or project name
57
severity — "info" | "warning" | "critical" mapped from priority/label
58
rationale — ticket URL for traceability
59
60
After ingestion, ``_link_to_code`` runs a lightweight name-match pass to
61
create ANNOTATES edges from each ticket to code symbols it mentions.
62
"""
63
64
def __init__(self, store: GraphStore) -> None:
65
self.store = store
66
67
# ── GitHub Issues ─────────────────────────────────────────────────────────
68
69
def ingest_github_issues(
70
self,
71
repo: str,
72
token: str = "",
73
state: str = "open",
74
limit: int = 100,
75
) -> dict[str, Any]:
76
"""
77
Fetch GitHub issues for *repo* and ingest them into the graph.
78
79
Parameters
80
----------
81
repo:
82
Repository in ``"owner/repo"`` format.
83
token:
84
GitHub personal access token (or ``GITHUB_TOKEN`` env var value).
85
If empty, unauthenticated requests are used (60 req/h rate limit).
86
state:
87
``"open"``, ``"closed"``, or ``"all"``.
88
limit:
89
Maximum number of issues to fetch (GitHub paginates at 100/page).
90
91
Returns
92
-------
93
dict with keys: tickets, linked
94
"""
95
import urllib.request
96
97
headers: dict[str, str] = {
98
"Accept": "application/vnd.github+json",
99
"X-GitHub-Api-Version": "2022-11-28",
100
"User-Agent": "navegador/0.4",
101
}
102
if token:
103
headers["Authorization"] = f"Bearer {token}"
104
105
per_page = min(limit, 100)
106
url = f"https://api.github.com/repos/{repo}/issues?state={state}&per_page={per_page}&page=1"
107
108
try:
109
req = urllib.request.Request(url, headers=headers)
110
with urllib.request.urlopen(req, timeout=15) as resp:
111
import json
112
113
issues: list[dict] = json.loads(resp.read().decode())
114
except Exception as exc:
115
raise RuntimeError(f"Failed to fetch GitHub issues for {repo!r}: {exc}") from exc
116
117
# Filter out pull requests (GitHub issues API returns both)
118
issues = [i for i in issues if "pull_request" not in i]
119
120
domain = repo.split("/")[-1] if "/" in repo else repo
121
tickets_created = 0
122
123
for issue in issues[:limit]:
124
number = issue.get("number", 0)
125
title = (issue.get("title") or "").strip()
126
body = (issue.get("body") or "").strip()
127
html_url = issue.get("html_url", "")
128
labels = [lbl.get("name", "") for lbl in issue.get("labels", [])]
129
severity = self._github_severity(labels)
130
131
node_name = f"#{number}: {title}"[:200]
132
self.store.create_node(
133
_TICKET_LABEL,
134
{
135
"name": node_name,
136
"description": body[:2000],
137
"domain": domain,
138
"severity": severity,
139
"rationale": html_url,
140
"examples": "",
141
},
142
)
143
tickets_created += 1
144
145
# Assignees → Person nodes + ASSIGNED_TO edges
146
for assignee in issue.get("assignees", []) or []:
147
login = (assignee.get("login") or "").strip()
148
if login:
149
self.store.create_node(
150
NodeLabel.Person,
151
{"name": login, "email": "", "role": "", "team": ""},
152
)
153
self.store.create_edge(
154
_TICKET_LABEL,
155
{"name": node_name},
156
EdgeType.ASSIGNED_TO,
157
NodeLabel.Person,
158
{"name": login},
159
)
160
161
linked = self._link_to_code(domain)
162
logger.info(
163
"TicketIngester.ingest_github_issues(%s): tickets=%d linked=%d",
164
repo,
165
tickets_created,
166
linked,
167
)
168
return {"tickets": tickets_created, "linked": linked}
169
170
# ── Linear (stub) ─────────────────────────────────────────────────────────
171
172
def ingest_linear(self, api_key: str, project: str = "") -> dict[str, Any]:
173
"""
174
Ingest Linear issues into the knowledge graph.
175
176
.. note::
177
Not yet implemented. Linear GraphQL API support is planned
178
for a future release. Track progress at:
179
https://github.com/weareconflict/navegador/issues/53
180
181
Raises
182
------
183
NotImplementedError
184
"""
185
raise NotImplementedError(
186
"Linear ingestion is not yet implemented. "
187
"Planned for a future release — see GitHub issue #53. "
188
"To contribute, implement ingest_linear() in navegador/pm.py "
189
"using the Linear GraphQL API (https://developers.linear.app/docs)."
190
)
191
192
# ── Jira (stub) ───────────────────────────────────────────────────────────
193
194
def ingest_jira(self, url: str, token: str = "", project: str = "") -> dict[str, Any]:
195
"""
196
Ingest Jira tickets into the knowledge graph.
197
198
.. note::
199
Not yet implemented. Jira REST API support is planned
200
for a future release. Track progress at:
201
https://github.com/weareconflict/navegador/issues/53
202
203
Raises
204
------
205
NotImplementedError
206
"""
207
raise NotImplementedError(
208
"Jira ingestion is not yet implemented. "
209
"Planned for a future release — see GitHub issue #53. "
210
"To contribute, implement ingest_jira() in navegador/pm.py "
211
"using the Jira REST API v3 "
212
"(https://developer.atlassian.com/cloud/jira/platform/rest/v3/)."
213
)
214
215
# ── Helpers ───────────────────────────────────────────────────────────────
216
217
def _link_to_code(self, domain: str = "") -> int:
218
"""
219
Create ANNOTATES edges from ticket Rule nodes to matching code symbols.
220
221
Matches code node names against significant words (≥4 chars) in each
222
ticket's name and description.
223
224
Returns
225
-------
226
int — number of edges created
227
"""
228
ticket_cypher = "MATCH (t:Rule) WHERE t.domain = $domain RETURN t.name, t.description"
229
code_cypher = (
230
"MATCH (c) WHERE c:Function OR c:Class OR c:Method RETURN labels(c)[0], c.name"
231
)
232
233
try:
234
t_result = self.store.query(ticket_cypher, {"domain": domain})
235
c_result = self.store.query(code_cypher)
236
except Exception:
237
logger.warning("TicketIngester._link_to_code: queries failed", exc_info=True)
238
return 0
239
240
tickets = [
241
(str(row[0]), str(row[1] or "")) for row in (t_result.result_set or []) if row[0]
242
]
243
code_nodes = [
244
(str(row[0]), str(row[1])) for row in (c_result.result_set or []) if row[0] and row[1]
245
]
246
247
if not tickets or not code_nodes:
248
return 0
249
250
linked = 0
251
for t_name, t_desc in tickets:
252
combined = f"{t_name} {t_desc}"
253
tokens = {w.lower() for w in re.split(r"[\s\W]+", combined) if len(w) >= 4}
254
if not tokens:
255
continue
256
257
for c_label, c_name in code_nodes:
258
if any(tok in c_name.lower() for tok in tokens):
259
cypher = (
260
"MATCH (t:Rule {name: $tn}), (c:" + c_label + " {name: $cn}) "
261
"MERGE (t)-[r:ANNOTATES]->(c)"
262
)
263
try:
264
self.store.query(cypher, {"tn": t_name, "cn": c_name})
265
linked += 1
266
except Exception:
267
logger.debug("TicketIngester: could not link %s → %s", t_name, c_name)
268
return linked
269
270
@staticmethod
271
def _github_severity(labels: list[str]) -> str:
272
"""Map GitHub label names to navegador severity levels."""
273
label_lower = {lbl.lower() for lbl in labels}
274
if label_lower & {"critical", "blocker", "urgent", "p0"}:
275
return "critical"
276
if label_lower & {"bug", "high", "p1", "important"}:
277
return "warning"
278
return "info"
279

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button