Files
bruno fc185b9ab5 feat: 初始化 Plane + Gitea + GitHub 全链路集成系统
完整实现企业级 DevOps 全链路解决方案

## 核心功能

✅ Plane 任务管理系统部署 (Docker Compose + K8s)
✅ Gitea ↔ GitHub 双向镜像同步
✅ Plane ↔ Gitea 集成 (Issue/PR 双向同步)
✅ Plane ↔ GitHub 集成 (Issue/PR 双向同步)
✅ CI/CD Pipeline 配置
✅ Claude AI 辅助开发工作流

## 主要组件

### 部署配置
- plane/docker-compose.yaml - Plane 完整部署
- k3s/plane.yaml - Kubernetes 生产部署
- gitea/docker-compose.yaml - Gitea 配置

### 同步服务
- gitea-to-github-sync/ - Gitea ↔ GitHub 镜像同步
- plane-integration/gitea-sync/ - Plane ↔ Gitea 同步
- plane-integration/github-sync/ - Plane ↔ GitHub 同步

### 文档
- README.md - 完整系统文档
- DEPLOYMENT-CHECKLIST.md - 部署检查清单
- docs/plane-templates.md - Plane Issue 模板
- docs/claude-dev-workflow.md - Claude AI 工作流

### 自动化脚本
- deploy.sh - 一键部署脚本
- gitea/gitea-mirror.sh - Gitea 镜像管理

## 快速开始

```bash
./deploy.sh
```

详见 README.md

---

🤖 Generated with Claude Code
Co-Authored-By: Claude <[email protected]>
2025-11-25 07:02:44 +08:00

