|
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
|
|