Skill: digisal/youtrack-digisal (version 1.0.1)

--- name: youtrack description: Interact with YouTrack project management system via REST API. Read projects and issues, create tasks, generate invoices from time tracking data, and manage knowledge base articles. Use for reading projects and work items, creating or updating issues, generating client invoices from time tracking, and working with knowledge base articles. --- # YouTrack YouTrack integration for project management, time tracking, and knowledge base. ## Quick Start ### Authentication To generate a permanent token: 1. From the main navigation menu, select **Administration** > **Access Management** > **Users** 2. Find your user and click to open settings 3. Generate a new permanent API token 4. Set the token as an environment variable: ```bash export YOUTRACK_TOKEN=your-permanent-token-here ``` **Important:** Configure your hourly rate (default $100/hour) by passing `--rate` to invoice_generator.py or updating `hourly_rate` parameter in your code. Then use any YouTrack script: ```bash # List all projects python3 scripts/youtrack_api.py --url https://your-instance.youtrack.cloud --list-projects # List issues in a project python3 scripts/youtrack_api.py --url https://your-instance.youtrack.cloud --list-issues "project: MyProject" # Generate invoice for a project python3 scripts/invoice_generator.py --url https://your-instance.youtrack.cloud --project MyProject --month "January 2026" --from-date "2026-01-01" ``` ## Python Scripts ### `scripts/youtrack_api.py` Core API client for all YouTrack operations. **In your Python code:** ```python from youtrack_api import YouTrackAPI api = YouTrackAPI('https://your-instance.youtrack.cloud', [REDACTED_SECRET]') # Projects projects = api.get_projects() project = api.get_project('project-id') # Issues issues = api.get_issues(query='project: MyProject') issue = api.get_issue('issue-id') # Create issue api.create_issue('project-id', 'Summary', 'Description') # Work items (time tracking) work_items = api.get_work_items('issue-id') issue_with_time = api.get_issue_with_work_items('issue-id') # Knowledge base articles = api.get_articles() article = api.get_article('article-id') api.create_article('project-id', 'Title', 'Content') ``` **CLI usage:** ```bash python3 scripts/youtrack_api.py --url https://your-instance.youtrack.cloud \ --token YOUR_TOKEN \ --list-projects python3 scripts/youtrack_api.py --url https://your-instance.youtrack.cloud \ --get-issue ABC-123 python3 scripts/youtrack_api.py --url https://your-instance.youtrack.cloud \ --get-articles ``` ### `scripts/invoice_generator.py` Generate client invoices from time tracking data. **In your Python code:** ```python from youtrack_api import YouTrackAPI from invoice_generator import InvoiceGenerator api = YouTrackAPI('https://your-instance.youtrack.cloud', [REDACTED_SECRET]') generator = InvoiceGenerator(api, hourly_rate=100.0) # Get time data for a project project_data = generator.get_project_time_data('project-id', from_date='2026-01-01') # Generate invoice invoice_text = generator.generate_invoice_text(project_data, month='January 2026') print(invoice_text) ``` **CLI usage:** ```bash python3 scripts/invoice_generator.py \ --url https://your-instance.youtrack.cloud \ --project MyProject \ --from-date 2026-01-01 \ --month "January 2026" \ --rate 100 \ --format text ``` Save the text output and print to PDF for clients. ## Common Workflows ### 1. List All Projects ```bash python3 scripts/youtrack_api.py --url https://your-instance.youtrack.cloud --list-projects ``` ### 2. Find Issues in a Project ```bash # All issues in a project python3 scripts/youtrack_api.py --url https://your-instance.youtrack.cloud --list-issues "project: MyProject" # Issues updated since a date python3 scripts/youtrack_api.py --url https://your-instance.youtrack.cloud --list-issues "project: MyProject updated >= 2026-01-01" # Issues assigned to you python3 scripts/youtrack_api.py --url https://your-instance.youtrack.cloud --list-issues "assignee: me" ``` ### 3. Create a New Issue ```python from youtrack_api import YouTrackAPI api = YouTrackAPI('https://your-instance.youtrack.cloud') api.create_issue( project_id='MyProject', summary='Task title', description='Task description' ) ``` ### 4. Generate Monthly Invoice ```bash # Generate invoice for January 2026 python3 scripts/invoice_generator.py \ --url https://your-instance.youtrack.cloud \ --project ClientProject \ --from-date 2026-01-01 \ --month "January 2026" \ --rate 100 \ --format text > invoice.txt ``` Save the text output and print to PDF for clients. ### 5. Read Knowledge Base ```python from youtrack_api import YouTrackAPI api = YouTrackAPI('https://your-instance.youtrack.cloud') # All articles articles = api.get_articles() # Articles for specific project articles = api.get_articles(project_id='MyProject') # Get specific article article = api.get_article('article-id') ``` ## Billing Logic Invoice generator uses this calculation: 1. Sum all time tracked per issue (in minutes) 2. Convert to 30-minute increments (round up) 3. Minimum charge is 30 minutes (at configured rate/2) 4. Multiply by rate (default $100/hour = $50 per half-hour) Examples: - 15 minutes → $50 (30 min minimum) - 35 minutes → $100 (rounded to 60 min) - 60 minutes → $100 - 67 minutes → $150 (rounded to 90 min) ## Environment Variables - `YOUTRACK_TOKEN`: Your permanent API token (recommended over passing as argument) - Set with `export YOUTRACK_TOKEN=your-token` ## API Details See `REFERENCES.md` for: - Complete API endpoint documentation - Query language examples - Field IDs and structures ## Error Handling Scripts will raise errors for: - Missing or invalid token - Network issues - API errors (404, 403, etc.) Check stderr for error details.

path: REFERENCES.md
content: # YouTrack API References

## Core API Endpoints

### Base URL
Your YouTrack instance: `https://your-instance.youtrack.cloud/`
API base: `https://your-instance.youtrack.cloud/api/`

### Authentication
- Use permanent token with `Authorization: Bearer <token>` header
- To generate a token: From main navigation menu, select **Administration** > **Access Management** > **Users**, find your user, and generate a permanent API token

### Projects

**List all projects**
- `GET /api/admin/projects?fields=id,name,shortName`
- Returns array of project objects with id, name, shortName

**Get specific project**
- `GET /api/admin/projects/{projectId}?fields=id,name,shortName,description`

### Issues

**List issues with query**
- `GET /api/issues?query={query}&fields={fields}`
- Query examples:
  - `project: MyProject`
  - `project: MyProject updated: 2026-01-01 ..`
  - `assignee: me`
- Common fields to request:
  - `id,summary,description,created,updated,project(id,name),customFields(name,value)`

**Get issue**
- `GET /api/issues/{issueId}`

**Create issue**
- `POST /api/issues`
- Body: `{"project": {"id": "projectId"}, "summary": "Summary", "description": "Description"}`

**Update issue**
- `POST /api/issues/{issueId}`
- Body: `{"summary": "New summary", "description": "New description"}`

### Time Tracking

**Get work items for an issue**
- `GET /api/issues/{issueId}/timeTracking/workItems`
- Returns array of work items with:
  - `id`, `date`, `duration` (in minutes), `author`, `text`

**Work item structure:**
```json
{
  "id": "...",
  "date": "2026-01-15T10:30:00.000+0000",
  "duration": {"minutes": 30},
  "author": {"name": "User Name", "id": "..."},
  "text": "Work description",
  "type": {...}
}
```

### Knowledge Base (Articles)

**List articles**
- `GET /api/articles?project={projectId}`

**Get article**
- `GET /api/articles/{articleId}`

**Create article**
- `POST /api/articles`
- Body: `{"project": {"id": "projectId"}, "title": "Title", "content": "Content"}`

## Query Language Examples

```
project: MyProject
project: MyProject assignee: me
project: MyProject updated >= 2026-01-01
priority: Critical
has: time
```

**Note:** Date filtering in REST API uses `updated >= YYYY-MM-DD` format, not `updated: date ..` format.

## Field IDs Reference

Common custom field IDs (may vary by setup):
- Priority: `priority`
- State: `State`
- Assignee: `Assignee`
- Due Date: `Due Date`
- Type: `Type`

Check your instance's actual field IDs in YouTrack UI or via API.


path: scripts/invoice_generator.py
content: #!/usr/bin/env python3
"""
YouTrack Invoice Generator
Generates invoices from time tracking data in YouTrack projects.
"""

import os
import sys
import json
import argparse
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from youtrack_api import YouTrackAPI