450 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Plane ↔ GitHub 双向同步服务
- Plane Issue → GitHub Issue
- GitHub PR/Commit → Plane Issue 更新
- Webhook 处理
- GitHub OAuth 集成
"""
import os
import logging
import hmac
import hashlib
from datetime import datetime
from typing import Dict, Optional, List
import requests
from flask import Flask, request, jsonify
import re
logging.basicConfig(
level=getattr(logging, os.getenv('LOG_LEVEL', 'INFO')),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# 配置
PLANE_API_URL = os.getenv('PLANE_API_URL', 'http://plane-api:8000')
PLANE_API_TOKEN = os.getenv('PLANE_API_TOKEN', '')
GITHUB_TOKEN = os.getenv('GITHUB_TOKEN', '')
WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET', '')
PORT = int(os.getenv('PORT', '8002'))
app = Flask(__name__)
class PlaneGitHubSync:
"""Plane ↔ GitHub 同步管理器"""
def __init__(self):
self.plane_headers = {
'Authorization': f'Bearer {PLANE_API_TOKEN}',
'Content-Type': 'application/json'
}
self.github_headers = {
'Authorization': f'token {GITHUB_TOKEN}',
'Accept': 'application/vnd.github.v3+json'
}
def get_plane_issues(self, workspace_slug: str, project_id: str) -> List[Dict]:
"""获取 Plane 项目的所有 Issue"""
try:
url = f'{PLANE_API_URL}/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/'
response = requests.get(url, headers=self.plane_headers, timeout=30)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get Plane issues: {e}")
return []
def create_github_issue(self, owner: str, repo: str, issue_data: Dict) -> Optional[Dict]:
"""在 GitHub 创建 Issue"""
try:
url = f'https://api.github.com/repos/{owner}/{repo}/issues'
data = {
'title': issue_data['name'],
'body': self._format_plane_issue_body(issue_data),
'labels': self._extract_labels(issue_data)
}
# 添加 assignees (如果有)
if issue_data.get('assignees'):
# 需要将 Plane 用户映射到 GitHub 用户名
data['assignees'] = [a.get('github_username') for a in issue_data['assignees'] if a.get('github_username')]
response = requests.post(url, headers=self.github_headers, json=data, timeout=30)
response.raise_for_status()
github_issue = response.json()
logger.info(f"Created GitHub issue #{github_issue['number']}: {github_issue['title']}")
return github_issue
except Exception as e:
logger.error(f"Failed to create GitHub issue: {e}")
return None
def update_github_issue(self, owner: str, repo: str, issue_number: int, issue_data: Dict) -> Optional[Dict]:
"""更新 GitHub Issue"""
try:
url = f'https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}'
data = {
'title': issue_data['name'],
'body': self._format_plane_issue_body(issue_data),
'state': 'closed' if issue_data.get('state') == 'completed' else 'open'
}
response = requests.patch(url, headers=self.github_headers, json=data, timeout=30)
response.raise_for_status()
logger.info(f"Updated GitHub issue #{issue_number}")
return response.json()
except Exception as e:
logger.error(f"Failed to update GitHub issue: {e}")
return None
def create_plane_issue(self, workspace_slug: str, project_id: str, github_issue: Dict) -> Optional[Dict]:
"""在 Plane 创建 Issue"""
try:
url = f'{PLANE_API_URL}/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/'
# 映射 GitHub 优先级到 Plane
priority = self._map_github_labels_to_priority(github_issue.get('labels', []))
data = {
'name': github_issue['title'],
'description': github_issue.get('body', ''),
'state': 'started',
'priority': priority,
'labels': [label['name'] for label in github_issue.get('labels', [])],
'external_id': f"github-{github_issue['number']}",
'external_source': 'github'
}
response = requests.post(url, headers=self.plane_headers, json=data, timeout=30)
response.raise_for_status()
plane_issue = response.json()
logger.info(f"Created Plane issue: {plane_issue['name']}")
return plane_issue
except Exception as e:
logger.error(f"Failed to create Plane issue: {e}")
return None
def update_plane_issue_from_pr(self, workspace_slug: str, project_id: str, issue_id: str, pr_data: Dict):
"""根据 GitHub PR 更新 Plane Issue"""
try:
url = f'{PLANE_API_URL}/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/{issue_id}/'
# 根据 PR 状态更新 Issue
state_mapping = {
'open': 'in_progress',
'closed': 'completed' if pr_data.get('merged') else 'cancelled',
}
data = {
'state': state_mapping.get(pr_data['state'], 'in_progress'),
}
# 添加评论记录 PR 信息
comment_url = f'{url}comments/'
pr_status = 'merged' if pr_data.get('merged') else pr_data['state']
comment_data = {
'comment': f"🔀 PR #{pr_data['number']} {pr_status}: {pr_data['title']}\n{pr_data['html_url']}"
}
response = requests.patch(url, headers=self.plane_headers, json=data, timeout=30)
response.raise_for_status()
requests.post(comment_url, headers=self.plane_headers, json=comment_data, timeout=30)
logger.info(f"Updated Plane issue from PR #{pr_data['number']}")
except Exception as e:
logger.error(f"Failed to update Plane issue from PR: {e}")
def update_plane_issue_from_commit(self, workspace_slug: str, project_id: str, issue_id: str, commit_data: Dict):
"""根据 GitHub Commit 更新 Plane Issue"""
try:
url = f'{PLANE_API_URL}/api/v1/workspaces/{workspace_slug}/projects/{project_id}/issues/{issue_id}/comments/'
comment_data = {
'comment': f"💻 Commit [{commit_data['sha'][:7]}]({commit_data['html_url']}): {commit_data['commit']['message']}"
}
response = requests.post(url, headers=self.plane_headers, json=comment_data, timeout=30)
response.raise_for_status()
logger.info(f"Added commit comment to Plane issue")
except Exception as e:
logger.error(f"Failed to update Plane issue from commit: {e}")
def _format_plane_issue_body(self, issue: Dict) -> str:
"""格式化 Plane Issue 为 GitHub Issue body"""
description = issue.get('description', '')
priority = issue.get('priority', 'medium')
state = issue.get('state', 'backlog')
assignees = ', '.join([f"@{a.get('github_username', a['display_name'])}" for a in issue.get('assignees', [])])
body = f"{description}\n\n"
body += f"### Details\n"
body += f"- **Priority:** `{priority}`\n"
body += f"- **State:** `{state}`\n"
if assignees:
body += f"- **Assignees:** {assignees}\n"
# 添加 Plane 链接
if issue.get('id'):
body += f"\n---\n"
body += f"📋 [View in Plane]({PLANE_API_URL.replace('/api', '')}/projects/{issue.get('project')}/issues/{issue['id']})\n"
body += f"\n*Synced from Plane*"
return body
def _extract_labels(self, issue: Dict) -> List[str]:
"""提取 Plane Issue 的标签"""
labels = []
# 优先级标签
priority = issue.get('priority', 'medium')
labels.append(f"priority: {priority}")
# 状态标签
state = issue.get('state', 'backlog')
labels.append(f"status: {state}")
# Issue 类型
issue_type = issue.get('type', 'task')
labels.append(issue_type)
# 自定义标签
for label in issue.get('labels', []):
labels.append(label.get('name', ''))
return [l for l in labels if l]
def _map_github_labels_to_priority(self, labels: List[Dict]) -> str:
"""将 GitHub 标签映射到 Plane 优先级"""
label_names = [label['name'].lower() for label in labels]
if any('critical' in name or 'urgent' in name for name in label_names):
return 'urgent'
elif any('high' in name for name in label_names):
return 'high'
elif any('low' in name for name in label_names):
return 'low'
else:
return 'medium'
def extract_issue_number_from_text(self, text: str) -> Optional[str]:
"""从文本中提取 issue 编号"""
# 匹配 #123, Closes #123, Fixes #123 等格式
patterns = [
r'#(\d+)',
r'[Cc]loses?\s+#(\d+)',
r'[Ff]ixes?\s+#(\d+)',
r'[Rr]esolves?\s+#(\d+)',
]
for pattern in patterns:
match = re.search(pattern, text)
if match:
return match.group(1)
return None
def get_github_pr_reviews(self, owner: str, repo: str, pr_number: int) -> List[Dict]:
"""获取 GitHub PR 的 Review 信息"""
try:
url = f'https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/reviews'
response = requests.get(url, headers=self.github_headers, timeout=30)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get PR reviews: {e}")
return []
# 全局同步实例
sync = PlaneGitHubSync()
def verify_signature(payload: bytes, signature: str) -> bool:
"""验证 GitHub webhook 签名"""
if not WEBHOOK_SECRET:
return True
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route('/health', methods=['GET'])
def health():
"""健康检查"""
return jsonify({
'status': 'healthy',
'service': 'plane-github-sync',
'timestamp': datetime.utcnow().isoformat()
})
@app.route('/webhook/plane', methods=['POST'])
def plane_webhook():
"""Plane webhook 处理"""
try:
payload = request.json
event_type = payload.get('event', '')
logger.info(f"Received Plane webhook: {event_type}")
if event_type in ['issue.created', 'issue.updated']:
issue = payload.get('issue', {})
project = payload.get('project', {})
logger.info(f"Processing Plane issue: {issue.get('name')}")
# 同步到 GitHub
# 需要在项目配置中存储 GitHub 仓库信息
github_config = project.get('github_integration', {})
if github_config:
owner = github_config.get('owner')
repo = github_config.get('repo')
if owner and repo:
if event_type == 'issue.created' and not issue.get('external_source'):
sync.create_github_issue(owner, repo, issue)
elif event_type == 'issue.updated' and issue.get('external_id'):
issue_number = int(issue['external_id'].replace('github-', ''))
sync.update_github_issue(owner, repo, issue_number, issue)
return jsonify({'status': 'accepted'}), 202
except Exception as e:
logger.error(f"Plane webhook error: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/webhook/github', methods=['POST'])
def github_webhook():
"""GitHub webhook 处理"""
try:
signature = request.headers.get('X-Hub-Signature-256', '')
if not verify_signature(request.data, signature):
return jsonify({'error': 'Invalid signature'}), 401
payload = request.json
event_type = request.headers.get('X-GitHub-Event', '')
logger.info(f"Received GitHub webhook: {event_type}")
if event_type == 'push':
# 处理 push 事件
commits = payload.get('commits', [])
repository = payload.get('repository', {})
for commit in commits:
issue_number = sync.extract_issue_number_from_text(commit['message'])
if issue_number:
logger.info(f"Found issue reference #{issue_number} in commit")
# 更新对应的 Plane issue
elif event_type == 'pull_request':
# 处理 PR 事件
pr = payload.get('pull_request', {})
action = payload.get('action', '')
repository = payload.get('repository', {})
logger.info(f"PR #{pr['number']} {action}: {pr['title']}")
# 从 PR 中提取 issue 编号
issue_number = sync.extract_issue_number_from_text(pr.get('title', ''))
if not issue_number:
issue_number = sync.extract_issue_number_from_text(pr.get('body', ''))
if issue_number:
logger.info(f"Found issue reference #{issue_number} in PR")
# 更新对应的 Plane issue
elif event_type == 'issues':
# 处理 issue 事件
issue = payload.get('issue', {})
action = payload.get('action', '')
repository = payload.get('repository', {})
logger.info(f"Issue #{issue['number']} {action}: {issue['title']}")
# 同步到 Plane
# 需要根据仓库配置找到对应的 Plane workspace/project
elif event_type == 'pull_request_review':
# 处理 PR Review 事件
review = payload.get('review', {})
pr = payload.get('pull_request', {})
logger.info(f"PR #{pr['number']} review {review['state']}: {review.get('body', '')}")
return jsonify({'status': 'accepted'}), 202
except Exception as e:
logger.error(f"GitHub webhook error: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/sync/manual', methods=['POST'])
def manual_sync():
"""手动触发同步"""
try:
data = request.json
workspace_slug = data.get('workspace_slug')
project_id = data.get('project_id')
github_owner = data.get('github_owner')
github_repo = data.get('github_repo')
if not all([workspace_slug, project_id, github_owner, github_repo]):
return jsonify({'error': 'Missing required parameters'}), 400
# 获取 Plane issues 并同步到 GitHub
plane_issues = sync.get_plane_issues(workspace_slug, project_id)
synced_count = 0
for issue in plane_issues:
if not issue.get('external_source'): # 只同步 Plane 原生 issue
github_issue = sync.create_github_issue(github_owner, github_repo, issue)
if github_issue:
synced_count += 1
return jsonify({
'status': 'completed',
'synced': synced_count
})
except Exception as e:
logger.error(f"Manual sync error: {e}")
return jsonify({'error': str(e)}), 500
def main():
"""主函数"""
logger.info("Starting Plane ↔ GitHub Sync Service")
logger.info(f"Configuration:")
logger.info(f" - Plane API: {PLANE_API_URL}")
logger.info(f" - Port: {PORT}")
if not PLANE_API_TOKEN or not GITHUB_TOKEN:
logger.error("PLANE_API_TOKEN and GITHUB_TOKEN are required")
return
app.run(host='0.0.0.0', port=PORT, debug=False)
if __name__ == '__main__':
main()