From 21560cad37ca5ec14afe0114a97077323a4cdd78 Mon Sep 17 00:00:00 2001 From: ViktorBilokin Date: Sun, 29 Mar 2026 21:10:15 -0400 Subject: [PATCH 01/16] feat: Jira integration, sync improvements, and dashboard bot filter cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Jira integration: JiraController, JiraService, JiraPage with full UI - Sync page: sortable table (by name/status/last synced), status-first default sort, Actions column header, Re-sync/Clean styled as buttons - Sync trigger: now includes completed repos for incremental re-sync alongside pending (full sync) - Dashboard: remove automatic [bot] login filter — bots must now be listed explicitly in Bot Names setting - Config page: update Bot Names hint to drop the [bot] auto-filter mention - Makefile: force-kill lingering processes on ports 8080/3000/5432 during stop - application.yml: add Anthropic API key config - Add react-markdown dependency Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 4 + .../controller/AdminController.java | 39 +- .../controller/JiraController.java | 36 + .../githubpulse/service/jira/JiraClient.java | 322 +++++ .../githubpulse/service/jira/JiraEpicDto.java | 13 + .../service/jira/JiraIssueDetailDto.java | 12 + .../service/jira/JiraIssueDto.java | 10 + .../service/jira/TicketQualityService.java | 153 +++ backend/src/main/resources/application.yml | 4 + frontend/package-lock.json | 1184 ++++++++++++++++- frontend/package.json | 1 + frontend/src/App.tsx | 7 +- frontend/src/api/client.ts | 35 + frontend/src/components/Layout.tsx | 52 +- frontend/src/pages/ConfigPage.tsx | 9 +- frontend/src/pages/DashboardPage.tsx | 5 +- frontend/src/pages/JiraPage.tsx | 383 ++++++ frontend/src/pages/SyncPage.tsx | 101 +- 18 files changed, 2311 insertions(+), 59 deletions(-) create mode 100644 backend/src/main/java/com/githubpulse/controller/JiraController.java create mode 100644 backend/src/main/java/com/githubpulse/service/jira/JiraClient.java create mode 100644 backend/src/main/java/com/githubpulse/service/jira/JiraEpicDto.java create mode 100644 backend/src/main/java/com/githubpulse/service/jira/JiraIssueDetailDto.java create mode 100644 backend/src/main/java/com/githubpulse/service/jira/JiraIssueDto.java create mode 100644 backend/src/main/java/com/githubpulse/service/jira/TicketQualityService.java create mode 100644 frontend/src/pages/JiraPage.tsx diff --git a/Makefile b/Makefile index c25550d..34cd540 100644 --- a/Makefile +++ b/Makefile @@ -71,6 +71,10 @@ stop: -@pkill -f "spring-boot:run" 2>/dev/null || true -@pkill -f "vite" 2>/dev/null || true docker compose down + @echo "Clearing any remaining processes on target ports..." + -@lsof -ti :8080 | xargs kill -9 2>/dev/null || true + -@lsof -ti :3000 | xargs kill -9 2>/dev/null || true + -@lsof -ti :5432 | xargs kill -9 2>/dev/null || true @echo "All services stopped." # Restart diff --git a/backend/src/main/java/com/githubpulse/controller/AdminController.java b/backend/src/main/java/com/githubpulse/controller/AdminController.java index 2d8af77..13aaa70 100644 --- a/backend/src/main/java/com/githubpulse/controller/AdminController.java +++ b/backend/src/main/java/com/githubpulse/controller/AdminController.java @@ -17,6 +17,7 @@ import com.githubpulse.domain.enums.SyncStatus; import java.time.Instant; +import java.util.ArrayList; import java.util.List; @RestController @@ -200,20 +201,32 @@ public ResponseEntity untrackRepository(@PathVariable Long id) { @PostMapping("/sync") public ResponseEntity triggerSync() { List activeRepos = repositoryRepository.findByIsActiveTrue(); - List pendingRepos = activeRepos.stream() - .filter(repo -> { - var syncState = syncStateRepository.findByRepositoryId(repo.getId()); - return syncState.isEmpty() || syncState.get().getStatus() == SyncStatus.PENDING; - }) - .toList(); - if (pendingRepos.isEmpty()) { - return ResponseEntity.ok("No pending repositories to sync."); + + List pendingRepos = new ArrayList<>(); + List completedRepos = new ArrayList<>(); + + for (Repository repo : activeRepos) { + var syncState = syncStateRepository.findByRepositoryId(repo.getId()); + if (syncState.isEmpty() || syncState.get().getStatus() == SyncStatus.PENDING) { + pendingRepos.add(repo); + } else if (syncState.get().getStatus() == SyncStatus.COMPLETED) { + completedRepos.add(repo); + } } - log.info("[SYNC] Manual sync triggered for {} pending repositories (out of {} active): {}", - pendingRepos.size(), activeRepos.size(), - pendingRepos.stream().map(Repository::getFullName).toList()); - syncOrchestrator.syncReposAsync(pendingRepos); - return ResponseEntity.ok("Sync triggered for " + pendingRepos.size() + " pending repositories. Running in background."); + + List toSync = new ArrayList<>(); + toSync.addAll(pendingRepos); + toSync.addAll(completedRepos); + + if (toSync.isEmpty()) { + return ResponseEntity.ok("No repositories to sync."); + } + + log.info("[SYNC] Manual sync triggered: {} pending (full) + {} completed (incremental since last sync)", + pendingRepos.size(), completedRepos.size()); + syncOrchestrator.syncReposAsync(toSync); + return ResponseEntity.ok("Sync triggered: " + pendingRepos.size() + " pending (full) + " + + completedRepos.size() + " completed (incremental). Running in background."); } @PostMapping("/sync/failed") diff --git a/backend/src/main/java/com/githubpulse/controller/JiraController.java b/backend/src/main/java/com/githubpulse/controller/JiraController.java new file mode 100644 index 0000000..531f89c --- /dev/null +++ b/backend/src/main/java/com/githubpulse/controller/JiraController.java @@ -0,0 +1,36 @@ +package com.githubpulse.controller; + +import com.githubpulse.service.jira.JiraClient; +import com.githubpulse.service.jira.JiraEpicDto; +import com.githubpulse.service.jira.JiraIssueDetailDto; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/jira") +@CrossOrigin(origins = "*") +public class JiraController { + + private final JiraClient jiraClient; + + public JiraController(JiraClient jiraClient) { + this.jiraClient = jiraClient; + } + + @GetMapping("/epics") + public List getEpics() { + return jiraClient.getEpicsWithChildren(); + } + + @GetMapping("/issue/{key}") + public JiraIssueDetailDto getIssueDetail(@PathVariable String key) { + return jiraClient.getIssueDetail(key); + } + + @GetMapping("/debug") + public Map debug() { + return jiraClient.debug(); + } +} diff --git a/backend/src/main/java/com/githubpulse/service/jira/JiraClient.java b/backend/src/main/java/com/githubpulse/service/jira/JiraClient.java new file mode 100644 index 0000000..10a6cbc --- /dev/null +++ b/backend/src/main/java/com/githubpulse/service/jira/JiraClient.java @@ -0,0 +1,322 @@ +package com.githubpulse.service.jira; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.githubpulse.repository.AppSettingRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.*; + +@Service +public class JiraClient { + + private static final Logger log = LoggerFactory.getLogger(JiraClient.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private final AppSettingRepository settingsRepository; + private final TicketQualityService qualityService; + private final HttpClient httpClient = HttpClient.newHttpClient(); + + public JiraClient(AppSettingRepository settingsRepository, TicketQualityService qualityService) { + this.settingsRepository = settingsRepository; + this.qualityService = qualityService; + } + + private String getSetting(String key) { + return settingsRepository.findById(key).map(s -> s.getValue()).orElse(""); + } + + public JiraIssueDetailDto getIssueDetail(String key) { + String host = getSetting("jira.host").strip(); + if (host.endsWith("/")) host = host.substring(0, host.length() - 1); + String email = getSetting("jira.email").strip(); + String token = getSetting("jira.token").strip(); + String auth = Base64.getEncoder() + .encodeToString((email + ":" + token).getBytes(StandardCharsets.UTF_8)); + + Map raw = rawGet( + host + "/rest/api/3/issue/" + key + "?fields=summary,description,customfield_10037,customfield_10158", + auth); + + try { + JsonNode fields = ((JsonNode) raw.get("body")).path("fields"); + String summary = fields.path("summary").asText(null); + String description = extractAdfText(fields.path("description")); + // acceptance criteria: try customfield_10037 first, then customfield_10158 + String ac = extractAdfText(fields.path("customfield_10037")); + if (ac == null) ac = extractAdfText(fields.path("customfield_10158")); + + // fetch epic summary for context + String epicSummary = null; + JsonNode epicLinkNode = fields.path("customfield_10014"); + if (!epicLinkNode.isMissingNode() && !epicLinkNode.isNull()) { + String epicKey = epicLinkNode.asText(null); + if (epicKey != null && !epicKey.isBlank()) { + String host2 = getSetting("jira.host").strip(); + if (host2.endsWith("/")) host2 = host2.substring(0, host2.length() - 1); + String auth2 = Base64.getEncoder() + .encodeToString((getSetting("jira.email").strip() + ":" + getSetting("jira.token").strip()) + .getBytes(StandardCharsets.UTF_8)); + Map epicRaw = rawGet(host2 + "/rest/api/3/issue/" + epicKey + "?fields=summary", auth2); + JsonNode epicBody = (JsonNode) epicRaw.get("body"); + if (epicBody != null) epicSummary = epicBody.path("fields").path("summary").asText(null); + } + } + + TicketQualityService.TicketQualityResult quality = + qualityService.evaluate(key, epicSummary, summary, description, ac); + + return new JiraIssueDetailDto(key, summary, description, ac, + quality.score(), quality.feedback(), + quality.improvedDescription(), quality.improvedAcceptanceCriteria()); + } catch (Exception e) { + log.error("[JIRA] Failed to parse issue detail for {}: {}", key, e.getMessage()); + throw new RuntimeException("Failed to load issue details: " + e.getMessage(), e); + } + } + + /** Recursively extracts plain text from Atlassian Document Format (ADF) JSON. */ + private String extractAdfText(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) return null; + StringBuilder sb = new StringBuilder(); + extractAdfTextInto(node, sb); + String result = sb.toString().strip(); + return result.isEmpty() ? null : result; + } + + private void extractAdfTextInto(JsonNode node, StringBuilder sb) { + if (node.isTextual()) { + sb.append(node.asText()); + return; + } + // ADF text node + JsonNode text = node.path("text"); + if (!text.isMissingNode()) { + sb.append(text.asText()); + } + // recurse into content array + JsonNode content = node.path("content"); + if (content.isArray()) { + String type = node.path("type").asText(""); + for (JsonNode child : content) { + extractAdfTextInto(child, sb); + } + // add newline after block-level nodes + if (type.matches("paragraph|heading|bulletList|orderedList|listItem|blockquote|codeBlock|rule")) { + sb.append("\n"); + } + } + } + + public Map debug() { + String host = getSetting("jira.host").strip(); + if (host.endsWith("/")) host = host.substring(0, host.length() - 1); + String project = getSetting("jira.project").strip(); + String email = getSetting("jira.email").strip(); + String token = getSetting("jira.token").strip(); + String auth = Base64.getEncoder() + .encodeToString((email + ":" + token).getBytes(StandardCharsets.UTF_8)); + + Map result = new LinkedHashMap<>(); + result.put("settings", Map.of( + "host", host, "project", project, "email", email, + "tokenLength", token.length())); + + // 1. Who am I? + result.put("myself", rawGet(host + "/rest/api/3/myself", auth)); + + // 2. Can I see the project? + result.put("project", rawGet(host + "/rest/api/3/project/" + + java.net.URLEncoder.encode(project, StandardCharsets.UTF_8), auth)); + + // 3. Minimal search with properly quoted project name + result.put("searchAny", rawGet( + host + "/rest/api/3/search/jql?jql=" + + java.net.URLEncoder.encode("project=\"" + project + "\"", StandardCharsets.UTF_8) + + "&maxResults=3", auth)); + + // 4. List all projects accessible to this token + result.put("allProjects", rawGet(host + "/rest/api/3/project/search?maxResults=50", auth)); + + return result; + } + + private Map rawGet(String url, String auth) { + try { + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Basic " + auth) + .header("Accept", "application/json") + .GET().build(); + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + log.info("[JIRA DEBUG] GET {} → {}: {}", url, resp.statusCode(), resp.body()); + try { + return Map.of("status", resp.statusCode(), "body", objectMapper.readTree(resp.body())); + } catch (Exception e) { + return Map.of("status", resp.statusCode(), "rawBody", resp.body()); + } + } catch (Exception e) { + return Map.of("error", e.getMessage()); + } + } + + public List getEpicsWithChildren() { + String host = getSetting("jira.host").strip(); + if (host.endsWith("/")) host = host.substring(0, host.length() - 1); + String project = getSetting("jira.project").strip(); + String email = getSetting("jira.email").strip(); + String token = getSetting("jira.token").strip(); + + log.info("[JIRA] Settings — host='{}' project='{}' email='{}' token={}", + host, project, email, token.isBlank() ? "MISSING" : "SET(" + token.length() + " chars)"); + + if (host.isBlank() || project.isBlank() || email.isBlank() || token.isBlank()) { + log.warn("[JIRA] Missing configuration — host, project, email or token not set"); + return List.of(); + } + + String auth = Base64.getEncoder() + .encodeToString((email + ":" + token).getBytes(StandardCharsets.UTF_8)); + + log.info("[JIRA] Fetching non-done epics for project '{}'", project); + List epicNodes = searchIssues(host, auth, + "project=\"" + project + "\" AND issuetype=Epic AND status!=Done ORDER BY key ASC"); + log.info("[JIRA] Found {} epic(s)", epicNodes.size()); + + Map epicMap = new LinkedHashMap<>(); + for (JsonNode node : epicNodes) { + JiraIssueDto issue = mapIssue(node); + epicMap.put(issue.key(), + new JiraEpicDto(issue.key(), issue.summary(), issue.status(), issue.assignee(), + issue.priority(), issue.storyPoints(), new ArrayList<>())); + } + + if (!epicMap.isEmpty()) { + String epicKeys = epicMap.keySet().stream() + .map(k -> "\"" + k + "\"") + .reduce((a, b) -> a + "," + b).orElse(""); + log.info("[JIRA] Fetching children for {} epic(s)", epicMap.size()); + List childNodes = searchIssues(host, auth, + "project=\"" + project + "\" AND \"Epic Link\" in (" + epicKeys + ") AND status!=Done ORDER BY key ASC"); + log.info("[JIRA] Found {} child issue(s)", childNodes.size()); + + for (JsonNode node : childNodes) { + JiraIssueDto issue = mapIssue(node); + String epicKey = resolveEpicKey(node); + if (epicKey != null && epicMap.containsKey(epicKey)) { + epicMap.get(epicKey).children().add(issue); + } + } + } + + List result = new ArrayList<>(epicMap.values()); + return result; + } + + private String resolveEpicKey(JsonNode issueNode) { + JsonNode fields = issueNode.path("fields"); + + // Next-gen: parent whose issuetype is Epic + JsonNode parent = fields.path("parent"); + if (!parent.isMissingNode() && !parent.isNull()) { + String parentType = parent.path("fields").path("issuetype").path("name").asText(""); + if ("Epic".equals(parentType)) { + return parent.path("key").asText(null); + } + } + + // Classic: customfield_10014 (Epic Link field stores the epic key as plain text) + JsonNode epicLink = fields.path("customfield_10014"); + if (!epicLink.isMissingNode() && !epicLink.isNull()) { + String val = epicLink.asText(null); + if (val != null && !val.isBlank()) return val; + } + + return null; + } + + private List searchIssues(String host, String auth, String jql) { + List all = new ArrayList<>(); + String nextPageToken = null; + int maxResults = 100; + + while (true) { + try { + String url = host + "/rest/api/3/search/jql?jql=" + + java.net.URLEncoder.encode(jql, StandardCharsets.UTF_8) + + "&fields=summary,assignee,priority,customfield_10002,issuetype,parent,customfield_10014,status" + + "&maxResults=" + maxResults + + (nextPageToken != null ? "&nextPageToken=" + java.net.URLEncoder.encode(nextPageToken, StandardCharsets.UTF_8) : ""); + log.info("[JIRA] GET {}", url); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Basic " + auth) + .header("Accept", "application/json") + .GET() + .build(); + + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() >= 400) { + String msg = "Jira API error " + response.statusCode() + ": " + response.body(); + log.error("[JIRA] {}", msg); + throw new RuntimeException(msg); + } + + JsonNode root = objectMapper.readTree(response.body()); + log.info("[JIRA] Response status={} body={}", response.statusCode(), response.body()); + JsonNode issues = root.path("issues"); + if (issues.isEmpty()) { + log.info("[JIRA] No more issues in response (nextPageToken={})", nextPageToken); + break; + } + + for (JsonNode issue : issues) all.add(issue); + + JsonNode nextToken = root.path("nextPageToken"); + if (nextToken.isMissingNode() || nextToken.isNull()) break; + nextPageToken = nextToken.asText(); + + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + log.error("[JIRA] Failed to call Jira API: {}", e.getMessage()); + throw new RuntimeException("Failed to call Jira API: " + e.getMessage(), e); + } + } + return all; + } + + private JiraIssueDto mapIssue(JsonNode issue) { + String key = issue.path("key").asText(); + JsonNode fields = issue.path("fields"); + + String summary = fields.path("summary").asText(); + + String assignee = null; + JsonNode assigneeNode = fields.path("assignee"); + if (!assigneeNode.isMissingNode() && !assigneeNode.isNull()) { + assignee = assigneeNode.path("displayName").asText(null); + } + + String priority = fields.path("priority").path("name").asText(null); + + String status = fields.path("status").path("name").asText(null); + + Integer storyPoints = null; + JsonNode sp = fields.path("customfield_10002"); + if (!sp.isMissingNode() && !sp.isNull()) { + storyPoints = sp.asInt(); + } + + return new JiraIssueDto(key, summary, status, assignee, priority, storyPoints); + } +} diff --git a/backend/src/main/java/com/githubpulse/service/jira/JiraEpicDto.java b/backend/src/main/java/com/githubpulse/service/jira/JiraEpicDto.java new file mode 100644 index 0000000..f7ef9b1 --- /dev/null +++ b/backend/src/main/java/com/githubpulse/service/jira/JiraEpicDto.java @@ -0,0 +1,13 @@ +package com.githubpulse.service.jira; + +import java.util.List; + +public record JiraEpicDto( + String key, + String summary, + String status, + String assignee, + String priority, + Integer storyPoints, + List children +) {} diff --git a/backend/src/main/java/com/githubpulse/service/jira/JiraIssueDetailDto.java b/backend/src/main/java/com/githubpulse/service/jira/JiraIssueDetailDto.java new file mode 100644 index 0000000..be18286 --- /dev/null +++ b/backend/src/main/java/com/githubpulse/service/jira/JiraIssueDetailDto.java @@ -0,0 +1,12 @@ +package com.githubpulse.service.jira; + +public record JiraIssueDetailDto( + String key, + String summary, + String description, + String acceptanceCriteria, + Integer qualityScore, + String qualityFeedback, + String improvedDescription, + String improvedAcceptanceCriteria +) {} diff --git a/backend/src/main/java/com/githubpulse/service/jira/JiraIssueDto.java b/backend/src/main/java/com/githubpulse/service/jira/JiraIssueDto.java new file mode 100644 index 0000000..c9fb6e8 --- /dev/null +++ b/backend/src/main/java/com/githubpulse/service/jira/JiraIssueDto.java @@ -0,0 +1,10 @@ +package com.githubpulse.service.jira; + +public record JiraIssueDto( + String key, + String summary, + String status, + String assignee, + String priority, + Integer storyPoints +) {} diff --git a/backend/src/main/java/com/githubpulse/service/jira/TicketQualityService.java b/backend/src/main/java/com/githubpulse/service/jira/TicketQualityService.java new file mode 100644 index 0000000..0bfc04d --- /dev/null +++ b/backend/src/main/java/com/githubpulse/service/jira/TicketQualityService.java @@ -0,0 +1,153 @@ +package com.githubpulse.service.jira; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.githubpulse.repository.AppSettingRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Service +public class TicketQualityService { + + private static final Logger log = LoggerFactory.getLogger(TicketQualityService.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String CLAUDE_API_URL = "https://api.anthropic.com/v1/messages"; + private static final String CLAUDE_MODEL = "claude-haiku-4-5-20251001"; + + private final AppSettingRepository settingsRepository; + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @Value("${anthropic.api-key:}") + private String apiKeyFromEnv; + + public TicketQualityService(AppSettingRepository settingsRepository) { + this.settingsRepository = settingsRepository; + } + + private String getApiKey() { + String fromDb = settingsRepository.findById("anthropic.token") + .map(s -> s.getValue()).orElse("").strip(); + return fromDb.isBlank() ? apiKeyFromEnv : fromDb; + } + + public TicketQualityResult evaluate(String issueKey, String epicSummary, + String summary, String description, String acceptanceCriteria) { + String apiKey = getApiKey(); + if (apiKey == null || apiKey.isBlank()) { + log.warn("[QUALITY] Anthropic API key not configured"); + return new TicketQualityResult(null, "Anthropic API key not configured.", null, null); + } + + String prompt = buildPrompt(issueKey, epicSummary, summary, description, acceptanceCriteria); + + try { + ObjectNode body = objectMapper.createObjectNode(); + body.put("model", CLAUDE_MODEL); + body.put("max_tokens", 1024); + ArrayNode messages = body.putArray("messages"); + ObjectNode msg = messages.addObject(); + msg.put("role", "user"); + msg.put("content", prompt); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(CLAUDE_API_URL)) + .header("x-api-key", apiKey) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() >= 400) { + log.error("[QUALITY] Claude API error {}: {}", response.statusCode(), response.body()); + return new TicketQualityResult(null, "Claude API error: " + response.statusCode(), null, null); + } + + JsonNode root = objectMapper.readTree(response.body()); + String text = root.path("content").get(0).path("text").asText("").strip(); + return parseResponse(text); + + } catch (Exception e) { + log.error("[QUALITY] Failed to evaluate ticket {}: {}", issueKey, e.getMessage()); + return new TicketQualityResult(null, "Evaluation failed: " + e.getMessage(), null, null); + } + } + + private String buildPrompt(String issueKey, String epicSummary, + String summary, String description, String acceptanceCriteria) { + return """ + You are a senior software engineering analyst evaluating Jira ticket quality. + + Ticket: %s + Epic context: %s + Summary: %s + Description: %s + Acceptance Criteria: %s + + Evaluate the quality of this ticket's description and acceptance criteria. Consider: + - Is the scope clearly defined? What exactly should be done? + - Is there a clear definition of what is OUT of scope? + - Are assumptions stated? + - Are acceptance criteria testable and specific? + - Is there enough detail for a developer to start work without clarification? + + Then provide an improved version of the description and acceptance criteria addressing the gaps. + + Respond ONLY in this exact format (no other text): + SCORE: + FEEDBACK: <2-3 sentences of concise feedback> + IMPROVED_DESCRIPTION: + IMPROVED_AC: + """.formatted( + issueKey, + epicSummary != null ? epicSummary : "N/A", + summary != null ? summary : "N/A", + description != null ? description : "N/A", + acceptanceCriteria != null ? acceptanceCriteria : "N/A" + ); + } + + private TicketQualityResult parseResponse(String text) { + try { + Integer score = null; + String feedback = null; + String improvedDescription = null; + String improvedAc = null; + + // Split on known section markers to handle multi-line values + String[] sections = text.split("\n(?=SCORE:|FEEDBACK:|IMPROVED_DESCRIPTION:|IMPROVED_AC:)"); + for (String section : sections) { + if (section.startsWith("SCORE:")) { + score = Integer.parseInt(section.replace("SCORE:", "").strip()); + } else if (section.startsWith("FEEDBACK:")) { + feedback = section.replace("FEEDBACK:", "").strip(); + } else if (section.startsWith("IMPROVED_DESCRIPTION:")) { + improvedDescription = section.replace("IMPROVED_DESCRIPTION:", "").strip(); + } else if (section.startsWith("IMPROVED_AC:")) { + improvedAc = section.replace("IMPROVED_AC:", "").strip(); + } + } + + if (score == null) { + log.warn("[QUALITY] Could not parse score from response: {}", text); + return new TicketQualityResult(null, text, null, null); + } + return new TicketQualityResult(score, feedback, improvedDescription, improvedAc); + } catch (Exception e) { + return new TicketQualityResult(null, text, null, null); + } + } + + public record TicketQualityResult(Integer score, String feedback, + String improvedDescription, String improvedAcceptanceCriteria) {} +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index f5f1f23..aa5c293 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -38,6 +38,10 @@ github: max-concurrent-requests: 10 requests-per-second: 10 +# Anthropic configuration +anthropic: + api-key: ${ANTHROPIC_API_KEY:} + # Sync configuration sync: cron: "0 0 * * * *" # Every hour diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb3d3a3..d7e28fb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "lucide-react": "^1.7.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-markdown": "^10.1.0", "react-router-dom": "^7.13.2", "recharts": "^3.8.1", "shadcn": "^4.1.0", @@ -2496,13 +2497,39 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2510,6 +2537,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", @@ -2524,7 +2566,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2546,6 +2587,12 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2853,6 +2900,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -3079,6 +3132,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3260,6 +3323,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3277,6 +3350,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3458,6 +3571,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -3597,7 +3720,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -3753,6 +3875,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -3841,6 +3976,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3857,6 +4001,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -4238,6 +4395,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4405,6 +4572,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4933,6 +5106,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/headers-polyfill": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", @@ -4965,6 +5178,16 @@ "node": ">=16.9.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5074,6 +5297,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -5101,12 +5330,46 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -5152,6 +5415,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -5765,6 +6038,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5802,6 +6085,159 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -5838,17 +6274,459 @@ "node": ">= 8" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" } }, "node_modules/micromatch/node_modules/picomatch": { @@ -6281,6 +7159,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -6478,6 +7381,16 @@ "node": ">=6" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6594,6 +7507,33 @@ "license": "MIT", "peer": true }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -6785,6 +7725,39 @@ "redux": "^5.0.0" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7238,6 +8211,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7282,6 +8265,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", @@ -7348,6 +8345,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7484,6 +8499,26 @@ "node": ">=16" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -7660,6 +8695,93 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -7803,6 +8925,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -8164,6 +9314,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 45ce405..d1f4ac5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "lucide-react": "^1.7.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-markdown": "^10.1.0", "react-router-dom": "^7.13.2", "recharts": "^3.8.1", "shadcn": "^4.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f903c7..2d35e59 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Layout } from '@/components/Layout'; import { DashboardPage } from '@/pages/DashboardPage'; import { AdminPage } from '@/pages/AdminPage'; import { ConfigPage } from '@/pages/ConfigPage'; import { SyncPage } from '@/pages/SyncPage'; +import { JiraPage } from '@/pages/JiraPage'; const queryClient = new QueryClient({ defaultOptions: { @@ -21,10 +22,12 @@ function App() { }> - } /> + } /> + } /> } /> } /> } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8db88bc..54cf6b9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -109,6 +109,41 @@ export async function getSyncHistory(): Promise { return data; } +// Jira +export interface JiraIssue { + key: string; + summary: string; + status: string | null; + assignee: string | null; + priority: string | null; + storyPoints: number | null; +} + +export interface JiraEpic extends JiraIssue { + children: JiraIssue[]; +} + +export async function getJiraEpics(): Promise { + const { data } = await api.get('/jira/epics'); + return data; +} + +export interface JiraIssueDetail { + key: string; + summary: string; + description: string | null; + acceptanceCriteria: string | null; + qualityScore: number | null; + qualityFeedback: string | null; + improvedDescription: string | null; + improvedAcceptanceCriteria: string | null; +} + +export async function getJiraIssueDetail(key: string): Promise { + const { data } = await api.get(`/jira/issue/${key}`); + return data; +} + // Settings export async function getSettings(): Promise> { const { data } = await api.get('/admin/settings'); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 64f2c8e..5598fa2 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,7 @@ +import { useState } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; import { cn } from '@/lib/utils'; -import { LayoutDashboard, Settings, Cog, RefreshCw } from 'lucide-react'; +import { LayoutDashboard, Settings, Cog, RefreshCw, Ticket, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; interface NavItem { to: string; @@ -10,7 +11,8 @@ interface NavItem { } const navItems: NavItem[] = [ - { to: '/', label: 'Dashboard', icon: LayoutDashboard }, + { to: '/github', label: 'Git Activity', icon: LayoutDashboard }, + { to: '/jira', label: 'Jira', icon: Ticket }, { to: '/admin', label: 'Admin', @@ -22,47 +24,67 @@ const navItems: NavItem[] = [ }, ]; -function NavItemLink({ item, nested }: { item: NavItem; nested?: boolean }) { +function NavItemLink({ item, nested, collapsed }: { item: NavItem; nested?: boolean; collapsed: boolean }) { return ( cn( 'flex items-center gap-3 rounded-md text-sm font-medium transition-colors', - nested ? 'py-1.5 px-3 pl-10' : 'px-3 py-2', + collapsed ? 'justify-center px-2 py-2' : nested ? 'py-1.5 px-3 pl-10' : 'px-3 py-2', isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground' ) } > - - {item.label} + + {!collapsed && item.label} ); } export function Layout() { + const [collapsed, setCollapsed] = useState(false); + return (
-