Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <branch>`.

## 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`.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 109 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,109 @@
# github-pulse
<!-- test: sample PR to validate CI workflow -->
# 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
```
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.githubpulse.domain.enums.SyncStatus;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

@RestController
Expand Down Expand Up @@ -200,20 +201,32 @@ public ResponseEntity<Void> untrackRepository(@PathVariable Long id) {
@PostMapping("/sync")
public ResponseEntity<String> triggerSync() {
List<Repository> activeRepos = repositoryRepository.findByIsActiveTrue();
List<Repository> 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<Repository> pendingRepos = new ArrayList<>();
List<Repository> 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<Repository> 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);
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change modifies the manual sync response strings and behavior (completed repos now sync incrementally). Existing Cucumber scenarios currently assert the old wording like "No pending" / "1 pending"; consider either keeping backward-compatible response text or updating the BDD tests accordingly so CI doesn't break.

Suggested change
syncOrchestrator.syncReposAsync(toSync);
syncOrchestrator.syncReposAsync(toSync);
if (pendingRepos.isEmpty() && !completedRepos.isEmpty()) {
// Backward-compatible phrasing for scenarios with no pending repos,
// while still informing about incremental sync of completed repos.
return ResponseEntity.ok(
"No pending repositories. " + completedRepos.size()
+ " completed repositories will sync incrementally. Running in background.");
}

Copilot uses AI. Check for mistakes.
return ResponseEntity.ok("Sync triggered: " + pendingRepos.size() + " pending (full) + "
+ completedRepos.size() + " completed (incremental). Running in background.");
}

@PostMapping("/sync/failed")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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<JiraEpicDto> getEpics() {
return jiraClient.getEpicsWithChildren();
}

@GetMapping("/issue/{key}")
public JiraIssueDetailDto getIssueDetail(@PathVariable String key) {
if (key == null || !JIRA_KEY_PATTERN.matcher(key).matches()) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot is it secure?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the endpoint is secure. Here's the full SSRF protection in place:

  1. Strict input validation — the JIRA_KEY_PATTERN regex (^[A-Z][A-Z0-9]{0,9}-\d+$) is enforced in both JiraController (returning HTTP 400 on mismatch) and again inside JiraClient as defence-in-depth. A valid key can only contain uppercase letters, digits, and one hyphen — none of which can inject a URL scheme, authority, or path separator.

  2. URL-encoding the key — commit f9b2098 adds URLEncoder.encode(key, StandardCharsets.UTF_8) when building the path in JiraClient.getIssueDetail (and the same for epicKey fetched from the Jira API response). This makes the sanitization explicit and eliminates the taint path that Snyk's static analysis was still flagging.

  3. assertAllowedUrl allowlist — every outbound HTTP call in JiraClient goes through assertAllowedUrl, which verifies the URL starts with the admin-configured jira.host value before the request is sent.

CodeQL reports 0 alerts after the change.

throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid Jira issue key");
}
return jiraClient.getIssueDetail(key);
}
Comment on lines +27 to +38
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New Jira endpoints (/api/jira/epics, /api/jira/issue/{key}) introduce significant behavior (external Jira calls + Claude quality evaluation), but there are currently no Cucumber scenarios covering Jira integration. Consider adding BDD coverage for: missing config → empty result/4xx, invalid key → 400, and happy-path responses (with JiraClient mocked/stubbed).

Copilot uses AI. Check for mistakes.

@GetMapping("/debug")
public Map<String, Object> debug() {
return jiraClient.debug();
}
Comment on lines +40 to +43
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /api/jira/debug endpoint appears to be enabled unconditionally and returns detailed Jira API responses plus configuration metadata. Exposing this in production (especially with @CrossOrigin("*")) is a security risk. Consider removing the endpoint, gating it behind a feature flag/profile, or restricting access (e.g., admin auth / local-only).

Copilot uses AI. Check for mistakes.
}
Loading