Created
April 10, 2026 03:20
-
-
Save starius/80925275c7d352e8c5ebd06d558659ba to your computer and use it in GitHub Desktop.
Save all issues and discussions from GitHub repo using gh tool
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| usage() { | |
| cat <<'EOF' | |
| Usage: gh-discussions-save.sh OWNER/REPO | |
| Fetch all discussions, top-level comments, and threaded replies with GitHub | |
| CLI and save one Markdown file per discussion in the current directory. | |
| Examples: | |
| ./gh-discussions-save.sh cli/cli | |
| ./gh-discussions-save.sh https://github.com/cli/cli | |
| Requirements: | |
| - gh | |
| - jq | |
| - an authenticated gh session (`gh auth login`) | |
| EOF | |
| } | |
| die() { | |
| printf 'Error: %s\n' "$*" >&2 | |
| exit 1 | |
| } | |
| need_cmd() { | |
| command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" | |
| } | |
| normalize_repo() { | |
| local repo="$1" | |
| repo="${repo#https://github.com/}" | |
| repo="${repo#http://github.com/}" | |
| repo="${repo#github.com/}" | |
| repo="${repo%/}" | |
| case "$repo" in | |
| */*) | |
| printf '%s\n' "$repo" | |
| ;; | |
| *) | |
| return 1 | |
| ;; | |
| esac | |
| } | |
| slugify() { | |
| local input="$1" | |
| local slug | |
| slug="$( | |
| printf '%s' "$input" | | |
| tr '[:upper:]' '[:lower:]' | | |
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g' | |
| )" | |
| slug="${slug:0:80}" | |
| if [[ -z "$slug" ]]; then | |
| slug="untitled" | |
| fi | |
| printf '%s\n' "$slug" | |
| } | |
| safe_token() { | |
| local input="$1" | |
| local token | |
| token="$( | |
| printf '%s' "$input" | | |
| sed -E 's/[^A-Za-z0-9._-]+/-/g; s/^-+//; s/-+$//; s/-+/-/g' | |
| )" | |
| token="${token:0:120}" | |
| if [[ -z "$token" ]]; then | |
| token="item" | |
| fi | |
| printf '%s\n' "$token" | |
| } | |
| render_list_line() { | |
| local label="$1" | |
| local value="$2" | |
| if [[ -n "$value" ]]; then | |
| printf -- '- %s: %s\n' "$label" "$value" | |
| fi | |
| } | |
| DISCUSSIONS_QUERY="$(cat <<'EOF' | |
| query($owner: String!, $repo: String!, $endCursor: String) { | |
| repository(owner: $owner, name: $repo) { | |
| discussions(first: 100, after: $endCursor, orderBy: {field: CREATED_AT, direction: ASC}) { | |
| nodes { | |
| id | |
| number | |
| title | |
| body | |
| url | |
| createdAt | |
| updatedAt | |
| isAnswered | |
| answerChosenAt | |
| locked | |
| author { | |
| login | |
| } | |
| authorAssociation | |
| category { | |
| name | |
| isAnswerable | |
| } | |
| comments { | |
| totalCount | |
| } | |
| } | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| } | |
| } | |
| } | |
| EOF | |
| )" | |
| COMMENTS_QUERY="$(cat <<'EOF' | |
| query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) { | |
| repository(owner: $owner, name: $repo) { | |
| discussion(number: $number) { | |
| comments(first: 100, after: $endCursor) { | |
| nodes { | |
| id | |
| databaseId | |
| url | |
| body | |
| createdAt | |
| updatedAt | |
| isAnswer | |
| author { | |
| login | |
| } | |
| authorAssociation | |
| } | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| } | |
| } | |
| } | |
| } | |
| EOF | |
| )" | |
| REPLIES_QUERY="$(cat <<'EOF' | |
| query($id: ID!, $endCursor: String) { | |
| node(id: $id) { | |
| ... on DiscussionComment { | |
| replies(first: 100, after: $endCursor) { | |
| nodes { | |
| id | |
| url | |
| body | |
| createdAt | |
| updatedAt | |
| isAnswer | |
| author { | |
| login | |
| } | |
| authorAssociation | |
| } | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| } | |
| } | |
| } | |
| } | |
| EOF | |
| )" | |
| if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then | |
| usage | |
| exit 0 | |
| fi | |
| if [[ $# -ne 1 ]]; then | |
| usage >&2 | |
| exit 1 | |
| fi | |
| need_cmd gh | |
| need_cmd jq | |
| repo="$(normalize_repo "$1")" || die "Repository must look like OWNER/REPO or https://github.com/OWNER/REPO" | |
| owner="${repo%%/*}" | |
| repo_name="${repo#*/}" | |
| gh auth status >/dev/null 2>&1 || die "gh is not authenticated. Run: gh auth login" | |
| workdir="$(pwd)" | |
| tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/gh-discussions-save.XXXXXX")" | |
| trap 'rm -rf "$tmpdir"' EXIT | |
| discussions_pages="$tmpdir/discussions-pages.json" | |
| discussions_file="$tmpdir/discussions.json" | |
| comments_dir="$tmpdir/comments-by-discussion" | |
| replies_dir="$tmpdir/replies-by-comment" | |
| mkdir -p "$comments_dir" "$replies_dir" | |
| printf 'Fetching discussions from %s...\n' "$repo" >&2 | |
| gh api graphql --paginate --slurp \ | |
| -F owner="$owner" \ | |
| -F repo="$repo_name" \ | |
| -f query="$DISCUSSIONS_QUERY" > "$discussions_pages" | |
| jq '[.[].data.repository.discussions.nodes[]?] | sort_by(.number)' \ | |
| "$discussions_pages" > "$discussions_file" | |
| discussion_count="$(jq 'length' "$discussions_file")" | |
| printf 'Found %s discussions. Fetching threads...\n' "$discussion_count" >&2 | |
| total_comments=0 | |
| total_replies=0 | |
| while IFS= read -r discussion_json; do | |
| number="$(jq -r '.number' <<<"$discussion_json")" | |
| comments_pages="$tmpdir/discussion-$number-comments-pages.json" | |
| comments_file="$comments_dir/$number.json" | |
| printf 'Fetching comments for discussion #%s...\n' "$number" >&2 | |
| gh api graphql --paginate --slurp \ | |
| -F owner="$owner" \ | |
| -F repo="$repo_name" \ | |
| -F number="$number" \ | |
| -f query="$COMMENTS_QUERY" > "$comments_pages" | |
| jq '[.[].data.repository.discussion.comments.nodes[]?] | sort_by(.createdAt)' \ | |
| "$comments_pages" > "$comments_file" | |
| comments_in_discussion="$(jq 'length' "$comments_file")" | |
| total_comments="$((total_comments + comments_in_discussion))" | |
| while IFS= read -r comment_json; do | |
| comment_id="$(jq -r '.id' <<<"$comment_json")" | |
| comment_dbid="$(jq -r '.databaseId // empty' <<<"$comment_json")" | |
| comment_key="$comment_dbid" | |
| if [[ -z "$comment_key" ]]; then | |
| comment_key="$(safe_token "$comment_id")" | |
| fi | |
| reply_pages="$tmpdir/${number}-${comment_key}-replies-pages.json" | |
| reply_file="$replies_dir/$comment_key.json" | |
| gh api graphql --paginate --slurp \ | |
| -F id="$comment_id" \ | |
| -f query="$REPLIES_QUERY" > "$reply_pages" | |
| jq '[.[].data.node.replies.nodes[]?] | sort_by(.createdAt)' \ | |
| "$reply_pages" > "$reply_file" | |
| replies_in_comment="$(jq 'length' "$reply_file")" | |
| total_replies="$((total_replies + replies_in_comment))" | |
| done < <(jq -c '.[]' "$comments_file") | |
| done < <(jq -c '.[]' "$discussions_file") | |
| printf 'Writing %s discussion files to %s...\n' "$discussion_count" "$workdir" >&2 | |
| while IFS= read -r discussion_json; do | |
| number="$(jq -r '.number' <<<"$discussion_json")" | |
| title="$(jq -r '.title' <<<"$discussion_json")" | |
| body="$(jq -r '.body // empty' <<<"$discussion_json")" | |
| url="$(jq -r '.url // empty' <<<"$discussion_json")" | |
| created_at="$(jq -r '.createdAt // empty' <<<"$discussion_json")" | |
| updated_at="$(jq -r '.updatedAt // empty' <<<"$discussion_json")" | |
| author="$(jq -r '.author.login // "unknown"' <<<"$discussion_json")" | |
| author_association="$(jq -r '.authorAssociation // empty' <<<"$discussion_json")" | |
| category="$(jq -r '.category.name // empty' <<<"$discussion_json")" | |
| answered="$(jq -r '.isAnswered' <<<"$discussion_json")" | |
| answer_chosen_at="$(jq -r '.answerChosenAt // empty' <<<"$discussion_json")" | |
| locked="$(jq -r '.locked' <<<"$discussion_json")" | |
| comments_total="$(jq -r '.comments.totalCount // 0' <<<"$discussion_json")" | |
| comments_file="$comments_dir/$number.json" | |
| file_name="discussion-${number}-$(slugify "$title").md" | |
| { | |
| printf '# %s\n\n' "$title" | |
| printf '> Exported from `%s`.\n\n' "$repo" | |
| printf -- '- Discussion: #%s\n' "$number" | |
| render_list_line "Category" "$category" | |
| render_list_line "Author" "$author" | |
| render_list_line "Author association" "$author_association" | |
| render_list_line "Created" "$created_at" | |
| render_list_line "Updated" "$updated_at" | |
| render_list_line "URL" "$url" | |
| printf -- '- Answered: %s\n' "$answered" | |
| if [[ -n "$answer_chosen_at" ]]; then | |
| render_list_line "Answer chosen" "$answer_chosen_at" | |
| fi | |
| printf -- '- Locked: %s\n' "$locked" | |
| printf -- '- Comments: %s\n' "$comments_total" | |
| printf '\n## Body\n\n' | |
| if [[ -n "$body" ]]; then | |
| printf '%s\n' "$body" | |
| else | |
| printf '_No body._\n' | |
| fi | |
| printf '\n## Comments\n\n' | |
| if [[ ! -f "$comments_file" ]] || [[ "$(jq 'length' "$comments_file")" -eq 0 ]]; then | |
| printf '_No comments._\n' | |
| else | |
| comment_index=0 | |
| while IFS= read -r comment_json; do | |
| comment_index="$((comment_index + 1))" | |
| comment_id="$(jq -r '.id' <<<"$comment_json")" | |
| comment_dbid="$(jq -r '.databaseId // empty' <<<"$comment_json")" | |
| comment_key="$comment_dbid" | |
| if [[ -z "$comment_key" ]]; then | |
| comment_key="$(safe_token "$comment_id")" | |
| fi | |
| comment_author="$(jq -r '.author.login // "unknown"' <<<"$comment_json")" | |
| comment_assoc="$(jq -r '.authorAssociation // empty' <<<"$comment_json")" | |
| comment_created="$(jq -r '.createdAt // empty' <<<"$comment_json")" | |
| comment_updated="$(jq -r '.updatedAt // empty' <<<"$comment_json")" | |
| comment_url="$(jq -r '.url // empty' <<<"$comment_json")" | |
| comment_body="$(jq -r '.body // empty' <<<"$comment_json")" | |
| is_answer="$(jq -r '.isAnswer' <<<"$comment_json")" | |
| reply_file="$replies_dir/$comment_key.json" | |
| printf '### Comment %s by %s\n\n' "$comment_index" "$comment_author" | |
| render_list_line "Created" "$comment_created" | |
| if [[ -n "$comment_updated" && "$comment_updated" != "$comment_created" ]]; then | |
| render_list_line "Updated" "$comment_updated" | |
| fi | |
| render_list_line "Author association" "$comment_assoc" | |
| render_list_line "URL" "$comment_url" | |
| if [[ "$is_answer" == "true" ]]; then | |
| printf -- '- Marked as answer: true\n' | |
| fi | |
| printf '\n' | |
| if [[ -n "$comment_body" ]]; then | |
| printf '%s\n' "$comment_body" | |
| else | |
| printf '_No body._\n' | |
| fi | |
| if [[ -f "$reply_file" ]] && [[ "$(jq 'length' "$reply_file")" -gt 0 ]]; then | |
| printf '\n#### Replies\n\n' | |
| reply_index=0 | |
| while IFS= read -r reply_json; do | |
| reply_index="$((reply_index + 1))" | |
| reply_author="$(jq -r '.author.login // "unknown"' <<<"$reply_json")" | |
| reply_assoc="$(jq -r '.authorAssociation // empty' <<<"$reply_json")" | |
| reply_created="$(jq -r '.createdAt // empty' <<<"$reply_json")" | |
| reply_updated="$(jq -r '.updatedAt // empty' <<<"$reply_json")" | |
| reply_url="$(jq -r '.url // empty' <<<"$reply_json")" | |
| reply_body="$(jq -r '.body // empty' <<<"$reply_json")" | |
| reply_is_answer="$(jq -r '.isAnswer' <<<"$reply_json")" | |
| printf '##### Reply %s by %s\n\n' "$reply_index" "$reply_author" | |
| render_list_line "Created" "$reply_created" | |
| if [[ -n "$reply_updated" && "$reply_updated" != "$reply_created" ]]; then | |
| render_list_line "Updated" "$reply_updated" | |
| fi | |
| render_list_line "Author association" "$reply_assoc" | |
| render_list_line "URL" "$reply_url" | |
| if [[ "$reply_is_answer" == "true" ]]; then | |
| printf -- '- Marked as answer: true\n' | |
| fi | |
| printf '\n' | |
| if [[ -n "$reply_body" ]]; then | |
| printf '%s\n' "$reply_body" | |
| else | |
| printf '_No body._\n' | |
| fi | |
| printf '\n' | |
| done < <(jq -c '.[]' "$reply_file") | |
| fi | |
| printf '\n' | |
| done < <(jq -c '.[]' "$comments_file") | |
| fi | |
| } > "$workdir/$file_name" | |
| done < <(jq -c '.[]' "$discussions_file") | |
| printf 'Done. Wrote %s Markdown files with %s comments and %s replies into %s.\n' \ | |
| "$discussion_count" "$total_comments" "$total_replies" "$workdir" >&2 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| usage() { | |
| cat <<'EOF' | |
| Usage: gh-issues-save.sh OWNER/REPO | |
| Fetch all non-PR issues and their comments with GitHub CLI and save one | |
| Markdown file per issue in the current directory. | |
| Examples: | |
| ./gh-issues-save.sh cli/cli | |
| ./gh-issues-save.sh https://github.com/cli/cli | |
| Requirements: | |
| - gh | |
| - jq | |
| - an authenticated gh session (`gh auth login`) | |
| EOF | |
| } | |
| die() { | |
| printf 'Error: %s\n' "$*" >&2 | |
| exit 1 | |
| } | |
| need_cmd() { | |
| command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" | |
| } | |
| normalize_repo() { | |
| local repo="$1" | |
| repo="${repo#https://github.com/}" | |
| repo="${repo#http://github.com/}" | |
| repo="${repo#github.com/}" | |
| repo="${repo%/}" | |
| case "$repo" in | |
| */*) | |
| printf '%s\n' "$repo" | |
| ;; | |
| *) | |
| return 1 | |
| ;; | |
| esac | |
| } | |
| slugify() { | |
| local input="$1" | |
| local slug | |
| slug="$( | |
| printf '%s' "$input" | | |
| tr '[:upper:]' '[:lower:]' | | |
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g' | |
| )" | |
| slug="${slug:0:80}" | |
| if [[ -z "$slug" ]]; then | |
| slug="untitled" | |
| fi | |
| printf '%s\n' "$slug" | |
| } | |
| render_list_line() { | |
| local label="$1" | |
| local value="$2" | |
| if [[ -n "$value" ]]; then | |
| printf -- '- %s: %s\n' "$label" "$value" | |
| fi | |
| } | |
| if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then | |
| usage | |
| exit 0 | |
| fi | |
| if [[ $# -ne 1 ]]; then | |
| usage >&2 | |
| exit 1 | |
| fi | |
| need_cmd gh | |
| need_cmd jq | |
| repo="$(normalize_repo "$1")" || die "Repository must look like OWNER/REPO or https://github.com/OWNER/REPO" | |
| gh auth status >/dev/null 2>&1 || die "gh is not authenticated. Run: gh auth login" | |
| workdir="$(pwd)" | |
| tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/gh-issues-save.XXXXXX")" | |
| trap 'rm -rf "$tmpdir"' EXIT | |
| issues_pages="$tmpdir/issues-pages.json" | |
| issues_file="$tmpdir/issues.json" | |
| comments_pages="$tmpdir/comments-pages.json" | |
| comments_file="$tmpdir/comments.json" | |
| comments_dir="$tmpdir/comments-by-issue" | |
| mkdir -p "$comments_dir" | |
| printf 'Fetching issues from %s...\n' "$repo" >&2 | |
| gh api --paginate --slurp \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "repos/$repo/issues?state=all&per_page=100" > "$issues_pages" | |
| jq '[.[][]? | select(.pull_request | not)] | sort_by(.number)' \ | |
| "$issues_pages" > "$issues_file" | |
| printf 'Fetching issue comments from %s...\n' "$repo" >&2 | |
| gh api --paginate --slurp \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "repos/$repo/issues/comments?per_page=100" > "$comments_pages" | |
| jq '[.[][]?] | sort_by(.issue_url, .created_at)' \ | |
| "$comments_pages" > "$comments_file" | |
| while IFS= read -r comment_group; do | |
| issue_url="$(jq -r '.[0].issue_url' <<<"$comment_group")" | |
| issue_number="${issue_url##*/}" | |
| printf '%s\n' "$comment_group" > "$comments_dir/$issue_number.json" | |
| done < <(jq -c 'group_by(.issue_url)[]?' "$comments_file") | |
| issue_count="$(jq 'length' "$issues_file")" | |
| comment_count="$(jq 'length' "$comments_file")" | |
| printf 'Writing %s issue files to %s...\n' "$issue_count" "$workdir" >&2 | |
| while IFS= read -r issue_json; do | |
| number="$(jq -r '.number' <<<"$issue_json")" | |
| title="$(jq -r '.title' <<<"$issue_json")" | |
| state="$(jq -r '.state' <<<"$issue_json")" | |
| state_reason="$(jq -r '.state_reason // empty' <<<"$issue_json")" | |
| author="$(jq -r '.user.login // "unknown"' <<<"$issue_json")" | |
| author_association="$(jq -r '.author_association // empty' <<<"$issue_json")" | |
| created_at="$(jq -r '.created_at // empty' <<<"$issue_json")" | |
| updated_at="$(jq -r '.updated_at // empty' <<<"$issue_json")" | |
| closed_at="$(jq -r '.closed_at // empty' <<<"$issue_json")" | |
| url="$(jq -r '.html_url // empty' <<<"$issue_json")" | |
| labels="$(jq -r '[.labels[]?.name] | join(", ")' <<<"$issue_json")" | |
| assignees="$(jq -r '[.assignees[]?.login] | join(", ")' <<<"$issue_json")" | |
| milestone="$(jq -r '.milestone.title // empty' <<<"$issue_json")" | |
| body="$(jq -r '.body // empty' <<<"$issue_json")" | |
| comments_total="$(jq -r '.comments // 0' <<<"$issue_json")" | |
| file_name="issue-${number}-$(slugify "$title").md" | |
| comment_file="$comments_dir/$number.json" | |
| { | |
| printf '# %s\n\n' "$title" | |
| printf '> Exported from `%s`.\n\n' "$repo" | |
| printf -- '- Issue: #%s\n' "$number" | |
| printf -- '- State: %s\n' "$state" | |
| render_list_line "State reason" "$state_reason" | |
| render_list_line "Author" "$author" | |
| render_list_line "Author association" "$author_association" | |
| render_list_line "Created" "$created_at" | |
| render_list_line "Updated" "$updated_at" | |
| render_list_line "Closed" "$closed_at" | |
| render_list_line "URL" "$url" | |
| render_list_line "Labels" "$labels" | |
| render_list_line "Assignees" "$assignees" | |
| render_list_line "Milestone" "$milestone" | |
| printf -- '- Comments: %s\n' "$comments_total" | |
| printf '\n## Body\n\n' | |
| if [[ -n "$body" ]]; then | |
| printf '%s\n' "$body" | |
| else | |
| printf '_No body._\n' | |
| fi | |
| printf '\n## Comments\n\n' | |
| if [[ ! -f "$comment_file" ]]; then | |
| printf '_No comments._\n' | |
| else | |
| while IFS= read -r comment_json; do | |
| comment_author="$(jq -r '.user.login // "unknown"' <<<"$comment_json")" | |
| comment_assoc="$(jq -r '.author_association // empty' <<<"$comment_json")" | |
| comment_created="$(jq -r '.created_at // empty' <<<"$comment_json")" | |
| comment_updated="$(jq -r '.updated_at // empty' <<<"$comment_json")" | |
| comment_url="$(jq -r '.html_url // empty' <<<"$comment_json")" | |
| comment_body="$(jq -r '.body // empty' <<<"$comment_json")" | |
| printf '### %s\n\n' "$comment_author" | |
| render_list_line "Created" "$comment_created" | |
| if [[ -n "$comment_updated" && "$comment_updated" != "$comment_created" ]]; then | |
| render_list_line "Updated" "$comment_updated" | |
| fi | |
| render_list_line "Author association" "$comment_assoc" | |
| render_list_line "URL" "$comment_url" | |
| printf '\n' | |
| if [[ -n "$comment_body" ]]; then | |
| printf '%s\n' "$comment_body" | |
| else | |
| printf '_No body._\n' | |
| fi | |
| printf '\n' | |
| done < <(jq -c '.[]' "$comment_file") | |
| fi | |
| } > "$workdir/$file_name" | |
| done < <(jq -c '.[]' "$issues_file") | |
| printf 'Done. Wrote %s Markdown files with %s comments into %s.\n' \ | |
| "$issue_count" "$comment_count" "$workdir" >&2 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment