1916 lines
57 KiB
Plaintext
1916 lines
57 KiB
Plaintext
# Task ID: 101
|
|
# Title: Implement GitHub Issue Export Feature with Bidirectional Linking
|
|
# Status: pending
|
|
# Dependencies: None
|
|
# Priority: high
|
|
# Description: Add a 'github-export' command that creates GitHub issues from Task Master tasks and establishes bidirectional linking between tasks and issues. This complements the import feature by enabling full GitHub integration workflow.
|
|
# Details:
|
|
## Core Problem Statement
|
|
|
|
Users need the ability to export Task Master tasks to GitHub issues to:
|
|
|
|
1. **Share Tasks with Team**: Convert internal tasks to GitHub issues for team collaboration
|
|
2. **Track Progress Publicly**: Make task progress visible in GitHub project boards
|
|
3. **Integrate with GitHub Workflow**: Connect Task Master planning with GitHub development workflow
|
|
4. **Maintain Synchronization**: Keep tasks and issues linked for status updates
|
|
5. **Enable Hybrid Workflow**: Allow teams to work with both Task Master and GitHub seamlessly
|
|
|
|
## Core Requirements
|
|
|
|
### 1. GitHub Export Command
|
|
- **Command**: `task-master github-export --id=<taskId> --repo=<owner/repo> [options]`
|
|
- **Functionality**: Create GitHub issue from Task Master task
|
|
- **Authentication**: Use GitHub Personal Access Token or OAuth
|
|
- **Repository Target**: Support any accessible GitHub repository
|
|
|
|
### 2. Bidirectional Linking System
|
|
- **Task → Issue**: Store GitHub issue URL in task metadata
|
|
- **Issue → Task**: Include Task Master reference in GitHub issue description
|
|
- **Link Validation**: Verify links remain valid and accessible
|
|
- **Link Display**: Show GitHub links in task views and vice versa
|
|
|
|
### 3. Content Mapping and Formatting
|
|
- **Title Mapping**: Task title → GitHub issue title
|
|
- **Description Mapping**: Task description → GitHub issue description
|
|
- **Details Conversion**: Convert Task Master details to GitHub markdown
|
|
- **Metadata Preservation**: Include Task Master ID, priority, status in issue
|
|
- **Subtask Handling**: Convert subtasks to GitHub issue checklist or separate issues
|
|
|
|
### 4. Advanced Export Options
|
|
- **Selective Export**: Choose which task fields to include
|
|
- **Template Customization**: Custom GitHub issue templates
|
|
- **Label Management**: Map Task Master priorities/tags to GitHub labels
|
|
- **Assignee Mapping**: Map Task Master assignments to GitHub assignees
|
|
- **Milestone Integration**: Connect tasks to GitHub milestones
|
|
|
|
## Technical Implementation Requirements
|
|
|
|
### 1. GitHub API Integration
|
|
```javascript
|
|
// Core export service
|
|
class GitHubExportService {
|
|
constructor(token, baseURL = 'https://api.github.com') {
|
|
this.token = token;
|
|
this.baseURL = baseURL;
|
|
this.rateLimiter = new RateLimiter();
|
|
}
|
|
|
|
async exportTask(task, repoOwner, repoName, options = {}) {
|
|
// Validate repository access
|
|
// Format task content for GitHub
|
|
// Create GitHub issue via API
|
|
// Update task with GitHub link
|
|
// Return export result
|
|
}
|
|
|
|
async updateTaskWithGitHubLink(taskId, issueUrl) {
|
|
// Add GitHub link to task metadata
|
|
// Update task file with link reference
|
|
// Regenerate task files if needed
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Content Formatting System
|
|
```javascript
|
|
class TaskToGitHubFormatter {
|
|
formatIssueTitle(task) {
|
|
return `[Task ${task.id}] ${task.title}`;
|
|
}
|
|
|
|
formatIssueDescription(task) {
|
|
let description = `# ${task.title}\n\n`;
|
|
description += `**Task Master ID**: ${task.id}\n`;
|
|
description += `**Priority**: ${task.priority}\n`;
|
|
description += `**Status**: ${task.status}\n\n`;
|
|
|
|
if (task.description) {
|
|
description += `## Description\n${task.description}\n\n`;
|
|
}
|
|
|
|
if (task.details) {
|
|
description += `## Implementation Details\n${task.details}\n\n`;
|
|
}
|
|
|
|
if (task.subtasks && task.subtasks.length > 0) {
|
|
description += `## Subtasks\n`;
|
|
task.subtasks.forEach(subtask => {
|
|
const checked = subtask.status === 'done' ? 'x' : ' ';
|
|
description += `- [${checked}] ${subtask.title}\n`;
|
|
});
|
|
}
|
|
|
|
description += `\n---\n*Exported from Task Master*`;
|
|
return description;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Bidirectional Link Management
|
|
```javascript
|
|
class LinkManager {
|
|
async addGitHubLinkToTask(taskId, issueUrl, issueNumber) {
|
|
const task = await getTask(taskId);
|
|
|
|
if (!task.metadata) task.metadata = {};
|
|
task.metadata.githubIssue = {
|
|
url: issueUrl,
|
|
number: issueNumber,
|
|
exportedAt: new Date().toISOString(),
|
|
repository: this.extractRepoFromUrl(issueUrl)
|
|
};
|
|
|
|
await updateTask(taskId, task);
|
|
await regenerateTaskFiles();
|
|
}
|
|
|
|
async validateGitHubLink(issueUrl) {
|
|
// Check if GitHub issue still exists
|
|
// Verify access permissions
|
|
// Return link status
|
|
}
|
|
|
|
generateTaskMasterReference(taskId, projectName) {
|
|
return `\n\n---\n**Task Master Reference**: Task #${taskId} in project "${projectName}"`;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Command Line Interface
|
|
```javascript
|
|
// In commands.js
|
|
program
|
|
.command('github-export')
|
|
.description('Export Task Master task to GitHub issue')
|
|
.requiredOption('-i, --id <taskId>', 'Task ID to export')
|
|
.requiredOption('-r, --repo <owner/repo>', 'Target GitHub repository')
|
|
.option('-t, --token <token>', 'GitHub Personal Access Token (or use GITHUB_TOKEN env var)')
|
|
.option('--title <title>', 'Override issue title')
|
|
.option('--labels <labels>', 'Comma-separated list of GitHub labels')
|
|
.option('--assignees <assignees>', 'Comma-separated list of GitHub usernames')
|
|
.option('--milestone <milestone>', 'GitHub milestone number or title')
|
|
.option('--template <template>', 'Custom issue template file')
|
|
.option('--include-subtasks', 'Export subtasks as checklist items')
|
|
.option('--separate-subtasks', 'Create separate issues for subtasks')
|
|
.option('--dry-run', 'Preview the issue content without creating it')
|
|
.option('--force', 'Overwrite existing GitHub link if present')
|
|
.action(async (options) => {
|
|
await handleGitHubExport(options);
|
|
});
|
|
```
|
|
|
|
### 5. MCP Tool Integration
|
|
```javascript
|
|
// MCP tool for github-export
|
|
export function registerGitHubExportTool(server) {
|
|
server.addTool({
|
|
name: "github_export_task",
|
|
description: "Export a Task Master task to GitHub issue with bidirectional linking",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
taskId: { type: "string", description: "Task ID to export" },
|
|
repository: { type: "string", description: "GitHub repository (owner/repo)" },
|
|
token: { type: "string", description: "GitHub Personal Access Token" },
|
|
options: {
|
|
type: "object",
|
|
properties: {
|
|
title: { type: "string", description: "Override issue title" },
|
|
labels: { type: "array", items: { type: "string" } },
|
|
assignees: { type: "array", items: { type: "string" } },
|
|
milestone: { type: "string", description: "Milestone number or title" },
|
|
includeSubtasks: { type: "boolean", description: "Include subtasks as checklist" },
|
|
separateSubtasks: { type: "boolean", description: "Create separate issues for subtasks" },
|
|
dryRun: { type: "boolean", description: "Preview without creating" }
|
|
}
|
|
}
|
|
},
|
|
required: ["taskId", "repository"]
|
|
},
|
|
execute: async (args) => {
|
|
return await gitHubExportDirect(args);
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
## Advanced Features
|
|
|
|
### 1. Batch Export
|
|
- Export multiple tasks at once
|
|
- Maintain relationships between exported issues
|
|
- Progress tracking for bulk operations
|
|
- Rollback capability for failed exports
|
|
|
|
### 2. Synchronization Features
|
|
- **Status Sync**: Update Task Master when GitHub issue status changes
|
|
- **Comment Sync**: Sync comments between Task Master and GitHub
|
|
- **Webhook Integration**: Real-time updates via GitHub webhooks
|
|
- **Conflict Resolution**: Handle conflicting updates gracefully
|
|
|
|
### 3. Template System
|
|
```javascript
|
|
// Custom export templates
|
|
const issueTemplates = {
|
|
bug: {
|
|
title: "[BUG] {task.title}",
|
|
labels: ["bug", "task-master"],
|
|
body: `## Bug Description\n{task.description}\n\n## Steps to Reproduce\n{task.details}`
|
|
},
|
|
feature: {
|
|
title: "[FEATURE] {task.title}",
|
|
labels: ["enhancement", "task-master"],
|
|
body: `## Feature Request\n{task.description}\n\n## Implementation Details\n{task.details}`
|
|
}
|
|
};
|
|
```
|
|
|
|
### 4. Integration with GitHub Projects
|
|
- Automatically add exported issues to GitHub project boards
|
|
- Map Task Master status to GitHub project columns
|
|
- Sync priority levels with GitHub project priorities
|
|
|
|
## Error Handling and Edge Cases
|
|
|
|
### 1. Authentication Issues
|
|
- Invalid or expired GitHub tokens
|
|
- Insufficient repository permissions
|
|
- Rate limiting and quota management
|
|
|
|
### 2. Repository Issues
|
|
- Non-existent repositories
|
|
- Private repository access
|
|
- Repository permission changes
|
|
|
|
### 3. Content Issues
|
|
- Task content too large for GitHub issue
|
|
- Invalid characters in titles or descriptions
|
|
- Markdown formatting conflicts
|
|
|
|
### 4. Link Management Issues
|
|
- Broken or invalid GitHub links
|
|
- Deleted GitHub issues
|
|
- Repository transfers or renames
|
|
|
|
## Testing Strategy
|
|
|
|
### 1. Unit Tests
|
|
- GitHub API client functionality
|
|
- Content formatting and conversion
|
|
- Link management operations
|
|
- Error handling scenarios
|
|
|
|
### 2. Integration Tests
|
|
- End-to-end export workflow
|
|
- Bidirectional linking verification
|
|
- GitHub API integration
|
|
- Authentication flow testing
|
|
|
|
### 3. Performance Tests
|
|
- Bulk export operations
|
|
- Rate limiting compliance
|
|
- Large task content handling
|
|
- Concurrent export operations
|
|
|
|
## Security Considerations
|
|
|
|
### 1. Token Management
|
|
- Secure storage of GitHub tokens
|
|
- Token validation and refresh
|
|
- Scope limitation and permissions
|
|
- Environment variable protection
|
|
|
|
### 2. Data Privacy
|
|
- Sensitive information filtering
|
|
- Private repository handling
|
|
- User consent for public exports
|
|
- Audit logging for exports
|
|
|
|
## Documentation Requirements
|
|
|
|
### 1. User Guide
|
|
- Setup and authentication instructions
|
|
- Export workflow examples
|
|
- Troubleshooting common issues
|
|
- Best practices for GitHub integration
|
|
|
|
### 2. API Documentation
|
|
- MCP tool reference
|
|
- CLI command documentation
|
|
- Configuration options
|
|
- Integration examples
|
|
|
|
### 3. Developer Guide
|
|
- Extension points for custom templates
|
|
- Webhook setup instructions
|
|
- Advanced configuration options
|
|
- Contributing guidelines
|
|
|
|
# Test Strategy:
|
|
|
|
|
|
# Subtasks:
|
|
## 1. Implement GitHub API Export Service [pending]
|
|
### Dependencies: None
|
|
### Description: Create the core service for exporting tasks to GitHub issues via the GitHub REST API
|
|
### Details:
|
|
## Implementation Requirements
|
|
|
|
### Core GitHub Export Service
|
|
```javascript
|
|
// scripts/modules/github/github-export-service.js
|
|
class GitHubExportService {
|
|
constructor(token, options = {}) {
|
|
this.token = token;
|
|
this.baseURL = options.baseURL || 'https://api.github.com';
|
|
this.rateLimiter = new RateLimiter({
|
|
tokensPerInterval: 5000, // GitHub API limit
|
|
interval: 'hour'
|
|
});
|
|
}
|
|
|
|
async exportTask(task, repoOwner, repoName, exportOptions = {}) {
|
|
// Validate repository access
|
|
await this.validateRepositoryAccess(repoOwner, repoName);
|
|
|
|
// Format task content for GitHub
|
|
const issueData = this.formatTaskAsIssue(task, exportOptions);
|
|
|
|
// Create GitHub issue
|
|
const issue = await this.createGitHubIssue(repoOwner, repoName, issueData);
|
|
|
|
// Update task with GitHub link
|
|
await this.updateTaskWithGitHubLink(task.id, issue.html_url, issue.number);
|
|
|
|
return {
|
|
success: true,
|
|
issue: issue,
|
|
taskId: task.id,
|
|
issueUrl: issue.html_url
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Repository Validation
|
|
- **Access Check**: Verify user has write access to target repository
|
|
- **Repository Existence**: Confirm repository exists and is accessible
|
|
- **Permission Validation**: Check if user can create issues in the repository
|
|
- **Rate Limit Check**: Ensure API quota is available for the operation
|
|
|
|
### Issue Creation Logic
|
|
```javascript
|
|
async createGitHubIssue(owner, repo, issueData) {
|
|
const response = await this.makeAPIRequest('POST', `/repos/${owner}/${repo}/issues`, {
|
|
title: issueData.title,
|
|
body: issueData.body,
|
|
labels: issueData.labels || [],
|
|
assignees: issueData.assignees || [],
|
|
milestone: issueData.milestone || null
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new GitHubAPIError(`Failed to create issue: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
```
|
|
|
|
### Error Handling
|
|
- **Authentication Errors**: Invalid or expired tokens
|
|
- **Permission Errors**: Insufficient repository access
|
|
- **Rate Limiting**: Handle API quota exceeded
|
|
- **Network Errors**: Connection timeouts and failures
|
|
- **Validation Errors**: Invalid repository or issue data
|
|
|
|
### Testing Requirements
|
|
- Unit tests for API client methods
|
|
- Mock GitHub API responses for testing
|
|
- Error scenario testing (invalid repos, auth failures)
|
|
- Rate limiting behavior verification
|
|
- Integration tests with real GitHub API (using test repositories)
|
|
|
|
## 2. Create Task-to-GitHub Content Formatter [pending]
|
|
### Dependencies: 101.1
|
|
### Description: Implement intelligent content formatting to convert Task Master tasks into properly formatted GitHub issues
|
|
### Details:
|
|
## Implementation Requirements
|
|
|
|
### Core Formatting Service
|
|
```javascript
|
|
// scripts/modules/github/task-formatter.js
|
|
class TaskToGitHubFormatter {
|
|
constructor(options = {}) {
|
|
this.options = {
|
|
includeTaskId: true,
|
|
includeMetadata: true,
|
|
convertSubtasksToChecklist: true,
|
|
addTaskMasterReference: true,
|
|
...options
|
|
};
|
|
}
|
|
|
|
formatTaskAsIssue(task, exportOptions = {}) {
|
|
return {
|
|
title: this.formatTitle(task, exportOptions),
|
|
body: this.formatBody(task, exportOptions),
|
|
labels: this.formatLabels(task, exportOptions),
|
|
assignees: this.formatAssignees(task, exportOptions)
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Title Formatting
|
|
```javascript
|
|
formatTitle(task, options) {
|
|
let title = task.title;
|
|
|
|
// Add task ID prefix if enabled
|
|
if (this.options.includeTaskId && !options.hideTaskId) {
|
|
title = `[Task ${task.id}] ${title}`;
|
|
}
|
|
|
|
// Add priority indicator for high priority tasks
|
|
if (task.priority === 'high') {
|
|
title = `🔥 ${title}`;
|
|
}
|
|
|
|
// Truncate if too long (GitHub limit is 256 characters)
|
|
if (title.length > 250) {
|
|
title = title.substring(0, 247) + '...';
|
|
}
|
|
|
|
return title;
|
|
}
|
|
```
|
|
|
|
### Body Formatting
|
|
```javascript
|
|
formatBody(task, options) {
|
|
let body = '';
|
|
|
|
// Header with task metadata
|
|
if (this.options.includeMetadata) {
|
|
body += this.formatMetadataSection(task);
|
|
}
|
|
|
|
// Main description
|
|
if (task.description) {
|
|
body += `## Description\n\n${task.description}\n\n`;
|
|
}
|
|
|
|
// Implementation details
|
|
if (task.details) {
|
|
body += `## Implementation Details\n\n${this.formatDetails(task.details)}\n\n`;
|
|
}
|
|
|
|
// Test strategy
|
|
if (task.testStrategy) {
|
|
body += `## Test Strategy\n\n${task.testStrategy}\n\n`;
|
|
}
|
|
|
|
// Subtasks as checklist
|
|
if (task.subtasks && task.subtasks.length > 0 && this.options.convertSubtasksToChecklist) {
|
|
body += this.formatSubtasksSection(task.subtasks);
|
|
}
|
|
|
|
// Dependencies
|
|
if (task.dependencies && task.dependencies.length > 0) {
|
|
body += this.formatDependenciesSection(task.dependencies);
|
|
}
|
|
|
|
// Task Master reference
|
|
if (this.options.addTaskMasterReference) {
|
|
body += this.formatTaskMasterReference(task);
|
|
}
|
|
|
|
return body;
|
|
}
|
|
```
|
|
|
|
### Metadata Section
|
|
```javascript
|
|
formatMetadataSection(task) {
|
|
let metadata = '## Task Information\n\n';
|
|
metadata += `| Field | Value |\n`;
|
|
metadata += `|-------|-------|\n`;
|
|
metadata += `| **Task ID** | ${task.id} |\n`;
|
|
metadata += `| **Priority** | ${this.formatPriority(task.priority)} |\n`;
|
|
metadata += `| **Status** | ${this.formatStatus(task.status)} |\n`;
|
|
|
|
if (task.dependencies && task.dependencies.length > 0) {
|
|
metadata += `| **Dependencies** | ${task.dependencies.join(', ')} |\n`;
|
|
}
|
|
|
|
if (task.complexityScore) {
|
|
metadata += `| **Complexity** | ${task.complexityScore}/10 |\n`;
|
|
}
|
|
|
|
metadata += '\n';
|
|
return metadata;
|
|
}
|
|
```
|
|
|
|
### Subtasks Formatting
|
|
```javascript
|
|
formatSubtasksSection(subtasks) {
|
|
let section = '## Subtasks\n\n';
|
|
|
|
subtasks.forEach(subtask => {
|
|
const checked = subtask.status === 'done' ? 'x' : ' ';
|
|
section += `- [${checked}] **${subtask.title}**`;
|
|
|
|
if (subtask.description) {
|
|
section += ` - ${subtask.description}`;
|
|
}
|
|
|
|
section += '\n';
|
|
|
|
// Add subtask details as indented content
|
|
if (subtask.details) {
|
|
const indentedDetails = subtask.details
|
|
.split('\n')
|
|
.map(line => ` ${line}`)
|
|
.join('\n');
|
|
section += `${indentedDetails}\n`;
|
|
}
|
|
});
|
|
|
|
section += '\n';
|
|
return section;
|
|
}
|
|
```
|
|
|
|
### Label Generation
|
|
```javascript
|
|
formatLabels(task, options) {
|
|
const labels = [];
|
|
|
|
// Always add task-master label
|
|
labels.push('task-master');
|
|
|
|
// Priority-based labels
|
|
if (task.priority === 'high') {
|
|
labels.push('priority:high');
|
|
} else if (task.priority === 'low') {
|
|
labels.push('priority:low');
|
|
}
|
|
|
|
// Status-based labels
|
|
if (task.status === 'in-progress') {
|
|
labels.push('in-progress');
|
|
}
|
|
|
|
// Complexity-based labels
|
|
if (task.complexityScore >= 8) {
|
|
labels.push('complexity:high');
|
|
} else if (task.complexityScore <= 3) {
|
|
labels.push('complexity:low');
|
|
}
|
|
|
|
// Custom labels from options
|
|
if (options.labels) {
|
|
labels.push(...options.labels);
|
|
}
|
|
|
|
return labels;
|
|
}
|
|
```
|
|
|
|
### Markdown Conversion
|
|
```javascript
|
|
formatDetails(details) {
|
|
// Convert Task Master specific formatting to GitHub markdown
|
|
let formatted = details;
|
|
|
|
// Convert code blocks
|
|
formatted = formatted.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
|
|
return `\`\`\`${lang || ''}\n${code}\`\`\``;
|
|
});
|
|
|
|
// Convert inline code
|
|
formatted = formatted.replace(/`([^`]+)`/g, '`$1`');
|
|
|
|
// Convert headers
|
|
formatted = formatted.replace(/^(#{1,6})\s+(.+)$/gm, '$1 $2');
|
|
|
|
// Convert lists
|
|
formatted = formatted.replace(/^\s*[-*+]\s+(.+)$/gm, '- $1');
|
|
|
|
// Convert numbered lists
|
|
formatted = formatted.replace(/^\s*\d+\.\s+(.+)$/gm, (match, content, offset, string) => {
|
|
const lineNumber = string.substring(0, offset).split('\n').length;
|
|
return `${lineNumber}. ${content}`;
|
|
});
|
|
|
|
return formatted;
|
|
}
|
|
```
|
|
|
|
### Task Master Reference
|
|
```javascript
|
|
formatTaskMasterReference(task) {
|
|
return `\n---\n\n*This issue was exported from Task Master*\n\n` +
|
|
`**Original Task**: #${task.id}\n` +
|
|
`**Exported**: ${new Date().toISOString()}\n` +
|
|
`**Task Master Project**: ${this.getProjectName()}\n`;
|
|
}
|
|
```
|
|
|
|
### Template System
|
|
```javascript
|
|
class IssueTemplateManager {
|
|
constructor() {
|
|
this.templates = {
|
|
default: new DefaultTemplate(),
|
|
bug: new BugTemplate(),
|
|
feature: new FeatureTemplate(),
|
|
epic: new EpicTemplate()
|
|
};
|
|
}
|
|
|
|
applyTemplate(task, templateName, options) {
|
|
const template = this.templates[templateName] || this.templates.default;
|
|
return template.format(task, options);
|
|
}
|
|
}
|
|
|
|
class BugTemplate extends TaskToGitHubFormatter {
|
|
formatTitle(task, options) {
|
|
return `🐛 [BUG] ${task.title}`;
|
|
}
|
|
|
|
formatBody(task, options) {
|
|
let body = '## Bug Report\n\n';
|
|
body += `**Task ID**: ${task.id}\n\n`;
|
|
|
|
if (task.description) {
|
|
body += `### Description\n${task.description}\n\n`;
|
|
}
|
|
|
|
if (task.details) {
|
|
body += `### Steps to Reproduce\n${task.details}\n\n`;
|
|
}
|
|
|
|
body += `### Expected Behavior\n<!-- Describe what should happen -->\n\n`;
|
|
body += `### Actual Behavior\n<!-- Describe what actually happens -->\n\n`;
|
|
|
|
return body + this.formatTaskMasterReference(task);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Testing Requirements
|
|
- Unit tests for all formatting methods
|
|
- Test with various task structures (with/without subtasks, different priorities)
|
|
- Markdown conversion accuracy testing
|
|
- Template system testing
|
|
- Character limit and truncation testing
|
|
- Special character handling (emojis, unicode)
|
|
- Large content handling and performance testing
|
|
|
|
## 3. Implement Bidirectional Link Management System [pending]
|
|
### Dependencies: 101.2
|
|
### Description: Create a robust system for managing links between Task Master tasks and GitHub issues, including validation and synchronization
|
|
### Details:
|
|
## Implementation Requirements
|
|
|
|
### Core Link Management Service
|
|
```javascript
|
|
// scripts/modules/github/link-manager.js
|
|
class GitHubLinkManager {
|
|
constructor(githubService) {
|
|
this.githubService = githubService;
|
|
this.linkCache = new Map();
|
|
}
|
|
|
|
async addGitHubLinkToTask(taskId, issueUrl, issueNumber, repository) {
|
|
const task = await this.getTask(taskId);
|
|
|
|
// Initialize metadata if it doesn't exist
|
|
if (!task.metadata) {
|
|
task.metadata = {};
|
|
}
|
|
|
|
// Add GitHub link information
|
|
task.metadata.githubIssue = {
|
|
url: issueUrl,
|
|
number: issueNumber,
|
|
repository: repository,
|
|
exportedAt: new Date().toISOString(),
|
|
lastValidated: new Date().toISOString(),
|
|
status: 'active'
|
|
};
|
|
|
|
// Update task in storage
|
|
await this.updateTask(taskId, task);
|
|
|
|
// Regenerate task files to include the link
|
|
await this.regenerateTaskFiles();
|
|
|
|
// Cache the link for quick access
|
|
this.linkCache.set(taskId, task.metadata.githubIssue);
|
|
|
|
return task.metadata.githubIssue;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Task Metadata Schema
|
|
```javascript
|
|
// Enhanced task structure with GitHub integration
|
|
const taskWithGitHubLink = {
|
|
id: 42,
|
|
title: "Example Task",
|
|
description: "Task description",
|
|
// ... other task fields ...
|
|
metadata: {
|
|
githubIssue: {
|
|
url: "https://github.com/owner/repo/issues/123",
|
|
number: 123,
|
|
repository: "owner/repo",
|
|
exportedAt: "2024-01-15T10:30:00.000Z",
|
|
lastValidated: "2024-01-15T10:30:00.000Z",
|
|
status: "active", // active, closed, deleted, invalid
|
|
syncEnabled: true,
|
|
lastSyncAt: "2024-01-15T10:30:00.000Z"
|
|
},
|
|
// Other metadata fields...
|
|
}
|
|
};
|
|
```
|
|
|
|
### Link Validation System
|
|
```javascript
|
|
class LinkValidator {
|
|
constructor(githubService) {
|
|
this.githubService = githubService;
|
|
}
|
|
|
|
async validateGitHubLink(taskId, linkInfo) {
|
|
try {
|
|
const { repository, number } = linkInfo;
|
|
const [owner, repo] = repository.split('/');
|
|
|
|
// Check if issue still exists
|
|
const issue = await this.githubService.getIssue(owner, repo, number);
|
|
|
|
if (!issue) {
|
|
return {
|
|
valid: false,
|
|
status: 'deleted',
|
|
message: 'GitHub issue no longer exists'
|
|
};
|
|
}
|
|
|
|
// Check if issue is closed
|
|
const status = issue.state === 'open' ? 'active' : 'closed';
|
|
|
|
// Update link status if changed
|
|
if (linkInfo.status !== status) {
|
|
await this.updateLinkStatus(taskId, status);
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
status: status,
|
|
issue: issue,
|
|
lastValidated: new Date().toISOString()
|
|
};
|
|
|
|
} catch (error) {
|
|
if (error.status === 404) {
|
|
return {
|
|
valid: false,
|
|
status: 'deleted',
|
|
message: 'GitHub issue not found'
|
|
};
|
|
} else if (error.status === 403) {
|
|
return {
|
|
valid: false,
|
|
status: 'access_denied',
|
|
message: 'Access denied to GitHub issue'
|
|
};
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async validateAllLinks() {
|
|
const tasks = await this.getAllTasksWithGitHubLinks();
|
|
const results = [];
|
|
|
|
for (const task of tasks) {
|
|
if (task.metadata?.githubIssue) {
|
|
const result = await this.validateGitHubLink(task.id, task.metadata.githubIssue);
|
|
results.push({
|
|
taskId: task.id,
|
|
...result
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Task File Enhancement
|
|
```javascript
|
|
// Enhanced task file generation with GitHub links
|
|
class TaskFileGenerator {
|
|
generateTaskFile(task) {
|
|
let content = this.generateBasicTaskContent(task);
|
|
|
|
// Add GitHub integration section if link exists
|
|
if (task.metadata?.githubIssue) {
|
|
content += this.generateGitHubSection(task.metadata.githubIssue);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
generateGitHubSection(githubInfo) {
|
|
let section = '\n## GitHub Integration\n\n';
|
|
|
|
section += `**GitHub Issue**: [#${githubInfo.number}](${githubInfo.url})\n`;
|
|
section += `**Repository**: ${githubInfo.repository}\n`;
|
|
section += `**Status**: ${this.formatGitHubStatus(githubInfo.status)}\n`;
|
|
section += `**Exported**: ${new Date(githubInfo.exportedAt).toLocaleDateString()}\n`;
|
|
|
|
if (githubInfo.lastValidated) {
|
|
section += `**Last Validated**: ${new Date(githubInfo.lastValidated).toLocaleDateString()}\n`;
|
|
}
|
|
|
|
if (githubInfo.status === 'closed') {
|
|
section += '\n> ⚠️ **Note**: The linked GitHub issue has been closed.\n';
|
|
} else if (githubInfo.status === 'deleted') {
|
|
section += '\n> ❌ **Warning**: The linked GitHub issue no longer exists.\n';
|
|
}
|
|
|
|
return section;
|
|
}
|
|
|
|
formatGitHubStatus(status) {
|
|
const statusMap = {
|
|
'active': '🟢 Active',
|
|
'closed': '🔴 Closed',
|
|
'deleted': '❌ Deleted',
|
|
'invalid': '⚠️ Invalid',
|
|
'access_denied': '🔒 Access Denied'
|
|
};
|
|
|
|
return statusMap[status] || status;
|
|
}
|
|
}
|
|
```
|
|
|
|
### GitHub Issue Reference System
|
|
```javascript
|
|
class GitHubReferenceManager {
|
|
generateTaskMasterReference(taskId, projectName, taskUrl = null) {
|
|
let reference = '\n\n---\n\n';
|
|
reference += '**🔗 Task Master Integration**\n\n';
|
|
reference += `- **Task ID**: #${taskId}\n`;
|
|
reference += `- **Project**: ${projectName}\n`;
|
|
reference += `- **Exported**: ${new Date().toISOString()}\n`;
|
|
|
|
if (taskUrl) {
|
|
reference += `- **Task URL**: [View in Task Master](${taskUrl})\n`;
|
|
}
|
|
|
|
reference += '\n*This issue is managed by Task Master. Changes made here may be overwritten during synchronization.*\n';
|
|
|
|
return reference;
|
|
}
|
|
|
|
async updateGitHubIssueWithTaskReference(issueUrl, taskId, projectName) {
|
|
const { owner, repo, number } = this.parseGitHubUrl(issueUrl);
|
|
const issue = await this.githubService.getIssue(owner, repo, number);
|
|
|
|
if (!issue) {
|
|
throw new Error('GitHub issue not found');
|
|
}
|
|
|
|
// Check if Task Master reference already exists
|
|
const hasReference = issue.body.includes('Task Master Integration');
|
|
|
|
if (!hasReference) {
|
|
const reference = this.generateTaskMasterReference(taskId, projectName);
|
|
const updatedBody = issue.body + reference;
|
|
|
|
await this.githubService.updateIssue(owner, repo, number, {
|
|
body: updatedBody
|
|
});
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Link Synchronization
|
|
```javascript
|
|
class LinkSynchronizer {
|
|
constructor(githubService, linkManager) {
|
|
this.githubService = githubService;
|
|
this.linkManager = linkManager;
|
|
}
|
|
|
|
async syncTaskWithGitHubIssue(taskId) {
|
|
const task = await this.getTask(taskId);
|
|
const githubInfo = task.metadata?.githubIssue;
|
|
|
|
if (!githubInfo || !githubInfo.syncEnabled) {
|
|
return { synced: false, reason: 'Sync not enabled' };
|
|
}
|
|
|
|
const { repository, number } = githubInfo;
|
|
const [owner, repo] = repository.split('/');
|
|
|
|
try {
|
|
const issue = await this.githubService.getIssue(owner, repo, number);
|
|
|
|
if (!issue) {
|
|
await this.linkManager.updateLinkStatus(taskId, 'deleted');
|
|
return { synced: false, reason: 'Issue deleted' };
|
|
}
|
|
|
|
// Sync status changes
|
|
const changes = await this.detectChanges(task, issue);
|
|
|
|
if (changes.length > 0) {
|
|
await this.applyChanges(taskId, changes);
|
|
await this.linkManager.updateLastSync(taskId);
|
|
|
|
return {
|
|
synced: true,
|
|
changes: changes,
|
|
lastSync: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
return { synced: true, changes: [] };
|
|
|
|
} catch (error) {
|
|
console.error(`Failed to sync task ${taskId}:`, error);
|
|
return { synced: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
async detectChanges(task, issue) {
|
|
const changes = [];
|
|
|
|
// Check if GitHub issue was closed and task is still pending
|
|
if (issue.state === 'closed' && task.status !== 'done') {
|
|
changes.push({
|
|
type: 'status',
|
|
from: task.status,
|
|
to: 'done',
|
|
reason: 'GitHub issue closed'
|
|
});
|
|
}
|
|
|
|
// Check if GitHub issue was reopened and task is done
|
|
if (issue.state === 'open' && task.status === 'done') {
|
|
changes.push({
|
|
type: 'status',
|
|
from: task.status,
|
|
to: 'in-progress',
|
|
reason: 'GitHub issue reopened'
|
|
});
|
|
}
|
|
|
|
return changes;
|
|
}
|
|
}
|
|
```
|
|
|
|
### CLI Integration
|
|
```javascript
|
|
// Add link management commands
|
|
program
|
|
.command('github-link')
|
|
.description('Manage GitHub links for tasks')
|
|
.option('--validate', 'Validate all GitHub links')
|
|
.option('--sync <taskId>', 'Sync specific task with GitHub')
|
|
.option('--sync-all', 'Sync all linked tasks')
|
|
.option('--remove <taskId>', 'Remove GitHub link from task')
|
|
.action(async (options) => {
|
|
if (options.validate) {
|
|
await validateAllGitHubLinks();
|
|
} else if (options.sync) {
|
|
await syncTaskWithGitHub(options.sync);
|
|
} else if (options.syncAll) {
|
|
await syncAllTasksWithGitHub();
|
|
} else if (options.remove) {
|
|
await removeGitHubLink(options.remove);
|
|
}
|
|
});
|
|
```
|
|
|
|
### Testing Requirements
|
|
- Unit tests for link management operations
|
|
- Integration tests with GitHub API
|
|
- Link validation testing (valid, invalid, deleted issues)
|
|
- Synchronization testing with various scenarios
|
|
- Error handling testing (network failures, auth issues)
|
|
- Performance testing with large numbers of linked tasks
|
|
- Cache behavior testing
|
|
- Concurrent operation testing
|
|
|
|
## 4. Create CLI and MCP Tool Integration [pending]
|
|
### Dependencies: 101.3
|
|
### Description: Implement the command-line interface and MCP tools for GitHub export functionality
|
|
### Details:
|
|
## Implementation Requirements
|
|
|
|
### CLI Command Implementation
|
|
```javascript
|
|
// In scripts/modules/commands.js
|
|
program
|
|
.command('github-export')
|
|
.description('Export Task Master task to GitHub issue with bidirectional linking')
|
|
.requiredOption('-i, --id <taskId>', 'Task ID to export')
|
|
.requiredOption('-r, --repo <owner/repo>', 'Target GitHub repository (owner/repo format)')
|
|
.option('-t, --token <token>', 'GitHub Personal Access Token (or use GITHUB_TOKEN env var)')
|
|
.option('--title <title>', 'Override the GitHub issue title')
|
|
.option('--labels <labels>', 'Comma-separated list of GitHub labels to add')
|
|
.option('--assignees <assignees>', 'Comma-separated list of GitHub usernames to assign')
|
|
.option('--milestone <milestone>', 'GitHub milestone number or title')
|
|
.option('--template <template>', 'Issue template to use (bug, feature, epic, default)')
|
|
.option('--include-subtasks', 'Include subtasks as checklist items in the issue')
|
|
.option('--separate-subtasks', 'Create separate GitHub issues for each subtask')
|
|
.option('--dry-run', 'Preview the issue content without actually creating it')
|
|
.option('--force', 'Overwrite existing GitHub link if task is already linked')
|
|
.option('--no-link-back', 'Do not add Task Master reference to the GitHub issue')
|
|
.option('--sync', 'Enable automatic synchronization between task and issue')
|
|
.action(async (options) => {
|
|
try {
|
|
await handleGitHubExport(options);
|
|
} catch (error) {
|
|
console.error(chalk.red('GitHub export failed:'), error.message);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
```
|
|
|
|
### Core Export Handler
|
|
```javascript
|
|
// scripts/modules/github/github-export-handler.js
|
|
async function handleGitHubExport(options) {
|
|
const {
|
|
id: taskId,
|
|
repo: repository,
|
|
token,
|
|
title: titleOverride,
|
|
labels,
|
|
assignees,
|
|
milestone,
|
|
template = 'default',
|
|
includeSubtasks,
|
|
separateSubtasks,
|
|
dryRun,
|
|
force,
|
|
linkBack = true,
|
|
sync = false
|
|
} = options;
|
|
|
|
// Validate inputs
|
|
await validateExportOptions(options);
|
|
|
|
// Get task details
|
|
const task = await getTask(taskId);
|
|
if (!task) {
|
|
throw new Error(`Task ${taskId} not found`);
|
|
}
|
|
|
|
// Check for existing GitHub link
|
|
if (task.metadata?.githubIssue && !force) {
|
|
const existingUrl = task.metadata.githubIssue.url;
|
|
console.log(chalk.yellow(`Task ${taskId} is already linked to GitHub issue: ${existingUrl}`));
|
|
console.log(chalk.gray('Use --force to overwrite the existing link'));
|
|
return;
|
|
}
|
|
|
|
// Initialize GitHub service
|
|
const githubToken = token || process.env.GITHUB_TOKEN;
|
|
if (!githubToken) {
|
|
throw new Error('GitHub token required. Use --token flag or set GITHUB_TOKEN environment variable');
|
|
}
|
|
|
|
const githubService = new GitHubExportService(githubToken);
|
|
const formatter = new TaskToGitHubFormatter();
|
|
const linkManager = new GitHubLinkManager(githubService);
|
|
|
|
// Format task content
|
|
const exportOptions = {
|
|
titleOverride,
|
|
labels: labels ? labels.split(',').map(l => l.trim()) : [],
|
|
assignees: assignees ? assignees.split(',').map(a => a.trim()) : [],
|
|
milestone,
|
|
template,
|
|
includeSubtasks,
|
|
linkBack
|
|
};
|
|
|
|
const issueData = formatter.formatTaskAsIssue(task, exportOptions);
|
|
|
|
// Dry run - just show what would be created
|
|
if (dryRun) {
|
|
console.log(chalk.cyan('\\n=== DRY RUN - GitHub Issue Preview ===\\n'));
|
|
console.log(chalk.bold('Title:'), issueData.title);
|
|
console.log(chalk.bold('\\nLabels:'), issueData.labels.join(', ') || 'None');
|
|
console.log(chalk.bold('\\nAssignees:'), issueData.assignees.join(', ') || 'None');
|
|
console.log(chalk.bold('\\nBody:'));
|
|
console.log(issueData.body);
|
|
console.log(chalk.cyan('\\n=== End Preview ===\\n'));
|
|
return;
|
|
}
|
|
|
|
// Show progress
|
|
console.log(chalk.blue(`Exporting task ${taskId} to GitHub repository ${repository}...`));
|
|
|
|
// Export to GitHub
|
|
const result = await githubService.exportTask(task, repository, exportOptions);
|
|
|
|
if (result.success) {
|
|
console.log(chalk.green(`✅ Successfully exported task ${taskId} to GitHub!`));
|
|
console.log(chalk.cyan(`GitHub Issue: ${result.issueUrl}`));
|
|
|
|
// Add bidirectional link
|
|
await linkManager.addGitHubLinkToTask(taskId, result.issueUrl, result.issue.number, repository);
|
|
|
|
if (linkBack) {
|
|
await linkManager.updateGitHubIssueWithTaskReference(result.issueUrl, taskId, getProjectName());
|
|
}
|
|
|
|
if (sync) {
|
|
await linkManager.enableSync(taskId);
|
|
console.log(chalk.blue('🔄 Synchronization enabled for this task'));
|
|
}
|
|
|
|
// Handle subtasks if requested
|
|
if (separateSubtasks && task.subtasks && task.subtasks.length > 0) {
|
|
console.log(chalk.blue('\\nExporting subtasks as separate issues...'));
|
|
await exportSubtasksAsSeparateIssues(task.subtasks, repository, githubService, linkManager);
|
|
}
|
|
|
|
} else {
|
|
throw new Error(result.error || 'Export failed');
|
|
}
|
|
}
|
|
```
|
|
|
|
### MCP Tool Implementation
|
|
```javascript
|
|
// mcp-server/src/tools/github-export.js
|
|
import { githubExportDirect } from '../core/direct-functions/github-export-direct.js';
|
|
import { handleApiResult, withNormalizedProjectRoot } from './utils.js';
|
|
|
|
export function registerGitHubExportTool(server) {
|
|
server.addTool({
|
|
name: "github_export_task",
|
|
description: "Export a Task Master task to GitHub issue with bidirectional linking",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
taskId: {
|
|
type: "string",
|
|
description: "Task ID to export (required)"
|
|
},
|
|
repository: {
|
|
type: "string",
|
|
description: "GitHub repository in owner/repo format (required)"
|
|
},
|
|
token: {
|
|
type: "string",
|
|
description: "GitHub Personal Access Token (optional if GITHUB_TOKEN env var is set)"
|
|
},
|
|
options: {
|
|
type: "object",
|
|
properties: {
|
|
title: {
|
|
type: "string",
|
|
description: "Override the GitHub issue title"
|
|
},
|
|
labels: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
description: "GitHub labels to add to the issue"
|
|
},
|
|
assignees: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
description: "GitHub usernames to assign to the issue"
|
|
},
|
|
milestone: {
|
|
type: "string",
|
|
description: "GitHub milestone number or title"
|
|
},
|
|
template: {
|
|
type: "string",
|
|
enum: ["default", "bug", "feature", "epic"],
|
|
description: "Issue template to use"
|
|
},
|
|
includeSubtasks: {
|
|
type: "boolean",
|
|
description: "Include subtasks as checklist items"
|
|
},
|
|
separateSubtasks: {
|
|
type: "boolean",
|
|
description: "Create separate issues for subtasks"
|
|
},
|
|
dryRun: {
|
|
type: "boolean",
|
|
description: "Preview without creating the issue"
|
|
},
|
|
force: {
|
|
type: "boolean",
|
|
description: "Overwrite existing GitHub link"
|
|
},
|
|
linkBack: {
|
|
type: "boolean",
|
|
description: "Add Task Master reference to GitHub issue"
|
|
},
|
|
enableSync: {
|
|
type: "boolean",
|
|
description: "Enable automatic synchronization"
|
|
}
|
|
}
|
|
},
|
|
projectRoot: {
|
|
type: "string",
|
|
description: "Project root directory path"
|
|
}
|
|
},
|
|
required: ["taskId", "repository"]
|
|
},
|
|
execute: withNormalizedProjectRoot(async (args, { log, session }) => {
|
|
try {
|
|
const result = await githubExportDirect(args, log, { session });
|
|
return handleApiResult(result, log);
|
|
} catch (error) {
|
|
log(`GitHub export error: ${error.message}`);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
})
|
|
});
|
|
}
|
|
```
|
|
|
|
### Direct Function Implementation
|
|
```javascript
|
|
// mcp-server/src/core/direct-functions/github-export-direct.js
|
|
import { handleGitHubExport } from '../../../../scripts/modules/github/github-export-handler.js';
|
|
import { createLogWrapper } from '../../tools/utils.js';
|
|
|
|
export async function githubExportDirect(args, log, context = {}) {
|
|
const { session } = context;
|
|
const mcpLog = createLogWrapper(log);
|
|
|
|
try {
|
|
// Prepare options for the core handler
|
|
const options = {
|
|
id: args.taskId,
|
|
repo: args.repository,
|
|
token: args.token,
|
|
...args.options,
|
|
projectRoot: args.projectRoot
|
|
};
|
|
|
|
// Call the core export handler
|
|
const result = await handleGitHubExport(options, {
|
|
session,
|
|
mcpLog,
|
|
outputFormat: 'json' // Request JSON output for MCP
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
taskId: args.taskId,
|
|
repository: args.repository,
|
|
issueUrl: result.issueUrl,
|
|
issueNumber: result.issue.number,
|
|
exportedAt: new Date().toISOString(),
|
|
message: `Successfully exported task ${args.taskId} to GitHub issue #${result.issue.number}`
|
|
}
|
|
};
|
|
|
|
} catch (error) {
|
|
mcpLog(`GitHub export failed: ${error.message}`);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Validation Functions
|
|
```javascript
|
|
// scripts/modules/github/validation.js
|
|
async function validateExportOptions(options) {
|
|
const { id: taskId, repo: repository, token } = options;
|
|
|
|
// Validate task ID
|
|
if (!taskId || !/^\\d+(\\.\\d+)*$/.test(taskId)) {
|
|
throw new Error('Invalid task ID format');
|
|
}
|
|
|
|
// Validate repository format
|
|
if (!repository || !/^[a-zA-Z0-9._-]+\\/[a-zA-Z0-9._-]+$/.test(repository)) {
|
|
throw new Error('Repository must be in owner/repo format');
|
|
}
|
|
|
|
// Validate GitHub token
|
|
const githubToken = token || process.env.GITHUB_TOKEN;
|
|
if (!githubToken) {
|
|
throw new Error('GitHub token is required');
|
|
}
|
|
|
|
if (!/^gh[ps]_[a-zA-Z0-9]{36,}$/.test(githubToken)) {
|
|
console.warn(chalk.yellow('Warning: GitHub token format appears invalid'));
|
|
}
|
|
|
|
// Validate labels format
|
|
if (options.labels) {
|
|
const labels = options.labels.split(',').map(l => l.trim());
|
|
for (const label of labels) {
|
|
if (label.length > 50) {
|
|
throw new Error(`Label "${label}" is too long (max 50 characters)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate assignees format
|
|
if (options.assignees) {
|
|
const assignees = options.assignees.split(',').map(a => a.trim());
|
|
for (const assignee of assignees) {
|
|
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(assignee)) {
|
|
throw new Error(`Invalid GitHub username: ${assignee}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Help Integration
|
|
```javascript
|
|
// Add to help system
|
|
const githubExportHelp = {
|
|
command: 'github-export',
|
|
description: 'Export Task Master task to GitHub issue',
|
|
usage: 'task-master github-export --id=<taskId> --repo=<owner/repo> [options]',
|
|
examples: [
|
|
{
|
|
command: 'task-master github-export --id=42 --repo=myorg/myproject',
|
|
description: 'Export task 42 to GitHub repository'
|
|
},
|
|
{
|
|
command: 'task-master github-export --id=42 --repo=myorg/myproject --labels="bug,urgent" --assignees="john,jane"',
|
|
description: 'Export with custom labels and assignees'
|
|
},
|
|
{
|
|
command: 'task-master github-export --id=42 --repo=myorg/myproject --dry-run',
|
|
description: 'Preview the GitHub issue without creating it'
|
|
},
|
|
{
|
|
command: 'task-master github-export --id=42 --repo=myorg/myproject --template=bug --sync',
|
|
description: 'Export using bug template with sync enabled'
|
|
}
|
|
],
|
|
options: [
|
|
{ flag: '--id <taskId>', description: 'Task ID to export (required)' },
|
|
{ flag: '--repo <owner/repo>', description: 'GitHub repository (required)' },
|
|
{ flag: '--token <token>', description: 'GitHub Personal Access Token' },
|
|
{ flag: '--title <title>', description: 'Override issue title' },
|
|
{ flag: '--labels <labels>', description: 'Comma-separated labels' },
|
|
{ flag: '--assignees <users>', description: 'Comma-separated assignees' },
|
|
{ flag: '--milestone <milestone>', description: 'GitHub milestone' },
|
|
{ flag: '--template <template>', description: 'Issue template (bug, feature, epic)' },
|
|
{ flag: '--include-subtasks', description: 'Include subtasks as checklist' },
|
|
{ flag: '--separate-subtasks', description: 'Create separate issues for subtasks' },
|
|
{ flag: '--dry-run', description: 'Preview without creating' },
|
|
{ flag: '--force', description: 'Overwrite existing GitHub link' },
|
|
{ flag: '--no-link-back', description: 'Skip Task Master reference in issue' },
|
|
{ flag: '--sync', description: 'Enable automatic synchronization' }
|
|
]
|
|
};
|
|
```
|
|
|
|
### Testing Requirements
|
|
- Unit tests for CLI option parsing and validation
|
|
- Integration tests for MCP tool functionality
|
|
- End-to-end tests with real GitHub repositories
|
|
- Error handling tests for various failure scenarios
|
|
- Dry-run functionality testing
|
|
- Template system testing
|
|
- Subtask export testing (both checklist and separate issues)
|
|
- Authentication and authorization testing
|
|
- Rate limiting and retry logic testing
|
|
|
|
## 5. Create Comprehensive Testing Suite and Documentation [pending]
|
|
### Dependencies: 101.4
|
|
### Description: Implement thorough testing for the GitHub export system and create comprehensive documentation
|
|
### Details:
|
|
## Implementation Requirements
|
|
|
|
### Testing Strategy
|
|
|
|
#### 1. Unit Tests
|
|
```javascript
|
|
// tests/unit/github-export.test.js
|
|
describe('GitHub Export System', () => {
|
|
describe('GitHubExportService', () => {
|
|
test('should validate repository access', async () => {
|
|
const service = new GitHubExportService('mock-token');
|
|
const mockGitHub = jest.spyOn(service, 'validateRepositoryAccess');
|
|
|
|
await service.exportTask(mockTask, 'owner', 'repo');
|
|
expect(mockGitHub).toHaveBeenCalledWith('owner', 'repo');
|
|
});
|
|
|
|
test('should handle authentication errors', async () => {
|
|
const service = new GitHubExportService('invalid-token');
|
|
|
|
await expect(service.exportTask(mockTask, 'owner', 'repo'))
|
|
.rejects.toThrow('Authentication failed');
|
|
});
|
|
|
|
test('should respect rate limits', async () => {
|
|
const service = new GitHubExportService('valid-token');
|
|
const rateLimiter = jest.spyOn(service.rateLimiter, 'removeTokens');
|
|
|
|
await service.exportTask(mockTask, 'owner', 'repo');
|
|
expect(rateLimiter).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('TaskToGitHubFormatter', () => {
|
|
test('should format task title correctly', () => {
|
|
const formatter = new TaskToGitHubFormatter();
|
|
const task = { id: 42, title: 'Test Task', priority: 'high' };
|
|
|
|
const result = formatter.formatTitle(task);
|
|
expect(result).toBe('🔥 [Task 42] Test Task');
|
|
});
|
|
|
|
test('should truncate long titles', () => {
|
|
const formatter = new TaskToGitHubFormatter();
|
|
const longTitle = 'A'.repeat(300);
|
|
const task = { id: 1, title: longTitle };
|
|
|
|
const result = formatter.formatTitle(task);
|
|
expect(result.length).toBeLessThanOrEqual(250);
|
|
expect(result).toEndWith('...');
|
|
});
|
|
|
|
test('should format subtasks as checklist', () => {
|
|
const formatter = new TaskToGitHubFormatter();
|
|
const task = {
|
|
id: 1,
|
|
title: 'Parent Task',
|
|
subtasks: [
|
|
{ title: 'Subtask 1', status: 'done' },
|
|
{ title: 'Subtask 2', status: 'pending' }
|
|
]
|
|
};
|
|
|
|
const result = formatter.formatBody(task);
|
|
expect(result).toContain('- [x] **Subtask 1**');
|
|
expect(result).toContain('- [ ] **Subtask 2**');
|
|
});
|
|
|
|
test('should generate appropriate labels', () => {
|
|
const formatter = new TaskToGitHubFormatter();
|
|
const task = { priority: 'high', complexityScore: 9 };
|
|
|
|
const labels = formatter.formatLabels(task);
|
|
expect(labels).toContain('task-master');
|
|
expect(labels).toContain('priority:high');
|
|
expect(labels).toContain('complexity:high');
|
|
});
|
|
});
|
|
|
|
describe('GitHubLinkManager', () => {
|
|
test('should add GitHub link to task metadata', async () => {
|
|
const linkManager = new GitHubLinkManager(mockGitHubService);
|
|
const taskId = '42';
|
|
const issueUrl = 'https://github.com/owner/repo/issues/123';
|
|
|
|
await linkManager.addGitHubLinkToTask(taskId, issueUrl, 123, 'owner/repo');
|
|
|
|
const task = await getTask(taskId);
|
|
expect(task.metadata.githubIssue).toBeDefined();
|
|
expect(task.metadata.githubIssue.url).toBe(issueUrl);
|
|
expect(task.metadata.githubIssue.number).toBe(123);
|
|
});
|
|
|
|
test('should validate GitHub links', async () => {
|
|
const linkManager = new GitHubLinkManager(mockGitHubService);
|
|
const linkInfo = {
|
|
repository: 'owner/repo',
|
|
number: 123,
|
|
status: 'active'
|
|
};
|
|
|
|
mockGitHubService.getIssue.mockResolvedValue({ state: 'open' });
|
|
|
|
const result = await linkManager.validateGitHubLink('42', linkInfo);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.status).toBe('active');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
#### 2. Integration Tests
|
|
```javascript
|
|
// tests/integration/github-export-integration.test.js
|
|
describe('GitHub Export Integration', () => {
|
|
let testRepository;
|
|
let githubToken;
|
|
|
|
beforeAll(() => {
|
|
githubToken = process.env.GITHUB_TEST_TOKEN;
|
|
testRepository = process.env.GITHUB_TEST_REPO || 'taskmaster-test/test-repo';
|
|
|
|
if (!githubToken) {
|
|
throw new Error('GITHUB_TEST_TOKEN environment variable required for integration tests');
|
|
}
|
|
});
|
|
|
|
test('should export task to real GitHub repository', async () => {
|
|
const task = createTestTask();
|
|
const service = new GitHubExportService(githubToken);
|
|
|
|
const result = await service.exportTask(task, testRepository, {
|
|
labels: ['test', 'automated'],
|
|
template: 'default'
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.issueUrl).toMatch(/https:\\/\\/github\\.com\\/.+\\/issues\\/\\d+/);
|
|
|
|
// Cleanup: Close the test issue
|
|
await service.updateIssue(testRepository, result.issue.number, { state: 'closed' });
|
|
});
|
|
|
|
test('should handle repository permission errors', async () => {
|
|
const task = createTestTask();
|
|
const service = new GitHubExportService(githubToken);
|
|
|
|
await expect(service.exportTask(task, 'private/inaccessible-repo'))
|
|
.rejects.toThrow(/permission|access/i);
|
|
});
|
|
|
|
test('should respect GitHub API rate limits', async () => {
|
|
const service = new GitHubExportService(githubToken);
|
|
const tasks = Array.from({ length: 10 }, () => createTestTask());
|
|
|
|
const startTime = Date.now();
|
|
|
|
for (const task of tasks) {
|
|
await service.exportTask(task, testRepository);
|
|
}
|
|
|
|
const endTime = Date.now();
|
|
const duration = endTime - startTime;
|
|
|
|
// Should take some time due to rate limiting
|
|
expect(duration).toBeGreaterThan(1000);
|
|
});
|
|
});
|
|
```
|
|
|
|
#### 3. CLI Tests
|
|
```javascript
|
|
// tests/cli/github-export-cli.test.js
|
|
describe('GitHub Export CLI', () => {
|
|
test('should validate required options', async () => {
|
|
const result = await runCLI(['github-export']);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain('required option');
|
|
});
|
|
|
|
test('should perform dry run correctly', async () => {
|
|
const result = await runCLI([
|
|
'github-export',
|
|
'--id=42',
|
|
'--repo=owner/repo',
|
|
'--dry-run'
|
|
]);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain('DRY RUN');
|
|
expect(result.stdout).toContain('GitHub Issue Preview');
|
|
});
|
|
|
|
test('should handle invalid task ID', async () => {
|
|
const result = await runCLI([
|
|
'github-export',
|
|
'--id=invalid',
|
|
'--repo=owner/repo'
|
|
]);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain('Invalid task ID');
|
|
});
|
|
|
|
test('should validate repository format', async () => {
|
|
const result = await runCLI([
|
|
'github-export',
|
|
'--id=42',
|
|
'--repo=invalid-format'
|
|
]);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain('owner/repo format');
|
|
});
|
|
});
|
|
```
|
|
|
|
#### 4. MCP Tool Tests
|
|
```javascript
|
|
// tests/mcp/github-export-mcp.test.js
|
|
describe('GitHub Export MCP Tool', () => {
|
|
test('should export task via MCP', async () => {
|
|
const args = {
|
|
taskId: '42',
|
|
repository: 'owner/repo',
|
|
token: 'test-token',
|
|
options: {
|
|
labels: ['test'],
|
|
dryRun: true
|
|
}
|
|
};
|
|
|
|
const result = await githubExportDirect(args, mockLog, { session: mockSession });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data.taskId).toBe('42');
|
|
});
|
|
|
|
test('should handle MCP tool errors', async () => {
|
|
const args = {
|
|
taskId: 'invalid',
|
|
repository: 'owner/repo'
|
|
};
|
|
|
|
const result = await githubExportDirect(args, mockLog, { session: mockSession });
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBeDefined();
|
|
});
|
|
});
|
|
```
|
|
|
|
### Mock Data and Utilities
|
|
```javascript
|
|
// tests/utils/github-mocks.js
|
|
export function createTestTask() {
|
|
return {
|
|
id: Math.floor(Math.random() * 1000),
|
|
title: 'Test Task',
|
|
description: 'This is a test task for GitHub export',
|
|
details: 'Implementation details for the test task',
|
|
priority: 'medium',
|
|
status: 'pending',
|
|
subtasks: [
|
|
{ title: 'Test Subtask 1', status: 'done' },
|
|
{ title: 'Test Subtask 2', status: 'pending' }
|
|
]
|
|
};
|
|
}
|
|
|
|
export function mockGitHubAPI() {
|
|
return {
|
|
getIssue: jest.fn(),
|
|
createIssue: jest.fn(),
|
|
updateIssue: jest.fn(),
|
|
getRepository: jest.fn()
|
|
};
|
|
}
|
|
|
|
export function createMockGitHubResponse(issueNumber = 123) {
|
|
return {
|
|
id: issueNumber,
|
|
number: issueNumber,
|
|
title: 'Test Issue',
|
|
body: 'Test issue body',
|
|
state: 'open',
|
|
html_url: `https://github.com/owner/repo/issues/${issueNumber}`,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
}
|
|
```
|
|
|
|
### Documentation
|
|
|
|
#### 1. User Guide
|
|
```markdown
|
|
# GitHub Export Feature
|
|
|
|
## Overview
|
|
The GitHub Export feature allows you to create GitHub issues directly from your Task Master tasks, maintaining bidirectional links between tasks and issues.
|
|
|
|
## Setup
|
|
|
|
### 1. GitHub Token
|
|
Create a GitHub Personal Access Token with the following permissions:
|
|
- `repo` (for private repositories)
|
|
- `public_repo` (for public repositories)
|
|
|
|
Set the token as an environment variable:
|
|
```bash
|
|
export GITHUB_TOKEN=your_token_here
|
|
```
|
|
|
|
### 2. Basic Usage
|
|
```bash
|
|
# Export a task to GitHub
|
|
task-master github-export --id=42 --repo=myorg/myproject
|
|
|
|
# Export with custom labels and assignees
|
|
task-master github-export --id=42 --repo=myorg/myproject \\
|
|
--labels="bug,urgent" --assignees="john,jane"
|
|
|
|
# Preview before creating
|
|
task-master github-export --id=42 --repo=myorg/myproject --dry-run
|
|
```
|
|
|
|
## Advanced Features
|
|
|
|
### Templates
|
|
Use predefined templates for different issue types:
|
|
```bash
|
|
# Bug report template
|
|
task-master github-export --id=42 --repo=myorg/myproject --template=bug
|
|
|
|
# Feature request template
|
|
task-master github-export --id=42 --repo=myorg/myproject --template=feature
|
|
```
|
|
|
|
### Subtask Handling
|
|
```bash
|
|
# Include subtasks as checklist items
|
|
task-master github-export --id=42 --repo=myorg/myproject --include-subtasks
|
|
|
|
# Create separate issues for each subtask
|
|
task-master github-export --id=42 --repo=myorg/myproject --separate-subtasks
|
|
```
|
|
|
|
### Synchronization
|
|
```bash
|
|
# Enable automatic synchronization
|
|
task-master github-export --id=42 --repo=myorg/myproject --sync
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Common Issues
|
|
1. **Authentication Error**: Verify your GitHub token has the correct permissions
|
|
2. **Repository Not Found**: Ensure the repository exists and you have access
|
|
3. **Rate Limit Exceeded**: Wait for the rate limit to reset or use a different token
|
|
|
|
### Link Management
|
|
```bash
|
|
# Validate all GitHub links
|
|
task-master github-link --validate
|
|
|
|
# Sync specific task with GitHub
|
|
task-master github-link --sync 42
|
|
|
|
# Remove GitHub link from task
|
|
task-master github-link --remove 42
|
|
```
|
|
```
|
|
|
|
#### 2. API Documentation
|
|
```markdown
|
|
# GitHub Export API Reference
|
|
|
|
## MCP Tool: github_export_task
|
|
|
|
### Parameters
|
|
- `taskId` (string, required): Task ID to export
|
|
- `repository` (string, required): GitHub repository in owner/repo format
|
|
- `token` (string, optional): GitHub Personal Access Token
|
|
- `options` (object, optional): Export configuration
|
|
|
|
### Options Object
|
|
- `title` (string): Override issue title
|
|
- `labels` (array): GitHub labels to add
|
|
- `assignees` (array): GitHub usernames to assign
|
|
- `milestone` (string): GitHub milestone
|
|
- `template` (string): Issue template (bug, feature, epic, default)
|
|
- `includeSubtasks` (boolean): Include subtasks as checklist
|
|
- `separateSubtasks` (boolean): Create separate issues for subtasks
|
|
- `dryRun` (boolean): Preview without creating
|
|
- `force` (boolean): Overwrite existing GitHub link
|
|
- `linkBack` (boolean): Add Task Master reference to issue
|
|
- `enableSync` (boolean): Enable automatic synchronization
|
|
|
|
### Response
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"taskId": "42",
|
|
"repository": "owner/repo",
|
|
"issueUrl": "https://github.com/owner/repo/issues/123",
|
|
"issueNumber": 123,
|
|
"exportedAt": "2024-01-15T10:30:00.000Z",
|
|
"message": "Successfully exported task 42 to GitHub issue #123"
|
|
}
|
|
}
|
|
```
|
|
```
|
|
|
|
### Performance Testing
|
|
```javascript
|
|
// tests/performance/github-export-performance.test.js
|
|
describe('GitHub Export Performance', () => {
|
|
test('should export large task within time limit', async () => {
|
|
const largeTask = createLargeTask(); // Task with many subtasks and long content
|
|
const service = new GitHubExportService('test-token');
|
|
|
|
const startTime = performance.now();
|
|
await service.exportTask(largeTask, 'owner/repo');
|
|
const endTime = performance.now();
|
|
|
|
expect(endTime - startTime).toBeLessThan(5000); // Should complete in under 5 seconds
|
|
});
|
|
|
|
test('should handle concurrent exports', async () => {
|
|
const service = new GitHubExportService('test-token');
|
|
const tasks = Array.from({ length: 5 }, () => createTestTask());
|
|
|
|
const promises = tasks.map(task => service.exportTask(task, 'owner/repo'));
|
|
const results = await Promise.all(promises);
|
|
|
|
results.forEach(result => {
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Test Configuration
|
|
```javascript
|
|
// jest.config.js additions
|
|
module.exports = {
|
|
// ... existing config
|
|
testEnvironment: 'node',
|
|
setupFilesAfterEnv: ['<rootDir>/tests/setup/github-setup.js'],
|
|
testMatch: [
|
|
'**/tests/**/*.test.js',
|
|
'**/tests/**/*.spec.js'
|
|
],
|
|
collectCoverageFrom: [
|
|
'scripts/modules/github/**/*.js',
|
|
'mcp-server/src/tools/github-*.js',
|
|
'mcp-server/src/core/direct-functions/github-*.js'
|
|
],
|
|
coverageThreshold: {
|
|
global: {
|
|
branches: 80,
|
|
functions: 80,
|
|
lines: 80,
|
|
statements: 80
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
### Continuous Integration
|
|
```yaml
|
|
# .github/workflows/github-export-tests.yml
|
|
name: GitHub Export Tests
|
|
|
|
on: [push, pull_request]
|
|
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v3
|
|
- uses: actions/setup-node@v3
|
|
with:
|
|
node-version: '18'
|
|
- run: npm ci
|
|
- run: npm run test:github-export
|
|
env:
|
|
GITHUB_TEST_TOKEN: ${{ secrets.GITHUB_TEST_TOKEN }}
|
|
GITHUB_TEST_REPO: ${{ secrets.GITHUB_TEST_REPO }}
|
|
```
|
|
|