Skip to content

GitLab同期ロジック設計

概要

Issue Outsource Platform (PF) と GitLab (gitlab.ethan-tech.jp) の間でIssue・ラベル・マイルストーン・アサインを双方向同期する仕組みの設計。


同期アーキテクチャ

┌──────────────┐         ┌──────────────────┐         ┌──────────────┐
│   GitLab     │◄───────►│  Sync Service    │◄───────►│  PF Database │
│  REST API    │  HTTP    │  (Backend)       │  ORM    │  PostgreSQL  │
└──────┬───────┘         └────────┬─────────┘         └──────────────┘
       │                          │
       │  Webhook                 │  Task Queue
       ▼                          ▼
┌──────────────┐         ┌──────────────────┐
│  Webhook     │────────►│  Async Workers   │
│  Handler     │         │  (Celery/asyncio)│
└──────────────┘         └──────────────────┘

同期方向

方向トリガー説明
PF → GitLabPF上のCRUD操作PFでIssue作成・更新時にGitLab APIで反映
GitLab → PFWebhook受信GitLab上の変更をWebhook経由でPFに反映
双方向手動同期API/projects/:id/sync で全体同期を実行

Issue同期

PF → GitLab 同期

PF側でIssueを作成・更新した場合にGitLab APIを呼び出して反映する。

作成フロー

mermaid
sequenceDiagram
    participant User as ユーザー
    participant PF as PF API
    participant DB as Database
    participant Sync as SyncService
    participant GL as GitLab API

    User->>PF: POST /projects/:id/issues
    PF->>DB: PF側Issue作成
    PF->>Sync: GitLab同期タスクキュー投入
    PF-->>User: 201 Created (PF Issue)

    Sync->>GL: POST /api/v4/projects/:id/issues
    GL-->>Sync: 201 Created (GitLab Issue)
    Sync->>DB: gitlab_issue_id, gitlab_issue_iid 保存
    Sync->>DB: sync_status → synced

更新フロー

mermaid
sequenceDiagram
    participant User as ユーザー
    participant PF as PF API
    participant DB as Database
    participant Sync as SyncService
    participant GL as GitLab API

    User->>PF: PUT /issues/:id
    PF->>DB: PF側Issue更新
    PF->>DB: sync_status → pending
    PF->>Sync: GitLab同期タスクキュー投入
    PF-->>User: 200 OK

    Sync->>DB: 変更差分取得
    Sync->>GL: PUT /api/v4/projects/:id/issues/:iid
    GL-->>Sync: 200 OK
    Sync->>DB: sync_status → synced, last_synced_at 更新

PF → GitLab フィールドマッピング

PFフィールドGitLabフィールド変換ルール
titletitleそのまま
descriptiondescriptionMarkdown互換
status: openstate_event: reopen-
status: closedstate_event: close-
labelslabelsカンマ区切り文字列
difficultylabelsdifficulty:<value> ラベルに変換
reward.amountlabelsreward:<amount> ラベルに変換
milestonemilestone_idマイルストーン名→ID解決
deadlinedue_dateISO 8601 → YYYY-MM-DD
assigneeassignee_idsPFユーザー→GitLabユーザーID解決

GitLab → PF 同期

Webhook経由でGitLab上の変更をPFに反映する。

同期フロー

mermaid
sequenceDiagram
    participant GL as GitLab
    participant WH as Webhook Handler
    participant Sync as SyncService
    participant DB as Database

    GL->>WH: Issue Hook (action: update)
    WH->>Sync: 非同期タスク投入

    Sync->>DB: gitlab_issue_id で PF Issue検索
    alt PF Issue存在
        Sync->>DB: 競合チェック (updated_at比較)
        alt 競合なし
            Sync->>DB: PF Issue更新
            Sync->>DB: sync_status → synced
        else 競合あり (PF側が新しい)
            Sync->>DB: conflict_log記録
            Sync->>DB: sync_status → conflict
            Note over Sync: 管理者に通知して手動解決
        end
    else PF Issue未存在
        Sync->>DB: PF Issue新規作成
        Sync->>DB: sync_status → synced
    end

GitLab → PF フィールドマッピング

GitLabフィールドPFフィールド変換ルール
titletitleそのまま
descriptiondescriptionそのまま
state: openedstatus: open-
state: closedstatus: closed-
labelslabels + difficulty + rewardラベルプレフィックスで分離
milestone.titlemilestone-
due_datedeadlineYYYY-MM-DD → ISO 8601
assignee_idsassigneeGitLabユーザーID→PFユーザー解決

競合解決ルール

双方向同期では競合が発生する可能性がある。

競合検出

python
def detect_conflict(pf_issue, gitlab_payload):
    """
    PF側のupdated_atとGitLab側のupdated_atを比較。
    PF側が最後にsyncした時刻より後にPF側で変更があれば競合。
    """
    if pf_issue.updated_at > pf_issue.last_synced_at:
        # PF側にも未同期の変更がある → 競合
        return True
    return False

解決ポリシー

ケースルール理由
PF側のみ変更PF → GitLab に同期通常の更新フロー
GitLab側のみ変更GitLab → PF に同期Webhook経由の通常フロー
両方変更(競合)GitLab優先 + PF変更をconflict_logに記録GitLabが信頼できるソース
両方変更(重要フィールド)手動解決を要求報酬・ステータスは自動マージしない

重要フィールド(自動マージ禁止)

  • status (Issue状態)
  • reward (報酬額)
  • assignee (担当者)

ラベル同期ルール

ラベルカテゴリ

プレフィックスPFフィールド
reward:reward.amountreward:10000
difficulty:difficultydifficulty:medium
status:PF内部ステータスstatus:in_review
その他labels配列api, design, bug

同期ルール

python
def sync_labels_to_gitlab(pf_issue):
    """PF Issue → GitLabラベル変換"""
    labels = list(pf_issue.labels)  # 通常ラベル

    # 報酬ラベル
    if pf_issue.reward_amount:
        labels.append(f"reward:{pf_issue.reward_amount}")

    # 難易度ラベル
    if pf_issue.difficulty:
        labels.append(f"difficulty:{pf_issue.difficulty}")

    return ",".join(labels)


def sync_labels_from_gitlab(gitlab_labels):
    """GitLabラベル → PFフィールド分解"""
    result = {
        "labels": [],
        "reward_amount": None,
        "difficulty": None,
    }

    for label in gitlab_labels:
        if label.startswith("reward:"):
            result["reward_amount"] = int(label.split(":")[1])
        elif label.startswith("difficulty:"):
            result["difficulty"] = label.split(":")[1]
        elif label.startswith("status:"):
            pass  # ステータスラベルはPF側で管理(無視)
        else:
            result["labels"].append(label)

    return result

マイルストーン同期

PF → GitLab

python
async def resolve_milestone_id(project_id: int, milestone_title: str) -> int:
    """マイルストーン名からGitLab milestone_idを解決"""
    milestones = await gitlab_api.get(
        f"/projects/{project_id}/milestones",
        params={"search": milestone_title}
    )
    for m in milestones:
        if m["title"] == milestone_title:
            return m["id"]

    # 存在しない場合は作成
    new_milestone = await gitlab_api.post(
        f"/projects/{project_id}/milestones",
        json={"title": milestone_title}
    )
    return new_milestone["id"]

GitLab → PF

Webhookペイロードの milestone.title をそのままPF側のmilestoneフィールドに保存する。


アサイン同期

ユーザーマッピング

PFユーザーとGitLabユーザーは gitlab_user_id で紐付ける。

sql
-- usersテーブル
CREATE TABLE users (
    id UUID PRIMARY KEY,
    gitlab_user_id INTEGER UNIQUE NOT NULL,
    username VARCHAR(255) NOT NULL,
    ...
);

PF → GitLab アサイン同期

python
async def sync_assignee_to_gitlab(pf_issue, assignee_user):
    """PF側のアサインをGitLabに反映"""
    await gitlab_api.put(
        f"/projects/{pf_issue.gitlab_project_id}/issues/{pf_issue.gitlab_issue_iid}",
        json={"assignee_ids": [assignee_user.gitlab_user_id]}
    )

GitLab → PF アサイン同期

python
async def sync_assignee_from_gitlab(gitlab_payload):
    """GitLabのアサイン変更をPFに反映"""
    gitlab_assignee_ids = gitlab_payload["object_attributes"].get("assignee_ids", [])

    if not gitlab_assignee_ids:
        # アサイン解除
        return None

    # GitLabユーザーID → PFユーザー解決
    pf_user = await db.query(User).filter(
        User.gitlab_user_id == gitlab_assignee_ids[0]
    ).first()

    return pf_user

Rate Limit対策

GitLab APIレートリミット

GitLab (self-hosted) のデフォルトレートリミット:

  • 認証済み: 2000リクエスト/分
  • 未認証: 500リクエスト/分

対策1: バッチ処理

大量のIssue同期が必要な場合はバッチ処理で実行する。

python
import asyncio

BATCH_SIZE = 50
BATCH_INTERVAL = 2  # 秒

async def batch_sync_issues(project_id: int, issues: list):
    """バッチでIssueを同期"""
    for i in range(0, len(issues), BATCH_SIZE):
        batch = issues[i:i + BATCH_SIZE]

        tasks = [sync_single_issue(project_id, issue) for issue in batch]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        # エラーのあったIssueをログ記録
        for issue, result in zip(batch, results):
            if isinstance(result, Exception):
                logger.error(f"Sync failed for issue {issue.id}: {result}")

        # バッチ間隔を空ける
        if i + BATCH_SIZE < len(issues):
            await asyncio.sleep(BATCH_INTERVAL)

対策2: 指数バックオフ

Rate Limit (429) やサーバーエラー (5xx) 時に指数バックオフでリトライする。

python
import asyncio
import random

MAX_RETRIES = 5
BASE_DELAY = 1  # 秒

async def gitlab_api_call_with_retry(method, url, **kwargs):
    """指数バックオフ付きGitLab API呼び出し"""
    for attempt in range(MAX_RETRIES):
        try:
            response = await gitlab_api.request(method, url, **kwargs)

            if response.status_code == 429:
                retry_after = int(response.headers.get("Retry-After", 60))
                logger.warning(f"Rate limited. Retry after {retry_after}s")
                await asyncio.sleep(retry_after)
                continue

            if response.status_code >= 500:
                raise GitLabServerError(f"Server error: {response.status_code}")

            return response

        except (GitLabServerError, ConnectionError) as e:
            if attempt == MAX_RETRIES - 1:
                raise

            # 指数バックオフ + ジッター
            delay = BASE_DELAY * (2 ** attempt) + random.uniform(0, 1)
            logger.warning(
                f"Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}. "
                f"Retrying in {delay:.1f}s"
            )
            await asyncio.sleep(delay)

対策3: レートリミットモニタリング

GitLab APIのレスポンスヘッダからレートリミット状態を監視する。

python
class RateLimitMonitor:
    """GitLab APIレートリミットの監視"""

    def __init__(self):
        self.remaining = None
        self.limit = None
        self.reset_at = None

    def update_from_response(self, response):
        self.remaining = int(response.headers.get("RateLimit-Remaining", 0))
        self.limit = int(response.headers.get("RateLimit-Limit", 2000))
        self.reset_at = int(response.headers.get("RateLimit-Reset", 0))

    @property
    def usage_ratio(self):
        if not self.limit:
            return 0
        return 1 - (self.remaining / self.limit)

    async def wait_if_needed(self):
        """使用率が80%を超えたらスロットリング"""
        if self.usage_ratio > 0.8:
            wait_time = max(1, self.reset_at - time.time())
            logger.warning(f"Rate limit usage at {self.usage_ratio:.0%}. Waiting {wait_time:.0f}s")
            await asyncio.sleep(min(wait_time, 60))

同期エラー時のリトライ戦略

エラー分類

カテゴリリトライ可否対応
一時的エラー429, 502, 503, タイムアウトリトライ可指数バックオフ
永続的エラー400, 401, 404, 422リトライ不可エラーログ + 通知
ネットワークエラーDNS解決失敗、接続拒否リトライ可指数バックオフ
データ整合性エラーユーザーマッピング不在リトライ不可エラーログ + 手動対応

リトライキュー

python
from enum import Enum

class SyncTaskStatus(Enum):
    PENDING = "pending"
    PROCESSING = "processing"
    COMPLETED = "completed"
    FAILED = "failed"
    RETRY = "retry"

class SyncTask:
    """同期タスク管理"""
    id: UUID
    task_type: str        # issue_sync, label_sync, assignee_sync
    direction: str        # pf_to_gitlab, gitlab_to_pf
    resource_id: str      # PF Issue ID or GitLab Issue ID
    payload: dict
    status: SyncTaskStatus
    retry_count: int = 0
    max_retries: int = 5
    next_retry_at: datetime = None
    error_message: str = None
    created_at: datetime
    updated_at: datetime

リトライスケジュール

リトライ回数 | 待機時間  | 累計経過
------------|----------|--------
1回目       | 30秒     | 30秒
2回目       | 2分      | 2分30秒
3回目       | 8分      | 10分30秒
4回目       | 32分     | 42分30秒
5回目       | 2時間    | 2時間42分30秒

5回失敗後:

  • status → failed
  • 管理者へSlack/Discord通知
  • 管理画面から手動リトライ可能(POST /webhooks/events/:id/retry

Dead Letter Queue

リトライ上限超過タスクはDead Letter Queueに移動し、管理画面で確認・再処理できるようにする。

sql
CREATE TABLE sync_dead_letter_queue (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    original_task_id UUID REFERENCES sync_tasks(id),
    task_type VARCHAR(100) NOT NULL,
    direction VARCHAR(20) NOT NULL,
    resource_id VARCHAR(255) NOT NULL,
    payload JSONB NOT NULL,
    error_message TEXT,
    retry_count INTEGER NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    resolved_at TIMESTAMP,
    resolved_by UUID REFERENCES users(id)
);

手動同期API

全体同期

POST /projects/:id/sync で特定プロジェクトの全Issue・ラベル・マイルストーンを同期する。

mermaid
sequenceDiagram
    participant Admin as 管理者
    participant PF as PF API
    participant Sync as SyncService
    participant GL as GitLab API
    participant DB as Database

    Admin->>PF: POST /projects/:id/sync
    PF-->>Admin: 202 Accepted (sync_job_id)

    PF->>Sync: 同期ジョブ開始

    Sync->>GL: GET /projects/:id/issues?per_page=100
    loop 全ページ取得
        GL-->>Sync: Issues (100件ずつ)
    end

    loop 各Issue
        Sync->>DB: PF Issue検索 (gitlab_issue_id)
        alt 存在する
            Sync->>DB: 差分チェック・更新
        else 存在しない
            Sync->>DB: PF Issue新規作成
        end
    end

    Sync->>DB: sync_job status → completed
    Sync->>Admin: 完了通知 (Slack/Discord)

同期ステータス確認

json
GET /projects/:id/sync/status

{
  "status": "success",
  "data": {
    "last_sync_at": "2026-03-28T00:00:00Z",
    "sync_job": {
      "id": "uuid",
      "status": "completed",
      "issues_synced": 45,
      "issues_created": 3,
      "issues_updated": 42,
      "issues_failed": 0,
      "duration_seconds": 12,
      "started_at": "2026-03-28T00:00:00Z",
      "completed_at": "2026-03-28T00:00:12Z"
    }
  }
}

同期状態管理テーブル

sql
CREATE TABLE sync_status (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    resource_type VARCHAR(50) NOT NULL,  -- issue, label, milestone
    pf_resource_id UUID NOT NULL,
    gitlab_resource_id INTEGER,
    sync_direction VARCHAR(20),          -- pf_to_gitlab, gitlab_to_pf, bidirectional
    sync_state VARCHAR(20) DEFAULT 'synced',  -- synced, pending, conflict, error
    last_synced_at TIMESTAMP,
    last_error TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_sync_status_resource ON sync_status(resource_type, pf_resource_id);
CREATE INDEX idx_sync_status_state ON sync_status(sync_state);