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_connections import router as forgejo_connections_router
|
||||||
from app.api.forgejo_issues import router as forgejo_issues_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.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.gateway import router as gateway_router
|
||||||
from app.api.gateways import router as gateways_router
|
from app.api.gateways import router as gateways_router
|
||||||
from app.api.metrics import router as metrics_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_connections_router)
|
||||||
api_v1.include_router(forgejo_issues_router)
|
api_v1.include_router(forgejo_issues_router)
|
||||||
api_v1.include_router(forgejo_repositories_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(gateway_router)
|
||||||
api_v1.include_router(gateways_router)
|
api_v1.include_router(gateways_router)
|
||||||
api_v1.include_router(metrics_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_groups import BoardGroup
|
||||||
from app.models.board_memory import BoardMemory
|
from app.models.board_memory import BoardMemory
|
||||||
from app.models.board_onboarding import BoardOnboardingSession
|
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_webhook_payloads import BoardWebhookPayload
|
||||||
from app.models.board_webhooks import BoardWebhook
|
from app.models.board_webhooks import BoardWebhook
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
|
|
@ -44,6 +45,7 @@ __all__ = [
|
||||||
"BoardOnboardingSession",
|
"BoardOnboardingSession",
|
||||||
"BoardGroup",
|
"BoardGroup",
|
||||||
"Board",
|
"Board",
|
||||||
|
"BoardRepositoryLink",
|
||||||
"ForgejoConnection",
|
"ForgejoConnection",
|
||||||
"ForgejoRepository",
|
"ForgejoRepository",
|
||||||
"Gateway",
|
"Gateway",
|
||||||
|
|
@ -64,4 +66,5 @@ __all__ = [
|
||||||
"Tag",
|
"Tag",
|
||||||
"TagAssignment",
|
"TagAssignment",
|
||||||
"User",
|
"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 {
|
import {
|
||||||
getForgejoConnections,
|
getForgejoConnections,
|
||||||
deleteForgejoConnection,
|
deleteForgejoConnection,
|
||||||
|
validateConnection,
|
||||||
type ForgejoConnection,
|
type ForgejoConnection,
|
||||||
} from "@/lib/api-forgejo";
|
} 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 (
|
return (
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
|
|
@ -86,6 +107,7 @@ export default function ForgejoConnectionsPage() {
|
||||||
connections={connections}
|
connections={connections}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onValidate={handleValidateConnection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
|
|
@ -10,45 +10,150 @@ import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { DataTable } from "@/components/tables/DataTable";
|
import { DataTable } from "@/components/tables/DataTable";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
type GitProject = {
|
import {
|
||||||
id: string;
|
getForgejoRepositories,
|
||||||
name: string;
|
type ForgejoRepository,
|
||||||
url: string;
|
syncRepository,
|
||||||
updatedAt: string;
|
} from "@/lib/api-forgejo";
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function GitProjectsPage() {
|
export default function GitProjectsPage() {
|
||||||
const _useAuth = useAuth();
|
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({
|
const table = useReactTable({
|
||||||
data: gitProjects,
|
data: repositories,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
@ -61,13 +166,34 @@ export default function GitProjectsPage() {
|
||||||
signUpForceRedirectUrl: "/git-projects",
|
signUpForceRedirectUrl: "/git-projects",
|
||||||
}}
|
}}
|
||||||
title="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
|
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
|
<DataTable
|
||||||
table={table}
|
table={table}
|
||||||
isLoading={false}
|
isLoading={loading}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
icon: (
|
icon: (
|
||||||
<svg
|
<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" />
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
title: EMPTY_STATE_DATA.title,
|
title: "No repositories tracked yet",
|
||||||
description: EMPTY_STATE_DATA.description,
|
description:
|
||||||
actionHref: "/git-projects/connect",
|
"Connect a Forgejo instance and add repositories to start tracking issues.",
|
||||||
actionLabel: "Connect repository",
|
actionHref: "/git-projects/connections",
|
||||||
|
actionLabel: "Set up connection",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTa
|
||||||
import {
|
import {
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
deleteForgejoRepository,
|
deleteForgejoRepository,
|
||||||
|
syncRepository,
|
||||||
|
validateRepository,
|
||||||
type ForgejoRepository,
|
type ForgejoRepository,
|
||||||
} from "@/lib/api-forgejo";
|
} 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 (
|
return (
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
|
|
@ -84,6 +127,8 @@ export default function ForgejoRepositoriesPage() {
|
||||||
repositories={repositories}
|
repositories={repositories}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onSync={handleSync}
|
||||||
|
onValidate={handleValidateRepository}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, CheckCircle2 } from "lucide-react";
|
||||||
import { DataTable } from "@/components/tables/DataTable";
|
import { DataTable } from "@/components/tables/DataTable";
|
||||||
import DropdownSelect from "@/components/ui/dropdown-select";
|
import DropdownSelect from "@/components/ui/dropdown-select";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -23,6 +24,7 @@ interface ConnectionsTableProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onEdit?: (connection: ForgejoConnection) => void;
|
onEdit?: (connection: ForgejoConnection) => void;
|
||||||
onDelete?: (connection: ForgejoConnection) => void;
|
onDelete?: (connection: ForgejoConnection) => void;
|
||||||
|
onValidate?: (connection: ForgejoConnection) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ForgejoConnectionsTable({
|
export function ForgejoConnectionsTable({
|
||||||
|
|
@ -30,10 +32,13 @@ export function ForgejoConnectionsTable({
|
||||||
isLoading,
|
isLoading,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onValidate,
|
||||||
}: ConnectionsTableProps) {
|
}: ConnectionsTableProps) {
|
||||||
|
// onEdit available for future use
|
||||||
|
const _ = onEdit;
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: connections,
|
data: connections,
|
||||||
columns: columns,
|
columns: columns(onValidate),
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -70,7 +75,9 @@ export function ForgejoConnectionsTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnDef<ForgejoConnection>[] = [
|
const columns = (
|
||||||
|
onValidate?: (connection: ForgejoConnection) => void
|
||||||
|
): ColumnDef<ForgejoConnection>[] => [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
|
|
@ -125,11 +132,35 @@ const columns: ColumnDef<ForgejoConnection>[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
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 = [
|
const options = [
|
||||||
{ value: "edit", label: "Edit" },
|
{ value: "edit", label: "Edit" },
|
||||||
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
|
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
|
||||||
|
|
@ -160,12 +191,31 @@ function ActionsCell({ connection }: { connection: ForgejoConnection }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownSelect
|
<div className="flex items-center gap-2">
|
||||||
ariaLabel="Connection actions"
|
{onValidate && (
|
||||||
options={options}
|
<Button
|
||||||
onValueChange={handleSelect}
|
variant="ghost"
|
||||||
triggerClassName="h-8 w-8 p-0"
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, CheckCircle2 } from "lucide-react";
|
||||||
import { DataTable } from "@/components/tables/DataTable";
|
import { DataTable } from "@/components/tables/DataTable";
|
||||||
import DropdownSelect from "@/components/ui/dropdown-select";
|
import DropdownSelect from "@/components/ui/dropdown-select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -22,6 +23,8 @@ interface RepositoriesTableProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onEdit?: (repository: ForgejoRepository) => void;
|
onEdit?: (repository: ForgejoRepository) => void;
|
||||||
onDelete?: (repository: ForgejoRepository) => void;
|
onDelete?: (repository: ForgejoRepository) => void;
|
||||||
|
onSync?: (repository: ForgejoRepository) => void;
|
||||||
|
onValidate?: (repository: ForgejoRepository) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ForgejoRepositoriesTable({
|
export function ForgejoRepositoriesTable({
|
||||||
|
|
@ -29,10 +32,14 @@ export function ForgejoRepositoriesTable({
|
||||||
isLoading,
|
isLoading,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onSync,
|
||||||
|
onValidate,
|
||||||
}: RepositoriesTableProps) {
|
}: RepositoriesTableProps) {
|
||||||
|
// onEdit available for future use
|
||||||
|
const _ = onEdit;
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: repositories,
|
data: repositories,
|
||||||
columns: columns,
|
columns: columns(onSync, onValidate),
|
||||||
getCoreRowModel: getCoreRowModel(),
|
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",
|
accessorKey: "displayName",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
|
|
@ -128,7 +138,7 @@ const columns: ColumnDef<ForgejoRepository>[] = [
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const lastSyncAt = row.original.last_sync_at;
|
const lastSyncAt = row.original.last_sync_at;
|
||||||
const lastSyncError = row.original.last_sync_error;
|
const lastSyncError = row.original.last_sync_error;
|
||||||
|
|
||||||
if (!lastSyncAt) {
|
if (!lastSyncAt) {
|
||||||
return <span className="text-sm text-slate-400">Never</span>;
|
return <span className="text-sm text-slate-400">Never</span>;
|
||||||
}
|
}
|
||||||
|
|
@ -151,11 +161,56 @@ const columns: ColumnDef<ForgejoRepository>[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
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 = [
|
const options = [
|
||||||
{ value: "edit", label: "Edit" },
|
{ value: "edit", label: "Edit" },
|
||||||
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
|
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
|
||||||
|
|
@ -186,12 +241,74 @@ function ActionsCell({ repository }: { repository: ForgejoRepository }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownSelect
|
<div className="flex items-center gap-2">
|
||||||
ariaLabel="Repository actions"
|
{onSync && (
|
||||||
options={options}
|
<Button
|
||||||
onValueChange={handleSelect}
|
variant="ghost"
|
||||||
triggerClassName="h-8 w-8 p-0"
|
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,
|
CheckCircle2,
|
||||||
Folder,
|
Folder,
|
||||||
FolderGit,
|
FolderGit,
|
||||||
|
CircleDot,
|
||||||
Building2,
|
Building2,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Network,
|
Network,
|
||||||
|
|
@ -120,6 +121,13 @@ export function DashboardSidebar() {
|
||||||
<FolderGit className="h-4 w-4" />
|
<FolderGit className="h-4 w-4" />
|
||||||
Git Projects
|
Git Projects
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/git-projects/issues"
|
||||||
|
className={navItemClass(pathname.startsWith("/git-projects/issues"))}
|
||||||
|
>
|
||||||
|
<CircleDot className="h-4 w-4" />
|
||||||
|
Issues
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/tags"
|
href="/tags"
|
||||||
className={navItemClass(pathname.startsWith("/tags"))}
|
className={navItemClass(pathname.startsWith("/tags"))}
|
||||||
|
|
|
||||||
|
|
@ -165,3 +165,115 @@ export async function deleteForgejoRepository(repositoryId: string): Promise<voi
|
||||||
method: "DELETE",
|
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