Skip to content

一. 基本使用

2. 理解 GitHub Issues 默认标签的含义

  • bug
    • 表示 Issue 描述了一个程序错误或功能异常。
    • 示例:软件崩溃、功能未按预期工作。
    • 用途:标记需要修复的问题,方便开发团队优先处理。
  • documentation
    • 表示 Issue 与文档相关,例如需要更新 README、API 文档或用户指南。
    • 示例:文档中有拼写错误、缺少安装说明。
    • 用途:标记与代码无关的文档改进任务。
  • duplicate
    • 表示该 Issue 是另一个已有 Issue 的重复。
    • 示例:用户报告了与已有 Issue 相同的问题。
    • 用途:标记后,通常会关闭此 Issue,并链接到原始 Issue。
  • enhancement
    • 表示 Issue 提议了一个新功能或对现有功能的改进。
    • 示例:建议添加暗黑模式、优化性能。
    • 用途:标记功能请求,供产品团队评估。
  • good first issue
    • 表示该 Issue 适合新手贡献者,难度较低,易于上手。
    • 示例:修复简单的 UI 问题、更新文档中的链接。
    • 用途:吸引开源社区新手参与项目。
  • help wanted
    • 表示项目维护者希望社区帮助解决该 Issue。
    • 示例:需要外部贡献者实现某个功能或修复 bug。
    • 用途:标记需要社区协助的任务。
  • invalid
    • 表示 Issue 不符合项目要求或无法重现。
    • 示例:用户提交了不相关的问题或误解了功能。
    • 用途:标记后,通常会关闭 Issue。
  • question
    • 表示 Issue 是一个问题或需要澄清,而不是具体的任务。
    • 示例:用户询问如何使用某个功能。
    • 用途:标记讨论或支持请求,区分于 bug 或功能请求。
  • wontfix
    • 表示该 Issue 不会被修复或实现。
    • 示例:提议的功能与项目目标不符,或 bug 影响较小。
    • 用途:标记后,通常关闭 Issue,说明不予处理的原因。

1. 导出Github Issues内容

我最喜欢的工作方式是通过一个类似于Github Issues的任务单号来跟踪功能或BUG,然后用一个多维表格对其做汇总。此前我的工单都是通过Notion笔记或Markdown文件手动创建的,而近期我都在发布或维护开源项目,因此准备更换为Github Issues,这样做的好处是:

  • 项目代码和工单记录都在一个地方,方便查找;
  • 内容结构化,方便做筛选和分类;
  • 可以用Github的API自动导出内容,方便做数据分析。

后续

实际使用发现Github Issues的编辑非常不方便,因此后面我还是回归了Notion。

不过我还是担心Github存在被禁的风险,所以我研究了一下Github Issues的导出方法,其大概的流程如下:

1.1 安装 GitHub CLI

GitHub CLI 是一个跨平台的命令行工具,支持 Windows、macOS 和 Linux。其中我更关注Windows下的使用,以下是安装步骤:

  • 手动安装:
    • 访问 GitHub CLI 发布页面,下载最新的 .msi 文件(如 gh_X.X.X_windows_amd64.msi)。
    • 双击运行安装程序,按照提示完成安装。
  • 验证安装: 打开命令提示符或 PowerShell,运行:
shell
gh --version

如果显示版本号(如 gh version X.X.X),则安装成功。

1.2 配置 GitHub CLI

安装完成后,需要登录 GitHub 账户以认证:

  1. 在终端运行:
shell
gh auth login
  1. 选择登录方式:
    • 推荐选择 GitHub.com(除非使用 GitHub Enterprise)。
    • 选择 HTTPS 或 SSH(HTTPS 更简单)。
    • 选择 通过浏览器登录(会打开浏览器让你授权)。
  2. 按照提示在浏览器中登录并授权 GitHub CLI。
  3. 验证登录状态:
shell
gh auth status

应显示已登录的账户信息。

1.3 导出Github Issues内容

后续的脚本都是使用大模型生成的。

1. 导出为CSV的Python脚本

点击查看代码
Python
import subprocess
import json
import csv
import re
from datetime import datetime

def run_gh_command(command):
    """执行 GitHub CLI 命令并返回 JSON 结果"""
    try:
        result = subprocess.run(command, capture_output=True, text=True, encoding='utf-8', check=True)
        if result.stdout.strip():  # 确保 stdout 不为空
            return json.loads(result.stdout)
        else:
            print(f"Command returned empty output: {command}")
            return []
    except subprocess.CalledProcessError as e:
        print(f"Error running command {command}: {e.stderr}")
        return []
    except json.JSONDecodeError as e:
        print(f"Error decoding JSON: {e}")
        return []

