feat(scripts): issues info
This commit is contained in:
parent
f59208c3ac
commit
e56f252da6
|
|
@ -6,8 +6,8 @@ from typing import TYPE_CHECKING
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import cast, String
|
||||
from sqlmodel import select, func
|
||||
from sqlalchemy import String, cast
|
||||
from sqlmodel import func, select
|
||||
|
||||
from app.api.deps import require_org_member
|
||||
from app.core.auth import AuthContext, get_auth_context
|
||||
|
|
@ -15,9 +15,19 @@ from app.db import crud
|
|||
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 ForgejoIssueListResponse, ForgejoIssueRead, CloseIssueResponse
|
||||
from app.schemas.forgejo_issues import (
|
||||
CloseIssueResponse,
|
||||
ForgejoIssueDetailRead,
|
||||
ForgejoIssueListResponse,
|
||||
ForgejoIssueRead,
|
||||
)
|
||||
from app.services.activity_log import record_activity
|
||||
from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError
|
||||
from app.services.forgejo_issue_close import (
|
||||
CloseIssueAccessError,
|
||||
CloseIssueNotFoundError,
|
||||
CloseIssueRemoteError,
|
||||
close_issue_by_id,
|
||||
)
|
||||
from app.services.organizations import OrganizationContext, list_accessible_board_ids
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -35,7 +45,9 @@ async def list_issues(
|
|||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
repository_id: str | None = Query(None, description="Filter by repository ID"),
|
||||
board_id: str | None = Query(None, description="Filter by board ID (returns issues from all repos linked to this board)"),
|
||||
board_id: str | None = Query(
|
||||
None, description="Filter by board ID (returns issues from all repos linked to this board)"
|
||||
),
|
||||
state: str | None = Query(None, description="Filter by state (open, closed)"),
|
||||
label: str | None = Query(None, description="Filter by label name"),
|
||||
assignee: str | None = Query(None, description="Filter by assignee login"),
|
||||
|
|
@ -54,7 +66,9 @@ async def list_issues(
|
|||
try:
|
||||
board_uuid = UUID(board_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid board_id format")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid board_id format"
|
||||
)
|
||||
linked_repo_ids = (
|
||||
await session.exec(
|
||||
select(BoardRepositoryLink.repository_id).where(
|
||||
|
|
@ -71,7 +85,10 @@ async def list_issues(
|
|||
try:
|
||||
repo_uuid = UUID(repository_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid repository_id format")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Invalid repository_id format",
|
||||
)
|
||||
statement = statement.where(ForgejoIssue.repository_id == repo_uuid)
|
||||
|
||||
if state:
|
||||
|
|
@ -79,27 +96,27 @@ async def list_issues(
|
|||
|
||||
if label:
|
||||
# Filter by label name — search within the JSON labels array cast to text
|
||||
statement = statement.where(
|
||||
cast(ForgejoIssue.labels, String).ilike(f"%{label}%")
|
||||
)
|
||||
statement = statement.where(cast(ForgejoIssue.labels, String).ilike(f"%{label}%"))
|
||||
|
||||
if assignee:
|
||||
# Filter by assignee login — search within the JSON assignees array cast to text
|
||||
statement = statement.where(
|
||||
cast(ForgejoIssue.assignees, String).ilike(f"%{assignee}%")
|
||||
)
|
||||
statement = statement.where(cast(ForgejoIssue.assignees, String).ilike(f"%{assignee}%"))
|
||||
|
||||
if search:
|
||||
statement = statement.where(
|
||||
(ForgejoIssue.title.ilike(f"%{search}%")) |
|
||||
(ForgejoIssue.body_preview.ilike(f"%{search}%")) |
|
||||
(ForgejoIssue.body.ilike(f"%{search}%"))
|
||||
(ForgejoIssue.title.ilike(f"%{search}%"))
|
||||
| (ForgejoIssue.body_preview.ilike(f"%{search}%"))
|
||||
| (ForgejoIssue.body.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
# Count total
|
||||
total_statement = select(func.count()).select_from(ForgejoIssue).where(
|
||||
ForgejoIssue.organization_id == ctx.organization.id,
|
||||
ForgejoIssue.is_pull_request.is_(False),
|
||||
total_statement = (
|
||||
select(func.count())
|
||||
.select_from(ForgejoIssue)
|
||||
.where(
|
||||
ForgejoIssue.organization_id == ctx.organization.id,
|
||||
ForgejoIssue.is_pull_request.is_(False),
|
||||
)
|
||||
)
|
||||
if board_id:
|
||||
try:
|
||||
|
|
@ -136,16 +153,18 @@ async def list_issues(
|
|||
)
|
||||
if search:
|
||||
total_statement = total_statement.where(
|
||||
(ForgejoIssue.title.ilike(f"%{search}%")) |
|
||||
(ForgejoIssue.body_preview.ilike(f"%{search}%")) |
|
||||
(ForgejoIssue.body.ilike(f"%{search}%"))
|
||||
(ForgejoIssue.title.ilike(f"%{search}%"))
|
||||
| (ForgejoIssue.body_preview.ilike(f"%{search}%"))
|
||||
| (ForgejoIssue.body.ilike(f"%{search}%"))
|
||||
)
|
||||
total_result = await session.exec(total_statement)
|
||||
total = total_result.one()
|
||||
|
||||
# Pagination
|
||||
offset = (page - 1) * limit
|
||||
statement = statement.offset(offset).limit(limit).order_by(ForgejoIssue.forgejo_issue_number.desc())
|
||||
statement = (
|
||||
statement.offset(offset).limit(limit).order_by(ForgejoIssue.forgejo_issue_number.desc())
|
||||
)
|
||||
|
||||
issues = (await session.exec(statement)).all()
|
||||
items = [ForgejoIssueRead.model_validate(issue) for issue in issues]
|
||||
|
|
@ -158,17 +177,19 @@ async def list_issues(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/{issue_id}", response_model=ForgejoIssueRead)
|
||||
@router.get("/{issue_id}", response_model=ForgejoIssueDetailRead)
|
||||
async def get_issue(
|
||||
issue_id: str,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> ForgejoIssueRead:
|
||||
) -> ForgejoIssueDetailRead:
|
||||
"""Get one cached issue by ID."""
|
||||
try:
|
||||
uuid = UUID(issue_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid issue_id format")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid issue_id format"
|
||||
)
|
||||
|
||||
issue = await crud.get_by_id(session, ForgejoIssue, uuid)
|
||||
if issue is None:
|
||||
|
|
@ -176,7 +197,7 @@ async def get_issue(
|
|||
if issue.organization_id != ctx.organization.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
return ForgejoIssueRead.model_validate(issue)
|
||||
return ForgejoIssueDetailRead.model_validate(issue)
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -224,14 +245,16 @@ async def close_issue(
|
|||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> CloseIssueResponse:
|
||||
"""Close a Forgejo issue as an authenticated user.
|
||||
|
||||
|
||||
The user must have write access to the board that the issue's repository
|
||||
is linked to. The issue must belong to a repository linked to that board.
|
||||
"""
|
||||
try:
|
||||
uuid = UUID(issue_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid issue_id format")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid issue_id format"
|
||||
)
|
||||
|
||||
issue = await crud.get_by_id(session, ForgejoIssue, uuid)
|
||||
if issue is None:
|
||||
|
|
@ -256,9 +279,7 @@ async def close_issue(
|
|||
detail="Issue repository is not linked to any board",
|
||||
)
|
||||
|
||||
allowed_board_ids = set(
|
||||
await list_accessible_board_ids(session, member=ctx.member, write=True)
|
||||
)
|
||||
allowed_board_ids = set(await list_accessible_board_ids(session, member=ctx.member, write=True))
|
||||
authorized_board_id = next(
|
||||
(link.board_id for link in links if link.board_id in allowed_board_ids),
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -323,6 +323,7 @@ async def _upsert_issue(
|
|||
labels=_labels(issue_data),
|
||||
assignees=_assignees(issue_data),
|
||||
milestone=milestone,
|
||||
forgejo_payload=dict(issue_data),
|
||||
author=_author(issue_data),
|
||||
html_url=str(issue_data.get("html_url") or ""),
|
||||
forgejo_created_at=_parse_iso_datetime(issue_data.get("created_at")),
|
||||
|
|
@ -341,6 +342,7 @@ async def _upsert_issue(
|
|||
existing.is_pull_request = False
|
||||
existing.labels = _labels(issue_data)
|
||||
existing.assignees = _assignees(issue_data)
|
||||
existing.forgejo_payload = dict(issue_data)
|
||||
existing.author = _author(issue_data)
|
||||
existing.html_url = str(issue_data.get("html_url") or "")
|
||||
existing.forgejo_created_at = _parse_iso_datetime(issue_data.get("created_at"))
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ class ForgejoIssue(SQLModel, table=True):
|
|||
labels: list[dict[str, object]] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
assignees: list[dict[str, object]] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
milestone: dict[str, object] | None = Field(default=None, sa_column=Column(JSON, nullable=True))
|
||||
forgejo_payload: dict[str, object] | None = Field(
|
||||
default=None, sa_column=Column(JSON, nullable=True)
|
||||
)
|
||||
|
||||
author: str
|
||||
html_url: str
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ class ForgejoIssueRead(ForgejoIssueBase):
|
|||
updated_at: datetime
|
||||
|
||||
|
||||
class ForgejoIssueDetailRead(ForgejoIssueRead):
|
||||
"""Issue detail payload including full cached Forgejo source payload."""
|
||||
|
||||
forgejo_payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ForgejoIssueListResponse(SQLModel):
|
||||
"""Paginated list response for issues."""
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,13 @@ async def close_cached_issue(
|
|||
issue.state = "closed"
|
||||
issue.forgejo_closed_at = utcnow()
|
||||
issue.last_synced_at = utcnow()
|
||||
|
||||
if isinstance(issue.forgejo_payload, dict):
|
||||
payload = dict(issue.forgejo_payload)
|
||||
payload["state"] = "closed"
|
||||
payload["closed_at"] = issue.forgejo_closed_at.isoformat()
|
||||
payload["updated_at"] = issue.last_synced_at.isoformat()
|
||||
issue.forgejo_payload = payload
|
||||
|
||||
# Update the issue in the session
|
||||
session.add(issue)
|
||||
await session.flush()
|
||||
|
|
@ -114,7 +120,9 @@ async def close_cached_issue(
|
|||
"repository_id": str(repository.id),
|
||||
"repository_full_name": f"{repository.owner}/{repository.repo}",
|
||||
"state": "closed",
|
||||
"forgejo_closed_at": issue.forgejo_closed_at.isoformat() if issue.forgejo_closed_at else None,
|
||||
"forgejo_closed_at": (
|
||||
issue.forgejo_closed_at.isoformat() if issue.forgejo_closed_at else None
|
||||
),
|
||||
"last_synced_at": issue.last_synced_at.isoformat(),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import select
|
||||
|
|
@ -14,7 +13,7 @@ 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 ForgejoAPIClient, get_forgejo_client
|
||||
from app.services.forgejo_client import get_forgejo_client
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -63,7 +62,11 @@ class IssueSyncService:
|
|||
)
|
||||
|
||||
# Forgejo returns issues as a JSON array, not wrapped in "items"
|
||||
issues = response if isinstance(response, list) else response.get("items", response.get("data", []))
|
||||
issues = (
|
||||
response
|
||||
if isinstance(response, list)
|
||||
else response.get("items", response.get("data", []))
|
||||
)
|
||||
if not isinstance(issues, list) or len(issues) == 0:
|
||||
break
|
||||
|
||||
|
|
@ -77,22 +80,26 @@ class IssueSyncService:
|
|||
|
||||
# Parse labels
|
||||
labels_data = []
|
||||
for label in (issue_data.get("labels") or []):
|
||||
labels_data.append({
|
||||
"id": label.get("id"),
|
||||
"name": label.get("name", ""),
|
||||
"color": label.get("color", ""),
|
||||
"description": label.get("description", ""),
|
||||
})
|
||||
for label in issue_data.get("labels") or []:
|
||||
labels_data.append(
|
||||
{
|
||||
"id": label.get("id"),
|
||||
"name": label.get("name", ""),
|
||||
"color": label.get("color", ""),
|
||||
"description": label.get("description", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Parse assignees
|
||||
assignees_data = []
|
||||
for assignee in (issue_data.get("assignees") or []):
|
||||
assignees_data.append({
|
||||
"login": assignee.get("login", ""),
|
||||
"id": assignee.get("id", 0),
|
||||
"avatar_url": assignee.get("avatar_url", ""),
|
||||
})
|
||||
for assignee in issue_data.get("assignees") or []:
|
||||
assignees_data.append(
|
||||
{
|
||||
"login": assignee.get("login", ""),
|
||||
"id": assignee.get("id", 0),
|
||||
"avatar_url": assignee.get("avatar_url", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Parse milestone
|
||||
milestone_data = None
|
||||
|
|
@ -133,6 +140,7 @@ class IssueSyncService:
|
|||
labels=labels_data,
|
||||
assignees=assignees_data,
|
||||
milestone=milestone_data,
|
||||
forgejo_payload=dict(issue_data),
|
||||
author=issue_data.get("user", {}).get("login", ""),
|
||||
html_url=issue_data.get("html_url", ""),
|
||||
forgejo_created_at=created_at,
|
||||
|
|
@ -150,6 +158,7 @@ class IssueSyncService:
|
|||
existing.labels = labels_data
|
||||
existing.assignees = assignees_data
|
||||
existing.milestone = milestone_data
|
||||
existing.forgejo_payload = dict(issue_data)
|
||||
existing.author = issue_data.get("user", {}).get("login", "")
|
||||
existing.html_url = issue_data.get("html_url", "")
|
||||
existing.forgejo_created_at = created_at
|
||||
|
|
@ -225,7 +234,9 @@ class IssueSyncService:
|
|||
"total": created + updated_count,
|
||||
}
|
||||
|
||||
async def _find_issue(self, repository_id: UUID, forgejo_issue_number: int) -> ForgejoIssue | None:
|
||||
async def _find_issue(
|
||||
self, repository_id: UUID, forgejo_issue_number: int
|
||||
) -> ForgejoIssue | None:
|
||||
"""Find an existing cached issue by repository and number."""
|
||||
statement = select(ForgejoIssue).where(
|
||||
ForgejoIssue.repository_id == repository_id,
|
||||
|
|
@ -243,4 +254,4 @@ class IssueSyncService:
|
|||
parsed = datetime.fromisoformat(cleaned)
|
||||
return parsed.replace(tzinfo=None)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
"""add cached raw Forgejo payload on issues
|
||||
|
||||
Revision ID: d3f4a5b6c7d8
|
||||
Revises: a7aa29cf8a20
|
||||
Create Date: 2026-05-22 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "d3f4a5b6c7d8"
|
||||
down_revision = "a7aa29cf8a20"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"forgejo_issues",
|
||||
sa.Column("forgejo_payload", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("forgejo_issues", "forgejo_payload")
|
||||
|
|
@ -17,8 +17,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||
from app import models as _models
|
||||
from app.api import deps as deps_api
|
||||
from app.api.agent_forgejo import router as agent_forgejo_router
|
||||
from app.api.forgejo_issues import router as forgejo_issues_router
|
||||
from app.api.deps import require_org_member
|
||||
from app.api.forgejo_issues import router as forgejo_issues_router
|
||||
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context
|
||||
from app.core.auth import AuthContext, get_auth_context
|
||||
from app.db.session import get_session
|
||||
|
|
@ -66,28 +66,34 @@ def _build_test_app(
|
|||
app.dependency_overrides[get_session] = _override_get_session
|
||||
|
||||
if org_ctx is not None:
|
||||
|
||||
async def _override_require_org_member() -> OrganizationContext:
|
||||
return org_ctx
|
||||
|
||||
app.dependency_overrides[require_org_member] = _override_require_org_member
|
||||
|
||||
if auth_ctx is not None:
|
||||
|
||||
async def _override_get_auth_context() -> AuthContext:
|
||||
return auth_ctx
|
||||
|
||||
app.dependency_overrides[get_auth_context] = _override_get_auth_context
|
||||
|
||||
if agent_ctx is not None:
|
||||
|
||||
async def _override_get_agent_auth_context() -> AgentAuthContext:
|
||||
return agent_ctx
|
||||
|
||||
app.dependency_overrides[get_agent_auth_context] = _override_get_agent_auth_context
|
||||
|
||||
if board is not None:
|
||||
|
||||
async def _override_get_board_for_actor_read() -> Board:
|
||||
return board
|
||||
|
||||
app.dependency_overrides[deps_api.get_board_for_actor_read] = _override_get_board_for_actor_read
|
||||
app.dependency_overrides[deps_api.get_board_for_actor_read] = (
|
||||
_override_get_board_for_actor_read
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
|
@ -155,6 +161,7 @@ async def _seed(session: AsyncSession) -> SimpleNamespace:
|
|||
is_pull_request=False,
|
||||
labels=[],
|
||||
assignees=[],
|
||||
forgejo_payload={"number": 42, "state": "open"},
|
||||
author="kaspa",
|
||||
html_url="https://forgejo.example.local/openclaw/pipeline/issues/42",
|
||||
forgejo_created_at=datetime(2026, 5, 19, 12, 0, 0),
|
||||
|
|
@ -267,6 +274,9 @@ async def test_human_writer_can_close_linked_issue_and_records_activity(
|
|||
assert stored_issue.state == "closed"
|
||||
assert stored_issue.forgejo_closed_at is not None
|
||||
assert stored_issue.last_synced_at is not None
|
||||
assert stored_issue.forgejo_payload is not None
|
||||
assert stored_issue.forgejo_payload.get("state") == "closed"
|
||||
assert stored_issue.forgejo_payload.get("closed_at")
|
||||
|
||||
events = (await session.exec(select(ActivityEvent))).all()
|
||||
assert len(events) == 1
|
||||
|
|
|
|||
|
|
@ -243,6 +243,9 @@ async def test_forgejo_webhook_closes_issue_and_records_board_activity() -> None
|
|||
assert stored_issue.assignees == [
|
||||
{"login": "codex", "id": 7, "avatar_url": "https://avatar.test/c"}
|
||||
]
|
||||
assert isinstance(stored_issue.forgejo_payload, dict)
|
||||
assert stored_issue.forgejo_payload.get("number") == 42
|
||||
assert stored_issue.forgejo_payload.get("title") == "Fix webhook cache updates"
|
||||
assert stored_issue.author == "kaspa"
|
||||
assert stored_issue.forgejo_closed_at == datetime(2026, 5, 19, 12, 45, 0)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue