Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,46 @@ and the server
spaghettihubserver
```

## Environment Variables

The following environment variables are used by the application:

- `GITHUB_TOKEN` (or `--gh_token` for worker): GitHub personal access token with repo permissions for API access

## Using the Mirror PR Comments Tool

The mirror PR comments tool allows you to mirror all open comments from a GitHub Pull Request to a Launchpad Merge Proposal.

### API Endpoint

`POST /v1/tools/mirror-pr-comments`

**Request Body:**
```json
{
"github_pr_url": "https://github.com/owner/repo/pull/123",
"launchpad_mp_url": "https://code.launchpad.net/~owner/project/+merge/456",
"include_outdated": false,
"include_review_states": false
}
```

**Response:**
```json
{
"workflow_id": "mirror-pr-comments-<hash>",
"status": "started"
}
```

**Options:**
- `include_outdated` (bool): Include outdated review comments (default: false)
- `include_review_states` (bool): Include review approval/rejection states as comments (default: false)

The tool will:
1. Fetch all issue comments and review comments from the GitHub PR
2. Deduplicate against already mirrored comments (idempotent)
3. Post new comments to the Launchpad MP
4. Record sync metadata in the database

Please note that some configurations are hardcoded. Contributions to make the code generic are more than welcome
53 changes: 53 additions & 0 deletions alembic/versions/d4f9a1b2c3e5_add_mirrored_comment_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""add mirrored_comment table

Revision ID: d4f9a1b2c3e5
Revises: cea232fe4aef
Create Date: 2025-11-06 07:56:20.000000

"""
from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.schema import CreateSequence, Sequence

from alembic import op

# revision identifiers, used by Alembic.
revision: str = 'd4f9a1b2c3e5'
down_revision: Union[str, None] = 'cea232fe4aef'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.execute(CreateSequence(Sequence('mirrored_comment_id_seq')))

op.create_table(
"mirrored_comment",
sa.Column("id", sa.Integer, server_default=sa.text(
"nextval('mirrored_comment_id_seq')"), primary_key=True),
sa.Column("fingerprint", sa.String(64), nullable=False),
sa.Column("github_comment_id", sa.String(64), nullable=False),
sa.Column("github_source", sa.String(32), nullable=False),
sa.Column("mp_identifier", sa.String(256), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("posted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("error_message", sa.Text, nullable=True),
)

op.create_unique_constraint(
"uq_mirrored_comment_fingerprint",
"mirrored_comment",
["fingerprint"]
)

op.create_index(
"ix_mirrored_comment_mp_identifier",
"mirrored_comment",
["mp_identifier"]
)


def downgrade() -> None:
pass
84 changes: 84 additions & 0 deletions spaghettihub/common/db/mirrored_comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from typing import List, Optional

from sqlalchemy import insert, select
from sqlalchemy.sql.functions import count

from spaghettihub.common.db.repository import BaseRepository
from spaghettihub.common.db.sequences import MirroredCommentSequence
from spaghettihub.common.db.tables import MirroredCommentTable
from spaghettihub.common.models.base import ListResult
from spaghettihub.common.models.mirrored_comments import MirroredComment


class MirroredCommentsRepository(BaseRepository[MirroredComment]):
async def get_next_id(self) -> int:
stmt = select(MirroredCommentSequence.next_value())
return (
await self.connection_provider.get_current_connection().execute(stmt)
).scalar()

async def create(self, entity: MirroredComment) -> MirroredComment:
stmt = (
insert(MirroredCommentTable)
.returning(
MirroredCommentTable.c.id,
MirroredCommentTable.c.fingerprint,
MirroredCommentTable.c.github_comment_id,
MirroredCommentTable.c.github_source,
MirroredCommentTable.c.mp_identifier,
MirroredCommentTable.c.created_at,
MirroredCommentTable.c.posted_at,
MirroredCommentTable.c.status,
MirroredCommentTable.c.error_message,
)
.values(
id=entity.id,
fingerprint=entity.fingerprint,
github_comment_id=entity.github_comment_id,
github_source=entity.github_source,
mp_identifier=entity.mp_identifier,
created_at=entity.created_at,
posted_at=entity.posted_at,
status=entity.status,
error_message=entity.error_message,
)
)
result = await self.connection_provider.get_current_connection().execute(stmt)
row = result.one()
return MirroredComment(**row._asdict())

async def find_by_fingerprint(self, fingerprint: str) -> Optional[MirroredComment]:
stmt = select("*").select_from(MirroredCommentTable).where(
MirroredCommentTable.c.fingerprint == fingerprint
)
result = await self.connection_provider.get_current_connection().execute(stmt)
row = result.first()
if not row:
return None
return MirroredComment(**row._asdict())

async def find_by_mp_identifier(self, mp_identifier: str) -> List[MirroredComment]:
stmt = select("*").select_from(MirroredCommentTable).where(
MirroredCommentTable.c.mp_identifier == mp_identifier
)
result = await self.connection_provider.get_current_connection().execute(stmt)
return [MirroredComment(**row._asdict()) for row in result.all()]

async def find_by_id(self, id: int) -> Optional[MirroredComment]:
stmt = select("*").select_from(MirroredCommentTable).where(
MirroredCommentTable.c.id == id
)
result = await self.connection_provider.get_current_connection().execute(stmt)
row = result.first()
if not row:
return None
return MirroredComment(**row._asdict())

async def list(self, size: int, page: int) -> ListResult[MirroredComment]:
pass

async def update(self, entity: MirroredComment) -> MirroredComment:
pass

async def delete(self, id: int) -> None:
pass
1 change: 1 addition & 0 deletions spaghettihub/common/db/sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
"launchpad_to_github_work_id_seq", start=1)
UsersSequence = Sequence("user_auth_id_seq", start=1)
MAASSequence = Sequence("maas_id_seq", start=1)
MirroredCommentSequence = Sequence("mirrored_comment_id_seq", start=1)
18 changes: 17 additions & 1 deletion spaghettihub/common/db/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
from spaghettihub.common.db.sequences import (BugCommentSequence,
EmbeddingSequence,
LaunchpadToGithubWorkSequence,
MAASSequence,
MergeProposalsSequence,
MyTextSequence, UsersSequence, MAASSequence)
MirroredCommentSequence,
MyTextSequence, UsersSequence)

METADATA = MetaData()

Expand Down Expand Up @@ -95,3 +97,17 @@
Column("username", String(128), nullable=False),
Column("password", Text, nullable=False)
)

MirroredCommentTable = Table(
"mirrored_comment",
METADATA,
Column("id", Integer, MirroredCommentSequence, primary_key=True),
Column("fingerprint", String(64), nullable=False, unique=True),
Column("github_comment_id", String(64), nullable=False),
Column("github_source", String(32), nullable=False),
Column("mp_identifier", String(256), nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False),
Column("posted_at", DateTime(timezone=True), nullable=True),
Column("status", String(32), nullable=False),
Column("error_message", Text, nullable=True),
)
16 changes: 16 additions & 0 deletions spaghettihub/common/models/mirrored_comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from datetime import datetime
from typing import Optional

from pydantic import BaseModel


class MirroredComment(BaseModel):
id: int
fingerprint: str
github_comment_id: str
github_source: str
mp_identifier: str
created_at: datetime
posted_at: Optional[datetime] = None
status: str
error_message: Optional[str] = None
15 changes: 14 additions & 1 deletion spaghettihub/common/services/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from spaghettihub.common.db.last_update import LastUpdateRepository
from spaghettihub.common.db.maas import MAASRepository
from spaghettihub.common.db.merge_proposals import MergeProposalsRepository
from spaghettihub.common.db.mirrored_comments import MirroredCommentsRepository
from spaghettihub.common.db.texts import TextsRepository
from spaghettihub.common.db.users import UsersRepository
from spaghettihub.common.services.bugs import BugsService
Expand All @@ -15,6 +16,7 @@
from spaghettihub.common.services.github import LaunchpadToGithubWorkService
from spaghettihub.common.services.last_update import LastUpdateService
from spaghettihub.common.services.merge_proposals import MergeProposalsService
from spaghettihub.common.services.mirror_comments import MirrorCommentsService
from spaghettihub.common.services.runner import GithubWorkflowRunnerService
from spaghettihub.common.services.texts import TextsService
from spaghettihub.common.services.users import UsersService
Expand All @@ -29,6 +31,8 @@ class ServiceCollection:
launchpad_to_github_work_service: LaunchpadToGithubWorkService
users_service: UsersService
github_workflow_runner_service: GithubWorkflowRunnerService
mirror_comments_service: MirrorCommentsService
mirrored_comments_repository: MirroredCommentsRepository

@classmethod
def produce(cls, connection_provider: ConnectionProvider, embeddings_cache: EmbeddingsCache | None = None,
Expand Down Expand Up @@ -75,7 +79,8 @@ def produce(cls, connection_provider: ConnectionProvider, embeddings_cache: Embe
)
services.github_workflow_runner_service = GithubWorkflowRunnerService(
connection_provider=connection_provider,
maas_repository=MAASRepository(connection_provider=connection_provider),
maas_repository=MAASRepository(
connection_provider=connection_provider),
webhook_secret=webhook_secret,
temporal_client=temporal_client
)
Expand All @@ -85,4 +90,12 @@ def produce(cls, connection_provider: ConnectionProvider, embeddings_cache: Embe
connection_provider=connection_provider
)
)
services.mirrored_comments_repository = MirroredCommentsRepository(
connection_provider=connection_provider
)
services.mirror_comments_service = MirrorCommentsService(
connection_provider=connection_provider,
mirrored_comments_repository=services.mirrored_comments_repository,
temporal_client=temporal_client
)
return services
54 changes: 54 additions & 0 deletions spaghettihub/common/services/mirror_comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import hashlib
from typing import Optional

from temporalio.client import Client

from spaghettihub.common.db.base import ConnectionProvider
from spaghettihub.common.db.mirrored_comments import MirroredCommentsRepository
from spaghettihub.common.services.base import Service
from spaghettihub.common.workflows.constants import TASK_QUEUE_NAME
from spaghettihub.common.workflows.mirror_pr_comments.params import (
MirrorCommentsResult, MirrorPullRequestCommentsParams)


class MirrorCommentsService(Service):

def __init__(
self,
connection_provider: ConnectionProvider,
mirrored_comments_repository: MirroredCommentsRepository,
temporal_client: Optional[Client] = None
):
super().__init__(connection_provider)
self.mirrored_comments_repository = mirrored_comments_repository
self.temporal_client = temporal_client

async def start_mirror(
self,
github_pr_url: str,
launchpad_mp_url: str,
include_outdated: bool = False,
include_review_states: bool = False,
) -> str:
"""Start a workflow to mirror PR comments to Launchpad MP."""
# Generate unique workflow ID
workflow_id_content = f"{github_pr_url}:{launchpad_mp_url}"
workflow_hash = hashlib.sha256(
workflow_id_content.encode()).hexdigest()[:16]
workflow_id = f"mirror-pr-comments-{workflow_hash}"

params = MirrorPullRequestCommentsParams(
github_pr_url=github_pr_url,
launchpad_mp_url=launchpad_mp_url,
include_outdated=include_outdated,
include_review_states=include_review_states,
)

await self.temporal_client.start_workflow(
"mirror-pr-comments-workflow",
params,
id=workflow_id,
task_queue=TASK_QUEUE_NAME,
)

return workflow_id
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Mirror PR Comments Workflow
Loading