feat(forgejo): batch 3 — board-repo links, agent issue APIs, close service, sync/validate UI, issues page (batch 3.0.0)

This commit is contained in:
null 2026-05-19 03:32:54 -05:00
parent 4c540b1c9a
commit d56ccb31da
16 changed files with 1472 additions and 60 deletions

View File

@ -0,0 +1,246 @@
"""Agent-scoped Forgejo issue read APIs for board-linked repositories."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import select, func
from sqlalchemy import and_
from app.api.deps import get_board_for_actor_read
from app.core.agent_auth import get_agent_auth_context
from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_issues import ForgejoIssue
from app.schemas.forgejo_issues import ForgejoIssueRead, ForgejoIssueListResponse
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio.session import AsyncSession
router = APIRouter(prefix="/agent/boards", tags=["agent-board-issues"])
SESSION_DEP = Depends(get_session)
BOARD_READ_DEP = Depends(get_board_for_actor_read)
AGENT_CTX_DEP = Depends(get_agent_auth_context)
def _agent_board_openapi_hints(
*,
intent: str,
when_to_use: list[str],
routing_examples: list[dict[str, object]],
required_actor: str = "any_agent",
when_not_to_use: list[str] | None = None,
routing_policy: list[str] | None = None,
) -> dict[str, object]:
"""Generate LLM routing hints for board-scoped agent endpoints."""
return {
"x-llm-intent": intent,
"x-when-to-use": when_to_use,
"x-when-not-to-use": when_not_to_use
or [
"Use a more specific endpoint for direct state mutation or direct messaging.",
],
"x-required-actor": required_actor,
"x-prerequisites": [
"Authenticated agent token",
"Board access is validated before execution",
],
"x-side-effects": [
"Read-only access to issues linked to board repositories",
],
"x-negative-guidance": [
"Avoid this endpoint when a focused sibling endpoint handles the action.",
],
"x-routing-policy": routing_policy
or [
"Use when the request intent matches this board-scoped route.",
"Prefer dedicated mutation/read routes once intent is narrowed.",
],
"x-routing-policy-examples": routing_examples,
}
@router.get(
"/{board_id}/git/issues",
response_model=ForgejoIssueListResponse,
summary="List issues for board's linked repositories",
description=(
"List Forgejo issues from repositories linked to the specified board.\n\n"
"Use this endpoint when an agent needs to discover issues across all "
"repositories associated with its assigned board.\n\n"
"LLM Routing Guidance:\n"
"- Use when you need to list issues for a specific board's repositories\n"
"- Filter by state, search text, or assignee for targeted results\n"
"- Use this instead of generic issue lists when board context matters\n"
"- Exclude pull requests automatically"
),
openapi_extra=_agent_board_openapi_hints(
intent="board_issue_discovery",
when_to_use=[
"Need to list issues for a specific board's repositories",
"Looking for issues to assign or track",
"Discovering work items related to a board's scope",
],
when_not_to_use=[
"Listing all issues across all repositories (use forgejo issues list)",
"Working with issues from unlinked repositories",
"Need repository-agnostic issue search",
],
routing_examples=[
{
"input": {
"intent": "list issues for board xyz",
"board_id": "uuid",
},
"decision": "agent_board_list_issues",
},
{
"input": {
"intent": "find issues assigned to me",
"assignee": "me",
},
"decision": "agent_board_list_issues (with assignee filter)",
},
],
),
)
async def list_board_issues(
board_id: UUID,
session: AsyncSession = SESSION_DEP,
board: ForgejoIssue = BOARD_READ_DEP,
state: str | None = Query(default=None, description="Filter by issue state (open/closed)"),
search: str | None = Query(default=None, description="Search in title/body"),
page: int = Query(default=1, ge=1, description="Page number"),
limit: int = Query(default=30, ge=1, le=100, description="Items per page"),
) -> ForgejoIssueListResponse:
"""List issues for repositories linked to a board."""
# Get linked repositories
link_statement = select(BoardRepositoryLink).where(
BoardRepositoryLink.board_id == board_id
)
links = (await session.exec(link_statement)).all()
if not links:
return ForgejoIssueListResponse(
items=[],
total=0,
page=page,
limit=limit,
)
repository_ids = [link.repository_id for link in links]
# Build base query
statement = select(ForgejoIssue).where(
ForgejoIssue.repository_id.in_(repository_ids)
)
# Apply filters
if state:
statement = statement.where(ForgejoIssue.state == state)
if search:
statement = statement.where(
ForgejoIssue.title.ilike(f"%{search}%") |
ForgejoIssue.body_preview.ilike(f"%{search}%")
)
# Count total
count_statement = select(func.count(ForgejoIssue.id)).where(
ForgejoIssue.repository_id.in_(repository_ids)
)
if state:
count_statement = count_statement.where(ForgejoIssue.state == state)
if search:
count_statement = count_statement.where(
ForgejoIssue.title.ilike(f"%{search}%") |
ForgejoIssue.body_preview.ilike(f"%{search}%")
)
total = await session.scalar(count_statement) or 0
# Apply pagination and execute
offset = (page - 1) * limit
statement = statement.offset(offset).limit(limit)
issues = (await session.exec(statement)).all()
return ForgejoIssueListResponse(
items=[ForgejoIssueRead.model_validate(issue) for issue in issues],
total=total,
page=page,
limit=limit,
)
@router.get(
"/{board_id}/git/issues/{issue_id}",
response_model=ForgejoIssueRead,
summary="Read one issue from board-linked repositories",
description=(
"Read a specific Forgejo issue by id, ensuring it belongs to a repository "
"linked to the specified board.\n\n"
"Use this endpoint when an agent needs to inspect a specific issue "
"within the context of a board's repositories."
),
openapi_extra=_agent_board_openapi_hints(
intent="board_issue_inspection",
when_to_use=[
"Need to read a specific issue's details",
"Verifying issue state before taking action",
"Inspecting issue metadata for board context",
],
when_not_to_use=[
"Listing multiple issues (use list endpoint)",
"Working with issues from unlinked repositories",
],
routing_examples=[
{
"input": {
"intent": "read issue 123 for board xyz",
"board_id": "uuid",
"issue_id": "uuid",
},
"decision": "agent_board_read_issue",
},
],
),
)
async def read_board_issue(
board_id: UUID,
issue_id: UUID,
session: AsyncSession = SESSION_DEP,
board: ForgejoIssue = BOARD_READ_DEP,
) -> ForgejoIssueRead:
"""Read one issue from board-linked repositories."""
# Get linked repositories
link_statement = select(BoardRepositoryLink).where(
BoardRepositoryLink.board_id == board_id
)
links = (await session.exec(link_statement)).all()
if not links:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No repositories linked to this board",
)
repository_ids = [link.repository_id for link in links]
# Find issue
statement = select(ForgejoIssue).where(
and_(
ForgejoIssue.id == issue_id,
ForgejoIssue.repository_id.in_(repository_ids),
)
)
issue = (await session.exec(statement)).first()
if issue is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Issue not found or not linked to this board",
)
return ForgejoIssueRead.model_validate(issue)

View File

@ -0,0 +1,116 @@
"""API routes for board-to-repository linking operations."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import select
from sqlalchemy.exc import IntegrityError
from app.api.deps import (
get_board_for_actor_read,
get_board_for_actor_write,
)
from app.core.logging import get_logger
from app.core.time import utcnow
from app.db import crud
from app.db.session import get_session
from app.models.forgejo_repositories import ForgejoRepository
from app.models.board_repository_links import BoardRepositoryLink
from app.schemas.board_repository_links import (
BoardRepositoryLinkCreate,
BoardRepositoryLinkDeleteResponse,
BoardRepositoryLinkRead,
BoardRepositoryLinkResponse,
)
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio.session import AsyncSession
router = APIRouter(prefix="/boards/{board_id}/forgejo/repositories", tags=["board-repositories"])
logger = get_logger(__name__)
SESSION_DEP = Depends(get_session)
BOARD_READ_DEP = Depends(get_board_for_actor_read)
BOARD_WRITE_DEP = Depends(get_board_for_actor_write)
@router.get("", response_model=list[BoardRepositoryLinkRead])
async def list_board_repositories(
board_id: UUID,
session: AsyncSession = SESSION_DEP,
board: BoardRepositoryLink = BOARD_READ_DEP,
) -> list[BoardRepositoryLinkRead]:
"""List repositories linked to a board."""
statement = select(BoardRepositoryLink).where(
BoardRepositoryLink.board_id == board_id
)
links = (await session.exec(statement)).all()
return [BoardRepositoryLinkRead.model_validate(link) for link in links]
@router.post("", response_model=BoardRepositoryLinkResponse)
async def link_repository_to_board(
board_id: UUID,
payload: BoardRepositoryLinkCreate,
session: AsyncSession = SESSION_DEP,
board: BoardRepositoryLink = BOARD_WRITE_DEP,
) -> BoardRepositoryLinkResponse:
"""Link a Forgejo repository to a board."""
# Verify repository belongs to same organization as board
repository = await crud.get_by_id(session, ForgejoRepository, payload.repository_id)
if repository is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repository not found or access denied",
)
if repository.organization_id != board.organization_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Repository must belong to the same organization as the board",
)
# Create the link
link = BoardRepositoryLink(
board_id=board_id,
repository_id=payload.repository_id,
organization_id=board.organization_id,
)
try:
await crud.create(session, BoardRepositoryLink, **link.model_dump())
await session.flush()
link_read = BoardRepositoryLinkRead.model_validate(link)
return BoardRepositoryLinkResponse(link=link_read)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Repository is already linked to this board",
)
@router.delete("/{repository_id}", response_model=BoardRepositoryLinkDeleteResponse)
async def unlink_repository_from_board(
board_id: UUID,
repository_id: UUID,
session: AsyncSession = SESSION_DEP,
board: BoardRepositoryLink = BOARD_WRITE_DEP,
) -> BoardRepositoryLinkDeleteResponse:
"""Remove a repository link from a board."""
statement = select(BoardRepositoryLink).where(
BoardRepositoryLink.board_id == board_id,
BoardRepositoryLink.repository_id == repository_id,
)
link = (await session.exec(statement)).first()
if link is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Repository link not found",
)
await session.delete(link)
await session.commit()
return BoardRepositoryLinkDeleteResponse(
success=True,
message="Repository unlinked successfully",
)