class InvoiceGenerator:
    """Generate invoices from YouTrack time tracking data."""

    def __init__(self, api: YouTrackAPI, hourly_rate: float = 100.0):
        """
        Initialize invoice generator.

        Args:
            api: YouTrackAPI instance
            hourly_rate: Rate per hour (default $100)
        """
        self.api = api
        self.hourly_rate = hourly_rate
        self.rate_per_half_hour = hourly_rate / 2

    def get_project_time_data(self, project_id: str, from_date: Optional[str] = None) -> Dict[str, Any]:
        """
        Get time tracking data for a project.

        Args:
            project_id: Project ID
            from_date: Optional start date (not currently supported in REST API)

        Returns:
            Dictionary with time data per issue
        """
        # Build query for project issues
        # Note: Date filtering with 'updated:' syntax not supported in REST API
        # Use 'updated >= YYYY-MM-DD' format instead
        query = f'project: {project_id}'
        if from_date:
            query += f' updated >= {from_date}'

        issues = self.api.get_issues(query=query)

        project_data = {
            'project': None,
            'issues': [],
            'total_minutes': 0,
            'total_hours': 0,
            'total_cost': 0
        }

        # Get project info
        try:
            project_data['project'] = self.api.get_project(project_id)
        except:
            # Fallback: use project name from first issue
            if issues:
                project_data['project'] = {'name': issues[0].get('project', {}).get('name', project_id)}

        for issue in issues:
            issue_id = issue.get('id')
            issue_with_time = self.api.get_issue_with_work_items(issue_id)

            work_items = issue_with_time.get('workItems', [])
            total_minutes = sum(
                wi.get('duration', {}).get('minutes', 0)
                for wi in work_items
            )

            if total_minutes > 0:
                hours = total_minutes / 60
                cost = self._calculate_cost(total_minutes)

                project_data['issues'].append({
                    'id': issue_id,
                    'summary': issue.get('summary', 'No summary'),
                    'description': issue.get('description', ''),
                    'work_items': work_items,
                    'total_minutes': total_minutes,
                    'total_hours': hours,
                    'cost': cost
                })

                project_data['total_minutes'] += total_minutes
                project_data['total_cost'] += cost

        project_data['total_hours'] = project_data['total_minutes'] / 60

        return project_data

    def _calculate_cost(self, minutes: int) -> float:
        """
        Calculate cost based on time, billed in 30-minute increments.

        Args:
            minutes: Total minutes

        Returns:
            Total cost
        """
        # Convert to 30-minute increments, round up
        half_hour_units = (minutes + 29) // 30
        # Minimum 30 minutes (1 half-hour unit)
        half_hour_units = max(half_hour_units, 1)
        return half_hour_units * self.rate_per_half_hour

    def generate_invoice_text(self, project_data: Dict[str, Any], month: Optional[str] = None) -> str:
        """
        Generate invoice as plain text (can be printed to PDF).

        Args:
            project_data: Project data from get_project_time_data()
            month: Optional month label (e.g., "January 2026")

        Returns:
            Invoice text
        """
        project = project_data['project'] or {}
        project_name = project.get('name', 'Unknown Project')

        lines = []
        lines.append("=" * 70)
        lines.append(f"INVOICE - {project_name}")

        if month:
            lines.append(f"Period: {month}")

        lines.append("")
        lines.append("WORK ITEMS")
        lines.append("-" * 70)

        for issue in project_data['issues']:
            lines.append("")
            lines.append(f"Task: {issue['summary']}")
            lines.append(f"ID: {issue['id']}")

            if issue['description']:
                desc = issue['description'][:200] + "..." if len(issue['description']) > 200 else issue['description']
                lines.append(f"Description: {desc}")

            for wi in issue['work_items']:
                duration = wi.get('duration', {})
                mins = duration.get('minutes', 0)
                date = wi.get('date', '')
                author = wi.get('author', {}).get('name', 'Unknown')
                lines.append(f"  - {date}: {mins} min ({author})")

            lines.append(f"  Task total: {issue['total_hours']:.2f} hours (${issue['cost']:.2f})")

        lines.append("")
        lines.append("-" * 70)
        lines.append(f"TOTAL: {project_data['total_hours']:.2f} hours")
        lines.append(f"TOTAL COST: ${project_data['total_cost']:.2f}")
        lines.append("=" * 70)

        return "\n".join(lines)

    def generate_invoice_json(self, project_data: Dict[str, Any], month: Optional[str] = None) -> str:
        """
        Generate invoice as JSON for programmatic use.

        Args:
            project_data: Project data from get_project_time_data()
            month: Optional month label

        Returns:
            JSON string
        """
        invoice = {
            'project': project_data['project'],
            'period': month,
            'items': project_data['issues'],
            'summary': {
                'total_minutes': project_data['total_minutes'],
                'total_hours': project_data['total_hours'],
                'total_cost': project_data['total_cost']
            }
        }
        return json.dumps(invoice, indent=2)


def main():
    """CLI interface for generating invoices."""
    parser = argparse.ArgumentParser(description='YouTrack Invoice Generator')
    parser.add_argument('--url', required=True, help='YouTrack instance URL')
    parser.add_argument('--token', help='API token (or set YOUTRACK_TOKEN env var)')
    parser.add_argument('--project', required=True, help='Project ID to generate invoice for')
    parser.add_argument('--from-date', help='Start date (YYYY-MM-DD)')
    parser.add_argument('--month', help='Month label (e.g., "January 2026")')
    parser.add_argument('--rate', type=float, default=100.0, help='Hourly rate (default: 100)')
    parser.add_argument('--format', choices=['text', 'json'], default='text', help='Output format')

    args = parser.parse_args()

    try:
        api = YouTrackAPI(args.url, args.token)
        generator = InvoiceGenerator(api, hourly_rate=args.rate)

        project_data = generator.get_project_time_data(args.project, args.from_date)

        if args.format == 'text':
            output = generator.generate_invoice_text(project_data, args.month)
        else:
            output = generator.generate_invoice_json(project_data, args.month)

        print(output)

    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()


path: scripts/youtrack_api.py
content: #!/usr/bin/env python3
"""
YouTrack REST API Client
Handles authentication and basic API calls for YouTrack Cloud instances.
"""

import os
import sys
import json
import argparse
from urllib.parse import urljoin
from typing import Optional, Dict, List, Any
import urllib.request
import urllib.error
from datetime import datetime


