Skill: dimos92/jira-issue-analyzer (version 1.0.0)

--- name: jira-issue-analyzer description: Jira 问题分析编排器。负责获取 issue 与附件、委托日志分析 subagent，并产出最终 Markdown 报告到本地目录。适用于用户要求生成 Jira 问题分析文档的场景。 --- # Jira Issue Analyzer ## 前置检查 1. Jira 脚本目录（已迁移到 Skill 内，不在项目目录）： - `~/.cursor/skills/jira-issue-analyzer/jira` 2. 初始化虚拟环境（必须）： - `cd ~/.cursor/skills/jira-issue-analyzer/jira` - `python3 -m venv .venv` - `.venv/bin/python -m pip install -r requirements.txt` 3. 配置： - `cp .env.example .env` - 填写 `JIRA_BASE_URL`、`JIRA_TOKEN` 4. 连接测试（必须使用 venv python）： - `.venv/bin/python main.py --test` ## 标准工作流 ### 1) 获取 issue 信息 ```bash cd ~/.cursor/skills/jira-issue-analyzer/jira .venv/bin/python main.py get <ISSUE_KEY_OR_URL> -f json ``` ### 2) 下载附件 ```bash cd ~/.cursor/skills/jira-issue-analyzer/jira .venv/bin/python main.py download <ISSUE_KEY_OR_URL> -d /tmp/jira_<ISSUE_KEY> ``` ### 3) 解压压缩包并递归处理 需要时解压压缩包，作为后续分析输入。 ### 4) 委托日志分析 subagent 日志分析与问题归因请直接委托个人 subagent： - `jira-log-analyst`（定义文件：`~/.cursor/agents/jira-log-analyst.md`） ### 5) 输出标准报告 按 [report-template.md](report-template.md) 结构输出。 ### 6) 报告落地到本地文件 将报告以 Markdown 文件保存到项目目录： - 目录：`<project>/.cursor/work/jira/` - 文件名建议：`<ISSUE_KEY>_analysis.md` 示例路径： - `/Users/zhangyu/FlutterProject/flutter_hiigge_app/.cursor/work/jira/PI2506-150_analysis.md` ### 7) 清理下载附件 报告生成并保存后，删除临时附件目录，避免磁盘堆积： - 例如删除 `/tmp/jira_<ISSUE_KEY>` - 必须在报告落盘完成后再执行 ## 快速命令参考 ```bash # 获取 issue 详情 cd ~/.cursor/skills/jira-issue-analyzer/jira .venv/bin/python main.py get HA-2560 -f json # 下载附件 .venv/bin/python main.py download HA-2560 -d /tmp/jira_HA-2560 ```

path: report-template.md
content: # Jira 问题分析报告模板

## 问题概述
- **Issue**: `<KEY>`
- **标题**: `<summary>`
- **状态/优先级**: `<status>/<priority>`
- **分析范围**: Jira 描述、评论、附件（日志/图片/压缩包）

## 现象与影响
- **用户现象**: `<用户可见问题>`
- **触发条件**: `<操作步骤/环境/时间段>`
- **影响范围**: `<版本/机型/地区/用户群>`

## 证据清单
- **Jira 描述**: `<关键句>`
- **Jira 评论**: `<关键句 + 评论时间/作者>`
- **日志证据**:
  - `<文件名>`: `<关键错误片段或关键词统计>`
- **图片证据**:
  - `<文件名>`: `<界面观察事实、错误文案>`
- **其它附件**:
  - `<文件名>`: `<用途或结果>`

## 根因假设（按置信度）
1. **[高/中/低] 假设 1**
   - 说明：`<为什么>`
   - 证据：`<对应日志/图片/Jira 字段>`
2. **[高/中/低] 假设 2**
   - 说明：`<为什么>`
   - 证据：`<对应日志/图片/Jira 字段>`

## 修复建议
- **短期修复**: `<可快速落地的修复>`
- **长期治理**: `<架构/监控/防回归建议>`
- **风险点**: `<修复引入风险与回滚点>`

## 验证方案
- **功能验证**: `<步骤与预期>`
- **回归范围**: `<受影响模块>`
- **日志验证**: `<关键日志点是否消失/新增>`
- **灰度与监控**: `<指标与告警>`

## 待补充信息
- `<缺失日志或时间段>`
- `<缺失截图或复现视频>`
- `<版本号/设备信息/网络环境>`

## 结论
- `<一句话结论>`
- `<下一步责任人与动作>`


path: README.md
content: # jira-issue-analyzer 分发包

本目录为 **Jira Issue Analyzer** Skill 的完整分发内容，与 Cursor 中的 `jira-issue-analyzer` 技能及 `jira-log-analyst` 子代理配套使用。

## 包内结构

| 路径 | 说明 |
|------|------|
| `jira-issue-analyzer-USAGE.md` | **安装与使用说明（请先读）** |
| `SKILL.md` | Cursor Skill 定义（工作流与命令） |
| `report-template.md` | 分析报告 Markdown 模板 |
| `agents/jira-log-analyst.md` | 日志分析子代理定义（Cursor Agent） |
| `jira/` | Python 脚本：拉取 Issue、下载附件等 |


path: jira/config.py
content: """Configuration loading module for Jira client."""

import os
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()


class Config:
    """Configuration class for Jira connection settings."""

    def __init__(self):
        self.jira_base_url = os.getenv('JIRA_BASE_URL', '')
        self.jira_token = os.getenv('JIRA_TOKEN', '')

    def validate(self) -> bool:
        """Validate that required configuration is present."""
        if not self.jira_base_url:
            print("Error: JIRA_BASE_URL is not set")
            return False
        if not self.jira_token:
            print("Error: JIRA_TOKEN is not set")
            return False
        return True

    @property
    def api_base(self) -> str:
        """Get the base API URL."""
        return f"{self.jira_base_url}/rest/api/2"


# Global config instance
config = Config()


path: jira/requirements.txt
content: requests>=2.28.0
python-dotenv>=1.0.0


path: jira/unzip_attachment.py
content: """Unzip the Jira attachment."""

import zipfile
import os

zip_file = "c52056b2-b788-47a9-9390-bc94045457b4_app_log.zip"
extract_dir = "app_log"

if not os.path.exists(zip_file):
    print(f"Error: {zip_file} not found")
    exit(1)

print(f"Extracting {zip_file}...")

with zipfile.ZipFile(zip_file, 'r') as zf:
    # Create extract directory
    os.makedirs(extract_dir, exist_ok=True)

    # Extract all files
    zf.extractall(extract_dir)

    # List extracted files
    print(f"\nExtracted to: {extract_dir}/")
    print("Files:")
    for name in zf.namelist():
        filepath = os.path.join(extract_dir, name)
        if os.path.isfile(filepath):
            size = os.path.getsize(filepath)
            print(f"  - {name} ({size:,} bytes)")

print("\nDone!")


path: jira/jira_client.py
content: """Jira API client with Bearer token authentication."""

import requests
from typing import Dict, List, Optional, Any
from config import config


class JiraClient:
    """Jira API client using Bearer token authentication."""

    def __init__(self):
        self.base_url = config.jira_base_url
        self.api_base = config.api_base
        self.session = requests.Session()
        self.session.trust_env = False  # Disable environment proxy settings
        self.session.headers.update({
            'Authorization': f'Bearer {config.jira_token}',
            'Content-Type': 'application/json',
            'Accept': 'application/json',
        })

    def test_connection(self) -> bool:
        """Test the connection to Jira server."""
        try:
            response = self.session.get(f"{self.api_base}/myself")
            if response.status_code == 200:
                user = response.json()
                print(f"Connected as: {user.get('displayName', 'Unknown')}")
                return True
            else:
                print(f"Connection failed: {response.status_code} - {response.text}")
                return False
        except requests.RequestException as e:
            print(f"Connection error: {e}")
            return False

    def search_issues(
        self,
        jql: str,
        fields: Optional[List[str]] = None,
        max_results: int = 100,
        start_at: int = 0
    ) -> Dict[str, Any]:
        params = {
            'jql': jql,
            'maxResults': max_results,
            'startAt': start_at,
        }
        if fields:
            params['fields'] = ','.join(fields)

        response = self.session.get(
            f"{self.api_base}/search",
            params=params
        )
        response.raise_for_status()
        return response.json()

    def get_issues_by_assignee(
        self,
        assignee: str,
        max_results: int = 100
    ) -> List[Dict[str, Any]]:
        jql = f'assignee = "{assignee}"'
        fields = [
            'summary', 'status', 'priority', 'assignee',
            'created', 'updated', 'issuetype', 'project'
        ]

        result = self.search_issues(jql=jql, fields=fields, max_results=max_results)

        issues = []
        for issue in result.get('issues', []):
            fields = issue.get('fields', {})
            issues.append({
                'key': issue.get('key'),
                'summary': fields.get('summary'),
                'status': fields.get('status', {}).get('name'),
                'priority': fields.get('priority', {}).get('name', 'None'),
                'assignee': fields.get('assignee', {}).get('displayName', 'Unassigned'),
                'assignee_email': fields.get('assignee', {}).get('emailAddress', ''),
                'created': fields.get('created'),
                'updated': fields.get('updated'),
                'issuetype': fields.get('issuetype', {}).get('name', ''),
                'project': fields.get('project', {}).get('name', ''),
                'url': f"{self.base_url}/browse/{issue.get('key')}",
            })

        return issues

    def get_issue(self, issue_key: str) -> Dict[str, Any]:
        response = self.session.get(
            f"{self.api_base}/issue/{issue_key}",
            params={'expand': 'attachments,comments'}
        )
        response.raise_for_status()
        return response.json()

    def get_issue_info(self, issue_key: str) -> Dict[str, Any]:
        issue = self.get_issue(issue_key)
        fields = issue.get('fields', {})

        return {
            'key': issue.get('key'),
            'id': issue.get('id'),
            'summary': fields.get('summary'),
            'description': fields.get('description'),
            'status': fields.get('status', {}).get('name'),
            'priority': fields.get('priority', {}).get('name', 'None'),
            'issuetype': fields.get('issuetype', {}).get('name', ''),
            'project': fields.get('project', {}).get('name', ''),
            'project_key': fields.get('project', {}).get('key', ''),
            'assignee': fields.get('assignee', {}).get('displayName', 'Unassigned'),
            'assignee_email': fields.get('assignee', {}).get('emailAddress', ''),
            'reporter': fields.get('reporter', {}).get('displayName', ''),
            'reporter_email': fields.get('reporter', {}).get('emailAddress', ''),
            'created': fields.get('created'),
            'updated': fields.get('updated'),
            'resolutiondate': fields.get('resolutiondate'),
            'duedate': fields.get('duedate'),
            'components': [c.get('name') for c in fields.get('components', [])],
            'labels': fields.get('labels', []),
            'url': f"{self.base_url}/browse/{issue.get('key')}",
            'attachments': [
                {
                    'id': a.get('id'),
                    'filename': a.get('filename'),
                    'size': a.get('size'),
                    'mimeType': a.get('mimeType'),
                    'content': a.get('content'),
                    'author': a.get('author', {}).get('displayName', ''),
                    'created': a.get('created'),
                }
                for a in fields.get('attachment', [])
            ],
            'comments': [
                {
                    'id': c.get('id'),
                    'author': c.get('author', {}).get('displayName', ''),
                    'body': c.get('body'),
                    'created': c.get('created'),
                    'updated': c.get('updated'),
                }
                for c in fields.get('comment', {}).get('comments', [])
            ],
            'raw_fields': fields,
        }

    def download_attachment(self, content_url: str, filename: str, dest_dir: str = '.') -> str:
        import os
        os.makedirs(dest_dir, exist_ok=True)
        filepath = os.path.join(dest_dir, filename)

        response = self.session.get(content_url, stream=True)
        response.raise_for_status()

        with open(filepath, 'wb') as f:
            for chunk in response.iter_content(chunk_size=8192):
                f.write(chunk)

        return filepath

    def download_issue_attachments(self, issue_key: str, dest_dir: str = None) -> List[str]:
        import os
        if dest_dir is None:
            dest_dir = os.path.join('attachments', issue_key)

        issue = self.get_issue_info(issue_key)
        downloaded = []

        for att in issue.get('attachments', []):
            print(f"Downloading: {att['filename']}...")
            filepath = self.download_attachment(
                att['content'],
                att['filename'],
                dest_dir
            )
            downloaded.append(filepath)
            print(f"  Saved: {filepath} ({os.path.getsize(filepath):,} bytes)")

        return downloaded


path: jira/README.md
content: # Jira Issue Fetcher (Skill 内置)

用于查询 Jira 问题与下载附件。该脚本已迁移到 Skill 目录，不再放在项目内。

## 目录

`~/.cursor/skills/jira-issue-analyzer/jira`

## 使用前准备（必须使用 venv）

```bash
cd ~/.cursor/skills/jira-issue-analyzer/jira
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
cp .env.example .env
```

编辑 `.env`：

```env
JIRA_BASE_URL=http://jira.banya.com.cn
JIRA_TOKEN=your_personal_access_token_here
```

## 命令示例

```bash
.venv/bin/python main.py --test
.venv/bin/python main.py get PI2506-150 -f json
.venv/bin/python main.py download PI2506-150 -d /tmp/jira_PI2506-150
```


path: jira/main.py
content: """Main entry point for Jira issue fetcher."""

import argparse
import json
import os
import re
from datetime import datetime
from typing import List, Dict, Any

from config import config
from jira_client import JiraClient


def format_datetime(dt_str: str) -> str:
    if not dt_str:
        return ''
    try:
        dt = datetime.strptime(dt_str, '%Y-%m-%dT%H:%M:%S.%f%z')
        return dt.strftime('%Y-%m-%d %H:%M')
    except ValueError:
        try:
            dt = datetime.strptime(dt_str, '%Y-%m-%dT%H:%M:%S.%f%z')
            return dt.strftime('%Y-%m-%d %H:%M')
        except ValueError:
            return dt_str


def format_size(size_bytes: int) -> str:
    if size_bytes < 1024:
        return f"{size_bytes} B"
    if size_bytes < 1024 * 1024:
        return f"{size_bytes / 1024:.1f} KB"
    return f"{size_bytes / 1024 / 1024:.2f} MB"


def extract_issue_key(input_str: str) -> str:
    # Support keys like HA-123 and PI2506-150.
    url_pattern = r'/browse/([A-Z][A-Z0-9]*-[0-9]+)'
    match = re.search(url_pattern, input_str, re.IGNORECASE)
    if match:
        return match.group(1).upper()

    key_pattern = r'^[A-Z][A-Z0-9]*-[0-9]+$'
    if re.match(key_pattern, input_str, re.IGNORECASE):
        return input_str.upper()

    raise ValueError(f"Invalid issue key or URL: {input_str}")