View File

@ -24,6 +24,8 @@ from app.api.boards import router as boards_router
from app.api.forgejo_connections import router as forgejo_connections_router
from app.api.forgejo_issues import router as forgejo_issues_router
from app.api.forgejo_repositories import router as forgejo_repositories_router
from app.api.board_repository_links import router as board_repository_links_router
from app.api.agent_forgejo import router as agent_forgejo_router
from app.api.gateway import router as gateway_router
from app.api.gateways import router as gateways_router
from app.api.metrics import router as metrics_router
@ -559,6 +561,8 @@ api_v1.include_router(activity_router)
api_v1.include_router(forgejo_connections_router)
api_v1.include_router(forgejo_issues_router)
api_v1.include_router(forgejo_repositories_router)
api_v1.include_router(board_repository_links_router)
api_v1.include_router(agent_forgejo_router)
api_v1.include_router(gateway_router)
api_v1.include_router(gateways_router)
api_v1.include_router(metrics_router)

View File

@ -8,6 +8,7 @@ from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup
from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession
from app.models.board_repository_links import BoardRepositoryLink
from app.models.board_webhook_payloads import BoardWebhookPayload
from app.models.board_webhooks import BoardWebhook
from app.models.boards import Board
@ -44,6 +45,7 @@ __all__ = [
"BoardOnboardingSession",
"BoardGroup",
"Board",
"BoardRepositoryLink",
"ForgejoConnection",
"ForgejoRepository",
"Gateway",
@ -64,4 +66,5 @@ __all__ = [
"Tag",
"TagAssignment",
"User",
"BoardRepositoryLink",
]

View File

@ -0,0 +1,33 @@
"""Board-to-Forgejo-repository link model for board-specific issue filtering."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import Field
from app.core.time import utcnow
from app.models.tenancy import TenantScoped
RUNTIME_ANNOTATION_TYPES = (datetime,)
class BoardRepositoryLink(TenantScoped, table=True):
"""Link between a board and a Forgejo-tracked repository."""
__tablename__ = "board_repository_links" # pyright: ignore[reportAssignmentType]
__table_args__ = (
UniqueConstraint(
"board_id",
"repository_id",
name="uq_board_repository_links_board_repository",
),
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
board_id: UUID = Field(foreign_key="boards.id", index=True)
repository_id: UUID = Field(foreign_key="forgejo_repositories.id", index=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
created_at: datetime = Field(default_factory=utcnow)

View File

@ -0,0 +1,47 @@
"""Schemas for board-to-repository linking operations."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from sqlmodel import SQLModel
class BoardRepositoryLinkCreate(SQLModel):
"""Schema for creating a board repository link."""
repository_id: UUID
class BoardRepositoryLinkRead(SQLModel):
"""Schema for reading a board repository link."""
id: UUID
board_id: UUID
repository_id: UUID
organization_id: UUID
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class BoardRepositoryLinkList(BaseModel):
"""List response for board repository links."""
links: list[BoardRepositoryLinkRead]
class BoardRepositoryLinkResponse(BaseModel):
"""Single link response with success status."""
success: bool = True
link: BoardRepositoryLinkRead | None = None
class BoardRepositoryLinkDeleteResponse(BaseModel):
"""Delete response with success status."""
success: bool = True
message: str | None = None

View File

@ -0,0 +1,156 @@
"""Service for closing Forgejo issues and updating local cache."""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from sqlmodel import select
from app.core.logging import get_logger
from app.core.time import utcnow
from app.db import crud
from app.models.forgejo_connections import ForgejoConnection
from app.models.forgejo_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository
from app.services.forgejo_client import get_forgejo_client
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio.session import AsyncSession
logger = get_logger(__name__)
class CloseIssueError(Exception):
"""Base exception for close issue errors."""
class CloseIssueNotFoundError(CloseIssueError):
"""Raised when issue or repository is not found."""
class CloseIssueAccessError(CloseIssueError):
"""Raised when access is denied to close issue."""
class CloseIssueRemoteError(CloseIssueError):
"""Raised when remote Forgejo API call fails."""
async def close_cached_issue(
session: AsyncSession,
issue: ForgejoIssue,
actor_agent_id: UUID | None = None,
actor_user_id: UUID | None = None,
) -> dict[str, object]:
"""
Close a Forgejo issue remotely and update local cache.
Args:
session: Async session for database operations
issue: ForgejoIssue to close
actor_agent_id: Optional agent ID performing the close
actor_user_id: Optional user ID performing the close
Returns:
Normalized result dict with success status and details
Raises:
CloseIssueNotFoundError: If repository or connection not found
CloseIssueAccessError: If organization mismatch
CloseIssueRemoteError: If Forgejo API call fails
"""
# Load repository and connection
repository = await crud.get_by_id(session, ForgejoRepository, issue.repository_id)
if repository is None:
raise CloseIssueNotFoundError("Repository not found")
connection = await crud.get_by_id(session, ForgejoConnection, repository.connection_id)
if connection is None:
raise CloseIssueNotFoundError("Connection not found")
# Verify organization matches
if issue.organization_id != repository.organization_id:
raise CloseIssueAccessError("Organization mismatch")
if issue.organization_id != connection.organization_id:
raise CloseIssueAccessError("Organization mismatch")
# Close issue on Forgejo first
try:
async with get_forgejo_client(connection) as client:
result = await client.close_issue(
owner=repository.owner,
repo=repository.repo,
issue_number=issue.forgejo_issue_number,
)
except Exception as e:
raise CloseIssueRemoteError(f"Failed to close issue on Forgejo: {e}")
# Only update local cache if Forgejo call succeeded
issue.state = "closed"
issue.forgejo_closed_at = utcnow()
issue.last_synced_at = utcnow()
# Update the issue in the session
session.add(issue)
await session.flush()
logger.info(
"forgejo.issue.closed",
extra={
"issue_id": str(issue.id),
"forgejo_issue_number": issue.forgejo_issue_number,
"repository_id": str(repository.id),
"organization_id": str(issue.organization_id),
"actor_agent_id": str(actor_agent_id) if actor_agent_id else None,
"actor_user_id": str(actor_user_id) if actor_user_id else None,
},
)
return {
"success": True,
"issue_id": str(issue.id),
"forgejo_issue_number": issue.forgejo_issue_number,
"state": "closed",
"forgejo_closed_at": issue.forgejo_closed_at.isoformat() if issue.forgejo_closed_at else None,
"last_synced_at": issue.last_synced_at.isoformat(),
}
async def close_issue_by_id(
session: AsyncSession,
issue_id: UUID,
actor_agent_id: UUID | None = None,
actor_user_id: UUID | None = None,
) -> dict[str, object]:
"""
Close a Forgejo issue by ID.
Args:
session: Async session for database operations
issue_id: LocalForgejoIssue ID to close
actor_agent_id: Optional agent ID performing the close
actor_user_id: Optional user ID performing the close
Returns:
Normalized result dict with success status and details
Raises:
CloseIssueNotFoundError: If issue not found
CloseIssueAccessError: If access is denied
CloseIssueRemoteError: If Forgejo API call fails
"""
# Find the issue
statement = select(ForgejoIssue).where(ForgejoIssue.id == issue_id)
issue = (await session.exec(statement)).first()
if issue is None:
raise CloseIssueNotFoundError("Issue not found")
# Close the issue
return await close_cached_issue(
session=session,
issue=issue,
actor_agent_id=actor_agent_id,
actor_user_id=actor_user_id,
)

View File

@ -0,0 +1,63 @@
"""Add board_repository_links table for board-to-repository associations.
Revision ID: a1b2c3d4e5f7
Revises: f5a2b3c4d5e6
Create Date: 2026-05-19 01:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f7"
down_revision = "f5a2b3c4d5e6"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create board_repository_links table."""
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("board_repository_links"):
op.create_table(
"board_repository_links",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("board_id", sa.Uuid(), nullable=False),
sa.Column("repository_id", sa.Uuid(), nullable=False),
sa.Column("organization_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"]),
sa.ForeignKeyConstraint(["repository_id"], ["forgejo_repositories.id"]),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
)
op.create_index(
"ix_board_repository_links_board_id",
"board_repository_links",
["board_id"],
)
op.create_index(
"ix_board_repository_links_repository_id",
"board_repository_links",
["repository_id"],
)
op.create_index(
"ix_board_repository_links_org_id",
"board_repository_links",
["organization_id"],
)
op.create_unique_constraint(
"uq_board_repository_links_board_repository",
"board_repository_links",
["board_id", "repository_id"],
)
def downgrade() -> None:
"""Drop board_repository_links table."""
op.drop_table("board_repository_links")

