diff --git a/docs/tutorials/configs/data_sources.json b/docs/tutorials/configs/data_sources.json new file mode 100644 index 00000000..6e3249d0 --- /dev/null +++ b/docs/tutorials/configs/data_sources.json @@ -0,0 +1,24 @@ +{ + "sources": [ + { + "id": "my-jira", + "type": "jira_cloud", + "enabled": true, + "display_name": "My Jira Project", + "connection": { + "site_url": "https://your-company.atlassian.net", + "email": "your-email@company.com", + "api_token": "YOUR_JIRA_API_TOKEN" + }, + "scope": { + "project_keys": ["GML", "TSE"], + "created_after": null, + "updated_after": null, + "status_categories": ["new", "indeterminate", "done"], + "jql_extra": "", + "include_comments": true, + "story_points_field": null + } + } + ] +} diff --git a/graphrag-ui/src/main.tsx b/graphrag-ui/src/main.tsx index 53239a5c..05191b66 100755 --- a/graphrag-ui/src/main.tsx +++ b/graphrag-ui/src/main.tsx @@ -12,6 +12,7 @@ import LLMConfig from "./pages/setup/LLMConfig.tsx"; import GraphDBConfig from "./pages/setup/GraphDBConfig.tsx"; import GraphRAGConfig from "./pages/setup/GraphRAGConfig.tsx"; import McpServersConfig from "./pages/setup/McpServersConfig.tsx"; +import DataSourcesConfig from "./pages/setup/DataSourcesConfig.tsx"; import CustomizePrompts from "./pages/setup/CustomizePrompts.tsx"; import { ThemeProvider } from "./components/ThemeProvider.tsx"; import { ModeToggle } from "@/components/ModeToggle.tsx"; @@ -79,6 +80,10 @@ const router = createBrowserRouter([ path: "kg-admin/ingest", element: , }, + { + path: "kg-admin/data-sources", + element: , + }, { path: "server-config", element: , diff --git a/graphrag-ui/src/pages/setup/ConnectorPromptGuide.tsx b/graphrag-ui/src/pages/setup/ConnectorPromptGuide.tsx new file mode 100644 index 00000000..d1e4a236 --- /dev/null +++ b/graphrag-ui/src/pages/setup/ConnectorPromptGuide.tsx @@ -0,0 +1,234 @@ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { BookOpen, Check, ChevronDown, Copy } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface ExamplePrompt { + id: string; + name: string; + where: string; + text: string; +} + +interface ConnectorGuide { + title: string; + summary: string; + points: string[]; + examples: ExamplePrompt[]; +} + +const JIRA_PLANNER_EXAMPLE = `## Jira issues +Each Jira issue is stored in two places. They answer different parts of the same question, so a question about one issue needs both. + +- Structural (graphrag__structural_retrieve): the current record. JiraIssue attributes are issue_key, summary, issue_type, status, status_category, priority, resolution, labels, components, fix_versions, created, updated, due, url, story_points. Assignee and reporter are JiraUser vertices reached by JIRA_ASSIGNED_TO and JIRA_REPORTED_BY. Project, parent, and issue links are edges. Comment text is JiraComment.body. Status, assignee, priority, and resolution changes are JiraChange events (field, from_value, to_value, created) reached by JIRA_HAS_CHANGE. The person who made a change is JIRA_CHANGE_BY. +- Unstructured (graphrag__hybrid_search): short document chunks for that issue. Each chunk starts with the issue key. They hold the description, comments, and one chunk per change: who changed status, assignee, priority, or resolution, when, and from which value to which. + +Decide which issue the user means, using this message and ## Conversation: +- A project key plus a number (any casing, such as gml-2191) identifies one issue. +- Only a number (ticket 2191, summary of 2191) does not. Retrieve every issue_key that ends with that number so the answer can ask which project they mean. Do not plan a full write-up of one issue until a single key is known. +- A follow-up that does not repeat the key refers to the issue already discussed. Carry that key into every retrieval step. + +Once one issue is identified, plan both retrievals. Put the full issue key and the aspect the user asked about into both tool questions. Set hybrid top_k to at least 8. +- A broad question (what is it, summarize, tell me about) needs the current fields plus the description, recent comments, and notable changes. +- History, timeline, progress, or who did what needs the JiraChange events and the comment text, together with the current status, assignee, and resolution. +- Release, fix version, due date, or when it will be fixed needs fix_versions, due, status, and resolution, plus any release or fix mentioned in the description or comments. + +For the structural step, say which match to use: an exact case-insensitive match on issue_key when the full key is known, or issue_key ending with the number when only a number is known. Name the attributes and neighbour names to return. Returning the vertex alone is not enough. For a broad issue summary, request current fields, assignee, reporter, and project only. Request parent or linked issues only when the user asks about them, using a separate structural step for each relationship. + +A count, list, or filter across many issues stays a structural query. Add hybrid search only when that question also needs description, comment, or change-log text. + +The final answer step depends on every retrieval step.`; + +const JIRA_QUERY_GUIDANCE_EXAMPLE = `## Jira issue queries +- For a broad single-issue lookup, retrieve the JiraIssue fields, assignee, reporter, and project. Do not add parent, issue-link, or comment relationships unless the question asks for them. +- Handle JIRA_HAS_PARENT and JIRA_LINKS_TO in a separate, focused graph query. If the relationship is requested, prefer MATCH over OPTIONAL MATCH. +- Return the related issue_key and requested edge attributes such as link_type. Do not return the parent or link edge object itself.`; + +const JIRA_AGENT_EXAMPLE = `## Jira issues +Each issue has a current record in the graph and short document chunks. For one issue, call both graphrag__structural_retrieve and graphrag__hybrid_search before you answer. Use the conversation to resolve which issue is meant when this message does not repeat the key. + +- Structural returns current fields: issue_key, summary, type, status, priority, resolution, labels, components, fix_versions, created, updated, due, url, story_points, plus assignee and reporter names. Comment text is JiraComment.body. Status, assignee, priority, and resolution changes are JiraChange events. Ask for an exact case-insensitive issue_key match when the full key is known. When the user gave only a number, ask for every issue_key that ends with that number and return the keys and summaries. Always ask for the attributes by name. A bare vertex id has no fields. +- Hybrid search (top_k at least 8) returns chunks that start with the issue key: the description, comments, and the change log. Include the issue key and the aspect the user asked about in the search text. + +If several project keys share that number, ask which one they mean and list the full keys. Do not mix those issues into one answer. + +Answer only what was asked, using both sources. A broad question gets the current fields plus the description and recent activity. A history question gets the change log in time order and the comments, with the current status beside it. A release or "when will this be fixed" question uses fix_versions, due, status, and resolution, plus any release mentioned in the text. If that fact is in neither source, say it is not available.`; + +const JIRA_RESPONSE_EXAMPLE = `## Jira issues +Treat structured rows and document passages as one issue, not as competing answers. + +- Structured rows are the current record: issue key, summary, status, priority, resolution, fix version, due date, url, assignee, reporter, labels, components, and links. Comment text is the comment body. Each status, assignee, priority, or resolution change is its own event: field, previous value, new value, who, and when. +- Document passages are the description, comments, and the change log. Use a passage only when it names the same issue key. + +If the user named only a number and the context has more than one project key for it, ask which issue they mean and list the full keys. Do not include details from those issues. If only one key matches, confirm that key and then answer. + +If the user message is only a full issue key, they are selecting that issue. Answer with its current fields and a short account of the description and recent activity. + +Otherwise answer the question they asked. +- A broad question (what is it, summarize, tell me about) gets the title and url, the current fields, then the description and the recent comments or changes. +- History, timeline, progress, or who did what gets the change log in time order, then the comments. State the current status from the structured row. +- Release, fix version, or when it will be fixed uses fix version, due date, status, and resolution, plus any release or fix stated in the description or comments. If none of those say when or in which release, say that is not in the available data. An empty fix version is not a release. A due date of 1970-01-01 means no due date was set. + +Prefer a structured value when the same field also appears in a passage. Do not invent a person, date, status, or release.`; + +const GUIDES: Record = { + jira_cloud: { + title: "Jira Cloud", + summary: + "Keep the prompt that is already on this graph. Add a short Jira section at the end so one ticket is answered from the current record and from the description, comments, and change history.", + points: [ + "A project key plus a number, in any casing, is one ticket. Copy that key into both the structural question and the hybrid question.", + "A number alone is not one ticket. Retrieve every issue key that ends with that number. If more than one project matches, ask which key they mean and list the full keys.", + "A follow-up that does not repeat the key is about the ticket already in the conversation. Carry that key into the next retrieval.", + "Structural search returns the current fields, the people, and the JiraChange events. Hybrid search returns the description, comment text, and one chunk per change. Use both for a question about one ticket.", + "Do not put a real ticket key in the prompt. The model will treat that key as the ticket to retrieve. If fix version or due date is empty, say it is not available.", + ], + examples: [ + { + id: "query_guidance", + name: "Query Guidance", + where: "Customize Prompts → Query Guidance, on this graph", + text: JIRA_QUERY_GUIDANCE_EXAMPLE, + }, + { + id: "agentic_planner", + name: "Agentic Planner", + where: "Customize Prompts → Agentic Planner, on this graph", + text: JIRA_PLANNER_EXAMPLE, + }, + { + id: "agentic_agent", + name: "React Agent", + where: "Customize Prompts → React Agent, on this graph", + text: JIRA_AGENT_EXAMPLE, + }, + { + id: "chatbot_response", + name: "Chatbot Responses", + where: "Customize Prompts → Chatbot Responses, on this graph", + text: JIRA_RESPONSE_EXAMPLE, + }, + ], + }, +}; + +const ConnectorPromptGuide: React.FC<{ connectorType: string }> = ({ + connectorType, +}) => { + const guide = GUIDES[connectorType]; + const [openId, setOpenId] = useState(null); + const [copiedId, setCopiedId] = useState(null); + + if (!guide) return null; + + const copyExample = async (example: ExamplePrompt) => { + try { + await navigator.clipboard.writeText(example.text); + setCopiedId(example.id); + window.setTimeout(() => { + setCopiedId((current) => (current === example.id ? null : current)); + }, 2000); + } catch { + setCopiedId(null); + } + }; + + return ( +
+
+ +
+

+ 4. Prompt guide +

+

+ How to write the {guide.title} prompt for this graph. +

+
+
+ +
+

+ How to build the prompt +

+

{guide.summary}

+
    + {guide.points.map((point) => ( +
  • {point}
  • + ))} +
+
+ +
+ These examples are an addition, not a replacement. Open{" "} + + Customize Prompts + + , select this graph, and open the prompt named on the example. Leave + the instructions already in that prompt, scroll to the end, and paste + the example there. Then save. Do not delete the existing prompt text. +
+ +
+

+ Example prompts +

+ {guide.examples.map((example) => { + const open = openId === example.id; + return ( +
+
+ + +
+ {open && ( +
+                  {example.text}
+                
+ )} +
+ ); + })} +
+
+ ); +}; + +export default ConnectorPromptGuide; diff --git a/graphrag-ui/src/pages/setup/DataSourcesConfig.tsx b/graphrag-ui/src/pages/setup/DataSourcesConfig.tsx new file mode 100644 index 00000000..1593e7c1 --- /dev/null +++ b/graphrag-ui/src/pages/setup/DataSourcesConfig.tsx @@ -0,0 +1,1415 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + DatabaseZap, + Loader2, + Plus, + Save, + ShieldCheck, + Trash2, +} from "lucide-react"; +import ConfigScopeToggle from "@/components/ConfigScopeToggle"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { safeJson } from "@/utils/safeJson"; +import { pauseIdleTimer, pingIdleTimer, resumeIdleTimer } from "@/hooks/useIdleTimeout"; +import type { JiraSchemaStatus, JiraSource } from "@/types/dataSources"; +import ConnectorPromptGuide from "@/pages/setup/ConnectorPromptGuide"; + +const editingSourceKey = (graph: string) => + `graphrag:jira-editing-source:${graph}`; + +const syncFeedbackKey = (graph: string) => + `graphrag:jira-sync-feedback:${graph}`; + +interface JiraProject { + id: string; + key: string; + name: string; +} + +interface ActionFeedback { + type: "success" | "error" | "pending"; + text: string; +} + +const ActionStatus: React.FC<{ feedback: ActionFeedback | null }> = ({ + feedback, +}) => { + if (!feedback) return null; + return ( +
+ {feedback.text} +
+ ); +}; + +const emptySource = (): JiraSource => ({ + id: "", + type: "jira_cloud", + enabled: true, + display_name: "", + connection: { site_url: "", email: "", api_token: "" }, + scope: { + project_keys: [], + created_after: null, + updated_after: null, + status_categories: [], + jql_extra: "", + include_comments: true, + }, + sync: { overlap_seconds: 120, last_issue_count: 0 }, +}); + +const labelClass = "block text-sm font-medium mb-2 text-black dark:text-white"; +const inputClass = "dark:border-[#3D3D3D] dark:bg-background"; +const jiraStatusCategories = [ + { value: "new", label: "To do" }, + { value: "indeterminate", label: "In progress" }, + { value: "done", label: "Done" }, +] as const; + +const errorDetail = (data: any, fallback: string) => { + if (typeof data?.detail === "string") return data.detail; + if (typeof data?.detail?.message === "string") { + const conflicts = Array.isArray(data.detail.conflicts) + ? ` ${data.detail.conflicts.join("; ")}` + : ""; + return `${data.detail.message}${conflicts}`; + } + return fallback; +}; + +const DataSourcesConfig: React.FC = () => { + const [selectedGraph, setSelectedGraph] = useState( + sessionStorage.getItem("selectedGraph") || "" + ); + const [availableGraphs, setAvailableGraphs] = useState([]); + const [sources, setSources] = useState([]); + const [editing, setEditing] = useState(null); + const [connectionVerified, setConnectionVerified] = useState(false); + const [projectSelectionLoaded, setProjectSelectionLoaded] = useState(false); + const [projects, setProjects] = useState([]); + const [directProjectKey, setDirectProjectKey] = useState(""); + const [projectSearch, setProjectSearch] = useState(""); + const [schema, setSchema] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(""); + const [message, setMessage] = useState(""); + const [messageType, setMessageType] = useState<"success" | "error" | "">(""); + const [connectionFeedback, setConnectionFeedback] = + useState(null); + const [scopeFeedback, setScopeFeedback] = + useState(null); + const [saveFeedback, setSaveFeedback] = + useState(null); + const [countFeedback, setCountFeedback] = + useState(null); + const [sourceFeedback, setSourceFeedback] = + useState(null); + const [schemaFeedback, setSchemaFeedback] = + useState(null); + const [syncFeedback, setSyncFeedbackRaw] = + useState(null); + + const setSyncFeedback = ( + feedback: ActionFeedback | null, + graph?: string + ) => { + const key = syncFeedbackKey(graph ?? selectedGraph); + if (feedback) { + sessionStorage.setItem(key, JSON.stringify(feedback)); + } else { + sessionStorage.removeItem(key); + } + setSyncFeedbackRaw(feedback); + }; + + useEffect(() => { + const creds = sessionStorage.getItem("auth"); + if (!creds) return; + fetch("/ui/list_graphs", { headers: { Authorization: creds } }) + .then((response) => (response.ok ? response.json() : null)) + .then((data) => { + const graphs = Array.isArray(data?.graphs) + ? data.graphs + : Array.isArray(data) + ? data + : []; + setAvailableGraphs(graphs); + if (!selectedGraph && graphs.length) setSelectedGraph(graphs[0]); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + const syncSelectedGraph = () => + setSelectedGraph(sessionStorage.getItem("selectedGraph") || ""); + window.addEventListener("graphrag:selectedGraph", syncSelectedGraph); + return () => + window.removeEventListener("graphrag:selectedGraph", syncSelectedGraph); + }, []); + + const loadSources = useCallback(async () => { + if (!selectedGraph) { + setSources([]); + return; + } + setLoading(true); + setMessage(""); + try { + const response = await fetch(`/ui/${selectedGraph}/data-sources`, { + headers: { Authorization: sessionStorage.getItem("auth")! }, + }); + const data = await safeJson(response); + if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`); + const loadedSources: JiraSource[] = Array.isArray(data.sources) + ? data.sources + : []; + setSources(loadedSources); + setEditing((current) => { + if (current) return current; + const rememberedId = sessionStorage.getItem( + editingSourceKey(selectedGraph) + ); + if (rememberedId === "__closed__") return null; + const remembered = loadedSources.find( + (source) => source.id === rememberedId + ); + return remembered || (loadedSources.length === 1 ? loadedSources[0] : null); + }); + } catch (error: any) { + setMessage(`Failed to load data sources: ${error.message}`); + setMessageType("error"); + } finally { + setLoading(false); + } + }, [selectedGraph]); + + const loadSchema = useCallback(async () => { + if (!selectedGraph) { + setSchema(null); + return false; + } + try { + const response = await fetch( + `/ui/${selectedGraph}/data-sources/jira/schema`, + { headers: { Authorization: sessionStorage.getItem("auth")! } } + ); + const data = await safeJson(response); + if (!response.ok) { + throw new Error( + errorDetail(data, `Failed to inspect schema (${response.status})`) + ); + } + setSchema(data); + return true; + } catch (error: any) { + setSchemaFeedback({ + type: "error", + text: `Failed to inspect Jira schema: ${error.message}`, + }); + return false; + } + }, [selectedGraph]); + + useEffect(() => { + setEditing(null); + setConnectionVerified(false); + setProjectSelectionLoaded(false); + setProjects([]); + setDirectProjectKey(""); + setProjectSearch(""); + setConnectionFeedback(null); + setScopeFeedback(null); + setSaveFeedback(null); + setCountFeedback(null); + setSourceFeedback(null); + setSchemaFeedback(null); + // Restore persisted sync feedback for this graph so navigation doesn't clear it. + const stored = sessionStorage.getItem(syncFeedbackKey(selectedGraph)); + setSyncFeedbackRaw(stored ? (JSON.parse(stored) as ActionFeedback) : null); + loadSources(); + loadSchema(); + }, [loadSchema, loadSources]); + + const patch = (value: Partial) => { + setConnectionFeedback(null); + setScopeFeedback(null); + setSaveFeedback(null); + setCountFeedback(null); + setEditing((current) => (current ? { ...current, ...value } : current)); + }; + + const patchConnectionDetails = (value: Partial) => { + setConnectionVerified(false); + setProjectSelectionLoaded(false); + setProjects([]); + setProjectSearch(""); + patch(value); + }; + + const validateConnection = ( + source: JiraSource, + requireToken = true + ) => { + const missingFields = [ + !source.id.trim() && "source id", + !source.display_name.trim() && "display name", + !source.connection.site_url.trim() && "site URL", + !source.connection.email.trim() && "Atlassian email", + requireToken && !source.connection.api_token.trim() && "API token", + ].filter(Boolean) as string[]; + + if (missingFields.length > 0) { + const fields = + missingFields.length === 1 + ? missingFields[0] + : `${missingFields.slice(0, -1).join(", ")} and ${ + missingFields[missingFields.length - 1] + }`; + setConnectionFeedback({ + type: "error", + text: `${ + fields.charAt(0).toUpperCase() + fields.slice(1) + } ${missingFields.length === 1 ? "is" : "are"} required.`, + }); + return false; + } + return true; + }; + + const persistSource = async (source: JiraSource) => { + const response = await fetch( + `/ui/${selectedGraph}/data-sources/${encodeURIComponent(source.id)}`, + { + method: "PUT", + headers: { + Authorization: sessionStorage.getItem("auth")!, + "Content-Type": "application/json", + }, + body: JSON.stringify(source), + } + ); + const data = await safeJson(response); + if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`); + return data.source as JiraSource; + }; + + const save = async () => { + if ( + !editing || + !selectedGraph || + !connectionVerified || + !validateConnection(editing, false) + ) { + return; + } + if (editing.scope.project_keys.length === 0) { + setSaveFeedback({ + type: "error", + text: "Select at least one Jira project before saving the scope.", + }); + return; + } + setBusy("save"); + setSaveFeedback({ + type: "pending", + text: "Saving Jira project scope…", + }); + try { + const saved = await persistSource(editing); + setEditing(saved); + sessionStorage.setItem(editingSourceKey(selectedGraph), saved.id); + await loadSources(); + setSaveFeedback({ + type: "success", + text: `Project scope saved (${saved.scope.project_keys.length} selected).`, + }); + } catch (error: any) { + setSaveFeedback({ + type: "error", + text: `Failed to save project scope: ${error.message}`, + }); + } finally { + setBusy(""); + } + }; + + const test = async () => { + if (!editing || !selectedGraph) return; + setConnectionVerified(false); + setProjectSelectionLoaded(false); + setProjects([]); + setProjectSearch(""); + setCountFeedback(null); + setSaveFeedback(null); + if (!validateConnection(editing)) return; + setScopeFeedback(null); + setBusy("test"); + setConnectionFeedback({ + type: "pending", + text: "Testing Jira connection…", + }); + try { + const response = await fetch( + `/ui/${selectedGraph}/data-sources/${encodeURIComponent(editing.id)}/test`, + { + method: "POST", + headers: { + Authorization: sessionStorage.getItem("auth")!, + "Content-Type": "application/json", + }, + body: JSON.stringify(editing), + } + ); + const data = await safeJson(response); + if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`); + setConnectionFeedback({ + type: "success", + text: `Connected as ${data.account?.display_name || "Jira user"}.`, + }); + setConnectionVerified(true); + setEditing(data.source); + sessionStorage.setItem(editingSourceKey(selectedGraph), data.source.id); + await loadSources(); + } catch (error: any) { + setConnectionVerified(false); + setConnectionFeedback({ + type: "error", + text: `Connection failed: ${error.message}`, + }); + } finally { + setBusy(""); + } + }; + + const loadProjects = async () => { + if (!editing || !connectionVerified) return; + const projectKey = directProjectKey.trim().toUpperCase(); + setProjectSelectionLoaded(false); + setProjects([]); + setProjectSearch(""); + setCountFeedback(null); + setBusy("projects"); + setScopeFeedback({ + type: "pending", + text: projectKey + ? `Loading Jira project ${projectKey}…` + : "Loading visible Jira projects…", + }); + try { + const query = projectKey + ? `?project_key=${encodeURIComponent(projectKey)}` + : ""; + const response = await fetch( + `/ui/${selectedGraph}/data-sources/${encodeURIComponent(editing.id)}/projects${query}`, + { headers: { Authorization: sessionStorage.getItem("auth")! } } + ); + const data = await safeJson(response); + if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`); + const visibleProjects = Array.isArray(data.projects) ? data.projects : []; + setProjects(visibleProjects); + setProjectSearch(""); + setProjectSelectionLoaded(true); + if (projectKey && visibleProjects.length === 1) { + setEditing((current) => + current + ? { + ...current, + scope: { + ...current.scope, + project_keys: [visibleProjects[0].key], + }, + } + : current + ); + } + setScopeFeedback({ + type: "success", + text: + visibleProjects.length > 0 + ? projectKey + ? `Loaded and selected ${visibleProjects[0].key} — ${visibleProjects[0].name}.` + : `Loaded ${visibleProjects.length} visible Jira projects.` + : "Connection succeeded, but this account has no visible Jira projects.", + }); + } catch (error: any) { + setProjectSelectionLoaded(false); + setScopeFeedback({ + type: "error", + text: `Failed to load projects: ${error.message}`, + }); + } finally { + setBusy(""); + } + }; + + const previewTicketCount = async () => { + if ( + !editing || + !connectionVerified || + editing.scope.project_keys.length === 0 + ) { + setCountFeedback({ + type: "error", + text: "Select at least one Jira project before previewing tickets.", + }); + return; + } + setBusy("count"); + setCountFeedback({ + type: "pending", + text: "Checking how many Jira tickets match this scope…", + }); + try { + const response = await fetch( + `/ui/${selectedGraph}/data-sources/${encodeURIComponent(editing.id)}/issues/count`, + { + method: "POST", + headers: { + Authorization: sessionStorage.getItem("auth")!, + "Content-Type": "application/json", + }, + body: JSON.stringify(editing.scope), + } + ); + const data = await safeJson(response); + if (!response.ok) { + throw new Error(data.detail || `HTTP ${response.status}`); + } + const count = Number(data.count || 0); + setCountFeedback({ + type: "success", + text: `Approximately ${count.toLocaleString()} Jira tickets match this scope.`, + }); + } catch (error: any) { + setCountFeedback({ + type: "error", + text: `Failed to preview ticket count: ${error.message}`, + }); + } finally { + setBusy(""); + } + }; + + const installSchema = async () => { + if (!selectedGraph) return; + setBusy("schema"); + setSchemaFeedback({ + type: "pending", + text: "Installing Jira schema…", + }); + try { + const response = await fetch( + `/ui/${selectedGraph}/data-sources/jira/schema/install`, + { + method: "POST", + headers: { Authorization: sessionStorage.getItem("auth")! }, + } + ); + const data = await safeJson(response); + if (!response.ok) { + throw new Error( + errorDetail(data, `Schema installation failed (${response.status})`) + ); + } + setSchema(data.schema); + setSchemaFeedback({ + type: "success", + text: "Jira schema installed.", + }); + } catch (error: any) { + setSchemaFeedback({ + type: "error", + text: `Schema installation failed: ${error.message}`, + }); + } finally { + setBusy(""); + } + }; + + const pollSync = async (sourceId: string, runId: string) => { + for (;;) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + const response = await fetch( + `/ui/${selectedGraph}/data-sources/${encodeURIComponent(sourceId)}/sync/${runId}`, + { headers: { Authorization: sessionStorage.getItem("auth")! } } + ); + const data = await safeJson(response); + if (!response.ok) { + throw new Error( + errorDetail(data, `Sync status failed (${response.status})`) + ); + } + pingIdleTimer(); + if (data.status === "failed") { + throw new Error(data.error || "Jira synchronization failed"); + } + if (data.status === "completed") return data.result; + } + }; + + const sync = async (source: JiraSource) => { + setBusy(`sync:${source.id}`); + setSources((current) => + current.map((item) => + item.id === source.id + ? { ...item, sync: { ...item.sync, last_error: null } } + : item + ) + ); + setSyncFeedback({ + type: "pending", + text: `Ingesting ${source.display_name} into the graph…`, + }); + pauseIdleTimer(); + try { + const response = await fetch( + `/ui/${selectedGraph}/data-sources/${encodeURIComponent(source.id)}/sync`, + { + method: "POST", + headers: { Authorization: sessionStorage.getItem("auth")! }, + } + ); + const data = await safeJson(response); + if (!response.ok) { + throw new Error( + errorDetail(data, `Jira synchronization failed (${response.status})`) + ); + } + const result = await pollSync(source.id, data.run_id); + let buildStarted = false; + const missingChunkEmbeddings = Number( + result.missing_chunk_embeddings || 0 + ); + if (result.rebuild_required) { + setSyncFeedback({ + type: "pending", + text: + missingChunkEmbeddings > 0 && result.documents_loaded === 0 + ? `No Jira changes detected, but ${missingChunkEmbeddings} graph chunks are missing embeddings. Starting a recovery build…` + : "Jira ingestion complete. Starting the GraphRAG build…", + }); + const rebuild = await fetch(`/ui/${selectedGraph}/rebuild_graph`, { + method: "POST", + headers: { Authorization: sessionStorage.getItem("auth")! }, + }); + const rebuildData = await safeJson(rebuild); + if (!rebuild.ok) { + throw new Error( + errorDetail( + rebuildData, + "Jira data was ingested, but the GraphRAG build failed to start." + ) + ); + } + buildStarted = true; + + // Poll rebuild_status and show live ECC progress on the Data Sources + // page, identical to the progress bar shown on the KGAdmin page. + const creds = sessionStorage.getItem("auth")!; + const baseMsg = + `Jira ingestion complete: ${result.issues_upserted} issues updated, ` + + `${result.issues_deleted || 0} removed, and ` + + `${result.documents_loaded} changed documents loaded. `; + let pollDone = false; + while (!pollDone) { + await new Promise((r) => setTimeout(r, 3000)); + try { + const statusResp = await fetch( + `/ui/${selectedGraph}/rebuild_status`, + { headers: { Authorization: creds } } + ); + if (!statusResp.ok) break; + const statusData = await statusResp.json(); + if (statusData.is_running) { + const stage = statusData.stage ? ` — ${statusData.stage}` : " — Building…"; + setSyncFeedback({ + type: "pending", + text: baseMsg + `GraphRAG build in progress${stage}`, + }); + } else { + pollDone = true; + } + } catch { + break; + } + } + } + await loadSources(); + setSyncFeedback({ + type: "success", + text: + `Jira ingestion complete: ${result.issues_upserted} issues updated, ` + + `${result.issues_deleted || 0} removed, and ` + + `${result.documents_loaded} changed documents loaded.` + + (buildStarted + ? missingChunkEmbeddings > 0 + ? ` GraphRAG recovery build complete.` + : " GraphRAG build for chunking and embedding complete." + : " No Jira changes were detected, so no new build was started."), + }); + } catch (error: any) { + setSyncFeedback({ + type: "error", + text: `Jira ingestion failed: ${error.message}`, + }); + } finally { + resumeIdleTimer(); + setBusy(""); + } + }; + + const remove = async (source: JiraSource) => { + if (!window.confirm(`Remove data source "${source.display_name}"?`)) return; + setBusy(`delete:${source.id}`); + setSourceFeedback({ + type: "pending", + text: `Removing ${source.display_name}…`, + }); + try { + const response = await fetch( + `/ui/${selectedGraph}/data-sources/${encodeURIComponent(source.id)}`, + { + method: "DELETE", + headers: { Authorization: sessionStorage.getItem("auth")! }, + } + ); + const data = await safeJson(response); + if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`); + if (editing?.id === source.id) { + setEditing(null); + sessionStorage.removeItem(editingSourceKey(selectedGraph)); + } + await loadSources(); + setSourceFeedback({ + type: "success", + text: `${source.display_name} was removed.`, + }); + } catch (error: any) { + setSourceFeedback({ + type: "error", + text: `Delete failed: ${error.message}`, + }); + } finally { + setBusy(""); + } + }; + + const normalizedProjectSearch = projectSearch.trim().toLowerCase(); + const filteredProjects = normalizedProjectSearch + ? projects.filter( + (project) => + project.key.toLowerCase().includes(normalizedProjectSearch) || + project.name.toLowerCase().includes(normalizedProjectSearch) + ) + : projects; + + return ( +
+
+
+ +
+
+

+ Data sources +

+

+ Connect, configure, ingest, and monitor external sources for the + selected knowledge graph. +

+
+
+ + {}} + onGraphChange={(graph) => { + setSelectedGraph(graph); + sessionStorage.setItem("selectedGraph", graph); + window.dispatchEvent(new Event("graphrag:selectedGraph")); + }} + /> + + {message && ( +
+ {message} +
+ )} + + {selectedGraph && ( +
+
+
+
+ +
+
+

+ Jira Cloud +

+

+ Connect Jira, select projects, install its graph schema, then + ingest issues and build GraphRAG. +

+
+
+ +
+ +
+ Until per-user Jira ACL enforcement is enabled, graph users can + retrieve every selected-project issue visible to the connector account. +
+ +
+ {sourceFeedback && ( +
+ +
+ )} +
+
+

+ 1. Connection and scope +

+ + {loading ? "Loading…" : `${sources.length} configured source${sources.length === 1 ? "" : "s"}`} + +
+
+ {sources.length === 0 && !loading && ( +

+ No Jira sources configured for this graph. +

+ )} +
+ {sources.map((source) => ( +
+
+
+ {source.display_name} +
+
+ {source.connection.site_url} + {source.scope.project_keys.length > 0 && + ` · ${source.scope.project_keys.join(", ")}`} +
+
+ + {source.sync.last_tested_at + ? "Connection tested" + : "Connection not tested"} + +
+ {source.sync.last_error && ( +
{source.sync.last_error}
+ )} +
+
+ + +
+
+ ))} +
+
+ + {editing && ( +
+

+ Connection details +

+
+
+ + source.id === editing.id)} + onChange={(event) => + patchConnectionDetails({ id: event.target.value }) + } + className={inputClass} + placeholder="jira-acme" + /> +
+
+ + patch({ display_name: event.target.value })} + className={inputClass} + placeholder="Acme Jira" + /> +
+
+ + + patchConnectionDetails({ + connection: { + ...editing.connection, + site_url: event.target.value, + }, + }) + } + className={inputClass} + placeholder="https://your-company.atlassian.net" + /> +

+ Jira tenant base URL—not graphql.atlassian.net or an issue URL. +

+
+
+ + + patchConnectionDetails({ + connection: { + ...editing.connection, + email: event.target.value, + }, + }) + } + className={inputClass} + /> +

+ Email address associated with the API token. +

+
+
+ + + patchConnectionDetails({ + connection: { + ...editing.connection, + api_token: event.target.value, + }, + }) + } + className={inputClass} + placeholder="Required each time you test the connection" + /> +
+
+ + { + setDirectProjectKey(event.target.value); + setProjectSelectionLoaded(false); + setProjects([]); + setProjectSearch(""); + setConnectionFeedback(null); + setScopeFeedback(null); + setSaveFeedback(null); + setCountFeedback(null); + }} + className={inputClass} + placeholder="GML" + /> +

+ Enter a key to load only that Jira project, or leave this + blank to load every project visible to the account. +

+
+
+ +
+ {connectionFeedback && ( +
+ +
+ )} + {connectionVerified && ( + <> +
+

+ Projects to ingest +

+

+ Load the requested Jira project or all projects visible + to this account, then select the projects for this graph. +

+
+
+ +
+ + )} +
+ + {projects.length > 0 && ( +
+
+ {directProjectKey.trim() + ? "Jira project" + : "Visible Jira projects"} +
+ {!directProjectKey.trim() && ( + setProjectSearch(event.target.value)} + className={`${inputClass} mb-2`} + placeholder="Search by project key or name" + aria-label="Search visible Jira projects" + /> + )} +
+ {filteredProjects.map((project) => ( + + ))} + {filteredProjects.length === 0 && ( +

+ No projects match this search. +

+ )} +
+
+ )} + + {connectionVerified && ( + + )} + + {connectionVerified && + projectSelectionLoaded && + projects.length > 0 && ( +
+

+ Ticket filters +

+

+ Optionally limit which tickets are included from the + selected projects. +

+
+
+ + + patch({ + scope: { + ...editing.scope, + created_after: event.target.value || null, + }, + }) + } + className={inputClass} + /> +
+
+ + + patch({ + scope: { + ...editing.scope, + updated_after: event.target.value || null, + }, + }) + } + className={inputClass} + /> +
+
+
Status categories
+
+ {jiraStatusCategories.map((category) => { + const selected = + editing.scope.status_categories || []; + return ( + + ); + })} +
+

+ Leave all categories unselected to include every status. +

+
+ +
+ + Advanced scope options + +
+ + + patch({ + scope: { + ...editing.scope, + jql_extra: event.target.value, + }, + }) + } + className={inputClass} + placeholder="statusCategory != Done" + /> +
+
+
+ +
+ {countFeedback && ( +
+ +
+ )} +
+
+ )} + + {connectionVerified && projectSelectionLoaded && saveFeedback && ( + + )} + +
+ + {connectionVerified && projectSelectionLoaded && ( + + )} +
+
+ )} + + {sources.length > 0 && ( +
+
+
+
+ + 2. Jira graph schema +
+

+ {schema?.status === "installed" + ? "Installed" + : schema?.status === "not_initialized" + ? "Initialize this graph before installing the Jira schema." + : schema?.status === "conflict" + ? "Existing graph schema conflicts with the Jira connector." + : schema?.status === "incomplete" + ? "The Jira schema is incomplete." + : sources.some( + (source) => + source.sync.last_tested_at && + source.scope.project_keys.length > 0 + ) + ? "Ready to install." + : sources.some( + (source) => source.sync.last_tested_at + ) + ? "Select and save at least one project first." + : "Test a saved connection before installing the schema."} +

+
+
+ {schema && + schema.status !== "installed" && + schema.status !== "conflict" && + schema.status !== "not_initialized" && ( + + )} +
+
+ {(schema?.conflicts?.length || 0) > 0 && ( +
    + {schema?.conflicts.map((conflict) => ( +
  • {conflict}
  • + ))} +
+ )} + {schemaFeedback && ( +
+ +
+ )} +
+ )} + + {sources.length > 0 && schema?.status === "installed" && ( +
+

+ 3. Ingest and build +

+

+ Import Jira issues into this graph. The existing GraphRAG loading + pipeline creates chunks and embeddings after structural ingestion. +

+ {syncFeedback && ( +
+ +
+ )} +
+ {sources.map((source) => ( +
+
+
+ {source.display_name} +
+
+ Last ingestion: {source.sync.last_completed_at || "Never"} · + Issues: {source.sync.last_issue_count || 0} +
+ {source.sync.last_error && + busy !== `sync:${source.id}` && ( +
+ {source.sync.last_error} +
+ )} +
+ +
+ ))} +
+ +
+ )} +
+ )} +
+ ); +}; + +export default DataSourcesConfig; diff --git a/graphrag-ui/src/pages/setup/KGAdmin.tsx b/graphrag-ui/src/pages/setup/KGAdmin.tsx index 2cf23b5a..791fcba3 100644 --- a/graphrag-ui/src/pages/setup/KGAdmin.tsx +++ b/graphrag-ui/src/pages/setup/KGAdmin.tsx @@ -300,6 +300,8 @@ const KGAdmin = () => { setRefreshMessage(""); setPollingActive(false); setRebuildProgress(null); + } else if (refreshGraphName) { + checkRebuildStatus(refreshGraphName); } }; @@ -2734,6 +2736,7 @@ const KGAdmin = () => { setRefreshGraphName(v); sessionStorage.setItem("selectedGraph", v); window.dispatchEvent(new Event("graphrag:selectedGraph")); + checkRebuildStatus(v); }} disabled={isRefreshing || isRebuildRunning || isCheckingStatus} > diff --git a/graphrag-ui/src/pages/setup/SetupLayout.tsx b/graphrag-ui/src/pages/setup/SetupLayout.tsx index 1d2ddde0..426b57cd 100644 --- a/graphrag-ui/src/pages/setup/SetupLayout.tsx +++ b/graphrag-ui/src/pages/setup/SetupLayout.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { Outlet, useNavigate, useLocation } from "react-router-dom"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Database, Settings, FileText, ChevronRight } from "lucide-react"; +import { ArrowLeft, Database, DatabaseZap, Settings, FileText, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { useRoles } from "@/hooks/useRoles"; @@ -41,6 +41,12 @@ const SetupLayout = () => { path: "/setup/kg-admin", subItems: [], }, + { + title: "Data Sources", + icon: DatabaseZap, + path: "/setup/kg-admin/data-sources", + subItems: [], + }, { title: "Server Configuration", icon: Settings, @@ -168,7 +174,9 @@ const SetupLayout = () => { const Icon = item.icon; const hasSubItems = item.subItems.length > 0; const isExpanded = expandedSection === item.path; - const isItemActive = isParentActive(item.path); + const isItemActive = hasSubItems + ? isParentActive(item.path) + : isActive(item.path); return (
diff --git a/graphrag-ui/src/types/dataSources.ts b/graphrag-ui/src/types/dataSources.ts new file mode 100644 index 00000000..eb87568f --- /dev/null +++ b/graphrag-ui/src/types/dataSources.ts @@ -0,0 +1,41 @@ +export interface JiraSource { + id: string; + type: "jira_cloud"; + enabled: boolean; + display_name: string; + connection: { + site_url: string; + email: string; + api_token: string; + cloud_id?: string | null; + }; + scope: { + project_keys: string[]; + created_after?: string | null; + updated_after?: string | null; + status_categories?: Array<"new" | "indeterminate" | "done">; + jql_extra: string; + include_comments: boolean; + story_points_field?: string | null; + }; + sync: { + overlap_seconds: number; + checkpoint?: string | null; + last_tested_at?: string | null; + last_started_at?: string | null; + last_completed_at?: string | null; + last_error?: string | null; + last_issue_count: number; + }; +} + +export interface JiraSchemaStatus { + status: + | "not_initialized" + | "not_installed" + | "incomplete" + | "conflict" + | "installed"; + missing: Record; + conflicts: string[]; +} diff --git a/graphrag/app/connectors/__init__.py b/graphrag/app/connectors/__init__.py new file mode 100644 index 00000000..a1cb1b33 --- /dev/null +++ b/graphrag/app/connectors/__init__.py @@ -0,0 +1 @@ +"""Enterprise data-source connectors.""" diff --git a/graphrag/app/connectors/jira/__init__.py b/graphrag/app/connectors/jira/__init__.py new file mode 100644 index 00000000..5a5ef0bb --- /dev/null +++ b/graphrag/app/connectors/jira/__init__.py @@ -0,0 +1,5 @@ +"""Jira Cloud connector.""" + +from .config import JiraDataSource + +__all__ = ["JiraDataSource"] diff --git a/graphrag/app/connectors/jira/adf.py b/graphrag/app/connectors/jira/adf.py new file mode 100644 index 00000000..5e1ad67d --- /dev/null +++ b/graphrag/app/connectors/jira/adf.py @@ -0,0 +1,122 @@ +"""Convert Atlassian Document Format (ADF) to retrieval-friendly markdown.""" + +from __future__ import annotations + +from typing import Any + + +def _escape_inline(value: str) -> str: + return value.replace("\\", "\\\\").replace("*", "\\*").replace("_", "\\_") + + +def _with_marks(text: str, marks: list[dict[str, Any]]) -> str: + for mark in marks: + mark_type = mark.get("type") + attrs = mark.get("attrs") or {} + if mark_type == "code": + escaped = text.replace("`", "\\`") + text = f"`{escaped}`" + elif mark_type == "strong": + text = f"**{text}**" + elif mark_type == "em": + text = f"*{text}*" + elif mark_type == "strike": + text = f"~~{text}~~" + elif mark_type == "link" and attrs.get("href"): + text = f"[{text}]({attrs['href']})" + return text + + +def _inline(node: dict[str, Any]) -> str: + node_type = node.get("type") + attrs = node.get("attrs") or {} + if node_type == "text": + text = _escape_inline(str(node.get("text") or "")) + return _with_marks(text, node.get("marks") or []) + if node_type == "hardBreak": + return " \n" + if node_type == "mention": + return f"@{attrs.get('text') or attrs.get('displayName') or 'user'}" + if node_type == "emoji": + return str(attrs.get("text") or attrs.get("shortName") or "") + if node_type == "inlineCard": + url = str(attrs.get("url") or "") + return f"[{url}]({url})" if url else "" + return "".join(_inline(child) for child in node.get("content") or []) + + +def _block(node: dict[str, Any], depth: int = 0) -> str: + node_type = node.get("type") + attrs = node.get("attrs") or {} + children = node.get("content") or [] + + if node_type == "doc": + return "\n\n".join( + value for child in children if (value := _block(child, depth)).strip() + ) + if node_type == "paragraph": + return "".join(_inline(child) for child in children).strip() + if node_type == "heading": + level = min(max(int(attrs.get("level") or 1), 1), 6) + return f"{'#' * level} {''.join(_inline(c) for c in children).strip()}" + if node_type == "blockquote": + content = "\n".join(_block(child, depth) for child in children).strip() + return "\n".join(f"> {line}" for line in content.splitlines()) + if node_type == "codeBlock": + language = attrs.get("language") or "" + content = "".join(_inline(child) for child in children) + return f"```{language}\n{content}\n```" + if node_type == "rule": + return "---" + if node_type in ("bulletList", "orderedList"): + ordered = node_type == "orderedList" + start = int(attrs.get("order") or 1) + lines: list[str] = [] + for index, child in enumerate(children): + value = _block(child, depth + 1).strip() + if not value: + continue + prefix = f"{start + index}. " if ordered else "- " + indentation = " " * depth + continuation = "\n".join( + f"{indentation} {line}" for line in value.splitlines()[1:] + ) + first = f"{indentation}{prefix}{value.splitlines()[0]}" + lines.append(f"{first}\n{continuation}".rstrip()) + return "\n".join(lines) + if node_type == "listItem": + return "\n".join( + value for child in children if (value := _block(child, depth)).strip() + ) + if node_type in ("table", "tableRow", "tableCell", "tableHeader"): + # ADF tables can contain arbitrary blocks. Tabs preserve cell + # boundaries for embedding without pretending to provide full GFM. + separator = "\n" if node_type in ("table", "tableRow") else " " + values = [_block(child, depth).strip() for child in children] + values = [value for value in values if value] + if node_type == "tableRow": + separator = " | " + return separator.join(values) + if node_type in ("panel", "expand", "nestedExpand"): + title = str(attrs.get("title") or "").strip() + body = "\n\n".join( + value for child in children if (value := _block(child, depth)).strip() + ) + return f"**{title}**\n\n{body}".strip() if title else body + if node_type == "mediaSingle": + return "\n".join(_block(child, depth) for child in children).strip() + if node_type == "media": + name = attrs.get("alt") or attrs.get("id") or "attachment" + return f"[Attachment: {name}]" + return "".join(_inline(child) for child in children).strip() + + +def adf_to_markdown(value: Any) -> str: + """Return markdown for an ADF document, or a safe string fallback.""" + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if not isinstance(value, dict): + return str(value).strip() + return _block(value).strip() diff --git a/graphrag/app/connectors/jira/client.py b/graphrag/app/connectors/jira/client.py new file mode 100644 index 00000000..7cae672a --- /dev/null +++ b/graphrag/app/connectors/jira/client.py @@ -0,0 +1,442 @@ +"""Small, testable Jira Cloud REST API client.""" + +from __future__ import annotations + +import concurrent.futures +import logging +import time +from collections.abc import Iterator +from datetime import datetime, timedelta, timezone +from email.utils import parsedate_to_datetime +from typing import Any, Callable +from urllib.parse import quote + +import httpx + +from .config import JiraDataSource + +logger = logging.getLogger(__name__) + + +ISSUE_FIELDS = [ + "summary", + "status", + "issuetype", + "priority", + "resolution", + "project", + "assignee", + "reporter", + "created", + "updated", + "duedate", + "labels", + "components", + "fixVersions", + "parent", + "issuelinks", + "comment", + "description", + "attachment", +] +STATUS_CATEGORY_JQL = { + "new": "To Do", + "indeterminate": "In Progress", + "done": "Done", +} + +# Number of threads used to complete comment pagination within a single page. +# Each thread issues its own HTTP request, so raising this reduces wall-clock +# time proportionally up to the Jira rate-limit ceiling (~100 req/min on free +# plans, higher on paid). 10 is a safe default that won't trigger throttling +# on typical Atlassian Cloud accounts. +COMMENT_FETCH_WORKERS = 10 + + +class JiraAPIError(RuntimeError): + def __init__(self, status_code: int, message: str): + super().__init__(message) + self.status_code = status_code + + +class JiraCloudClient: + def __init__( + self, + source: JiraDataSource, + *, + client: httpx.Client | None = None, + sleep: Callable[[float], None] = time.sleep, + max_attempts: int = 4, + ): + self.source = source + self._sleep = sleep + self._max_attempts = max_attempts + self._owns_client = client is None + self._client = client or httpx.Client( + base_url=source.connection.site_url, + auth=(source.connection.email, source.connection.api_token), + headers={"Accept": "application/json"}, + timeout=httpx.Timeout(30.0, connect=10.0), + ) + + def close(self) -> None: + if self._owns_client: + self._client.close() + + def __enter__(self) -> "JiraCloudClient": + return self + + def __exit__(self, *_args) -> None: + self.close() + + @staticmethod + def _retry_after(response: httpx.Response, attempt: int) -> float: + value = response.headers.get("Retry-After") + if value: + try: + return min(max(float(value), 0.0), 60.0) + except ValueError: + try: + retry_at = parsedate_to_datetime(value) + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + return min( + max((retry_at - datetime.now(timezone.utc)).total_seconds(), 0), + 60.0, + ) + except (TypeError, ValueError): + pass + return min(2 ** attempt, 30) + + @staticmethod + def _error_message(response: httpx.Response) -> str: + try: + payload = response.json() + except ValueError: + return f"Jira returned HTTP {response.status_code}" + if isinstance(payload, dict): + messages = payload.get("errorMessages") + if isinstance(messages, list) and messages: + return "; ".join(str(message) for message in messages) + errors = payload.get("errors") + if isinstance(errors, dict) and errors: + return "; ".join(f"{key}: {value}" for key, value in errors.items()) + if payload.get("message"): + return str(payload["message"]) + return f"Jira returned HTTP {response.status_code}" + + def _request(self, method: str, path: str, **kwargs) -> Any: + last_error: Exception | None = None + for attempt in range(self._max_attempts): + try: + response = self._client.request(method, path, **kwargs) + except (httpx.TimeoutException, httpx.TransportError) as exc: + last_error = exc + if attempt + 1 == self._max_attempts: + break + self._sleep(min(2 ** attempt, 30)) + continue + + if response.status_code == 429 or response.status_code >= 500: + if attempt + 1 < self._max_attempts: + self._sleep(self._retry_after(response, attempt)) + continue + if response.is_error: + raise JiraAPIError( + response.status_code, + self._error_message(response), + ) + if response.status_code == 204: + return None + try: + return response.json() + except ValueError as exc: + raise JiraAPIError( + response.status_code, + "Jira returned an invalid JSON response", + ) from exc + raise JiraAPIError(503, f"Unable to reach Jira: {last_error}") from last_error + + def myself(self) -> dict[str, Any]: + return self._request("GET", "/rest/api/3/myself") + + def cloud_id(self) -> str: + payload = self._request("GET", "/_edge/tenant_info") + cloud_id = payload.get("cloudId") if isinstance(payload, dict) else None + if not cloud_id: + raise JiraAPIError(502, "Jira did not return a cloudId") + return str(cloud_id) + + def projects(self) -> list[dict[str, Any]]: + start_at = 0 + projects: list[dict[str, Any]] = [] + while True: + payload = self._request( + "GET", + "/rest/api/3/project/search", + params={"startAt": start_at, "maxResults": 100, "orderBy": "key"}, + ) + values = payload.get("values") or [] + projects.extend(values) + if payload.get("isLast", True) or not values: + break + start_at += len(values) + return projects + + def project(self, project_key: str) -> dict[str, Any]: + """Return one project by key without enumerating every visible project.""" + return self._request( + "GET", + f"/rest/api/3/project/{quote(project_key, safe='')}", + ) + + def _jql( + self, + *, + incremental: bool = True, + include_order: bool = True, + ) -> str: + scope = self.source.scope + projects = ", ".join(scope.project_keys) + clauses = [f"project in ({projects})"] + if scope.created_after: + clauses.append(f'created >= "{scope.created_after.isoformat()}"') + if scope.updated_after: + clauses.append(f'updated >= "{scope.updated_after.isoformat()}"') + if scope.status_categories: + categories = ", ".join( + f'"{STATUS_CATEGORY_JQL[category]}"' + for category in scope.status_categories + ) + clauses.append(f"statusCategory in ({categories})") + if scope.jql_extra: + clauses.append(f"({scope.jql_extra})") + if incremental and self.source.sync.checkpoint: + checkpoint = self.source.sync.checkpoint + if checkpoint.tzinfo is None: + checkpoint = checkpoint.replace(tzinfo=timezone.utc) + checkpoint = checkpoint.astimezone(timezone.utc) - timedelta( + seconds=self.source.sync.overlap_seconds + ) + clauses.append(f'updated >= "{checkpoint:%Y-%m-%d %H:%M}"') + jql = " AND ".join(clauses) + return f"{jql} ORDER BY updated ASC, key ASC" if include_order else jql + + def approximate_issue_count(self) -> int: + payload = self._request( + "POST", + "/rest/api/3/search/approximate-count", + json={ + "jql": self._jql( + incremental=False, + include_order=False, + ) + }, + headers={"Content-Type": "application/json"}, + ) + return int(payload.get("count") or 0) + + def _iter_search_pages( + self, + *, + fields: list[str], + incremental: bool, + ) -> Iterator[list[dict[str, Any]]]: + token: str | None = None + jql = self._jql(incremental=incremental) + while True: + body: dict[str, Any] = { + "jql": jql, + "fields": fields, + "fieldsByKeys": False, + "maxResults": 100, + } + if token: + body["nextPageToken"] = token + payload = self._request( + "POST", + "/rest/api/3/search/jql", + json=body, + headers={"Content-Type": "application/json"}, + ) + issues = payload.get("issues") or [] + if issues: + yield issues + token = payload.get("nextPageToken") + if not token: + break + + def _iter_search(self, *, fields: list[str], incremental: bool): + for issues in self._iter_search_pages( + fields=fields, + incremental=incremental, + ): + yield from issues + + def iter_issue_pages(self) -> Iterator[list[dict[str, Any]]]: + """Yield complete issue pages for durable page-level synchronization. + + Comment completion and changelog fetching are parallelised / batched + across issues within each page so that extra Jira API calls run + concurrently rather than one at a time. + """ + fields = list(ISSUE_FIELDS) + story_points = self.source.scope.story_points_field + if story_points: + fields.append(story_points) + for issues in self._iter_search_pages(fields=fields, incremental=True): + if self.source.scope.include_comments: + workers = min(len(issues), COMMENT_FETCH_WORKERS) + with concurrent.futures.ThreadPoolExecutor( + max_workers=workers, thread_name_prefix="jira-comment" + ) as pool: + futures = [ + pool.submit(self._complete_comments, issue) + for issue in issues + ] + for fut in concurrent.futures.as_completed(futures): + fut.result() + # Bulk-fetch changelogs for all issues in this page in one POST. + self._bulk_fetch_changelogs(issues) + yield issues + + def iter_issues_by_keys(self, keys: set[str]) -> Iterator[list[dict[str, Any]]]: + """Fetch specific Jira issues by key without a full incremental scan. + + Used for recovery: when a previous sync wrote structural vertices but + failed before embedding, this fetches only the failed issues by key + (JQL ``key in (...)``), avoiding a full re-scan of all Jira issues. + Yields pages of up to 100 issues with comments completed, identical in + structure to ``iter_issue_pages()``. + """ + if not keys: + return + fields = list(ISSUE_FIELDS) + story_points = self.source.scope.story_points_field + if story_points: + fields.append(story_points) + keys_sorted = sorted(keys) + for i in range(0, len(keys_sorted), 100): + batch = keys_sorted[i : i + 100] + jql = "key in (" + ", ".join(batch) + ") ORDER BY updated ASC, key ASC" + payload = self._request( + "POST", + "/rest/api/3/search/jql", + json={ + "jql": jql, + "fields": fields, + "fieldsByKeys": False, + "maxResults": 100, + }, + headers={"Content-Type": "application/json"}, + ) + issues = payload.get("issues") or [] + if not issues: + continue + if self.source.scope.include_comments: + workers = min(len(issues), COMMENT_FETCH_WORKERS) + with concurrent.futures.ThreadPoolExecutor( + max_workers=workers, thread_name_prefix="jira-recovery" + ) as pool: + futures = [ + pool.submit(self._complete_comments, issue) + for issue in issues + ] + for fut in concurrent.futures.as_completed(futures): + fut.result() + # Bulk-fetch changelogs for all issues in this page in one POST. + self._bulk_fetch_changelogs(issues) + yield issues + + def iter_issues(self) -> Iterator[dict[str, Any]]: + for issues in self.iter_issue_pages(): + yield from issues + + def _complete_comments(self, issue: dict[str, Any]) -> None: + fields = issue.setdefault("fields", {}) + page = fields.get("comment") or {} + comments = list(page.get("comments") or []) + total = int(page.get("total") or len(comments)) + if len(comments) >= total: + page["comments"] = comments + fields["comment"] = page + return + + issue_id = issue.get("id") or issue.get("key") + start_at = len(comments) + while start_at < total: + payload = self._request( + "GET", + f"/rest/api/3/issue/{issue_id}/comment", + params={"startAt": start_at, "maxResults": 100, "orderBy": "created"}, + ) + batch = payload.get("comments") or [] + if not batch: + break + comments.extend(batch) + start_at += len(batch) + total = int(payload.get("total") or total) + page["comments"] = comments + page["total"] = total + fields["comment"] = page + + def _bulk_fetch_changelogs(self, issues: list[dict[str, Any]]) -> None: + """Bulk-fetch changelogs for up to 1,000 issues in a single POST. + + Uses POST /rest/api/3/changelog/bulkfetch which returns changelogs for + all requested issues in one round-trip instead of N individual GETs. + Paginates via nextPageToken if the response is truncated. + Results are attached at issue["changelog"]["histories"] keyed by + Jira issue ID so the mapper can access them as issue.get("changelog"). + On any error the method returns silently — no issue is blocked. + """ + if not issues: + return + + # Build a mapping from Jira numeric id → issue dict for fast lookup. + id_to_issue: dict[str, dict[str, Any]] = { + str(issue["id"]): issue for issue in issues if issue.get("id") + } + keys = [issue["key"] for issue in issues if issue.get("key")] + if not keys: + return + + # Initialise empty changelog on every issue so mapper never sees None. + for issue in issues: + issue.setdefault("changelog", {"histories": []}) + + try: + token: str | None = None + while True: + body: dict[str, Any] = { + "issueIdsOrKeys": keys, + "maxResults": 100, + } + if token: + body["nextPageToken"] = token + payload = self._request( + "POST", + "/rest/api/3/changelog/bulkfetch", + json=body, + headers={"Content-Type": "application/json"}, + ) + for entry in payload.get("issueChangeLogs") or []: + issue_id = str(entry.get("issueId") or "") + issue = id_to_issue.get(issue_id) + if issue is None: + continue + histories = issue["changelog"].setdefault("histories", []) + for h in entry.get("changeHistories") or []: + # Normalise ms-epoch timestamp → ISO string for mapper. + created = h.get("created") + if isinstance(created, (int, float)): + created = datetime.fromtimestamp( + created / 1000, tz=timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%S+00:00") + h = dict(h, created=created) + histories.append(h) + token = payload.get("nextPageToken") + if not token: + break + except Exception as exc: + logger.warning("bulk changelog fetch failed: %s — skipping", exc) diff --git a/graphrag/app/connectors/jira/config.py b/graphrag/app/connectors/jira/config.py new file mode 100644 index 00000000..1c5dfddd --- /dev/null +++ b/graphrag/app/connectors/jira/config.py @@ -0,0 +1,138 @@ +"""Validated configuration for the Jira Cloud connector.""" + +from __future__ import annotations + +import re +from datetime import date, datetime +from typing import Literal +from urllib.parse import urlparse + +from pydantic import BaseModel, Field, field_validator, model_validator + + +SOURCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") +PROJECT_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,31}$") + + +class JiraConnection(BaseModel): + site_url: str + email: str = Field(min_length=3) + api_token: str = "" + cloud_id: str | None = None + + @field_validator("site_url") + @classmethod + def validate_site_url(cls, value: str) -> str: + normalized = value.strip().rstrip("/") + parsed = urlparse(normalized) + if parsed.scheme != "https" or not parsed.hostname: + raise ValueError("site_url must be a valid HTTPS URL") + if parsed.path not in ("", "/") or parsed.query or parsed.fragment: + raise ValueError("site_url must not include a path, query, or fragment") + return normalized + + @field_validator("email") + @classmethod + def normalize_email(cls, value: str) -> str: + value = value.strip() + if "@" not in value: + raise ValueError("email must be a valid Atlassian account email") + return value + + +class JiraScope(BaseModel): + project_keys: list[str] = Field(default_factory=list) + created_after: date | None = None + updated_after: date | None = None + status_categories: list[ + Literal["new", "indeterminate", "done"] + ] = Field(default_factory=list) + jql_extra: str = "" + include_comments: bool = True + story_points_field: str | None = None + + @field_validator("project_keys") + @classmethod + def normalize_project_keys(cls, values: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for raw in values: + key = raw.strip().upper() + if not PROJECT_KEY_RE.fullmatch(key): + raise ValueError(f"invalid Jira project key: {raw!r}") + if key not in seen: + normalized.append(key) + seen.add(key) + return normalized + + @field_validator("status_categories") + @classmethod + def deduplicate_status_categories( + cls, + values: list[Literal["new", "indeterminate", "done"]], + ) -> list[Literal["new", "indeterminate", "done"]]: + return list(dict.fromkeys(values)) + + @field_validator("jql_extra") + @classmethod + def validate_jql_extra(cls, value: str) -> str: + value = value.strip() + if re.search(r"\border\s+by\b", value, flags=re.IGNORECASE): + raise ValueError("jql_extra must not contain ORDER BY") + return value + + @field_validator("story_points_field") + @classmethod + def validate_story_points_field(cls, value: str | None) -> str | None: + if value is None or not value.strip(): + return None + value = value.strip() + if not re.fullmatch(r"customfield_\d+", value): + raise ValueError("story_points_field must look like customfield_10016") + return value + + +class JiraSyncState(BaseModel): + overlap_seconds: int = Field(default=120, ge=0, le=3600) + checkpoint: datetime | None = None + migrating_legacy_comments: bool = False + last_tested_at: datetime | None = None + last_started_at: datetime | None = None + last_completed_at: datetime | None = None + last_error: str | None = None + last_issue_count: int = Field(default=0, ge=0) + + +class JiraDataSource(BaseModel): + id: str + type: Literal["jira_cloud"] = "jira_cloud" + enabled: bool = True + display_name: str = Field(min_length=1, max_length=100) + connection: JiraConnection + scope: JiraScope + sync: JiraSyncState = Field(default_factory=JiraSyncState) + + @field_validator("id") + @classmethod + def validate_id(cls, value: str) -> str: + value = value.strip() + if not SOURCE_ID_RE.fullmatch(value): + raise ValueError( + "id must be 1-64 characters using letters, numbers, '_' or '-'" + ) + return value + + @field_validator("display_name") + @classmethod + def normalize_display_name(cls, value: str) -> str: + return value.strip() + +class JiraSourceFile(BaseModel): + sources: list[JiraDataSource] = Field(default_factory=list) + + @model_validator(mode="after") + def unique_source_ids(self) -> "JiraSourceFile": + ids = [source.id for source in self.sources] + if len(ids) != len(set(ids)): + raise ValueError("data-source ids must be unique") + return self diff --git a/graphrag/app/connectors/jira/mapper.py b/graphrag/app/connectors/jira/mapper.py new file mode 100644 index 00000000..184f2614 --- /dev/null +++ b/graphrag/app/connectors/jira/mapper.py @@ -0,0 +1,814 @@ +"""Deterministic Jira payload to GraphRAG mapping.""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from dateutil import parser as date_parser + +from common.chunkers.structured import StructuredChunker +from common.config import get_graphrag_config + +from .adf import adf_to_markdown +from .config import JiraDataSource +from .schema import ( + JIRA_ASSIGNEE_EDGE, + JIRA_CHANGE_AUTHOR_EDGE, + JIRA_CHANGE_EDGE, + JIRA_COMMENT_AFTER_EDGE, + JIRA_COMMENT_AUTHOR_EDGE, + JIRA_COMMENT_ISSUE_EDGE, + JIRA_COMMENT_REPLY_EDGE, + JIRA_LINK_EDGE, + JIRA_PARENT_EDGE, + JIRA_PROJECT_EDGE, + JIRA_REPORTER_EDGE, +) + + +@dataclass(frozen=True) +class VertexRecord: + vertex_type: str + vertex_id: str + attributes: dict[str, Any] + + +@dataclass(frozen=True) +class EdgeRecord: + source_type: str + source_id: str + edge_type: str + target_type: str + target_id: str + attributes: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class MappedChunk: + chunk_id: str + index: int + text: str + + +@dataclass +class MappedComment: + comment_id: str + comment_vertex_id: str + issue_vertex_id: str + content_hash: str + chunks: list[MappedChunk] + + +@dataclass +class MappedFact: + """One short searchable fact for an issue. + + entities are extra CONTAINS_ENTITY targets besides the issue itself. + """ + + chunk: MappedChunk + entities: tuple[tuple[str, str], ...] = () + + +@dataclass +class MappedIssue: + issue_id: str + issue_vertex_id: str + updated: datetime | None + content_hash: str + vertices: list[VertexRecord] + edges: list[EdgeRecord] + facts: list[MappedFact] + legacy_document_id: str + change_vertex_ids: list[str] + comments: list[MappedComment] + + +def _datetime(value: Any) -> str | None: + if not value: + return None + try: + return date_parser.parse(str(value)).strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OverflowError): + return None + + +def _parsed_datetime(value: Any) -> datetime | None: + if not value: + return None + try: + return date_parser.parse(str(value)) + except (TypeError, ValueError, OverflowError): + return None + + +def _timestamp(value: Any) -> float: + parsed = _parsed_datetime(value) + return parsed.timestamp() if parsed is not None else 0.0 + + +def _display_name(user: dict[str, Any] | None) -> str: + if not user: + return "" + return str(user.get("displayName") or user.get("accountId") or "").strip() + + +def _comma_names(values: list[Any] | None) -> str: + names: list[str] = [] + for value in values or []: + if isinstance(value, dict): + name = value.get("name") + else: + name = value + if name is not None and str(name).strip(): + names.append(str(name).strip()) + return ", ".join(names) + + +_FENCED_BLOCK_RE = re.compile(r"```[^\n]*\n(.*?)```", re.DOTALL) +_LOG_LINE_RE = re.compile( + r"""(?ix) + ^\s*(?: + \[?\d{4}[-/]\d{2}[-/]\d{2}[T\s]\d{2}:\d{2}:\d{2} + |\[?(?:TRACE|DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|CRITICAL)\]?\b + |(?:Traceback\s+\(most\s+recent\s+call\s+last\)|Caused\s+by:) + |(?:at\s+[\w.$]+\([^)]*(?::\d+)?\)) + |(?:File\s+"[^"]+",\s+line\s+\d+) + |\{.*"(?:timestamp|time|level|severity|logger)"\s*: + ) + """ +) +_LOG_OMISSION = "[Log output omitted from search content.]" + + +def _is_log_heavy(lines: list[str]) -> bool: + non_empty = [line for line in lines if line.strip()] + if len(non_empty) < 5: + return False + matched = sum(bool(_LOG_LINE_RE.search(line)) for line in non_empty) + return matched >= 3 and matched / len(non_empty) >= 0.6 + + +def _filter_long_log_output(text: str, chunk_size: int) -> str: + """Remove log-dominated blocks only when a comment needs chunking.""" + if len(text) <= chunk_size: + return text + + def replace_fence(match: re.Match[str]) -> str: + body = match.group(1) + return _LOG_OMISSION if _is_log_heavy(body.splitlines()) else match.group(0) + + filtered = _FENCED_BLOCK_RE.sub(replace_fence, text) + lines = filtered.splitlines() + output: list[str] = [] + index = 0 + while index < len(lines): + end = index + while end < len(lines) and ( + not lines[end].strip() or _LOG_LINE_RE.search(lines[end]) + ): + end += 1 + block = lines[index:end] + if _is_log_heavy(block): + if not output or output[-1] != _LOG_OMISSION: + output.append(_LOG_OMISSION) + index = end + continue + output.append(lines[index]) + index += 1 + return "\n".join(output).strip() + + +class JiraIssueMapper: + def __init__( + self, + source: JiraDataSource, + graphname: str | None = None, + ): + self.source = source + chunker_config = get_graphrag_config(graphname).get( + "chunker_config", + {}, + ) + self.comment_chunker = StructuredChunker( + chunk_size=chunker_config.get("chunk_size", 0), + overlap_size=chunker_config.get("overlap_size", -1), + ) + + def _id(self, object_type: str, object_id: Any) -> str: + # cloud_id intentionally excluded — vertex IDs use only the portable + # object_type + object_id so they are stable across graph recreations + # and don't leak internal tenant identifiers. + return f"jira:{object_type}:{object_id}".lower() + + def _issue_id(self, issue_key: str) -> str: + # Use the human-readable ticket key so GenerateFunction can construct + # the vertex ID directly from what the user says (e.g. "GML-2191" + # → "jira:gml-2191:issue"). cloud_id is NOT included — ticket keys + # are unique within a graph's connected Jira project scope. + return f"jira:{issue_key}:issue".lower() + + def _user_vertex( + self, user: dict[str, Any] | None + ) -> VertexRecord | None: + if not user or not user.get("accountId"): + return None + account_id = str(user["accountId"]) + return VertexRecord( + "JiraUser", + self._id("user", account_id), + { + "account_id": account_id, + "display_name": _display_name(user), + }, + ) + + def map(self, issue: dict[str, Any]) -> MappedIssue: + issue_id = str(issue["id"]) + issue_key = str(issue.get("key") or issue_id) + fields = issue.get("fields") or {} + project = fields.get("project") or {} + project_id = str(project.get("id") or project.get("key") or "unknown") + issue_vertex_id = self._issue_id(issue_key) + project_vertex_id = self._id("project", project_id) + site_url = self.source.connection.site_url + issue_url = f"{site_url}/browse/{issue_key}" + + description = adf_to_markdown(fields.get("description")) + + status = fields.get("status") or {} + status_category = status.get("statusCategory") or {} + priority = fields.get("priority") or {} + resolution = fields.get("resolution") or {} + issue_type = fields.get("issuetype") or {} + story_points_field = self.source.scope.story_points_field + story_points = fields.get(story_points_field) if story_points_field else None + + issue_attrs = { + "issue_key": issue_key, + "summary": str(fields.get("summary") or ""), + "issue_type": str(issue_type.get("name") or ""), + "status": str(status.get("name") or ""), + "status_category": str(status_category.get("key") or ""), + "priority": str(priority.get("name") or ""), + "resolution": str(resolution.get("name") or ""), + "labels": _comma_names(fields.get("labels")), + "components": _comma_names(fields.get("components")), + "fix_versions": _comma_names(fields.get("fixVersions")), + "created": _datetime(fields.get("created")), + "updated": _datetime(fields.get("updated")), + "due": _datetime(fields.get("duedate")), + "url": issue_url, + } + if story_points is not None: + try: + issue_attrs["story_points"] = float(story_points) + except (TypeError, ValueError): + pass + issue_attrs = { + key: value for key, value in issue_attrs.items() if value not in (None, "") + } + + vertices: dict[tuple[str, str], VertexRecord] = {} + + def add_vertex(vertex: VertexRecord | None) -> None: + if not vertex: + return + key = (vertex.vertex_type, vertex.vertex_id) + existing = vertices.get(key) + if existing and existing.attributes.keys() - vertex.attributes.keys(): + return + vertices[key] = vertex + + add_vertex( + VertexRecord( + "JiraProject", + project_vertex_id, + { + "project_key": str(project.get("key") or ""), + "name": str(project.get("name") or ""), + "url": ( + f"{site_url}/jira/software/projects/{project.get('key')}" + if project.get("key") + else site_url + ), + }, + ) + ) + add_vertex(VertexRecord("JiraIssue", issue_vertex_id, issue_attrs)) + + edges = [ + EdgeRecord( + "JiraIssue", + issue_vertex_id, + JIRA_PROJECT_EDGE, + "JiraProject", + project_vertex_id, + ) + ] + + for field_name, edge_type in ( + ("assignee", JIRA_ASSIGNEE_EDGE), + ("reporter", JIRA_REPORTER_EDGE), + ): + user_vertex = self._user_vertex(fields.get(field_name)) + add_vertex(user_vertex) + if user_vertex: + edges.append( + EdgeRecord( + "JiraIssue", + issue_vertex_id, + edge_type, + "JiraUser", + user_vertex.vertex_id, + ) + ) + + mapped_comments: list[MappedComment] = [] + previous_comment_vertex_id: str | None = None + comments = ( + (fields.get("comment") or {}).get("comments") or [] + if self.source.scope.include_comments + else [] + ) + comments = sorted( + comments, + key=lambda comment: ( + _timestamp(comment.get("created")), + str(comment.get("id") or ""), + ), + ) + for comment in comments: + raw_comment_id = comment.get("id") + if raw_comment_id is None: + continue + + # Skip bot/automation comments — accountType "app" means a Jira + # automation rule, CI integration, or service-account bot. These + # produce high-volume noise (build status, deploy notifications, + # auto-transitions) with no useful search content. + author = comment.get("author") or {} + if author.get("accountType") == "app": + continue + + # Skip empty comments — nothing meaningful to store or search. + comment_body = adf_to_markdown(comment.get("body")) + if not comment_body or not comment_body.strip(): + continue + + comment_id = str(raw_comment_id) + comment_vertex_id = self._id("comment", comment_id) + author_vertex = self._user_vertex(author) + add_vertex(author_vertex) + visibility = comment.get("visibility") + if not isinstance(visibility, dict): + visibility = {} + visibility_text = ":".join( + str(value) + for value in ( + visibility.get("type"), + visibility.get("value"), + ) + if value + ) + comment_body = _filter_long_log_output( + comment_body, + self.comment_chunker.chunk_size, + ) + comment_text = self._comment_document_text( + issue_key=issue_key, + comment=comment, + body=comment_body, + ) + chunks = [ + self._lead_with_issue(issue_key, str(chunk)) + for chunk in self.comment_chunker.chunk(comment_text) + ] + chunks = [chunk for chunk in chunks if chunk] + comment_hash = hashlib.sha256( + ( + "direct-comment-chunks\0" + + "\0".join(chunks) + ).encode("utf-8") + ).hexdigest() + comment_attrs = { + "comment_id": comment_id, + "body": comment_body, + "created": _datetime(comment.get("created")), + "updated": _datetime(comment.get("updated")), + "visibility": visibility_text, + "is_public": comment.get("jsdPublic"), + "ontology_class": "Event", + "content_hash": comment_hash, + } + add_vertex( + VertexRecord( + "JiraComment", + comment_vertex_id, + { + key: value + for key, value in comment_attrs.items() + if value not in (None, "") + }, + ) + ) + edges.append( + EdgeRecord( + "JiraComment", + comment_vertex_id, + JIRA_COMMENT_ISSUE_EDGE, + "JiraIssue", + issue_vertex_id, + ) + ) + if author_vertex: + edges.append( + EdgeRecord( + "JiraComment", + comment_vertex_id, + JIRA_COMMENT_AUTHOR_EDGE, + "JiraUser", + author_vertex.vertex_id, + ) + ) + parent_comment = comment.get("parent") + if not isinstance(parent_comment, dict): + parent_comment = {} + parent_comment_id = comment.get("parentId") or parent_comment.get("id") + if parent_comment_id: + parent_comment_vertex_id = self._id( + "comment", + parent_comment_id, + ) + add_vertex( + VertexRecord( + "JiraComment", + parent_comment_vertex_id, + { + "comment_id": str(parent_comment_id), + "ontology_class": "Event", + }, + ) + ) + edges.append( + EdgeRecord( + "JiraComment", + comment_vertex_id, + JIRA_COMMENT_REPLY_EDGE, + "JiraComment", + parent_comment_vertex_id, + ) + ) + if previous_comment_vertex_id: + edges.append( + EdgeRecord( + "JiraComment", + comment_vertex_id, + JIRA_COMMENT_AFTER_EDGE, + "JiraComment", + previous_comment_vertex_id, + ) + ) + mapped_comments.append( + MappedComment( + comment_id=comment_id, + comment_vertex_id=comment_vertex_id, + issue_vertex_id=issue_vertex_id, + content_hash=comment_hash, + chunks=[ + MappedChunk( + chunk_id=( + f"{comment_vertex_id}:chunk:{index}:" + f"{hashlib.sha256(text.encode('utf-8')).hexdigest()[:12]}" + ), + index=index, + text=text, + ) + for index, text in enumerate(chunks) + ], + ) + ) + previous_comment_vertex_id = comment_vertex_id + + parent = fields.get("parent") or {} + if parent.get("id"): + parent_key = str(parent.get("key") or parent["id"]) + parent_id = self._issue_id(parent_key) + parent_fields = parent.get("fields") or {} + add_vertex( + VertexRecord( + "JiraIssue", + parent_id, + { + "issue_key": str(parent.get("key") or ""), + "summary": str(parent_fields.get("summary") or ""), + "url": ( + f"{site_url}/browse/{parent.get('key')}" + if parent.get("key") + else site_url + ), + }, + ) + ) + edges.append( + EdgeRecord( + "JiraIssue", + issue_vertex_id, + JIRA_PARENT_EDGE, + "JiraIssue", + parent_id, + ) + ) + + for link in fields.get("issuelinks") or []: + link_type = link.get("type") or {} + target = link.get("outwardIssue") + relation = link_type.get("outward") + if not target: + target = link.get("inwardIssue") + relation = link_type.get("inward") + if not target or not target.get("id"): + continue + target_key = str(target.get("key") or target["id"]) + target_id = self._issue_id(target_key) + target_fields = target.get("fields") or {} + add_vertex( + VertexRecord( + "JiraIssue", + target_id, + { + "issue_key": target_key, + "summary": str(target_fields.get("summary") or ""), + "url": f"{site_url}/browse/{target_key}" if target_key else site_url, + }, + ) + ) + edges.append( + EdgeRecord( + "JiraIssue", + issue_vertex_id, + JIRA_LINK_EDGE, + "JiraIssue", + target_id, + {"link_type": str(relation or link_type.get("name") or "relates to")}, + ) + ) + + change_vertices, change_edges, change_parts = self._map_changes( + issue_id=issue_id, + issue_key=issue_key, + changelog=issue.get("changelog") or {}, + ) + for vertex in change_vertices: + add_vertex(vertex) + edges.extend(change_edges) + facts = self._facts( + issue_vertex_id, + [ + ( + self._record_text( + issue_key=issue_key, + issue_url=issue_url, + fields=fields, + project=project, + ), + (), + ), + *self._description_parts(issue_key, description), + *change_parts, + ], + ) + content_hash = hashlib.sha256( + "\0".join(fact.chunk.text for fact in facts).encode("utf-8") + ).hexdigest() + issue_vertex = vertices[("JiraIssue", issue_vertex_id)] + issue_attributes = dict(issue_vertex.attributes) + issue_attributes["content_hash"] = content_hash + vertices[("JiraIssue", issue_vertex_id)] = VertexRecord( + "JiraIssue", + issue_vertex_id, + issue_attributes, + ) + + return MappedIssue( + issue_id=issue_id, + issue_vertex_id=issue_vertex_id, + updated=_parsed_datetime(fields.get("updated")), + content_hash=content_hash, + vertices=list(vertices.values()), + edges=edges, + facts=facts, + legacy_document_id=self._id("issue-doc", issue_id), + change_vertex_ids=[vertex.vertex_id for vertex in change_vertices], + comments=mapped_comments, + ) + + def _comment_document_text( + self, + *, + issue_key: str, + comment: dict[str, Any], + body: str, + ) -> str: + author = _display_name(comment.get("author")) or "Unknown user" + created = _datetime(comment.get("created")) or "" + when = f" at {created}" if created else "" + return ( + f"Issue: {issue_key}\n" + f"Comment by {author}{when}:\n" + f"{body.strip()}" + ) + + def _lead_with_issue(self, issue_key: str, text: str) -> str: + cleaned = text.strip() + prefix = f"Issue: {issue_key}" + if not cleaned or cleaned.startswith(prefix): + return cleaned + return f"{prefix}\n{cleaned}" + + def _record_text( + self, + *, + issue_key: str, + issue_url: str, + fields: dict[str, Any], + project: dict[str, Any], + ) -> str: + status = fields.get("status") or {} + status_category = status.get("statusCategory") or {} + issue_type = fields.get("issuetype") or {} + priority = fields.get("priority") or {} + resolution = fields.get("resolution") or {} + project_line = ( + f"{project.get('key') or ''} {project.get('name') or ''}".strip() + ) + status_name = str(status.get("name") or "") + category = str(status_category.get("key") or "") + status_line = ( + f"{status_name} ({category})".strip() + if category + else status_name + ) + lines = [f"Issue: {issue_key}"] + for label, value in ( + ("Summary", fields.get("summary")), + ("URL", issue_url), + ("Project", project_line), + ("Type", issue_type.get("name")), + ("Status", status_line), + ("Priority", priority.get("name")), + ("Resolution", resolution.get("name")), + ("Assignee", _display_name(fields.get("assignee")) or "Unassigned"), + ("Reporter", _display_name(fields.get("reporter"))), + ("Labels", _comma_names(fields.get("labels"))), + ("Components", _comma_names(fields.get("components"))), + ("Fix versions", _comma_names(fields.get("fixVersions"))), + ("Due", _datetime(fields.get("duedate"))), + ("Updated", _datetime(fields.get("updated"))), + ): + text = str(value or "").strip() + if text: + lines.append(f"{label}: {text}") + return "\n".join(lines) + + def _description_parts( + self, + issue_key: str, + description: str, + ) -> list[tuple[str, tuple[tuple[str, str], ...]]]: + if not description or not description.strip(): + return [] + source_text = f"Issue: {issue_key}\nDescription:\n{description.strip()}" + return [ + (self._lead_with_issue(issue_key, str(piece)), ()) + for piece in self.comment_chunker.chunk(source_text) + if str(piece).strip() + ] + + _CHANGELOG_FIELDS = {"status", "assignee", "priority", "resolution"} + + def _map_changes( + self, + *, + issue_id: str, + issue_key: str, + changelog: dict[str, Any], + ) -> tuple[ + list[VertexRecord], + list[EdgeRecord], + list[tuple[str, tuple[tuple[str, str], ...]]], + ]: + """One JiraChange event and one fact per tracked changelog item.""" + histories = sorted( + changelog.get("histories") or [], + key=lambda history: history.get("created") or "", + ) + vertices: list[VertexRecord] = [] + edges: list[EdgeRecord] = [] + parts: list[tuple[str, tuple[tuple[str, str], ...]]] = [] + change_index = 0 + issue_vertex_id = self._issue_id(issue_key) + for history in histories: + author = history.get("author") or {} + author_name = _display_name(author) or "Unknown" + created = _datetime(history.get("created")) + history_id = str(history.get("id") or "") + for item in history.get("items") or []: + field = str(item.get("field") or "").lower() + if field not in self._CHANGELOG_FIELDS: + continue + from_val = str(item.get("fromString") or "").strip() + to_val = str(item.get("toString") or "").strip() + if not to_val: + continue + change_index += 1 + change_key = ( + f"{issue_id}:{history_id or change_index}:" + f"{field}:{change_index}" + ) + change_vertex_id = self._id("change", change_key) + attributes = { + "change_id": change_key, + "field": field, + "from_value": from_val, + "to_value": to_val, + "created": created, + "ontology_class": "Event", + } + vertices.append( + VertexRecord( + "JiraChange", + change_vertex_id, + { + key: value + for key, value in attributes.items() + if value not in (None, "") + }, + ) + ) + edges.append( + EdgeRecord( + "JiraIssue", + issue_vertex_id, + JIRA_CHANGE_EDGE, + "JiraChange", + change_vertex_id, + ) + ) + author_vertex = self._user_vertex(author) + if author_vertex: + vertices.append(author_vertex) + edges.append( + EdgeRecord( + "JiraChange", + change_vertex_id, + JIRA_CHANGE_AUTHOR_EDGE, + "JiraUser", + author_vertex.vertex_id, + ) + ) + when = f"{created}: " if created else "" + if from_val: + sentence = ( + f"{when}{author_name} changed {field} from " + f"\"{from_val}\" to \"{to_val}\"" + ) + else: + sentence = ( + f"{when}{author_name} set {field} to \"{to_val}\"" + ) + parts.append( + ( + f"Issue: {issue_key}\n{sentence}", + (("JiraChange", change_vertex_id),), + ) + ) + return vertices, edges, parts + + def _facts( + self, + issue_vertex_id: str, + parts: list[tuple[str, tuple[tuple[str, str], ...]]], + ) -> list[MappedFact]: + facts: list[MappedFact] = [] + for text, entities in parts: + cleaned = text.strip() + if not cleaned: + continue + digest = hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:12] + index = len(facts) + facts.append( + MappedFact( + chunk=MappedChunk( + chunk_id=f"{issue_vertex_id}:fact:{index}:{digest}", + index=index, + text=cleaned + "\n", + ), + entities=entities, + ) + ) + return facts diff --git a/graphrag/app/connectors/jira/schema.py b/graphrag/app/connectors/jira/schema.py new file mode 100644 index 00000000..007b9344 --- /dev/null +++ b/graphrag/app/connectors/jira/schema.py @@ -0,0 +1,313 @@ +"""Predefined Jira domain schema.""" + +from __future__ import annotations + +from typing import Any + +from common.db.schema_utils import SchemaProposal, read_existing_schema + + +JIRA_PROJECT_EDGE = "JIRA_BELONGS_TO" +JIRA_ASSIGNEE_EDGE = "JIRA_ASSIGNED_TO" +JIRA_REPORTER_EDGE = "JIRA_REPORTED_BY" +JIRA_PARENT_EDGE = "JIRA_HAS_PARENT" +JIRA_LINK_EDGE = "JIRA_LINKS_TO" +JIRA_COMMENT_ISSUE_EDGE = "JIRA_COMMENT_ON" +JIRA_COMMENT_AUTHOR_EDGE = "JIRA_COMMENTED_BY" +JIRA_COMMENT_REPLY_EDGE = "JIRA_COMMENT_REPLIES_TO" +JIRA_COMMENT_AFTER_EDGE = "JIRA_COMMENT_AFTER" +JIRA_CHANGE_EDGE = "JIRA_HAS_CHANGE" +JIRA_CHANGE_AUTHOR_EDGE = "JIRA_CHANGE_BY" + + +def _attribute_types(metadata: dict[str, Any]) -> dict[str, str]: + attributes: dict[str, str] = {} + primary_id = (metadata.get("PrimaryId") or {}).get("AttributeName") + for attribute in metadata.get("Attributes") or []: + name = attribute.get("AttributeName") + if not name or name == primary_id: + continue + attribute_type = ( + (attribute.get("AttributeType") or {}).get("Name") or "STRING" + ) + attributes[str(name).casefold()] = str(attribute_type).upper() + return attributes + + +def _edge_pairs(metadata: dict[str, Any]) -> set[tuple[str, str]]: + pairs: set[tuple[str, str]] = set() + source = metadata.get("FromVertexTypeName") + target = metadata.get("ToVertexTypeName") + if source and target and source != "*" and target != "*": + pairs.add((str(source).casefold(), str(target).casefold())) + for pair in metadata.get("EdgePairs") or []: + source = pair.get("From") + target = pair.get("To") + if source and target: + pairs.add((str(source).casefold(), str(target).casefold())) + return pairs + + +def jira_schema_proposal() -> SchemaProposal: + """Return the bounded Jira schema consumed by GraphRAG.""" + proposal = SchemaProposal(domain_label="Jira Cloud") + proposal.add_vertex( + "JiraProject", + ( + "A Jira project, modeled as a POLE+O Object subtype and work " + "container. project_key is the short key, such as PAY." + ), + [ + ("project_key", "STRING"), + ("name", "STRING"), + ("url", "STRING"), + ], + ) + proposal.add_vertex( + "JiraIssue", + ( + "A Jira work item modeled as a POLE+O Object subtype. Filter using " + "issue_key, status, status_category (new, indeterminate, or done), " + "priority, issue_type, resolution, labels, components, " + "fix_versions, created, updated, or due. Status, assignee, " + "priority, and resolution changes are JiraChange events." + ), + [ + ("issue_key", "STRING"), + ("summary", "STRING"), + ("issue_type", "STRING"), + ("status", "STRING"), + ("status_category", "STRING"), + ("priority", "STRING"), + ("resolution", "STRING"), + ("labels", "STRING"), + ("components", "STRING"), + ("fix_versions", "STRING"), + ("created", "DATETIME"), + ("updated", "DATETIME"), + ("due", "DATETIME"), + ("url", "STRING"), + ("story_points", "DOUBLE"), + ("content_hash", "STRING"), + ], + ) + proposal.add_vertex( + "JiraUser", + ( + "An Atlassian account modeled as a POLE+O Person subtype. " + "account_id is the stable identity and display_name is the " + "human-readable name." + ), + [ + ("account_id", "STRING"), + ("display_name", "STRING"), + ], + ) + proposal.add_vertex( + "JiraComment", + ( + "A Jira comment modeled as a POLE+O Event subtype. body is the " + "comment text. Searchable text is also stored as document chunks. " + "Use graph edges for issue, author, ordering, and explicit replies." + ), + [ + ("comment_id", "STRING"), + ("body", "STRING"), + ("created", "DATETIME"), + ("updated", "DATETIME"), + ("visibility", "STRING"), + ("is_public", "BOOL"), + ("ontology_class", "STRING"), + ("content_hash", "STRING"), + ], + ) + proposal.add_vertex( + "JiraChange", + ( + "One status, assignee, priority, or resolution change, modeled as " + "a POLE+O Event subtype. field is the changed field, from_value " + "and to_value are the previous and new values, and created is " + "when the change happened. The author is JIRA_CHANGE_BY." + ), + [ + ("change_id", "STRING"), + ("field", "STRING"), + ("from_value", "STRING"), + ("to_value", "STRING"), + ("created", "DATETIME"), + ("ontology_class", "STRING"), + ], + ) + + proposal.add_edge_pair( + JIRA_PROJECT_EDGE, + "JiraIssue", + "JiraProject", + "The Jira issue's current project.", + ) + proposal.add_edge_pair( + JIRA_ASSIGNEE_EDGE, + "JiraIssue", + "JiraUser", + "The Jira issue's current assignee.", + ) + proposal.add_edge_pair( + JIRA_REPORTER_EDGE, + "JiraIssue", + "JiraUser", + "The Atlassian account that reported the Jira issue.", + ) + proposal.add_edge_pair( + JIRA_PARENT_EDGE, + "JiraIssue", + "JiraIssue", + "The parent Jira issue, including epic and subtask parents.", + ) + proposal.add_edge_pair( + JIRA_LINK_EDGE, + "JiraIssue", + "JiraIssue", + ( + "A directed Jira issue link. link_type is the phrase from the " + "source issue toward the target, such as blocks or is blocked by." + ), + [("link_type", "STRING")], + ) + proposal.add_edge_pair( + JIRA_COMMENT_ISSUE_EDGE, + "JiraComment", + "JiraIssue", + "The Jira issue on which the comment was posted.", + ) + proposal.add_edge_pair( + JIRA_COMMENT_AUTHOR_EDGE, + "JiraComment", + "JiraUser", + "The Atlassian account that authored the comment.", + ) + proposal.add_edge_pair( + JIRA_COMMENT_REPLY_EDGE, + "JiraComment", + "JiraComment", + "An explicit source-provided parent comment; never inferred.", + ) + proposal.add_edge_pair( + JIRA_COMMENT_AFTER_EDGE, + "JiraComment", + "JiraComment", + "Chronological order between adjacent comments on one issue.", + ) + proposal.add_edge_pair( + JIRA_CHANGE_EDGE, + "JiraIssue", + "JiraChange", + "A status, assignee, priority, or resolution change on this issue.", + ) + proposal.add_edge_pair( + JIRA_CHANGE_AUTHOR_EDGE, + "JiraChange", + "JiraUser", + "The Atlassian account that made the change.", + ) + return proposal + + +def jira_schema_status(conn) -> dict[str, Any]: + """Inspect whether the one current Jira schema is fully installed.""" + proposal = jira_schema_proposal() + existing = read_existing_schema(conn) + conflicts: list[str] = [] + missing_vertices: list[str] = [] + missing_edges: list[str] = [] + missing_pairs: list[str] = [] + + required_core = ("Document", "DocumentChunk", "Content") + missing_core = [name for name in required_core if not existing.has_vertex(name)] + required_core_edges = ("CONTAINS_ENTITY", "HAS_CHILD", "HAS_CONTENT") + missing_core_edges = [ + name for name in required_core_edges if not existing.has_edge(name) + ] + if missing_core or missing_core_edges: + return { + "status": "not_initialized", + "missing": { + "core_vertices": missing_core, + "core_edges": missing_core_edges, + }, + "conflicts": [], + } + + for vertex in proposal.vertices: + if not existing.has_vertex(vertex.name): + missing_vertices.append(vertex.name) + continue + metadata = conn.getVertexType(vertex.name) or {} + actual = _attribute_types(metadata) + for attribute in vertex.attributes: + actual_type = actual.get(attribute.name.casefold()) + if actual_type is None: + conflicts.append( + f"{vertex.name}.{attribute.name} is missing" + ) + elif actual_type != attribute.type.upper(): + conflicts.append( + f"{vertex.name}.{attribute.name} must be " + f"{attribute.type.upper()}, found {actual_type}" + ) + + for edge in proposal.edges: + if not existing.has_edge(edge.name): + missing_edges.append(edge.name) + continue + metadata = conn.getEdgeType(edge.name) or {} + if bool(metadata.get("IsDirected")) != edge.directed: + expected = "directed" if edge.directed else "undirected" + conflicts.append(f"{edge.name} must be {expected}") + actual_attributes = _attribute_types(metadata) + for attribute in edge.attributes: + actual_type = actual_attributes.get(attribute.name.casefold()) + if actual_type is None: + conflicts.append(f"{edge.name}.{attribute.name} is missing") + elif actual_type != attribute.type.upper(): + conflicts.append( + f"{edge.name}.{attribute.name} must be " + f"{attribute.type.upper()}, found {actual_type}" + ) + actual_pairs = _edge_pairs(metadata) + for source, target in edge.pairs: + if (source.casefold(), target.casefold()) not in actual_pairs: + missing_pairs.append(f"{edge.name}: {source} -> {target}") + + required_links = ( + ("CONTAINS_ENTITY", "Document", "JiraIssue"), + ("CONTAINS_ENTITY", "DocumentChunk", "JiraIssue"), + ("CONTAINS_ENTITY", "Document", "JiraComment"), + ("CONTAINS_ENTITY", "DocumentChunk", "JiraComment"), + ("CONTAINS_ENTITY", "Document", "JiraChange"), + ("CONTAINS_ENTITY", "DocumentChunk", "JiraChange"), + ) + for edge, source, target in required_links: + if not existing.has_edge_pair(edge, source, target): + missing_pairs.append(f"{edge}: {source} -> {target}") + + missing = { + "vertices": missing_vertices, + "edges": missing_edges, + "pairs": missing_pairs, + } + if conflicts: + status = "conflict" + elif not any(missing.values()): + status = "installed" + elif any( + existing.has_vertex(vertex.name) for vertex in proposal.vertices + ): + status = "incomplete" + else: + status = "not_installed" + + return { + "status": status, + "missing": missing, + "conflicts": conflicts, + } diff --git a/graphrag/app/connectors/jira/state.py b/graphrag/app/connectors/jira/state.py new file mode 100644 index 00000000..e99e1a4e --- /dev/null +++ b/graphrag/app/connectors/jira/state.py @@ -0,0 +1,150 @@ +"""Atomic persistence and secret redaction for Jira data sources.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from urllib.parse import urlparse + +from common.config import _config_file_lock, validate_graphname + +from .config import JiraDataSource, JiraSourceFile, JiraSyncState + + +MASKED_SECRET = "********" +NON_TENANT_ATLASSIAN_HOSTS = { + "api.atlassian.com", + "atlassian.net", + "graphql.atlassian.net", + "id.atlassian.com", +} + + +class JiraSourceStore: + def __init__(self, config_root: str = "configs/graph_configs"): + self.config_root = Path(config_root) + + def path(self, graphname: str) -> Path: + validate_graphname(graphname) + return self.config_root / graphname / "data_sources.json" + + def load(self, graphname: str) -> JiraSourceFile: + path = self.path(graphname) + if not path.exists(): + return JiraSourceFile() + with _config_file_lock: + with path.open("r", encoding="utf-8") as stream: + payload = json.load(stream) + return JiraSourceFile.model_validate(payload) + + def save(self, graphname: str, config: JiraSourceFile) -> None: + path = self.path(graphname) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") + payload = config.model_dump(mode="json") + for source in payload["sources"]: + if not source["connection"].get("api_token"): + source["connection"].pop("api_token", None) + with _config_file_lock: + try: + with tmp.open("w", encoding="utf-8") as stream: + json.dump(payload, stream, indent=2) + stream.flush() + os.fsync(stream.fileno()) + os.replace(tmp, path) + finally: + if tmp.exists(): + tmp.unlink() + + def list(self, graphname: str, *, redact: bool = True) -> list[dict]: + sources = self.load(graphname).sources + return [self.redact(source) if redact else source.model_dump(mode="json") for source in sources] + + def get(self, graphname: str, source_id: str) -> JiraDataSource: + for source in self.load(graphname).sources: + if source.id == source_id: + return source + raise KeyError(source_id) + + def upsert(self, graphname: str, submitted: JiraDataSource) -> JiraDataSource: + hostname = urlparse(submitted.connection.site_url).hostname + if hostname and hostname.lower() in NON_TENANT_ATLASSIAN_HOSTS: + raise ValueError( + "site_url must be your Jira tenant URL, for example " + "https://your-company.atlassian.net" + ) + config = self.load(graphname) + sources = list(config.sources) + existing = next((s for s in sources if s.id == submitted.id), None) + overlap_seconds = submitted.sync.overlap_seconds + submitted.connection.api_token = ( + existing.connection.api_token + if existing is not None + and existing.connection.api_token != MASKED_SECRET + else "" + ) + if existing is not None: + credentials_changed = ( + submitted.connection.email != existing.connection.email + ) + if submitted.connection.site_url != existing.connection.site_url: + unused_draft = ( + existing.sync.last_tested_at is None + and existing.sync.checkpoint is None + and existing.sync.last_issue_count == 0 + ) + if not unused_draft: + raise ValueError( + "site_url cannot be changed after a source has been used; " + "create a new source so existing Jira data can be reconciled" + ) + credentials_changed = True + else: + pass + submitted.sync = existing.sync.model_copy(deep=True) + submitted.sync.overlap_seconds = overlap_seconds + if credentials_changed: + submitted.sync.last_tested_at = None + if submitted.scope != existing.scope: + submitted.sync.checkpoint = None + else: + submitted.sync = JiraSyncState(overlap_seconds=overlap_seconds) + sources = [source for source in sources if source.id != submitted.id] + sources.append(submitted) + sources.sort(key=lambda source: source.id) + config.sources = sources + self.save(graphname, config) + return submitted + + def delete(self, graphname: str, source_id: str) -> None: + config = self.load(graphname) + remaining = [source for source in config.sources if source.id != source_id] + if len(remaining) == len(config.sources): + raise KeyError(source_id) + config.sources = remaining + self.save(graphname, config) + + def update_runtime_state( + self, + graphname: str, + source: JiraDataSource, + ) -> None: + """Persist connector-owned sync fields without losing edits.""" + config = self.load(graphname) + sources = list(config.sources) + for index, stored in enumerate(sources): + if stored.id != source.id: + continue + stored.sync = source.sync + sources[index] = stored + config.sources = sources + self.save(graphname, config) + return + raise KeyError(source.id) + + @staticmethod + def redact(source: JiraDataSource) -> dict: + payload = source.model_dump(mode="json") + payload["connection"]["api_token"] = "" + return payload diff --git a/graphrag/app/connectors/jira/sync.py b/graphrag/app/connectors/jira/sync.py new file mode 100644 index 00000000..e26b3004 --- /dev/null +++ b/graphrag/app/connectors/jira/sync.py @@ -0,0 +1,1173 @@ +"""Jira Cloud synchronization into the existing GraphRAG pipeline.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import queue +import threading +import time +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any, Iterator + +from common.config import get_embedding_store +from common.db.health import embedding_coverage + +from .client import JiraCloudClient +from .config import JiraDataSource +from .mapper import ( + EdgeRecord, + JiraIssueMapper, + MappedChunk, + MappedComment, + MappedIssue, + VertexRecord, +) +from .schema import ( + JIRA_ASSIGNEE_EDGE, + JIRA_CHANGE_AUTHOR_EDGE, + JIRA_CHANGE_EDGE, + JIRA_COMMENT_AFTER_EDGE, + JIRA_COMMENT_AUTHOR_EDGE, + JIRA_COMMENT_ISSUE_EDGE, + JIRA_COMMENT_REPLY_EDGE, + JIRA_LINK_EDGE, + JIRA_PARENT_EDGE, + JIRA_PROJECT_EDGE, + JIRA_REPORTER_EDGE, + jira_schema_status, +) +from .state import JiraSourceStore + +logger = logging.getLogger(__name__) + +CURRENT_STATE_EDGES = ( + JIRA_PROJECT_EDGE, + JIRA_ASSIGNEE_EDGE, + JIRA_REPORTER_EDGE, + JIRA_PARENT_EDGE, + JIRA_LINK_EDGE, + JIRA_CHANGE_EDGE, +) +COMMENT_STATE_EDGES = ( + JIRA_COMMENT_ISSUE_EDGE, + JIRA_COMMENT_AUTHOR_EDGE, + JIRA_COMMENT_REPLY_EDGE, + JIRA_COMMENT_AFTER_EDGE, +) +UPSERT_BATCH_SIZE = 500 +EMBEDDING_BATCH_SIZE = 32 +# Flush accumulated documents and comment-chunks to TigerGraph every this +# many Jira pages. A higher value means fewer supportai.ingest() round-trips +# (the dominant overhead for large initial loads) at the cost of a larger +# in-memory accumulation. 10 pages × 100 issues = 1 000 issues per flush. +DOC_LOAD_BATCH_PAGES = 10 + + +def _attrs(attributes: dict[str, Any]) -> dict[str, dict[str, Any]]: + return { + key: {"value": value} + for key, value in attributes.items() + if value is not None + } + + +def _upsert_payload( + vertices: list[VertexRecord], + edges: list[EdgeRecord], +) -> dict[str, Any]: + payload: dict[str, Any] = { + "vertices": defaultdict(dict), + "edges": defaultdict( + lambda: defaultdict( + lambda: defaultdict(lambda: defaultdict(dict)) + ) + ), + } + for vertex in vertices: + payload["vertices"][vertex.vertex_type][vertex.vertex_id] = _attrs( + vertex.attributes + ) + for edge in edges: + payload["edges"][edge.source_type][edge.source_id][edge.edge_type][ + edge.target_type + ][edge.target_id] = _attrs(edge.attributes) + return payload + + +class JiraSyncService: + def __init__( + self, + graphname: str, + source: JiraDataSource, + conn, + *, + store: JiraSourceStore | None = None, + client: JiraCloudClient | None = None, + ): + self.graphname = graphname + self.source = source + self.conn = conn + self.store = store or JiraSourceStore() + self.client = client or JiraCloudClient(source) + self._owns_client = client is None + + def run(self) -> dict[str, Any]: + if not self.source.enabled: + raise ValueError("Cannot sync a disabled data source") + + started = datetime.now(timezone.utc) + self.source.sync.last_started_at = started + self.source.sync.last_error = None + self.store.update_runtime_state(self.graphname, self.source) + + try: + schema_result = jira_schema_status(self.conn) + if schema_result["status"] != "installed": + raise RuntimeError( + "Jira schema is not installed for this graph. " + "Install it from Data Sources before synchronization." + ) + self._prepare_legacy_comment_migration() + + mapper = JiraIssueMapper( + self.source, + graphname=self.graphname, + ) + existing_hashes = self._all_existing_hashes() + # If the graph has no JiraIssue vertices but a checkpoint exists, + # the graph was likely cleared or recreated. Reset the checkpoint + # so the next iteration performs a full sync instead of a no-op. + if not existing_hashes and self.source.sync.checkpoint is not None: + logger.warning( + "Graph appears empty but checkpoint is set — resetting " + "checkpoint for graph=%s source=%s to trigger a full sync", + self.graphname, + self.source.id, + ) + self.source.sync.checkpoint = None + self.store.update_runtime_state(self.graphname, self.source) + existing_comment_hashes = self._all_existing_comment_hashes() + issues_upserted = 0 + documents_loaded = 0 + comments_deleted = 0 + + # Pending batches — accumulated across DOC_LOAD_BATCH_PAGES pages + # before being flushed to TigerGraph in one supportai.ingest call. + pending_docs: list[MappedIssue] = [] + pending_comments: list[MappedComment] = [] + pages_since_flush = 0 + # Track the highest "updated" timestamp seen so far. We only + # advance the checkpoint AFTER a successful batch flush so that a + # crash before the flush leaves the checkpoint behind the lost + # pages, ensuring they are re-fetched on the next sync run. + pending_checkpoint = self.source.sync.checkpoint + + # ── Recovery phase ──────────────────────────────────────────────── + # Re-embed ingested issues whose content hash was cleared before + # embedding finished. This does not change the checkpoint and does + # not replace the project scan below. + recovery_keys = self._find_recovery_issue_keys() + if recovery_keys: + recovery_docs: list[MappedIssue] = [] + recovery_comments: list[MappedComment] = [] + for issues in self.client.iter_issues_by_keys(recovery_keys): + mapped = [mapper.map(issue) for issue in issues] + changed_docs, changed_comms, deleted = self._upsert_issue_records( + mapped, existing_hashes, existing_comment_hashes + ) + recovery_docs.extend(changed_docs) + recovery_comments.extend(changed_comms) + documents_loaded += len(changed_docs) + comments_deleted += deleted + issues_upserted += len(mapped) + + if recovery_docs or recovery_comments: + self._finalize_batch( + recovery_docs, + recovery_comments, + existing_hashes, + existing_comment_hashes, + ) + # Do not move the checkpoint here. A cleared checkpoint means a + # new project must be scanned from the start. Link stubs are + # not failed embeddings, and their update times must not become + # the checkpoint for that scan. + logger.info( + "Recovery complete: %d doc(s), %d comment(s) re-embedded " + "for graph=%s source=%s. The project scan continues from " + "the saved checkpoint.", + len(recovery_docs), len(recovery_comments), + self.graphname, self.source.id, + ) + # ── End recovery phase ──────────────────────────────────────────── + + for issues in self._iter_pages_pipelined(): + mapped = [mapper.map(issue) for issue in issues] + changed_docs, changed_comms, deleted_count = ( + self._upsert_issue_records( + mapped, + existing_hashes, + existing_comment_hashes, + ) + ) + pending_docs.extend(changed_docs) + pending_comments.extend(changed_comms) + documents_loaded += len(changed_docs) + comments_deleted += deleted_count + issues_upserted += len(mapped) + pages_since_flush += 1 + + page_max = max( + (item.updated for item in mapped if item.updated is not None), + default=None, + ) + if page_max is not None and ( + pending_checkpoint is None or page_max > pending_checkpoint + ): + pending_checkpoint = page_max + + if pages_since_flush >= DOC_LOAD_BATCH_PAGES: + self._finalize_batch( + pending_docs, + pending_comments, + existing_hashes, + existing_comment_hashes, + ) + pending_docs = [] + pending_comments = [] + pages_since_flush = 0 + if pending_checkpoint != self.source.sync.checkpoint: + self.source.sync.checkpoint = pending_checkpoint + self.store.update_runtime_state( + self.graphname, self.source + ) + + # Final flush for any pages that did not fill a complete batch. + if pending_docs or pending_comments: + self._finalize_batch( + pending_docs, + pending_comments, + existing_hashes, + existing_comment_hashes, + ) + if pending_checkpoint != self.source.sync.checkpoint: + self.source.sync.checkpoint = pending_checkpoint + self.store.update_runtime_state(self.graphname, self.source) + + chunk_coverage = embedding_coverage( + self.conn, + "DocumentChunk", + ) + logger.info( + "embedding_coverage graph=%s: %s", + self.graphname, + chunk_coverage, + ) + missing_chunk_embeddings = ( + int(chunk_coverage["missing"]) + if chunk_coverage is not None + else 0 + ) + + # Recovery check: if this sync loaded nothing new but there are + # DocumentChunks still at epoch_processed=0, a previous ECC rebuild + # must have failed partway through. Trigger a fresh rebuild so + # those chunks get embedded without the user having to intervene. + if not documents_loaded and not comments_deleted and not missing_chunk_embeddings: + if self._has_unprocessed_chunks(): + logger.info( + "Detected unprocessed DocumentChunks from a prior failed " + "ECC rebuild for graph=%s source=%s — triggering recovery rebuild", + self.graphname, + self.source.id, + ) + missing_chunk_embeddings = 1 + self.source.sync.last_completed_at = datetime.now(timezone.utc) + self.source.sync.last_issue_count = issues_upserted + self.source.sync.last_error = None + self.source.sync.migrating_legacy_comments = False + self.store.update_runtime_state(self.graphname, self.source) + return { + "status": "completed", + "issues_upserted": issues_upserted, + "issues_deleted": 0, + "comments_deleted": comments_deleted, + "documents_loaded": documents_loaded, + "checkpoint": ( + self.source.sync.checkpoint.isoformat() + if self.source.sync.checkpoint is not None + else None + ), + "schema": schema_result["status"], + "missing_chunk_embeddings": missing_chunk_embeddings, + "rebuild_required": bool( + documents_loaded + or comments_deleted + or missing_chunk_embeddings + ), + } + except Exception as exc: + logger.exception( + "Jira sync failed for graph=%s source=%s", + self.graphname, + self.source.id, + ) + self.source.sync.last_error = str(exc)[:1000] + self.store.update_runtime_state(self.graphname, self.source) + raise + finally: + if self._owns_client: + self.client.close() + + def _find_recovery_issue_keys(self) -> set[str]: + """Return issue keys whose own ingest was written but not embedded. + + Link and parent placeholders also have an empty content_hash. They + were never ingested, so they are not failed embeddings. Only a full + issue (status or updated set) or a real comment (body or created set) + is recovered. This does not replace the project scan. + """ + keys: set[str] = set() + + try: + issue_verts = self.conn.getVertices( + "JiraIssue", + where='content_hash=""', + select="content_hash,status,updated", + limit=5000, + ) or [] + for vertex in issue_verts: + if not self._issue_embed_incomplete(vertex): + continue + key = self._issue_key_from_vertex_id(str(vertex.get("v_id", ""))) + if key: + keys.add(key) + except Exception as exc: + logger.warning( + "Recovery: could not query empty-hash JiraIssue vertices " + "graph=%s: %s", self.graphname, exc, + ) + + try: + comment_verts = self.conn.getVertices( + "JiraComment", + where='content_hash=""', + select="content_hash,body,created", + limit=5000, + ) or [] + for vertex in comment_verts: + if not self._comment_embed_incomplete(vertex): + continue + comment_v_id = str(vertex.get("v_id", "")) + try: + edges = self.conn.getEdges( + "JiraComment", + comment_v_id, + JIRA_COMMENT_ISSUE_EDGE, + ) or [] + except Exception: + continue + for edge in edges: + key = self._issue_key_from_vertex_id(str(edge.get("to_id", ""))) + if key: + keys.add(key) + except Exception as exc: + logger.warning( + "Recovery: could not query empty-hash JiraComment vertices " + "graph=%s: %s", self.graphname, exc, + ) + + if keys: + logger.info( + "Recovery: found %d ingested issue(s) with missing embeddings " + "in graph=%s source=%s. Re-embedding them without moving the " + "project checkpoint.", + len(keys), self.graphname, self.source.id, + ) + return keys + + @staticmethod + def _vertex_attr(vertex: dict, name: str) -> str: + attributes = vertex.get("attributes") or {} + value = attributes.get(name) + if isinstance(value, dict): + value = value.get("value") + return str(value or "").strip() + + @classmethod + def _issue_embed_incomplete(cls, vertex: dict) -> bool: + """True for an ingested issue whose content hash was not saved. + + A link placeholder has no status and no real updated time. + """ + status = cls._vertex_attr(vertex, "status") + updated = cls._vertex_attr(vertex, "updated") + return bool(status) or cls._is_real_timestamp(updated) + + @classmethod + def _comment_embed_incomplete(cls, vertex: dict) -> bool: + """True for a stored comment whose content hash was not saved. + + A parent-comment placeholder has no body and no created time. + """ + body = cls._vertex_attr(vertex, "body") + created = cls._vertex_attr(vertex, "created") + return bool(body) or cls._is_real_timestamp(created) + + @staticmethod + def _is_real_timestamp(value: str) -> bool: + return bool(value) and not value.startswith("1970-01-01") + + @staticmethod + def _issue_key_from_vertex_id(vertex_id: str) -> str: + if not vertex_id.startswith("jira:") or not vertex_id.endswith(":issue"): + return "" + return vertex_id.removeprefix("jira:").removesuffix(":issue").upper() + + def _all_content_hashes(self, vertex_type: str) -> dict[str, str]: + # pyTigerGraph's getVerticesById raises error 601 as soon as any + # requested STRING ID does not exist, which is the normal state during + # an initial or incremental ingestion. Read the lightweight hash + # projection once and filter it in memory. + # + # getVertices() in this version of pyTigerGraph does not support an + # offset parameter, and an unbounded call hits TigerGraph's 4 MB REST + # limit (REST-4000) on large vertex sets. Use a GSQL interpreted query + # with LIMIT/OFFSET for proper pagination without that constraint. + _PAGE = 10_000 + result: dict[str, str] = {} + offset = 0 + while True: + query = ( + f"INTERPRET QUERY() FOR GRAPH {self.graphname} {{\n" + f" verts = {{{vertex_type}.*}};\n" + f" res = SELECT v FROM verts:v\n" + f" ORDER BY v.content_hash ASC\n" + f" LIMIT {_PAGE} OFFSET {offset};\n" + f" PRINT res[res.content_hash];\n" + f"}}" + ) + response = self.conn.runInterpretedQuery(query) or [] + page = response[0].get("res", []) if response else [] + for vertex in page: + # TG prefixes the attribute with the result-set alias: + # "res.content_hash" rather than plain "content_hash". + result[str(vertex.get("v_id", ""))] = str( + (vertex.get("attributes") or {}).get("res.content_hash") or "" + ) + if len(page) < _PAGE: + break + offset += _PAGE + return result + + def _all_existing_hashes(self) -> dict[str, str]: + return self._all_content_hashes("JiraIssue") + + def _all_existing_comment_hashes(self) -> dict[str, str]: + return self._all_content_hashes("JiraComment") + + def _has_unprocessed_chunks(self) -> bool: + """True when at least one DocumentChunk has epoch_processed=0. + + Called only when this sync loaded no new content (documents_loaded=0, + comments_deleted=0). In that case any epoch_processed=0 chunk is a + survivor from a prior ECC rebuild that failed before it could embed + everything. We signal rebuild_required so the next ECC run can + complete the job without the user having to force a re-ingest. + """ + try: + hits = self.conn.getVertices( + "DocumentChunk", + where="epoch_processed=0", + select="epoch_processed", + limit=1, + ) or [] + logger.info( + "_has_unprocessed_chunks graph=%s: found %d chunk(s) with epoch_processed=0", + self.graphname, + len(hits), + ) + return len(hits) > 0 + except Exception as exc: + logger.warning("_has_unprocessed_chunks check failed: %s", exc) + return False + + def _prepare_legacy_comment_migration(self) -> None: + if self.source.sync.migrating_legacy_comments: + return + documents = self.conn.getVertices("Document", select="id") or [] + has_legacy_comments = any( + ":comment-doc:" in str(document.get("v_id") or "") + for document in documents + ) + if not has_legacy_comments: + return + self.source.sync.migrating_legacy_comments = True + self.source.sync.checkpoint = None + self.store.update_runtime_state(self.graphname, self.source) + + def _existing_hashes(self, issue_ids: list[str]) -> dict[str, str]: + if not issue_ids: + return {} + requested = set(issue_ids) + return { + issue_id: content_hash + for issue_id, content_hash in self._all_existing_hashes().items() + if issue_id in requested + } + + def _iter_pages_pipelined(self) -> Iterator[list[dict[str, Any]]]: + """Yield Jira issue pages pre-fetched by a background thread. + + The background thread requests the next page from the Jira API while + the main thread is writing the current page to TigerGraph, overlapping + network I/O with graph I/O. A queue capacity of 2 bounds memory: at + most two extra pages are held in RAM at any moment. + """ + page_queue: queue.Queue[tuple[str, Any]] = queue.Queue(maxsize=2) + error_holder: list[Exception] = [] + + def _fetch() -> None: + try: + for page in self.client.iter_issue_pages(): + page_queue.put(("page", page)) + except Exception as exc: # noqa: BLE001 + error_holder.append(exc) + finally: + page_queue.put(("done", None)) + + thread = threading.Thread( + target=_fetch, daemon=True, name="jira-page-fetcher" + ) + thread.start() + try: + while True: + kind, value = page_queue.get() + if kind == "done": + if error_holder: + raise error_holder[0] + break + yield value + finally: + thread.join(timeout=60) + + def _upsert_issue_records( + self, + mapped: list[MappedIssue], + existing_hashes: dict[str, str], + existing_comment_hashes: dict[str, str], + ) -> tuple[list[MappedIssue], list[MappedComment], int]: + """Write graph vertices/edges for one page; return items to embed later. + + Fact chunks and comment chunks are written by the caller in batches + via _finalize_batch(). + + Returns (changed_documents, changed_comments, comments_deleted). + """ + changed_documents = self._changed_documents(mapped, existing_hashes) + mapped_comments = [ + comment + for item in mapped + for comment in item.comments + ] + changed_comments = self._changed_comments( + mapped_comments, + existing_comment_hashes, + ) + comments_deleted = 0 + for item in mapped: + comments_deleted += self._reconcile_issue_comments( + item, + existing_comment_hashes, + ) + self._reconcile_issue_changes(item) + + all_vertices: dict[tuple[str, str], VertexRecord] = {} + all_edges: list[EdgeRecord] = [] + for item in mapped: + for vertex in item.vertices: + key = (vertex.vertex_type, vertex.vertex_id) + previous = all_vertices.get(key) + if ( + previous + and previous.attributes.keys() - vertex.attributes.keys() + ): + # Keep richer attributes if a linked-issue placeholder + # arrives after a full issue record in the same page. + continue + all_vertices[key] = vertex + all_edges.extend(item.edges) + if item.issue_vertex_id in existing_hashes: + self._delete_current_edges(item.issue_vertex_id) + for change_vertex_id in item.change_vertex_ids: + self._delete_change_author_edges(change_vertex_id) + for comment in item.comments: + if comment.comment_vertex_id in existing_comment_hashes: + self._delete_current_comment_edges( + comment.comment_vertex_id + ) + + # Do not advance a changed item's content hash until its document load + # succeeds. A failed batch is therefore safe to replay after restart. + def strip_content_hash(vertex_type: str, vertex_id: str) -> None: + key = (vertex_type, vertex_id) + vertex = all_vertices.get(key) + if vertex is not None: + attributes = dict(vertex.attributes) + attributes.pop("content_hash", None) + all_vertices[key] = VertexRecord( + vertex.vertex_type, + vertex.vertex_id, + attributes, + ) + + for item in changed_documents: + strip_content_hash("JiraIssue", item.issue_vertex_id) + for comment in changed_comments: + strip_content_hash("JiraComment", comment.comment_vertex_id) + + self._upsert_records(list(all_vertices.values()), all_edges) + return changed_documents, changed_comments, comments_deleted + + def _finalize_batch( + self, + changed_documents: list[MappedIssue], + changed_comments: list[MappedComment], + existing_hashes: dict[str, str], + existing_comment_hashes: dict[str, str], + ) -> None: + """Write issue fact chunks and embed comment chunks for a batch. + + Called after every DOC_LOAD_BATCH_PAGES pages (and once at the end of + the sync). Facts are written by the connector, one short chunk per + record, description piece, and change, instead of one document blob. + """ + if changed_documents: + self._write_issue_facts(changed_documents, existing_hashes) + if changed_comments: + self._upsert_comment_chunks(changed_comments) + self._embed_chunks( + [ + chunk + for comment in changed_comments + for chunk in comment.chunks + ] + ) + for comment in changed_comments: + self.conn.upsertVertex( + "JiraComment", + comment.comment_vertex_id, + attributes={"content_hash": comment.content_hash}, + ) + existing_comment_hashes[ + comment.comment_vertex_id + ] = comment.content_hash + + def _upsert_issue_page( + self, + mapped: list[MappedIssue], + existing_hashes: dict[str, str], + existing_comment_hashes: dict[str, str], + ) -> tuple[int, int]: + changed_documents = self._changed_documents(mapped, existing_hashes) + mapped_comments = [ + comment + for item in mapped + for comment in item.comments + ] + changed_comments = self._changed_comments( + mapped_comments, + existing_comment_hashes, + ) + comments_deleted = 0 + for item in mapped: + comments_deleted += self._reconcile_issue_comments( + item, + existing_comment_hashes, + ) + self._reconcile_issue_changes(item) + + all_vertices: dict[tuple[str, str], VertexRecord] = {} + all_edges: list[EdgeRecord] = [] + for item in mapped: + for vertex in item.vertices: + key = (vertex.vertex_type, vertex.vertex_id) + previous = all_vertices.get(key) + if ( + previous + and previous.attributes.keys() - vertex.attributes.keys() + ): + # Keep richer attributes if a linked-issue placeholder + # arrives after a full issue record in the same page. + continue + all_vertices[key] = vertex + all_edges.extend(item.edges) + if item.issue_vertex_id in existing_hashes: + self._delete_current_edges(item.issue_vertex_id) + for change_vertex_id in item.change_vertex_ids: + self._delete_change_author_edges(change_vertex_id) + for comment in item.comments: + if comment.comment_vertex_id in existing_comment_hashes: + self._delete_current_comment_edges( + comment.comment_vertex_id + ) + + # Do not advance a changed issue's content hash until its document load + # succeeds. A failed page is therefore safe to replay after restart. + def strip_content_hash(vertex_type: str, vertex_id: str) -> None: + key = (vertex_type, vertex_id) + vertex = all_vertices.get(key) + if vertex is not None: + attributes = dict(vertex.attributes) + attributes.pop("content_hash", None) + all_vertices[key] = VertexRecord( + vertex.vertex_type, + vertex.vertex_id, + attributes, + ) + + for item in changed_documents: + strip_content_hash("JiraIssue", item.issue_vertex_id) + for comment in changed_comments: + strip_content_hash("JiraComment", comment.comment_vertex_id) + + self._upsert_records(list(all_vertices.values()), all_edges) + + documents_to_load: list[MappedIssue | MappedComment] = [ + *changed_documents, + *changed_comments, + ] + if documents_to_load: + if changed_documents: + self._write_issue_facts(changed_documents, existing_hashes) + if changed_comments: + self._upsert_comment_chunks(changed_comments) + self._embed_chunks( + [ + chunk + for comment in changed_comments + for chunk in comment.chunks + ] + ) + for comment in changed_comments: + self.conn.upsertVertex( + "JiraComment", + comment.comment_vertex_id, + attributes={"content_hash": comment.content_hash}, + ) + existing_comment_hashes[ + comment.comment_vertex_id + ] = comment.content_hash + + return len(documents_to_load), comments_deleted + + def _upsert_records( + self, + vertices: list[VertexRecord], + edges: list[EdgeRecord], + ) -> None: + # Vertices must exist before their edge batches are applied. Keeping + # payloads bounded avoids REST request-size failures on large projects. + for records, are_edges in ((vertices, False), (edges, True)): + for start in range(0, len(records), UPSERT_BATCH_SIZE): + batch = records[start : start + UPSERT_BATCH_SIZE] + payload = _upsert_payload( + [] if are_edges else batch, + batch if are_edges else [], + ) + result = self.conn.upsertData(json.dumps(payload)) + if isinstance(result, dict) and ( + result.get("skipped_vertices") or result.get("skipped_edges") + ): + raise RuntimeError( + "TigerGraph rejected part of the Jira graph upsert" + ) + + def _changed_documents( + self, + mapped: list[MappedIssue], + existing: dict[str, str], + ) -> list[MappedIssue]: + changed: list[MappedIssue] = [] + for item in mapped: + if existing.get(item.issue_vertex_id) == item.content_hash: + continue + if item.issue_vertex_id in existing: + self._delete_issue_search_content(item) + changed.append(item) + return changed + + def _changed_comments( + self, + comments: list[MappedComment], + existing: dict[str, str], + ) -> list[MappedComment]: + changed: list[MappedComment] = [] + for comment in comments: + if existing.get(comment.comment_vertex_id) == comment.content_hash: + continue + if comment.comment_vertex_id in existing: + self._delete_comment_content( + comment.issue_vertex_id, + comment.comment_vertex_id, + ) + changed.append(comment) + return changed + + def _issue_comment_ids(self, issue_vertex_id: str) -> set[str]: + try: + edges = self.conn.getEdges( + "JiraIssue", + issue_vertex_id, + f"reverse_{JIRA_COMMENT_ISSUE_EDGE}", + ) or [] + except Exception as exc: + if "is not a valid vertex id" in str(exc): + return set() + raise + return { + str(edge.get("to_id")) + for edge in edges + if edge.get("to_id") is not None + } + + def _reconcile_issue_comments( + self, + issue: MappedIssue, + existing_hashes: dict[str, str], + ) -> int: + current_ids = { + comment.comment_vertex_id for comment in issue.comments + } + stale_ids = self._issue_comment_ids(issue.issue_vertex_id) - current_ids + for comment_vertex_id in stale_ids: + self._delete_comment( + issue.issue_vertex_id, + comment_vertex_id, + ) + existing_hashes.pop(comment_vertex_id, None) + return len(stale_ids) + + def _reconcile_issue_changes(self, issue: MappedIssue) -> None: + current_ids = set(issue.change_vertex_ids) + for change_vertex_id in ( + self._issue_change_ids(issue.issue_vertex_id) - current_ids + ): + self.conn.delVerticesById("JiraChange", [change_vertex_id]) + + def _issue_change_ids(self, issue_vertex_id: str) -> set[str]: + try: + edges = self.conn.getEdges( + "JiraIssue", + issue_vertex_id, + JIRA_CHANGE_EDGE, + ) or [] + except Exception as exc: + if "is not a valid vertex id" in str(exc): + return set() + raise + return { + str(edge.get("to_id")) + for edge in edges + if edge.get("to_id") is not None + } + + def _delete_change_author_edges(self, change_vertex_id: str) -> None: + try: + self.conn.delEdges( + "JiraChange", + change_vertex_id, + JIRA_CHANGE_AUTHOR_EDGE, + ) + except Exception as exc: + if "is not a valid vertex id" in str(exc): + return + raise + + def _delete_current_edges(self, issue_vertex_id: str) -> None: + for edge_type in CURRENT_STATE_EDGES: + self.conn.delEdges("JiraIssue", issue_vertex_id, edge_type) + + def _delete_current_comment_edges( + self, + comment_vertex_id: str, + ) -> None: + for edge_type in COMMENT_STATE_EDGES: + self.conn.delEdges("JiraComment", comment_vertex_id, edge_type) + + def _delete_comment( + self, + issue_vertex_id: str, + comment_vertex_id: str, + ) -> None: + self._delete_comment_content(issue_vertex_id, comment_vertex_id) + self.conn.delVerticesById("JiraComment", [comment_vertex_id]) + + def _comment_chunk_ids(self, comment_vertex_id: str) -> list[str]: + try: + edges = self.conn.getEdges( + "JiraComment", + comment_vertex_id, + "reverse_CONTAINS_ENTITY", + ) or [] + except Exception as exc: + if "is not a valid vertex id" in str(exc): + return [] + raise + return [ + str(edge["to_id"]) + for edge in edges + if edge.get("to_type") == "DocumentChunk" + and edge.get("to_id") is not None + ] + + def _delete_comment_content( + self, + issue_vertex_id: str, + comment_vertex_id: str, + ) -> None: + direct_chunk_ids = self._comment_chunk_ids(comment_vertex_id) + if direct_chunk_ids: + get_embedding_store( + graphname=self.graphname + ).remove_embeddings(ids=direct_chunk_ids) + self.conn.delVerticesById("DocumentChunk", direct_chunk_ids) + self.conn.delVerticesById("Content", direct_chunk_ids) + + # Remove records created by the former Document -> ECC chunking path. + comment_id = comment_vertex_id.rsplit(":comment:", 1)[-1] + legacy_document_id = f"{issue_vertex_id}:comment-doc:{comment_id}" + self._delete_document_chunks(legacy_document_id) + self.conn.delVerticesById("Document", [legacy_document_id]) + self.conn.delVerticesById("Content", [legacy_document_id]) + + def _delete_document_chunks(self, document_id: str) -> None: + try: + edges = self.conn.getEdges("Document", document_id, "HAS_CHILD") or [] + except Exception as exc: + if "is not a valid vertex id" in str(exc): + return + raise + chunk_ids = [ + str(edge.get("to_id")) + for edge in edges + if edge.get("to_id") is not None + ] + if not chunk_ids: + return + get_embedding_store(graphname=self.graphname).remove_embeddings(ids=chunk_ids) + self.conn.delVerticesById("DocumentChunk", chunk_ids) + self.conn.delVerticesById("Content", chunk_ids) + + def _write_issue_facts( + self, + issues: list[MappedIssue], + existing_hashes: dict[str, str], + ) -> None: + self._upsert_issue_facts(issues) + self._embed_chunks( + [fact.chunk for issue in issues for fact in issue.facts] + ) + for issue in issues: + self.conn.upsertVertex( + "JiraIssue", + issue.issue_vertex_id, + attributes={"content_hash": issue.content_hash}, + ) + existing_hashes[issue.issue_vertex_id] = issue.content_hash + + def _delete_issue_search_content(self, issue: MappedIssue) -> None: + chunk_ids = self._issue_fact_chunk_ids(issue.issue_vertex_id) + if chunk_ids: + get_embedding_store( + graphname=self.graphname + ).remove_embeddings(ids=chunk_ids) + self.conn.delVerticesById("DocumentChunk", chunk_ids) + self.conn.delVerticesById("Content", chunk_ids) + document_id = issue.legacy_document_id.lower() + self._delete_document_chunks(document_id) + self.conn.delVerticesById("Document", [document_id]) + self.conn.delVerticesById("Content", [document_id]) + + def _issue_fact_chunk_ids(self, issue_vertex_id: str) -> list[str]: + try: + edges = self.conn.getEdges( + "JiraIssue", + issue_vertex_id, + "reverse_CONTAINS_ENTITY", + ) or [] + except Exception as exc: + if "is not a valid vertex id" in str(exc): + return [] + raise + return [ + str(edge["to_id"]) + for edge in edges + if edge.get("to_type") == "DocumentChunk" + and edge.get("to_id") is not None + and ":fact:" in str(edge["to_id"]) + ] + + def _upsert_issue_facts(self, issues: list[MappedIssue]) -> None: + epoch_added = int(time.time()) + vertices: list[VertexRecord] = [] + edges: list[EdgeRecord] = [] + for issue in issues: + previous_chunk_id: str | None = None + for fact in issue.facts: + chunk = fact.chunk + vertices.extend( + self._chunk_vertices(chunk, epoch_added, "jira_issue") + ) + edges.extend( + self._chunk_edges( + chunk.chunk_id, + issue.issue_vertex_id, + fact.entities, + previous_chunk_id, + ) + ) + previous_chunk_id = chunk.chunk_id + self._upsert_records(vertices, edges) + + def _chunk_vertices( + self, + chunk: MappedChunk, + epoch_added: int, + ctype: str, + ) -> list[VertexRecord]: + return [ + VertexRecord( + "DocumentChunk", + chunk.chunk_id, + { + "idx": chunk.index, + "epoch_added": epoch_added, + "epoch_processing": 0, + "epoch_processed": 0, + }, + ), + VertexRecord( + "Content", + chunk.chunk_id, + { + "ctype": ctype, + "text": chunk.text, + "epoch_added": epoch_added, + }, + ), + ] + + def _chunk_edges( + self, + chunk_id: str, + issue_vertex_id: str, + entities: tuple[tuple[str, str], ...], + previous_chunk_id: str | None, + ) -> list[EdgeRecord]: + edges = [ + EdgeRecord( + "DocumentChunk", + chunk_id, + "HAS_CONTENT", + "Content", + chunk_id, + ), + EdgeRecord( + "DocumentChunk", + chunk_id, + "CONTAINS_ENTITY", + "JiraIssue", + issue_vertex_id, + ), + ] + for vertex_type, vertex_id in entities: + edges.append( + EdgeRecord( + "DocumentChunk", + chunk_id, + "CONTAINS_ENTITY", + vertex_type, + vertex_id, + ) + ) + if previous_chunk_id: + edges.append( + EdgeRecord( + "DocumentChunk", + chunk_id, + "IS_AFTER", + "DocumentChunk", + previous_chunk_id, + ) + ) + return edges + + def _upsert_comment_chunks( + self, + comments: list[MappedComment], + ) -> None: + epoch_added = int(time.time()) + vertices: list[VertexRecord] = [] + edges: list[EdgeRecord] = [] + for comment in comments: + previous_chunk_id: str | None = None + for chunk in comment.chunks: + vertices.extend( + self._chunk_vertices(chunk, epoch_added, "jira_comment") + ) + edges.extend( + self._chunk_edges( + chunk.chunk_id, + comment.issue_vertex_id, + (("JiraComment", comment.comment_vertex_id),), + previous_chunk_id, + ) + ) + previous_chunk_id = chunk.chunk_id + self._upsert_records(vertices, edges) + + def _embed_chunks(self, chunks: list[MappedChunk]) -> None: + if not chunks: + return + store = get_embedding_store(graphname=self.graphname) + + async def embed_batches() -> None: + # Cap at 5 concurrent aadd_embeddings calls. Each call makes ~32 + # sequential Gemini requests; 20 concurrent was causing traffic + # spikes that trigger Gemini 500 INTERNAL (server overload). + # 5 concurrent × ~32 requests = ~160 in-flight, safe for the API. + sem = asyncio.Semaphore(5) + + async def _run_batch(batch: list) -> None: + async with sem: + await store.aadd_embeddings( + [(chunk.text, []) for chunk in batch], + [ + { + "vertex_id": ( + chunk.chunk_id, + "DocumentChunk", + ) + } + for chunk in batch + ], + ) + + tasks = [ + _run_batch(chunks[s : s + EMBEDDING_BATCH_SIZE]) + for s in range(0, len(chunks), EMBEDDING_BATCH_SIZE) + ] + await asyncio.gather(*tasks) + + asyncio.run(embed_batches()) + processed_at = int(time.time()) + for chunk in chunks: + self.conn.upsertVertex( + "DocumentChunk", + chunk.chunk_id, + attributes={"epoch_processed": processed_at}, + ) diff --git a/graphrag/app/main.py b/graphrag/app/main.py index cfa79aaa..30c01eb4 100644 --- a/graphrag/app/main.py +++ b/graphrag/app/main.py @@ -99,6 +99,7 @@ async def lifespan(app: FastAPI): app.include_router(routers.queryai_router, prefix=PATH_PREFIX) app.include_router(routers.ui_router, prefix=PATH_PREFIX) app.include_router(routers.mcp_servers_router, prefix=PATH_PREFIX) +app.include_router(routers.data_sources_router, prefix=PATH_PREFIX) excluded_metrics_paths = ("/docs", "/openapi.json", "/metrics") diff --git a/graphrag/app/routers/__init__.py b/graphrag/app/routers/__init__.py index 054cc955..d2785067 100644 --- a/graphrag/app/routers/__init__.py +++ b/graphrag/app/routers/__init__.py @@ -4,3 +4,4 @@ from .supportai import router as supportai_router from .ui import router as ui_router from .mcp_servers import router as mcp_servers_router +from .data_sources import router as data_sources_router diff --git a/graphrag/app/routers/data_sources.py b/graphrag/app/routers/data_sources.py new file mode 100644 index 00000000..8be0fa64 --- /dev/null +++ b/graphrag/app/routers/data_sources.py @@ -0,0 +1,578 @@ +"""Graph-scoped enterprise data-source management.""" + +from __future__ import annotations + +import logging +import threading +import uuid +from datetime import datetime, timezone +from typing import Annotated, Any + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from fastapi.security import HTTPBasicCredentials + +from common.config import validate_graphname +from common.db.schema_utils import apply_proposal +from common.utils.graph_locks import ( + acquire_graph_lock, + get_current_operation, + release_graph_lock, +) +from connectors.jira.client import JiraAPIError, JiraCloudClient +from connectors.jira.config import PROJECT_KEY_RE, JiraDataSource, JiraScope +from connectors.jira.schema import ( + jira_schema_proposal, + jira_schema_status, +) +from connectors.jira.state import JiraSourceStore +from connectors.jira.sync import JiraSyncService + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["Data Sources"]) +route_prefix = "/ui" +store = JiraSourceStore() + +_sync_state: dict[str, dict[str, Any]] = {} +_sync_state_lock = threading.Lock() +_jira_tokens: dict[tuple[str, str], str] = {} +_jira_tokens_lock = threading.Lock() + + +def _ui_basic_auth(): + from routers.ui import ui_basic_auth + + return ui_basic_auth + + +def _require_access( + graphname: str, + auth: tuple[list[str], HTTPBasicCredentials], +) -> HTTPBasicCredentials: + validate_graphname(graphname) + graphs, credentials = auth + if graphname not in graphs: + raise HTTPException(status_code=403, detail="Graph access is required.") + from routers.ui import _require_roles + + _require_roles(credentials, {"superuser", "globaldesigner"}) + return credentials + + +def _source_or_404(graphname: str, source_id: str) -> JiraDataSource: + try: + return store.get(graphname, source_id) + except KeyError: + raise HTTPException(status_code=404, detail="Data source not found.") + + +def _source_with_credentials( + graphname: str, + source_id: str, +) -> JiraDataSource: + source = _source_or_404(graphname, source_id) + with _jira_tokens_lock: + token = _jira_tokens.get((graphname, source_id)) + token = token or source.connection.api_token + if not token: + raise HTTPException( + status_code=409, + detail=( + "Jira API token is not available. Test the connection again " + "and provide the token." + ), + ) + resolved = source.model_copy(deep=True) + resolved.connection.api_token = token + return resolved + + +def _jira_error(exc: JiraAPIError) -> HTTPException: + if exc.status_code == 404: + detail = ( + "Jira REST API was not found at the configured Site URL. " + "Use the Jira tenant base URL, for example " + "https://your-company.atlassian.net." + ) + elif exc.status_code == 401: + detail = ( + "Jira rejected the credentials. Use the Atlassian account email " + "that owns the API token and a valid Jira API token." + ) + elif exc.status_code == 403: + detail = ( + "Jira accepted the credentials but denied access. Verify that the " + "account can access Jira and browse the required projects." + ) + else: + detail = str(exc) + status_code = 400 if 400 <= exc.status_code < 500 else 502 + return HTTPException(status_code=status_code, detail=detail) + + +def _connection(request: Request, graphname: str): + authorization = request.headers.get("Authorization") + if not authorization: + raise HTTPException(status_code=401, detail="Missing Authorization header.") + from routers.ui import ws_basic_auth + + _, conn = ws_basic_auth(authorization, graphname) + return conn + + +def _set_sync_state(sync_run_id: str, **updates: Any) -> None: + with _sync_state_lock: + current = dict(_sync_state.get(sync_run_id) or {}) + current.update(updates) + _sync_state[sync_run_id] = current + + +@router.get(f"{route_prefix}/{{graphname}}/data-sources") +def list_data_sources( + graphname: str, + auth: Annotated[ + tuple[list[str], HTTPBasicCredentials], + Depends(_ui_basic_auth()), + ], +): + _require_access(graphname, auth) + return {"sources": store.list(graphname)} + + +@router.put(f"{route_prefix}/{{graphname}}/data-sources/{{source_id}}") +def save_data_source( + graphname: str, + source_id: str, + source: JiraDataSource, + auth: Annotated[ + tuple[list[str], HTTPBasicCredentials], + Depends(_ui_basic_auth()), + ], +): + _require_access(graphname, auth) + if source.id != source_id: + raise HTTPException( + status_code=400, + detail="Path source id must match the request body.", + ) + try: + saved = store.upsert(graphname, source) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"source": store.redact(saved)} + + +@router.delete(f"{route_prefix}/{{graphname}}/data-sources/{{source_id}}") +def delete_data_source( + graphname: str, + source_id: str, + auth: Annotated[ + tuple[list[str], HTTPBasicCredentials], + Depends(_ui_basic_auth()), + ], +): + _require_access(graphname, auth) + try: + store.delete(graphname, source_id) + except KeyError: + raise HTTPException(status_code=404, detail="Data source not found.") + with _jira_tokens_lock: + _jira_tokens.pop((graphname, source_id), None) + return {"status": "deleted"} + + +@router.post(f"{route_prefix}/{{graphname}}/data-sources/{{source_id}}/test") +def test_data_source( + graphname: str, + source_id: str, + auth: Annotated[ + tuple[list[str], HTTPBasicCredentials], + Depends(_ui_basic_auth()), + ], + candidate: JiraDataSource | None = None, +): + _require_access(graphname, auth) + if candidate is None or not candidate.connection.api_token.strip(): + raise HTTPException( + status_code=400, + detail="Enter the Jira API token before testing the connection.", + ) + if candidate.id != source_id: + raise HTTPException( + status_code=400, + detail="Path source id must match the request body.", + ) + token = candidate.connection.api_token.strip() + try: + with JiraCloudClient(candidate) as client: + account = client.myself() + cloud_id = candidate.connection.cloud_id or client.cloud_id() + except JiraAPIError as exc: + raise _jira_error(exc) + try: + source = store.upsert(graphname, candidate) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + with _jira_tokens_lock: + _jira_tokens[(graphname, source_id)] = token + source.connection.cloud_id = cloud_id + source.sync.last_tested_at = datetime.now(timezone.utc) + store.update_runtime_state(graphname, source) + return { + "status": "connected", + "account": { + "account_id": account.get("accountId"), + "display_name": account.get("displayName"), + }, + "cloud_id": cloud_id, + "tested_at": source.sync.last_tested_at.isoformat(), + "source": store.redact(source), + } + + +@router.get(f"{route_prefix}/{{graphname}}/data-sources/{{source_id}}/projects") +def list_jira_projects( + graphname: str, + source_id: str, + auth: Annotated[ + tuple[list[str], HTTPBasicCredentials], + Depends(_ui_basic_auth()), + ], + project_key: str | None = None, +): + _require_access(graphname, auth) + source = _source_with_credentials(graphname, source_id) + if source.sync.last_tested_at is None: + raise HTTPException( + status_code=409, + detail="Test this Jira connection before loading projects.", + ) + normalized_project_key = project_key.strip().upper() if project_key else None + if normalized_project_key and not PROJECT_KEY_RE.fullmatch( + normalized_project_key + ): + raise HTTPException( + status_code=400, + detail=( + "Project key must start with a letter and contain only letters, " + "numbers, or underscores." + ), + ) + try: + with JiraCloudClient(source) as client: + projects = ( + [client.project(normalized_project_key)] + if normalized_project_key + else client.projects() + ) + except JiraAPIError as exc: + if normalized_project_key and exc.status_code == 404: + raise HTTPException( + status_code=400, + detail=( + f"Jira project {normalized_project_key} was not found or " + "is not visible to this account." + ), + ) + raise _jira_error(exc) + return { + "projects": [ + { + "id": project.get("id"), + "key": project.get("key"), + "name": project.get("name"), + } + for project in projects + ] + } + + +@router.post( + f"{route_prefix}/{{graphname}}/data-sources/{{source_id}}/issues/count" +) +def preview_jira_issue_count( + graphname: str, + source_id: str, + scope: JiraScope, + auth: Annotated[ + tuple[list[str], HTTPBasicCredentials], + Depends(_ui_basic_auth()), + ], +): + """Estimate how many Jira issues match the unsaved project scope.""" + _require_access(graphname, auth) + if not scope.project_keys: + raise HTTPException( + status_code=400, + detail="Select at least one Jira project before previewing tickets.", + ) + source = _source_with_credentials(graphname, source_id) + if source.sync.last_tested_at is None: + raise HTTPException( + status_code=409, + detail="Test this Jira connection before previewing tickets.", + ) + preview_source = source.model_copy(deep=True) + preview_source.scope = scope + try: + with JiraCloudClient(preview_source) as client: + count = client.approximate_issue_count() + except JiraAPIError as exc: + raise _jira_error(exc) + return {"count": count, "approximate": True} + + +@router.get(f"{route_prefix}/{{graphname}}/data-sources/jira/schema") +def get_jira_schema_status( + graphname: str, + request: Request, + auth: Annotated[ + tuple[list[str], HTTPBasicCredentials], + Depends(_ui_basic_auth()), + ], +): + _require_access(graphname, auth) + conn = _connection(request, graphname) + return jira_schema_status(conn) + + +@router.post(f"{route_prefix}/{{graphname}}/data-sources/jira/schema/install") +def install_jira_schema( + graphname: str, + request: Request, + auth: Annotated[ + tuple[list[str], HTTPBasicCredentials], + Depends(_ui_basic_auth()), + ], +): + _require_access(graphname, auth) + sources = store.load(graphname).sources + if not sources: + raise HTTPException( + status_code=409, + detail="Save a Jira connection before installing its schema.", + ) + if not any(source.sync.last_tested_at for source in sources): + raise HTTPException( + status_code=409, + detail="Test a Jira connection before installing its schema.", + ) + if not any( + source.sync.last_tested_at and source.scope.project_keys + for source in sources + ): + raise HTTPException( + status_code=409, + detail="Select and save at least one Jira project before installing its schema.", + ) + operation = "install_jira_schema" + if not acquire_graph_lock(graphname, operation): + current = get_current_operation(graphname) or "another operation" + raise HTTPException( + status_code=409, + detail=f"Graph '{graphname}' is busy with '{current}'.", + ) + try: + conn = _connection(request, graphname) + before = jira_schema_status(conn) + if before["status"] == "not_initialized": + raise HTTPException( + status_code=409, + detail=( + "Initialize the knowledge graph before installing " + "the Jira connector schema." + ), + ) + if before["status"] == "conflict": + raise HTTPException( + status_code=409, + detail={ + "message": "Existing graph schema conflicts with Jira schema.", + "conflicts": before["conflicts"], + }, + ) + + result = apply_proposal(conn, graphname, jira_schema_proposal()) + if result.get("status") == "error": + raise HTTPException( + status_code=500, + detail=result.get("error") or "Jira schema installation failed.", + ) + retrievers = result.get("retrievers") or {} + if retrievers.get("status") == "error": + raise HTTPException( + status_code=500, + detail=( + "Jira schema was applied, but retriever installation failed: " + f"{retrievers.get('error', 'unknown error')}" + ), + ) + + # The connection used to apply the SCHEMA_CHANGE JOB can retain the + # pre-migration schema snapshot. Verify through a fresh connection so + # a successful migration is not reported as a false 500. + verification_conn = _connection(request, graphname) + after = jira_schema_status(verification_conn) + if after["status"] != "installed": + logger.error( + "Jira schema verification failed for graph %s: %s", + graphname, + after, + ) + raise HTTPException( + status_code=500, + detail={ + "message": ( + "Jira schema installation completed, but verification " + "did not find a compatible schema." + ), + "missing": after.get("missing"), + "conflicts": after.get("conflicts"), + }, + ) + return { + "status": "installed", + "schema": after, + "migration": { + "status": result.get("status"), + "statements": result.get("statements") or [], + "summary": result.get("summary") or {}, + "retrievers": retrievers, + }, + } + finally: + release_graph_lock(graphname, operation) + + +@router.post(f"{route_prefix}/{{graphname}}/data-sources/{{source_id}}/sync") +def start_sync( + graphname: str, + source_id: str, + request: Request, + background_tasks: BackgroundTasks, + auth: Annotated[ + tuple[list[str], HTTPBasicCredentials], + Depends(_ui_basic_auth()), + ], +): + _require_access(graphname, auth) + source = _source_with_credentials(graphname, source_id) + if not source.enabled: + raise HTTPException(status_code=409, detail="Jira data source is disabled.") + if source.sync.last_tested_at is None: + raise HTTPException( + status_code=409, + detail="Test this Jira connection before synchronization.", + ) + if not source.scope.project_keys: + raise HTTPException( + status_code=409, + detail="Select at least one Jira project before synchronization.", + ) + authorization = request.headers.get("Authorization") + if not authorization: + raise HTTPException(status_code=401, detail="Missing Authorization header.") + conn = _connection(request, graphname) + schema = jira_schema_status(conn) + if schema["status"] != "installed": + raise HTTPException( + status_code=409, + detail=( + "Jira schema is not installed for this graph. " + "Install it from Data Sources before synchronization." + ), + ) + + operation = f"jira_sync:{source_id}" + if not acquire_graph_lock(graphname, operation): + current = get_current_operation(graphname) or "another operation" + raise HTTPException( + status_code=409, + detail=f"Graph '{graphname}' is busy with '{current}'.", + ) + + run_id = uuid.uuid4().hex + try: + _set_sync_state( + run_id, + run_id=run_id, + graphname=graphname, + source_id=source_id, + status="queued", + started_at=datetime.now(timezone.utc).isoformat(), + ) + except Exception: + release_graph_lock(graphname, operation) + raise + + def run() -> None: + result: dict[str, Any] | None = None + error: Exception | None = None + try: + _set_sync_state(run_id, status="running") + from routers.ui import ws_basic_auth + + _, conn = ws_basic_auth(authorization, graphname) + source = _source_with_credentials(graphname, source_id) + result = JiraSyncService( + graphname, + source, + conn, + store=store, + ).run() + except Exception as exc: + error = exc + logger.exception( + "Jira data-source sync failed graph=%s source=%s", + graphname, + source_id, + ) + finally: + # Release before publishing a terminal state. The UI starts the + # GraphRAG rebuild as soon as it observes "completed". + release_graph_lock(graphname, operation) + + if error is not None: + _set_sync_state( + run_id, + status="failed", + completed_at=datetime.now(timezone.utc).isoformat(), + error=str(error)[:1000], + ) + else: + _set_sync_state( + run_id, + status="completed", + completed_at=datetime.now(timezone.utc).isoformat(), + result=result, + ) + + try: + background_tasks.add_task(run) + except Exception: + release_graph_lock(graphname, operation) + raise + return {"status": "submitted", "run_id": run_id} + + +@router.get( + f"{route_prefix}/{{graphname}}/data-sources/{{source_id}}/sync/{{run_id}}" +) +def get_sync_status( + graphname: str, + source_id: str, + run_id: str, + auth: Annotated[ + tuple[list[str], HTTPBasicCredentials], + Depends(_ui_basic_auth()), + ], +): + _require_access(graphname, auth) + with _sync_state_lock: + state = dict(_sync_state.get(run_id) or {}) + if ( + not state + or state.get("graphname") != graphname + or state.get("source_id") != source_id + ): + raise HTTPException(status_code=404, detail="Sync run not found.") + return state diff --git a/graphrag/app/tools/generate_cypher.py b/graphrag/app/tools/generate_cypher.py index d9199de4..b9f3dbac 100644 --- a/graphrag/app/tools/generate_cypher.py +++ b/graphrag/app/tools/generate_cypher.py @@ -13,6 +13,7 @@ # along with this program. If not, see . import logging +import re from typing import Iterable from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import PromptTemplate @@ -27,6 +28,51 @@ logger = logging.getLogger(__name__) +def validate_tigergraph_cypher(query: str, schema: str) -> None: + """Reject the OpenCypher shape TigerGraph cannot compile.""" + directed_self_edges: set[str] = set() + edge_name = from_type = to_type = None + for line in schema.partition("Edge Types:")[2].splitlines() + ["END"]: + if line and not line[0].isspace(): + edge_name = line.strip() + from_type = to_type = None + elif edge_name and line.strip().startswith("From Vertex:"): + from_type = line.split(":", 1)[1].strip() + elif edge_name and line.strip().startswith("To Vertex:"): + to_type = line.split(":", 1)[1].strip() + elif ( + edge_name + and from_type == to_type + and line.strip() == "Edge direction: Directed" + ): + directed_self_edges.add(edge_name) + + returned = re.search(r"\bRETURN\b(.*)", query, re.I | re.S) + if not returned: + return + return_items = returned.group(1).split(",") + for edge_var, edge_type in re.findall( + r"OPTIONAL\s+MATCH\s*\([^)]*\)\s*-\s*" + r"\[\s*(\w+)\s*:\s*(\w+)[^]]*]\s*-\s*\([^)]*\)", + query, + re.I, + ): + returns_edge = any( + re.fullmatch( + rf"`?{re.escape(edge_var)}`?(?:\s+AS\s+\w+)?", + item.strip(), + re.I, + ) + for item in return_items + ) + if edge_type in directed_self_edges and returns_edge: + raise ValueError( + f"TigerGraph cannot return {edge_var} from an undirected " + f"OPTIONAL MATCH on directed self-edge {edge_type}. Use MATCH " + "or return only the related vertex and requested edge attributes." + ) + + class GenerateCypher(BaseTool): """GenerateCypher Tool. Tool to generate and execute the appropriate Cypher query for the question. @@ -105,6 +151,7 @@ def generate_cypher(self, question: str, history: Iterable[str]) -> str: if not any(kw in out_upper for kw in ("MATCH", "RETURN", "WITH", "UNWIND", "CALL")): LogWriter.info(f"request_id={req_id_cv.get()} EXIT generate_cypher - LLM did not produce a valid Cypher query") raise ValueError(f"LLM did not produce a valid Cypher query: {out[:200]}") + validate_tigergraph_cypher(out, schema) query_header = "USE GRAPH " + self.conn.graphname + " "+ "\n" + "INTERPRET OPENCYPHER QUERY () {" + "\n" query_footer = "\n}" diff --git a/graphrag/tests/test_generate_cypher.py b/graphrag/tests/test_generate_cypher.py new file mode 100644 index 00000000..bc7fcd66 --- /dev/null +++ b/graphrag/tests/test_generate_cypher.py @@ -0,0 +1,59 @@ +import pytest + +from app.tools.generate_cypher import validate_tigergraph_cypher + + +SCHEMA = """Edge Types: +REPORTS_TO + From Vertex: Person + To Vertex: Person + Edge direction: Directed + Attributes: + since of type DATETIME +WORKS_FOR + From Vertex: Person + To Vertex: Company + Edge direction: Directed + Attributes: + No attributes +""" + + +def test_rejects_optional_undirected_directed_self_edge_object(): + query = """ +MATCH (person:Person) +OPTIONAL MATCH (person)-[manager_edge:REPORTS_TO]-(manager:Person) +RETURN person, manager_edge, manager +""" + + with pytest.raises(ValueError, match="cannot return manager_edge"): + validate_tigergraph_cypher(query, SCHEMA) + + +@pytest.mark.parametrize( + "query", + [ + """ + OPTIONAL MATCH (person:Person)-[manager_edge:REPORTS_TO]->(manager:Person) + RETURN person, manager_edge, manager + """, + """ + MATCH (person:Person)-[manager_edge:REPORTS_TO]-(manager:Person) + RETURN person, manager_edge, manager + """, + """ + OPTIONAL MATCH (person:Person)-[employment:WORKS_FOR]-(company:Company) + RETURN person, employment, company + """, + """ + OPTIONAL MATCH (person:Person)-[manager_edge:REPORTS_TO]-(manager:Person) + RETURN person, manager + """, + """ + OPTIONAL MATCH (person:Person)-[manager_edge:REPORTS_TO]-(manager:Person) + RETURN person, manager_edge.since, manager + """, + ], +) +def test_accepts_other_query_shapes(query): + validate_tigergraph_cypher(query, SCHEMA) diff --git a/graphrag/tests/test_jira_connector.py b/graphrag/tests/test_jira_connector.py new file mode 100644 index 00000000..674cf19f --- /dev/null +++ b/graphrag/tests/test_jira_connector.py @@ -0,0 +1,1306 @@ +from __future__ import annotations + +import json +from datetime import date, datetime, timezone + +import httpx +import pytest + +from connectors.jira.adf import adf_to_markdown +from connectors.jira.client import JiraCloudClient +from connectors.jira.config import JiraDataSource +from connectors.jira.mapper import JiraIssueMapper +from connectors.jira.schema import ( + JIRA_ASSIGNEE_EDGE, + JIRA_CHANGE_AUTHOR_EDGE, + JIRA_CHANGE_EDGE, + JIRA_COMMENT_AFTER_EDGE, + JIRA_COMMENT_AUTHOR_EDGE, + JIRA_COMMENT_ISSUE_EDGE, + JIRA_COMMENT_REPLY_EDGE, + JIRA_LINK_EDGE, + JIRA_PARENT_EDGE, + JIRA_PROJECT_EDGE, + JIRA_REPORTER_EDGE, + jira_schema_proposal, + jira_schema_status, +) +from connectors.jira.state import JiraSourceStore +from connectors.jira.sync import JiraSyncService + + +def test_sync_state_records_public_run_id_without_argument_collision(): + from routers import data_sources + + run_id = "test-run-id" + try: + data_sources._set_sync_state( + run_id, + run_id=run_id, + status="queued", + ) + assert data_sources._sync_state[run_id] == { + "run_id": run_id, + "status": "queued", + } + finally: + with data_sources._sync_state_lock: + data_sources._sync_state.pop(run_id, None) + + +def source(**overrides) -> JiraDataSource: + payload = { + "id": "jira-acme", + "display_name": "Acme Jira", + "connection": { + "site_url": "https://acme.atlassian.net/", + "email": "svc@example.com", + "api_token": "secret", + }, + "scope": { + "project_keys": ["pay"], + "include_comments": True, + }, + } + payload.update(overrides) + return JiraDataSource.model_validate(payload) + + +def test_source_config_normalizes_url_and_projects(): + config = source() + assert config.connection.site_url == "https://acme.atlassian.net" + assert config.scope.project_keys == ["PAY"] + + +def test_store_rejects_atlassian_service_url(tmp_path): + store = JiraSourceStore(str(tmp_path)) + with pytest.raises(ValueError, match="Jira tenant URL"): + store.upsert( + "TestGraph", + source( + connection={ + "site_url": "https://graphql.atlassian.net", + "email": "svc@example.com", + "api_token": "secret", + } + ), + ) + + +def test_source_config_allows_connection_draft_without_scope(): + config = source(scope={"project_keys": []}) + assert config.scope.project_keys == [] + + +def test_adf_to_markdown_preserves_structure_and_links(): + adf = { + "type": "doc", + "version": 1, + "content": [ + { + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Decision"}], + }, + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Use "}, + { + "type": "text", + "text": "GraphRAG", + "marks": [ + {"type": "strong"}, + { + "type": "link", + "attrs": {"href": "https://example.com"}, + }, + ], + }, + ], + }, + ], + } + markdown = adf_to_markdown(adf) + assert "## Decision" in markdown + assert "[**GraphRAG**](https://example.com)" in markdown + + +def test_enhanced_search_uses_next_page_token_and_completes_comments(): + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path.endswith("/search/jql"): + body = json.loads(request.content) + if body.get("nextPageToken") == "page-2": + return httpx.Response(200, json={"issues": [{"id": "2", "fields": {}}]}) + return httpx.Response( + 200, + json={ + "issues": [ + { + "id": "1", + "fields": { + "comment": { + "total": 2, + "comments": [{"id": "10"}], + } + }, + } + ], + "nextPageToken": "page-2", + }, + ) + if request.url.path.endswith("/issue/1/comment"): + return httpx.Response( + 200, + json={"total": 2, "comments": [{"id": "11"}]}, + ) + raise AssertionError(f"unexpected request: {request.url}") + + http_client = httpx.Client( + base_url="https://acme.atlassian.net", + transport=httpx.MockTransport(handler), + ) + issues = list(JiraCloudClient(source(), client=http_client).iter_issues()) + assert [issue["id"] for issue in issues] == ["1", "2"] + assert len(issues[0]["fields"]["comment"]["comments"]) == 2 + search_bodies = [ + json.loads(request.content) + for request in requests + if request.url.path.endswith("/search/jql") + ] + assert search_bodies[1]["nextPageToken"] == "page-2" + assert "/rest/api/3/search/jql" in str(requests[0].url) + + +def test_project_loads_one_project_by_key(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert request.url.path == "/rest/api/3/project/PAY" + return httpx.Response( + 200, + json={"id": "10001", "key": "PAY", "name": "Payments"}, + ) + + http_client = httpx.Client( + base_url="https://acme.atlassian.net", + transport=httpx.MockTransport(handler), + ) + project = JiraCloudClient(source(), client=http_client).project("PAY") + assert project == {"id": "10001", "key": "PAY", "name": "Payments"} + + +def test_project_endpoint_loads_only_requested_project(monkeypatch): + from routers import data_sources + + configured = source( + sync={"last_tested_at": datetime.now(timezone.utc)} + ) + + class Client: + def __init__(self, jira_source): + assert jira_source == configured + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def project(self, project_key): + assert project_key == "PAY" + return {"id": "10001", "key": "PAY", "name": "Payments"} + + monkeypatch.setattr(data_sources, "_require_access", lambda *args: None) + monkeypatch.setattr( + data_sources, "_source_or_404", lambda *args: configured + ) + monkeypatch.setattr(data_sources, "JiraCloudClient", Client) + + result = data_sources.list_jira_projects( + "TestGraph", + "jira-acme", + auth=(["TestGraph"], object()), + project_key=" pay ", + ) + assert result == { + "projects": [{"id": "10001", "key": "PAY", "name": "Payments"}] + } + + +def test_search_applies_structured_scope_filters(): + request_bodies: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + request_bodies.append(json.loads(request.content)) + return httpx.Response(200, json={"issues": []}) + + configured = source( + scope={ + "project_keys": ["PAY"], + "created_after": "2026-01-01", + "updated_after": "2026-06-01", + "status_categories": ["new", "indeterminate"], + "include_comments": True, + } + ) + http_client = httpx.Client( + base_url="https://acme.atlassian.net", + transport=httpx.MockTransport(handler), + ) + assert list( + JiraCloudClient(configured, client=http_client).iter_issues() + ) == [] + assert request_bodies[0]["jql"] == ( + 'project in (PAY) AND created >= "2026-01-01" ' + 'AND updated >= "2026-06-01" ' + 'AND statusCategory in ("To Do", "In Progress") ' + "ORDER BY updated ASC, key ASC" + ) + + +def test_approximate_count_uses_scope_without_ordering(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/rest/api/3/search/approximate-count" + body = json.loads(request.content) + assert body["jql"] == ( + 'project in (PAY) AND created >= "2026-01-01" ' + 'AND statusCategory in ("To Do", "In Progress")' + ) + assert "ORDER BY" not in body["jql"] + return httpx.Response(200, json={"count": 42}) + + configured = source( + scope={ + "project_keys": ["PAY"], + "created_after": "2026-01-01", + "status_categories": ["new", "indeterminate"], + } + ) + http_client = httpx.Client( + base_url="https://acme.atlassian.net", + transport=httpx.MockTransport(handler), + ) + assert ( + JiraCloudClient( + configured, + client=http_client, + ).approximate_issue_count() + == 42 + ) + + +def test_existing_hash_lookup_treats_new_issue_ids_as_missing(tmp_path): + new_id = "jira:cloud-1:issue:100" + existing_id = "jira:cloud-1:issue:existing" + + class Connection: + def runInterpretedQuery(self, query): + assert "JiraIssue" in query + return [ + { + "res": [ + { + "v_id": existing_id, + "attributes": { + "res.content_hash": "existing-hash", + }, + } + ] + } + ] + + service = JiraSyncService( + "TestGraph", + source(), + Connection(), + store=JiraSourceStore(str(tmp_path)), + client=object(), + ) + assert service._existing_hashes([new_id, existing_id]) == { + existing_id: "existing-hash" + } + + +def test_comment_reconciliation_deletes_only_removed_comments( + tmp_path, + monkeypatch, +): + issue = { + "id": "10422", + "key": "PAY-123", + "fields": { + "summary": "Login timeout", + "project": {"id": "10001", "key": "PAY", "name": "Payments"}, + "comment": { + "comments": [ + { + "id": "9001", + "author": { + "accountId": "ada", + "displayName": "Ada", + }, + "created": "2026-09-18T11:02:00.000+0000", + "body": "Keep this comment.", + } + ] + }, + }, + } + mapped = JiraIssueMapper(source()).map(issue) + current_id = mapped.comments[0].comment_vertex_id + stale_id = "jira:comment:9002" + + class Connection: + def getEdges(self, vertex_type, vertex_id, edge_type): + assert vertex_type == "JiraIssue" + assert vertex_id == mapped.issue_vertex_id + assert edge_type == f"reverse_{JIRA_COMMENT_ISSUE_EDGE}" + return [{"to_id": current_id}, {"to_id": stale_id}] + + service = JiraSyncService( + "TestGraph", + source(), + Connection(), + store=JiraSourceStore(str(tmp_path)), + client=object(), + ) + deleted: list[tuple[str, str]] = [] + monkeypatch.setattr( + service, + "_delete_comment", + lambda issue_id, comment_id: deleted.append( + (issue_id, comment_id) + ), + ) + hashes = {current_id: "current", stale_id: "stale"} + + assert service._reconcile_issue_comments(mapped, hashes) == 1 + assert deleted == [(mapped.issue_vertex_id, stale_id)] + assert hashes == {current_id: "current"} + + +def test_sync_keeps_checkpoint_when_a_page_batch_is_interrupted( + tmp_path, + monkeypatch, +): + from connectors.jira import sync as sync_module + + store = JiraSourceStore(str(tmp_path)) + configured = store.upsert("TestGraph", source()) + issue = { + "id": "10422", + "key": "PAY-123", + "fields": { + "summary": "Login timeout", + "project": {"id": "10001", "key": "PAY", "name": "Payments"}, + "updated": "2026-09-20T14:03:00.000+0000", + }, + } + + class Client: + def cloud_id(self): + return "cloud-1" + + def iter_issue_pages(self): + yield [issue] + raise RuntimeError("simulated shutdown") + + class Connection: + def getVertices(self, vertex_type, select=""): + assert vertex_type == "Document" + assert select == "id" + return [] + + def runInterpretedQuery(self, query): + assert "JiraIssue" in query or "JiraComment" in query + return [{"res": []}] + + monkeypatch.setattr( + sync_module, + "jira_schema_status", + lambda *args: {"status": "installed"}, + ) + service = JiraSyncService( + "TestGraph", + configured, + Connection(), + store=store, + client=Client(), + ) + monkeypatch.setattr( + service, + "_upsert_issue_records", + lambda *args: ([], [], 0), + ) + + with pytest.raises(RuntimeError, match="simulated shutdown"): + service.run() + + stored = store.get("TestGraph", "jira-acme") + assert stored.sync.checkpoint is None + assert stored.sync.last_completed_at is None + assert stored.sync.last_error == "simulated shutdown" + + +def test_recovery_ignores_link_stubs_and_does_not_move_checkpoint( + tmp_path, + monkeypatch, +): + from connectors.jira import sync as sync_module + + store = JiraSourceStore(str(tmp_path)) + configured = store.upsert("TestGraph", source()) + configured.sync.checkpoint = None + store.update_runtime_state("TestGraph", configured) + recovered: dict = {} + + class Client: + def iter_issues_by_keys(self, keys): + recovered["keys"] = set(keys) + yield [ + { + "id": "1", + "key": "OLD-1", + "fields": { + "summary": "Already started", + "project": {"id": "1", "key": "PAY", "name": "Payments"}, + "status": {"name": "Done"}, + "updated": "2099-01-01T00:00:00.000+0000", + }, + } + ] + + def iter_issue_pages(self): + yield [ + { + "id": "2", + "key": "GLE-10", + "fields": { + "summary": "New project ticket", + "project": {"id": "2", "key": "GLE", "name": "GLE"}, + "updated": "2024-01-01T00:00:00.000+0000", + }, + } + ] + + class Connection: + def getVertices(self, vertex_type, select="", where="", limit=0): + if vertex_type == "Document": + return [] + if vertex_type == "JiraIssue": + return [ + { + "v_id": "jira:gle-stub:issue", + "attributes": { + "content_hash": "", + "status": "", + "updated": "1970-01-01 00:00:00", + }, + }, + { + "v_id": "jira:old-1:issue", + "attributes": { + "content_hash": "", + "status": "Done", + "updated": "2099-01-01 00:00:00", + }, + }, + ] + if vertex_type == "JiraComment": + return [ + { + "v_id": "jira:comment:parent", + "attributes": {"content_hash": "", "body": "", "created": ""}, + }, + { + "v_id": "jira:comment:real", + "attributes": { + "content_hash": "", + "body": "A real comment that was not embedded.", + "created": "2024-02-01 00:00:00", + }, + }, + ] + return [] + + def getEdges(self, vertex_type, vertex_id, edge_type): + if vertex_id == "jira:comment:real": + return [{"to_id": "jira:pay-9:issue"}] + return [] + + def runInterpretedQuery(self, query): + return [{"res": []}] + + monkeypatch.setattr( + sync_module, + "jira_schema_status", + lambda *args: {"status": "installed"}, + ) + monkeypatch.setattr( + sync_module, + "embedding_coverage", + lambda *args: {"total": 1, "missing": 0}, + ) + service = JiraSyncService( + "TestGraph", + store.get("TestGraph", "jira-acme"), + Connection(), + store=store, + client=Client(), + ) + monkeypatch.setattr( + service, + "_upsert_issue_records", + lambda *args: ([], [], 0), + ) + + result = service.run() + + assert recovered["keys"] == {"OLD-1", "PAY-9"} + assert result["status"] == "completed" + stored = store.get("TestGraph", "jira-acme") + assert stored.sync.checkpoint == datetime(2024, 1, 1, tzinfo=timezone.utc) + + +def test_sync_requires_rebuild_when_existing_chunks_lack_embeddings( + tmp_path, + monkeypatch, +): + from connectors.jira import sync as sync_module + + store = JiraSourceStore(str(tmp_path)) + configured = store.upsert("TestGraph", source()) + + class Client: + def cloud_id(self): + return "cloud-1" + + def iter_issue_pages(self): + return iter(()) + + class Connection: + def getVertices(self, vertex_type, select=""): + return [] + + def runInterpretedQuery(self, query): + return [{"res": []}] + + monkeypatch.setattr( + sync_module, + "jira_schema_status", + lambda *args: {"status": "installed"}, + ) + monkeypatch.setattr( + sync_module, + "embedding_coverage", + lambda *args: {"total": 12, "missing": 3}, + ) + + result = JiraSyncService( + "TestGraph", + configured, + Connection(), + store=store, + client=Client(), + ).run() + + assert result["documents_loaded"] == 0 + assert result["missing_chunk_embeddings"] == 3 + assert result["rebuild_required"] is True + + +def test_mapper_writes_small_schema_and_searchable_document(): + issue = { + "id": "10422", + "key": "PAY-123", + "fields": { + "summary": "Login timeout", + "project": {"id": "10001", "key": "PAY", "name": "Payments"}, + "issuetype": {"name": "Bug"}, + "status": { + "name": "In Progress", + "statusCategory": {"key": "indeterminate"}, + }, + "priority": {"name": "High"}, + "assignee": {"accountId": "ada", "displayName": "Ada"}, + "reporter": {"accountId": "grace", "displayName": "Grace"}, + "created": "2026-09-01T10:00:00.000+0000", + "updated": "2026-09-20T14:03:00.000+0000", + "description": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Timeout at checkout"}], + } + ], + }, + "comment": { + "comments": [ + { + "id": "9001", + "author": { + "accountId": "ada", + "displayName": "Ada", + }, + "created": "2026-09-18T11:02:00.000+0000", + "body": "Increase the gateway timeout.", + }, + { + "id": "9002", + "parentId": "9001", + "author": { + "accountId": "grace", + "displayName": "Grace", + }, + "created": "2026-09-18T12:02:00.000+0000", + "body": "The timeout was increased.", + } + ] + }, + "issuelinks": [], + }, + } + mapped = JiraIssueMapper(source()).map(issue) + assert {vertex.vertex_type for vertex in mapped.vertices} == { + "JiraComment", + "JiraIssue", + "JiraProject", + "JiraUser", + } + assert {edge.edge_type for edge in mapped.edges} == { + JIRA_PROJECT_EDGE, + JIRA_ASSIGNEE_EDGE, + JIRA_REPORTER_EDGE, + JIRA_COMMENT_ISSUE_EDGE, + JIRA_COMMENT_AUTHOR_EDGE, + JIRA_COMMENT_REPLY_EDGE, + JIRA_COMMENT_AFTER_EDGE, + } + fact_text = "\n".join(fact.chunk.text for fact in mapped.facts) + assert fact_text.startswith("Issue: PAY-123") + assert "Login timeout" in fact_text + assert "Increase the gateway timeout." not in fact_text + assert "1970" not in fact_text + assert "Fix versions" not in fact_text + comment_vertex = next( + vertex + for vertex in mapped.vertices + if vertex.vertex_type == "JiraComment" + and vertex.attributes.get("comment_id") == "9001" + ) + assert comment_vertex.attributes["body"] == "Increase the gateway timeout." + assert len(mapped.comments) == 2 + assert len(mapped.comments[0].chunks) == 1 + assert mapped.comments[0].chunks[0].chunk_id.startswith( + "jira:comment:9001:chunk:0:" + ) + assert mapped.comments[0].chunks[0].text.startswith("Issue: PAY-123") + assert "Increase the gateway timeout." in mapped.comments[0].chunks[0].text + + +def test_issue_changes_are_separate_facts_and_events(): + issue = { + "id": "10422", + "key": "GML-2192", + "fields": { + "summary": "Extend the LLM judge", + "project": {"id": "10001", "key": "GML", "name": "GraphRAG"}, + "status": {"name": "Done", "statusCategory": {"key": "done"}}, + "assignee": {"accountId": "prins", "displayName": "Prins Kumar"}, + "updated": "2026-09-24T10:00:00.000+0000", + "description": "Judge output now accepts gemini-3.5-flash.", + }, + "changelog": { + "histories": [ + { + "id": "7001", + "created": "2026-09-16T09:00:00.000+0000", + "author": { + "accountId": "prins", + "displayName": "Prins Kumar", + }, + "items": [ + { + "field": "status", + "fromString": "To Do", + "toString": "In Progress", + }, + { + "field": "Rank", + "fromString": "1", + "toString": "2", + }, + ], + }, + { + "id": "7002", + "created": "2026-09-24T10:00:00.000+0000", + "author": { + "accountId": "automation", + "displayName": "Automation for Jira", + "accountType": "app", + }, + "items": [ + { + "field": "status", + "fromString": "In Progress", + "toString": "Done", + } + ], + }, + ] + }, + } + + mapped = JiraIssueMapper(source()).map(issue) + changes = [ + vertex + for vertex in mapped.vertices + if vertex.vertex_type == "JiraChange" + ] + assert len(changes) == 2 + assert {vertex.attributes["field"] for vertex in changes} == {"status"} + assert {edge.edge_type for edge in mapped.edges} >= { + JIRA_CHANGE_EDGE, + JIRA_CHANGE_AUTHOR_EDGE, + } + fact_text = "\n".join(fact.chunk.text for fact in mapped.facts) + assert "Issue: GML-2192" in fact_text + assert 'changed status from "To Do" to "In Progress"' in fact_text + assert 'changed status from "In Progress" to "Done"' in fact_text + assert "Rank" not in fact_text + assert "1970" not in fact_text + assert "Fix versions" not in fact_text + change_facts = [ + fact for fact in mapped.facts if fact.entities + ] + assert len(change_facts) == 2 + assert all( + entity[0] == "JiraChange" for fact in change_facts for entity in fact.entities + ) + assert mapped.facts[0].chunk.chunk_id.startswith("jira:gml-2192:issue:fact:0:") + assert all( + fact.chunk.text.startswith("Issue: GML-2192") for fact in mapped.facts + ) + + +def test_issue_facts_are_upserted_directly_without_document(tmp_path, monkeypatch): + issue = { + "id": "10422", + "key": "GML-2192", + "fields": { + "summary": "Extend the LLM judge", + "project": {"id": "10001", "key": "GML", "name": "GraphRAG"}, + "status": {"name": "Done"}, + "description": "Judge output now accepts gemini-3.5-flash.", + }, + "changelog": { + "histories": [ + { + "id": "7001", + "created": "2026-09-16T09:00:00.000+0000", + "author": { + "accountId": "prins", + "displayName": "Prins Kumar", + }, + "items": [ + { + "field": "status", + "fromString": "To Do", + "toString": "In Progress", + } + ], + } + ] + }, + } + mapped = JiraIssueMapper(source()).map(issue) + service = JiraSyncService( + "TestGraph", + source(), + object(), + store=JiraSourceStore(str(tmp_path)), + client=object(), + ) + captured: dict = {} + monkeypatch.setattr( + service, + "_upsert_records", + lambda vertices, edges: captured.update(vertices=vertices, edges=edges), + ) + + service._upsert_issue_facts([mapped]) + + assert {vertex.vertex_type for vertex in captured["vertices"]} == { + "DocumentChunk", + "Content", + } + edge_types = { + (edge.source_type, edge.edge_type, edge.target_type) + for edge in captured["edges"] + } + assert ("DocumentChunk", "CONTAINS_ENTITY", "JiraIssue") in edge_types + assert ("DocumentChunk", "CONTAINS_ENTITY", "JiraChange") in edge_types + assert ("DocumentChunk", "HAS_CONTENT", "Content") in edge_types + assert ("DocumentChunk", "IS_AFTER", "DocumentChunk") in edge_types + assert "Document" not in { + vertex.vertex_type for vertex in captured["vertices"] + } + + +def test_long_jira_comment_drops_log_heavy_blocks_before_chunking(): + log_lines = "\n".join( + f"2026-09-28 12:00:{index:02d} ERROR request failed" + for index in range(80) + ) + issue = { + "id": "10422", + "key": "PAY-123", + "fields": { + "summary": "Login timeout", + "project": {"id": "10001", "key": "PAY", "name": "Payments"}, + "comment": { + "comments": [ + { + "id": "9001", + "author": { + "accountId": "ada", + "displayName": "Ada", + }, + "body": ( + "The gateway failed during checkout.\n\n" + f"```\n{log_lines}\n```\n\n" + "Please inspect the timeout configuration." + ), + } + ] + }, + }, + } + + mapped = JiraIssueMapper(source()).map(issue) + content = "\n".join( + chunk.text for chunk in mapped.comments[0].chunks + ) + assert "The gateway failed during checkout." in content + assert "Please inspect the timeout configuration." in content + assert "[Log output omitted from search content.]" in content + assert "ERROR request failed" not in content + + +def test_comment_chunks_are_upserted_directly_without_document(tmp_path, monkeypatch): + issue = { + "id": "10422", + "key": "PAY-123", + "fields": { + "summary": "Login timeout", + "project": {"id": "10001", "key": "PAY", "name": "Payments"}, + "comment": { + "comments": [ + { + "id": "9001", + "author": { + "accountId": "ada", + "displayName": "Ada", + }, + "body": "Increase the gateway timeout.", + } + ] + }, + }, + } + comment = JiraIssueMapper(source()).map(issue).comments[0] + service = JiraSyncService( + "TestGraph", + source(), + object(), + store=JiraSourceStore(str(tmp_path)), + client=object(), + ) + captured: dict = {} + monkeypatch.setattr( + service, + "_upsert_records", + lambda vertices, edges: captured.update( + vertices=vertices, + edges=edges, + ), + ) + + service._upsert_comment_chunks([comment]) + + assert {vertex.vertex_type for vertex in captured["vertices"]} == { + "DocumentChunk", + "Content", + } + assert "Document" not in { + vertex.vertex_type for vertex in captured["vertices"] + } + chunk_id = comment.chunks[0].chunk_id + assert { + (edge.source_type, edge.edge_type, edge.target_type) + for edge in captured["edges"] + } == { + ("DocumentChunk", "HAS_CONTENT", "Content"), + ("DocumentChunk", "CONTAINS_ENTITY", "JiraComment"), + ("DocumentChunk", "CONTAINS_ENTITY", "JiraIssue"), + } + chunk_vertex = next( + vertex + for vertex in captured["vertices"] + if vertex.vertex_type == "DocumentChunk" + ) + assert chunk_vertex.vertex_id == chunk_id + assert chunk_vertex.attributes["epoch_processed"] == 0 + + +def test_comment_chunks_are_embedded_by_existing_store(tmp_path, monkeypatch): + issue = { + "id": "10422", + "key": "PAY-123", + "fields": { + "summary": "Login timeout", + "project": {"id": "10001", "key": "PAY", "name": "Payments"}, + "comment": { + "comments": [ + { + "id": "9001", + "body": "Increase the gateway timeout.", + } + ] + }, + }, + } + comment = JiraIssueMapper(source()).map(issue).comments[0] + embedded: list[tuple[list, list]] = [] + processed: list[tuple[str, str, dict]] = [] + + class Store: + async def aadd_embeddings(self, embeddings, metadatas): + embedded.append((embeddings, metadatas)) + + class Connection: + def upsertVertex(self, vertex_type, vertex_id, attributes): + processed.append((vertex_type, vertex_id, attributes)) + + monkeypatch.setattr( + "connectors.jira.sync.get_embedding_store", + lambda **kwargs: Store(), + ) + service = JiraSyncService( + "TestGraph", + source(), + Connection(), + store=JiraSourceStore(str(tmp_path)), + client=object(), + ) + + service._embed_chunks(comment.chunks) + + assert embedded == [ + ( + [(comment.chunks[0].text, [])], + [ + { + "vertex_id": ( + comment.chunks[0].chunk_id, + "DocumentChunk", + ) + } + ], + ) + ] + assert processed[0][:2] == ( + "DocumentChunk", + comment.chunks[0].chunk_id, + ) + assert processed[0][2]["epoch_processed"] > 0 + + +def test_comment_cleanup_removes_direct_and_legacy_content(tmp_path, monkeypatch): + direct_chunk_id = "jira:cloud-1:comment:9001:chunk:0:abc" + + class Connection: + def getEdges(self, vertex_type, vertex_id, edge_type): + if vertex_type == "JiraComment": + return [ + { + "to_type": "DocumentChunk", + "to_id": direct_chunk_id, + } + ] + if vertex_type == "Document": + return [{"to_id": "legacy-chunk"}] + raise AssertionError((vertex_type, vertex_id, edge_type)) + + def delVerticesById(self, vertex_type, vertex_ids): + deleted.append((vertex_type, tuple(vertex_ids))) + + deleted: list[tuple[str, tuple[str, ...]]] = [] + service = JiraSyncService( + "TestGraph", + source(), + Connection(), + store=JiraSourceStore(str(tmp_path)), + client=object(), + ) + monkeypatch.setattr( + "connectors.jira.sync.get_embedding_store", + lambda **kwargs: type( + "Store", + (), + {"remove_embeddings": lambda self, ids: None}, + )(), + ) + + service._delete_comment_content( + "jira:cloud-1:issue:10422", + "jira:cloud-1:comment:9001", + ) + + assert ("DocumentChunk", (direct_chunk_id,)) in deleted + assert ("Content", (direct_chunk_id,)) in deleted + assert ("DocumentChunk", ("legacy-chunk",)) in deleted + assert ( + "Document", + ("jira:cloud-1:issue:10422:comment-doc:9001",), + ) in deleted + + +def test_legacy_comment_migration_resets_checkpoint_only_once(tmp_path): + store = JiraSourceStore(str(tmp_path)) + configured = store.upsert("TestGraph", source()) + configured.sync.checkpoint = datetime(2026, 9, 24, tzinfo=timezone.utc) + store.update_runtime_state("TestGraph", configured) + + class Connection: + def getVertices(self, vertex_type, select=""): + assert vertex_type == "Document" + assert select == "id" + return [ + { + "v_id": ( + "jira:cloud-1:issue:10422:" + "comment-doc:9001" + ) + } + ] + + service = JiraSyncService( + "TestGraph", + store.get("TestGraph", "jira-acme"), + Connection(), + store=store, + client=object(), + ) + service._prepare_legacy_comment_migration() + + migrated = store.get("TestGraph", "jira-acme") + assert migrated.sync.checkpoint is None + assert migrated.sync.migrating_legacy_comments is True + + migrated.sync.checkpoint = datetime(2026, 9, 25, tzinfo=timezone.utc) + store.update_runtime_state("TestGraph", migrated) + resumed = JiraSyncService( + "TestGraph", + store.get("TestGraph", "jira-acme"), + Connection(), + store=store, + client=object(), + ) + resumed._prepare_legacy_comment_migration() + assert store.get( + "TestGraph", + "jira-acme", + ).sync.checkpoint == datetime(2026, 9, 25, tzinfo=timezone.utc) + + +def test_schema_is_bounded(): + proposal = jira_schema_proposal() + assert {vertex.name for vertex in proposal.vertices} == { + "JiraChange", + "JiraComment", + "JiraIssue", + "JiraProject", + "JiraUser", + } + assert {edge.name for edge in proposal.edges} == { + JIRA_PROJECT_EDGE, + JIRA_ASSIGNEE_EDGE, + JIRA_REPORTER_EDGE, + JIRA_PARENT_EDGE, + JIRA_LINK_EDGE, + JIRA_COMMENT_ISSUE_EDGE, + JIRA_COMMENT_AUTHOR_EDGE, + JIRA_COMMENT_REPLY_EDGE, + JIRA_COMMENT_AFTER_EDGE, + JIRA_CHANGE_EDGE, + JIRA_CHANGE_AUTHOR_EDGE, + } + + +def test_store_only_preserves_manually_configured_token(tmp_path): + store = JiraSourceStore(str(tmp_path)) + original = source() + tested_at = datetime(2026, 9, 24, tzinfo=timezone.utc) + store.upsert("TestGraph", original) + config_path = tmp_path / "TestGraph" / "data_sources.json" + payload = json.loads(config_path.read_text()) + assert "api_token" not in payload["sources"][0]["connection"] + + payload["sources"][0]["connection"]["api_token"] = "configured-secret" + config_path.write_text(json.dumps(payload)) + runtime_source = store.get("TestGraph", "jira-acme") + runtime_source.sync.last_tested_at = tested_at + store.update_runtime_state("TestGraph", runtime_source) + redacted = store.list("TestGraph")[0] + assert redacted["connection"]["api_token"] == "" + + submitted = JiraDataSource.model_validate(redacted) + submitted.display_name = "Renamed" + store.upsert("TestGraph", submitted) + assert ( + store.get("TestGraph", "jira-acme").connection.api_token + == "configured-secret" + ) + assert store.get("TestGraph", "jira-acme").sync.last_tested_at == tested_at + + changed_credentials = store.get("TestGraph", "jira-acme") + changed_credentials.connection.api_token = "new-secret" + store.upsert("TestGraph", changed_credentials) + assert ( + store.get("TestGraph", "jira-acme").connection.api_token + == "configured-secret" + ) + + submitted.display_name = "Renamed again" + store.upsert("TestGraph", submitted) + +def test_store_resets_checkpoint_when_ingestion_filters_change(tmp_path): + store = JiraSourceStore(str(tmp_path)) + store.upsert("TestGraph", source()) + configured = store.get("TestGraph", "jira-acme") + configured.sync.checkpoint = datetime(2026, 9, 24, tzinfo=timezone.utc) + store.update_runtime_state("TestGraph", configured) + + changed = store.get("TestGraph", "jira-acme") + changed.scope.created_after = date(2026, 1, 1) + store.upsert("TestGraph", changed) + + assert store.get("TestGraph", "jira-acme").sync.checkpoint is None + + +class SchemaConnection: + def __init__(self): + proposal = jira_schema_proposal() + self.vertices = { + "Document": {}, + "DocumentChunk": {}, + "Content": {}, + **{ + vertex.name: { + "PrimaryId": {"AttributeName": "id"}, + "Attributes": [ + { + "AttributeName": attribute.name, + "AttributeType": {"Name": attribute.type}, + } + for attribute in vertex.attributes + ], + } + for vertex in proposal.vertices + }, + } + self.edges = { + edge.name: { + "FromVertexTypeName": edge.pairs[0][0], + "ToVertexTypeName": edge.pairs[0][1], + "IsDirected": edge.directed, + "Attributes": [ + { + "AttributeName": attribute.name, + "AttributeType": {"Name": attribute.type}, + } + for attribute in edge.attributes + ], + } + for edge in proposal.edges + } + self.edges["CONTAINS_ENTITY"] = { + "FromVertexTypeName": "*", + "ToVertexTypeName": "*", + "IsDirected": True, + "EdgePairs": [ + {"From": "Document", "To": "JiraIssue"}, + {"From": "DocumentChunk", "To": "JiraIssue"}, + {"From": "Document", "To": "JiraComment"}, + {"From": "DocumentChunk", "To": "JiraComment"}, + {"From": "Document", "To": "JiraChange"}, + {"From": "DocumentChunk", "To": "JiraChange"}, + ], + } + self.edges["HAS_CHILD"] = { + "FromVertexTypeName": "Document", + "ToVertexTypeName": "DocumentChunk", + "IsDirected": True, + } + self.edges["HAS_CONTENT"] = { + "FromVertexTypeName": "*", + "ToVertexTypeName": "*", + "IsDirected": True, + "EdgePairs": [ + {"From": "Document", "To": "Content"}, + {"From": "DocumentChunk", "To": "Content"}, + ], + } + + def getVertexTypes(self): + return list(self.vertices) + + def getVertexType(self, name): + return self.vertices[name] + + def getEdgeTypes(self): + return list(self.edges) + + def getEdgeType(self, name): + return self.edges[name] + + +def test_schema_status_detects_installed_and_conflicting_schema(): + conn = SchemaConnection() + assert jira_schema_status(conn)["status"] == "installed" + + issue_attributes = conn.vertices["JiraIssue"]["Attributes"] + next( + attribute + for attribute in issue_attributes + if attribute["AttributeName"] == "story_points" + )["AttributeType"]["Name"] = "STRING" + result = jira_schema_status(conn) + assert result["status"] == "conflict" + assert "JiraIssue.story_points must be DOUBLE" in result["conflicts"][0] + + +def test_schema_status_marks_partial_graph_incomplete(): + conn = SchemaConnection() + conn.vertices.pop("JiraComment") + for edge_type in ( + JIRA_COMMENT_ISSUE_EDGE, + JIRA_COMMENT_AUTHOR_EDGE, + JIRA_COMMENT_REPLY_EDGE, + JIRA_COMMENT_AFTER_EDGE, + ): + conn.edges.pop(edge_type) + conn.edges["CONTAINS_ENTITY"]["EdgePairs"] = [ + pair + for pair in conn.edges["CONTAINS_ENTITY"]["EdgePairs"] + if pair["To"] != "JiraComment" + ] + + result = jira_schema_status(conn) + assert result["status"] == "incomplete"