一. 基本使用
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 账户以认证:
- 在终端运行:
shell
gh auth login
- 选择登录方式:
- 推荐选择 GitHub.com(除非使用 GitHub Enterprise)。
- 选择 HTTPS 或 SSH(HTTPS 更简单)。
- 选择 通过浏览器登录(会打开浏览器让你授权)。
- 按照提示在浏览器中登录并授权 GitHub CLI。
- 验证登录状态:
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 图片 
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""
# 匹配 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()