def clean_csv_field(text):
    """清理 CSV 字段,移除换行符并转义双引号"""
    if not isinstance(text, str):
        text = str(text)
    text = re.sub(r'\n|\r', ' ', text)  # 替换换行符为空格
    text = text.replace('"', '""')  # 转义双引号
    return text

def get_issues(repo, state='all', per_page=100):
    """获取所有 Issues,支持分页"""
    issues = []
    fetched_count = 0
    while True:
        command = [
            'gh', 'issue', 'list', '--repo', repo, '--state', state,
            '--limit', str(per_page),
            '--json', 'number,title,body,state,assignees,createdAt,updatedAt,closedAt,url'
        ]
        page_issues = run_gh_command(command)
        if not page_issues:
            break
        issues.extend(page_issues)
        fetched_count += len(page_issues)
        if len(page_issues) < per_page:
            # 如果返回的数量小于每页限制,说明已经到最后一页
            break
    return issues

def get_comments(repo, issue_number):
    """获取指定 Issue 的评论"""
    command = [
        'gh', 'api', f'repos/{repo}/issues/{issue_number}/comments',
        '--jq', '[.[] | {user: .user.login, body: .body, createdAt: .created_at}]'
    ]
    return run_gh_command(command)

def export_to_csv(repo, output_file):
    """导出 Issues 和评论到 CSV 文件"""
    # 获取 Issues
    issues = get_issues(repo)
    if not issues:
        print("No issues found or error occurred.")
        return

    # 准备 CSV 文件
    headers = [
        'Number', 'Title', 'Body', 'State', 'Assignees', 'CreatedAt',
        'UpdatedAt', 'ClosedAt', 'URL', 'CommentAuthor', 'CommentBody', 'CommentCreatedAt'
    ]
    rows = []

    # 处理每个 Issue
    for issue in issues:
        assignees = issue.get('assignees', [])
        assignees_str = ', '.join([a['login'] for a in assignees]) if assignees else 'Unassigned'
        
        # 获取评论
        comments = get_comments(repo, issue['number'])
        
        # 如果没有评论,添加 Issue 信息
        if not comments:
            rows.append([
                issue['number'],
                clean_csv_field(issue['title']),
                clean_csv_field(issue.get('body', '')),
                issue['state'],
                clean_csv_field(assignees_str),
                issue['createdAt'],
                issue['updatedAt'],
                issue.get('closedAt', ''),
                issue['url'],
                '', '', ''  # 空评论字段
            ])
        else:
            # 为每个评论添加一行
            for comment in comments:
                rows.append([
                    issue['number'],
                    clean_csv_field(issue['title']),
                    clean_csv_field(issue.get('body', '')),
                    issue['state'],
                    clean_csv_field(assignees_str),
                    issue['createdAt'],
                    issue['updatedAt'],
                    issue.get('closedAt', ''),
                    issue['url'],
                    clean_csv_field(comment['user']),
                    clean_csv_field(comment['body']),
                    comment['createdAt']
                ])

    # 写入 CSV 文件
    with open(output_file, 'w', newline='', encoding='utf-8') as f:
        writer = csv.writer(f, quoting=csv.QUOTE_ALL)
        writer.writerow(headers)
        writer.writerows(rows)
    
    print(f"Issues and comments exported to {output_file}")

def main():
    # 配置
    repo = input("Enter the repository (format: owner/repo): ").strip()
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    output_file = f'issues_comments_{timestamp}.csv'
    
    # 执行导出
    export_to_csv(repo, output_file)

if __name__ == '__main__':
    main()

对于CSV中可能出现的中文乱码问题,可以进行如下操作:

  • 选择工具栏中的“数据”;
  • 选择其中的“自文本”,分隔符选择“逗号”;
  • 最终生成即可。 注意,由于最终生成的内容是插入的方式,因此需要将原先的内容删除掉。

2. 导出为Markdown的Python脚本

点击查看代码
Python
import subprocess
import json
import os
import re
import urllib.request
import urllib.error
from datetime import datetime
from pathlib import Path

def run_gh_command(command):
    """执行 GitHub CLI 命令并返回 JSON 结果"""
    try:
        result = subprocess.run(command, capture_output=True, text=True, encoding='utf-8', check=True)
        if result.stdout.strip():  # 确保 stdout 不为空
            return json.loads(result.stdout)
        else:
            print(f"Command returned empty output: {command}")
            return []
    except subprocess.CalledProcessError as e:
        print(f"Error running command {command}: {e.stderr}")
        return []
    except json.JSONDecodeError as e:
        print(f"Error decoding JSON: {e}")
        return []

def sanitize_filename(text):
    """清理文件名,移除非法字符"""
    text = re.sub(r'[<>:"/\\|?*]', '', text)
    text = re.sub(r'\s+', '_', text.strip())
    return text[:50]  # 限制文件名长度

def download_image(url, output_dir, image_name):
    """下载图片并返回本地路径"""
    try:
        output_path = output_dir / image_name
        urllib.request.urlretrieve(url, output_path)
        return f"images/{image_name}"
    except urllib.error.URLError as e:
        print(f"Failed to download image {url}: {e}")
        return url  # 下载失败时保留原 URL

def process_images(content, issue_number, output_dir):
    """处理 Markdown 或 HTML 中的图片,下载并替换为本地路径"""
    if not content:
        return content
    images_dir = output_dir / "images"
    images_dir.mkdir(exist_ok=True)
    
    # 匹配 Markdown 图片 ![alt](url)
    def replace_md_image(match):
        url = match.group(2)
        alt = match.group(1) or "image"
        ext = os.path.splitext(url)[1] or ".png"
        image_name = f"issue_{issue_number}_{len(images_dir.glob('*')) + 1}{ext}"
        local_path = download_image(url, images_dir, image_name)
        return f"![{alt}]({local_path})"
    
    # 匹配 HTML <img> 标签
    def replace_html_image(match):
        url = match.group(1)
        ext = os.path.splitext(url)[1] or ".png"
        image_name = f"issue_{issue_number}_{len(images_dir.glob('*')) + 1}{ext}"
        local_path = download_image(url, images_dir, image_name)
        return f'<img src="{local_path}" alt="image">'
    
    content = re.sub(r'!\[(.*?)\]\((.*?)\)', replace_md_image, content)
    content = re.sub(r'<img[^>]+src=["\'](.*?)["\'][^>]*>', replace_html_image, content)
    return content

def get_issues(repo, state='all', per_page=100):
    """获取所有 Issues,支持分页"""
    issues = []
    fetched_count = 0
    while True:
        command = [
            'gh', 'issue', 'list', '--repo', repo, '--state', state,
            '--limit', str(per_page),
            '--json', 'number,title,body,state,assignees,createdAt,updatedAt,closedAt,url'
        ]
        page_issues = run_gh_command(command)
        if not page_issues:
            break
        issues.extend(page_issues)
        fetched_count += len(page_issues)
        if len(page_issues) < per_page:
            # 如果返回的数量小于每页限制,说明已经到最后一页
            break
    return issues

def get_comments(repo, issue_number):
    """获取指定 Issue 的评论"""
    command = [
        'gh', 'api', f'repos/{repo}/issues/{issue_number}/comments',
        '--jq', '[.[] | {user: .user.login, body: .body, createdAt: .created_at}]'
    ]
    return run_gh_command(command)

def export_to_markdown(repo, output_dir):
    """导出 Issues 和评论到单独的 Markdown 文件"""
    output_path = Path(output_dir)
    output_path.mkdir(exist_ok=True)
    
    issues = get_issues(repo)
    if not issues:
        print("No issues found or error occurred.")
        return
    
    for issue in issues:
        number = issue['number']
        title = issue['title'] or "Untitled"
        assignees = issue.get('assignees', [])
        assignees_str = ', '.join([a['login'] for a in assignees]) if assignees else 'Unassigned'
        body = issue.get('body', '')
        
        # 处理正文中的图片
        body = process_images(body, number, output_path)
        
        # 获取评论
        comments = get_comments(repo, number)
        
        # 构建 Markdown 内容
        md_content = []
        md_content.append(f"# Issue #{number}: {title}\n")
        md_content.append(f"**State**: {issue['state']}")
        md_content.append(f"**Assignees**: {assignees_str}")
        md_content.append(f"**Created At**: {issue['createdAt']}")
        md_content.append(f"**Updated At**: {issue['updatedAt']}")
        if issue.get('closedAt'):
            md_content.append(f"**Closed At**: {issue.get('closedAt')}")
        md_content.append(f"**URL**: {issue['url']}\n")
        md_content.append(f"## Description\n{body}\n")
        
        if comments:
            md_content.append("## Comments\n")
            for comment in comments:
                comment_body = process_images(comment['body'], number, output_path)
                md_content.append(f"### {comment['user']} commented at {comment['createdAt']}\n{comment_body}\n")
        
        # 保存 Markdown 文件
        filename = f"issue_{number}_{sanitize_filename(title)}.md"
        with open(output_path / filename, 'w', encoding='utf-8') as f:
            f.write('\n'.join(md_content))
        print(f"Exported Issue #{number} to {filename}")

def main():
    repo = input("Enter the repository (format: owner/repo): ").strip()
    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    output_dir = f"issues_markdown_{timestamp}"
    export_to_markdown(repo, output_dir)
    print(f"All issues exported to {output_dir}")

if __name__ == '__main__':
    main()