View File

@ -10,6 +10,7 @@ import { ForgejoConnectionsTable } from "@/components/git/ForgejoConnectionsTabl
import {
getForgejoConnections,
deleteForgejoConnection,
validateConnection,
type ForgejoConnection,
} from "@/lib/api-forgejo";
@ -54,6 +55,26 @@ export default function ForgejoConnectionsPage() {
}
};
const handleValidateConnection = async (connection: ForgejoConnection) => {
try {
const result = await validateConnection(connection.id);
if (result.ok) {
alert(
`Connection validated successfully!\n\n` +
`Response time: ${result.response_time_ms}ms`
);
} else {
alert(
`Connection validation failed: ${result.error_message || "Unknown error"}`
);
}
return result;
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to validate connection");
throw err;
}
};
return (
<DashboardPageLayout
signedOut={{
@ -86,6 +107,7 @@ export default function ForgejoConnectionsPage() {
connections={connections}
isLoading={isLoading}
onDelete={handleDelete}
onValidate={handleValidateConnection}
/>
)}
</div>

View File

@ -0,0 +1,263 @@
"use client";
export const dynamic = "force-dynamic";
import { useMemo, useState, useEffect, useCallback } from "react";
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { DataTable } from "@/components/tables/DataTable";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import {
getForgejoIssues,
getForgejoRepositories,
type ForgejoIssue,
type ForgejoRepository,
} from "@/lib/api-forgejo";
export default function GitIssuesPage() {
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
const [repos, setRepos] = useState<ForgejoRepository[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [stateFilter, setStateFilter] = useState<string>("open");
const [repoFilter, setRepoFilter] = useState<string>("all");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const limit = 30;
const fetchIssues = useCallback(async () => {
setLoading(true);
try {
const result = await getForgejoIssues({
state: stateFilter || undefined,
repository_id: repoFilter !== "all" ? repoFilter : undefined,
search: search || undefined,
page,
limit,
});
setIssues(result.items);
setTotal(result.total);
} catch (err) {
console.error("Failed to fetch issues:", err);
} finally {
setLoading(false);
}
}, [stateFilter, repoFilter, search, page]);
useEffect(() => {
getForgejoRepositories().then(setRepos).catch(console.error);
}, []);
useEffect(() => {
fetchIssues();
}, [fetchIssues]);
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
() => [
{
accessorKey: "forgejo_issue_number",
header: "#",
cell: ({ row }) => (
<a
href={row.original.html_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm text-blue-600 hover:underline dark:text-blue-400"
>
#{row.original.forgejo_issue_number}
</a>
),
},
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => (
<div className="max-w-md truncate">{row.original.title}</div>
),
},
{
accessorKey: "state",
header: "State",
cell: ({ row }) => {
const state = row.original.state;
return (
<Badge
variant={state === "open" ? "success" : "default"}
className={
state === "open"
? ""
: ""
}
>
{state}
</Badge>
);
},
},
{
accessorKey: "author",
header: "Author",
},
{
accessorKey: "labels",
header: "Labels",
cell: ({ row }) => {
const labels = row.original.labels;
if (!labels || labels.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{labels.slice(0, 3).map((label: Record<string, unknown>, i: number) => (
<Badge
key={i}
variant="outline"
className="text-xs"
style={
label.color
? { backgroundColor: `#${label.color}`, color: "#fff" }
: undefined
}
>
{String(label.name || "")}
</Badge>
))}
{labels.length > 3 && (
<span className="text-xs text-slate-500">+{labels.length - 3}</span>
)}
</div>
);
},
},
{
accessorKey: "forgejo_updated_at",
header: "Updated",
cell: ({ row }) => {
try {
return new Date(row.original.forgejo_updated_at).toLocaleDateString();
} catch {
return row.original.forgejo_updated_at;
}
},
},
],
[],
);
const table = useReactTable({
data: issues,
columns,
getCoreRowModel: getCoreRowModel(),
});
const totalPages = Math.ceil(total / limit);
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to view issues.",
forceRedirectUrl: "/git-projects/issues",
signUpForceRedirectUrl: "/git-projects/issues",
}}
title="Issues"
description={`${total} issue${total === 1 ? "" : "s"} from tracked repositories.`}
stickyHeader
>
<div className="mb-4 flex flex-wrap gap-3">
<Select value={stateFilter} onValueChange={(v) => { setStateFilter(v); setPage(1); }}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="State" />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
<Select value={repoFilter} onValueChange={(v) => { setRepoFilter(v); setPage(1); }}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Repository" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All repositories</SelectItem>
{repos.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.display_name || `${r.owner}/${r.repo}`}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Search issues…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="w-[240px]"
/>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900">
<DataTable
table={table}
isLoading={loading}
emptyState={{
icon: (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
),
title: "No issues found",
description: "Sync a repository to pull in issues, or adjust your filters.",
}}
/>
</div>
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
<span>
Page {page} of {totalPages} ({total} total)
</span>
<div className="flex gap-2">
<button
className="rounded border px-3 py-1 disabled:opacity-50"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Previous
</button>
<button
className="rounded border px-3 py-1 disabled:opacity-50"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</button>
</div>
</div>
)}
</DashboardPageLayout>
);
}

