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:
parent
4c540b1c9a
commit
d56ccb31da
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,10 +209,11 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue