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 → GitLab | PF上のCRUD操作 | PFでIssue作成・更新時にGitLab APIで反映 |
| GitLab → PF | Webhook受信 | 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フィールド | 変換ルール |
|---|---|---|
| title | title | そのまま |
| description | description | Markdown互換 |
| status: open | state_event: reopen | - |
| status: closed | state_event: close | - |
| labels | labels | カンマ区切り文字列 |
| difficulty | labels | difficulty:<value> ラベルに変換 |
| reward.amount | labels | reward:<amount> ラベルに変換 |
| milestone | milestone_id | マイルストーン名→ID解決 |
| deadline | due_date | ISO 8601 → YYYY-MM-DD |
| assignee | assignee_ids | PFユーザー→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
endGitLab → PF フィールドマッピング
| GitLabフィールド | PFフィールド | 変換ルール |
|---|---|---|
| title | title | そのまま |
| description | description | そのまま |
| state: opened | status: open | - |
| state: closed | status: closed | - |
| labels | labels + difficulty + reward | ラベルプレフィックスで分離 |
| milestone.title | milestone | - |
| due_date | deadline | YYYY-MM-DD → ISO 8601 |
| assignee_ids | assignee | GitLabユーザー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.amount | reward:10000 |
difficulty: | difficulty | difficulty: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_userRate 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);