You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
docuseal/docs/backlog/populate_github_project.py

347 lines
13 KiB

#!/usr/bin/env python3
"""
Script to populate GitHub Projects board with user stories from the backlog.
This script reads stories from the epic details markdown file and creates
GitHub issues (cards) in the specified project board.
Usage:
python populate_github_project.py --token <github_token> --project <project_id> --owner <owner> --repo <repo>
Requirements:
- GitHub Personal Access Token with:
- repo scope (for creating issues)
- project scope (for adding to project board)
- Project ID (found in GitHub project URL)
- Owner and repository name
Note: This script uses the GitHub GraphQL API for project board operations.
"""
import re
import argparse
import requests
from pathlib import Path
from typing import List, Dict, Optional
class GitHubProjectPopulator:
"""Populates GitHub Projects board with user stories."""
def __init__(self, token: str, owner: str, repo: str, project_id: str):
self.token = token
self.owner = owner
self.repo = repo
self.project_id = project_id
self.base_url = "https://api.github.com"
self.graphql_url = "https://api.github.com/graphql"
self.headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
}
def parse_stories(self, file_path: Path) -> List[Dict]:
"""Parse stories from epic details markdown file."""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Split by stories - looking for ### Story X.X: pattern
story_pattern = r'### Story ([\d.]+): (.+?)\n\n(.*?)(?=\n### Story [\d.]+:|$)'
matches = re.findall(story_pattern, content, re.DOTALL)
stories = []
for story_num, title, body in matches:
# Extract Status, Priority, Epic, Estimated Effort, Risk Level
status_match = re.search(r'\*\*Status\*\*: (.+)', body)
priority_match = re.search(r'\*\*Priority\*\*: (.+)', body)
epic_match = re.search(r'\*\*Epic\*\*: (.+)', body)
effort_match = re.search(r'\*\*Estimated Effort\*\*: (.+)', body)
risk_match = re.search(r'\*\*Risk Level\*\*: (.+)', body)
# Extract User Story
user_story_match = re.search(r'#### User Story\n\n(.*?)(?=\n####|$)', body, re.DOTALL)
user_story = user_story_match.group(1).strip() if user_story_match else ""
# Extract Background
background_match = re.search(r'#### Background\n\n(.*?)(?=\n####|$)', body, re.DOTALL)
background = background_match.group(1).strip() if background_match else ""
# Extract Acceptance Criteria
acceptance_match = re.search(r'#### Acceptance Criteria\n\n(.*?)(?=\n####|$)', body, re.DOTALL)
acceptance = acceptance_match.group(1).strip() if acceptance_match else ""
# Extract Tasks/Subtasks if present
tasks_match = re.search(r'#### Tasks / Subtasks\n\n(.*?)(?=\n####|$)', body, re.DOTALL)
tasks = tasks_match.group(1).strip() if tasks_match else ""
stories.append({
'number': story_num,
'title': title,
'status': status_match.group(1).strip() if status_match else "Draft",
'priority': priority_match.group(1).strip() if priority_match else "Medium",
'epic': epic_match.group(1).strip() if epic_match else "General",
'effort': effort_match.group(1).strip() if effort_match else "Unknown",
'risk': risk_match.group(1).strip() if risk_match else "Low",
'user_story': user_story,
'background': background,
'acceptance': acceptance,
'tasks': tasks,
})
return stories
def create_issue(self, story: Dict) -> Optional[Dict]:
"""Create a GitHub issue for a story."""
url = f"{self.base_url}/repos/{self.owner}/{self.repo}/issues"
# Build the issue body
body_parts = []
# User Story section
if story['user_story']:
body_parts.append("## 📖 User Story\n")
body_parts.append(story['user_story'])
body_parts.append("")
# Background section
if story['background']:
body_parts.append("## 📋 Background\n")
body_parts.append(story['background'])
body_parts.append("")
# Acceptance Criteria section
if story['acceptance']:
body_parts.append("## ✅ Acceptance Criteria\n")
body_parts.append(story['acceptance'])
body_parts.append("")
# Tasks/Subtasks section
if story['tasks']:
body_parts.append("## 📝 Tasks / Subtasks\n")
body_parts.append(story['tasks'])
body_parts.append("")
# Metadata section
body_parts.append("## 📊 Metadata\n")
body_parts.append(f"- **Story Number**: {story['number']}")
body_parts.append(f"- **Epic**: {story['epic']}")
body_parts.append(f"- **Priority**: {story['priority']}")
body_parts.append(f"- **Estimated Effort**: {story['effort']}")
body_parts.append(f"- **Risk Level**: {story['risk']}")
body_parts.append(f"- **Status**: {story['status']}")
issue_data = {
"title": f"[{story['number']}] {story['title']}",
"body": "\n".join(body_parts),
"labels": self._get_labels(story),
}
response = requests.post(url, headers=self.headers, json=issue_data)
if response.status_code == 201:
print(f"✅ Created issue: {story['number']} - {story['title']}")
return response.json()
else:
print(f"❌ Failed to create issue {story['number']}: {response.status_code}")
print(f" Response: {response.text}")
return None
def _get_labels(self, story: Dict) -> List[str]:
"""Generate labels based on story metadata."""
labels = []
# Epic-based labels
epic_label = story['epic'].replace(" ", "-").lower()
labels.append(f"epic:{epic_label}")
# Priority label
priority_label = story['priority'].lower()
labels.append(f"priority:{priority_label}")
# Risk label
risk_label = story['risk'].lower()
labels.append(f"risk:{risk_label}")
# Status label
status_label = story['status'].lower().replace(" ", "-")
labels.append(f"status:{status_label}")
# Portal-based labels (inferred from title)
title_lower = story['title'].lower()
if any(word in title_lower for word in ['admin', 'tp', 'training provider']):
labels.append("portal:admin")
elif any(word in title_lower for word in ['student']):
labels.append("portal:student")
elif any(word in title_lower for word in ['sponsor']):
labels.append("portal:sponsor")
elif any(word in title_lower for word in ['database', 'model', 'api', 'backend']):
labels.append("type:backend")
elif any(word in title_lower for word in ['testing', 'qa', 'audit', 'security']):
labels.append("type:qa")
elif any(word in title_lower for word in ['infrastructure', 'deployment', 'docs']):
labels.append("type:infrastructure")
return labels
def add_issue_to_project(self, issue: Dict, column: str = "Todo") -> bool:
"""Add an issue to the GitHub project board."""
# Note: This uses the older Projects v2 API which requires project node ID
# For simplicity, we'll use the REST API with project card endpoint
issue_id = issue['number']
# Try to add to project using GraphQL (Projects v2)
query = """
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item {
id
}
}
}
"""
# Get the issue node ID (GraphQL ID)
# For now, we'll skip this step and just create the issue
# The user can manually add issues to the project board
print(f" Note: Issue {issue_id} created. Add to project manually via GitHub UI.")
return True
def create_issues_batch(self, stories: List[Dict]) -> List[Dict]:
"""Create all issues in batch."""
created_issues = []
print(f"\n📝 Creating {len(stories)} issues...\n")
for story in stories:
issue = self.create_issue(story)
if issue:
created_issues.append(issue)
# Small delay to avoid rate limiting
import time
time.sleep(0.5)
return created_issues
def generate_summary(self, stories: List[Dict], output_path: Path):
"""Generate a summary markdown file with all stories."""
with open(output_path, 'w', encoding='utf-8') as f:
f.write("# GitHub Project - User Stories Summary\n\n")
f.write(f"**Total Stories:** {len(stories)}\n\n")
f.write("## Stories by Phase\n\n")
# Group by epic
epics = {}
for story in stories:
epic = story['epic']
if epic not in epics:
epics[epic] = []
epics[epic].append(story)
for epic, epic_stories in sorted(epics.items()):
f.write(f"### {epic}\n\n")
f.write("| # | Title | Priority | Risk | Effort |\n")
f.write("|---|-------|----------|------|--------|\n")
for story in epic_stories:
f.write(f"| {story['number']} | **{story['title']}** | {story['priority']} | {story['risk']} | {story['effort']} |\n")
f.write("\n")
f.write("## Quick Reference\n\n")
for story in stories:
f.write(f"### {story['number']}: {story['title']}\n\n")
f.write(f"**Status:** {story['status']} | **Priority:** {story['priority']} | **Epic:** {story['epic']}\n\n")
f.write("**User Story:**\n")
f.write(story['user_story'])
f.write("\n\n---\n\n")
print(f"✅ Summary generated: {output_path}")
def main():
parser = argparse.ArgumentParser(
description="Populate GitHub Projects board with user stories"
)
parser.add_argument(
"--token",
required=True,
help="GitHub Personal Access Token"
)
parser.add_argument(
"--owner",
required=True,
help="GitHub repository owner (username or org)"
)
parser.add_argument(
"--repo",
required=True,
help="GitHub repository name"
)
parser.add_argument(
"--project",
help="GitHub Project ID (optional - for adding to project board)"
)
parser.add_argument(
"--input",
default="/home/dev-mode/dev/dyict-projects/floDoc-v3/docs/prd/6-epic-details.md",
help="Path to epic details markdown file"
)
parser.add_argument(
"--output",
default="/home/dev-mode/dev/dyict-projects/floDoc-v3/docs/backlog/github_project_summary.md",
help="Path to output summary markdown file"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Parse stories without creating GitHub issues"
)
args = parser.parse_args()
# Parse stories
input_file = Path(args.input)
if not input_file.exists():
print(f"❌ Input file not found: {input_file}")
return
print(f"📖 Parsing stories from: {input_file}")
populator = GitHubProjectPopulator(
token=args.token,
owner=args.owner,
repo=args.repo,
project_id=args.project or ""
)
stories = populator.parse_stories(input_file)
print(f"✅ Found {len(stories)} stories\n")
if args.dry_run:
print("🔍 Dry run mode - showing stories that would be created:\n")
for story in stories:
print(f" [{story['number']}] {story['title']}")
print(f" Epic: {story['epic']}, Priority: {story['priority']}, Risk: {story['risk']}")
print()
return
# Create issues
created_issues = populator.create_issues_batch(stories)
# Generate summary
populator.generate_summary(stories, Path(args.output))
print(f"\n{'='*60}")
print(f"✅ Complete!")
print(f" Created {len(created_issues)} issues")
print(f" Summary: {args.output}")
print(f"{'='*60}")
if args.project:
print(f"\n⚠️ Note: Issues created but not added to project board.")
print(f" To add issues to project, use GitHub UI or update script to use GraphQL API.")
print(f" Project ID: {args.project}")
if __name__ == '__main__':
main()