class YouTrackAPI:
    """Simple YouTrack REST API client."""

    def __init__(self, base_url: str, [REDACTED_SECRET] = None):
        """
        Initialize YouTrack API client.

        Args:
            base_url: Your YouTrack instance URL (e.g., https://sl.youtrack.cloud)
            [REDACTED_SECRET] API token (or set YOUTRACK_TOKEN env var)
        """
        # Normalize base URL
        self.base_url = base_url.rstrip('/')
        self.token = token or os.environ.get('YOUTRACK_TOKEN')

        if not self.token:
            raise ValueError(
                "YouTrack token required. Set YOUTRACK_TOKEN env var or pass as argument."
            )

        # Set up headers with bearer token auth
        self.headers = {
            'Authorization': f'Bearer {self.token}',
            'Accept': 'application/json',
            'Content-Type': 'application/json'
        }

    def _make_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> Dict[str, Any]:
        """
        Make an authenticated API request.

        Args:
            method: HTTP method (GET, POST, PUT, DELETE)
            endpoint: API endpoint (e.g., '/api/issues')
            data: Request body for POST/PUT

        Returns:
            Parsed JSON response
        """
        url = urljoin(self.base_url, endpoint)

        req_data = None
        if data is not None:
            req_data = json.dumps(data).encode('utf-8')

        req = urllib.request.Request(
            url,
            data=req_data,
            headers=self.headers,
            method=method
        )

        try:
            with urllib.request.urlopen(req) as response:
                if response.status >= 400:
                    error_body = response.read().decode('utf-8')
                    raise RuntimeError(f"API Error {response.status}: {error_body}")

                result = response.read().decode('utf-8')
                if result:
                    return json.loads(result)
                return {}
        except urllib.error.HTTPError as e:
            error_body = e.read().decode('utf-8') if e.fp else ''
            raise RuntimeError(f"HTTP Error {e.code}: {error_body}")
        except Exception as e:
            raise RuntimeError(f"Request failed: {e}")

    # Projects

    def get_projects(self) -> List[Dict]:
        """Get all projects."""
        result = self._make_request('GET', '/api/admin/projects?fields=id,name,shortName')
        return result if isinstance(result, list) else []

    def get_project(self, project_id: str) -> Dict:
        """Get a specific project by ID."""
        return self._make_request('GET', f'/api/admin/projects/{project_id}?fields=id,name,shortName,description')

    # Issues

    def get_issues(self, query: Optional[str] = None, fields: str = 'id,summary,description,created,updated,project(id,name),customFields(name,value)') -> List[Dict]:
        """
        Get issues, optionally filtered by a query.

        Args:
            query: YouTrack query language (e.g., 'project: MyProject')
            fields: Comma-separated list of fields to return

        Returns:
            List of issues
        """
        params = {'fields': fields}
        if query:
            params['query'] = query

        # Build query string
        query_string = '&'.join(f'{k}={urllib.parse.quote(str(v))}' for k, v in params.items())
        endpoint = f'/api/issues?{query_string}'

        result = self._make_request('GET', endpoint)
        return result if isinstance(result, list) else []

    def get_issue(self, issue_id: str) -> Dict:
        """Get a specific issue by ID."""
        return self._make_request('GET', f'/api/issues/{issue_id}')

    def create_issue(self, project_id: str, summary: str, description: str = '') -> Dict:
        """
        Create a new issue.

        Args:
            project_id: Project ID or short name
            summary: Issue summary
            description: Issue description

        Returns:
            Created issue
        """
        data = {
            'project': {'id': project_id},
            'summary': summary,
            'description': description
        }
        return self._make_request('POST', '/api/issues', data)

    def update_issue(self, issue_id: str, summary: Optional[str] = None, description: Optional[str] = None) -> Dict:
        """Update an issue's summary and/or description."""
        data = {}
        if summary is not None:
            data['summary'] = summary
        if description is not None:
            data['description'] = description
        return self._make_request('POST', f'/api/issues/{issue_id}', data)

    # Time Tracking

    def get_work_items(self, issue_id: str) -> List[Dict]:
        """Get all work items (time entries) for an issue."""
        result = self._make_request('GET', f'/api/issues/{issue_id}/timeTracking/workItems?fields=id,date,duration(minutes),author(name),text')
        # Convert date from milliseconds to ISO format
        for wi in result:
            if 'date' in wi and wi['date']:
                wi['date'] = datetime.fromtimestamp(wi['date'] / 1000).isoformat()
        return result if isinstance(result, list) else []

    def get_issue_with_work_items(self, issue_id: str) -> Dict:
        """Get an issue with all its work items included."""
        issue = self.get_issue(issue_id)
        work_items = self.get_work_items(issue_id)
        issue['workItems'] = work_items
        return issue

    # Knowledge Base (Articles)

    def get_articles(self, project_id: Optional[str] = None) -> List[Dict]:
        """
        Get knowledge base articles.

        Args:
            project_id: Optional project ID to filter by

        Returns:
            List of articles
        """
        endpoint = '/api/articles'
        if project_id:
            endpoint += f'?project={project_id}'

        result = self._make_request('GET', endpoint)
        return result if isinstance(result, list) else []

    def get_article(self, article_id: str) -> Dict:
        """Get a specific article by ID."""
        return self._make_request('GET', f'/api/articles/{article_id}')

    def create_article(self, project_id: str, title: str, content: str) -> Dict:
        """
        Create a new knowledge base article.

        Args:
            project_id: Project ID
            title: Article title
            content: Article content

        Returns:
            Created article
        """
        data = {
            'project': {'id': project_id},
            'title': title,
            'content': content
        }
        return self._make_request('POST', '/api/articles', data)


def main():
    """CLI interface for testing the YouTrack API."""
    parser = argparse.ArgumentParser(description='YouTrack API Client')
    parser.add_argument('--url', required=True, help='YouTrack instance URL')
    parser.add_argument('--token', help='API token (or set YOUTRACK_TOKEN env var)')
    parser.add_argument('--list-projects', action='store_true', help='List all projects')
    parser.add_argument('--list-issues', help='List issues (optional query)')
    parser.add_argument('--get-issue', help='Get specific issue ID')
    parser.add_argument('--get-articles', action='store_true', help='List articles')

    args = parser.parse_args()

    try:
        api = YouTrackAPI(args.url, args.token)

        if args.list_projects:
            projects = api.get_projects()
            print(json.dumps(projects, indent=2))
        elif args.list_issues is not None:
            issues = api.get_issues(query=args.list_issues)
            print(json.dumps(issues, indent=2))
        elif args.get_issue:
            issue = api.get_issue_with_work_items(args.get_issue)
            print(json.dumps(issue, indent=2))
        elif args.get_articles:
            articles = api.get_articles()
            print(json.dumps(articles, indent=2))
        else:
            print("No action specified. Use --help for options.")
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)