def print_table(issues: List[Dict[str, Any]]) -> None:
    if not issues:
        print("No issues found.")
        return

    columns = [
        ('Key', 12, 'key'),
        ('Status', 12, 'status'),
        ('Priority', 10, 'priority'),
        ('Summary', 50, 'summary'),
        ('Updated', 16, 'updated'),
    ]

    header = ' | '.join(f"{col[0]:<{col[1]}}" for col in columns)
    print(header)
    print('-' * len(header))

    for issue in issues:
        row = []
        for col in columns:
            value = issue.get(col[2], '')
            if col[2] in ('created', 'updated'):
                value = format_datetime(value)
            value = str(value)[:col[1]]
            row.append(f"{value:<{col[1]}}")
        print(' | '.join(row))

    print(f"\nTotal: {len(issues)} issues")


def print_json(data: Any) -> None:
    print(json.dumps(data, indent=2, ensure_ascii=False))


def print_issue_detail(issue: Dict[str, Any]) -> None:
    print(f"\n{'='*60}")
    print(f"[{issue['key']}] {issue['summary']}")
    print(f"{'='*60}")

    print("\n📋 基本信息")
    print(f"  状态:     {issue['status']}")
    print(f"  优先级:   {issue['priority']}")
    print(f"  类型:     {issue.get('issuetype', 'N/A')}")
    print(f"  项目:     {issue.get('project', 'N/A')} ({issue.get('project_key', '')})")
    if issue.get('components'):
        print(f"  组件:     {', '.join(issue['components'])}")
    if issue.get('labels'):
        print(f"  标签:     {', '.join(issue['labels'])}")

    print("\n👥 人员")
    print(f"  经办人:   {issue['assignee']} ({issue.get('assignee_email', '')})")
    print(f"  报告人:   {issue.get('reporter', '')} ({issue.get('reporter_email', '')})")

    print("\n📅 时间")
    print(f"  创建:     {format_datetime(issue['created'])}")
    print(f"  更新:     {format_datetime(issue['updated'])}")

    if issue.get('description'):
        print("\n📝 描述")
        print(f"  {issue['description'][:500]}{'...' if len(issue['description']) > 500 else ''}")

    if issue.get('comments'):
        print(f"\n💬 评论 ({len(issue['comments'])})")
        for c in issue['comments']:
            print(f"  [{format_datetime(c['created'])}] {c['author']}:")
            print(f"    {c['body'][:200]}{'...' if len(c['body']) > 200 else ''}")

    if issue.get('attachments'):
        print(f"\n📎 附件 ({len(issue['attachments'])})")
        for a in issue['attachments']:
            print(f"  - {a['filename']} ({format_size(a['size'])}) by {a['author']}")

    print("\n🔗 链接")
    print(f"  {issue['url']}")
    print()


def cmd_get(args):
    client = JiraClient()
    try:
        issue_key = extract_issue_key(args.issue)
        print(f"Fetching issue: {issue_key}\n")
        issue = client.get_issue_info(issue_key)
        if args.format == 'json':
            print_json(issue)
        else:
            print_issue_detail(issue)
        return 0
    except ValueError as e:
        print(f"Error: {e}")
        return 1
    except Exception as e:
        print(f"Error fetching issue: {e}")
        return 1


def cmd_download(args):
    client = JiraClient()
    try:
        issue_key = extract_issue_key(args.issue)
        print(f"Downloading attachments for: {issue_key}\n")
        dest_dir = args.dest or os.path.join('attachments', issue_key)
        downloaded = client.download_issue_attachments(issue_key, dest_dir)
        if downloaded:
            print(f"\nDownloaded {len(downloaded)} file(s) to: {dest_dir}")
        else:
            print("No attachments found.")
        return 0
    except ValueError as e:
        print(f"Error: {e}")
        return 1
    except Exception as e:
        print(f"Error downloading attachments: {e}")
        return 1


def cmd_list(args):
    client = JiraClient()
    print(f"Fetching issues for: {args.assignee}")
    print()
    try:
        issues = client.get_issues_by_assignee(
            args.assignee,
            max_results=args.max_results
        )
        if args.format == 'json':
            print_json(issues)
        elif args.format == 'detail':
            for issue in issues:
                print_issue_detail(issue)
            print(f"Total: {len(issues)} issues")
        else:
            print_table(issues)
        return 0
    except Exception as e:
        print(f"Error fetching issues: {e}")
        return 1


def main():
    parser = argparse.ArgumentParser(description='Jira issue fetcher')
    subparsers = parser.add_subparsers(dest='command', help='Commands')

    get_parser = subparsers.add_parser('get', help='Get a single issue by key or URL')
    get_parser.add_argument('issue')
    get_parser.add_argument('-f', '--format', choices=['detail', 'json'], default='detail')
    get_parser.set_defaults(func=cmd_get)

    download_parser = subparsers.add_parser('download', help='Download attachments for an issue')
    download_parser.add_argument('issue')
    download_parser.add_argument('-d', '--dest')
    download_parser.set_defaults(func=cmd_download)

    list_parser = subparsers.add_parser('list', help='List issues by assignee')
    list_parser.add_argument('-a', '--assignee', default='[REDACTED_SECRET]')
    list_parser.add_argument('-f', '--format', choices=['table', 'json', 'detail'], default='table')
    list_parser.add_argument('-m', '--max-results', type=int, default=100)
    list_parser.set_defaults(func=cmd_list)

    parser.add_argument('--test', action='store_true', help='Test connection only')
    args = parser.parse_args()

    if not config.validate():
        print("\nPlease create a .env file with your Jira credentials.")
        print("Copy .env.example to .env and fill in your token.")
        return 1

    client = JiraClient()
    if args.test:
        print("Testing connection to Jira...")
        if client.test_connection():
            print("Connection successful!")
            return 0
        print("Connection failed!")
        return 1

    if args.command and hasattr(args, 'func'):
        return args.func(args)

    parser.print_help()
    return 0


if __name__ == '__main__':
    exit(main())


path: agents/jira-log-analyst.md
content: ---
name: jira-log-analyst
description: Jira 日志分析专家。基于 App/设备日志/复现图片/复现视频/沟通记录做证据驱动分析、时间线构建、冲突识别与根因假设输出，用于分析 Jira问题。
---

你是 Jira 日志分析专家，专门处理“附件日志定位问题”场景。

工作目标：
1. 先输出结论，再补充证据。
2. 每条结论必须绑定证据来源（文件名、时间点、关键日志片段）。
3. 证据不足时必须明确写“待补充信息”，禁止给确定性结论。

分析流程：
1. 建立时间线：事件发生时间、首次异常、最后异常、关键恢复点。
2. 默认检索关键词：error、exception、crash、fatal、anr、timeout、failed、denied。
3. 同时核对设备侧与 App 侧证据；出现矛盾时标注“证据冲突”并说明两侧证据。
4. 对“无错误码但结果异常”场景，优先检查业务字段（如记录列表为空、统计值异常）。

输出格式（Markdown）：
- 问题概述
- 现象与影响
- 证据清单
- 根因假设（按置信度）
- 修复建议
- 验证方案
- 待补充信息

报告产物要求：
- 按 `~/.cursor/skills/jira-issue-analyzer/report-template.md` 结构输出。
- 最终报告必须落地为 Markdown 文件，目录为 `<project>/.cursor/work/jira/`。
- 文件名建议：`<ISSUE_KEY>_analysis.md`。
- 示例：`/Users/zhangyu/FlutterProject/flutter_hiigge_app/.cursor/work/jira/<ISSUE_KEY>_analysis.md`。

约束：
- 不用“可能/大概”堆砌结论；每个判断都要附证据。
- Jira 不可访问时，仅基于用户提供内容做初步分析，并明确限制。
- 附件为空时，仅分析 issue 描述和评论，输出最小报告。
- 日志不可读/损坏时，记录文件名并给出重传建议。
- 图片模糊时，输出“无法辨识项”，请求原图或更高分辨率截图。


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