refactor(issue-sync): streamline issue closing logic and enhance error handling
This commit is contained in:
parent
1076ca27bb
commit
11d950a13a
|
|
@ -694,7 +694,10 @@ async def close_issue(
|
||||||
if auth.user is None:
|
if auth.user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
# Get boards linked to this issue's repository for this organization.
|
# Check board links — used for authorization and activity logging.
|
||||||
|
# If the repository is not linked to any board the user can still close
|
||||||
|
# the issue (org membership is sufficient); we just skip the board-scoped
|
||||||
|
# activity event.
|
||||||
links = (
|
links = (
|
||||||
await session.exec(
|
await session.exec(
|
||||||
select(BoardRepositoryLink).where(
|
select(BoardRepositoryLink).where(
|
||||||
|
|
@ -703,22 +706,19 @@ async def close_issue(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
if not links:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Issue repository is not linked to any board",
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed_board_ids = set(await list_accessible_board_ids(session, member=ctx.member, write=True))
|
authorized_board_id: object = None
|
||||||
authorized_board_id = next(
|
if links:
|
||||||
(link.board_id for link in links if link.board_id in allowed_board_ids),
|
allowed_board_ids = set(await list_accessible_board_ids(session, member=ctx.member, write=True))
|
||||||
None,
|
authorized_board_id = next(
|
||||||
)
|
(link.board_id for link in links if link.board_id in allowed_board_ids),
|
||||||
if authorized_board_id is None:
|
None,
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Board access denied",
|
|
||||||
)
|
)
|
||||||
|
if authorized_board_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Board access denied",
|
||||||
|
)
|
||||||
|
|
||||||
# Close the issue using the service.
|
# Close the issue using the service.
|
||||||
try:
|
try:
|
||||||
|
|
@ -735,15 +735,16 @@ async def close_issue(
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
||||||
|
|
||||||
repository_full_name = str(result.get("repository_full_name") or "unknown/unknown")
|
repository_full_name = str(result.get("repository_full_name") or "unknown/unknown")
|
||||||
record_activity(
|
if authorized_board_id is not None:
|
||||||
session,
|
record_activity(
|
||||||
event_type="forgejo.issue.closed",
|
session,
|
||||||
message=(
|
event_type="forgejo.issue.closed",
|
||||||
"Forgejo issue closed by user "
|
message=(
|
||||||
f"{auth.user.id}: {repository_full_name}#{result['forgejo_issue_number']}"
|
"Forgejo issue closed by user "
|
||||||
),
|
f"{auth.user.id}: {repository_full_name}#{result['forgejo_issue_number']}"
|
||||||
board_id=authorized_board_id,
|
),
|
||||||
)
|
board_id=authorized_board_id,
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return CloseIssueResponse(
|
return CloseIssueResponse(
|
||||||
|
|
|
||||||
|
|
@ -85,209 +85,209 @@ class IssueSyncService:
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Forgejo returns issues as a JSON array, not wrapped in "items"
|
# Forgejo returns issues as a JSON array, not wrapped in "items"
|
||||||
issues = (
|
issues = (
|
||||||
response
|
response
|
||||||
if isinstance(response, list)
|
if isinstance(response, list)
|
||||||
else response.get("items", response.get("data", []))
|
else response.get("items", response.get("data", []))
|
||||||
)
|
)
|
||||||
if not isinstance(issues, list) or len(issues) == 0:
|
if not isinstance(issues, list) or len(issues) == 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
for issue_data in issues:
|
for issue_data in issues:
|
||||||
# Skip pull requests
|
# Skip pull requests
|
||||||
if issue_data.get("pull_request") is not None:
|
if issue_data.get("pull_request") is not None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
raw_number = issue_data.get("number", 0)
|
raw_number = issue_data.get("number", 0)
|
||||||
try:
|
try:
|
||||||
forgejo_number = int(raw_number)
|
forgejo_number = int(raw_number)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
forgejo_number = 0
|
forgejo_number = 0
|
||||||
state = issue_data.get("state", "open")
|
state = issue_data.get("state", "open")
|
||||||
issue_payload = dict(issue_data)
|
issue_payload = dict(issue_data)
|
||||||
comments_payload: list[dict[str, object]] = []
|
comments_payload: list[dict[str, object]] = []
|
||||||
timeline_payload: list[dict[str, object]] = []
|
timeline_payload: list[dict[str, object]] = []
|
||||||
reactions_payload: list[dict[str, object]] = []
|
reactions_payload: list[dict[str, object]] = []
|
||||||
|
|
||||||
# Enrich each issue with full detail and exhaustive nested data.
|
# Enrich each issue with full detail and exhaustive nested data.
|
||||||
try:
|
try:
|
||||||
full_issue = await client.get_issue(
|
full_issue = await client.get_issue(
|
||||||
owner=repository.owner,
|
|
||||||
repo=repository.repo,
|
|
||||||
issue_number=forgejo_number,
|
|
||||||
)
|
|
||||||
maybe_full = _as_dict(full_issue)
|
|
||||||
if maybe_full is not None:
|
|
||||||
issue_payload = maybe_full
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"issue_detail_sync_failed",
|
|
||||||
extra={
|
|
||||||
"repository_id": str(repository_id),
|
|
||||||
"issue_number": forgejo_number,
|
|
||||||
"error": str(exc),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
comments_payload = _as_dict_list(
|
|
||||||
await client.list_issue_comments(
|
|
||||||
owner=repository.owner,
|
owner=repository.owner,
|
||||||
repo=repository.repo,
|
repo=repository.repo,
|
||||||
issue_number=forgejo_number,
|
issue_number=forgejo_number,
|
||||||
)
|
)
|
||||||
)
|
maybe_full = _as_dict(full_issue)
|
||||||
except Exception as exc:
|
if maybe_full is not None:
|
||||||
logger.warning(
|
issue_payload = maybe_full
|
||||||
"issue_comments_sync_failed",
|
except Exception as exc:
|
||||||
extra={
|
logger.warning(
|
||||||
"repository_id": str(repository_id),
|
"issue_detail_sync_failed",
|
||||||
"issue_number": forgejo_number,
|
extra={
|
||||||
"error": str(exc),
|
"repository_id": str(repository_id),
|
||||||
},
|
"issue_number": forgejo_number,
|
||||||
)
|
"error": str(exc),
|
||||||
|
},
|
||||||
try:
|
|
||||||
timeline_payload = _as_dict_list(
|
|
||||||
await client.list_issue_timeline(
|
|
||||||
owner=repository.owner,
|
|
||||||
repo=repository.repo,
|
|
||||||
issue_number=forgejo_number,
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"issue_timeline_sync_failed",
|
|
||||||
extra={
|
|
||||||
"repository_id": str(repository_id),
|
|
||||||
"issue_number": forgejo_number,
|
|
||||||
"error": str(exc),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
reactions_payload = _as_dict_list(
|
comments_payload = _as_dict_list(
|
||||||
await client.list_issue_reactions(
|
await client.list_issue_comments(
|
||||||
owner=repository.owner,
|
owner=repository.owner,
|
||||||
repo=repository.repo,
|
repo=repository.repo,
|
||||||
issue_number=forgejo_number,
|
issue_number=forgejo_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"issue_comments_sync_failed",
|
||||||
|
extra={
|
||||||
|
"repository_id": str(repository_id),
|
||||||
|
"issue_number": forgejo_number,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"issue_reactions_sync_failed",
|
|
||||||
extra={
|
|
||||||
"repository_id": str(repository_id),
|
|
||||||
"issue_number": forgejo_number,
|
|
||||||
"error": str(exc),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse labels
|
try:
|
||||||
labels_data = []
|
timeline_payload = _as_dict_list(
|
||||||
for label in issue_payload.get("labels") or []:
|
await client.list_issue_timeline(
|
||||||
labels_data.append(
|
owner=repository.owner,
|
||||||
{
|
repo=repository.repo,
|
||||||
"id": label.get("id"),
|
issue_number=forgejo_number,
|
||||||
"name": label.get("name", ""),
|
)
|
||||||
"color": label.get("color", ""),
|
)
|
||||||
"description": label.get("description", ""),
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"issue_timeline_sync_failed",
|
||||||
|
extra={
|
||||||
|
"repository_id": str(repository_id),
|
||||||
|
"issue_number": forgejo_number,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
reactions_payload = _as_dict_list(
|
||||||
|
await client.list_issue_reactions(
|
||||||
|
owner=repository.owner,
|
||||||
|
repo=repository.repo,
|
||||||
|
issue_number=forgejo_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"issue_reactions_sync_failed",
|
||||||
|
extra={
|
||||||
|
"repository_id": str(repository_id),
|
||||||
|
"issue_number": forgejo_number,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse labels
|
||||||
|
labels_data = []
|
||||||
|
for label in issue_payload.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_payload.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
|
||||||
|
raw_milestone = issue_payload.get("milestone")
|
||||||
|
if raw_milestone and isinstance(raw_milestone, dict):
|
||||||
|
milestone_data = {
|
||||||
|
"id": raw_milestone.get("id"),
|
||||||
|
"title": raw_milestone.get("title", ""),
|
||||||
|
"state": raw_milestone.get("state", "open"),
|
||||||
|
"description": raw_milestone.get("description") or None,
|
||||||
|
"due_on": raw_milestone.get("due_on") or None,
|
||||||
|
"closed_at": raw_milestone.get("closed_at") or None,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
# Parse assignees
|
# Full body and preview
|
||||||
assignees_data = []
|
raw_body = issue_payload.get("body") or ""
|
||||||
for assignee in issue_payload.get("assignees") or []:
|
body_full = raw_body if raw_body else None
|
||||||
assignees_data.append(
|
body_preview = raw_body[:1000] if raw_body else None
|
||||||
{
|
|
||||||
"login": assignee.get("login", ""),
|
|
||||||
"id": assignee.get("id", 0),
|
|
||||||
"avatar_url": assignee.get("avatar_url", ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse milestone
|
# Parse dates — required fields fall back to utcnow(), optional closed_at stays None
|
||||||
milestone_data = None
|
created_at = self._parse_iso_date(issue_payload.get("created_at")) or utcnow()
|
||||||
raw_milestone = issue_payload.get("milestone")
|
updated_at = self._parse_iso_date(issue_payload.get("updated_at")) or utcnow()
|
||||||
if raw_milestone and isinstance(raw_milestone, dict):
|
closed_at = self._parse_iso_date(issue_payload.get("closed_at"))
|
||||||
milestone_data = {
|
|
||||||
"id": raw_milestone.get("id"),
|
|
||||||
"title": raw_milestone.get("title", ""),
|
|
||||||
"state": raw_milestone.get("state", "open"),
|
|
||||||
"description": raw_milestone.get("description") or None,
|
|
||||||
"due_on": raw_milestone.get("due_on") or None,
|
|
||||||
"closed_at": raw_milestone.get("closed_at") or None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Full body and preview
|
# Check if issue exists
|
||||||
raw_body = issue_payload.get("body") or ""
|
existing = await self._find_issue(repository_id, forgejo_number)
|
||||||
body_full = raw_body if raw_body else None
|
|
||||||
body_preview = raw_body[:1000] if raw_body else None
|
|
||||||
|
|
||||||
# Parse dates — required fields fall back to utcnow(), optional closed_at stays None
|
if existing is None:
|
||||||
created_at = self._parse_iso_date(issue_payload.get("created_at")) or utcnow()
|
issue = ForgejoIssue(
|
||||||
updated_at = self._parse_iso_date(issue_payload.get("updated_at")) or utcnow()
|
organization_id=self.organization_id,
|
||||||
closed_at = self._parse_iso_date(issue_payload.get("closed_at"))
|
repository_id=repository_id,
|
||||||
|
forgejo_issue_number=forgejo_number,
|
||||||
|
title=issue_data.get("title", ""),
|
||||||
|
body=body_full,
|
||||||
|
body_preview=body_preview,
|
||||||
|
state=state,
|
||||||
|
is_pull_request=False,
|
||||||
|
labels=labels_data,
|
||||||
|
assignees=assignees_data,
|
||||||
|
milestone=milestone_data,
|
||||||
|
forgejo_payload=issue_payload,
|
||||||
|
forgejo_comments_payload=comments_payload,
|
||||||
|
forgejo_timeline_payload=timeline_payload,
|
||||||
|
forgejo_reactions_payload=reactions_payload,
|
||||||
|
author=_author_login(issue_payload),
|
||||||
|
html_url=_html_url(issue_payload),
|
||||||
|
forgejo_created_at=created_at,
|
||||||
|
forgejo_updated_at=updated_at,
|
||||||
|
forgejo_closed_at=closed_at,
|
||||||
|
)
|
||||||
|
self.session.add(issue)
|
||||||
|
await self.session.flush()
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
existing.title = issue_data.get("title", "")
|
||||||
|
existing.body = body_full
|
||||||
|
existing.body_preview = body_preview
|
||||||
|
existing.state = state
|
||||||
|
existing.labels = labels_data
|
||||||
|
existing.assignees = assignees_data
|
||||||
|
existing.milestone = milestone_data
|
||||||
|
existing.forgejo_payload = issue_payload
|
||||||
|
existing.forgejo_comments_payload = comments_payload
|
||||||
|
existing.forgejo_timeline_payload = timeline_payload
|
||||||
|
existing.forgejo_reactions_payload = reactions_payload
|
||||||
|
existing.author = _author_login(issue_payload)
|
||||||
|
existing.html_url = _html_url(issue_payload)
|
||||||
|
existing.forgejo_created_at = created_at
|
||||||
|
existing.forgejo_updated_at = updated_at
|
||||||
|
existing.forgejo_closed_at = closed_at
|
||||||
|
existing.last_synced_at = utcnow()
|
||||||
|
await crud.save(self.session, existing)
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
# Check if issue exists
|
if state == "open":
|
||||||
existing = await self._find_issue(repository_id, forgejo_number)
|
open_count += 1
|
||||||
|
elif state == "closed":
|
||||||
|
closed_count += 1
|
||||||
|
|
||||||
if existing is None:
|
# If we got fewer than limit, we're done
|
||||||
issue = ForgejoIssue(
|
if len(issues) < limit:
|
||||||
organization_id=self.organization_id,
|
break
|
||||||
repository_id=repository_id,
|
|
||||||
forgejo_issue_number=forgejo_number,
|
|
||||||
title=issue_data.get("title", ""),
|
|
||||||
body=body_full,
|
|
||||||
body_preview=body_preview,
|
|
||||||
state=state,
|
|
||||||
is_pull_request=False,
|
|
||||||
labels=labels_data,
|
|
||||||
assignees=assignees_data,
|
|
||||||
milestone=milestone_data,
|
|
||||||
forgejo_payload=issue_payload,
|
|
||||||
forgejo_comments_payload=comments_payload,
|
|
||||||
forgejo_timeline_payload=timeline_payload,
|
|
||||||
forgejo_reactions_payload=reactions_payload,
|
|
||||||
author=_author_login(issue_payload),
|
|
||||||
html_url=_html_url(issue_payload),
|
|
||||||
forgejo_created_at=created_at,
|
|
||||||
forgejo_updated_at=updated_at,
|
|
||||||
forgejo_closed_at=closed_at,
|
|
||||||
)
|
|
||||||
self.session.add(issue)
|
|
||||||
await self.session.flush()
|
|
||||||
created += 1
|
|
||||||
else:
|
|
||||||
existing.title = issue_data.get("title", "")
|
|
||||||
existing.body = body_full
|
|
||||||
existing.body_preview = body_preview
|
|
||||||
existing.state = state
|
|
||||||
existing.labels = labels_data
|
|
||||||
existing.assignees = assignees_data
|
|
||||||
existing.milestone = milestone_data
|
|
||||||
existing.forgejo_payload = issue_payload
|
|
||||||
existing.forgejo_comments_payload = comments_payload
|
|
||||||
existing.forgejo_timeline_payload = timeline_payload
|
|
||||||
existing.forgejo_reactions_payload = reactions_payload
|
|
||||||
existing.author = _author_login(issue_payload)
|
|
||||||
existing.html_url = _html_url(issue_payload)
|
|
||||||
existing.forgejo_created_at = created_at
|
|
||||||
existing.forgejo_updated_at = updated_at
|
|
||||||
existing.forgejo_closed_at = closed_at
|
|
||||||
existing.last_synced_at = utcnow()
|
|
||||||
await crud.save(self.session, existing)
|
|
||||||
updated_count += 1
|
|
||||||
|
|
||||||
if state == "open":
|
|
||||||
open_count += 1
|
|
||||||
elif state == "closed":
|
|
||||||
closed_count += 1
|
|
||||||
|
|
||||||
# If we got fewer than limit, we're done
|
|
||||||
if len(issues) < limit:
|
|
||||||
break
|
|
||||||
current_page += 1
|
current_page += 1
|
||||||
|
|
||||||
# Sync repository label catalog
|
# Sync repository label catalog
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue