diff --git a/.github/workflows/version-dashboard.yml b/.github/workflows/version-dashboard.yml
new file mode 100644
index 0000000..6a49cc6
--- /dev/null
+++ b/.github/workflows/version-dashboard.yml
@@ -0,0 +1,236 @@
+name: Version Dashboard
+
+on:
+ schedule:
+ - cron: '*/30 * * * *' # every 30 minutes
+ workflow_dispatch: # allow manual trigger
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ generate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Fetch latest GitHub releases
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh api "/repos/$GITHUB_REPOSITORY/releases?per_page=100" > releases.json
+ echo "Fetched $(jq length releases.json) releases"
+
+ - name: Check health endpoints
+ run: |
+ mkdir -p health_results
+
+ check_health() {
+ local name=$1 env=$2 url=$3
+ local outfile="health_results/${name}__${env}.json"
+
+ http_code=$(curl -sf -o "/tmp/body_${name}_${env}.json" \
+ -w "%{http_code}" --max-time 10 "$url" 2>/dev/null || echo "000")
+
+ version=$(jq -r '.version // "unknown"' "/tmp/body_${name}_${env}.json" 2>/dev/null || echo "unknown")
+
+ if [ "$http_code" = "200" ]; then
+ status="ok"
+ elif [ "$http_code" = "000" ]; then
+ status="unreachable"
+ else
+ status="fail"
+ fi
+
+ jq -n \
+ --arg status "$status" \
+ --arg version "$version" \
+ --arg code "$http_code" \
+ '{status: $status, version: $version, http_code: $code}' \
+ > "$outfile"
+ }
+
+ export -f check_health
+
+ while IFS= read -r row; do
+ name=$(echo "$row" | jq -r '.name')
+ for env in ci uat test prod; do
+ url=$(echo "$row" | jq -r ".envs.$env")
+ [ "$url" != "null" ] && check_health "$name" "$env" "$url" &
+ done
+ done < <(jq -c '.[]' dashboard/services.json)
+
+ wait
+ echo "All health checks complete"
+
+ - name: Generate dashboard HTML
+ run: |
+ mkdir -p dist
+ python3 << 'EOF'
+ import json, os
+ from datetime import datetime, timezone
+
+ with open('dashboard/services.json') as f:
+ services = json.load(f)
+
+ with open('releases.json') as f:
+ all_releases = json.load(f)
+
+ ENVS = ['ci', 'uat', 'test', 'prod']
+
+ def get_latest_release(service_name):
+ prefix = f"{service_name}/"
+ for r in all_releases:
+ if (r['tag_name'].startswith(prefix)
+ and not r['draft']
+ and not r['prerelease']):
+ return {
+ 'version': r['tag_name'].removeprefix(prefix),
+ 'url': r['html_url'],
+ }
+ return {'version': 'N/A', 'url': None}
+
+ def get_health(service_name, env):
+ path = f"health_results/{service_name}__{env}.json"
+ if os.path.exists(path):
+ with open(path) as f:
+ return json.load(f)
+ return {'status': 'unknown', 'version': 'N/A', 'http_code': 'N/A'}
+
+ rows = []
+ for svc in services:
+ name = svc['name']
+ latest = get_latest_release(name)
+ envs = {}
+ for env in ENVS:
+ h = get_health(name, env)
+ behind = (
+ env != 'ci'
+ and h['version'] not in ('N/A', 'unknown')
+ and h['version'] != latest['version']
+ )
+ envs[env] = {**h, 'behind': behind}
+ rows.append({'name': name, 'latest': latest, 'envs': envs})
+
+ def cell_class(health, is_ci=False):
+ if health['status'] == 'ok':
+ return 'behind' if health['behind'] else 'ok'
+ if health['status'] == 'unreachable':
+ return 'unreachable'
+ if health['status'] == 'fail':
+ return 'fail'
+ return 'unknown'
+
+ def badge(health, is_ci=False):
+ s = health['status']
+ icons = {'ok': '✓', 'fail': '✗', 'unreachable': '!', 'unknown': '?'}
+ return icons.get(s, '?')
+
+ now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
+
+ header_cells = ''.join(
+ f'
{e.upper()} | ' for e in ENVS
+ )
+
+ service_rows = ''
+ for row in rows:
+ latest = row['latest']
+ latest_link = (
+ f'{latest["version"]}'
+ if latest['url'] else latest['version']
+ )
+ cells = ''
+ for env in ENVS:
+ h = row['envs'][env]
+ cls = cell_class(h, is_ci=(env == 'ci'))
+ icon = badge(h, is_ci=(env == 'ci'))
+ behind_label = ' behind' if h['behind'] else ''
+ cells += f'{icon} {h["version"]}{behind_label} | '
+ service_rows += f'| {row["name"]} | {latest_link} | {cells}
'
+
+ html = f"""
+
+
+
+
+ Service Version Dashboard
+
+
+
+ Service Version Dashboard
+ Last updated: {now}
+
+
+
+ | Service |
+ Latest Release |
+ {header_cells}
+
+
+
+ {service_rows}
+
+
+
+ Up to date
+ Behind latest
+ Health fail
+ Unreachable
+
+
+ """
+
+ with open('dist/index.html', 'w') as f:
+ f.write(html)
+
+ print(f"Dashboard generated with {len(rows)} services")
+ EOF
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: ./dist
+
+ deploy:
+ needs: generate
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..6604f18
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,104 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Git & Pull Requests
+
+- Always open PRs against **Bandwidth/github-pulse** (`origin`). Never use `ViktorBilokin/github-pulse` (`upstream`).
+- Push feature branches to `origin` and use `gh pr create --repo Bandwidth/github-pulse --base main --head `.
+
+## Commands
+
+### Development
+
+```bash
+make start-dev # Full dev mode: PostgreSQL + backend (port 8080) + frontend HMR (port 3000)
+make dev # Start PostgreSQL only (then run backend/frontend manually)
+make start # Production mode: single JAR serving frontend at port 8080
+```
+
+### Backend (Maven)
+
+```bash
+cd backend
+./mvnw spring-boot:run # Run backend
+./mvnw test # Run Cucumber BDD integration tests
+./mvnw package -DskipTests # Build production JAR
+```
+
+### Frontend (npm)
+
+```bash
+cd frontend
+npm run dev # Vite dev server on port 3000
+npm run build # TypeScript compile + Vite build → dist/
+npm run lint # ESLint
+```
+
+### Docker
+
+```bash
+make up # Start full stack via Docker Compose
+make down # Stop stack
+make logs # View logs
+```
+
+### Cleanup
+
+```bash
+make clean # Clean Maven build, node_modules/.vite, and static resources
+make stop # Stop all services and kill ports 8080, 3000, 5432
+```
+
+## Architecture
+
+**GitHub Pulse** is a GitHub activity analytics dashboard. The stack is Spring Boot 3.3.5 (Java 21) backend with a React + TypeScript + Vite frontend.
+
+### Request Flow
+
+```
+Browser (port 3000 dev / 80 prod)
+ → /api/* proxied to Spring Boot (port 8080)
+ → Services → PostgreSQL + GitHub API + Jira API + Anthropic API
+```
+
+In production, the frontend is built and embedded into the Spring Boot JAR as static resources (served at `/`).
+
+### Backend Structure (`backend/src/main/java/com/githubpulse/`)
+
+- **`controller/`** — REST controllers: `DashboardController`, `AdminController`, `FiltersController`, `JiraController`
+- **`service/github/`** — GitHub sync via GraphQL (`GitHubGraphQlSyncClient`) and REST (`GitHubPatClient`)
+- **`service/dashboard/`** — Aggregates metrics (top contributors, reviewers, PR details)
+- **`service/jira/`** — Jira REST API client + Claude-powered ticket quality scoring via `TicketQualityService`
+- **`scheduler/GitHubSyncScheduler.java`** — Hourly cron sync with ShedLock distributed locking
+- **`repository/`** — Spring Data JPA repositories for all entities
+
+### Frontend Structure (`frontend/src/`)
+
+- **`pages/`** — `DashboardPage`, `AdminPage`, `ConfigPage`, `SyncPage`, `JiraPage`
+- **`components/`** — Reusable UI components
+- Vite proxies `/api` → `http://localhost:8080` in dev
+
+### Database
+
+PostgreSQL 16 with Flyway migrations in `operations/database/`:
+- `V1__initial_schema.sql` — Core tables: `repositories`, `teams`, `contributors`, `team_members`, `pull_requests`, `reviews`, `comments`, `sync_state`, `shedlock`
+- `V2__create_app_settings_table.sql` — `app_settings` key-value store
+
+Hibernate is set to `validate` mode — schema changes must go through Flyway migrations.
+
+### Key Configuration (`backend/src/main/resources/application.yml`)
+
+All values are environment-driven:
+
+| Variable | Purpose |
+|---|---|
+| `GITHUB_TOKEN` | GitHub PAT (required; scopes: `repo`, `read:org`) |
+| `ANTHROPIC_API_KEY` | Claude API key for Jira quality scoring |
+| `DB_HOST/PORT/NAME/USERNAME/PASSWORD` | PostgreSQL connection |
+| `SYNC_CRON` | Sync schedule (default: `0 0 * * * *` — hourly) |
+| `SPRING_FLYWAY_LOCATIONS` | Path to migration SQL files |
+
+### Backend Tests
+
+Cucumber BDD integration tests with H2 (PostgreSQL compatibility mode). Feature files live in `backend/src/test/resources/features/`. Run with `./mvnw test`.
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/README.md b/README.md
index 0813a20..d1f8803 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,109 @@
-# github-pulse
-
+# GitHub Pulse
+
+GitHub Pulse is an internal engineering dashboard that aggregates GitHub activity across your organization's repositories. It gives teams visibility into pull requests, contributor metrics, and delivery health — with optional Jira integration to surface ticket quality and epic progress alongside code activity.
+
+## Features
+
+- **Dashboard** — PR activity, merge rates, review throughput, and contributor stats across tracked repositories
+- **Jira Integration** — View epics and stories, AI-powered ticket quality scoring, and acceptance criteria improvement suggestions
+- **Sync Management** — Configure sync schedules, trigger manual syncs, and inspect sync history
+- **Bot Filter** — Suppress bot-generated PRs from metrics
+- **Configuration** — Manage GitHub tokens, tracked repositories, team members, and Jira connection settings
+
+## Technology Stack
+
+| Layer | Technology |
+|---|---|
+| Frontend | React 19, TypeScript, Vite, Tailwind CSS 4, shadcn/ui, Recharts, TanStack Query |
+| Backend | Java 21, Spring Boot 3.3, Spring Data JPA |
+| Database | PostgreSQL 16, Flyway migrations |
+| AI | Anthropic Claude (ticket quality analysis) |
+| Testing | Cucumber (BDD integration tests) |
+| Scheduling | ShedLock (distributed cron) |
+| Packaging | Docker, Docker Compose |
+
+## Prerequisites
+
+- Java 21+
+- Node.js 20+
+- Docker (for PostgreSQL)
+- A GitHub Personal Access Token with `repo` and `read:org` scopes
+
+## Environment Variables
+
+Copy `.env.example` to `.env` and fill in your values:
+
+```bash
+cp .env.example .env
+```
+
+| Variable | Required | Description |
+|---|---|---|
+| `GITHUB_TOKEN` | Yes | GitHub PAT with `repo` and `read:org` scopes |
+| `ANTHROPIC_API_KEY` | No | Enables AI ticket quality analysis in Jira view |
+
+Database connection defaults (`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USERNAME`, `DB_PASSWORD`) are pre-configured for the Docker Compose stack and do not need to be set for local development.
+
+## Running Locally
+
+### Option 1 — Dev mode (recommended, frontend HMR on `:3000`)
+
+```bash
+make start-dev
+```
+
+- Frontend: http://localhost:3000
+- Backend API: http://localhost:8080
+
+### Option 2 — Production-like (single server on `:8080`)
+
+```bash
+make start
+```
+
+- UI + API: http://localhost:8080
+
+### Option 3 — Docker Compose (full containerized stack)
+
+```bash
+docker compose up -d --build
+```
+
+### Stopping
+
+```bash
+make stop
+```
+
+## Building
+
+Build a production JAR with the frontend embedded:
+
+```bash
+make build
+```
+
+The artifact is written to `backend/target/github-pulse-*.jar`.
+
+## Running Tests
+
+```bash
+cd backend && ./mvnw test
+```
+
+Tests use Cucumber BDD scenarios covering the dashboard, settings, team management, repository management, and sync workflows.
+
+## Project Structure
+
+```
+github-pulse/
+├── backend/ # Spring Boot application
+│ ├── src/main/java/ # Controllers, services, repositories, domain
+│ └── src/test/java/ # Cucumber step definitions and runners
+├── frontend/ # React + Vite SPA
+│ └── src/pages/ # Dashboard, Config, Jira, Sync, Admin
+├── operations/
+│ └── database/ # Flyway SQL migrations
+├── docker-compose.yml
+└── Makefile # Dev lifecycle commands
+```
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..7f26eec
--- /dev/null
+++ b/backend/src/main/java/com/githubpulse/controller/JiraController.java
@@ -0,0 +1,39 @@
+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.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+@RestController
+@RequestMapping("/api/jira")
+@CrossOrigin(origins = "*")
+public class JiraController {
+
+ private static final Pattern JIRA_KEY_PATTERN = Pattern.compile("^[A-Z][A-Z0-9]{0,9}-\\d+$");
+
+ 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) {
+ if (key == null || !JIRA_KEY_PATTERN.matcher(key).matches()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid Jira issue key");
+ }
+ return jiraClient.getIssueDetail(key);
+ }
+}
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..5a089db
--- /dev/null
+++ b/backend/src/main/java/com/githubpulse/service/jira/JiraClient.java
@@ -0,0 +1,342 @@
+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("");
+ }
+
+ private static final java.util.regex.Pattern JIRA_KEY_PATTERN =
+ java.util.regex.Pattern.compile("^[A-Z][A-Z0-9]{0,9}-\\d+$");
+
+ public JiraIssueDetailDto getIssueDetail(String key) {
+ if (key == null || !JIRA_KEY_PATTERN.matcher(key).matches()) {
+ throw new IllegalArgumentException("Invalid Jira issue key: " + 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/" + java.net.URLEncoder.encode(key, StandardCharsets.UTF_8)
+ + "?fields=summary,description,customfield_10037,customfield_10158,customfield_10014",
+ 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() && JIRA_KEY_PATTERN.matcher(epicKey).matches()) {
+ 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/"
+ + java.net.URLEncoder.encode(epicKey, StandardCharsets.UTF_8)
+ + "?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 void assertAllowedUrl(String url) {
+ String allowedHost = getSetting("jira.host").strip();
+ if (allowedHost.endsWith("/")) allowedHost = allowedHost.substring(0, allowedHost.length() - 1);
+ if (allowedHost.isBlank() || !url.startsWith(allowedHost + "/")) {
+ throw new SecurityException("Blocked outbound request to disallowed host: " + url);
+ }
+ }
+
+ private Map rawGet(String url, String auth) {
+ assertAllowedUrl(url);
+ 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.debug("[JIRA DEBUG] GET {} → {}", url, resp.statusCode());
+ 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) : "");
+ assertAllowedUrl(url);
+ 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());
+ 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/dashboard/services.json b/dashboard/services.json
new file mode 100644
index 0000000..c87ab88
--- /dev/null
+++ b/dashboard/services.json
@@ -0,0 +1,20 @@
+[
+ {
+ "name": "payments",
+ "envs": {
+ "ci": "https://payments.ci.example.com/health",
+ "uat": "https://payments.uat.example.com/health",
+ "test": "https://payments.test.example.com/health",
+ "prod": "https://payments.example.com/health"
+ }
+ },
+ {
+ "name": "auth",
+ "envs": {
+ "ci": "https://auth.ci.example.com/health",
+ "uat": "https://auth.uat.example.com/health",
+ "test": "https://auth.test.example.com/health",
+ "prod": "https://auth.example.com/health"
+ }
+ }
+]
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..764ae68 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,70 @@ 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 (
-