View File

@ -2,7 +2,7 @@
export const dynamic = "force-dynamic";
import { useMemo } from "react";
import { useMemo, useState, useEffect, useCallback } from "react";
import {
type ColumnDef,
@ -10,45 +10,150 @@ import {
useReactTable,
} from "@tanstack/react-table";
import Link from "next/link";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { DataTable } from "@/components/tables/DataTable";
type GitProject = {
id: string;
name: string;
url: string;
updatedAt: string;
};
const EMPTY_STATE_DATA = {
title: "No repositories tracked yet",
description:
"Connect a Forgejo instance to start tracking issues and pull requests from your Git projects.",
};
const columns: ColumnDef<GitProject>[] = [
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "url",
header: "URL",
},
{
accessorKey: "updatedAt",
header: "Updated",
},
];
import { Button } from "@/components/ui/button";
import {
getForgejoRepositories,
type ForgejoRepository,
syncRepository,
} from "@/lib/api-forgejo";
export default function GitProjectsPage() {
const _useAuth = useAuth();
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
const [loading, setLoading] = useState(true);
const [syncingId, setSyncingId] = useState<string | null>(null);
const [syncResult, setSyncResult] = useState<{
repoName: string;
created: number;
updated: number;
open: number;
closed: number;
} | null>(null);
const gitProjects: GitProject[] = useMemo(() => [], []);
const fetchRepos = useCallback(async () => {
try {
const repos = await getForgejoRepositories();
setRepositories(repos);
} catch (err) {
console.error("Failed to fetch repositories:", err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchRepos();
}, [fetchRepos]);
const handleSync = useCallback(
async (repo: ForgejoRepository) => {
setSyncingId(repo.id);
setSyncResult(null);
try {
const result = await syncRepository(repo.id);
setSyncResult({
repoName: `${repo.owner}/${repo.repo}`,
created: result.created,
updated: result.updated,
open: result.open,
closed: result.closed,
});
await fetchRepos();
} catch (err) {
console.error("Sync failed:", err);
} finally {
setSyncingId(null);
}
},
[fetchRepos],
);
const columns: ColumnDef<ForgejoRepository>[] = useMemo(
() => [
{
accessorKey: "display_name",
header: "Repository",
cell: ({ row }) => {
const repo = row.original;
const name =
repo.display_name || `${repo.owner}/${repo.repo}`;
return (
<div>
<div className="font-medium text-slate-900 dark:text-slate-100">
{name}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{repo.owner}/{repo.repo}
</div>
</div>
);
},
},
{
accessorKey: "connection",
header: "Connection",
cell: ({ row }) => {
const conn = row.original.connection;
return conn ? conn.name : "—";
},
},
{
accessorKey: "active",
header: "Status",
cell: ({ row }) => (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
row.original.active
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400"
}`}
>
{row.original.active ? "Active" : "Inactive"}
</span>
),
},
{
accessorKey: "last_sync_at",
header: "Last Synced",
cell: ({ row }) => {
const val = row.original.last_sync_at;
if (!val) return "—";
try {
return new Date(val).toLocaleString();
} catch {
return val;
}
},
},
{
id: "actions",
header: "",
cell: ({ row }) => {
const repo = row.original;
const isSyncing = syncingId === repo.id;
return (
<Button
variant="outline"
size="sm"
onClick={() => handleSync(repo)}
disabled={!!syncingId}
>
{isSyncing ? "Syncing…" : "Sync Issues"}
</Button>
);
},
},
],
[handleSync, syncingId],
);
const table = useReactTable({
data: gitProjects,
data: repositories,
columns,
getCoreRowModel: getCoreRowModel(),
});
@ -61,13 +166,34 @@ export default function GitProjectsPage() {
signUpForceRedirectUrl: "/git-projects",
}}
title="Git Projects"
description={`${gitProjects.length} repository${gitProjects.length === 1 ? "" : "s"} tracked.`}
description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked.`}
stickyHeader
>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
{syncResult && (
<div className="mb-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/20 dark:text-emerald-300">
<strong>{syncResult.repoName}</strong> synced:{" "}
{syncResult.created} created, {syncResult.updated} updated,{" "}
{syncResult.open} open, {syncResult.closed} closed
</div>
)}
<div className="mb-4 flex gap-3">
<Link href="/git-projects/connections">
<Button variant="outline" size="sm">
Connections
</Button>
</Link>
<Link href="/git-projects/repositories">
<Button variant="outline" size="sm">
Manage Repos
</Button>
</Link>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900">
<DataTable
table={table}
isLoading={false}
isLoading={loading}
emptyState={{
icon: (
<svg
@ -83,13 +209,14 @@ export default function GitProjectsPage() {
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
),
title: EMPTY_STATE_DATA.title,
description: EMPTY_STATE_DATA.description,
actionHref: "/git-projects/connect",
actionLabel: "Connect repository",
title: "No repositories tracked yet",
description:
"Connect a Forgejo instance and add repositories to start tracking issues.",
actionHref: "/git-projects/connections",
actionLabel: "Set up connection",
}}
/>
</div>
</DashboardPageLayout>
);
}
}

View File

@ -10,6 +10,8 @@ import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTa
import {
getForgejoRepositories,
deleteForgejoRepository,
syncRepository,
validateRepository,
type ForgejoRepository,
} from "@/lib/api-forgejo";
@ -54,6 +56,47 @@ export default function ForgejoRepositoriesPage() {
}
};
const handleSync = async (repository: ForgejoRepository) => {
try {
const result = await syncRepository(repository.id);
alert(
`Sync completed!\n\n` +
`Created: ${result.created}\n` +
`Updated: ${result.updated}\n` +
`Open: ${result.open}\n` +
`Closed: ${result.closed}\n` +
`Total: ${result.total}`
);
// Refetch to update last_sync_at
const data = await getForgejoRepositories();
setRepositories(data);
return result;
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to sync repository");
throw err;
}
};
const handleValidateRepository = async (repository: ForgejoRepository) => {
try {
const result = await validateRepository(repository.id);
if (result.ok) {
alert(
`Repository is valid!\n\n` +
`Repository exists: ${result.repo_exists ? "Yes" : "No"}`
);
} else {
alert(
`Repository validation failed: ${result.error_message || "Unknown error"}`
);
}
return result;
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to validate repository");
throw err;
}
};
return (
<DashboardPageLayout
signedOut={{
@ -84,6 +127,8 @@ export default function ForgejoRepositoriesPage() {
repositories={repositories}
isLoading={isLoading}
onDelete={handleDelete}
onSync={handleSync}
onValidate={handleValidateRepository}
/>
)}
</div>

View File

@ -11,6 +11,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2 } from "lucide-react";
import { DataTable } from "@/components/tables/DataTable";
import DropdownSelect from "@/components/ui/dropdown-select";
import { Input } from "@/components/ui/input";
@ -23,6 +24,7 @@ interface ConnectionsTableProps {
isLoading: boolean;
onEdit?: (connection: ForgejoConnection) => void;
onDelete?: (connection: ForgejoConnection) => void;
onValidate?: (connection: ForgejoConnection) => void;
}
export function ForgejoConnectionsTable({
@ -30,10 +32,13 @@ export function ForgejoConnectionsTable({
isLoading,
onEdit,
onDelete,
onValidate,
}: ConnectionsTableProps) {
// onEdit available for future use
const _ = onEdit;
const table = useReactTable({
data: connections,
columns: columns,
columns: columns(onValidate),
getCoreRowModel: getCoreRowModel(),
});
@ -70,7 +75,9 @@ export function ForgejoConnectionsTable({
);
}
const columns: ColumnDef<ForgejoConnection>[] = [
const columns = (
onValidate?: (connection: ForgejoConnection) => void
): ColumnDef<ForgejoConnection>[] => [
{
accessorKey: "name",
header: ({ column }) => {
@ -125,11 +132,35 @@ const columns: ColumnDef<ForgejoConnection>[] = [
},
{
id: "actions",
cell: ({ row }) => <ActionsCell connection={row.original} />,
cell: ({ row }) => <ActionsCell connection={row.original} onValidate={onValidate} />,
},
];
function ActionsCell({ connection }: { connection: ForgejoConnection }) {
function ActionsCell({
connection,
onValidate,
}: {
connection: ForgejoConnection;
onValidate?: (connection: ForgejoConnection) => void;
}) {
const [isValidateLoading, setIsValidateLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [validateResult, setValidateResult] = useState<{
ok: boolean;
error_message?: string;
response_time_ms?: number;
} | null>(null);
const handleValidate = async () => {
if (!onValidate) return;
setIsValidateLoading(true);
try {
await onValidate(connection);
} finally {
setIsValidateLoading(false);
}
};
const options = [
{ value: "edit", label: "Edit" },
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
@ -160,12 +191,31 @@ function ActionsCell({ connection }: { connection: ForgejoConnection }) {
};
return (
<DropdownSelect
ariaLabel="Connection actions"
options={options}
onValueChange={handleSelect}
triggerClassName="h-8 w-8 p-0"
/>
<div className="flex items-center gap-2">
{onValidate && (
<Button
variant="ghost"
onClick={handleValidate}
disabled={isValidateLoading}
className="h-8 w-8 p-0"
title="Validate connection"
>
{isValidateLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validateResult?.ok ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</Button>
)}
<DropdownSelect
ariaLabel="Connection actions"
options={options}
onValueChange={handleSelect}
triggerClassName="h-8 w-8 p-0"
/>
</div>
);
}

View File

@ -11,6 +11,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2 } from "lucide-react";
import { DataTable } from "@/components/tables/DataTable";
import DropdownSelect from "@/components/ui/dropdown-select";
import { cn } from "@/lib/utils";
@ -22,6 +23,8 @@ interface RepositoriesTableProps {
isLoading: boolean;
onEdit?: (repository: ForgejoRepository) => void;
onDelete?: (repository: ForgejoRepository) => void;
onSync?: (repository: ForgejoRepository) => void;
onValidate?: (repository: ForgejoRepository) => void;
}
export function ForgejoRepositoriesTable({
@ -29,10 +32,14 @@ export function ForgejoRepositoriesTable({
isLoading,
onEdit,
onDelete,
onSync,
onValidate,
}: RepositoriesTableProps) {
// onEdit available for future use
const _ = onEdit;
const table = useReactTable({
data: repositories,
columns: columns,
columns: columns(onSync, onValidate),
getCoreRowModel: getCoreRowModel(),
});
@ -69,7 +76,10 @@ export function ForgejoRepositoriesTable({
);
}
const columns: ColumnDef<ForgejoRepository>[] = [
const columns = (
onSync?: (repository: ForgejoRepository) => void,
onValidate?: (repository: ForgejoRepository) => void
): ColumnDef<ForgejoRepository>[] => [
{
accessorKey: "displayName",
header: ({ column }) => {
@ -128,7 +138,7 @@ const columns: ColumnDef<ForgejoRepository>[] = [
cell: ({ row }) => {
const lastSyncAt = row.original.last_sync_at;
const lastSyncError = row.original.last_sync_error;
if (!lastSyncAt) {
return <span className="text-sm text-slate-400">Never</span>;
}
@ -151,11 +161,56 @@ const columns: ColumnDef<ForgejoRepository>[] = [
},
{
id: "actions",
cell: ({ row }) => <ActionsCell repository={row.original} />,
cell: ({ row }) => <ActionsCell repository={row.original} onSync={onSync} onValidate={onValidate} />,
},
];
function ActionsCell({ repository }: { repository: ForgejoRepository }) {
function ActionsCell({
repository,
onSync,
onValidate,
}: {
repository: ForgejoRepository;
onSync?: (repository: ForgejoRepository) => void;
onValidate?: (repository: ForgejoRepository) => void;
}) {
const [isSyncLoading, setIsSyncLoading] = useState(false);
const [isValidateLoading, setIsValidateLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [syncResult, setSyncResult] = useState<{
created: number;
updated: number;
open: number;
closed: number;
total: number;
} | null>(null);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [validateResult, setValidateResult] = useState<{
ok: boolean;
repo_exists?: boolean;
error_message?: string;
} | null>(null);
const handleSync = async () => {
if (!onSync) return;
setIsSyncLoading(true);
try {
await onSync(repository);
} finally {
setIsSyncLoading(false);
}
};
const handleValidate = async () => {
if (!onValidate) return;
setIsValidateLoading(true);
try {
await onValidate(repository);
} finally {
setIsValidateLoading(false);
}
};
const options = [
{ value: "edit", label: "Edit" },
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
@ -186,12 +241,74 @@ function ActionsCell({ repository }: { repository: ForgejoRepository }) {
};
return (
<DropdownSelect
ariaLabel="Repository actions"
options={options}
onValueChange={handleSelect}
triggerClassName="h-8 w-8 p-0"
/>
<div className="flex items-center gap-2">
{onSync && (
<Button
variant="ghost"
onClick={handleSync}
disabled={isSyncLoading}
className="h-8 w-8 p-0"
title="Sync issues"
>
{isSyncLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : syncResult ? (
<svg
className="h-4 w-4 text-green-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
) : (
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
)}
</Button>
)}
{onValidate && (
<Button
variant="ghost"
onClick={handleValidate}
disabled={isValidateLoading}
className="h-8 w-8 p-0"
title="Validate repository"
>
{isValidateLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validateResult?.ok ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</Button>
)}
<DropdownSelect
ariaLabel="Repository actions"
options={options}
onValueChange={handleSelect}
triggerClassName="h-8 w-8 p-0"
/>
</div>
);
}

View File

@ -10,6 +10,7 @@ import {
CheckCircle2,
Folder,
FolderGit,
CircleDot,
Building2,
LayoutGrid,
Network,
@ -120,6 +121,13 @@ export function DashboardSidebar() {
<FolderGit className="h-4 w-4" />
Git Projects
</Link>
<Link
href="/git-projects/issues"
className={navItemClass(pathname.startsWith("/git-projects/issues"))}
>
<CircleDot className="h-4 w-4" />
Issues
</Link>
<Link
href="/tags"
className={navItemClass(pathname.startsWith("/tags"))}

View File

@ -165,3 +165,115 @@ export async function deleteForgejoRepository(repositoryId: string): Promise<voi
method: "DELETE",
});
}
// Forgejo Sync & Validation API
export async function syncRepository(
repositoryId: string,
): Promise<{
created: number;
updated: number;
open: number;
closed: number;
total: number;
}> {
return fetchJson<{ created: number; updated: number; open: number; closed: number; total: number }>(
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/sync`,
{
method: "POST",
},
);
}
export async function validateConnection(
connectionId: string,
): Promise<{
ok: boolean;
error_message?: string;
response_time_ms: number;
}> {
return fetchJson<{
ok: boolean;
error_message?: string;
response_time_ms: number;
}>(
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}/validate`,
{
method: "POST",
},
);
}
export async function validateRepository(
repositoryId: string,
): Promise<{
ok: boolean;
repo_exists: boolean;
error_message?: string;
}> {
return fetchJson<{
ok: boolean;
repo_exists: boolean;
error_message?: string;
}>(
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/validate`,
{
method: "POST",
},
);
}
// Forgejo Issue types
export interface ForgejoIssue {
id: string;
organization_id: string;
repository_id: string;
forgejo_issue_number: number;
title: string;
body_preview: string | null;
state: string;
is_pull_request: boolean;
labels: Record<string, unknown>[];
assignees: Record<string, unknown>[];
author: string;
html_url: string;
forgejo_created_at: string;
forgejo_updated_at: string;
forgejo_closed_at: string | null;
last_synced_at: string;
created_at: string;
updated_at: string;
}
export interface ForgejoIssueListResponse {
items: ForgejoIssue[];
total: number;
page: number;
limit: number;
}
// Forgejo Issue API
export async function getForgejoIssues(params?: {
repository_id?: string;
state?: string;
search?: string;
page?: number;
limit?: number;
}): Promise<ForgejoIssueListResponse> {
const searchParams = new URLSearchParams();
if (params?.repository_id) searchParams.set("repository_id", params.repository_id);
if (params?.state) searchParams.set("state", params.state);
if (params?.search) searchParams.set("search", params.search);
if (params?.page) searchParams.set("page", params.page.toString());
if (params?.limit) searchParams.set("limit", params.limit.toString());
const qs = searchParams.toString();
return fetchJson<ForgejoIssueListResponse>(
`${API_BASE_URL}/api/v1/forgejo/issues${qs ? `?${qs}` : ""}`,
);
}
export async function getForgejoIssue(issueId: string): Promise<ForgejoIssue> {
return fetchJson<ForgejoIssue>(
`${API_BASE_URL}/api/v1/forgejo/issues/${issueId}`,
);
}