Export full GitHub issues, PRs, discussions, and release notes to Markdown, HTML, or ZIP — includes all comments, reviews, diffs, and timelines. Single-page or batch export with pause/resume.
// ==UserScript== // @name GitHub | Issues, PRs, Discussions & Releases Exporter // @namespace https://greasyfork.org/en/users/1462137-piknockyou // @version 6.0.1 // @author Piknockyou // @license AGPL-3.0 // @description Export full GitHub issues, PRs, discussions, and release notes to Markdown, HTML, or ZIP — includes all comments, reviews, diffs, and timelines. Single-page or batch export with pause/resume. // @match *://github.com/*/* // @icon https://www.google.com/s2/favicons?sz=64&domain=github.com // @grant GM_openInTab // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addStyle // @require https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js // @run-at document-idle // ==/UserScript== // biome-ignore-all lint/style/useTemplate: style preference, string concat is fine // biome-ignore-all lint/complexity/useLiteralKeys: style preference (() => { // ════════════════════════════════════════════════════════════════════════════ // DEVELOPER GUIDELINES — READ BEFORE MAKING ANY CHANGES // ════════════════════════════════════════════════════════════════════════════ // // This comment block is the single source of truth for contribution rules. // Every developer (human or AI) MUST follow these guidelines. // // ── 1. VERSION & CHANGELOG ─────────────────────────────────────────────── // // • Every change MUST bump the version in BOTH the @version header AND // the changelog section below. No exceptions. // • Changelog entries must be concise but specific. State what changed, // not why. Example: "Fix: ZIP hangs in sandbox" not "Fixed a bug". // • The help/README window (openHelpWindow) must stay in sync with // any new features, removed features, or changed workflows. // // ── 2. DRY — DO NOT REPEAT YOURSELF ────────────────────────────────────── // // • Before adding code, search the entire file for existing utilities. // Key shared resources: // - API.request() / API.fetchAllPages() / API.graphql() — all HTTP // - Utils.* — formatting, files, delays, parsing // - createDebugLogger — debug log instances // - logClearHandlers — log clear callback registry // - wireLogActions() — log button wiring (copy + clear) // - ThreadConfig — export settings (load/get/set) // - EXP_FLAGS / EXP_DEPS / EXP_CHILD_TO_PARENT — settings metadata // - safeSetInnerHTML() — Trusted Types safe innerHTML // - showToast() — user notifications // • If two sections share similar logic, extract it into a shared // function. Never copy-paste code between sections. // • If you remove a feature, remove ALL traces: code, CSS, HTML, storage // keys, changelog references, help text. Grep the file to confirm. // // ── 3. ARCHITECTURE RULES ──────────────────────────────────────────────── // // • REST-first: prefer REST API. Only use GraphQL when it provides data // REST cannot (polls, review thread state, discussion timeline, etc.). // • All HTTP goes through API.request() — never use raw fetch(). // • All rate limit tracking is automatic via API.rateLimit.core/.graphql. // • Modules (ReleasesModule, RepoIndexModule, ThreadExportModule) own // their data and expose it via accessors. UI reads, never writes. // • The Panel layer builds HTML, wires events, and reads module state. // It must not contain fetch logic or data transformation. // // ── 4. UI / UX CONSISTENCY ─────────────────────────────────────────────── // // • Every button MUST have a title="..." tooltip explaining its action. // • Every section header's ? icon MUST have a .ghe-tip with: // what's included, how to use (steps), and any caveats. // • Use the 3-button pattern for long-running operations: // [Start/Resume (green/blue)] [⏸ Pause (red)] [✕ Reset (grey)] // • Download buttons: 🔗 = open in tab, 💾 = save file, 📦 = ZIP. // • Status text colors: #3fb950 = success/ready, #d29922 = in progress, // #f85149 = error/warning, #8b949e = neutral/hint. // • All CSS must work in both dark and light modes. For every dark rule, // add a corresponding #ghe-panel.ghe-light override. // // ── 5. STORAGE ─────────────────────────────────────────────────────────── // // • Every persistent checkbox/preference MUST use Prefs.get/set or // ThreadConfig.get/set. Search for all checkboxes and verify. // • Storage keys are defined in STORAGE_KEYS. Do not create ad-hoc keys. // • When removing a feature that used storage, add a GM_deleteValue() // call in the legacy cleanup section near the top of the file. // // ── 6. TOOLTIPS ────────────────────────────────────────────────────────── // // • Every interactive element (button, checkbox, input, link) that is // not self-explanatory MUST have a tooltip — either a native // title="..." or a .ghe-tip inside a .ghe-info for long text. // • When adding ANY new button or control, add a tooltip immediately. // Do not defer. Do not "add tooltips later". It is part of the work. // • Section ? icons use .ghe-tip (custom styled, position-aware). // Small controls use native title="..." (sufficient for short hints). // • After any UI change, scan the affected section for elements that // lack tooltips. If you find one, add it in the same patch. // // ── 7. PATCH WORKFLOW (for AI-assisted changes) ────────────────────────── // // • Always use VRTP patches — never rewrite the full file. // • First patch block MUST bump the version. // • Use <<<...>>> anchors in SEARCH blocks to skip unchanged sections. // • Never patch-on-patch: don't target text you just created in an // earlier REPLACE block within the same response. // • After patching, mentally verify: no duplicate functions, no orphaned // HTML IDs, no CSS selectors targeting removed elements. // // ── 8. TESTING CHECKLIST ───────────────────────────────────────────────── // // • Does it work without a token? (public repo, REST only) // • Does it work with a token? (GraphQL features enabled) // • Does light mode look correct? (toggle and check all new elements) // • Does SPA navigation reset state? (click between repos) // • Do all debug logs persist their checkbox state across sessions? // • Do all tooltips display and not clip outside the panel? // // ── 9. MAINTAINING THESE GUIDELINES ────────────────────────────────────── // // • These guidelines are a living document. Update them when: // - A new shared utility is added (list it in section 2) // - A new UI pattern is established (document it in section 4) // - A new storage key is introduced (note it in section 5) // - A recurring mistake is discovered (add a rule to prevent it) // • Keep guidelines concise and actionable. Rules without rationale // are fine — brevity > justification. // • If a guideline is no longer relevant, remove it. Dead rules erode // trust in live rules. // // ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════ // CHANGELOG // ════════════════════════════════════════════════════════════════════════════ // // v0.1.0 // - Initial combined script: shared foundation, token, API client, utilities // // v0.2.0 // - Added ReleasesModule (ported from Repo Lister, wired to shared API client) // // v0.3.0 // - Added RepoIndexModule (Issues/PRs/Discussions index, ported from Repo Lister) // // v0.4.0 // - Added ThreadExportModule (full issue/PR/discussion export orchestrator) // - Added MdGen (Markdown generators for issues, PRs, discussions) // // v0.5.0 // - Added MdGen.generateDiscussion(), MdGen.generate() master assembly // - Added ThreadExportModule orchestrator (fetch + generate for issue/PR/discussion) // // v0.6.0 // - Unified UI: single panel with token, releases, index, thread export sections // - Added toast notifications // - Fixed notifications newline bug in parsePage // // v0.7.0 // - Added unified panel with all sections: token, releases, index, thread export // - Thread export section with full settings + Export This Page button // - Navigation/init with SPA support // // v0.7.1 // - Fixed tofu debug log toggle: replaced emoji with text label + tooltip // // v0.7.2 // - Fixed openBlob: use document.write for HTML to avoid Firefox blob URL issues // // v0.7.3 // - Added single release export on /releases/tag/ pages // - parsePage now detects release tag pages // // v0.7.4 // - Single release export now reuses ReleasesModule rendering (DRY) // - ReleasesModule gains buildFrom(items) for external use // // v0.7.5 // - Efficient single/small-range index: direct REST lookup when no token + small range // // v0.7.6 // - Release filtering: tag patterns (v1.*, v1.0.0-v2.0.0) and date ranges (2024-01..2025-01) // // v0.7.7 // - Export settings: dependent state logic — sub-options disable when parent is unchecked // // v0.7.8 // - Export settings: hover tooltips on every checkbox explaining what each flag does // // v0.7.9 // - Discussion GraphQL enrichment: cursor-based pagination for 100+ comments // // v0.8.0 // - Batch full-content export from index section (concatenated Markdown) // - Progress UI with cancel support // - Token recommendation warning for batch export // // v0.8.1 // - Removed legacy storage keys (fabPos, onboardingDone) // - Unified rate limit indicator at top of panel, replacing per-section displays // // v0.8.2 // - Batch export: resume from last position after rate limit or cancel // - Batch export: partial download available mid-export // // v0.8.3 // - DRY: extracted shared DebugLogger factory (replaces duplicate in Releases + Index) // - DRY: migrated ReleasesModule.fetchAll to use API.request() // - DRY: migrated RepoIndexModule fetch loops to use API.request() // - DRY: migrated RepoIndexModule GraphQL calls to use API.graphql() // // v0.8.4 // - Fix: debugEnabled reference error in RepoIndexModule.setRange() // - Fix: missing _getItems accessor in RepoIndexModule return // - Fix: removed stale #ghe-rel-rl reference in wireReleases // - Added export preview: view Markdown in new tab before downloading // // v0.8.5 // - Fix: syntax error in parseReleaseFilter regex (unbalanced character class) // // v0.8.6 // - Fix: RepoIndexModule.reset() now resets running state // - Fix: SPA navigation now resets all module state (prevents stale data across repos) // - Fix: batch export re-snapshots items if index was re-run since last batch // - Enhanced preview: renders Markdown as styled HTML using marked.js instead of raw text // - Preview includes table of contents generation for H1/H2/H3 headings // // v0.9.0 // - Selective batch export: checkboxes to choose which items to export // - Batch item list with type badges and state indicators // - Select All / None / Invert controls for batch selection // - Live count of selected items updates as checkboxes change // - Scrollable item picker with search/filter // - Batch export respects selection (only checked items are exported) // // v0.9.1 // - Fix: wired batch picker JS (batchSelected, buildPicker, etc. were missing) // - Fix: batch export now works with selective picker end-to-end // - Batch export "Export All" auto-indexes if no index exists (single-click workflow) // - Batch section promoted: visible even before index, with auto-index capability // - Improved UX: two paths — quick "Export All" or selective via index-first // // v0.9.2 // - Fix: wired all batch picker JS into wireBatchExport (batchSelected, picker controls, etc.) // - Fix: batch export button references batchSelected correctly // - Auto-index: batch export auto-runs index if none exists yet // // v0.9.3 // - Batch section always visible with explanation — no longer hidden until index runs // - Batch section shows disabled state with hint when no index exists // - Panel left-edge resize sash: drag to make panel wider // - Panel width persisted across sessions // // v0.9.4 // - Added JSZip @require for ZIP bundle downloads // - Batch export: "Download as ZIP" button — individual .md files per item in a zip // - ZIP filenames include item number, type, and sanitized title // - ZIP includes an index.md with links to all exported files // // v0.9.5 // - Clarity: every section header has an info tooltip explaining what it exports // - Releases section: clarified as "Release Notes & Changelogs" with tooltip // - Issues/PRs/Discussions section: clarified as "Index" with tooltip explaining metadata-only // - Batch section: tooltip explains full-content export vs index // - Single release/thread sections: tooltips for clarity // - Section headers use help cursor on hover for discoverability // // v0.9.6 // - Fix: _generation counter now increments correctly (was on return object, unreachable) // - Progress feedback during single-thread export (shows current fetch step) // - Export settings accessible via collapsible panel in batch section too // // v0.9.7 // - Export settings collapsible added to batch section (same flags as single-thread) // - Minimized issue/PR comments detected via GraphQL and marked in output // - New setting: ISSUE_INCLUDE_MINIMIZED_COMMENTS to show/hide minimized comments // - GraphQL query fetches minimized state for issue/PR comments when token available // - Discussion timeline via GraphQL timelineItems (REST returns 404) // - Release notes: ZIP download button for releases export // // v0.9.8 // - Fix: ZIP download button no longer gets stuck on "Building ZIP" // - Fix: rate limit display no longer jumps between REST/GraphQL pools // - Fix: batch completion now clears progress detail (no stale "Exporting #X" text) // - Separate REST (core) and GraphQL rate limit pool tracking // - Inline rate limit indicator in batch export progress area // - Batch debug log (same pattern as releases/index debug logs) // - State filter buttons in batch picker (Open/Closed/Merged/Answered) // - State indicator cleanup: compact inline emoji, no oval container // - Panel default width increased from 340px to 400px for better readability // - Light/dark mode: comprehensive audit of all UI elements for consistency // - Renamed ZIP button label for clarity // // v0.9.9 // - Fix: ZIP generation now uses uint8array+Blob to avoid sandbox timeout // - Batch picker: proper table layout with aligned columns // - Batch picker: resizable height (drag like log textareas) // - State filter buttons reflect actual selection state automatically // - Copy-to-clipboard button on all debug log textareas // - Cancel/Resume/Reset buttons in batch export section // // v0.10.0 // - Cancel/Resume/Reset UI pattern applied to Releases and Index sections // - Releases: resume after rate limit instead of losing progress // - Index: Reset button to clear state and start fresh // - Consistent 3-button pattern (Go/Cancel/Reset) across all export sections // // v0.10.1 // - Manual pause/resume for Releases and Index (not just rate-limit) // - Cancel always allows resume from where it stopped // - DRY: unified paused state replaces rateLimited-only resume gate // // v0.10.2 // - Renamed Cancel → Pause across all sections (Releases, Index, Batch) // - Button labels now match behavior: Pause stops with resume, not discard // // v0.10.3 // - Fix: batch debug log checkbox now persists across sessions // // v0.10.4 // - Fix: downgrade JSZip 3.10.1 → 3.9.1 (3.10+ hangs in userscript sandbox // due to setimmediate postMessage check failing in isolated context) // // v0.10.5 // - DRY: unified log actions (clear + copy) as compact icon buttons in top-right // - Removed separate Clear buttons from all log sections // - Log action buttons: 20% opacity, full on hover // - Single wireLogActions() handles all log textareas // // v0.10.6 // - Custom styled tooltips on all section ? buttons (replaces native title attr) // - Tooltips position-aware: shift left/right to avoid panel edge clipping // - Removed redundant batch subtitle (info moved to tooltip) // - DRY: single CSS tooltip system via .ghe-tip data attribute // // v0.10.7 // - Fix: batch tooltip now visible (ghe-info CSS no longer requires h3 parent) // - Log action buttons: proper square sizing with visible icons // - Consistent ghe-info styling across all contexts (h3, span, div) // // v0.10.8 // - Batch hint: compact inline status next to count (red/green, not centered block) // - Release ZIP: now creates per-release .md files instead of single html+md bundle // // v0.10.9 // - Batch status: actionable hint text guides users through workflow steps // - Help button in panel header opens full documentation rendered via marked.js // // v0.11.0 // - Tooltips on every interactive button and control // - Rate limit bar: clear label explaining what it shows // - Token badge: tooltip explaining status // - Section ? tooltips: expanded with step-by-step workflow instructions // - All download/action buttons have descriptive tooltips // - Ambiguous UI elements clarified with context // // v0.11.1 // - Added developer guidelines comment block // // v0.11.2 // - Developer guidelines: added self-update rule and tooltip mandate // - Fix: tooltips flip below when they would clip above the panel viewport // - wireTooltipPositioning: now handles both horizontal and vertical overflow // // v0.11.3 // - Fix: release HTML output now much closer to GitHub's native rendering // - Fix: images in release notes now render on their own line (not inline with list items) // - Fix: release HTML uses GitHub's markdown-body class with proper GitHub-like CSS // - Fix: release card layout now uses 2-column flex (sidebar + content) matching GitHub // - Fix: pre-release/draft badges rendered as styled labels matching GitHub // - Fix: tag and commit links shown in sidebar like GitHub's layout // - Fix: contributors section rendered in footer like GitHub // - Fix: reactions rendered as pill buttons matching GitHub's style // // v0.11.4 // - Release filter: added ? tooltip with full format documentation and examples // - Release filter: support single year (2009), single month (2009-06), year range (2009..2010) // - Release filter: partial dates auto-expand (2009 → 2009-01-01..2009-12-31) // - Release filter: both .. and - accepted as range separators for dates // - Release filter: improved placeholder text for discoverability // // v0.11.5 // - Fix: images in release notes now properly render on their own line // (more aggressive <br>+image and image-after-text detection in preProcessMdBody) // - Fix: release filter date ranges use only .. separator (- is ambiguous with YYYY-MM-DD) // - Fix: filter tooltip corrected — no longer claims - works for date ranges // - Fix: tag range detection requires v-prefix or dots to avoid pure-number ambiguity // - Release filter: expandPartialDate helper for single year/month/day support // // v6.0 // - Renamed script to "GitHub Issues, PRs, Discussions & Releases Exporter" // - Updated LOG_PREFIX and internal SCRIPT_NAME to match new branding // - Renamed all storage keys and CSS policy to match new branding // ════════════════════════════════════════════════════════════════════════════ // CONSTANTS // ════════════════════════════════════════════════════════════════════════════ const SCRIPT_NAME = "GitHub | Issues, PRs, Discussions & Releases Exporter"; const LOG_PREFIX = "[GH-IPDR-Exporter]"; // GM storage keys (unified namespace) const STORAGE_KEYS = { TOKEN: "ghIPDR_token", PREFS: "ghIPDR_prefs", THREAD_EXPORT_CONFIG: "ghIPDR_threadConfig", }; // ════════════════════════════════════════════════════════════════════════════ // LOGGING // ════════════════════════════════════════════════════════════════════════════ const log = (...a) => console.log(LOG_PREFIX, ...a); const warn = (...a) => console.warn(LOG_PREFIX, ...a); const error = (...a) => console.error(LOG_PREFIX, ...a); // ════════════════════════════════════════════════════════════════════════════ // TRUSTED TYPES POLICY // ════════════════════════════════════════════════════════════════════════════ let trustedTypesPolicy = null; if (window.trustedTypes?.createPolicy) { try { trustedTypesPolicy = window.trustedTypes.createPolicy( "gh-ipdr-exporter-policy", { createHTML: (s) => s, }, ); // biome-ignore lint/correctness/noUnusedVariables: catch variable required by syntax } catch (e) { /* policy may already exist */ } } function safeSetInnerHTML(el, htmlStr) { if (trustedTypesPolicy) el.innerHTML = trustedTypesPolicy.createHTML(htmlStr); else el.innerHTML = htmlStr; } // ════════════════════════════════════════════════════════════════════════════ // TOKEN MANAGEMENT (unified) // ════════════════════════════════════════════════════════════════════════════ const Token = { get() { return (GM_getValue(STORAGE_KEYS.TOKEN, "") || "").trim(); }, set(t) { GM_setValue(STORAGE_KEYS.TOKEN, (t || "").trim()); }, clear() { GM_setValue(STORAGE_KEYS.TOKEN, ""); }, has() { return this.get().length > 0; }, }; // ════════════════════════════════════════════════════════════════════════════ // PREFERENCES (unified, two-tier: general prefs + thread-export config) // ════════════════════════════════════════════════════════════════════════════ // General prefs (panel checkboxes, UI state, etc.) const Prefs = { _load() { try { return JSON.parse(GM_getValue(STORAGE_KEYS.PREFS, "{}")); } catch { return {}; } }, _save(obj) { GM_setValue(STORAGE_KEYS.PREFS, JSON.stringify(obj)); }, get(k, def) { return this._load()[k] ?? def; }, set(k, v) { const p = this._load(); p[k] = v; this._save(p); }, reset() { GM_setValue(STORAGE_KEYS.PREFS, "{}"); }, }; // Thread export config (the detailed issue/PR/discussion export settings) // This is the equivalent of CONFIG + Settings from the Issue Exporter const THREAD_EXPORT_DEFAULTS = { // ─── Content ───────────────────────────────────────────────────────── INCLUDE_HEADER: true, INCLUDE_LABELS: true, INCLUDE_ASSIGNEES: true, INCLUDE_MILESTONE: true, INCLUDE_TIMESTAMPS: true, INCLUDE_AUTHOR_INFO: true, INCLUDE_REACTIONS: true, INCLUDE_COMMENT_IDS: false, // ─── Issue-specific ────────────────────────────────────────────────── ISSUE_INCLUDE_TYPE: true, ISSUE_INCLUDE_CLOSED_BY: true, ISSUE_INCLUDE_LOCK_REASON: true, ISSUE_INCLUDE_DEPENDENCIES: true, ISSUE_INCLUDE_SUB_ISSUES: true, ISSUE_INCLUDE_PINNED_COMMENT: true, // ─── Pull Request ──────────────────────────────────────────────────── PR_INCLUDE_MERGE_STATUS: true, PR_INCLUDE_BRANCH_INFO: true, PR_INCLUDE_DIFF_STATS: true, PR_INCLUDE_REQUESTED_REVIEWERS: true, PR_INCLUDE_MERGE_REQUIREMENTS: true, PR_INCLUDE_CHECKS_SECTION: true, PR_INCLUDE_REVIEWS: true, PR_INCLUDE_REVIEW_COMMENTS: true, PR_INCLUDE_REVIEW_THREAD_STATE: true, PR_REVIEW_COMMENTS_GROUP_BY_THREAD: true, PR_INCLUDE_PR_COMMITS: false, PR_INCLUDE_CHANGED_FILES: false, PR_INCLUDE_CHANGED_FILES_PATCHES: false, // ─── Discussion-specific ───────────────────────────────────────────── DISC_INCLUDE_CATEGORY: true, DISC_INCLUDE_ANSWER: true, DISC_INCLUDE_POLL: true, DISC_INCLUDE_UPVOTES: true, DISC_INCLUDE_COMMENT_REPLIES: true, DISC_INCLUDE_TIMELINE: false, // ─── References ────────────────────────────────────────────────────── INCLUDE_REFERENCES_SECTION: true, REF_INCLUDE_CROSS_REFERENCED: true, REF_INCLUDE_SAME_REPO: true, REF_INCLUDE_CROSS_REPO: true, REF_INCLUDE_ISSUES: true, REF_INCLUDE_PRS: true, REF_INCLUDE_DUPLICATES: true, REF_INCLUDE_COMMITS: true, REF_FETCH_COMMIT_DETAILS: false, // ─── Timeline ──────────────────────────────────────────────────────── INCLUDE_TIMELINE_SECTION: false, TL_INCLUDE_CROSS_REFERENCED: true, TL_INCLUDE_REFERENCED: true, TL_INCLUDE_RENAMED: true, TL_INCLUDE_LABEL_CHANGES: true, TL_INCLUDE_PROJECT_V2: false, TL_INCLUDE_SUBSCRIBE_EVENTS: false, TL_INCLUDE_MENTIONED_EVENTS: false, TL_INCLUDE_MARKED_DUPLICATE_EVENTS: true, TL_INCLUDE_CLOSED_EVENTS: true, TL_INCLUDE_REVIEWED: true, TL_INCLUDE_COMMITTED: true, TL_INCLUDE_MERGED: true, TL_INCLUDE_HEAD_REF_EVENTS: true, TL_INCLUDE_REVIEW_REQUESTED: true, // ─── Minimized ─────────────────────────────────────────────────────── INCLUDE_MINIMIZED_COMMENTS: true, // ─── Formatting ────────────────────────────────────────────────────── COLLAPSIBLE_LONG_COMMENTS: false, LONG_COMMENT_THRESHOLD: 500, COMMENT_SEPARATOR: "---", USE_HTML_DETAILS: true, }; const ThreadConfig = { _cache: null, _load() { if (this._cache) return this._cache; try { const saved = GM_getValue(STORAGE_KEYS.THREAD_EXPORT_CONFIG, null); if (saved) { const overrides = typeof saved === "string" ? JSON.parse(saved) : saved; this._cache = { ...THREAD_EXPORT_DEFAULTS, ...overrides }; return this._cache; } } catch (e) { warn("ThreadConfig load error:", e); } this._cache = { ...THREAD_EXPORT_DEFAULTS }; return this._cache; }, get(k) { return this._load()[k]; }, set(k, v) { try { let overrides = {}; const saved = GM_getValue(STORAGE_KEYS.THREAD_EXPORT_CONFIG, null); if (saved) overrides = typeof saved === "string" ? JSON.parse(saved) : saved; overrides[k] = v; GM_setValue(STORAGE_KEYS.THREAD_EXPORT_CONFIG, overrides); this._cache = null; } catch (e) { warn("ThreadConfig save error:", e); } }, load() { return this._load(); }, reset() { GM_deleteValue(STORAGE_KEYS.THREAD_EXPORT_CONFIG); this._cache = null; }, }; // ════════════════════════════════════════════════════════════════════════════ // SHARED UTILITIES // ════════════════════════════════════════════════════════════════════════════ const Utils = { /** * Parse current URL to determine page type and extract identifiers. * Returns null if not a recognized page. */ parsePage() { const parts = location.pathname.split("/").filter(Boolean); if (parts.length < 2) return null; // Exclude special top-level pages const special = [ "settings", "organizations", "orgs", "login", "signup", "explore", "topics", "trending", "collections", "events", "sponsors", "notifications", "new", "codespaces", "marketplace", ]; if (special.includes(parts[0])) return null; const owner = parts[0]; const repo = parts[1]; const fullName = `${owner}/${repo}`; // Specific page types if (parts.length >= 4) { if (parts[2] === "issues" && /^\d+$/.test(parts[3])) { return { type: "issue", owner, repo, fullName, number: parseInt(parts[3], 10), }; } if (parts[2] === "pull" && /^\d+$/.test(parts[3])) { return { type: "pr", owner, repo, fullName, number: parseInt(parts[3], 10), }; } if (parts[2] === "discussions" && /^\d+$/.test(parts[3])) { return { type: "discussion", owner, repo, fullName, number: parseInt(parts[3], 10), }; } if ( parts[2] === "releases" && parts[3] === "tag" && parts.length >= 5 ) { return { type: "release", owner, repo, fullName, number: null, tag: parts.slice(4).join("/"), }; } } // Generic repo page (code, releases list, issues list, etc.) return { type: "repo", owner, repo, fullName, number: null }; }, /** Repo full name from URL */ repoFullName() { const p = this.parsePage(); return p ? p.fullName : null; }, /** Is current page a single-thread page (issue, PR, or discussion)? */ isThreadPage() { const p = this.parsePage(); return ( p && (p.type === "issue" || p.type === "pr" || p.type === "discussion") ); }, /** Is current page any repo page? */ isRepoPage() { return this.parsePage() !== null; }, /** Sanitize text for filenames */ sanitizeFilename(text, maxLen = 80) { if (!text) return "github_export"; return ( text .replace(/·/g, "-") // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional range to strip control chars from filenames .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") .replace(/\s+/g, "_") .replace(/_+/g, "_") .replace(/^_+|_+$/g, "") .trim() .substring(0, maxLen) || "github_export" ); }, /** Format ISO date */ formatDate(isoString, includeTime = false) { if (!isoString) return ""; try { const date = new Date(isoString); if (Number.isNaN(date.getTime())) return isoString; const opts = { year: "numeric", month: "short", day: "numeric" }; if (includeTime) { opts.hour = "2-digit"; opts.minute = "2-digit"; } return date.toLocaleDateString("en-US", opts); } catch { return isoString; } }, /** Format rate-limit reset timestamp */ formatReset(ts) { return ts ? new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", }) : "?"; }, /** Format reactions object → emoji string */ formatReactions(reactions) { if (!reactions) return ""; const map = { "+1": "👍", "-1": "👎", laugh: "😄", hooray: "🎉", confused: "😕", heart: "❤️", rocket: "🚀", eyes: "👀", }; const parts = []; for (const [key, emoji] of Object.entries(map)) { const count = reactions[key]; if (count && count > 0) parts.push(`${emoji} ${count}`); } return parts.join(" · "); }, /** Escape HTML entities */ escapeHtml(text) { if (!text) return ""; return String(text) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); }, /** Format bytes to human-readable */ formatBytes(bytes) { if (!bytes || bytes === 0) return ""; if (bytes < 1024) return bytes + " B"; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB"; if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + " MB"; return (bytes / 1073741824).toFixed(2) + " GB"; }, /** Delay helper */ delay(ms) { return new Promise((r) => setTimeout(r, ms)); }, /** Download content as a file */ downloadBlob(content, filename, mime) { const b = new Blob([content], { type: `${mime};charset=utf-8` }); const u = URL.createObjectURL(b); const a = Object.assign(document.createElement("a"), { href: u, download: filename, }); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(u); }, /** Open content in new tab */ openBlob(content, mime) { // For HTML content, use document.write to avoid Firefox blob URL restrictions if (mime.includes("html")) { try { const tab = window.open("", "_blank"); if (tab) { tab.document.open(); tab.document.write(content); tab.document.close(); return; } // biome-ignore lint/correctness/noUnusedVariables: catch variable required by syntax } catch (e) { /* fall through to blob approach */ } } // Fallback for non-HTML or if window.open was blocked const b = new Blob([content], { type: `${mime};charset=utf-8` }); const u = URL.createObjectURL(b); try { if (typeof GM_openInTab === "function") GM_openInTab(u, { active: true, setParent: true }); else window.open(u, "_blank"); // biome-ignore lint/correctness/noUnusedVariables: catch variable required by syntax } catch (e) { // Last resort: download it instead this.downloadBlob(content, "export.html", mime); } setTimeout(() => URL.revokeObjectURL(u), 15000); }, }; // ════════════════════════════════════════════════════════════════════════════ // SHARED API CLIENT // ════════════════════════════════════════════════════════════════════════════ const API_BASE = "https://api.github.com"; const PER_PAGE = 100; const FETCH_DELAY_MS = 100; const REQUEST_TIMEOUT_MS = 30000; const API = { /** Last known rate-limit values — separate pools for REST (core) and GraphQL */ rateLimit: { core: { remaining: null, reset: null }, graphql: { remaining: null, reset: null }, }, /** Convenience: return the lower remaining count across pools (for UI display) */ get rateLimitSummary() { const c = this.rateLimit.core; const g = this.rateLimit.graphql; if (c.remaining === null && g.remaining === null) return { remaining: null, reset: null, pool: null }; if (c.remaining === null) return { remaining: g.remaining, reset: g.reset, pool: "graphql" }; if (g.remaining === null) return { remaining: c.remaining, reset: c.reset, pool: "core" }; if (c.remaining <= g.remaining) return { remaining: c.remaining, reset: c.reset, pool: "core" }; return { remaining: g.remaining, reset: g.reset, pool: "graphql" }; }, /** Build auth headers using shared token */ _headers(extra = {}) { const h = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", ...extra, }; const t = Token.get(); if (t) h["Authorization"] = `Bearer ${t}`; return h; }, /** * Core request method. * @param {string} endpoint Absolute URL or path starting with / * @param {object} opts fetch options + optional `signal` * @returns {{ data: any, headers: Headers }} */ async request(endpoint, opts = {}) { const url = endpoint.startsWith("http") ? endpoint : `${API_BASE}${endpoint}`; const headers = this._headers(opts.headers || {}); const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), REQUEST_TIMEOUT_MS, ); // Bridge external abort signal const externalSignal = opts.signal; let onExtAbort = null; if (externalSignal) { if (externalSignal.aborted) { controller.abort(); } else { onExtAbort = () => controller.abort(); externalSignal.addEventListener("abort", onExtAbort, { once: true }); } } try { const { signal: _s, headers: _h, ...rest } = opts; const response = await fetch(url, { ...rest, headers, signal: controller.signal, }); clearTimeout(timeoutId); if (onExtAbort && externalSignal) { try { externalSignal.removeEventListener("abort", onExtAbort); } catch {} } // Track rate limit — assign to correct pool const rlRemain = response.headers.get("X-RateLimit-Remaining"); const rlReset = response.headers.get("X-RateLimit-Reset"); if (rlRemain !== null) { const isGql = url.includes("/graphql"); const pool = isGql ? this.rateLimit.graphql : this.rateLimit.core; pool.remaining = parseInt(rlRemain, 10); pool.reset = parseInt(rlReset, 10); } if (!response.ok) { const summary = this.rateLimitSummary; if ( response.status === 403 && summary.remaining !== null && summary.remaining <= 0 ) { throw new Error( `Rate limit exceeded. Resets at ${Utils.formatReset(summary.reset)}`, ); } throw new Error( `API error: ${response.status} ${response.statusText}`, ); } const ct = response.headers.get("content-type") || ""; const data = ct.includes("json") ? await response.json() : await response.text(); return { data, headers: response.headers }; } catch (err) { clearTimeout(timeoutId); if (onExtAbort && externalSignal) { try { externalSignal.removeEventListener("abort", onExtAbort); } catch {} } if (err.name === "AbortError") { throw new Error( externalSignal?.aborted ? "Cancelled" : "Request timeout", ); } throw err; } }, /** * Fetch all pages of a paginated REST endpoint. * @param {string} endpoint Base endpoint (without page params) * @param {object} opts { signal, onProgress(count), maxPages } * @returns {Array} */ async fetchAllPages(endpoint, opts = {}) { const all = []; let page = 1; const maxPages = opts.maxPages || 500; const sep = endpoint.includes("?") ? "&" : "?"; while (page <= maxPages) { const url = `${endpoint}${sep}per_page=${PER_PAGE}&page=${page}`; const { data, headers } = await this.request(url, { signal: opts.signal, }); const items = Array.isArray(data) ? data : []; all.push(...items); if (opts.onProgress) opts.onProgress(all.length); const link = headers.get("Link"); if (!link?.includes('rel="next"') || items.length < PER_PAGE) break; page++; await Utils.delay(FETCH_DELAY_MS); } return all; }, /** * Execute a GraphQL query. * Requires token (GraphQL always needs auth). */ async graphql(query, variables = {}, signal = null) { if (!Token.has()) throw new Error("GraphQL requires a GitHub token"); const { data } = await this.request("/graphql", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), ...(signal ? { signal } : {}), }); if (data?.errors?.length) { throw new Error( `GraphQL error: ${data.errors.map((e) => e.message).join("; ")}`, ); } return data?.data || null; }, // ────────────────────────────────────────────────────────────────────── // Issue / PR endpoints (from Issue Exporter) // ────────────────────────────────────────────────────────────────────── async fetchIssue(owner, repo, number, signal) { const { data } = await this.request( `/repos/${owner}/${repo}/issues/${number}`, signal ? { signal } : {}, ); return data; }, async fetchPullRequest(owner, repo, number, signal) { const { data } = await this.request( `/repos/${owner}/${repo}/pulls/${number}`, signal ? { signal } : {}, ); return data; }, async fetchIssueComments(owner, repo, number, signal) { return this.fetchAllPages( `/repos/${owner}/${repo}/issues/${number}/comments`, { signal }, ); }, async fetchTimeline(owner, repo, number, signal) { try { return await this.fetchAllPages( `/repos/${owner}/${repo}/issues/${number}/timeline`, { signal }, ); } catch (e) { warn("Timeline fetch failed:", e.message); return []; } }, async fetchIssueDependenciesBlockedBy(owner, repo, number, signal) { try { const { data } = await this.request( `/repos/${owner}/${repo}/issues/${number}/dependencies/blocked_by?per_page=${PER_PAGE}`, signal ? { signal } : {}, ); return Array.isArray(data) ? data : []; } catch { return []; } }, async fetchIssueDependenciesBlocking(owner, repo, number, signal) { try { const { data } = await this.request( `/repos/${owner}/${repo}/issues/${number}/dependencies/blocking?per_page=${PER_PAGE}`, signal ? { signal } : {}, ); return Array.isArray(data) ? data : []; } catch { return []; } }, async fetchIssueSubIssues(owner, repo, number, signal) { try { const { data } = await this.request( `/repos/${owner}/${repo}/issues/${number}/sub_issues?per_page=${PER_PAGE}`, signal ? { signal } : {}, ); return Array.isArray(data) ? data : []; } catch { return []; } }, // ────────────────────────────────────────────────────────────────────── // PR-specific endpoints // ────────────────────────────────────────────────────────────────────── async fetchRequestedReviewers(owner, repo, number, signal) { const { data } = await this.request( `/repos/${owner}/${repo}/pulls/${number}/requested_reviewers`, signal ? { signal } : {}, ); return data; }, async fetchBranchRules(owner, repo, branch, signal) { if (!branch) return null; try { const { data } = await this.request( `/repos/${owner}/${repo}/rules/branches/${encodeURIComponent(branch)}`, signal ? { signal } : {}, ); return data; } catch { return null; } }, async fetchCombinedStatus(owner, repo, ref, signal) { if (!ref) return null; try { const { data } = await this.request( `/repos/${owner}/${repo}/commits/${ref}/status`, signal ? { signal } : {}, ); return data; } catch { return null; } }, async fetchCheckRuns(owner, repo, ref, signal) { if (!ref) return null; try { const { data } = await this.request( `/repos/${owner}/${repo}/commits/${ref}/check-runs?per_page=${PER_PAGE}`, signal ? { signal } : {}, ); return data; } catch { return null; } }, async fetchReviews(owner, repo, number, signal) { return this.fetchAllPages( `/repos/${owner}/${repo}/pulls/${number}/reviews`, { signal }, ); }, async fetchReviewComments(owner, repo, number, signal) { return this.fetchAllPages( `/repos/${owner}/${repo}/pulls/${number}/comments`, { signal }, ); }, async fetchPRCommits(owner, repo, number, signal) { return this.fetchAllPages( `/repos/${owner}/${repo}/pulls/${number}/commits`, { signal }, ); }, async fetchPRFiles(owner, repo, number, signal) { return this.fetchAllPages( `/repos/${owner}/${repo}/pulls/${number}/files`, { signal }, ); }, async fetchCommit(commitApiUrl, signal) { const { data } = await this.request( commitApiUrl, signal ? { signal } : {}, ); return data; }, // ────────────────────────────────────────────────────────────────────── // Discussion REST endpoints (NEW — confirmed by probe) // ────────────────────────────────────────────────────────────────────── async fetchDiscussion(owner, repo, number, signal) { const { data } = await this.request( `/repos/${owner}/${repo}/discussions/${number}`, signal ? { signal } : {}, ); return data; }, async fetchDiscussionComments(owner, repo, number, signal) { return this.fetchAllPages( `/repos/${owner}/${repo}/discussions/${number}/comments`, { signal }, ); }, // ────────────────────────────────────────────────────────────────────── // GraphQL enrichment endpoints // ────────────────────────────────────────────────────────────────────── /** * Fetch minimized state for issue/PR comments via GraphQL. * Returns a Map of comment databaseId → { isMinimized, minimizedReason }. */ async fetchCommentMinimizedState(owner, repo, number, signal) { const allComments = []; let cursor = null; let hasNext = true; while (hasNext) { if (signal?.aborted) throw new Error("Cancelled"); const query = ` query($owner: String!, $repo: String!, $number: Int!${cursor ? ", $after: String!" : ""}) { repository(owner: $owner, name: $repo) { issueOrPullRequest(number: $number) { ... on Issue { comments(first: 100${cursor ? ", after: $after" : ""}) { pageInfo { hasNextPage endCursor } nodes { databaseId isMinimized minimizedReason } } } ... on PullRequest { comments(first: 100${cursor ? ", after: $after" : ""}) { pageInfo { hasNextPage endCursor } nodes { databaseId isMinimized minimizedReason } } } } } }`; try { const vars = { owner, repo, number }; if (cursor) vars.after = cursor; const data = await this.graphql(query, vars, signal); const item = data?.repository?.issueOrPullRequest; const comments = item?.comments; if (!comments?.nodes) break; allComments.push(...comments.nodes); hasNext = !!comments.pageInfo?.hasNextPage; cursor = comments.pageInfo?.endCursor || null; if (hasNext) await Utils.delay(FETCH_DELAY_MS); } catch (e) { warn("Comment minimized state GQL failed:", e.message); break; } } const map = new Map(); for (const c of allComments) { if (c.databaseId != null) { map.set(c.databaseId, { isMinimized: !!c.isMinimized, minimizedReason: c.minimizedReason || null, }); } } return map; }, async fetchPRReviewThreads(owner, repo, number, signal) { const query = ` query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { reviewThreads(first: 100) { nodes { id isResolved isOutdated path line startLine comments(first: 100) { nodes { databaseId id isOutdated minimizedReason } } } } } } }`; try { const data = await this.graphql(query, { owner, repo, number }, signal); const threads = data?.repository?.pullRequest?.reviewThreads?.nodes || []; return threads.map((t) => ({ id: t.id, path: t.path, line: t.line, startLine: t.startLine, isResolved: !!t.isResolved, isOutdated: !!t.isOutdated, comments: (t.comments?.nodes || []).map((c) => ({ databaseId: c.databaseId, id: c.id, isOutdated: !!c.isOutdated, minimizedReason: c.minimizedReason || null, })), })); } catch (e) { warn("PR review threads GQL failed:", e.message); return []; } }, /** * Fetch discussion timeline via GraphQL timelineItems. * REST endpoint returns 404 for discussions, so GraphQL is required. */ async fetchDiscussionTimeline(owner, repo, number, signal) { const allEvents = []; let cursor = null; let hasNext = true; while (hasNext) { if (signal?.aborted) throw new Error("Cancelled"); const query = ` query($owner: String!, $repo: String!, $number: Int!${cursor ? ", $after: String!" : ""}) { repository(owner: $owner, name: $repo) { discussion(number: $number) { timelineItems(first: 100${cursor ? ", after: $after" : ""}) { pageInfo { hasNextPage endCursor } nodes { __typename ... on LabeledEvent { createdAt label { name } actor { login } } ... on UnlabeledEvent { createdAt label { name } actor { login } } ... on ClosedEvent { createdAt actor { login } stateReason } ... on ReopenedEvent { createdAt actor { login } } ... on LockedEvent { createdAt actor { login } lockReason } ... on UnlockedEvent { createdAt actor { login } } ... on PinnedEvent { createdAt actor { login } } ... on UnpinnedEvent { createdAt actor { login } } ... on TransferredEvent { createdAt actor { login } } ... on CategoryChangedEvent { createdAt actor { login } } ... on RenamedTitleEvent { createdAt actor { login } previousTitle currentTitle } ... on MarkedAsDuplicateEvent { createdAt actor { login } } ... on CrossReferencedEvent { createdAt actor { login } source { __typename ... on Issue { number title url repository { nameWithOwner } } ... on PullRequest { number title url repository { nameWithOwner } } } } } } } } }`; try { const vars = { owner, repo, number }; if (cursor) vars.after = cursor; const data = await this.graphql(query, vars, signal); const items = data?.repository?.discussion?.timelineItems; if (!items?.nodes) break; allEvents.push(...items.nodes); hasNext = !!items.pageInfo?.hasNextPage; cursor = items.pageInfo?.endCursor || null; if (hasNext) await Utils.delay(FETCH_DELAY_MS); } catch (e) { warn("Discussion timeline GQL failed:", e.message); break; } } return allEvents; }, /** * Fetch discussion enrichment via GraphQL (polls, upvotes, answer comment ID, * minimized comments). Paginates comments for discussions with 100+ comments. */ async fetchDiscussionGraphQL(owner, repo, number, signal) { // First page: fetch discussion metadata + first batch of comments const firstQuery = ` query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { discussion(number: $number) { upvoteCount isPinned isAnswered answer { databaseId } poll { question totalVotes options(first: 10) { nodes { text votes } } } comments(first: 100) { pageInfo { hasNextPage endCursor } nodes { databaseId isMinimized minimizedReason isAnswer replies(first: 100) { nodes { databaseId isMinimized minimizedReason } } } } } } }`; try { const data = await this.graphql( firstQuery, { owner, repo, number }, signal, ); const disc = data?.repository?.discussion; if (!disc) return null; // Paginate remaining comments if needed let pageInfo = disc.comments?.pageInfo; while (pageInfo?.hasNextPage && pageInfo.endCursor) { if (signal?.aborted) throw new Error("Cancelled"); await Utils.delay(FETCH_DELAY_MS); const nextQuery = ` query($owner: String!, $repo: String!, $number: Int!, $after: String!) { repository(owner: $owner, name: $repo) { discussion(number: $number) { comments(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { databaseId isMinimized minimizedReason isAnswer replies(first: 100) { nodes { databaseId isMinimized minimizedReason } } } } } } }`; const nextData = await this.graphql( nextQuery, { owner, repo, number, after: pageInfo.endCursor }, signal, ); const nextComments = nextData?.repository?.discussion?.comments; if (!nextComments?.nodes?.length) break; disc.comments.nodes.push(...nextComments.nodes); pageInfo = nextComments.pageInfo; } return disc; } catch (e) { warn("Discussion GQL enrichment failed:", e.message); return null; } }, // ────────────────────────────────────────────────────────────────────── // Releases endpoint (from Repo Lister) // ────────────────────────────────────────────────────────────────────── async fetchReleases(owner, repo, opts = {}) { return this.fetchAllPages(`/repos/${owner}/${repo}/releases`, opts); }, }; // ════════════════════════════════════════════════════════════════════════════ // SHARED DEBUG LOGGER FACTORY // ════════════════════════════════════════════════════════════════════════════ function createDebugLogger(prefix) { let entries = [], enabled = false; return { log(msg, data) { if (!enabled) return; const ts = new Date().toISOString().slice(11, 23); entries.push( data !== undefined ? `[${ts}] ${msg} ${JSON.stringify(data)}` : `[${ts}] ${msg}`, ); log(prefix, msg, data !== undefined ? data : ""); }, setEnabled(on) { enabled = on; }, getText() { return entries.join("\n"); }, clear() { entries = []; }, isEnabled() { return enabled; }, }; } // ════════════════════════════════════════════════════════════════════════════ // RELEASES MODULE // ════════════════════════════════════════════════════════════════════════════ const ReleasesModule = (() => { let html = "", md = "", items = []; let running = false; let paused = false; // true when stopped mid-fetch (cancel or rate limit) const dbgLog = createDebugLogger("[Releases]"); const _dbg = (msg, data) => dbgLog.log(msg, data); // ── Auto-link GitHub references in release body markdown ── function autoLinkRefs(src, repo) { if (!src || !repo) return src; let r = src; r = r.replace( /(^|[^[(\w/])#(\d+)\b/gm, // biome-ignore lint/correctness/noUnusedFunctionParameters: m (full match) unused (m, pre, num) => `${pre}[#${num}](https://github.com/${repo}/issues/${num})`, ); r = r.replace( /(^|[^[(\w/])@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)\b/gm, (m, pre, user) => { if ( /^(param|media|import|charset|font-face|keyframes|supports|layer)$/i.test( user, ) ) return m; return `${pre}[@${user}](https://github.com/${user})`; }, ); r = r.replace( /(^|[^[(\w/])([0-9a-f]{40})\b/gm, // biome-ignore lint/correctness/noUnusedFunctionParameters: m (full match) unused (m, pre, sha) => `${pre}[\`${sha.slice(0, 7)}\`](https://github.com/${repo}/commit/${sha})`, ); r = r.replace( /(^|[^[(\w])([a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+)#(\d+)\b/gm, (m, pre, xrepo, num) => { if (xrepo === repo) return m; return `${pre}[${xrepo}#${num}](https://github.com/${xrepo}/issues/${num})`; }, ); return r; } function renderMd(src, repo) { if (!src) return ""; try { if (typeof marked !== "undefined") { const renderer = typeof marked.parse === "function" ? marked : marked.marked; const processed = autoLinkRefs(src, repo); return renderer.parse(processed, { breaks: true, gfm: true, }); } } catch (e) { log("marked parse error:", e); } return `<pre style="white-space:pre-wrap">${Utils.escapeHtml(src)}</pre>`; } function guessAssetType(name) { const n = name.toLowerCase(); if (/\.(sig|asc|gpg)$/i.test(n)) return "Signature"; if (/sha\d*sum|checksum|\.sha\d+$/i.test(n)) return "Checksum"; if (/\.sigstore$/i.test(n)) return "Attestation"; if (/\.(exe|msi|msix)$/i.test(n)) return "Installer"; if (/\.(dmg|pkg)$/i.test(n)) return "macOS"; if (/\.(deb|rpm|appimage|snap|flatpak)$/i.test(n)) return "Linux"; if (/\.(tar\.gz|tar\.bz2|tar\.xz|tgz|zip|7z|rar)$/i.test(n)) return "Archive"; if (/\.(apk|aab|ipa)$/i.test(n)) return "Mobile"; return "Asset"; } function buildAssetList(rel) { const assets = []; for (const a of rel.assets || []) { assets.push({ type: guessAssetType(a.name), name: a.name, size: a.size, download_count: a.download_count, uploaded: a.created_at ? a.created_at.slice(0, 10) : "", digest: a.digest || null, url: a.browser_download_url || "", }); } if (rel.zipball_url) assets.push({ type: "Source", name: "Source code (zip)", size: null, download_count: null, uploaded: "", digest: null, url: rel.zipball_url, }); if (rel.tarball_url) assets.push({ type: "Source", name: "Source code (tar.gz)", size: null, download_count: null, uploaded: "", digest: null, url: rel.tarball_url, }); return assets; } function buildReactionsHtml(reactions) { if (!reactions?.total_count) return ""; const emojis = { "+1": "👍", "-1": "👎", laugh: "😄", hooray: "🎉", confused: "😕", heart: "❤️", rocket: "🚀", eyes: "👀", }; const parts = Object.entries(emojis) .filter(([k]) => reactions[k] > 0) .map( ([k, e]) => `<span class="reaction-pill">${e} ${reactions[k]}</span>`, ); return parts.length ? `<div class="reactions-container">${parts.join("")}</div>` : ""; } function buildContributorsHtml(rel) { // Extract unique non-bot contributors mentioned via @mentions in the body // and the release author const contributors = new Map(); if (rel.author?.login && !rel.author.login.endsWith("[bot]")) { contributors.set(rel.author.login, { login: rel.author.login, avatar: rel.author.avatar_url, html_url: rel.author.html_url, }); } // Try to extract @mentions from the body if (rel.body) { const mentionRe = /@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)/g; let m; // biome-ignore lint/suspicious/noAssignInExpressions: intentional loop while ((m = mentionRe.exec(rel.body)) !== null) { const user = m[1]; if (!contributors.has(user) && !user.endsWith("[bot]")) { contributors.set(user, { login: user, avatar: `https://avatars.githubusercontent.com/${user}?s=64&v=4`, html_url: `https://github.com/${user}`, }); } } } if (contributors.size === 0) return ""; const avatars = [...contributors.values()] .map( (c) => `<a href="${c.html_url}" class="contributor-link"><img src="${c.avatar}" alt="@${Utils.escapeHtml(c.login)}" width="32" height="32" class="avatar-circle"></a>`, ) .join("\n"); const names = [...contributors.values()] .map((c) => Utils.escapeHtml(c.login)) .join(", "); return `<div class="contributors-section"><h3>Contributors</h3><div class="contributor-avatars">${avatars}</div><div class="contributor-names">${names}</div></div>`; } function build() { const repo = Utils.repoFullName() || "?/?"; buildHtml(repo, items); buildMd(repo, items); } /** Build HTML+MD from an arbitrary array of release objects. Returns { html, md }. */ function buildFrom(releaseItems, repo) { const r = repo || Utils.repoFullName() || "?/?"; let h = "", m = ""; // Temporarily swap and build const prevHtml = html, prevMd = md; buildHtml(r, releaseItems); buildMd(r, releaseItems); h = html; m = md; html = prevHtml; md = prevMd; return { html: h, md: m }; } function buildHtml(repo, releaseItems) { const title = `Releases — ${repo}`; let body = ""; for (const rel of releaseItems) { const tag = rel.tag_name || ""; const name = rel.name || tag; const authorLogin = rel.author?.login || ""; const authorAvatar = rel.author?.avatar_url || ""; const authorUrl = rel.author?.html_url || `https://github.com/${authorLogin}`; const date = rel.published_at || rel.created_at || ""; const dateShort = date ? date.slice(0, 10) : ""; const dateIso = date; const safeTitle = (name || "") .replace(/</g, "<") .replace(/>/g, ">"); const bodyHtml = rel.body ? renderMd(rel.body, repo) : "<em>No release notes.</em>"; // Badges let badgesHtml = ""; if (rel.prerelease) { badgesHtml += '<span class="label label-warning">Pre-release</span>'; } if (rel.draft) { badgesHtml += '<span class="label label-draft">Draft</span>'; } // Sidebar: date, author, tag, commit let sidebarHtml = '<div class="release-sidebar">'; if (dateShort) { sidebarHtml += `<div class="sidebar-item"><time datetime="${Utils.escapeHtml(dateIso)}">${dateShort}</time></div>`; } if (authorLogin) { sidebarHtml += `<div class="sidebar-item">`; if (authorAvatar) { sidebarHtml += `<img src="${Utils.escapeHtml(authorAvatar)}&s=40" alt="@${Utils.escapeHtml(authorLogin)}" width="20" height="20" class="avatar-small"> `; } sidebarHtml += `<a href="${Utils.escapeHtml(authorUrl)}" class="muted-link">${Utils.escapeHtml(authorLogin)}</a></div>`; } sidebarHtml += `<div class="sidebar-item"><svg class="octicon" viewBox="0 0 16 16" width="16" height="16"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"></path></svg> <span class="wb-break-all">${Utils.escapeHtml(tag)}</span></div>`; if (rel.target_commitish) { const shortSha = rel.target_commitish.length > 7 ? rel.target_commitish.slice(0, 7) : rel.target_commitish; sidebarHtml += `<div class="sidebar-item"><svg class="octicon" viewBox="0 0 16 16" width="16" height="16"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"></path></svg> <code>${Utils.escapeHtml(shortSha)}</code></div>`; } sidebarHtml += "</div>"; // Assets let assetsHtml = ""; const allAssets = buildAssetList(rel); if (allAssets.length > 0) { const rows = allAssets .map((a) => { const sha = a.digest ? a.digest.replace(/^sha256:/i, "") : ""; const shaShort = sha ? `${sha.slice(0, 8)}…${sha.slice(-6)}` : ""; const shaCell = sha ? `<code title="${Utils.escapeHtml(sha)}">${shaShort}</code>` : ""; const dlCount = a.download_count != null ? a.download_count.toLocaleString() : ""; return `<tr><td>${a.type}</td><td><a href="${a.url}" target="_blank">${Utils.escapeHtml(a.name)}</a></td><td style="text-align:right">${Utils.formatBytes(a.size)}</td><td style="text-align:right">${dlCount}</td><td>${a.uploaded}</td><td>${shaCell}</td></tr>`; }) .join(""); assetsHtml = `<details open><summary class="assets-summary"><span class="assets-title">Assets</span> <span class="counter">${allAssets.length}</span></summary><table class="assets-table"><thead><tr><th>Type</th><th>File</th><th style="text-align:right">Size</th><th style="text-align:right">Downloads</th><th>Uploaded</th><th>SHA256</th></tr></thead><tbody>${rows}</tbody></table></details>`; } const reactionsHtml = buildReactionsHtml(rel.reactions); const contributorsHtml = buildContributorsHtml(rel); body += `<section class="release-section"> <div class="release-flex"> ${sidebarHtml} <div class="release-main"> <div class="Box"> <div class="Box-body"> <div class="release-header"> <div class="release-title-row"> <h2 class="release-title"><a href="${rel.html_url}" target="_blank" class="Link--primary">${safeTitle}</a></h2> ${badgesHtml ? `<span class="release-badges">${badgesHtml}</span>` : ""} </div> </div> <div class="markdown-body">${bodyHtml}</div> </div> <div class="Box-footer"> ${contributorsHtml} ${assetsHtml} ${reactionsHtml} </div> </div> </div> </div> </section>`; } html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${title}</title> <style> *{box-sizing:border-box} body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans',Helvetica,Arial,sans-serif;margin:0;padding:40px;background:#0d1117;color:#c9d1d9;line-height:1.5} a{color:#58a6ff;text-decoration:none} a:hover{text-decoration:underline} h1{font-size:22px;margin-bottom:24px;color:#f0f6fc} .release-section{margin-bottom:40px} .release-flex{display:flex;flex-direction:row;gap:24px} .release-sidebar{width:180px;flex-shrink:0;padding-top:4px;font-size:14px;color:#8b949e} .release-sidebar .sidebar-item{margin-bottom:8px;display:flex;align-items:center;gap:6px;word-break:break-all} .release-sidebar time{display:block} .release-sidebar .avatar-small{border-radius:50%;vertical-align:middle} .release-sidebar .muted-link{color:#8b949e} .release-sidebar .muted-link:hover{color:#58a6ff} .release-sidebar .octicon{fill:#8b949e;flex-shrink:0} .release-sidebar code{font-size:12px;background:#21262d;padding:1px 4px;border-radius:3px} .release-main{flex:1;min-width:0} .Box{border:1px solid #30363d;border-radius:6px;overflow:hidden} .Box-body{padding:16px} .Box-footer{padding:16px;border-top:1px solid #30363d} .release-header{margin-bottom:12px} .release-title-row{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px} .release-title{margin:0;font-size:24px;font-weight:700} .release-title a{color:#f0f6fc} .release-title a:hover{color:#58a6ff} .Link--primary{color:#f0f6fc} .Link--primary:hover{color:#58a6ff} .release-badges{display:inline-flex;gap:6px;align-items:center} .label{display:inline-block;padding:2px 8px;font-size:12px;font-weight:600;border-radius:2em;line-height:1.5} .label-warning{background:rgba(210,153,34,.2);color:#d29922;border:1px solid rgba(210,153,34,.4)} .label-draft{background:rgba(110,119,129,.2);color:#8b949e;border:1px solid rgba(110,119,129,.4)} .markdown-body{font-size:14px;line-height:1.6} .markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{margin:16px 0 8px;color:#f0f6fc;border-bottom:1px solid #21262d;padding-bottom:4px} .markdown-body h1{font-size:1.6em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em} .markdown-body ul,.markdown-body ol{padding-left:24px;margin:8px 0} .markdown-body li{margin:4px 0} .markdown-body blockquote{border-left:3px solid #30363d;padding-left:12px;color:#8b949e;margin:8px 0} .markdown-body p{margin:8px 0} .markdown-body img{max-width:100%;height:auto;border-radius:6px} .markdown-body hr{border:none;border-top:1px solid #30363d;margin:16px 0} .markdown-body code{font-size:12px;background:#21262d;padding:1px 4px;border-radius:3px} .markdown-body pre{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;overflow-x:auto;font-size:13px;line-height:1.45} .markdown-body pre code{background:none;padding:0;font-size:inherit} .markdown-body a.user-mention,.markdown-body a.commit-link{font-weight:600} table{border-collapse:collapse} .assets-summary{cursor:pointer;padding:8px 0;display:flex;align-items:center;gap:8px} .assets-title{font-size:18px;font-weight:700} .counter{display:inline-block;padding:0 6px;font-size:12px;font-weight:600;line-height:18px;color:#c9d1d9;background:rgba(110,118,129,.4);border-radius:2em} .assets-table{width:100%;border-collapse:collapse;font-size:13px;margin-top:4px} .assets-table thead tr{border-bottom:2px solid #30363d;text-align:left} .assets-table th{padding:4px 8px;font-weight:600;color:#8b949e} .assets-table td{padding:4px 8px;border-bottom:1px solid #21262d} .assets-table tr:hover{background:#161b22} .assets-table code{font-size:11px} .contributors-section{margin-bottom:12px} .contributors-section h3{margin:0 0 8px;font-size:14px;font-weight:600} .contributor-avatars{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px} .contributor-link{display:inline-block} .avatar-circle{border-radius:50%;vertical-align:middle} .contributor-names{font-size:12px;color:#8b949e;margin-top:4px} .reactions-container{display:flex;flex-wrap:wrap;gap:4px;margin-top:12px;align-items:center} .reaction-pill{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;font-size:12px;line-height:20px;border:1px solid #30363d;border-radius:2em;background:transparent;color:#c9d1d9;cursor:default} .reaction-pill:hover{background:#21262d} .wb-break-all{word-break:break-all} @media (max-width:768px){ .release-flex{flex-direction:column} .release-sidebar{width:100%;display:flex;flex-wrap:wrap;gap:8px 16px} } </style></head><body><h1>${title}</h1>${body}</body></html>`; } function buildMd(repo, releaseItems) { const lines = [`# Releases — ${repo}`, ""]; for (const rel of releaseItems) { const tag = rel.tag_name || ""; const name = rel.name || tag; const author = rel.author?.login || ""; const date = rel.published_at ? rel.published_at.slice(0, 10) : ""; const flags = [ rel.draft ? "Draft" : "", rel.prerelease ? "Pre-release" : "", ] .filter(Boolean) .join(", "); lines.push(`## ${name}${tag ? ` (\`${tag}\`)` : ""}`); const meta = [author, date, flags].filter(Boolean).join(" · "); if (meta) lines.push(`*${meta}*`); lines.push(""); if (rel.body) { lines.push(rel.body.trim()); lines.push(""); } const allAssets = buildAssetList(rel); if (allAssets.length > 0) { lines.push( "### Assets", "", "| Type | File | Size | Downloads | Uploaded | SHA256 |", "|:-----|:-----|-----:|----------:|:---------|:-------|", ); for (const a of allAssets) { const sha = a.digest ? a.digest.replace(/^sha256:/i, "") : ""; const shaShort = sha ? `${sha.slice(0, 8)}…${sha.slice(-6)}` : ""; const shaCell = sha ? `\`${shaShort}\`` : ""; const dlCount = a.download_count != null ? a.download_count.toLocaleString() : ""; const fileCell = a.url ? `[${a.name}](${a.url})` : a.name; lines.push( `| ${a.type} | ${fileCell} | ${Utils.formatBytes(a.size)} | ${dlCount} | ${a.uploaded} | ${shaCell} |`, ); } lines.push(""); } if (rel.reactions && rel.reactions.total_count > 0) { const emojis = { "+1": "👍", "-1": "👎", laugh: "😄", hooray: "🎉", confused: "😕", heart: "❤️", rocket: "🚀", eyes: "👀", }; const parts = Object.entries(emojis) .filter(([k]) => rel.reactions[k] > 0) .map(([k, e]) => `${e} ${rel.reactions[k]}`); if (parts.length) lines.push(`Reactions: ${parts.join(" · ")}`, ""); } lines.push("---", ""); } md = lines.join("\n"); } function setDebug(on) { dbgLog.setEnabled(on); } function getDebugLog() { return dbgLog.getText(); } function clearDebugLog() { dbgLog.clear(); } let releaseFilter = null; /** * Normalize a partial date string to a full YYYY-MM-DD range. * "2009" → { from: "2009-01-01", to: "2009-12-31" } * "2009-06" → { from: "2009-06-01", to: "2009-06-30" } * "2009-06-15" → { from: "2009-06-15", to: "2009-06-15" } */ function expandPartialDate(dateStr) { const d = dateStr.replace(/\//g, "-"); // Full date: YYYY-MM-DD if (/^\d{4}-\d{2}-\d{2}$/.test(d)) { return { from: d, to: d }; } // Year-month: YYYY-MM if (/^\d{4}-\d{2}$/.test(d)) { const [y, m] = d.split("-").map(Number); const lastDay = new Date(y, m, 0).getDate(); return { from: `${d}-01`, to: `${d}-${String(lastDay).padStart(2, "0")}`, }; } // Year only: YYYY if (/^\d{4}$/.test(d)) { return { from: `${d}-01-01`, to: `${d}-12-31` }; } return null; } /** * Parse release filter string. * Supports: * v1.* → tag glob * v1.0.0-v2.0.0 → tag range (- separator, needs v-prefix or dots) * v1.0.0..v2.0.0 → tag range (.. separator) * 2024-01-01..2025-06-01 → date range (full dates, .. only) * 2009..2010 → date range (year to year) * 2009-06..2010-03 → date range (month to month) * 2009 → single year (all releases in 2009) * 2009-06 → single month (all releases in June 2009) * 2009-06-15 → single day * v2.35.1 → exact tag * * Note: date ranges ONLY use ".." as separator because "-" is ambiguous * with YYYY-MM-DD formatting. Tag ranges accept both "-" and ".." since * tags use dots (v1.0.0) not dashes for structure. */ function parseReleaseFilter(str) { if (!str?.trim()) return null; const s = str.trim(); // Date range with .. separator: "2009..2010", "2024-01..2025-06", "2024-01-01..2025-06-01" const dateDotDot = s.match( /^(\d{4}(?:[-/]\d{2}(?:[-/]\d{2})?)?)\.\.(\d{4}(?:[-/]\d{2}(?:[-/]\d{2})?)?)$/, ); if (dateDotDot) { const fromExp = expandPartialDate(dateDotDot[1]); const toExp = expandPartialDate(dateDotDot[2]); if (fromExp && toExp) { return { type: "date", from: fromExp.from, to: toExp.to }; } } // Single date: "2009", "2009-06", "2009-06-15" // Only match if the string is purely a date pattern (no letters, no dots beyond date separators) const normalizedForDateCheck = s.replace(/\//g, "-"); if (/^\d{4}(?:-\d{2}(?:-\d{2})?)?$/.test(normalizedForDateCheck)) { const singleDate = expandPartialDate(s); if (singleDate) { return { type: "date", from: singleDate.from, to: singleDate.to }; } } // Tag range: v1.0.0-v2.0.0 or v1.0.0..v2.0.0 // At least one side must have a v-prefix or contain a dot to distinguish // from date expressions. Pure numeric ranges are not treated as tag ranges. const tagRange = s.match( /^(v?\d+\S*?)(?:\s*[-–]\s*|\s*\.\.\s*)(v?\d+\S*)$/i, ); if (tagRange) { const left = tagRange[1]; const right = tagRange[2]; const hasTagChars = /[v.]/i.test(left) || /[v.]/i.test(right); if (hasTagChars) { return { type: "tagRange", from: left, to: right }; } } // Glob: v1.*, v2.0.* if (s.includes("*")) { // Escape regex special chars (split to avoid character class parse issues) const escaped = s .replace(/\\/g, "\\\\") .replace(/[.+^${}()|]/g, "\\$&") .replace(/$/g, "$") .replace(/$/g, "$") .replace(/\*/g, ".*"); const regex = new RegExp("^" + escaped + "$", "i"); return { type: "glob", regex }; } // Exact tag return { type: "exact", tag: s }; } function matchesReleaseFilter(rel, filter) { if (!filter) return true; const tag = rel.tag_name || ""; const date = (rel.published_at || rel.created_at || "").slice(0, 10); switch (filter.type) { case "exact": return tag.toLowerCase() === filter.tag.toLowerCase(); case "glob": return filter.regex.test(tag); case "date": { if (!date) return false; return date >= filter.from && date <= filter.to; } case "tagRange": { const normTag = (t) => t.replace(/^v/i, ""); const normFrom = normTag(filter.from); const normTo = normTag(filter.to); const normCur = normTag(tag); // Pad segments for numeric comparison: 1.2.3 → 00001.00002.00003 const padVer = (v) => v .split(/[.-]/) .map((p) => (/^\d+$/.test(p) ? p.padStart(10, "0") : p)) .join("."); const pFrom = padVer(normFrom); const pTo = padVer(normTo); const pCur = padVer(normCur); const lo = pFrom <= pTo ? pFrom : pTo; const hi = pFrom <= pTo ? pTo : pFrom; return pCur >= lo && pCur <= hi; } default: return true; } } function setReleaseFilter(str) { releaseFilter = parseReleaseFilter(str); } let resumePage = 1; async function fetchAll(cb) { const repo = Utils.repoFullName(); if (!repo) return false; const resuming = paused && items.length > 0; running = true; paused = false; if (!resuming) { items = []; resumePage = 1; } const [owner, name] = repo.split("/"); let page = resumePage; _dbg( resuming ? "═══ RESUMING RELEASE FETCH ═══" : "═══ RELEASE FETCH START ═══", ); if (resuming) _dbg(`Resuming from page ${page} with ${items.length} items`); while (running) { const url = `/repos/${owner}/${name}/releases?per_page=100&page=${page}`; _dbg(`→ GET ${url}`); cb(`Fetching page ${page}…`); let data; try { const resp = await API.request(url); data = resp.data; _dbg( `← OK | RL remaining: ${API.rateLimit.core.remaining} | reset: ${Utils.formatReset(API.rateLimit.core.reset)}`, ); } catch (err) { _dbg(`✗ ${err.message}`); running = false; paused = true; resumePage = page; if (err.message.includes("Rate limit")) { cb( `⚠️ Rate limited. ${items.length} releases fetched so far. Click Resume when limit resets.`, ); } else { cb(`Error: ${err.message}`); } return false; } if (!Array.isArray(data)) { running = false; return false; } _dbg(` Received ${data.length} releases (page ${page})`); if (releaseFilter) { const matched = data.filter((r) => matchesReleaseFilter(r, releaseFilter), ); _dbg(` Filter matched: ${matched.length}/${data.length}`); items.push(...matched); } else { items.push(...data); } cb(`Fetched ${items.length} releases…`); if (data.length < 100) break; page++; resumePage = page; await Utils.delay(280); } // If stopped by cancel (running became false mid-loop), mark as paused if (!running && items.length > 0 && page <= 500) { paused = true; resumePage = page; if (items.length > 0) { build(); } cb(`Paused. ${items.length} releases fetched so far.`); return false; } running = false; if (items.length === 0) { cb("No releases found."); return false; } build(); _dbg(`═══ RELEASE FETCH DONE · ${items.length} releases ═══`); cb(`Done · ${items.length} releases`); return true; } function cancel() { running = false; } function isPaused() { return paused; } function state() { const s = API.rateLimitSummary; return { count: items.length, running, rlRemain: s.remaining, rlReset: s.reset, hasData: html.length > 0, finished: !running && items.length > 0 && !paused, paused, }; } return { fetchAll, cancel, setDebug, getDebugLog, clearDebugLog, state, build, buildFrom, setReleaseFilter, isPaused, _getItems: () => [...items], getHtml: () => html, getMd: () => md, hasData: () => html.length > 0, reset: () => { items = []; html = ""; md = ""; running = false; paused = false; resumePage = 1; releaseFilter = null; }, }; })(); // ════════════════════════════════════════════════════════════════════════════ // REPO INDEX MODULE (Issues / PRs / Discussions listing) // ════════════════════════════════════════════════════════════════════════════ const RepoIndexModule = (() => { let items = [], lowest = Infinity, highest = 0, running = false; let curPage = 1, rangeFilter = null; let html = "", md = ""; let paused = false, gqlBatchIndex = 0, discussionCursor = null; let fetchOpts = {}; const CFG = { perPage: 100, delayMs: 280, maxPages: 500 }; const dbgLog = createDebugLogger("[Index]"); const _dbg = (msg, data) => dbgLog.log(msg, data); function setDebug(on) { dbgLog.setEnabled(on); } function getDebugLog() { return dbgLog.getText(); } function clearDebugLog() { dbgLog.clear(); } // ── Range parsing ── function parseRange(str) { if (str === null || str === undefined || !String(str).trim()) return null; const parts = String(str) .split(",") .map((s) => s.trim()) .filter(Boolean); if (parts.length === 0) return null; const ranges = []; for (const part of parts) { const m = part.match(/^(\d+)\s*-\s*(\d+)$/); if (m) { const lo = parseInt(m[1], 10), hi = parseInt(m[2], 10); if (!Number.isNaN(lo) && !Number.isNaN(hi)) ranges.push([Math.min(lo, hi), Math.max(lo, hi)]); } else { const n = parseInt(part, 10); if (!Number.isNaN(n) && n > 0) ranges.push([n, n]); } } return ranges.length > 0 ? ranges : null; } function inRange(num) { if (!rangeFilter) return true; return rangeFilter.some(([lo, hi]) => num >= lo && num <= hi); } function setRange(rangeStr) { rangeFilter = parseRange(rangeStr); if (dbgLog.isEnabled()) _dbg("Range set:", rangeFilter ? JSON.stringify(rangeFilter) : "none"); } // ── REST URL builder ── function apiUrl(page, type) { const repo = Utils.repoFullName(); if (!repo) return null; const [o, n] = repo.split("/"); const endpoint = type === "pulls" ? "pulls" : "issues"; const u = new URL(`${API_BASE}/repos/${o}/${n}/${endpoint}`); u.searchParams.set("state", "all"); u.searchParams.set("per_page", CFG.perPage); u.searchParams.set("page", page); u.searchParams.set("sort", "created"); u.searchParams.set("direction", "asc"); return u.toString(); } // ── Build output ── function build(opts) { const repo = Utils.repoFullName() || "?/?"; const selParts = []; if (!opts || opts.issues) selParts.push("Issues"); if (!opts || opts.prs) selParts.push("Pull Requests"); if (!opts || opts.discussions) selParts.push("Discussions"); const selLabel = selParts.length === 3 ? "Issues, Pull Requests & Discussions" : selParts.length === 2 ? `${selParts[0]} & ${selParts[1]}` : selParts[0] || "Items"; const title = `${selLabel} Index — ${repo}`; const typed = items .map((i) => { if (i.pull_request) return { ...i, _k: "pr", _l: "Pull Request" }; if (i.discussion) return { ...i, _k: "discussion", _l: "DISCUSSION" }; return { ...i, _k: "issue", _l: "ISSUE" }; }) .sort((a, b) => a.number - b.number); const c = { issue: 0, pr: 0, discussion: 0 }; // biome-ignore lint/suspicious/useIterableCallbackReturn: increment has side effect, return unused typed.forEach((i) => c[i._k]++); const summary = `Total: ${items.length} (Issues: ${c.issue} · Pull Requests: ${c.pr} · Discussions: ${c.discussion})`; // ── HTML output ── const rows = typed .map((i) => { const isDsc = i._k === "discussion"; const st = isDsc ? i.isAnswered ? { l: "ANSWERED", c: "closed" } : { l: "OPEN", c: "open" } : i.state === "open" ? { l: "OPEN", c: "open" } : i.state_reason === "not_planned" ? { l: "NOT PLANNED", c: "not-planned" } : { l: "CLOSED", c: "closed" }; const labels = (i.labels || []) .map((l) => (typeof l === "object" ? l.name : l)) .join(", "); const meta = [ i._l, st.l, `${new Date(i.created_at).toISOString().slice(0, 10)}`, labels ? labels : "", ] .filter(Boolean) .join(" · "); const safe = (i.title || "") .replace(/</g, "<") .replace(/>/g, ">"); return `<li class="item ${st.c} ${i._k}"><a href="${i.html_url}" target="_blank">#${i.number} · ${safe}</a><div class="meta">${meta}</div></li>`; }) .join(""); html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${title}</title> <style>body{font-family:system-ui;margin:40px;background:#0d1117;color:#c9d1d9;line-height:1.5} a{color:#58a6ff;text-decoration:none}h1{font-size:22px;margin-bottom:8px} .summary{color:#8b949e;margin-bottom:20px}ul{list-style:none;padding:0} li{padding:10px 0;border-bottom:1px solid #30363d}.meta{font-size:13px;color:#8b949e;margin-top:4px} .open a{color:#3fb950}.pr a{color:#58a6ff}.discussion a{color:#8957e5} .closed a,.not-planned a{color:#f85149} .note{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px 14px;margin-bottom:16px;font-size:12px;color:#8b949e}</style></head><body> <h1>${title}</h1> <div class="note">ℹ️ This is an index listing — titles, status, and links only. Full content (descriptions, comments, diffs) is not included.</div> <div class="summary">Repository: <strong>${repo}</strong><br>${summary}</div> <ul>${rows}</ul></body></html>`; // ── Markdown output ── const mdl = [ `# ${title}`, "", `> ℹ️ This is an index listing — titles, status, and links only. Full content (descriptions, comments, diffs) is not included.`, "", `Repository: \`${repo}\``, `${summary}`, "", ]; typed.forEach((i) => { const isDsc = i._k === "discussion"; const stLabel = isDsc ? i.isAnswered ? "ANSWERED" : "OPEN" : i.state === "open" ? "OPEN" : i.state_reason === "not_planned" ? "NOT PLANNED" : "CLOSED"; mdl.push( `- [#${i.number} ${i.title}](${i.html_url}) — ${i._l} · **${stLabel}**`, ); }); mdl.push(""); md = mdl.join("\n"); } // ── Count total numbers in a range filter ── function rangeCount() { if (!rangeFilter) return Infinity; let total = 0; for (const [lo, hi] of rangeFilter) total += hi - lo + 1; return total; } // ── Direct REST lookup for small ranges (no pagination waste) ── async function fetchByDirectREST(cb, wantIssues, wantPRs) { const numbers = []; for (const [lo, hi] of rangeFilter) { for (let n = lo; n <= hi; n++) numbers.push(n); } _dbg(`Direct REST lookup: ${numbers.length} numbers`); const repo = Utils.repoFullName(); if (!repo) return; const [owner, name] = repo.split("/"); for (let i = 0; i < numbers.length && running; i++) { const num = numbers[i]; if (items.some((e) => e.number === num)) continue; cb(`Fetching #${num} (${i + 1}/${numbers.length})…`); _dbg(`→ GET /repos/${owner}/${name}/issues/${num}`); try { const { data } = await API.request( `/repos/${owner}/${name}/issues/${num}`, ); const isPR = !!data.pull_request; if (isPR && !wantPRs) { _dbg(` #${num} is PR, skipped`); continue; } if (!isPR && !wantIssues) { _dbg(` #${num} is issue, skipped`); continue; } items.push(data); if (data.number < lowest) lowest = data.number; if (data.number > highest) highest = data.number; _dbg(` Added #${num} (${isPR ? "PR" : "issue"})`); } catch (e) { if (e.message.includes("404")) { _dbg(` #${num} not found (may be discussion or deleted)`); } else if (e.message.includes("Rate limit")) { _dbg(`⚠ Rate limited at #${num}`); paused = true; running = false; cb(); return; } else { _dbg(` #${num} error: ${e.message}`); } } build(fetchOpts); cb(); if (i < numbers.length - 1) await Utils.delay(CFG.delayMs); } } // ── REST fetch: Issues and/or PRs ── async function fetchIssuesPRs(cb, wantIssues, wantPRs) { // GraphQL batch when range + token if (rangeFilter && Token.has()) { _dbg("Range + token → using GraphQL batch lookup"); await fetchByGraphQL(cb, wantIssues, wantPRs); return; } // Direct REST for small ranges without token (≤50 items) if (rangeFilter && !Token.has() && rangeCount() <= 50) { _dbg(`Range (${rangeCount()} items) + no token → direct REST lookup`); await fetchByDirectREST(cb, wantIssues, wantPRs); return; } if (rangeFilter && !Token.has()) { _dbg( "Range set but too large for direct REST without token → paginated fallback", ); } const endpoints = []; if (wantIssues && wantPRs) { endpoints.push({ type: "issues", wantIssues: true, wantPRs: true }); } else if (wantIssues) { endpoints.push({ type: "issues", wantIssues: true, wantPRs: false }); } else if (wantPRs) { endpoints.push({ type: "pulls", wantIssues: false, wantPRs: true }); } for (const ep of endpoints) { let page = ep.type === "pulls" ? 1 : curPage; let emptyStreak = 0; const maxEmptyPages = 3; _dbg( `Endpoint: /${ep.type} | wantIssues=${ep.wantIssues} wantPRs=${ep.wantPRs}`, ); while (running && page <= CFG.maxPages) { const url = apiUrl(page, ep.type); cb(); _dbg(`→ GET ${url}`); let data; try { const resp = await API.request(url); data = resp.data; _dbg( `← OK | RL remaining: ${API.rateLimit.core.remaining} | reset: ${Utils.formatReset(API.rateLimit.core.reset)}`, ); } catch (err) { _dbg(`✗ ${err.message}`); if (err.message.includes("Rate limit")) { paused = true; curPage = page; running = false; cb(); return; } await Utils.delay(5000); continue; } if (!Array.isArray(data)) { _dbg("✗ Not an array."); break; } _dbg(` Received ${data.length} items (page ${page})`); let added = 0, skipped = { dup: 0, range: 0, filter: 0 }; for (const i of data) { if (items.some((e) => e.number === i.number)) { skipped.dup++; continue; } if (!inRange(i.number)) { skipped.range++; continue; } const isPR = !!i.pull_request; if (ep.type === "issues") { if (isPR && !ep.wantPRs) { skipped.filter++; continue; } if (!isPR && !ep.wantIssues) { skipped.filter++; continue; } } if (ep.type === "pulls" && !i.pull_request) { i.pull_request = { url: i.url }; } items.push(i); if (i.number < lowest) lowest = i.number; if (i.number > highest) highest = i.number; added++; } _dbg( ` Added: ${added} | Skipped — dup: ${skipped.dup}, range: ${skipped.range}, filter: ${skipped.filter}`, ); if (added === 0 && data.length > 0 && skipped.dup === data.length) { _dbg(" All duplicates. Stopping."); break; } if (added === 0 && data.length > 0) { emptyStreak++; _dbg(` 0 matched (streak: ${emptyStreak}/${maxEmptyPages})`); if (emptyStreak >= maxEmptyPages) { _dbg(" Too many empty pages."); break; } } else { emptyStreak = 0; } build(fetchOpts); cb(); if (data.length < CFG.perPage) break; page++; if (ep.type !== "pulls") curPage = page; await Utils.delay(CFG.delayMs); } } } // ── GraphQL batch fetch (for range + token) ── async function fetchByGraphQL(cb, wantIssues, wantPRs) { const numbers = []; for (const [lo, hi] of rangeFilter) { for (let n = lo; n <= hi; n++) numbers.push(n); } _dbg( `GraphQL batch: ${numbers.length} numbers (#${numbers[0]}–#${numbers[numbers.length - 1]}), starting at index ${gqlBatchIndex}`, ); const repo = Utils.repoFullName(); if (!repo) return; const [owner, name] = repo.split("/"); const batchSize = 50; for ( let i = gqlBatchIndex; i < numbers.length && running; i += batchSize ) { const batch = numbers.slice(i, i + batchSize); const batchNum = Math.floor(i / batchSize) + 1; const totalBatches = Math.ceil(numbers.length / batchSize); cb( `GraphQL batch ${batchNum}/${totalBatches} (#${batch[0]}–#${batch[batch.length - 1]})…`, ); const fields = batch .map( (n) => `i${n}: issueOrPullRequest(number: ${n}) { __typename ... on Issue { number title state stateReason url createdAt updatedAt labels(first:20) { nodes { name } } } ... on PullRequest { number title state url createdAt updatedAt labels(first:20) { nodes { name } } } }`, ) .join("\n"); const query = `{ repository(owner: "${owner}", name: "${name}") {\n${fields}\n} }`; _dbg(`→ POST /graphql (batch ${batchNum}/${totalBatches})`); let j; try { // Use API.request for /graphql to get rate limit tracking, but handle errors/NOT_FOUND ourselves const resp = await API.request("/graphql", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }), }); j = resp.data; _dbg(`← OK | RL remaining: ${API.rateLimit.graphql.remaining}`); } catch (err) { _dbg(`✗ ${err.message}`); if (err.message.includes("Rate limit")) { gqlBatchIndex = i; paused = true; running = false; cb(); return; } await Utils.delay(5000); continue; } if (j.errors) { const realErrors = j.errors.filter((e) => e.type !== "NOT_FOUND"); if (realErrors.length) _dbg("✗ GraphQL errors:", realErrors); const notFound = j.errors.filter( (e) => e.type === "NOT_FOUND", ).length; if (notFound) _dbg(` ${notFound} numbers not found`); } const repoData = j.data?.repository || j.repository || {}; let added = 0, skippedType = 0; for (const key of Object.keys(repoData)) { const item = repoData[key]; if (!item) continue; const isIssue = item.__typename === "Issue"; const isPR = item.__typename === "PullRequest"; if (isIssue && !wantIssues) { skippedType++; continue; } if (isPR && !wantPRs) { skippedType++; continue; } if (items.some((e) => e.number === item.number)) continue; const normalized = { number: item.number, title: item.title, state: item.state === "OPEN" ? "open" : "closed", state_reason: item.stateReason === "NOT_PLANNED" ? "not_planned" : null, html_url: item.url, created_at: item.createdAt, updated_at: item.updatedAt, labels: (item.labels?.nodes || []).map((l) => ({ name: l.name })), }; if (isPR) normalized.pull_request = { url: item.url }; items.push(normalized); if (item.number < lowest) lowest = item.number; if (item.number > highest) highest = item.number; added++; } _dbg(` Added: ${added} | Skipped (type): ${skippedType}`); gqlBatchIndex = i + batchSize; build(fetchOpts); cb(); await Utils.delay(CFG.delayMs); } } // ── Discussions: handle cancel/rate-limit as pause ── async function fetchDiscussions(cb) { const repo = Utils.repoFullName(); if (!repo) return; const [owner, name] = repo.split("/"); let after = discussionCursor; _dbg( `Discussions fetch for ${owner}/${name}${after ? " (resuming)" : ""}`, ); do { const q = `{repository(owner:"${owner}",name:"${name}"){discussions(first:${CFG.perPage}${after ? `,after:"${after}"` : ""}){pageInfo{endCursor hasNextPage}edges{node{number title url createdAt updatedAt isAnswered labels(first:100){edges{node{name}}}}}}}}`; _dbg(`→ POST /graphql (discussions, after=${after || "null"})`); let j; try { const resp = await API.request("/graphql", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: q }), }); j = resp.data; _dbg(`← OK | RL remaining: ${API.rateLimit.graphql.remaining}`); } catch (err) { _dbg(`✗ ${err.message}`); if (err.message.includes("Rate limit")) { discussionCursor = after; paused = true; running = false; cb(); return; } await Utils.delay(5000); continue; } if (j.errors) { _dbg("✗ GraphQL errors:", j.errors); break; } const edges = j.data?.repository?.discussions?.edges || []; _dbg(` Received ${edges.length} discussions`); for (const e of edges) { const i = e.node; i.html_url = i.url; i.discussion = true; i.created_at = i.createdAt; i.updated_at = i.updatedAt; i.labels = i.labels.edges.map((x) => x.node.name); if (items.some((x) => x.number === i.number)) continue; if (!inRange(i.number)) continue; items.push(i); if (i.number < lowest) lowest = i.number; if (i.number > highest) highest = i.number; } build(fetchOpts); cb(); const pageData = j.data?.repository?.discussions || j.repository?.discussions; after = pageData?.pageInfo?.endCursor; discussionCursor = after; if (!pageData?.pageInfo?.hasNextPage) break; await Utils.delay(CFG.delayMs); } while (running && after); } // ── Main entry point ── let generationCounter = 0; async function run(cb, opts) { fetchOpts = opts; const resuming = paused && items.length > 0; paused = false; running = true; _dbg(resuming ? "═══ RESUMING EXPORT ═══" : "═══ EXPORT START ═══"); _dbg("Options:", { issues: opts.issues, prs: opts.prs, discussions: opts.discussions, }); _dbg("Range:", rangeFilter ? JSON.stringify(rangeFilter) : "none"); _dbg("Token:", Token.has() ? "set" : "not set"); if (resuming) _dbg( `Resuming with ${items.length} items (page ${curPage}, gqlBatch ${gqlBatchIndex})`, ); cb(); if (opts.issues || opts.prs) await fetchIssuesPRs(cb, opts.issues, opts.prs); if (running && opts.discussions) { // fetchDiscussions is defined above (replaced duplicate below) const repo = Utils.repoFullName(); if (repo && Token.has()) { await fetchDiscussions(cb); } else if (repo && !Token.has()) { _dbg("Discussions require token — skipping"); } } // If stopped mid-run by cancel, mark as paused if (!running && items.length > 0) { paused = true; build(fetchOpts); cb(); } running = false; generationCounter++; build(fetchOpts); _dbg(`═══ EXPORT DONE · ${items.length} items ═══`); cb(); } function cancel() { running = false; } function state() { const s = API.rateLimitSummary; return { count: items.length, lowest, highest, running, rlRemain: s.remaining, rlReset: s.reset, hasData: html.length > 0, finished: !running && items.length > 0 && !paused, paused, }; } function fileSlug() { const parts = []; if ( !fetchOpts || (!fetchOpts.issues && !fetchOpts.prs && !fetchOpts.discussions) ) { parts.push("items"); } else { if (fetchOpts.issues) parts.push("issues"); if (fetchOpts.prs) parts.push("prs"); if (fetchOpts.discussions) parts.push("discussions"); } return parts.join("-"); } return { run, cancel, setRange, setDebug, getDebugLog, clearDebugLog, state, fileSlug, getHtml: () => html, getMd: () => md, isPaused: () => paused, /** Return a shallow copy of the collected items for batch export */ _getItems: () => [...items], /** Generation counter — incremented each time a fetch completes */ get _generation() { return generationCounter; }, reset: () => { items = []; lowest = Infinity; highest = 0; html = ""; md = ""; running = false; curPage = 1; rangeFilter = null; fetchOpts = {}; paused = false; gqlBatchIndex = 0; discussionCursor = null; }, }; })(); // ════════════════════════════════════════════════════════════════════════════ // MARKDOWN GENERATOR HELPERS (shared across issue/PR/discussion) // ════════════════════════════════════════════════════════════════════════════ const MdGen = { // ── Shared helpers ── _repoFromUrl(htmlUrl) { const m = (htmlUrl || "").match(/github\.com\/([^/]+)\/([^/]+)/); return m ? `${m[1]}/${m[2]}` : ""; }, _hasLabel(obj, name) { return (obj?.labels || []).some( (l) => String(l?.name || "").toLowerCase() === String(name || "").toLowerCase(), ); }, _shortSha(sha) { const s = String(sha || ""); return s.length > 7 ? s.slice(0, 7) : s; }, _commitApiToHtml(apiUrl, sha) { try { const m = String(apiUrl || "").match( /api\.github\.com\/repos\/([^/]+)\/([^/]+)\/commits\/([a-f0-9]+)/i, ); if (m) return `https://github.com/${m[1]}/${m[2]}/commit/${m[3]}`; } catch {} return sha ? `https://github.com/commit/${sha}` : String(apiUrl || ""); }, // ── Header (shared for issue/PR) ── generateHeader(issue, cfg, prData, requestedReviewers) { const lines = []; const isPR = !!issue.pull_request || !!prData; const repo = this._repoFromUrl(issue.html_url); // biome-ignore lint/correctness/noUnusedVariables: destructured for clarity, may be used later const [owner, repoName] = repo.split("/"); lines.push(`> **Repository:** [${repo}](https://github.com/${repo}) `); const typeLabel = isPR ? "Pull Request" : "Issue"; lines.push(`> **${typeLabel}:** [#${issue.number}](${issue.html_url}) `); // State if (isPR && prData) { if (prData.merged) lines.push(`> **State:** 🟣 merged `); else if (prData.draft) lines.push(`> **State:** ⚪ draft `); else if (issue.state === "open") lines.push(`> **State:** 🟢 open `); else lines.push(`> **State:** 🔴 closed `); } else { const emoji = issue.state === "open" ? "🟢" : issue.state_reason === "completed" ? "🟣" : "🔴"; const label = issue.state === "closed" ? issue.state_reason ? `closed (${issue.state_reason})` : "closed" : "open"; lines.push(`> **State:** ${emoji} ${label} `); } // PR-specific if (isPR && prData) { if (cfg.PR_INCLUDE_BRANCH_INFO && prData.head && prData.base) { const headLabel = prData.head.label || `${prData.head.repo?.full_name || ""}:${prData.head.ref}`; const baseLabel = prData.base.label || `${prData.base.repo?.full_name || ""}:${prData.base.ref}`; lines.push(`> **Branch:** \`${headLabel}\` → \`${baseLabel}\` `); } if (cfg.PR_INCLUDE_MERGE_STATUS && prData.merged) { const by = prData.merged_by ? `[@${prData.merged_by.login}](${prData.merged_by.html_url})` : "unknown"; lines.push( `> **Merged by:** ${by} on ${Utils.formatDate(prData.merged_at)} `, ); } if (cfg.PR_INCLUDE_DIFF_STATS) { lines.push( `> **Changes:** +${prData.additions || 0} / -${prData.deletions || 0} across ${prData.changed_files || 0} files `, ); } if (cfg.PR_INCLUDE_REQUESTED_REVIEWERS && requestedReviewers) { const users = (requestedReviewers.users || []).map( (u) => `[@${u.login}](${u.html_url})`, ); const teams = (requestedReviewers.teams || []).map( (t) => `@${t.slug}`, ); const all = [...users, ...teams]; if (all.length > 0) lines.push(`> **Requested Reviewers:** ${all.join(", ")} `); } } else { // Issue-specific header fields if (cfg.ISSUE_INCLUDE_TYPE && issue.type?.name) lines.push(`> **Type:** ${issue.type.name} `); if (cfg.ISSUE_INCLUDE_CLOSED_BY && issue.closed_by) lines.push( `> **Closed by:** [@${issue.closed_by.login}](${issue.closed_by.html_url}) `, ); if ( cfg.ISSUE_INCLUDE_LOCK_REASON && issue.locked && issue.active_lock_reason ) lines.push(`> **Lock reason:** ${issue.active_lock_reason} `); if (issue.issue_dependencies_summary) { const bb = issue.issue_dependencies_summary.blocked_by || 0; const bl = issue.issue_dependencies_summary.blocking || 0; if (bb > 0 || bl > 0) lines.push( `> **Dependencies:** blocked by ${bb} · blocking ${bl} `, ); } if (issue.sub_issues_summary?.total > 0) lines.push(`> **Sub-issues:** ${issue.sub_issues_summary.total} `); if (issue.pinned_comment?.id) lines.push(`> **Pinned comment:** yes `); } // Common fields if (cfg.INCLUDE_LABELS && issue.labels?.length > 0) { lines.push( `> **Labels:** ${issue.labels.map((l) => `\`${l.name}\``).join(", ")} `, ); } if (cfg.INCLUDE_ASSIGNEES && issue.assignees?.length > 0) { lines.push( `> **Assignees:** ${issue.assignees.map((a) => `[@${a.login}](${a.html_url})`).join(", ")} `, ); } if (cfg.INCLUDE_MILESTONE && issue.milestone) { lines.push(`> **Milestone:** ${issue.milestone.title} `); } if (cfg.INCLUDE_TIMESTAMPS) { lines.push(`> **Created:** ${Utils.formatDate(issue.created_at)} `); if (issue.updated_at && issue.updated_at !== issue.created_at) lines.push(`> **Updated:** ${Utils.formatDate(issue.updated_at)} `); if (issue.closed_at) lines.push(`> **Closed:** ${Utils.formatDate(issue.closed_at)} `); } lines.push(""); return lines.join("\n"); }, // ── Single comment block ── generateComment(comment, index, cfg, minimizedMap) { const parts = []; // Check minimized state const minInfo = minimizedMap?.get(comment.id); const isMinimized = minInfo?.isMinimized; if (isMinimized && !cfg.INCLUDE_MINIMIZED_COMMENTS) return ""; let header = `### Comment #${index}`; if (cfg.INCLUDE_AUTHOR_INFO) header += ` — [@${comment.user.login}](${comment.user.html_url})`; if (cfg.INCLUDE_TIMESTAMPS && comment.created_at) header += ` · ${Utils.formatDate(comment.created_at)}`; if (isMinimized) header += ` · 🙈 *minimized${minInfo.minimizedReason ? `: ${minInfo.minimizedReason}` : ""}*`; const isLong = cfg.COLLAPSIBLE_LONG_COMMENTS && comment.body && comment.body.length > cfg.LONG_COMMENT_THRESHOLD; if (isLong && cfg.USE_HTML_DETAILS) { const preview = comment.body.substring(0, 100).replace(/\n/g, " ") + "..."; parts.push(`<details>`); parts.push( `<summary><strong>${header}</strong> — <em>${Utils.escapeHtml(preview)}</em></summary>\n`, ); parts.push(comment.body.trim()); if (cfg.INCLUDE_REACTIONS && comment.reactions) { const r = Utils.formatReactions(comment.reactions); if (r) parts.push(`\n${r}`); } parts.push(`\n</details>\n`); } else { parts.push(`${header}\n`); if (cfg.INCLUDE_COMMENT_IDS) parts.push(`\`ID: ${comment.id}\`\n`); parts.push(`\n${comment.body.trim()}\n`); if (cfg.INCLUDE_REACTIONS && comment.reactions) { const r = Utils.formatReactions(comment.reactions); if (r) parts.push(`\n${r}\n`); } } return parts.join("\n"); }, // ── Issue-specific sections ── generatePinnedComment(pinned, cfg) { if (!pinned?.id) return ""; const p = ["\n---\n", "## Pinned Comment\n"]; let h = ""; if (cfg.INCLUDE_AUTHOR_INFO && pinned.user) h += `*By [@${pinned.user.login}](${pinned.user.html_url})*`; if (cfg.INCLUDE_TIMESTAMPS && pinned.created_at) h += `${h ? " " : ""}*on ${Utils.formatDate(pinned.created_at)}*`; if (h) p.push(`${h}\n`); if (pinned.body?.trim()) p.push(`${pinned.body.trim()}\n`); if (cfg.INCLUDE_REACTIONS && pinned.reactions) { const r = Utils.formatReactions(pinned.reactions); if (r) p.push(`${r}\n`); } return p.join("\n"); }, // biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use generateDependencies(deps, cfg) { const bb = Array.isArray(deps?.blockedBy) ? deps.blockedBy : []; const bl = Array.isArray(deps?.blocking) ? deps.blocking : []; if (bb.length === 0 && bl.length === 0) return ""; const p = ["\n---\n", "## Dependencies\n"]; if (bb.length > 0) { p.push(`### Blocked By (${bb.length})\n`); // biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused bb.forEach((i) => p.push( `- [#${i.number}](${i.html_url})${i.title ? ` — ${i.title}` : ""}${i.state ? ` (${i.state})` : ""}`, ), ); p.push(""); } if (bl.length > 0) { p.push(`### Blocking (${bl.length})\n`); // biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused bl.forEach((i) => p.push( `- [#${i.number}](${i.html_url})${i.title ? ` — ${i.title}` : ""}${i.state ? ` (${i.state})` : ""}`, ), ); p.push(""); } return p.join("\n"); }, // biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use generateSubIssues(subs, cfg) { if (!Array.isArray(subs) || subs.length === 0) return ""; const p = ["\n---\n", `## Sub-Issues (${subs.length})\n`]; // biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused subs.forEach((i) => p.push( `- [#${i.number}](${i.html_url})${i.title ? ` — ${i.title}` : ""}${i.state ? ` (${i.state})` : ""}`, ), ); p.push(""); return p.join("\n"); }, // ── PR sections ── // biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use generatePRRequirements(prData, branchRules, cfg) { const normalize = (input) => { if (!input) return []; if (Array.isArray(input)) return input; if (Array.isArray(input.rules)) return input.rules; if (Array.isArray(input.rule_suites)) return input.rule_suites; if (input.type) return [input]; return []; }; const rules = normalize(branchRules); const prRule = rules.find((r) => r?.type === "pull_request"); const statusRule = rules.find( (r) => r?.type === "required_status_checks", ); const mqRule = rules.find((r) => r?.type === "merge_queue"); const depRule = rules.find((r) => r?.type === "required_deployments"); if (!prRule && !statusRule && !mqRule && !depRule && !prData?.auto_merge) return ""; const p = ["\n---\n", "## Merge Requirements\n"]; if (prRule?.parameters) { const pp = prRule.parameters; if (typeof pp.required_approving_review_count === "number") p.push( `- Required approvals: **${pp.required_approving_review_count}**`, ); if (typeof pp.require_code_owner_review === "boolean") p.push( `- Code owner review: **${pp.require_code_owner_review ? "yes" : "no"}**`, ); if (typeof pp.required_review_thread_resolution === "boolean") p.push( `- Conversation resolution: **${pp.required_review_thread_resolution ? "yes" : "no"}**`, ); if (typeof pp.dismiss_stale_reviews_on_push === "boolean") p.push( `- Dismiss stale reviews: **${pp.dismiss_stale_reviews_on_push ? "yes" : "no"}**`, ); if (typeof pp.require_last_push_approval === "boolean") p.push( `- Last push approval: **${pp.require_last_push_approval ? "yes" : "no"}**`, ); if ( Array.isArray(pp.allowed_merge_methods) && pp.allowed_merge_methods.length > 0 ) p.push(`- Merge methods: **${pp.allowed_merge_methods.join(", ")}**`); } if (statusRule?.parameters) { const checks = statusRule.parameters.required_status_checks || statusRule.parameters.required_checks || []; if (checks.length > 0) { p.push(`- Required status checks (${checks.length}):`); // biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused checks.forEach((c) => p.push(` - \`${c?.context || c?.name || "unknown"}\``), ); } } if (mqRule) p.push(`- Merge queue: **yes**`); if (depRule?.parameters?.required_deployment_environments?.length > 0) { p.push( `- Required deployments: **${depRule.parameters.required_deployment_environments.join(", ")}**`, ); } if (prData?.auto_merge) { const method = prData.auto_merge.merge_method || "unknown"; const by = prData.auto_merge.enabled_by?.login ? ` by [@${prData.auto_merge.enabled_by.login}](${prData.auto_merge.enabled_by.html_url})` : ""; p.push(`- Auto-merge: **${method}**${by}`); } p.push(""); return p.join("\n"); }, // biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use generateChecks(combinedStatus, checkRuns, cfg) { const checks = Array.isArray(checkRuns?.check_runs) ? checkRuns.check_runs : []; const statuses = Array.isArray(combinedStatus?.statuses) ? combinedStatus.statuses : []; const overall = combinedStatus?.state || null; if (!overall && checks.length === 0 && statuses.length === 0) return ""; const p = ["\n---\n", "## Checks & Status\n"]; if (overall) { const em = { success: "✅", pending: "🟡", failure: "🔴", error: "🔴" }; p.push(`- Overall: ${em[overall] || "📋"} **${overall}**`); } if (checks.length > 0) { const total = typeof checkRuns?.total_count === "number" ? checkRuns.total_count : checks.length; p.push(`\n### Check Runs (${total})\n`); checks .sort((a, b) => { const af = [ "failure", "timed_out", "cancelled", "action_required", ].includes(a?.conclusion); const bf = [ "failure", "timed_out", "cancelled", "action_required", ].includes(b?.conclusion); return Number(bf) - Number(af); }) .forEach((c) => { const conc = c.conclusion || c.status || "unknown"; const em = { success: "✅", neutral: "⚪", skipped: "⚪", pending: "🟡", queued: "🟡", in_progress: "🟡", failure: "🔴", timed_out: "🔴", cancelled: "⚫", action_required: "🟠", }; const app = c.app?.name ? ` — *${c.app.name}*` : ""; const link = c.details_url || c.html_url ? `[${c.name || "check"}](${c.details_url || c.html_url})` : c.name || "check"; p.push(`- ${em[conc] || "📋"} ${link} — **${conc}**${app}`); }); } if (statuses.length > 0) { p.push(`\n### Commit Statuses (${statuses.length})\n`); statuses.forEach((s) => { const em = { success: "✅", pending: "🟡", failure: "🔴", error: "🔴", }; const target = s.target_url ? `[${s.context || "default"}](${s.target_url})` : `\`${s.context || "default"}\``; const desc = s.description ? ` — ${s.description}` : ""; p.push(`- ${em[s.state] || "📋"} ${target} — **${s.state}**${desc}`); }); } p.push(""); return p.join("\n"); }, generateReviews(reviews, cfg) { if (!reviews || reviews.length === 0) return ""; const p = ["\n---\n", `## Reviews (${reviews.length})\n`]; const em = { APPROVED: "✅", CHANGES_REQUESTED: "🔴", COMMENTED: "💬", DISMISSED: "⚪", PENDING: "⏳", }; reviews.forEach((r, i) => { const user = r.user ? `[@${r.user.login}](${r.user.html_url})` : "Unknown"; const date = r.submitted_at ? ` · ${Utils.formatDate(r.submitted_at)}` : ""; p.push( `### ${em[r.state] || "📋"} ${r.state || "UNKNOWN"} — ${user}${date}\n`, ); if (r.body?.trim()) p.push(`${r.body.trim()}\n`); if (i !== reviews.length - 1) p.push(`${cfg.COMMENT_SEPARATOR}\n`); }); return p.join("\n"); }, generateReviewComments(reviewComments, cfg, reviews, threadData) { if (!reviewComments || reviewComments.length === 0) return ""; const p = [ "\n---\n", `## Review Comments (${reviewComments.length})\n`, "*Inline comments on code changes*\n", ]; const reviewById = new Map(); (reviews || []).forEach((r) => { if (r?.id != null) reviewById.set(r.id, r); }); const threadInfoById = new Map(); const threadInfoByKey = new Map(); (threadData || []).forEach((t) => { const norm = { id: t.id, isResolved: !!t.isResolved, isOutdated: !!t.isOutdated, path: t.path, line: t.line, startLine: t.startLine, }; (t.comments || []).forEach((c) => { if (c?.databaseId != null) threadInfoById.set(c.databaseId, { ...norm, commentIsOutdated: !!c.isOutdated, minimizedReason: c.minimizedReason, }); }); const key = `${t.path || ""}::${t.startLine || ""}::${t.line || ""}`; if (!threadInfoByKey.has(key)) threadInfoByKey.set(key, norm); }); const getLine = (rc) => { if (rc.subject_type === "file") return " (file-level)"; if (rc.start_line && rc.line && rc.start_line !== rc.line) return ` (lines ${rc.start_line}-${rc.line})`; if (rc.line) return ` (line ${rc.line})`; if (rc.original_line) return ` (line ${rc.original_line})`; return ""; }; const getThreadInfo = (rc, root) => { if (threadInfoById.has(rc.id)) return threadInfoById.get(rc.id); const target = root || rc; return ( threadInfoByKey.get( `${target.path || ""}::${target.start_line || ""}::${target.line || ""}`, ) || null ); }; const renderBadges = (ti, indent) => { if (!cfg.PR_INCLUDE_REVIEW_THREAD_STATE || !ti) return; const badges = [ti.isResolved ? "✅ Resolved" : "🟡 Unresolved"]; if (ti.isOutdated) badges.push("🕰️ Outdated"); p.push(`${indent}*${badges.join(" · ")}*\n`); }; const renderComment = (rc, indent, isReply, root) => { const ind = " ".repeat(indent); const user = rc.user ? `[@${rc.user.login}](${rc.user.html_url})` : "Unknown"; const date = rc.created_at ? ` · ${Utils.formatDate(rc.created_at)}` : ""; const line = getLine(rc); const reply = isReply ? " *(reply)*" : ""; const rev = rc.pull_request_review_id != null ? reviewById.get(rc.pull_request_review_id) : null; const revState = rev?.state ? ` · review: ${rev.state}` : ""; const ti = getThreadInfo(rc, root); const cBadges = []; if (cfg.PR_INCLUDE_REVIEW_THREAD_STATE && ti?.commentIsOutdated) cBadges.push("🕰️ outdated"); if (cfg.PR_INCLUDE_REVIEW_THREAD_STATE && ti?.minimizedReason) cBadges.push(`🙈 minimized: ${ti.minimizedReason}`); p.push(`${ind}**${user}**${date}${line}${reply}${revState}\n`); if (!isReply) renderBadges(ti, ind); else if (cBadges.length > 0) p.push(`${ind}*${cBadges.join(" · ")}*\n`); if (rc.diff_hunk && !isReply) { p.push(`${ind}<details><summary>Diff context</summary>\n`); p.push(`${ind}\`\`\`diff`); p.push(rc.diff_hunk); p.push(`${ind}\`\`\`\n`); p.push(`${ind}</details>\n`); } if (rc.body?.trim()) p.push(`${ind}${rc.body.trim()}\n`); if (cfg.INCLUDE_REACTIONS && rc.reactions) { const r = Utils.formatReactions(rc.reactions); if (r) p.push(`${ind}${r}\n`); } }; // Group by file const byFile = {}; reviewComments.forEach((rc) => { const path = rc.path || "unknown"; if (!byFile[path]) byFile[path] = []; byFile[path].push(rc); }); for (const [filePath, comments] of Object.entries(byFile)) { p.push(`### \`${filePath}\`\n`); if (cfg.PR_REVIEW_COMMENTS_GROUP_BY_THREAD) { const byId = new Map(); const roots = []; // biome-ignore lint/suspicious/useIterableCallbackReturn: Map.set return unused comments.forEach((rc) => byId.set(rc.id, { ...rc, _children: [] })); comments.forEach((rc) => { const node = byId.get(rc.id); if (rc.in_reply_to_id && byId.has(rc.in_reply_to_id)) byId.get(rc.in_reply_to_id)._children.push(node); else roots.push(node); }); roots.forEach((root, idx) => { renderComment(root, 0, false, root); if (root._children?.length > 0) // biome-ignore lint/suspicious/useIterableCallbackReturn: renderComment return unused root._children.forEach((child) => renderComment(child, 1, true, root), ); if (idx !== roots.length - 1) p.push(""); }); } else { comments.forEach((rc, idx) => { renderComment(rc, 0, !!rc.in_reply_to_id, rc); if (idx !== comments.length - 1) p.push(""); }); } p.push(""); } return p.join("\n"); }, // biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use generatePRCommits(commits, cfg) { if (!commits || commits.length === 0) return ""; const p = ["\n---\n", `## PR Commits (${commits.length})\n`]; commits.forEach((c) => { const sha = String(c.sha || "").substring(0, 7); const msg = c.commit?.message ? String(c.commit.message).split("\n")[0].trim() : "No message"; const author = c.commit?.author?.name || c.author?.login || "Unknown"; const date = c.commit?.author?.date ? ` · ${Utils.formatDate(c.commit.author.date)}` : ""; p.push( `- [\`${sha}\`](${c.html_url || ""}) ${msg} — *${author}*${date}`, ); }); p.push(""); return p.join("\n"); }, generatePRFiles(files, cfg) { if (!files || files.length === 0) return ""; const p = ["\n---\n", `## Changed Files (${files.length})\n`]; const em = { added: "🟢", removed: "🔴", modified: "🟡", renamed: "🔄", copied: "📋", changed: "🟡", unchanged: "⚪", }; files.forEach((f) => { const rename = f.previous_filename ? ` ← \`${f.previous_filename}\`` : ""; p.push( `- ${em[f.status] || "📄"} \`${f.filename}\` (${f.status})${rename} — +${f.additions || 0} / -${f.deletions || 0}`, ); if (cfg.PR_INCLUDE_CHANGED_FILES_PATCHES && f.patch) { p.push(` <details><summary>Diff</summary>\n`); p.push(" ```diff"); p.push(` ${f.patch}`); p.push(" ```\n"); p.push(" </details>"); } }); p.push(""); return p.join("\n"); }, // ── References section ── generateReferences(issue, timeline, commitDetails, cfg) { if (!cfg.REF_INCLUDE_CROSS_REFERENCED && !cfg.REF_INCLUDE_COMMITS) return ""; const currentRepo = this._repoFromUrl(issue.html_url); const crossRefs = (timeline || []) .filter((e) => e?.event === "cross-referenced" && e.source?.issue) .map((e) => { const src = e.source.issue; const repoFull = src?.repository?.full_name || ""; return { repoFull, isSameRepo: currentRepo && repoFull.toLowerCase() === currentRepo.toLowerCase(), isPR: !!src?.pull_request, isDup: this._hasLabel(src, "duplicate"), number: src.number, title: src.title || "", url: src.html_url || "", state: src.state || "", merged_at: src.pull_request?.merged_at || null, }; }); const filtered = []; if (cfg.REF_INCLUDE_CROSS_REFERENCED) { for (const r of crossRefs) { if (!r.url) continue; if (r.isSameRepo && !cfg.REF_INCLUDE_SAME_REPO) continue; if (!r.isSameRepo && !cfg.REF_INCLUDE_CROSS_REPO) continue; if (r.isPR && !cfg.REF_INCLUDE_PRS) continue; if (!r.isPR && !cfg.REF_INCLUDE_ISSUES) continue; filtered.push(r); } } const dedup = (arr) => { const seen = new Set(); return arr.filter((i) => { if (seen.has(i.url)) return false; seen.add(i.url); return true; }); }; const relatedPRs = dedup( filtered.filter( (r) => r.isPR && (!r.isDup || !cfg.REF_INCLUDE_DUPLICATES), ), ); const relatedIssues = dedup( filtered.filter( (r) => !r.isPR && (!r.isDup || !cfg.REF_INCLUDE_DUPLICATES), ), ); const duplicates = cfg.REF_INCLUDE_DUPLICATES ? dedup(filtered.filter((r) => r.isDup)) : []; const commits = []; if (cfg.REF_INCLUDE_COMMITS) { const seen = new Set(); (timeline || []) .filter((e) => e?.event === "referenced" && e.commit_id) .forEach((e) => { const sha = String(e.commit_id); if (seen.has(sha)) return; seen.add(sha); const info = commitDetails?.[sha]; commits.push({ sha, htmlUrl: info?.html_url || this._commitApiToHtml(e.commit_url, sha), subject: info?.message ? String(info.message).split("\n")[0].trim() : "", }); }); } if ( !relatedPRs.length && !relatedIssues.length && !duplicates.length && !commits.length ) return ""; const p = ["\n---\n", "## References\n"]; if (relatedPRs.length > 0) { p.push(`### Related Pull Requests (${relatedPRs.length})\n`); relatedPRs.forEach((r) => { const prefix = r.repoFull ? `${r.repoFull}#${r.number}` : `#${r.number}`; const title = r.title ? ` — ${r.title}` : ""; const status = r.merged_at ? ` (merged ${Utils.formatDate(r.merged_at)})` : r.state ? ` (${r.state})` : ""; p.push(`- [${prefix}](${r.url})${title}${status}`); }); p.push(""); } if (relatedIssues.length > 0) { p.push(`### Related Issues (${relatedIssues.length})\n`); relatedIssues.forEach((r) => { const prefix = r.repoFull ? `${r.repoFull}#${r.number}` : `#${r.number}`; p.push( `- [${prefix}](${r.url})${r.title ? ` — ${r.title}` : ""}${r.state ? ` (${r.state})` : ""}`, ); }); p.push(""); } if (duplicates.length > 0) { p.push(`### Duplicates (${duplicates.length})\n`); duplicates.forEach((r) => { const prefix = r.repoFull ? `${r.repoFull}#${r.number}` : `#${r.number}`; p.push( `- [${prefix}](${r.url})${r.title ? ` — ${r.title}` : ""}${r.state ? ` (${r.state})` : ""}`, ); }); p.push(""); } if (commits.length > 0) { p.push(`### Commits (${commits.length})\n`); // biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused commits.forEach((c) => p.push( `- [${this._shortSha(c.sha)}](${c.htmlUrl})${c.subject ? ` — ${c.subject}` : ""}`, ), ); p.push(""); } return p.join("\n"); }, // ── Timeline section ── // biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use generateTimeline(issue, timeline, commitDetails, cfg) { const events = (timeline || []).filter( (e) => e && e.event !== "commented", ); const p = ["\n---\n", "## Timeline (Verbose)\n"]; const include = (ev) => { switch (ev.event) { case "cross-referenced": return !!cfg.TL_INCLUDE_CROSS_REFERENCED; case "referenced": return !!cfg.TL_INCLUDE_REFERENCED; case "renamed": return !!cfg.TL_INCLUDE_RENAMED; case "labeled": case "unlabeled": return !!cfg.TL_INCLUDE_LABEL_CHANGES; case "added_to_project_v2": case "project_v2_item_status_changed": return !!cfg.TL_INCLUDE_PROJECT_V2; case "subscribed": case "unsubscribed": return !!cfg.TL_INCLUDE_SUBSCRIBE_EVENTS; case "mentioned": return !!cfg.TL_INCLUDE_MENTIONED_EVENTS; case "marked_as_duplicate": return !!cfg.TL_INCLUDE_MARKED_DUPLICATE_EVENTS; case "closed": case "reopened": return !!cfg.TL_INCLUDE_CLOSED_EVENTS; case "reviewed": return !!cfg.TL_INCLUDE_REVIEWED; case "committed": return !!cfg.TL_INCLUDE_COMMITTED; case "merged": return !!cfg.TL_INCLUDE_MERGED; case "head_ref_force_pushed": case "head_ref_deleted": case "head_ref_restored": return !!cfg.TL_INCLUDE_HEAD_REF_EVENTS; case "review_requested": case "review_request_removed": return !!cfg.TL_INCLUDE_REVIEW_REQUESTED; case "ready_for_review": case "convert_to_draft": return !!cfg.TL_INCLUDE_REVIEWED; case "assigned": case "unassigned": case "milestoned": case "demilestoned": case "locked": case "unlocked": case "pinned": case "unpinned": case "transferred": case "connected": case "disconnected": return true; default: return false; } }; const actor = (ev) => ev?.actor?.login ? `@${ev.actor.login}` : "someone"; const fmtDate = (ev) => ev?.created_at ? Utils.formatDate(ev.created_at) : ""; const fmt = (ev) => { const a = actor(ev); switch (ev.event) { case "renamed": return `✏️ ${a} renamed: "${ev.rename?.from}" → "${ev.rename?.to}"`; case "labeled": return `🏷️ ${a} added label \`${ev.label?.name}\``; case "unlabeled": return `🏷️ ${a} removed label \`${ev.label?.name}\``; case "closed": return `🔴 Closed by ${a}`; case "reopened": return `🟢 Reopened by ${a}`; case "assigned": return `👤 ${a} assigned ${ev.assignee?.login ? "@" + ev.assignee.login : "someone"}`; case "unassigned": return `👤 ${a} unassigned ${ev.assignee?.login ? "@" + ev.assignee.login : "someone"}`; case "milestoned": return `🎯 ${a} added milestone "${ev.milestone?.title}"`; case "demilestoned": return `🎯 ${a} removed milestone "${ev.milestone?.title}"`; case "locked": return `🔒 ${a} locked${ev.lock_reason ? ` (${ev.lock_reason})` : ""}`; case "unlocked": return `🔓 ${a} unlocked`; case "pinned": return `📌 ${a} pinned`; case "unpinned": return `📌 ${a} unpinned`; case "transferred": return `📦 ${a} transferred`; case "connected": return `🔗 ${a} connected`; case "disconnected": return `🔗 ${a} disconnected`; case "marked_as_duplicate": return `🔁 ${a} marked duplicate`; case "reviewed": { const em = { approved: "✅", changes_requested: "🔴", commented: "💬", dismissed: "⚪", }; return `${em[ev.state] || "📋"} ${a} reviewed: ${ev.state || "commented"}`; } case "committed": return `📝 Commit ${ev.sha ? ev.sha.substring(0, 7) : ""}${ev.message ? ": " + String(ev.message).split("\n")[0].trim() : ""}`; case "merged": return `🟣 ${a} merged`; case "head_ref_force_pushed": return `⬆️ ${a} force-pushed`; case "head_ref_deleted": return `🗑️ ${a} deleted head branch`; case "head_ref_restored": return `♻️ ${a} restored head branch`; case "review_requested": return `👀 ${a} requested review from ${ev.requested_reviewer?.login ? "@" + ev.requested_reviewer.login : "a team"}`; case "review_request_removed": return `👀 ${a} removed review request for ${ev.requested_reviewer?.login ? "@" + ev.requested_reviewer.login : "a team"}`; case "ready_for_review": return `🟢 ${a} marked ready for review`; case "convert_to_draft": return `⚪ ${a} converted to draft`; case "cross-referenced": { const src = ev.source?.issue; if (!src) return "🔗 Cross-referenced"; const kind = src.pull_request ? "PR" : "Issue"; return `🔗 ${kind}: ${src.repository?.full_name || ""}#${src.number}${src.title ? ` — ${src.title}` : ""}`; } case "referenced": { const sha = String(ev.commit_id || ""); const short = sha.length > 7 ? sha.slice(0, 7) : sha; const info = commitDetails?.[sha]; const subject = info?.message ? String(info.message).split("\n")[0].trim() : ""; return `🔗 Commit ${short}${subject ? ` — ${subject}` : ""}`; } default: return `${ev.event} by ${a}`; } }; const relevant = events.filter(include); if (relevant.length === 0) { p.push("*No timeline events.*\n"); return p.join("\n"); } // biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused relevant.forEach((ev) => p.push(`- ${fmtDate(ev)}: ${fmt(ev)}`)); p.push(""); return p.join("\n"); }, }; // ── Discussion-specific markdown generation (added to MdGen) ── MdGen.generateDiscussionHeader = function (disc, cfg, gqlData) { const lines = []; const repo = this._repoFromUrl(disc.html_url); lines.push(`> **Repository:** [${repo}](https://github.com/${repo}) `); lines.push(`> **Discussion:** [#${disc.number}](${disc.html_url}) `); // State const stateEmoji = disc.state === "open" ? "🟢" : "🔴"; const stateLabel = disc.state === "open" ? "open" : disc.state_reason ? `closed (${disc.state_reason})` : "closed"; lines.push(`> **State:** ${stateEmoji} ${stateLabel} `); // Category if (cfg.DISC_INCLUDE_CATEGORY && disc.category) { const emoji = disc.category.emoji ? `${disc.category.emoji} ` : ""; const answerable = disc.category.is_answerable ? " (Q&A)" : ""; lines.push( `> **Category:** ${emoji}${disc.category.name}${answerable} `, ); } // Author if (cfg.INCLUDE_AUTHOR_INFO && disc.user) { lines.push( `> **Author:** [@${disc.user.login}](${disc.user.html_url}) `, ); } // Answer info if (cfg.DISC_INCLUDE_ANSWER && disc.answer_chosen_at) { const by = disc.answer_chosen_by ? `[@${disc.answer_chosen_by.login}](${disc.answer_chosen_by.html_url})` : "unknown"; lines.push( `> **Answer chosen by:** ${by} on ${Utils.formatDate(disc.answer_chosen_at)} `, ); } // Upvotes (from GraphQL) if (cfg.DISC_INCLUDE_UPVOTES && gqlData?.upvoteCount > 0) { lines.push(`> **Upvotes:** ${gqlData.upvoteCount} `); } // Pinned (from GraphQL) if (gqlData?.isPinned) { lines.push(`> **Pinned:** yes `); } // Locked if (disc.locked) { const reason = disc.active_lock_reason ? ` (${disc.active_lock_reason})` : ""; lines.push(`> **Locked:** yes${reason} `); } // Labels if (cfg.INCLUDE_LABELS && disc.labels?.length > 0) { const names = disc.labels .map((l) => `\`${typeof l === "object" ? l.name : l}\``) .join(", "); lines.push(`> **Labels:** ${names} `); } // Timestamps if (cfg.INCLUDE_TIMESTAMPS) { lines.push(`> **Created:** ${Utils.formatDate(disc.created_at)} `); if (disc.updated_at && disc.updated_at !== disc.created_at) { lines.push(`> **Updated:** ${Utils.formatDate(disc.updated_at)} `); } } // Reactions if (cfg.INCLUDE_REACTIONS && disc.reactions) { const r = Utils.formatReactions(disc.reactions); if (r) lines.push(`> **Reactions:** ${r} `); } lines.push(""); return lines.join("\n"); }; MdGen.generateDiscussionPoll = (poll, cfg) => { if (!poll || !cfg.DISC_INCLUDE_POLL) return ""; const p = ["\n---\n", "## Poll\n"]; p.push(`**${poll.question}**\n`); p.push(`Total votes: **${poll.totalVotes || 0}**\n`); const options = poll.options?.nodes || []; if (options.length > 0) { p.push("| Option | Votes |"); p.push("|:-------|------:|"); options.forEach((o) => { const pct = poll.totalVotes > 0 ? ` (${((o.votes / poll.totalVotes) * 100).toFixed(1)}%)` : ""; p.push(`| ${o.text} | ${o.votes}${pct} |`); }); p.push(""); } return p.join("\n"); }; MdGen.generateDiscussionComments = (comments, cfg, gqlData) => { if (!comments || comments.length === 0) return ""; // Build GQL enrichment maps const gqlCommentMap = new Map(); const gqlReplyMap = new Map(); if (gqlData?.comments?.nodes) { for (const gc of gqlData.comments.nodes) { if (gc.databaseId != null) { gqlCommentMap.set(gc.databaseId, gc); } if (gc.replies?.nodes) { for (const gr of gc.replies.nodes) { if (gr.databaseId != null) gqlReplyMap.set(gr.databaseId, gr); } } } } // Find the answer comment ID from GQL let answerCommentId = null; if (gqlData?.answer?.databaseId) { answerCommentId = gqlData.answer.databaseId; } // Separate top-level comments and replies using parent_id const topLevel = []; const repliesByParent = new Map(); for (const c of comments) { if (c.parent_id) { if (!repliesByParent.has(c.parent_id)) repliesByParent.set(c.parent_id, []); repliesByParent.get(c.parent_id).push(c); } else { topLevel.push(c); } } const parts = []; parts.push("\n---\n"); parts.push(`## Comments (${topLevel.length})\n`); topLevel.forEach((comment, index) => { const user = comment.user ? `[@${comment.user.login}](${comment.user.html_url})` : "Unknown"; const date = comment.created_at ? ` · ${Utils.formatDate(comment.created_at)}` : ""; const assoc = comment.author_association && comment.author_association !== "NONE" ? ` · *${comment.author_association}*` : ""; // Check if this is the answer const isAnswer = comment.id === answerCommentId; const answerBadge = isAnswer ? " ✅ **Answer**" : ""; // Check GQL minimized state const gqlInfo = gqlCommentMap.get(comment.id); const minimized = gqlInfo?.isMinimized; const minimizedReason = gqlInfo?.minimizedReason; parts.push( `### Comment #${index + 1} — ${user}${date}${assoc}${answerBadge}\n`, ); if (minimized) { parts.push( `*🙈 This comment was minimized${minimizedReason ? `: ${minimizedReason}` : ""}*\n`, ); } if (comment.body?.trim()) { parts.push(`${comment.body.trim()}\n`); } else { parts.push("*No content.*\n"); } // Reactions if (cfg.INCLUDE_REACTIONS && comment.reactions) { const r = Utils.formatReactions(comment.reactions); if (r) parts.push(`${r}\n`); } // Replies if (cfg.DISC_INCLUDE_COMMENT_REPLIES) { const replies = repliesByParent.get(comment.id) || []; if (replies.length > 0) { parts.push(`\n#### Replies (${replies.length})\n`); replies.forEach((reply) => { const rUser = reply.user ? `[@${reply.user.login}](${reply.user.html_url})` : "Unknown"; const rDate = reply.created_at ? ` · ${Utils.formatDate(reply.created_at)}` : ""; const rAssoc = reply.author_association && reply.author_association !== "NONE" ? ` · *${reply.author_association}*` : ""; // Check GQL minimized for reply const rGql = gqlReplyMap.get(reply.id); const rMin = rGql?.isMinimized; const rMinReason = rGql?.minimizedReason; parts.push(`> **${rUser}**${rDate}${rAssoc}\n>`); if (rMin) { parts.push( `> *🙈 Minimized${rMinReason ? `: ${rMinReason}` : ""}*\n>`, ); } if (reply.body?.trim()) { // Indent reply body inside blockquote const indented = reply.body .trim() .split("\n") .map((l) => `> ${l}`) .join("\n"); parts.push(`${indented}\n`); } if (cfg.INCLUDE_REACTIONS && reply.reactions) { const rr = Utils.formatReactions(reply.reactions); if (rr) parts.push(`> ${rr}\n`); } parts.push(""); }); } } if (index !== topLevel.length - 1) { parts.push(`${cfg.COMMENT_SEPARATOR}\n`); } }); return parts.join("\n"); }; // ── Master generate function ── MdGen.generate = function (data) { const cfg = data.cfg || ThreadConfig.load(); const parts = []; if (data.type === "discussion") { return this._generateDiscussion(data, cfg); } // Issue or PR const issue = data.issue; const isPR = data.type === "pr"; // Title parts.push(`# ${issue.title}\n`); // Header if (cfg.INCLUDE_HEADER) { parts.push( this.generateHeader(issue, cfg, data.prData, data.requestedReviewers), ); } // Issue-specific sections if (!isPR) { if (cfg.ISSUE_INCLUDE_PINNED_COMMENT && data.pinnedComment) { const s = this.generatePinnedComment(data.pinnedComment, cfg); if (s) parts.push(s); } if (cfg.ISSUE_INCLUDE_DEPENDENCIES && data.issueDependencies) { const s = this.generateDependencies(data.issueDependencies, cfg); if (s) parts.push(s); } if (cfg.ISSUE_INCLUDE_SUB_ISSUES && data.issueSubIssues?.length > 0) { const s = this.generateSubIssues(data.issueSubIssues, cfg); if (s) parts.push(s); } } parts.push("---\n"); // Description parts.push("## Description\n"); if (cfg.INCLUDE_AUTHOR_INFO) { let line = `*Opened by [@${issue.user.login}](${issue.user.html_url})*`; if (cfg.INCLUDE_TIMESTAMPS && issue.created_at) line += ` *on ${Utils.formatDate(issue.created_at)}*`; parts.push(`${line}\n`); } if (issue.body?.trim()) parts.push(`\n${issue.body.trim()}\n`); else parts.push("\n*No description provided.*\n"); if (cfg.INCLUDE_REACTIONS && issue.reactions) { const r = Utils.formatReactions(issue.reactions); if (r) parts.push(`\n${r}\n`); } // PR sections if (isPR) { if ( cfg.PR_INCLUDE_MERGE_REQUIREMENTS && (data.branchRules || data.prData) ) { const s = this.generatePRRequirements( data.prData, data.branchRules, cfg, ); if (s) parts.push(s); } if ( cfg.PR_INCLUDE_CHECKS_SECTION && (data.combinedStatus || data.checkRuns) ) { const s = this.generateChecks(data.combinedStatus, data.checkRuns, cfg); if (s) parts.push(s); } if (cfg.PR_INCLUDE_REVIEWS && data.reviews?.length > 0) { parts.push(this.generateReviews(data.reviews, cfg)); } if (cfg.PR_INCLUDE_REVIEW_COMMENTS && data.reviewComments?.length > 0) { parts.push( this.generateReviewComments( data.reviewComments, cfg, data.reviews, data.reviewThreadData, ), ); } if (cfg.PR_INCLUDE_PR_COMMITS && data.prCommits?.length > 0) { parts.push(this.generatePRCommits(data.prCommits, cfg)); } if (cfg.PR_INCLUDE_CHANGED_FILES && data.prFiles?.length > 0) { parts.push(this.generatePRFiles(data.prFiles, cfg)); } } // References if (cfg.INCLUDE_REFERENCES_SECTION && data.timeline?.length > 0) { const s = this.generateReferences( issue, data.timeline, data.commitDetails, cfg, ); if (s) parts.push(s); } // Timeline if (cfg.INCLUDE_TIMELINE_SECTION && data.timeline?.length > 0) { const s = this.generateTimeline( issue, data.timeline, data.commitDetails, cfg, ); if (s) parts.push(s); } // Comments if (data.comments?.length > 0) { parts.push("\n---\n"); parts.push(`## Comments (${data.comments.length})\n`); let commentIdx = 0; data.comments.forEach((c) => { const rendered = this.generateComment( c, commentIdx + 1, cfg, data.commentMinimizedMap, ); if (rendered) { if (commentIdx > 0) parts.push(`\n${cfg.COMMENT_SEPARATOR}\n`); parts.push(rendered); commentIdx++; } }); } // Footer parts.push("\n---\n"); parts.push( `*Exported on ${new Date().toLocaleString()} from [${issue.html_url}](${issue.html_url})*\n`, ); return parts.join("\n"); }; MdGen._generateDiscussion = function (data, cfg) { const disc = data.discussion; const gql = data.gqlData; const parts = []; // Title parts.push(`# ${disc.title}\n`); // Header if (cfg.INCLUDE_HEADER) { parts.push(this.generateDiscussionHeader(disc, cfg, gql)); } parts.push("---\n"); // Body parts.push("## Description\n"); if (cfg.INCLUDE_AUTHOR_INFO && disc.user) { let line = `*Started by [@${disc.user.login}](${disc.user.html_url})*`; if (cfg.INCLUDE_TIMESTAMPS && disc.created_at) line += ` *on ${Utils.formatDate(disc.created_at)}*`; parts.push(`${line}\n`); } if (disc.body?.trim()) parts.push(`\n${disc.body.trim()}\n`); else parts.push("\n*No description provided.*\n"); if (cfg.INCLUDE_REACTIONS && disc.reactions) { const r = Utils.formatReactions(disc.reactions); if (r) parts.push(`\n${r}\n`); } // Poll (from GraphQL) if (gql?.poll) { const s = this.generateDiscussionPoll(gql.poll, cfg); if (s) parts.push(s); } // Comments if (data.comments?.length > 0) { const s = this.generateDiscussionComments(data.comments, cfg, gql); if (s) parts.push(s); } // Discussion timeline (from GraphQL) if (cfg.DISC_INCLUDE_TIMELINE && data.discussionTimeline?.length > 0) { const s = this._generateDiscussionTimeline(data.discussionTimeline, cfg); if (s) parts.push(s); } // Footer parts.push("\n---\n"); parts.push( `*Exported on ${new Date().toLocaleString()} from [${disc.html_url}](${disc.html_url})*\n`, ); return parts.join("\n"); }; // biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use MdGen._generateDiscussionTimeline = (events, cfg) => { if (!events || events.length === 0) return ""; const p = ["\n---\n", "## Timeline\n"]; const actor = (ev) => (ev?.actor?.login ? `@${ev.actor.login}` : "someone"); const fmtDate = (ev) => ev?.createdAt ? Utils.formatDate(ev.createdAt) : ""; const fmt = (ev) => { const a = actor(ev); switch (ev.__typename) { case "LabeledEvent": return `🏷️ ${a} added label \`${ev.label?.name}\``; case "UnlabeledEvent": return `🏷️ ${a} removed label \`${ev.label?.name}\``; case "ClosedEvent": return `🔴 Closed by ${a}${ev.stateReason ? ` (${ev.stateReason})` : ""}`; case "ReopenedEvent": return `🟢 Reopened by ${a}`; case "LockedEvent": return `🔒 ${a} locked${ev.lockReason ? ` (${ev.lockReason})` : ""}`; case "UnlockedEvent": return `🔓 ${a} unlocked`; case "PinnedEvent": return `📌 ${a} pinned`; case "UnpinnedEvent": return `📌 ${a} unpinned`; case "TransferredEvent": return `📦 ${a} transferred`; case "CategoryChangedEvent": return `📂 ${a} changed category`; case "RenamedTitleEvent": return `✏️ ${a} renamed: "${ev.previousTitle}" → "${ev.currentTitle}"`; case "MarkedAsDuplicateEvent": return `🔁 ${a} marked as duplicate`; case "CrossReferencedEvent": { const src = ev.source; if (!src) return `🔗 Cross-referenced by ${a}`; const kind = src.__typename === "PullRequest" ? "PR" : "Issue"; const repo = src.repository?.nameWithOwner || ""; return `🔗 ${kind}: ${repo}#${src.number}${src.title ? ` — ${src.title}` : ""}`; } default: return `${ev.__typename} by ${a}`; } }; // Filter out DiscussionComment nodes (they're in the comments section) const relevant = events.filter( (ev) => ev.__typename && ev.__typename !== "DiscussionComment", ); if (relevant.length === 0) { p.push("*No timeline events.*\n"); return p.join("\n"); } // biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused relevant.forEach((ev) => p.push(`- ${fmtDate(ev)}: ${fmt(ev)}`)); p.push(""); return p.join("\n"); }; // ════════════════════════════════════════════════════════════════════════════ // THREAD EXPORT MODULE (orchestrator) // ════════════════════════════════════════════════════════════════════════════ const ThreadExportModule = { /** * Export a single thread (issue, PR, or discussion) to Markdown. * This is the core function that fetches all data and generates output. * Designed to be callable both from the FAB and from batch export. * * @param {string} owner * @param {string} repo * @param {number} number * @param {string} type 'issue' | 'pr' | 'discussion' * @param {AbortSignal} signal * @returns {{ markdown: string, title: string, commentCount: number, summary: string }} */ async exportThread(owner, repo, number, type, signal, onProgress) { const cfg = ThreadConfig.load(); const check = () => { if (signal?.aborted) throw new Error("Cancelled"); }; const progress = (msg) => { if (onProgress) onProgress(msg); }; if (type === "discussion") { return this._exportDiscussion( owner, repo, number, cfg, signal, onProgress, ); } // ── Issue or PR ── progress("Fetching issue data…"); const issue = await API.fetchIssue(owner, repo, number, signal); check(); // Detect actual type (issue URL might be a PR) const isPR = type === "pr" || !!issue.pull_request; const actualType = isPR ? "pr" : "issue"; // Comments (conversation tab — same for issues and PRs) let comments = []; if (issue.comments > 0) { progress(`Fetching ${issue.comments} comments…`); comments = await API.fetchIssueComments(owner, repo, number, signal); check(); } // Fetch minimized state for comments via GraphQL let commentMinimizedMap = null; if (Token.has() && comments.length > 0) { progress("Fetching comment minimized state…"); commentMinimizedMap = await API.fetchCommentMinimizedState( owner, repo, number, signal, ); check(); } // Collect all data into a single object const exportData = { type: actualType, issue, comments, commentMinimizedMap, cfg, // PR-specific (filled below if PR) prData: null, reviews: [], reviewComments: [], prCommits: [], prFiles: [], requestedReviewers: null, branchRules: null, combinedStatus: null, checkRuns: null, reviewThreadData: [], // Issue-specific (filled below if issue) issueDependencies: null, issueSubIssues: [], pinnedComment: null, // Shared timeline: [], commitDetails: {}, }; if (isPR) { progress("Fetching PR details…"); exportData.prData = await API.fetchPullRequest( owner, repo, number, signal, ); check(); if (cfg.PR_INCLUDE_REQUESTED_REVIEWERS) { progress("Fetching requested reviewers…"); exportData.requestedReviewers = await API.fetchRequestedReviewers( owner, repo, number, signal, ); check(); } if (cfg.PR_INCLUDE_MERGE_REQUIREMENTS && exportData.prData?.base?.ref) { progress("Fetching branch rules…"); exportData.branchRules = await API.fetchBranchRules( owner, repo, exportData.prData.base.ref, signal, ); check(); } if (cfg.PR_INCLUDE_CHECKS_SECTION && exportData.prData?.head?.sha) { progress("Fetching checks & status…"); exportData.combinedStatus = await API.fetchCombinedStatus( owner, repo, exportData.prData.head.sha, signal, ); check(); exportData.checkRuns = await API.fetchCheckRuns( owner, repo, exportData.prData.head.sha, signal, ); check(); } if (cfg.PR_INCLUDE_REVIEWS) { progress("Fetching reviews…"); exportData.reviews = await API.fetchReviews( owner, repo, number, signal, ); check(); } if (cfg.PR_INCLUDE_REVIEW_COMMENTS) { progress("Fetching review comments…"); exportData.reviewComments = await API.fetchReviewComments( owner, repo, number, signal, ); check(); if ( cfg.PR_INCLUDE_REVIEW_THREAD_STATE && exportData.reviewComments.length > 0 ) { progress("Fetching review thread state (GraphQL)…"); exportData.reviewThreadData = await API.fetchPRReviewThreads( owner, repo, number, signal, ); check(); } } if (cfg.PR_INCLUDE_PR_COMMITS) { progress("Fetching PR commits…"); exportData.prCommits = await API.fetchPRCommits( owner, repo, number, signal, ); check(); } if (cfg.PR_INCLUDE_CHANGED_FILES) { progress("Fetching changed files…"); exportData.prFiles = await API.fetchPRFiles( owner, repo, number, signal, ); check(); } } else { // Issue-specific fetches if (cfg.ISSUE_INCLUDE_DEPENDENCIES) { progress("Fetching dependencies…"); const bb = await API.fetchIssueDependenciesBlockedBy( owner, repo, number, signal, ); check(); const bl = await API.fetchIssueDependenciesBlocking( owner, repo, number, signal, ); check(); exportData.issueDependencies = { blockedBy: bb, blocking: bl }; } if (cfg.ISSUE_INCLUDE_SUB_ISSUES) { progress("Fetching sub-issues…"); exportData.issueSubIssues = await API.fetchIssueSubIssues( owner, repo, number, signal, ); check(); } if (cfg.ISSUE_INCLUDE_PINNED_COMMENT && issue.pinned_comment) { exportData.pinnedComment = issue.pinned_comment; } } // Timeline (shared) if (cfg.INCLUDE_REFERENCES_SECTION || cfg.INCLUDE_TIMELINE_SECTION) { progress("Fetching timeline…"); exportData.timeline = await API.fetchTimeline( owner, repo, number, signal, ); check(); } // Optional commit details if ( cfg.INCLUDE_REFERENCES_SECTION && cfg.REF_INCLUDE_COMMITS && cfg.REF_FETCH_COMMIT_DETAILS ) { progress("Fetching commit details…"); const refs = (exportData.timeline || []).filter( (e) => e?.event === "referenced" && e.commit_id && e.commit_url, ); const unique = new Map(); refs.forEach((e) => { if (!unique.has(e.commit_id)) unique.set(e.commit_id, e.commit_url); }); for (const [sha, url] of unique.entries()) { check(); try { const c = await API.fetchCommit(url, signal); exportData.commitDetails[sha] = { html_url: c?.html_url, message: c?.commit?.message, }; } catch (e) { warn("Commit fetch failed:", sha, e?.message); } } } check(); // Generate markdown progress("Generating Markdown…"); const markdown = MdGen.generate(exportData); // Build summary const summaryParts = [`${comments.length + 1} messages`]; if (isPR) { if (exportData.reviews.length > 0) summaryParts.push(`${exportData.reviews.length} reviews`); if (exportData.reviewComments.length > 0) summaryParts.push( `${exportData.reviewComments.length} review comments`, ); } return { markdown, title: issue.title, commentCount: comments.length, summary: summaryParts.join(", "), type: actualType, }; }, /** * Export a discussion to Markdown. */ async _exportDiscussion(owner, repo, number, cfg, signal, onProgress) { const check = () => { if (signal?.aborted) throw new Error("Cancelled"); }; const progress = (msg) => { if (onProgress) onProgress(msg); }; // Fetch discussion via REST progress("Fetching discussion…"); const disc = await API.fetchDiscussion(owner, repo, number, signal); check(); // Fetch all comments via REST (flat list with parent_id for threading) progress("Fetching comments…"); const comments = await API.fetchDiscussionComments( owner, repo, number, signal, ); check(); // GraphQL enrichment (polls, upvotes, minimized, answer ID) let gqlData = null; if (Token.has()) { progress("Fetching GraphQL enrichment…"); gqlData = await API.fetchDiscussionGraphQL(owner, repo, number, signal); check(); } // Discussion timeline (GraphQL only — REST 404s) let discussionTimeline = []; if (cfg.DISC_INCLUDE_TIMELINE && Token.has()) { progress("Fetching discussion timeline…"); discussionTimeline = await API.fetchDiscussionTimeline( owner, repo, number, signal, ); check(); } const exportData = { type: "discussion", discussion: disc, comments, gqlData, discussionTimeline, cfg, }; const markdown = MdGen.generate(exportData); // Count top-level comments const topLevel = comments.filter((c) => !c.parent_id); const replyCount = comments.length - topLevel.length; const summaryParts = [`${topLevel.length} comments`]; if (replyCount > 0) summaryParts.push(`${replyCount} replies`); if (gqlData?.poll) summaryParts.push("poll"); return { markdown, title: disc.title, commentCount: topLevel.length, summary: summaryParts.join(", "), type: "discussion", }; }, }; // ════════════════════════════════════════════════════════════════════════════ // TOAST NOTIFICATIONS // ════════════════════════════════════════════════════════════════════════════ function showToast(message, type = "info") { document .querySelectorAll(".gh-exporter-toast") // biome-ignore lint/suspicious/useIterableCallbackReturn: remove() return unused .forEach((el) => el.remove()); const toast = document.createElement("div"); toast.className = "gh-exporter-toast"; toast.textContent = message; const bg = { info: "#1f6feb", success: "#238636", error: "#da3633" }; Object.assign(toast.style, { position: "fixed", bottom: "80px", left: "50%", transform: "translateX(-50%)", background: bg[type] || bg.info, color: "#fff", padding: "12px 24px", borderRadius: "8px", fontSize: "14px", fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', fontWeight: "500", zIndex: "2147483647", opacity: "0", transition: "opacity 0.3s", boxShadow: "0 4px 12px rgba(0,0,0,0.3)", pointerEvents: "none", }); document.body.appendChild(toast); requestAnimationFrame(() => { toast.style.opacity = "1"; }); setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000); } // ════════════════════════════════════════════════════════════════════════════ // UNIFIED PANEL — CSS // ════════════════════════════════════════════════════════════════════════════ function injectPanelCSS() { if (document.getElementById("ghe-css")) return; const s = document.createElement("style"); s.id = "ghe-css"; s.textContent = ` #ghe-root{position:fixed;bottom:20px;right:20px;z-index:999999;font-size:13px} #ghe-toggle{background:#238636;color:#fff;padding:8px 12px;border-radius:8px;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.4);user-select:none;font-weight:600;font-size:13px} #ghe-panel{background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:8px;min-width:400px;max-width:90vw;box-shadow:0 8px 32px rgba(0,0,0,.6);color:#c9d1d9;display:none;max-height:calc(100vh - 40px);overflow-y:auto;box-sizing:border-box;color-scheme:dark;position:relative} #ghe-sash{position:absolute;left:-3px;top:0;bottom:0;width:6px;cursor:ew-resize;z-index:1;background:transparent} #ghe-sash:hover,#ghe-sash.active{background:rgba(88,166,255,.3);border-radius:3px} .ghe-batch-disabled{opacity:.5;pointer-events:none} .ghe-batch-hint{text-align:center;padding:12px 8px;color:#8b949e;font-size:11px;font-style:italic} #ghe-panel input[type="checkbox"]{accent-color:#238636} .ghe-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:13px} .ghe-close{cursor:pointer;color:#8b949e;font-size:16px;padding:2px 6px} .ghe-sec{margin-bottom:6px;padding:10px 12px;border:1px solid #30363d;border-radius:6px} .ghe-sec h3{margin:0 0 6px;font-size:13px;color:#f0f6fc;cursor:default} .ghe-info{font-size:11px;color:#8b949e;font-weight:400;cursor:help;margin-left:4px;vertical-align:middle;display:inline-block;width:16px;height:16px;line-height:16px;text-align:center;border:1px solid #30363d;border-radius:50%;background:#161b22;position:relative} .ghe-info:hover{color:#c9d1d9;border-color:#8b949e} .ghe-tip{display:none;position:absolute;left:50%;transform:translateX(-50%);background:#1c2128;color:#c9d1d9;border:1px solid #444c56;border-radius:6px;padding:8px 10px;font-size:11px;font-weight:400;line-height:1.45;white-space:normal;width:max-content;max-width:280px;z-index:10;box-shadow:0 4px 12px rgba(0,0,0,.4);pointer-events:none;text-align:left} .ghe-tip.above{bottom:calc(100% + 8px)} .ghe-tip.below{top:calc(100% + 8px)} .ghe-tip::after{content:'';position:absolute;left:var(--arrow-left,50%);transform:translateX(-50%);border:5px solid transparent} .ghe-tip.above::after{top:100%;border-top-color:#444c56} .ghe-tip.below::after{bottom:100%;border-bottom-color:#444c56} .ghe-info:hover .ghe-tip{display:block} .ghe-btn{padding:5px 8px;border:none;border-radius:5px;cursor:pointer;color:#fff;font-size:12px;white-space:nowrap} .ghe-btn:disabled{opacity:.4;cursor:not-allowed} .ghe-g{background:#238636}.ghe-b{background:#1f6feb}.ghe-p{background:#8957e5}.ghe-gr{background:#6e7781}.ghe-r{background:#da3633} .ghe-row{display:flex;gap:3px;margin-top:6px;flex-wrap:wrap} .ghe-row .ghe-btn{flex:1;text-align:center;min-width:0} .ghe-st{color:#8b949e;font-size:11px;margin-top:4px;min-height:1.4em} .ghe-warn{color:#f85149} .ghe-rl{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:4px;font-size:11px;color:#8b949e;background:#161b22;border:1px solid #21262d;margin-bottom:6px} .ghe-rl-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} .ghe-rl-dot.ok{background:#3fb950}.ghe-rl-dot.low{background:#d29922}.ghe-rl-dot.out{background:#f85149} .ghe-rl-mini{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#8b949e} .ghe-rl-mini .ghe-rl-dot{width:6px;height:6px} .ghe-inp{width:60px;padding:3px;background:#21262d;border:1px solid #30363d;border-radius:4px;color:#fff;font-size:12px} .ghe-fw{width:100%} .ghe-chk{display:flex;flex-wrap:wrap;gap:4px 10px;margin-bottom:6px} .ghe-chk label{display:flex;align-items:center;gap:4px;font-size:12px;color:#c9d1d9;cursor:pointer;white-space:nowrap} .ghe-chk input{margin:0;cursor:pointer} .ghe-exp-grid{display:grid;grid-template-columns:1fr 1fr;gap:2px 10px;font-size:12px;margin-bottom:6px} .ghe-exp-grid label{display:flex;align-items:center;gap:4px;color:#c9d1d9;cursor:pointer;white-space:nowrap;padding:2px 0} .ghe-exp-grid label.indent{padding-left:16px;color:#8b949e;font-size:11px} .ghe-exp-grid label.disabled{opacity:.4;pointer-events:none} .ghe-exp-grid input{margin:0;cursor:pointer;width:14px;height:14px} .ghe-batch-list{height:180px;min-height:60px;overflow-y:auto;border:1px solid #30363d;border-radius:6px;background:#161b22;margin:6px 0;padding:0;font-size:12px;resize:vertical} .ghe-batch-list::-webkit-scrollbar{width:6px} .ghe-batch-list::-webkit-scrollbar-thumb{background:#30363d;border-radius:3px} .ghe-batch-list::-webkit-scrollbar-track{background:transparent} .ghe-batch-table{width:100%;border-collapse:collapse;font-size:12px} .ghe-batch-table tr{cursor:pointer;user-select:none} .ghe-batch-table tr:hover{background:#21262d} .ghe-batch-table td{padding:2px 4px;vertical-align:middle;white-space:nowrap} .ghe-batch-table td:first-child{width:20px;text-align:center} .ghe-batch-table td:nth-child(2){width:32px} .ghe-batch-table td:nth-child(3){width:20px;text-align:center;font-size:11px} .ghe-batch-table td:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:0;width:100%} .ghe-batch-table input{margin:0;cursor:pointer} .ghe-batch-table .badge{font-size:9px;font-weight:600;padding:1px 4px;border-radius:3px;text-transform:uppercase;letter-spacing:.3px;display:inline-block} .ghe-batch-table .badge.issue{background:#238636;color:#fff} .ghe-batch-table .badge.pr{background:#1f6feb;color:#fff} .ghe-batch-table .badge.discussion{background:#8957e5;color:#fff} .ghe-log-wrap{position:relative} .ghe-log-actions{position:absolute;top:3px;right:3px;display:flex;gap:2px;z-index:1;opacity:.2;transition:opacity .2s} .ghe-log-wrap:hover .ghe-log-actions{opacity:1} .ghe-log-act{background:rgba(33,38,45,.8);border:1px solid #30363d;border-radius:4px;color:#8b949e;font-size:13px;width:24px;height:24px;line-height:24px;text-align:center;padding:0;cursor:pointer;transition:color .15s,background .15s;display:inline-flex;align-items:center;justify-content:center} .ghe-log-act:hover{color:#c9d1d9;background:rgba(33,38,45,.95)} .ghe-batch-controls{display:flex;gap:3px;align-items:center;flex-wrap:wrap;margin-bottom:4px} .ghe-batch-controls button{font-size:10px;padding:2px 6px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;border-radius:4px;cursor:pointer} .ghe-batch-controls button:hover{background:#30363d} .ghe-batch-controls button.active{background:#1f6feb;color:#fff;border-color:#1f6feb} .ghe-batch-title{color:#f0f6fc} .ghe-batch-filter{flex:1;min-width:80px;padding:3px 6px;background:#21262d;border:1px solid #30363d;border-radius:4px;color:#c9d1d9;font-size:11px} #ghe-panel.ghe-light{background:#ffffff;border-color:#d0d7de;color:#1f2328;color-scheme:light} #ghe-panel.ghe-light .ghe-sec{border-color:#d0d7de;background:#ffffff} #ghe-panel.ghe-light .ghe-sec h3{color:#1f2328} #ghe-panel.ghe-light .ghe-info{background:#f6f8fa;border-color:#d0d7de;color:#656d76} #ghe-panel.ghe-light .ghe-info:hover{color:#1f2328;border-color:#656d76} #ghe-panel.ghe-light .ghe-tip{background:#ffffff;color:#1f2328;border-color:#d0d7de;box-shadow:0 4px 12px rgba(0,0,0,.12)} #ghe-panel.ghe-light .ghe-tip.above::after{border-top-color:#d0d7de} #ghe-panel.ghe-light .ghe-tip.below::after{border-bottom-color:#d0d7de} #ghe-panel.ghe-light .ghe-st{color:#656d76} #ghe-panel.ghe-light .ghe-close{color:#656d76} #ghe-panel.ghe-light .ghe-close:hover{color:#1f2328} #ghe-panel.ghe-light .ghe-hdr strong{color:#1f2328} #ghe-panel.ghe-light a{color:#0969da} #ghe-panel.ghe-light .ghe-inp{background:#f6f8fa;border-color:#d0d7de;color:#1f2328} #ghe-panel.ghe-light .ghe-chk label{color:#1f2328} #ghe-panel.ghe-light .ghe-exp-grid label{color:#1f2328} #ghe-panel.ghe-light .ghe-exp-grid label.indent{color:#656d76} #ghe-panel.ghe-light .ghe-exp-grid label.disabled{opacity:.4} #ghe-panel.ghe-light #ghe-token-det summary{color:#656d76} #ghe-panel.ghe-light #ghe-token-inp{background:#f6f8fa!important;border-color:#d0d7de!important;color:#1f2328!important} #ghe-panel.ghe-light textarea{background:#f6f8fa!important;color:#1f2328!important;border-color:#d0d7de!important} #ghe-panel.ghe-light code{background:#eaeef2!important;color:#1f2328!important} #ghe-panel.ghe-light .ghe-rl{background:#f6f8fa;border-color:#d0d7de;color:#656d76} #ghe-panel.ghe-light .ghe-rl-mini{color:#656d76} #ghe-panel.ghe-light .ghe-btn.ghe-gr{background:#d0d7de!important;color:#1f2328!important} #ghe-panel.ghe-light .ghe-btn.ghe-g{background:#1a7f37} #ghe-panel.ghe-light .ghe-btn.ghe-b{background:#0969da} #ghe-panel.ghe-light .ghe-btn.ghe-p{background:#7c3aed} #ghe-panel.ghe-light .ghe-btn.ghe-r{background:#cf222e} #ghe-panel.ghe-light .ghe-batch-list::-webkit-scrollbar-thumb{background:#d0d7de} #ghe-panel.ghe-light .ghe-batch-list{background:#f6f8fa;border-color:#d0d7de} #ghe-panel.ghe-light .ghe-batch-table tr:hover{background:#eaeef2} #ghe-panel.ghe-light .ghe-batch-table td:last-child{color:#1f2328} #ghe-panel.ghe-light .ghe-log-act{background:rgba(246,248,250,.9);border-color:#d0d7de;color:#656d76} #ghe-panel.ghe-light .ghe-log-act:hover{color:#1f2328} #ghe-panel.ghe-light .ghe-batch-controls button{background:#f6f8fa;border-color:#d0d7de;color:#1f2328} #ghe-panel.ghe-light .ghe-batch-controls button:hover{background:#eaeef2} #ghe-panel.ghe-light .ghe-batch-controls button.active{background:#0969da;color:#fff;border-color:#0969da} #ghe-panel.ghe-light .ghe-batch-filter{background:#f6f8fa;border-color:#d0d7de;color:#1f2328} #ghe-panel.ghe-light .ghe-batch-hint{color:#656d76} #ghe-panel.ghe-light .ghe-warn{color:#cf222e} #ghe-panel.ghe-light details summary{color:#656d76} #ghe-panel.ghe-light #ghe-batch-wrap{border-color:#d0d7de} #ghe-panel.ghe-light #ghe-sash:hover,#ghe-panel.ghe-light #ghe-sash.active{background:rgba(9,105,218,.2)} #ghe-panel.ghe-light .ghe-batch-title{color:#1f2328} #ghe-panel.ghe-light #ghe-batch-progress div[style*="background:#21262d"]{background:#e1e4e8!important} #ghe-panel.ghe-light #ghe-batch-bar{background:#1a7f37} `; document.head.appendChild(s); } // ════════════════════════════════════════════════════════════════════════════ // THREAD EXPORT SETTINGS — full flag metadata with groups + indent + deps // ════════════════════════════════════════════════════════════════════════════ /** Parent→children dependency map for export settings UI */ const EXP_DEPS = { INCLUDE_HEADER: [ "INCLUDE_LABELS", "INCLUDE_ASSIGNEES", "INCLUDE_MILESTONE", ], PR_INCLUDE_REVIEW_COMMENTS: [ "PR_INCLUDE_REVIEW_THREAD_STATE", "PR_REVIEW_COMMENTS_GROUP_BY_THREAD", ], PR_INCLUDE_CHANGED_FILES: ["PR_INCLUDE_CHANGED_FILES_PATCHES"], INCLUDE_REFERENCES_SECTION: [ "REF_INCLUDE_CROSS_REFERENCED", "REF_INCLUDE_SAME_REPO", "REF_INCLUDE_CROSS_REPO", "REF_INCLUDE_ISSUES", "REF_INCLUDE_PRS", "REF_INCLUDE_DUPLICATES", "REF_INCLUDE_COMMITS", "REF_FETCH_COMMIT_DETAILS", ], INCLUDE_TIMELINE_SECTION: [ "TL_INCLUDE_CROSS_REFERENCED", "TL_INCLUDE_REFERENCED", "TL_INCLUDE_RENAMED", "TL_INCLUDE_LABEL_CHANGES", "TL_INCLUDE_CLOSED_EVENTS", "TL_INCLUDE_REVIEWED", "TL_INCLUDE_COMMITTED", "TL_INCLUDE_MERGED", "TL_INCLUDE_HEAD_REF_EVENTS", "TL_INCLUDE_REVIEW_REQUESTED", "TL_INCLUDE_MARKED_DUPLICATE_EVENTS", "TL_INCLUDE_PROJECT_V2", "TL_INCLUDE_SUBSCRIBE_EVENTS", "TL_INCLUDE_MENTIONED_EVENTS", ], }; /** Reverse lookup: child key → parent key */ const EXP_CHILD_TO_PARENT = {}; for (const [parent, children] of Object.entries(EXP_DEPS)) { for (const child of children) EXP_CHILD_TO_PARENT[child] = parent; } const EXP_FLAGS = { // Content INCLUDE_HEADER: { l: "Include Header", g: "Content", t: "Include the metadata header block (state, labels, assignees, etc.)", }, INCLUDE_LABELS: { l: "Labels", g: "Content", t: "Show labels in header", indent: 1, }, INCLUDE_ASSIGNEES: { l: "Assignees", g: "Content", t: "Show assignees in header", indent: 1, }, INCLUDE_MILESTONE: { l: "Milestone", g: "Content", t: "Show milestone in header", indent: 1, }, INCLUDE_AUTHOR_INFO: { l: "Show Authors", g: "Content", t: "Show author names and profile links on comments", }, INCLUDE_REACTIONS: { l: "Show Reactions", g: "Content", t: "Show emoji reactions on the body and comments", }, INCLUDE_TIMESTAMPS: { l: "Show Timestamps", g: "Content", t: "Show created/updated/closed dates", }, INCLUDE_COMMENT_IDS: { l: "Comment IDs", g: "Content", t: "Show internal comment IDs (useful for API references)", }, // Issue ISSUE_INCLUDE_TYPE: { l: "Issue Type", g: "Issue", t: "Show issue type (bug, feature, task) if set", }, ISSUE_INCLUDE_CLOSED_BY: { l: "Closed By", g: "Issue", t: "Show who closed the issue", }, ISSUE_INCLUDE_LOCK_REASON: { l: "Lock Reason", g: "Issue", t: "Show lock reason if the issue is locked", }, ISSUE_INCLUDE_DEPENDENCIES: { l: "Dependencies", g: "Issue", t: "Fetch and show blocked-by / blocking issue dependencies", }, ISSUE_INCLUDE_SUB_ISSUES: { l: "Sub-Issues", g: "Issue", t: "Fetch and list sub-issues", }, ISSUE_INCLUDE_PINNED_COMMENT: { l: "Pinned Comment", g: "Issue", t: "Show the pinned comment if one exists", }, // PR PR_INCLUDE_MERGE_STATUS: { l: "Merge Status", g: "Pull Request", t: "Show merged/draft/open state and who merged", }, PR_INCLUDE_BRANCH_INFO: { l: "Branch Info", g: "Pull Request", t: "Show head → base branch names", }, PR_INCLUDE_DIFF_STATS: { l: "Diff Stats", g: "Pull Request", t: "Show additions/deletions/changed files count", }, PR_INCLUDE_REQUESTED_REVIEWERS: { l: "Requested Reviewers", g: "Pull Request", t: "Show users and teams requested for review", }, PR_INCLUDE_MERGE_REQUIREMENTS: { l: "Merge Requirements", g: "Pull Request", t: "Show branch protection rules and auto-merge status", }, PR_INCLUDE_CHECKS_SECTION: { l: "Checks & Status", g: "Pull Request", t: "Show CI check runs and commit statuses", }, PR_INCLUDE_REVIEWS: { l: "Reviews", g: "Pull Request", t: "Show review verdicts (approved, changes requested, etc.)", }, PR_INCLUDE_REVIEW_COMMENTS: { l: "Review Comments", g: "Pull Request", t: "Show inline code review comments with diff context", }, PR_INCLUDE_REVIEW_THREAD_STATE: { l: "Thread State (GQL)", g: "Pull Request", t: "Show resolved/outdated state on review threads (requires token)", indent: 1, }, PR_REVIEW_COMMENTS_GROUP_BY_THREAD: { l: "Group by Thread", g: "Pull Request", t: "Group reply chains together instead of flat list", indent: 1, }, PR_INCLUDE_PR_COMMITS: { l: "PR Commits", g: "Pull Request", t: "List all commits in the pull request", }, PR_INCLUDE_CHANGED_FILES: { l: "Changed Files", g: "Pull Request", t: "List all changed files with status and line counts", }, PR_INCLUDE_CHANGED_FILES_PATCHES: { l: "File Diffs", g: "Pull Request", t: "Include the actual diff patches for each file", indent: 1, }, // Discussion DISC_INCLUDE_CATEGORY: { l: "Category", g: "Discussion", t: "Show discussion category (General, Q&A, etc.)", }, DISC_INCLUDE_ANSWER: { l: "Answer", g: "Discussion", t: "Highlight the accepted answer comment in Q&A discussions", }, DISC_INCLUDE_POLL: { l: "Poll", g: "Discussion", t: "Show poll question, options, and vote counts (requires token)", }, DISC_INCLUDE_UPVOTES: { l: "Upvotes", g: "Discussion", t: "Show upvote count (requires token)", }, DISC_INCLUDE_COMMENT_REPLIES: { l: "Comment Replies", g: "Discussion", t: "Show nested replies under each top-level comment", }, DISC_INCLUDE_TIMELINE: { l: "Timeline (GQL)", g: "Discussion", t: "Include timeline events for discussions via GraphQL (labeled, closed, pinned, etc.)", }, // References INCLUDE_REFERENCES_SECTION: { l: "References Section", g: "References", t: "Include a deduplicated references section from timeline events", }, REF_INCLUDE_CROSS_REFERENCED: { l: "Cross References", g: "References", t: "Include issues/PRs that mention this thread", indent: 1, }, REF_INCLUDE_SAME_REPO: { l: "Same-Repo Refs", g: "References", t: "Include references from the same repository", indent: 1, }, REF_INCLUDE_CROSS_REPO: { l: "Cross-Repo Refs", g: "References", t: "Include references from other repositories", indent: 1, }, REF_INCLUDE_ISSUES: { l: "Referenced Issues", g: "References", t: "Include referenced issues", indent: 1, }, REF_INCLUDE_PRS: { l: "Referenced PRs", g: "References", t: "Include referenced pull requests", indent: 1, }, REF_INCLUDE_DUPLICATES: { l: "Duplicates", g: "References", t: "Include items marked as duplicates", indent: 1, }, REF_INCLUDE_COMMITS: { l: "Commit Refs", g: "References", t: "Include commits that reference this thread", indent: 1, }, REF_FETCH_COMMIT_DETAILS: { l: "Fetch Commit Details", g: "References", t: "Fetch full commit message for each referenced commit (extra API calls)", indent: 1, }, // Timeline INCLUDE_TIMELINE_SECTION: { l: "Timeline (Verbose)", g: "Timeline", t: "Include a verbose audit log of all timeline events", }, TL_INCLUDE_CROSS_REFERENCED: { l: "Cross References", g: "Timeline", t: "Show cross-reference events in timeline", indent: 1, }, TL_INCLUDE_REFERENCED: { l: "Commit Refs", g: "Timeline", t: "Show commit reference events in timeline", indent: 1, }, TL_INCLUDE_RENAMED: { l: "Title Changes", g: "Timeline", t: "Show title rename events", indent: 1, }, TL_INCLUDE_LABEL_CHANGES: { l: "Label Changes", g: "Timeline", t: "Show label added/removed events", indent: 1, }, TL_INCLUDE_CLOSED_EVENTS: { l: "Closed/Reopened", g: "Timeline", t: "Show closed and reopened events", indent: 1, }, TL_INCLUDE_REVIEWED: { l: "Reviews (PR)", g: "Timeline", t: "Show review events in timeline", indent: 1, }, TL_INCLUDE_COMMITTED: { l: "Commits (PR)", g: "Timeline", t: "Show commit events in timeline", indent: 1, }, TL_INCLUDE_MERGED: { l: "Merged (PR)", g: "Timeline", t: "Show merge events in timeline", indent: 1, }, TL_INCLUDE_HEAD_REF_EVENTS: { l: "Branch Events (PR)", g: "Timeline", t: "Show force-push, delete, restore branch events", indent: 1, }, TL_INCLUDE_REVIEW_REQUESTED: { l: "Review Requested (PR)", g: "Timeline", t: "Show review request/removal events", indent: 1, }, TL_INCLUDE_MARKED_DUPLICATE_EVENTS: { l: "Marked Duplicate", g: "Timeline", t: "Show marked-as-duplicate events", indent: 1, }, TL_INCLUDE_PROJECT_V2: { l: "Project v2 Events", g: "Timeline", t: "Show GitHub Projects v2 events (noisy)", indent: 1, }, TL_INCLUDE_SUBSCRIBE_EVENTS: { l: "Subscribe Events", g: "Timeline", t: "Show subscribe/unsubscribe events (noisy)", indent: 1, }, TL_INCLUDE_MENTIONED_EVENTS: { l: "Mentioned Events", g: "Timeline", t: "Show mentioned events (noisy)", indent: 1, }, // Minimized INCLUDE_MINIMIZED_COMMENTS: { l: "Show Minimized Comments", g: "Content", t: "Include comments that were minimized (hidden) by moderators, with a note", }, // Formatting COLLAPSIBLE_LONG_COMMENTS: { l: "Collapse Long Comments", g: "Formatting", t: "Wrap long comments in collapsible <details> blocks", }, }; const EXP_GROUP_ORDER = [ "Content", "Issue", "Pull Request", "Discussion", "References", "Timeline", "Formatting", ]; // ════════════════════════════════════════════════════════════════════════════ // UNIFIED PANEL // ════════════════════════════════════════════════════════════════════════════ const Panel = (() => { const ID = "ghe-root"; let threadExportAbort = null; let threadExporting = false; function create() { remove(); injectPanelCSS(); const pageInfo = Utils.parsePage(); const isThread = pageInfo && (pageInfo.type === "issue" || pageInfo.type === "pr" || pageInfo.type === "discussion"); const isRelease = pageInfo && pageInfo.type === "release"; const root = document.createElement("div"); root.id = ID; const toggle = document.createElement("div"); toggle.id = "ghe-toggle"; toggle.textContent = isThread ? "📥 Export" : isRelease ? "📥 Release Export" : "📋 Repo Exporter"; const panel = document.createElement("div"); panel.id = "ghe-panel"; // Build thread export settings HTML let expSettingsHtml = ""; if (isThread) { const grouped = {}; Object.entries(EXP_FLAGS).forEach(([k, m]) => { const g = m.g || "Other"; if (!grouped[g]) grouped[g] = []; grouped[g].push({ key: k, ...m }); }); let settingsInner = ""; EXP_GROUP_ORDER.forEach((gn) => { const flags = grouped[gn]; if (!flags) return; settingsInner += `<div style="margin-bottom:6px"><div style="font-size:10px;font-weight:600;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px;border-bottom:1px solid #21262d;padding-bottom:2px">${gn}</div><div class="ghe-exp-grid">`; flags.forEach((f) => { const cls = f.indent ? ' class="indent"' : ""; const checked = ThreadConfig.get(f.key) ? " checked" : ""; const tip = f.t ? ` title="${Utils.escapeHtml(f.t)}"` : ""; settingsInner += `<label${cls}${tip}><input type="checkbox" data-exp-key="${f.key}"${checked}> ${f.l}</label>`; }); settingsInner += "</div></div>"; }); const typeLabel = pageInfo.type === "pr" ? "Pull Request" : pageInfo.type === "discussion" ? "Discussion" : "Issue"; expSettingsHtml = ` <div class="ghe-sec" id="ghe-exp-sec"> <h3>📥 Export This ${typeLabel} #${pageInfo.number} <span class="ghe-info">?<span class="ghe-tip">Export this ${typeLabel.toLowerCase()} as a Markdown file with full content.<br><br><b>What's included:</b> body, all comments, metadata, reactions, labels, and any sections enabled in Export Settings below.<br><br><b>How to use:</b><br>1. Adjust settings if needed (⚙️ below)<br>2. Click Export or Preview<br>3. Download the .md file or view rendered HTML</span></span></h3> <div id="ghe-exp-st" class="ghe-st">Ready to export.</div> <button id="ghe-exp-go" class="ghe-btn ghe-g ghe-fw" style="margin-top:6px" title="Download this ${typeLabel.toLowerCase()} as a .md Markdown file">📥 Export to Markdown</button> <button id="ghe-exp-preview" class="ghe-btn ghe-b ghe-fw" style="margin-top:4px" title="Render the Markdown and open it as styled HTML in a new tab — includes table of contents, copy button, and raw/rendered toggle">👁️ Preview in New Tab</button> <details style="margin-top:8px"> <summary style="cursor:pointer;font-size:12px;color:#8b949e;user-select:none" title="Configure which sections to include in the export (reviews, timeline, references, etc.)">⚙️ Export Settings</summary> <div style="margin-top:6px"> ${settingsInner} <div class="ghe-row"><button id="ghe-exp-reset" class="ghe-btn ghe-gr" style="font-size:11px" title="Reset all export settings to their default values">Reset to Defaults</button></div> </div> </details> </div>`; } safeSetInnerHTML( panel, ` <div class="ghe-hdr"><strong style="font-size:12px">${SCRIPT_NAME}</strong><div style="display:flex;align-items:center;gap:4px"><button id="ghe-help" class="ghe-btn ghe-gr" style="font-size:11px;padding:2px 6px" title="Open full help & documentation in a new tab">📖</button><button id="ghe-theme" class="ghe-btn ghe-gr" style="font-size:11px;padding:2px 6px" title="Toggle between light and dark panel theme">🌙</button><a id="ghe-kofi" href="https://ko-fi.com/piknockyou" target="_blank" rel="noopener noreferrer" class="ghe-btn ghe-gr" style="font-size:11px;padding:2px 6px;text-decoration:none;color:#fff" title="Buy me a coffee — support this script's development">☕</a><span class="ghe-close" id="ghe-x" title="Close panel (click the toggle button to reopen)">✕</span></div></div> <div style="font-size:11px;color:#8b949e;margin-bottom:4px">${Utils.repoFullName() || ""}</div> <div class="ghe-rl" id="ghe-rl-bar" title="GitHub API rate limit — how many requests you have left this hour. Green = plenty, Yellow = running low, Red = exhausted. Resets every hour. Add a token for 5,000/hr (vs 60 without)."> <span class="ghe-rl-dot" id="ghe-rl-dot"></span> <span id="ghe-rl-text">API quota: no requests yet</span> </div> <div class="ghe-sec" style="padding:8px 12px"> <details id="ghe-token-det"> <summary style="cursor:pointer;font-size:12px;color:#8b949e;user-select:none">🔑 GitHub Token <span id="ghe-token-badge" style="font-size:11px" title="Token status — a token increases API rate limits from 60 to 5,000 requests/hour and enables Discussion exports"></span></summary> <div style="margin-top:6px"> <div style="font-size:11px;color:#8b949e;margin-bottom:4px">Required for Discussions export & higher rate limits (60→5k/hr).<br> <a href="https://github.com/settings/tokens" target="_blank" style="color:#58a6ff">Settings → Tokens</a> · scope: <code style="background:#21262d;padding:1px 4px;border-radius:3px;font-size:11px">public_repo</code></div> <div style="display:flex;gap:4px;align-items:center"> <input id="ghe-token-inp" type="password" placeholder="ghp_…" title="Paste your GitHub personal access token here" style="flex:1;padding:4px 6px;background:#21262d;border:1px solid #30363d;border-radius:4px;color:#c9d1d9;font-family:monospace;font-size:12px" value="${Token.has() ? "••••••••" : ""}"> <button id="ghe-token-save" class="ghe-btn ghe-g" style="font-size:11px;padding:4px 8px" title="Save token to local storage (encrypted by your userscript manager)">Save</button> <button id="ghe-token-clear" class="ghe-btn ghe-gr" style="font-size:11px;padding:4px 8px" title="Remove saved token">Clear</button> </div> <div id="ghe-token-st" class="ghe-st" style="margin-top:4px"></div> </div> </details> </div> ${expSettingsHtml} ${ isRelease ? ` <div class="ghe-sec" id="ghe-single-rel-sec"> <h3>📦 Export This Release <span class="ghe-info">?<span class="ghe-tip">Export this specific release's notes, changelog, and asset list.<br><br><b>What's included:</b> release title, description/changelog, download links, file sizes, reactions.<br><br><b>Not included:</b> actual binary files — only metadata and links.</span></span></h3> <div style="font-size:12px;color:#8b949e;margin-bottom:6px">Tag: <code style="background:#21262d;padding:1px 4px;border-radius:3px">${Utils.escapeHtml(pageInfo.tag || "")}</code></div> <div id="ghe-single-rel-st" class="ghe-st">Ready to export.</div> <div class="ghe-row" style="margin-top:6px"> <button id="ghe-single-rel-html" class="ghe-btn ghe-b" title="Open release notes as styled HTML in a new tab">🔗 HTML</button> <button id="ghe-single-rel-save-h" class="ghe-btn ghe-b" title="Download release notes as an HTML file">💾 HTML</button> <button id="ghe-single-rel-save-m" class="ghe-btn ghe-p" title="Download release notes as a Markdown file">💾 MD</button> </div> </div> ` : "" } <div class="ghe-sec" id="ghe-rel-sec"> <h3>📦 Release Notes & Changelogs <span class="ghe-info">?<span class="ghe-tip">Fetch and export release notes for all releases in this repository.<br><br><b>What's included:</b> version tags, changelogs, asset download links, file sizes, reactions.<br><br><b>How to use:</b><br>1. (Optional) Enter a filter to narrow results<br>2. Click Start to fetch all releases<br>3. Pause anytime, resume later<br>4. Download as HTML, Markdown, or ZIP (one file per release)<br><br><b>Note:</b> This exports release <em>notes</em>, not the actual binary files.</span></span></h3> <div style="display:flex;align-items:center;gap:4px;font-size:12px;margin-bottom:4px"> <span style="flex-shrink:0">Filter:</span> <input id="ghe-rel-filter" type="text" class="ghe-inp" style="width:140px;flex-shrink:0" placeholder="2009, v1.*, v1..v2" title="Filter by date, tag glob, tag range, or exact tag — hover the ? for details"> <span class="ghe-info">?<span class="ghe-tip above"><b>Release Filter</b><br><br><b>By date (use <code>..</code> for ranges):</b><br><code>2009</code> — all of 2009<br><code>2009-06</code> — June 2009 only<br><code>2009-06-15</code> — exact day<br><code>2009..2010</code> — 2009 through 2010<br><code>2009-01..2010-06</code> — Jan 2009 to Jun 2010<br><code>2024-01-01..2025-06-30</code> — exact range<br><br><b>By tag name:</b><br><code>v1.*</code> — wildcard / glob<br><code>v2.0.*</code> — any v2.0.x tag<br><code>v1.0.0..v2.0.0</code> — tag range<br><code>v1.0.0-v2.0.0</code> — tag range (<code>-</code> also works for tags)<br><code>v2.35.1</code> — exact tag<br><br><b>Date format:</b> <code>YYYY</code>, <code>YYYY-MM</code>, or <code>YYYY-MM-DD</code><br><b>Range separator:</b> always <code>..</code> for dates<br>(<code>-</code> is only for tag ranges since dates already use dashes)<br><br>Leave empty to fetch all.</span></span> </div> <div id="ghe-rel-st" class="ghe-st" style="display:flex;justify-content:space-between;align-items:center"><span id="ghe-rel-st-txt">No data yet.</span><label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap;flex-shrink:0" title="Show detailed API request log for debugging"><input type="checkbox" id="ghe-rel-dbg" ${Prefs.get("relDebug", false) ? "checked" : ""} style="margin:0;cursor:pointer"> Log</label></div> <div id="ghe-rel-dbg-wrap" style="display:none;margin-top:4px"> <div class="ghe-log-wrap"><textarea id="ghe-rel-dbg-log" readonly style="width:100%;height:80px;background:#161b22;color:#8b949e;border:1px solid #30363d;border-radius:4px;font-family:monospace;font-size:10px;padding:4px;resize:vertical;box-sizing:border-box"></textarea><div class="ghe-log-actions"><button class="ghe-log-act" data-log-action="clear" data-log-target="ghe-rel-dbg-log" data-log-module="releases" title="Clear the debug log">🗑</button><button class="ghe-log-act" data-log-action="copy" data-log-target="ghe-rel-dbg-log" title="Copy debug log to clipboard">📋</button></div></div> </div> <div class="ghe-row" style="margin-top:6px"> <button id="ghe-rel-go" class="ghe-btn ghe-g" style="flex:2" title="Fetch all releases from this repository">Start</button> <button id="ghe-rel-cancel" class="ghe-btn ghe-r" style="display:none" title="Pause fetching — you can resume later">⏸ Pause</button> <button id="ghe-rel-reset" class="ghe-btn ghe-gr" style="display:none" title="Discard all fetched data and start fresh">✕ Reset</button> </div> <div class="ghe-row" style="margin-top:6px"> <button id="ghe-rel-open" class="ghe-btn ghe-b" disabled title="Open all release notes as styled HTML in a new tab">🔗 HTML</button> <button id="ghe-rel-save-h" class="ghe-btn ghe-b" disabled title="Download all release notes as a single HTML file">💾 HTML</button> <button id="ghe-rel-save-m" class="ghe-btn ghe-p" disabled title="Download all release notes as a single Markdown file">💾 MD</button> <button id="ghe-rel-save-zip" class="ghe-btn ghe-b" disabled title="Download as ZIP with one Markdown file per release, plus combined files">📦 ZIP</button> </div> </div> <div class="ghe-sec" id="ghe-iss-sec"> <h3>📋 Issues · PRs · Discussions — Index <span class="ghe-info">?<span class="ghe-tip">Generate a lightweight listing of all issues, PRs, and/or discussions.<br><br><b>What's included:</b> number, title, state, labels, and link for each item.<br><b>Not included:</b> body text, comments, reviews, diffs.<br><br><b>How to use:</b><br>1. Check which types to include<br>2. (Optional) Set a number range<br>3. Click Start — pause/resume anytime<br>4. Download as HTML or Markdown<br><br><b>Tip:</b> This index is also used by Batch Full Export below to know which items exist.</span></span></h3> <div class="ghe-chk"> <label title="Include issues in the index"><input type="checkbox" id="ghe-chk-iss" ${Prefs.get("issues", true) ? "checked" : ""}> Issues</label> <label title="Include pull requests in the index"><input type="checkbox" id="ghe-chk-pr" ${Prefs.get("prs", true) ? "checked" : ""}> Pull Requests</label> <label title="Include discussions in the index (requires a GitHub token)"><input type="checkbox" id="ghe-chk-dsc" ${Prefs.get("discussions", true) ? "checked" : ""}> Discussions 🔑</label> </div> <div style="display:flex;align-items:center;gap:4px;font-size:12px;margin-bottom:4px"> <span style="flex-shrink:0" title="Limit which item numbers to fetch. Examples: 1-100, 50-200, 1-50,100-150">Range: #</span> <input id="ghe-iss-from" type="text" class="ghe-inp" style="width:90px" placeholder="e.g. 1-500" title="Enter a number range to fetch specific items. Examples: 1-500 — items #1 through #500 1-50,100-150 — two ranges 42 — single item Leave empty to fetch all."> </div> <div id="ghe-iss-pr" class="ghe-st" style="display:flex;justify-content:space-between;align-items:center"><span id="ghe-iss-pr-txt">No data yet.</span><label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap;flex-shrink:0" title="Show detailed API request log for debugging"><input type="checkbox" id="ghe-chk-dbg" ${Prefs.get("debug", false) ? "checked" : ""} style="margin:0;cursor:pointer"> Log</label></div> <div id="ghe-dbg-wrap" style="display:none;margin-top:4px"> <div class="ghe-log-wrap"><textarea id="ghe-dbg-log" readonly style="width:100%;height:80px;background:#161b22;color:#8b949e;border:1px solid #30363d;border-radius:4px;font-family:monospace;font-size:10px;padding:4px;resize:vertical;box-sizing:border-box"></textarea><div class="ghe-log-actions"><button class="ghe-log-act" data-log-action="clear" data-log-target="ghe-dbg-log" data-log-module="index" title="Clear the debug log">🗑</button><button class="ghe-log-act" data-log-action="copy" data-log-target="ghe-dbg-log" title="Copy debug log to clipboard">📋</button></div></div> </div> <div class="ghe-row" style="margin-top:6px"> <button id="ghe-iss-go" class="ghe-btn ghe-g" style="flex:2" title="Fetch the index of issues, PRs, and discussions">Start</button> <button id="ghe-iss-cancel" class="ghe-btn ghe-r" style="display:none" title="Pause fetching — you can resume later">⏸ Pause</button> <button id="ghe-iss-reset" class="ghe-btn ghe-gr" style="display:none" title="Discard all fetched data and start fresh">✕ Reset</button> </div> <div class="ghe-row" style="margin-top:6px"> <button id="ghe-iss-open" class="ghe-btn ghe-b" disabled title="Open the index as styled HTML in a new tab">🔗 HTML</button> <button id="ghe-iss-dl-h" class="ghe-btn ghe-b" disabled title="Download the index as an HTML file">💾 HTML</button> <button id="ghe-iss-dl-m" class="ghe-btn ghe-p" disabled title="Download the index as a Markdown file">💾 MD</button> </div> <div id="ghe-batch-wrap" style="margin-top:8px;padding-top:8px;border-top:1px solid #30363d"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px"> <span class="ghe-batch-title" style="font-size:12px;font-weight:600">📥 Batch Full Export <span class="ghe-info">?<span class="ghe-tip">Export the FULL content of selected issues, PRs, and discussions as Markdown.<br>Unlike the index above (titles only), this downloads everything:<br>• Issue/PR body and all comments<br>• PR reviews, review comments, commits, changed files<br>• Discussion replies, polls, answer highlighting<br>• Metadata, reactions, references, timeline<br><br>⚠️ Uses 5–15 API calls per item. Use a token for higher rate limits.</span></span></span> <span id="ghe-batch-count" style="font-size:11px;color:#8b949e"></span> <span id="ghe-batch-status" style="font-size:11px"></span> </div> <div id="ghe-batch-token-warn" style="display:none;font-size:11px;color:#f85149;margin-bottom:6px">⚠️ No token set. Batch export uses 5–15 API calls per item. You will likely hit the 60 req/hr unauthenticated limit. <a href="https://github.com/settings/tokens" target="_blank" style="color:#58a6ff">Add a token</a> for 5,000 req/hr.</div> <details style="margin-bottom:6px"> <summary style="cursor:pointer;font-size:12px;color:#8b949e;user-select:none" title="Configure which sections to include in each exported item (same settings as single-page export)">⚙️ Export Settings (applies to batch)</summary> <div id="ghe-batch-settings" style="margin-top:6px"></div> </details> <div id="ghe-batch-picker" style="display:none"> <div class="ghe-batch-controls"> <button id="ghe-batch-sel-all" title="Select all visible items">All</button> <button id="ghe-batch-sel-none" title="Deselect all">None</button> <button id="ghe-batch-sel-invert" title="Invert selection">Invert</button> <span style="width:1px;height:14px;background:#30363d;flex-shrink:0"></span> <button id="ghe-batch-sel-issues" title="Select only issues">Issues</button> <button id="ghe-batch-sel-prs" title="Select only PRs">PRs</button> <button id="ghe-batch-sel-disc" title="Select only discussions">Disc</button> <span style="width:1px;height:14px;background:#30363d;flex-shrink:0"></span> <button id="ghe-batch-st-all" class="active" title="Select all items">All</button> <button id="ghe-batch-st-open" title="Select only open items">Open</button> <button id="ghe-batch-st-closed" title="Select only closed items">Closed</button> <button id="ghe-batch-st-merged" title="Select only merged PRs">Merged</button> <button id="ghe-batch-st-answered" title="Select only answered discussions">Answered</button> </div> <div style="margin-bottom:3px"><input type="text" id="ghe-batch-search" class="ghe-batch-filter" style="width:100%" placeholder="Filter by title or #number…"></div> <div id="ghe-batch-list" class="ghe-batch-list"><table class="ghe-batch-table"><tbody id="ghe-batch-tbody"></tbody></table></div> <div style="font-size:11px;color:#8b949e;margin-top:2px"><span id="ghe-batch-sel-count">0</span> of <span id="ghe-batch-total-count">0</span> selected</div> </div> <div id="ghe-batch-st-el" class="ghe-st">Ready.</div> <div id="ghe-batch-progress" style="display:none;margin-top:4px"> <div style="display:flex;align-items:center;gap:6px"> <div style="flex:1;background:#21262d;border-radius:4px;height:6px;overflow:hidden"> <div id="ghe-batch-bar" style="background:#238636;height:100%;width:0%;transition:width 0.3s"></div> </div> <span id="ghe-batch-rl-mini" class="ghe-rl-mini"><span class="ghe-rl-dot"></span><span></span></span> </div> <div id="ghe-batch-detail" class="ghe-st" style="margin-top:2px"></div> </div> <div id="ghe-batch-dbg-st" class="ghe-st" style="display:flex;justify-content:flex-end;margin-top:2px"><label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap" title="Show debug log for batch export"><input type="checkbox" id="ghe-batch-dbg-chk" ${Prefs.get("batchDebug", false) ? "checked" : ""} style="margin:0;cursor:pointer"> Log</label></div> <div id="ghe-batch-dbg-wrap" style="display:none;margin-top:4px"> <div class="ghe-log-wrap"><textarea id="ghe-batch-dbg-log" readonly style="width:100%;height:80px;background:#161b22;color:#8b949e;border:1px solid #30363d;border-radius:4px;font-family:monospace;font-size:10px;padding:4px;resize:vertical;box-sizing:border-box"></textarea><div class="ghe-log-actions"><button class="ghe-log-act" data-log-action="clear" data-log-target="ghe-batch-dbg-log" data-log-module="batch" title="Clear log">🗑</button><button class="ghe-log-act" data-log-action="copy" data-log-target="ghe-batch-dbg-log" title="Copy log">📋</button></div></div> </div> <div class="ghe-row" style="margin-top:6px"> <button id="ghe-batch-go" class="ghe-btn ghe-g" title="Export full content for all selected items">📥 Export Selected</button> <button id="ghe-batch-cancel" class="ghe-btn ghe-r" style="display:none" title="Pause the batch export — you can resume later">⏸ Pause</button> <button id="ghe-batch-reset" class="ghe-btn ghe-gr" style="display:none" title="Discard all exported content and start fresh">✕ Reset</button> </div> <div class="ghe-row" style="margin-top:4px"> <button id="ghe-batch-dl" class="ghe-btn ghe-p" disabled title="Download all exported items as one combined Markdown file">💾 Single MD</button> <button id="ghe-batch-dl-zip" class="ghe-btn ghe-b" disabled title="Download as ZIP with one Markdown file per item, plus a combined file and index">📦 ZIP</button> </div> </div> </div> `, ); // Resize sash const sash = document.createElement("div"); sash.id = "ghe-sash"; sash.title = "Drag to resize"; panel.appendChild(sash); root.appendChild(toggle); root.appendChild(panel); document.body.appendChild(root); // ── Sash resize logic ── let sashDragging = false; let sashStartX = 0; let sashStartW = 0; sash.addEventListener("mousedown", (e) => { e.preventDefault(); sashDragging = true; sashStartX = e.clientX; sashStartW = panel.offsetWidth; sash.classList.add("active"); document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; }); document.addEventListener("mousemove", (e) => { if (!sashDragging) return; const delta = sashStartX - e.clientX; const newW = Math.max( 400, Math.min(window.innerWidth * 0.9, sashStartW + delta), ); panel.style.width = newW + "px"; }); document.addEventListener("mouseup", () => { if (!sashDragging) return; sashDragging = false; sash.classList.remove("active"); document.body.style.cursor = ""; document.body.style.userSelect = ""; // Persist width Prefs.set("panelWidth", panel.offsetWidth); }); // Toggle const flip = () => { const open = panel.style.display === "block"; panel.style.display = open ? "none" : "block"; toggle.style.display = open ? "block" : "none"; if (!open) { const rr = root.getBoundingClientRect(); panel.style.maxHeight = window.innerHeight - (window.innerHeight - rr.bottom) - 20 + "px"; // Restore persisted width or auto-size const savedW = Prefs.get("panelWidth", null); if (savedW && savedW >= 400) { panel.style.width = Math.min(savedW, window.innerWidth * 0.9) + "px"; } else { requestAnimationFrame(() => { panel.style.width = panel.offsetWidth + "px"; }); } } else { panel.style.width = ""; } }; toggle.onclick = flip; panel.querySelector("#ghe-x").onclick = flip; // Theme const themeBtn = panel.querySelector("#ghe-theme"); const applyTheme = (light) => { panel.classList.toggle("ghe-light", light); themeBtn.textContent = light ? "☀️" : "🌙"; }; applyTheme(Prefs.get("lightMode", false)); themeBtn.onclick = () => { const n = !Prefs.get("lightMode", false); Prefs.set("lightMode", n); applyTheme(n); }; panel .querySelector("#ghe-kofi") .addEventListener("click", (e) => e.stopPropagation()); // Help button panel.querySelector("#ghe-help").onclick = () => openHelpWindow(); wireToken(panel); wireRateLimit(panel); if (isRelease) wireSingleRelease(panel, pageInfo); wireReleases(panel); wireIndex(panel); if (isThread) wireThreadExport(panel, pageInfo); // Wire all log action buttons after all sections are wired (clear handlers registered) wireLogActions(panel); // Reposition tooltips that would clip outside the panel wireTooltipPositioning(panel); } /** Map of module name → clear callback, populated by each section's wiring */ const logClearHandlers = {}; /** * Wire all log action buttons (copy + clear) in one pass. * Each button has data-log-action="copy"|"clear" and data-log-target="textareaId". * Clear buttons also have data-log-module="releases"|"index"|"batch". */ function wireLogActions(panel) { panel.querySelectorAll(".ghe-log-act").forEach((btn) => { btn.addEventListener("click", () => { const targetId = btn.dataset.logTarget; const textarea = panel.querySelector(`#${targetId}`); const action = btn.dataset.logAction; if (action === "copy") { if (!textarea?.value) return; navigator.clipboard .writeText(textarea.value) .then(() => { const prev = btn.textContent; btn.textContent = "✅"; setTimeout(() => { btn.textContent = prev; }, 1500); }) .catch(() => { showToast("Copy failed", "error"); }); } else if (action === "clear") { const mod = btn.dataset.logModule; if (logClearHandlers[mod]) logClearHandlers[mod](); if (textarea) textarea.value = ""; } }); }); } // ── Unified Rate Limit Display ── function wireRateLimit(panel) { const dot = panel.querySelector("#ghe-rl-dot"); const text = panel.querySelector("#ghe-rl-text"); if (!dot || !text) return; let lastCoreR = null, lastGqlR = null; function updateDisplay() { const c = API.rateLimit.core; const g = API.rateLimit.graphql; if (c.remaining === lastCoreR && g.remaining === lastGqlR) return; lastCoreR = c.remaining; lastGqlR = g.remaining; if (c.remaining === null && g.remaining === null) { dot.className = "ghe-rl-dot"; text.textContent = "API quota: no requests yet"; text.style.color = ""; return; } const parts = []; if (c.remaining !== null) parts.push(`REST: ${c.remaining}/hr`); if (g.remaining !== null) parts.push(`GQL: ${g.remaining}/hr`); const lowest = API.rateLimitSummary; const resetStr = lowest.reset ? Utils.formatReset(lowest.reset) : "?"; if (lowest.remaining <= 0) { dot.className = "ghe-rl-dot out"; text.textContent = `⚠ ${parts.join(" · ")} · resets ${resetStr}`; text.style.color = "#f85149"; } else if (lowest.remaining <= 100) { dot.className = "ghe-rl-dot low"; text.textContent = `${parts.join(" · ")} · resets ${resetStr}`; text.style.color = "#d29922"; } else { dot.className = "ghe-rl-dot ok"; text.textContent = `${parts.join(" · ")}`; text.style.color = ""; } } setInterval(updateDisplay, 1000); updateDisplay(); } // ── Single Release ── function wireSingleRelease(panel, pageInfo) { const sec = panel.querySelector("#ghe-single-rel-sec"); if (!sec) return; const stEl = sec.querySelector("#ghe-single-rel-st"); const btnHtml = sec.querySelector("#ghe-single-rel-html"); const btnSaveH = sec.querySelector("#ghe-single-rel-save-h"); const btnSaveM = sec.querySelector("#ghe-single-rel-save-m"); let cached = null; // { release, html, md } async function ensureFetched() { if (cached) return cached; stEl.textContent = "Fetching release…"; // biome-ignore lint/suspicious/useIterableCallbackReturn: assignment return unused [btnHtml, btnSaveH, btnSaveM].forEach((b) => (b.disabled = true)); try { const { data } = await API.request( `/repos/${pageInfo.owner}/${pageInfo.repo}/releases/tags/${encodeURIComponent(pageInfo.tag)}`, ); const result = ReleasesModule.buildFrom([data], pageInfo.fullName); cached = { release: data, html: result.html, md: result.md }; stEl.textContent = `✅ ${data.name || data.tag_name || pageInfo.tag}`; // biome-ignore lint/suspicious/useIterableCallbackReturn: assignment return unused [btnHtml, btnSaveH, btnSaveM].forEach((b) => (b.disabled = false)); return cached; } catch (e) { stEl.textContent = `❌ ${e.message}`; // biome-ignore lint/suspicious/useIterableCallbackReturn: assignment return unused [btnHtml, btnSaveH, btnSaveM].forEach((b) => (b.disabled = false)); return null; } } const slug = () => Utils.sanitizeFilename(cached?.release?.name || pageInfo.tag); btnHtml.onclick = async () => { const c = await ensureFetched(); if (c) Utils.openBlob(c.html, "text/html"); }; btnSaveH.onclick = async () => { const c = await ensureFetched(); if (c) Utils.downloadBlob(c.html, `${slug()}-release.html`, "text/html"); }; btnSaveM.onclick = async () => { const c = await ensureFetched(); if (c) Utils.downloadBlob(c.md, `${slug()}-release.md`, "text/markdown"); }; } // ── Token ── function wireToken(panel) { const inp = panel.querySelector("#ghe-token-inp"); const badge = panel.querySelector("#ghe-token-badge"); const st = panel.querySelector("#ghe-token-st"); const updateBadge = () => { badge.textContent = Token.has() ? "✅" : "(not set)"; badge.style.color = Token.has() ? "#3fb950" : "#f85149"; }; updateBadge(); panel.querySelector("#ghe-token-save").onclick = () => { const v = inp.value.trim(); if (!v || v === "••••••••") { st.textContent = "Enter a token first."; return; } Token.set(v); inp.value = "••••••••"; inp.type = "password"; st.textContent = "Token saved."; updateBadge(); setTimeout(() => { st.textContent = ""; }, 3000); }; panel.querySelector("#ghe-token-clear").onclick = () => { Token.clear(); inp.value = ""; st.textContent = "Token cleared."; updateBadge(); setTimeout(() => { st.textContent = ""; }, 3000); }; inp.onfocus = () => { if (inp.value === "••••••••") { inp.value = ""; inp.type = "text"; } }; inp.onblur = () => { if (!inp.value && Token.has()) { inp.value = "••••••••"; inp.type = "password"; } }; } // ── Releases ── function wireReleases(panel) { const sec = panel.querySelector("#ghe-rel-sec"); const btnGo = sec.querySelector("#ghe-rel-go"); const btnCancel = sec.querySelector("#ghe-rel-cancel"); const btnReset = sec.querySelector("#ghe-rel-reset"); const btnOpen = sec.querySelector("#ghe-rel-open"); const btnSaveH = sec.querySelector("#ghe-rel-save-h"); const btnSaveM = sec.querySelector("#ghe-rel-save-m"); const stTxt = sec.querySelector("#ghe-rel-st-txt"); const dbgChk = sec.querySelector("#ghe-rel-dbg"); const dbgWrap = sec.querySelector("#ghe-rel-dbg-wrap"); const dbgLog = sec.querySelector("#ghe-rel-dbg-log"); const dlBtns = [btnOpen, btnSaveH, btnSaveM]; dbgChk.onchange = function () { Prefs.set("relDebug", this.checked); ReleasesModule.setDebug(this.checked); dbgWrap.style.display = this.checked ? "block" : "none"; }; ReleasesModule.setDebug(dbgChk.checked); dbgWrap.style.display = dbgChk.checked ? "block" : "none"; logClearHandlers.releases = () => ReleasesModule.clearDebugLog(); // Release filter input const relFilterInp = sec.querySelector("#ghe-rel-filter"); relFilterInp.addEventListener("input", () => { ReleasesModule.setReleaseFilter(relFilterInp.value.trim()); }); const refreshDbg = () => { if (dbgChk.checked) { dbgLog.value = ReleasesModule.getDebugLog(); dbgLog.scrollTop = dbgLog.scrollHeight; } }; // biome-ignore lint/suspicious/useIterableCallbackReturn: assignment return unused const enableDl = (on) => dlBtns.forEach((b) => (b.disabled = !on)); const updateUI = () => { const s = ReleasesModule.state(); if (s.running) { btnGo.style.display = "none"; btnCancel.style.display = "inline-block"; btnReset.style.display = "none"; } else if (s.paused) { btnGo.style.display = "inline-block"; btnGo.textContent = `▶ Resume (${s.count} fetched)`; btnGo.classList.remove("ghe-g"); btnGo.classList.add("ghe-b"); btnGo.disabled = false; btnCancel.style.display = "none"; btnReset.style.display = "inline-block"; } else if (s.finished) { btnGo.style.display = "inline-block"; btnGo.textContent = "↺ New Export"; btnGo.classList.remove("ghe-b"); btnGo.classList.add("ghe-g"); btnGo.disabled = false; btnCancel.style.display = "none"; btnReset.style.display = "none"; } else { btnGo.style.display = "inline-block"; btnGo.textContent = "Start"; btnGo.classList.remove("ghe-b"); btnGo.classList.add("ghe-g"); btnGo.disabled = false; btnCancel.style.display = "none"; btnReset.style.display = "none"; } enableDl(s.hasData); refreshDbg(); }; btnCancel.onclick = () => { ReleasesModule.cancel(); updateUI(); }; btnReset.onclick = () => { ReleasesModule.reset(); ReleasesModule.setReleaseFilter(relFilterInp.value.trim()); stTxt.textContent = "No data yet."; enableDl(false); updateUI(); }; btnGo.onclick = async () => { const s = ReleasesModule.state(); // If finished a full run, reset for new export if (s.finished && !ReleasesModule.isPaused()) { ReleasesModule.reset(); } ReleasesModule.setReleaseFilter(relFilterInp.value.trim()); enableDl(false); updateUI(); const ok = await ReleasesModule.fetchAll((msg) => { stTxt.textContent = msg; updateUI(); }); if (ok) { stTxt.textContent = `Done · ${ReleasesModule.state().count} releases`; enableDl(true); } updateUI(); }; const repo = Utils.repoFullName() || ""; btnOpen.onclick = () => { if (ReleasesModule.hasData()) Utils.openBlob(ReleasesModule.getHtml(), "text/html"); }; btnSaveH.onclick = () => { if (ReleasesModule.hasData()) Utils.downloadBlob( ReleasesModule.getHtml(), `${Utils.sanitizeFilename(repo)}-releases.html`, "text/html", ); }; btnSaveM.onclick = () => { if (ReleasesModule.hasData()) Utils.downloadBlob( ReleasesModule.getMd(), `${Utils.sanitizeFilename(repo)}-releases.md`, "text/markdown", ); }; const btnSaveZip = sec.querySelector("#ghe-rel-save-zip"); dlBtns.push(btnSaveZip); btnSaveZip.onclick = async () => { if (!ReleasesModule.hasData()) return; if (typeof JSZip === "undefined") { showToast("JSZip not loaded", "error"); return; } btnSaveZip.disabled = true; btnSaveZip.textContent = "⏳…"; try { const zip = new JSZip(); const repoSlug = Utils.sanitizeFilename(repo); const folder = zip.folder(repoSlug + "-releases"); // Build per-release individual .md files const items = ReleasesModule._getItems ? ReleasesModule._getItems() : []; if (items.length > 0) { for (const rel of items) { const tag = rel.tag_name || "untagged"; const result = ReleasesModule.buildFrom([rel], repo); const safeTag = Utils.sanitizeFilename(tag, 60); folder.file(`${safeTag}.md`, result.md); } } // Also include combined files folder.file("_all-releases.md", ReleasesModule.getMd()); folder.file("_all-releases.html", ReleasesModule.getHtml()); const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 }, }); const url = URL.createObjectURL(blob); const a = Object.assign(document.createElement("a"), { href: url, download: `${repoSlug}-releases.zip`, }); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast(`ZIP: ${items.length} release files`, "success"); } catch (err) { showToast(`ZIP failed: ${err.message}`, "error"); } finally { btnSaveZip.disabled = !ReleasesModule.hasData(); btnSaveZip.textContent = "📦 ZIP"; } }; updateUI(); } // ── Index ── function wireIndex(panel) { const sec = panel.querySelector("#ghe-iss-sec"); const chkIss = sec.querySelector("#ghe-chk-iss"); const chkPr = sec.querySelector("#ghe-chk-pr"); const chkDsc = sec.querySelector("#ghe-chk-dsc"); const chkDbg = sec.querySelector("#ghe-chk-dbg"); const dbgWrap = sec.querySelector("#ghe-dbg-wrap"); const dbgLog = sec.querySelector("#ghe-dbg-log"); const goBtn = sec.querySelector("#ghe-iss-go"); const cancelBtn = sec.querySelector("#ghe-iss-cancel"); const resetBtn = sec.querySelector("#ghe-iss-reset"); const rangeInput = sec.querySelector("#ghe-iss-from"); const btnOpen = sec.querySelector("#ghe-iss-open"); const btnDlH = sec.querySelector("#ghe-iss-dl-h"); const btnDlM = sec.querySelector("#ghe-iss-dl-m"); const updateGoEnabled = () => { const any = chkIss.checked || chkPr.checked || chkDsc.checked; goBtn.disabled = !any && !RepoIndexModule.state().running; }; chkIss.onchange = function () { Prefs.set("issues", this.checked); updateGoEnabled(); }; chkPr.onchange = function () { Prefs.set("prs", this.checked); updateGoEnabled(); }; chkDsc.onchange = function () { Prefs.set("discussions", this.checked); updateGoEnabled(); }; chkDbg.onchange = function () { Prefs.set("debug", this.checked); RepoIndexModule.setDebug(this.checked); dbgWrap.style.display = this.checked ? "block" : "none"; }; RepoIndexModule.setDebug(chkDbg.checked); dbgWrap.style.display = chkDbg.checked ? "block" : "none"; logClearHandlers.index = () => RepoIndexModule.clearDebugLog(); rangeInput.addEventListener("input", () => RepoIndexModule.setRange(rangeInput.value.trim()), ); const refreshDbg = () => { if (chkDbg.checked) { dbgLog.value = RepoIndexModule.getDebugLog(); dbgLog.scrollTop = dbgLog.scrollHeight; } }; const updateDlBtns = () => { const h = RepoIndexModule.state().hasData; btnOpen.disabled = !h; btnDlH.disabled = !h; btnDlM.disabled = !h; }; const updateUI = () => { const s = RepoIndexModule.state(); const pr = sec.querySelector("#ghe-iss-pr-txt"); if (s.count > 0) { const range = s.lowest === s.highest ? `#${s.lowest}` : `#${s.lowest} – #${s.highest}`; pr.textContent = s.running ? `Fetching… ${s.count} items (${range})` : s.paused ? `Paused · ${s.count} items (${range})` : `Done · ${s.count} items (${range})`; } else { pr.textContent = s.running ? "Fetching…" : "No data yet"; } if (s.running) { goBtn.style.display = "none"; cancelBtn.style.display = "inline-block"; resetBtn.style.display = "none"; } else if (s.paused) { goBtn.style.display = "inline-block"; goBtn.textContent = `▶ Resume (${s.count} items)`; goBtn.classList.remove("ghe-g"); goBtn.classList.add("ghe-b"); goBtn.disabled = false; cancelBtn.style.display = "none"; resetBtn.style.display = "inline-block"; } else if (s.finished) { goBtn.style.display = "inline-block"; goBtn.textContent = "↺ New Export"; goBtn.classList.remove("ghe-b"); goBtn.classList.add("ghe-g"); cancelBtn.style.display = "none"; resetBtn.style.display = "none"; updateGoEnabled(); } else { goBtn.style.display = "inline-block"; goBtn.textContent = "Start"; goBtn.classList.remove("ghe-b"); goBtn.classList.add("ghe-g"); cancelBtn.style.display = "none"; resetBtn.style.display = "none"; updateGoEnabled(); } refreshDbg(); updateDlBtns(); }; cancelBtn.onclick = () => { RepoIndexModule.cancel(); updateUI(); }; resetBtn.onclick = () => { RepoIndexModule.reset(); RepoIndexModule.setRange(rangeInput.value.trim()); sec.querySelector("#ghe-iss-pr-txt").textContent = "No data yet."; updateDlBtns(); updateUI(); }; goBtn.onclick = () => { const s = RepoIndexModule.state(); if (s.finished && !RepoIndexModule.isPaused()) RepoIndexModule.reset(); RepoIndexModule.setRange(rangeInput.value.trim()); RepoIndexModule.run(updateUI, { issues: chkIss.checked, prs: chkPr.checked, discussions: chkDsc.checked, }); }; const repo = Utils.repoFullName() || ""; btnOpen.onclick = () => { if (RepoIndexModule.state().hasData) Utils.openBlob(RepoIndexModule.getHtml(), "text/html"); }; btnDlH.onclick = () => { if (RepoIndexModule.state().hasData) Utils.downloadBlob( RepoIndexModule.getHtml(), `${Utils.sanitizeFilename(repo)}-${RepoIndexModule.fileSlug()}.html`, "text/html", ); }; btnDlM.onclick = () => { if (RepoIndexModule.state().hasData) Utils.downloadBlob( RepoIndexModule.getMd(), `${Utils.sanitizeFilename(repo)}-${RepoIndexModule.fileSlug()}.md`, "text/markdown", ); }; updateGoEnabled(); updateUI(); // ── Batch Full-Content Export ── wireBatchExport(sec); } // ── Batch Export ── function wireBatchExport(sec) { const wrap = sec.querySelector("#ghe-batch-wrap"); const countEl = sec.querySelector("#ghe-batch-count"); const tokenWarn = sec.querySelector("#ghe-batch-token-warn"); const stEl = sec.querySelector("#ghe-batch-st-el"); const progressWrap = sec.querySelector("#ghe-batch-progress"); const bar = sec.querySelector("#ghe-batch-bar"); const detailEl = sec.querySelector("#ghe-batch-detail"); const goBtn = sec.querySelector("#ghe-batch-go"); const cancelBtn = sec.querySelector("#ghe-batch-cancel"); const dlBtn = sec.querySelector("#ghe-batch-dl"); // Picker elements const pickerWrap = sec.querySelector("#ghe-batch-picker"); const listEl = sec.querySelector("#ghe-batch-list"); const tbodyEl = sec.querySelector("#ghe-batch-tbody"); const searchInp = sec.querySelector("#ghe-batch-search"); const selCountEl = sec.querySelector("#ghe-batch-sel-count"); const totalCountEl = sec.querySelector("#ghe-batch-total-count"); const dlZipBtn = sec.querySelector("#ghe-batch-dl-zip"); const resetBatchBtn = sec.querySelector("#ghe-batch-reset"); // Inline rate limit mini display const rlMiniEl = sec.querySelector("#ghe-batch-rl-mini"); // Debug log for batch const batchDbgChk = sec.querySelector("#ghe-batch-dbg-chk"); const batchDbgWrap = sec.querySelector("#ghe-batch-dbg-wrap"); const batchDbgLog = sec.querySelector("#ghe-batch-dbg-log"); const batchLogger = createDebugLogger("[Batch]"); batchDbgChk.addEventListener("change", () => { Prefs.set("batchDebug", batchDbgChk.checked); batchLogger.setEnabled(batchDbgChk.checked); batchDbgWrap.style.display = batchDbgChk.checked ? "block" : "none"; }); // Restore persisted state on create batchLogger.setEnabled(batchDbgChk.checked); batchDbgWrap.style.display = batchDbgChk.checked ? "block" : "none"; logClearHandlers.batch = () => batchLogger.clear(); function refreshBatchDbg() { if (batchDbgChk.checked) { batchDbgLog.value = batchLogger.getText(); batchDbgLog.scrollTop = batchDbgLog.scrollHeight; } } // Inline rate limit updater function updateBatchRL() { if (!rlMiniEl) return; const s = API.rateLimitSummary; const dot = rlMiniEl.querySelector(".ghe-rl-dot"); const txt = rlMiniEl.querySelector("span:last-child"); if (s.remaining === null) { txt.textContent = ""; dot.className = "ghe-rl-dot"; return; } const c = API.rateLimit.core; const g = API.rateLimit.graphql; const parts = []; if (c.remaining !== null) parts.push(`REST: ${c.remaining}`); if (g.remaining !== null) parts.push(`GraphQL: ${g.remaining}`); txt.textContent = parts.join(" "); if (s.remaining <= 0) dot.className = "ghe-rl-dot out"; else if (s.remaining <= 100) dot.className = "ghe-rl-dot low"; else dot.className = "ghe-rl-dot ok"; } let batchAbort = null; let batchRunning = false; let batchParts = []; let batchCompleted = 0; let batchFailed = 0; let batchItems = []; let batchRateLimited = false; let batchGeneration = -1; let batchSelected = new Set(); /** Parallel array to batchParts: metadata for each exported item */ let batchMeta = []; // ── Picker helpers ── function getItemType(item) { if (item.discussion) return "discussion"; if (item.pull_request) return "pr"; return "issue"; } function getItemState(item) { const type = getItemType(item); if (type === "discussion") { if (item.isAnswered) return { icon: "✅", label: "answered", filter: "answered" }; if (item.state === "closed" || item.state === "CLOSED") return { icon: "🔴", label: "closed", filter: "closed" }; return { icon: "🟢", label: "open", filter: "open" }; } if (item.state === "open") return { icon: "🟢", label: "open", filter: "open" }; if (type === "pr" && item.pull_request?.merged_at) return { icon: "🟣", label: "merged", filter: "merged" }; if (item.state_reason === "not_planned") return { icon: "⚫", label: "not planned", filter: "closed" }; return { icon: "🔴", label: "closed", filter: "closed" }; } function buildPicker(items) { if (!items || items.length === 0) { pickerWrap.style.display = "none"; return; } pickerWrap.style.display = "block"; totalCountEl.textContent = String(items.length); batchSelected = new Set(items.map((i) => i.number)); renderPickerList(items); updateSelCount(); } function renderPickerList(items, filterText) { const filter = (filterText || "").toLowerCase().trim(); let html = ""; const sorted = [...items].sort((a, b) => a.number - b.number); for (const item of sorted) { const type = getItemType(item); const st = getItemState(item); const title = item.title || "(no title)"; const searchText = `#${item.number} ${title} ${st.label}`.toLowerCase(); if (filter && !searchText.includes(filter)) continue; const checked = batchSelected.has(item.number) ? " checked" : ""; const badgeLabel = type === "pr" ? "PR" : type === "discussion" ? "DSC" : "ISS"; html += `<tr data-num="${item.number}" data-type="${type}" data-state="${st.filter}">` + `<td><input type="checkbox"${checked} data-batch-num="${item.number}"></td>` + `<td><span class="badge ${type}">${badgeLabel}</span></td>` + `<td title="${st.label}">${st.icon}</td>` + `<td title="${Utils.escapeHtml(title)}">#${item.number} ${Utils.escapeHtml(title)}</td>` + `</tr>`; } if (!html) html = '<tr><td colspan="4" style="padding:8px;color:#8b949e;text-align:center;font-size:11px">No items match filter</td></tr>'; safeSetInnerHTML(tbodyEl, html); // Wire checkbox changes tbodyEl.querySelectorAll("input[data-batch-num]").forEach((chk) => { chk.addEventListener("change", () => { const num = parseInt(chk.dataset.batchNum, 10); if (chk.checked) batchSelected.add(num); else batchSelected.delete(num); updateSelCount(); updateStateButtonHighlights(); }); }); // Click row to toggle checkbox tbodyEl.querySelectorAll("tr[data-num]").forEach((row) => { row.addEventListener("click", (e) => { if (e.target.tagName === "INPUT") return; const chk = row.querySelector("input[data-batch-num]"); if (chk) { chk.checked = !chk.checked; chk.dispatchEvent(new Event("change")); } }); }); } /** Update state filter button highlights based on actual selection */ function updateStateButtonHighlights() { const items = RepoIndexModule._getItems ? RepoIndexModule._getItems() : []; if (items.length === 0 || batchSelected.size === 0) { for (const btn of Object.values(stFilterBtns)) { if (btn) btn.classList.remove("active"); } return; } // Check if selection matches a state category const selectedStates = new Set(); const selectedTypes = new Set(); for (const item of items) { if (batchSelected.has(item.number)) { selectedStates.add(getItemState(item).filter); selectedTypes.add(getItemType(item)); } } // Count items per state const stateCounts = { open: 0, closed: 0, merged: 0, answered: 0 }; for (const item of items) { const sf = getItemState(item).filter; if (stateCounts[sf] !== undefined) stateCounts[sf]++; } const isAll = batchSelected.size === items.length; const isExactState = (state) => { if (selectedStates.size !== 1 || !selectedStates.has(state)) return false; return batchSelected.size === stateCounts[state]; }; stFilterBtns.all?.classList.toggle("active", isAll); stFilterBtns.open?.classList.toggle("active", isExactState("open")); stFilterBtns.closed?.classList.toggle("active", isExactState("closed")); stFilterBtns.merged?.classList.toggle("active", isExactState("merged")); stFilterBtns.answered?.classList.toggle( "active", isExactState("answered"), ); } function reRenderPicker() { const items = RepoIndexModule._getItems ? RepoIndexModule._getItems() : []; renderPickerList(items, searchInp.value); updateStateButtonHighlights(); } // State filter buttons — these SELECT items by state const stFilterBtns = { all: sec.querySelector("#ghe-batch-st-all"), open: sec.querySelector("#ghe-batch-st-open"), closed: sec.querySelector("#ghe-batch-st-closed"), merged: sec.querySelector("#ghe-batch-st-merged"), answered: sec.querySelector("#ghe-batch-st-answered"), }; function selectByState(val) { const items = RepoIndexModule._getItems ? RepoIndexModule._getItems() : []; if (val === "all") { batchSelected = new Set(items.map((i) => i.number)); } else { batchSelected.clear(); for (const item of items) { const st = getItemState(item); if (st.filter === val) batchSelected.add(item.number); } } tbodyEl.querySelectorAll("input[data-batch-num]").forEach((chk) => { chk.checked = batchSelected.has(parseInt(chk.dataset.batchNum, 10)); }); updateSelCount(); updateStateButtonHighlights(); } for (const [k, btn] of Object.entries(stFilterBtns)) { if (btn) btn.onclick = () => selectByState(k); } function updateSelCount() { selCountEl.textContent = String(batchSelected.size); if (!batchRunning && batchCompleted === 0) { goBtn.textContent = batchSelected.size > 0 ? `📥 Export ${batchSelected.size} Item${batchSelected.size === 1 ? "" : "s"}` : "📥 Export Selected"; goBtn.disabled = batchSelected.size === 0; } } function selectByType(type) { const items = RepoIndexModule._getItems ? RepoIndexModule._getItems() : []; batchSelected.clear(); for (const item of items) { if (getItemType(item) === type) batchSelected.add(item.number); } listEl.querySelectorAll("input[data-batch-num]").forEach((chk) => { chk.checked = batchSelected.has(parseInt(chk.dataset.batchNum, 10)); }); updateSelCount(); } // Wire picker controls sec.querySelector("#ghe-batch-sel-all").onclick = () => { const items = RepoIndexModule._getItems ? RepoIndexModule._getItems() : []; batchSelected = new Set(items.map((i) => i.number)); tbodyEl.querySelectorAll("input[data-batch-num]").forEach((chk) => { chk.checked = true; }); updateSelCount(); updateStateButtonHighlights(); }; sec.querySelector("#ghe-batch-sel-none").onclick = () => { batchSelected.clear(); tbodyEl.querySelectorAll("input[data-batch-num]").forEach((chk) => { chk.checked = false; }); updateSelCount(); updateStateButtonHighlights(); }; sec.querySelector("#ghe-batch-sel-invert").onclick = () => { tbodyEl.querySelectorAll("input[data-batch-num]").forEach((chk) => { const num = parseInt(chk.dataset.batchNum, 10); if (batchSelected.has(num)) batchSelected.delete(num); else batchSelected.add(num); chk.checked = batchSelected.has(num); }); updateSelCount(); updateStateButtonHighlights(); }; sec.querySelector("#ghe-batch-sel-issues").onclick = () => { selectByType("issue"); updateStateButtonHighlights(); }; sec.querySelector("#ghe-batch-sel-prs").onclick = () => { selectByType("pr"); updateStateButtonHighlights(); }; sec.querySelector("#ghe-batch-sel-disc").onclick = () => { selectByType("discussion"); updateStateButtonHighlights(); }; // ── Batch export settings (mirror of thread export settings) ── const batchSettingsEl = sec.querySelector("#ghe-batch-settings"); if (batchSettingsEl) { const grouped = {}; Object.entries(EXP_FLAGS).forEach(([k, m]) => { const g = m.g || "Other"; if (!grouped[g]) grouped[g] = []; grouped[g].push({ key: k, ...m }); }); let settingsHtml = ""; EXP_GROUP_ORDER.forEach((gn) => { const flags = grouped[gn]; if (!flags) return; settingsHtml += `<div style="margin-bottom:6px"><div style="font-size:10px;font-weight:600;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px;border-bottom:1px solid #21262d;padding-bottom:2px">${gn}</div><div class="ghe-exp-grid">`; flags.forEach((f) => { const cls = f.indent ? ' class="indent"' : ""; const checked = ThreadConfig.get(f.key) ? " checked" : ""; const tip = f.t ? ` title="${Utils.escapeHtml(f.t)}"` : ""; settingsHtml += `<label${cls}${tip}><input type="checkbox" data-bexp-key="${f.key}"${checked}> ${f.l}</label>`; }); settingsHtml += "</div></div>"; }); safeSetInnerHTML(batchSettingsEl, settingsHtml); // Wire batch settings checkboxes batchSettingsEl .querySelectorAll("input[data-bexp-key]") .forEach((chk) => { chk.addEventListener("change", () => { ThreadConfig.set(chk.dataset.bexpKey, chk.checked); updateBatchExpDeps(); }); }); function updateBatchExpDeps() { for (const [parentKey, childKeys] of Object.entries(EXP_DEPS)) { const parentChk = batchSettingsEl.querySelector( `input[data-bexp-key="${parentKey}"]`, ); if (!parentChk) continue; const parentOn = parentChk.checked; for (const childKey of childKeys) { const childChk = batchSettingsEl.querySelector( `input[data-bexp-key="${childKey}"]`, ); if (!childChk) continue; childChk.disabled = !parentOn; const label = childChk.closest("label"); if (label) label.classList.toggle("disabled", !parentOn); } } } updateBatchExpDeps(); } let searchTimeout = null; searchInp.addEventListener("input", () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => reRenderPicker(), 200); }); // ── Batch state management ── const statusEl = sec.querySelector("#ghe-batch-status"); function updateBatchVisibility() { const s = RepoIndexModule.state(); wrap.style.display = "block"; if (batchRunning) { // During batch export, don't change status text return; } if (batchCompleted > 0 && batchCompleted >= batchItems.length) { // Batch finished — show done state statusEl.innerHTML = ""; return; } if (batchCompleted > 0 && batchCompleted < batchItems.length) { // Batch paused mid-way statusEl.innerHTML = `<span style="color:#d29922">· paused at ${batchCompleted}/${batchItems.length}</span>`; return; } if (s.finished && s.count > 0) { countEl.textContent = `${s.count} items`; safeSetInnerHTML( statusEl, '<span style="color:#3fb950">· ready</span> — <span style="font-size:10px;color:#8b949e">select items below, then export</span>', ); tokenWarn.style.display = Token.has() ? "none" : "block"; const currentGen = RepoIndexModule._generation || 0; if (batchGeneration !== currentGen) { const items = RepoIndexModule._getItems ? RepoIndexModule._getItems() : []; buildPicker(items); batchGeneration = currentGen; } } else if (s.running) { countEl.textContent = ""; safeSetInnerHTML( statusEl, '<span style="color:#d29922">· indexing…</span>', ); } else { countEl.textContent = ""; safeSetInnerHTML( statusEl, '<span style="color:#8b949e">· <a href="#" id="ghe-batch-hint-link" style="color:#58a6ff;text-decoration:none;font-size:11px">run index above first ↑</a></span>', ); // Wire click to scroll to and flash the index Start button const hintLink = statusEl.querySelector("#ghe-batch-hint-link"); if (hintLink) { hintLink.onclick = (e) => { e.preventDefault(); const goBtn = sec.querySelector("#ghe-iss-go"); if (goBtn) { goBtn.scrollIntoView({ behavior: "smooth", block: "center" }); goBtn.style.transition = "box-shadow .3s"; goBtn.style.boxShadow = "0 0 0 3px rgba(88,166,255,.5)"; setTimeout(() => { goBtn.style.boxShadow = ""; }, 2000); } }; } } } function updateBatchButtons() { if (batchRunning) { goBtn.style.display = "none"; cancelBtn.style.display = "inline-block"; resetBatchBtn.style.display = "none"; dlBtn.disabled = true; dlZipBtn.disabled = true; } else { cancelBtn.style.display = "none"; goBtn.style.display = "inline-block"; if ( batchRateLimited || (batchCompleted > 0 && batchCompleted < batchItems.length) ) { goBtn.textContent = `▶ Resume (${batchCompleted}/${batchItems.length})`; goBtn.classList.remove("ghe-g"); goBtn.classList.add("ghe-b"); goBtn.disabled = false; resetBatchBtn.style.display = "inline-block"; } else if ( batchCompleted > 0 && batchCompleted >= batchItems.length ) { goBtn.textContent = "↺ New Export"; goBtn.classList.remove("ghe-b"); goBtn.classList.add("ghe-g"); goBtn.disabled = false; resetBatchBtn.style.display = "none"; } else { goBtn.classList.remove("ghe-b"); goBtn.classList.add("ghe-g"); resetBatchBtn.style.display = "none"; updateSelCount(); } dlBtn.disabled = batchParts.length === 0; dlZipBtn.disabled = batchMeta.length === 0; } refreshBatchDbg(); } function buildBatchResult() { if (batchParts.length === 0) return null; const pageInfo = Utils.parsePage(); const name = pageInfo ? pageInfo.fullName : "unknown"; const header = `# Full Export — ${name}\n\n` + `> Exported ${batchParts.length} items on ${new Date().toLocaleString()}\n\n---\n\n`; return header + batchParts.join("\n\n---\n\n---\n\n"); } function resetBatch() { batchParts = []; batchMeta = []; batchCompleted = 0; batchFailed = 0; batchItems = []; batchRateLimited = false; bar.style.width = "0%"; progressWrap.style.display = "none"; stEl.textContent = "Ready."; stEl.className = "ghe-st"; detailEl.textContent = ""; batchLogger.clear(); refreshBatchDbg(); } // Observe index state changes const indexSt = sec.querySelector("#ghe-iss-pr-txt"); if (indexSt) { const obs = new MutationObserver(updateBatchVisibility); obs.observe(indexSt, { childList: true, characterData: true, subtree: true, }); } setInterval(updateBatchVisibility, 2000); updateBatchVisibility(); updateBatchButtons(); // ── Main batch export handler ── goBtn.onclick = async () => { if (batchRunning) return; const pageInfo = Utils.parsePage(); if (!pageInfo) return; // If completed a full run, reset for new export if (batchCompleted > 0 && batchCompleted >= batchItems.length) { resetBatch(); const items = RepoIndexModule._getItems ? RepoIndexModule._getItems() : []; if (items.length > 0) buildPicker(items); updateBatchButtons(); return; } // Fresh start: build from selection if (batchCompleted === 0 || batchItems.length === 0) { const allItems = RepoIndexModule._getItems ? RepoIndexModule._getItems() : []; // Auto-index if no items yet if (allItems.length === 0) { stEl.textContent = "No index yet — fetching automatically…"; const chkIss = sec.querySelector("#ghe-chk-iss"); const chkPr = sec.querySelector("#ghe-chk-pr"); const chkDsc = sec.querySelector("#ghe-chk-dsc"); const opts = { issues: chkIss ? chkIss.checked : true, prs: chkPr ? chkPr.checked : true, discussions: chkDsc ? chkDsc.checked : true, }; await new Promise((resolve) => { RepoIndexModule.run(() => { const s = RepoIndexModule.state(); stEl.textContent = s.running ? `Indexing… ${s.count} items found` : `Indexed ${s.count} items`; if (!s.running) resolve(); }, opts); }); const newItems = RepoIndexModule._getItems ? RepoIndexModule._getItems() : []; if (newItems.length === 0) { stEl.textContent = "No items found in repository."; return; } buildPicker(newItems); batchGeneration = RepoIndexModule._generation || 0; updateBatchButtons(); stEl.textContent = `Found ${newItems.length} items. Select items above, then click Export.`; return; } const selectedItems = allItems .filter((i) => batchSelected.has(i.number)) .sort((a, b) => a.number - b.number); if (selectedItems.length === 0) { stEl.textContent = "No items selected. Check some items above."; return; } batchItems = selectedItems; batchGeneration = RepoIndexModule._generation || 0; } batchRunning = true; batchRateLimited = false; batchAbort = new AbortController(); progressWrap.style.display = "block"; updateBatchButtons(); // Start inline RL polling const rlPollId = setInterval(updateBatchRL, 1000); updateBatchRL(); const total = batchItems.length; const startFrom = batchCompleted; batchLogger.log( `=== Batch export start: ${total} items, resuming from ${startFrom} ===`, ); stEl.textContent = startFrom > 0 ? `Resuming from ${startFrom}/${total}…` : `Exporting 0/${total}…`; for (let i = startFrom; i < total; i++) { if (batchAbort.signal.aborted) break; const item = batchItems[i]; const num = item.number; const isPR = !!item.pull_request; const isDisc = !!item.discussion; const type = isDisc ? "discussion" : isPR ? "pr" : "issue"; const label = `#${num} (${type})`; detailEl.textContent = `Exporting ${label}…`; batchLogger.log(`→ Exporting ${label}`); const t0 = performance.now(); try { const result = await ThreadExportModule.exportThread( pageInfo.owner, pageInfo.repo, num, type, batchAbort.signal, ); const elapsed = ((performance.now() - t0) / 1000).toFixed(1); batchLogger.log(` ✓ ${label} — ${result.summary} (${elapsed}s)`); batchParts.push( `<!-- ═══ ${label}: ${(result.title || "").substring(0, 80)} ═══ -->\n\n${result.markdown}`, ); batchMeta.push({ number: num, type, title: result.title || "", markdown: result.markdown, failed: false, }); batchCompleted++; } catch (err) { if (err.message === "Cancelled") { batchLogger.log(` ⚠ Cancelled at ${label}`); break; } if (err.message.includes("Rate limit")) { batchRateLimited = true; batchLogger.log(` ⚠ Rate limited at ${label}`); stEl.textContent = `⚠️ Rate limited at ${label}. ${batchCompleted}/${total} done. Click Resume when limit resets.`; stEl.className = "ghe-st ghe-warn"; break; } const elapsed = ((performance.now() - t0) / 1000).toFixed(1); batchLogger.log(` ✗ ${label} — ${err.message} (${elapsed}s)`); warn(`Batch export failed for ${label}:`, err.message); batchParts.push( `<!-- ═══ ${label}: EXPORT FAILED — ${err.message} ═══ -->\n`, ); batchMeta.push({ number: num, type, title: "(export failed)", markdown: `# Export Failed\n\n${err.message}\n`, failed: true, }); batchFailed++; batchCompleted++; } const pct = Math.round((batchCompleted / total) * 100); bar.style.width = `${pct}%`; stEl.textContent = `Exporting ${batchCompleted}/${total}…${batchFailed ? ` (${batchFailed} failed)` : ""}`; updateBatchRL(); refreshBatchDbg(); if (!batchAbort.signal.aborted && batchCompleted < total) { await Utils.delay(200); } } clearInterval(rlPollId); batchRunning = false; batchAbort = null; if (!batchRateLimited) { if (batchCompleted >= total) { const msg = `✅ Done — ${batchCompleted} items exported${batchFailed ? ` (${batchFailed} failed)` : ""}.`; stEl.textContent = msg; stEl.className = "ghe-st"; detailEl.textContent = ""; batchLogger.log(`=== ${msg} ===`); } else { stEl.textContent = `Paused. ${batchCompleted}/${total} exported. Click Resume to continue.`; detailEl.textContent = ""; batchLogger.log(`=== Paused at ${batchCompleted}/${total} ===`); } } refreshBatchDbg(); updateBatchButtons(); }; cancelBtn.onclick = () => { if (batchAbort) batchAbort.abort(); }; resetBatchBtn.onclick = () => { if (batchAbort) batchAbort.abort(); resetBatch(); const items = RepoIndexModule._getItems ? RepoIndexModule._getItems() : []; if (items.length > 0) buildPicker(items); updateBatchButtons(); batchLogger.log("Batch reset by user"); refreshBatchDbg(); }; dlBtn.onclick = () => { log("Single MD button clicked, batchParts.length =", batchParts.length); batchLogger.log(`Single MD download: ${batchParts.length} parts`); const md = buildBatchResult(); if (!md) { warn("Single MD: buildBatchResult returned null"); return; } const pageInfo = Utils.parsePage(); const slug = pageInfo ? Utils.sanitizeFilename(pageInfo.fullName) : "export"; const suffix = batchCompleted < batchItems.length ? "-partial" : ""; Utils.downloadBlob( md, `${slug}-full-export${suffix}.md`, "text/markdown", ); batchLogger.log( `Single MD downloaded: ${Utils.formatBytes(md.length)} chars`, ); refreshBatchDbg(); }; dlZipBtn.onclick = async () => { log("ZIP button clicked, batchMeta.length =", batchMeta.length); batchLogger.log(`ZIP build requested: ${batchMeta.length} items`); if (batchMeta.length === 0) { warn("ZIP: no items in batchMeta"); showToast("No exported items to ZIP", "error"); return; } if (typeof JSZip === "undefined") { error("JSZip not loaded"); showToast("JSZip not loaded — cannot create ZIP", "error"); return; } dlZipBtn.disabled = true; dlZipBtn.textContent = "⏳…"; try { log("Creating JSZip instance…"); batchLogger.log("Creating ZIP…"); const zip = new JSZip(); const pi = Utils.parsePage(); const repoSlug = pi ? Utils.sanitizeFilename(pi.fullName) : "export"; const folder = zip.folder(repoSlug); log(`ZIP folder: ${repoSlug}, adding ${batchMeta.length} files…`); const indexLines = [ `# Export Index — ${pi ? pi.fullName : "unknown"}`, "", ]; indexLines.push( `> ${batchMeta.length} items exported on ${new Date().toLocaleString()}`, "", ); indexLines.push("| # | Type | Title | File |"); indexLines.push("|--:|:-----|:------|:-----|"); for (const meta of batchMeta) { const typeTag = meta.type === "pr" ? "PR" : meta.type === "discussion" ? "disc" : "issue"; const safeTitle = Utils.sanitizeFilename(meta.title, 60); const filename = `${String(meta.number).padStart(5, "0")}-${typeTag}-${safeTitle}.md`; folder.file(filename, meta.markdown); const status = meta.failed ? "❌" : "✅"; indexLines.push( `| ${status} ${meta.number} | ${typeTag} | ${meta.title.substring(0, 60)} | [${filename}](./${filename}) |`, ); } indexLines.push(""); folder.file("_index.md", indexLines.join("\n")); const singleMd = buildBatchResult(); if (singleMd) { folder.file("_all-combined.md", singleMd); } log("Generating ZIP blob…"); batchLogger.log("Compressing ZIP…"); let blob; try { const uint8 = await zip.generateAsync({ type: "uint8array", compression: "DEFLATE", compressionOptions: { level: 6 }, }); log(`ZIP uint8array ready: ${uint8.length} bytes`); blob = new Blob([uint8], { type: "application/zip" }); } catch (zipErr) { error("zip.generateAsync failed:", zipErr); batchLogger.log(`zip.generateAsync error: ${zipErr.message}`); throw zipErr; } log(`ZIP blob created: ${blob.size} bytes`); batchLogger.log(`ZIP ready: ${Utils.formatBytes(blob.size)}`); const suffix = batchCompleted < batchItems.length ? "-partial" : ""; const url = URL.createObjectURL(blob); const a = Object.assign(document.createElement("a"), { href: url, download: `${repoSlug}-export${suffix}.zip`, }); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast(`ZIP downloaded: ${batchMeta.length} files`, "success"); batchLogger.log(`ZIP downloaded: ${batchMeta.length} files`); } catch (err) { error("ZIP creation failed:", err); batchLogger.log(`ZIP FAILED: ${err.message}`); showToast(`ZIP failed: ${err.message}`, "error"); } dlZipBtn.disabled = batchMeta.length === 0; dlZipBtn.textContent = "📦 ZIP"; refreshBatchDbg(); }; } // ── Thread Export ── function wireThreadExport(panel, pageInfo) { const sec = panel.querySelector("#ghe-exp-sec"); if (!sec) return; const goBtn = sec.querySelector("#ghe-exp-go"); const stEl = sec.querySelector("#ghe-exp-st"); const resetBtn = sec.querySelector("#ghe-exp-reset"); // ── Dependent state logic for export settings ── /** Update disabled/enabled state of all child checkboxes based on parent state */ function updateExpDeps() { for (const [parentKey, childKeys] of Object.entries(EXP_DEPS)) { const parentChk = sec.querySelector( `input[data-exp-key="${parentKey}"]`, ); if (!parentChk) continue; const parentOn = parentChk.checked; for (const childKey of childKeys) { const childChk = sec.querySelector( `input[data-exp-key="${childKey}"]`, ); if (!childChk) continue; childChk.disabled = !parentOn; const label = childChk.closest("label"); if (label) { label.classList.toggle("disabled", !parentOn); } } } } // Wire all export settings checkboxes sec.querySelectorAll("input[data-exp-key]").forEach((chk) => { chk.addEventListener("change", () => { ThreadConfig.set(chk.dataset.expKey, chk.checked); // If this is a parent, update its children if (EXP_DEPS[chk.dataset.expKey]) { updateExpDeps(); } }); }); // Apply initial dependent state updateExpDeps(); // Reset button if (resetBtn) { resetBtn.onclick = () => { ThreadConfig.reset(); sec.querySelectorAll("input[data-exp-key]").forEach((chk) => { chk.checked = ThreadConfig.get(chk.dataset.expKey); }); updateExpDeps(); showToast("Settings reset to defaults", "info"); }; } const previewBtn = sec.querySelector("#ghe-exp-preview"); let lastExportResult = null; // cache for preview reuse /** Shared export runner — returns result or null on error */ async function runExport(actionLabel) { if (threadExporting && threadExportAbort) { threadExportAbort.abort(); showToast("Export cancelled", "info"); return null; } threadExporting = true; threadExportAbort = new AbortController(); goBtn.textContent = "⏳ Exporting… (click to cancel)"; goBtn.classList.remove("ghe-g"); goBtn.classList.add("ghe-r"); previewBtn.disabled = true; stEl.textContent = "Fetching data…"; try { const result = await ThreadExportModule.exportThread( pageInfo.owner, pageInfo.repo, pageInfo.number, pageInfo.type, threadExportAbort.signal, (step) => { stEl.textContent = step; }, ); lastExportResult = result; stEl.textContent = `✅ ${actionLabel}: ${result.summary}`; return result; } catch (err) { if (err.message === "Cancelled") { stEl.textContent = "Cancelled."; } else { error("Export failed:", err); stEl.textContent = `❌ ${err.message}`; showToast(`Export failed: ${err.message}`, "error"); } return null; } finally { threadExporting = false; threadExportAbort = null; goBtn.textContent = "📥 Export to Markdown"; goBtn.classList.remove("ghe-r"); goBtn.classList.add("ghe-g"); previewBtn.disabled = false; } } // Export button — download goBtn.onclick = async () => { const result = await runExport("Exported"); if (result) { const filename = `${Utils.sanitizeFilename(result.title)}_${pageInfo.owner}_${pageInfo.repo}_${pageInfo.number}.md`; Utils.downloadBlob(result.markdown, filename, "text/markdown"); showToast(`Exported ${result.summary}`, "success"); } }; // Preview button — open in new tab as rendered HTML previewBtn.onclick = async () => { const result = lastExportResult || (await runExport("Preview ready")); if (!result) return; const escapedMd = Utils.escapeHtml(result.markdown); const filename = JSON.stringify( Utils.sanitizeFilename(result.title) + "_" + pageInfo.owner + "_" + pageInfo.repo + "_" + pageInfo.number + ".md", ); // Render Markdown to HTML using marked.js (already @required) let renderedHtml = ""; try { const renderer = typeof marked.parse === "function" ? marked : marked.marked; renderedHtml = renderer.parse(result.markdown, { breaks: true, gfm: true, }); // biome-ignore lint/correctness/noUnusedVariables: catch variable required by syntax } catch (e) {} // Extract TOC from headings (H1–H3) const tocEntries = []; const headingRegex = /^(#{1,3})\s+(.+)$/gm; let hMatch; // biome-ignore lint/suspicious/noAssignInExpressions: intentional assignment loop while ((hMatch = headingRegex.exec(result.markdown)) !== null) { const level = hMatch[1].length; const text = hMatch[2].replace(/[`*_~$$]/g, "").trim(); const slug = text .toLowerCase() .replace(/[^\w\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-"); tocEntries.push({ level, text, slug }); } let tocHtml = ""; if (tocEntries.length > 2) { tocHtml = '<nav class="toc"><details open><summary>📑 Table of Contents</summary><ul>' + tocEntries .map((e) => { const indent = (e.level - 1) * 16; return `<li style="padding-left:${indent}px"><a href="#${e.slug}">${Utils.escapeHtml(e.text)}</a></li>`; }) .join("") + "</ul></details></nav>"; } const previewHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"> <title>Preview: ${Utils.escapeHtml(result.title)}</title> <style> *{box-sizing:border-box} body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;margin:0;background:#0d1117;color:#c9d1d9;line-height:1.6} .toolbar{position:sticky;top:0;background:#161b22;border-bottom:1px solid #30363d;padding:8px 20px;display:flex;align-items:center;gap:12px;z-index:10;flex-wrap:wrap} .toolbar button{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;color:#fff} .btn-dl{background:#238636}.btn-dl:hover{background:#2ea043} .btn-copy{background:#1f6feb}.btn-copy:hover{background:#388bfd} .btn-raw{background:#6e7781}.btn-raw:hover{background:#848d97} .info{font-size:12px;color:#8b949e;margin-left:auto} .toc{margin:20px 20px 0;padding:12px 16px;background:#161b22;border:1px solid #30363d;border-radius:8px} .toc summary{cursor:pointer;font-weight:600;font-size:14px;color:#f0f6fc} .toc ul{list-style:none;padding:0;margin:8px 0 0} .toc li{padding:3px 0} .toc a{color:#58a6ff;text-decoration:none;font-size:13px} .toc a:hover{text-decoration:underline} .content{max-width:900px;margin:0 auto;padding:20px} .content h1,.content h2,.content h3,.content h4{color:#f0f6fc;border-bottom:1px solid #21262d;padding-bottom:6px;margin-top:24px} .content h1{font-size:1.8em;border-bottom:2px solid #30363d} .content h2{font-size:1.4em} .content h3{font-size:1.15em} .content a{color:#58a6ff} .content blockquote{border-left:3px solid #30363d;padding:4px 16px;margin:12px 0;color:#8b949e;background:#161b22;border-radius:0 6px 6px 0} .content code{background:#21262d;padding:2px 6px;border-radius:4px;font-size:0.9em;font-family:'SF Mono',Consolas,'Liberation Mono',monospace} .content pre{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;overflow-x:auto;line-height:1.45} .content pre code{background:none;padding:0;font-size:13px} .content table{border-collapse:collapse;width:100%;margin:12px 0} .content th,.content td{border:1px solid #30363d;padding:8px 12px;text-align:left} .content th{background:#161b22;font-weight:600} .content tr:hover{background:#161b2288} .content img{max-width:100%;border-radius:6px} .content hr{border:none;border-top:1px solid #30363d;margin:24px 0} .content ul,.content ol{padding-left:24px} .content li{margin:4px 0} .content details{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:8px 12px;margin:8px 0} .content summary{cursor:pointer;font-weight:600} .raw-view{display:none;margin:20px;padding:20px;background:#161b22;border:1px solid #30363d;border-radius:8px;font-family:'SF Mono',Consolas,monospace;font-size:13px;line-height:1.6;white-space:pre-wrap;word-wrap:break-word;overflow-x:auto;color:#c9d1d9} </style></head><body> <div class="toolbar"> <button class="btn-dl" onclick="downloadMd()">💾 Download .md</button> <button class="btn-copy" onclick="copyMd()">📋 Copy Markdown</button> <button class="btn-raw" id="toggle-raw" onclick="toggleRaw()">{ } Raw</button> <span class="info">${Utils.escapeHtml(result.summary)} · ${result.markdown.length.toLocaleString()} chars</span> </div> ${tocHtml} <div class="content" id="rendered-view">${renderedHtml}</div> <pre class="raw-view" id="raw-view">${escapedMd}</pre> <script> var rawMd=${JSON.stringify(result.markdown)}; var showRaw=false; function toggleRaw(){showRaw=!showRaw;document.getElementById('rendered-view').style.display=showRaw?'none':'block';document.getElementById('raw-view').style.display=showRaw?'block':'none';document.getElementById('toggle-raw').textContent=showRaw?'📄 Rendered':'{ } Raw'} function downloadMd(){var b=new Blob([rawMd],{type:'text/markdown;charset=utf-8'});var u=URL.createObjectURL(b);var a=document.createElement('a');a.href=u;a.download=${filename};document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(u)} function copyMd(){navigator.clipboard.writeText(rawMd).then(function(){var b=document.querySelector('.btn-copy');b.textContent='✅ Copied!';setTimeout(function(){b.textContent='📋 Copy Markdown'},2000)}).catch(function(){prompt('Copy failed. Markdown:',rawMd.substring(0,500))})} </script></body></html>`; Utils.openBlob(previewHtml, "text/html"); showToast("Preview opened in new tab", "info"); }; } /** * Dynamically adjust tooltip position so it doesn't clip outside * the panel edges — both horizontally and vertically. * Vertical: default "above"; flips to "below" if not enough space. * Horizontal: shifts left/right to stay within panel bounds. */ function wireTooltipPositioning(panel) { panel.querySelectorAll(".ghe-info").forEach((info) => { info.addEventListener("mouseenter", () => { const tip = info.querySelector(".ghe-tip"); if (!tip) return; // ── Reset to default centered-above position ── tip.classList.remove("above", "below"); tip.style.left = "50%"; tip.style.transform = "translateX(-50%)"; tip.style.right = "auto"; tip.style.removeProperty("--arrow-left"); // Start as "above" tip.classList.add("above"); // ── Vertical: flip to below if clipped at top ── const panelRect = panel.getBoundingClientRect(); const infoRect = info.getBoundingClientRect(); // Measure how much space is above the ? relative to the panel's visible top const spaceAbove = infoRect.top - panelRect.top; // Estimate tip height (force layout) const tipRect = tip.getBoundingClientRect(); const tipHeight = tipRect.height + 8; // 8px gap if (spaceAbove < tipHeight) { tip.classList.remove("above"); tip.classList.add("below"); } // ── Horizontal: shift if clipped left or right ── const tipRect2 = tip.getBoundingClientRect(); if (tipRect2.right > panelRect.right - 8) { tip.style.left = "auto"; tip.style.right = "0"; tip.style.transform = "none"; const arrowOffset = infoRect.left - tip.getBoundingClientRect().left + 7; tip.style.setProperty("--arrow-left", arrowOffset + "px"); } else if (tipRect2.left < panelRect.left + 8) { tip.style.left = "0"; tip.style.transform = "none"; const arrowOffset = infoRect.left - tip.getBoundingClientRect().left + 7; tip.style.setProperty("--arrow-left", arrowOffset + "px"); } }); }); } /** Generate and open the help/documentation window */ function openHelpWindow() { const helpMd = `# ${SCRIPT_NAME} — Help ## Overview All-in-one GitHub exporter. Download releases, generate issue/PR/discussion indexes, and export full threads as Markdown. --- ## Quick Start ### 1. Set up a Token *(recommended)* Click **🔑 GitHub Token** to expand the token section. A personal access token increases your rate limit from 60 to 5,000 requests/hour and enables Discussion exports. 1. Go to [GitHub Settings → Tokens](https://github.com/settings/tokens) 2. Create a token with the \`public_repo\` scope 3. Paste it in the token field and click Save ### 2. Export a Single Issue / PR / Discussion Navigate to any issue, PR, or discussion page. The panel shows **📥 Export This Page**: - **Export to Markdown** — downloads a \`.md\` file with full content - **Preview** — opens a rendered HTML preview in a new tab - **⚙️ Export Settings** — configure what to include (reviews, timeline, etc.) ### 3. Export Release Notes On any repository page, expand **📦 Release Notes & Changelogs**: 1. *(Optional)* Enter a filter: tag glob (\`v1.*\`), tag range (\`v1.0..v2.0\`), or date range (\`2024-01..2025-06\`) 2. Click **Start** 3. Download as HTML, Markdown, or ZIP (one file per release) ### 4. Generate an Index Expand **📋 Issues · PRs · Discussions — Index**: 1. Check which types to include (Issues, PRs, Discussions) 2. *(Optional)* Enter a number range (e.g. \`1-500\`) 3. Click **Start** 4. Download as HTML or Markdown > **Note:** The index contains titles, status, and links only — not full content. ### 5. Batch Full Export To export full content for multiple items: 1. **Run the index first** (step 4 above) — this discovers all items 2. The **📥 Batch Full Export** section shows "ready" with a picker 3. **Select items** using the checkboxes, or use the quick-select buttons (All, None, Issues, PRs, Open, Closed, etc.) 4. Click **📥 Export Selected** 5. Download as a single combined Markdown file or as a ZIP with one file per item > **Tip:** You can **pause** a batch export at any time and **resume** later. If you hit a rate limit, it pauses automatically. --- ## Pause / Resume / Reset All export sections support: - **⏸ Pause** — stops the current export, keeping all progress - **▶ Resume** — continues from where it stopped - **✕ Reset** — discards progress and starts fresh --- ## Export Settings The **⚙️ Export Settings** panel lets you control exactly what's included: | Category | Examples | |:---------|:--------| | **Content** | Header, labels, assignees, reactions, timestamps | | **Issue** | Type, closed-by, dependencies, sub-issues, pinned comment | | **Pull Request** | Reviews, review comments, commits, changed files, checks, merge requirements | | **Discussion** | Category, answer, poll, upvotes, replies, timeline | | **References** | Cross-references, related PRs/issues, commits | | **Timeline** | Verbose audit log of all events | | **Formatting** | Collapsible long comments | Settings are shared between single-page export and batch export, and persist across sessions. --- ## Rate Limits The top of the panel shows your current rate limit: - **REST** — used for issues, PRs, releases (5,000/hr with token, 60/hr without) - **GraphQL** — used for discussions, review threads, polls (5,000/hr, requires token) When a limit is hit, exports pause automatically. Wait for the reset time shown, then resume. --- ## Debug Logs Each section has a **Log** checkbox that shows API request details. Useful for troubleshooting. The log area has: - 📋 Copy to clipboard - 🗑 Clear log --- ## Keyboard Shortcuts *None currently — all interaction is via the panel UI.* --- ## Links - [Report Issues](https://greasyfork.org/en/users/1462137-piknockyou) - [Support on Ko-Fi](https://ko-fi.com/piknockyou) --- *${SCRIPT_NAME} v${typeof GM_info !== "undefined" ? GM_info.script.version : "?"} · License: AGPL-3.0* `; let renderedHtml = ""; try { const renderer = typeof marked.parse === "function" ? marked : marked.marked; renderedHtml = renderer.parse(helpMd, { breaks: true, gfm: true }); // biome-ignore lint/correctness/noUnusedVariables: catch variable required by syntax } catch (e) {} const html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"> <title>${SCRIPT_NAME} — Help</title> <style> *{box-sizing:border-box} body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;margin:0;padding:0;background:#0d1117;color:#c9d1d9;line-height:1.6} .wrap{max-width:760px;margin:0 auto;padding:32px 24px} h1{font-size:1.8em;color:#f0f6fc;border-bottom:2px solid #30363d;padding-bottom:8px;margin-bottom:16px} h2{font-size:1.35em;color:#f0f6fc;border-bottom:1px solid #21262d;padding-bottom:6px;margin-top:32px} h3{font-size:1.1em;color:#f0f6fc;margin-top:20px} a{color:#58a6ff;text-decoration:none} a:hover{text-decoration:underline} code{background:#21262d;padding:2px 6px;border-radius:4px;font-size:0.9em;font-family:'SF Mono',Consolas,monospace} pre{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;overflow-x:auto} pre code{background:none;padding:0} blockquote{border-left:3px solid #30363d;padding:4px 16px;margin:12px 0;color:#8b949e;background:#161b22;border-radius:0 6px 6px 0} table{border-collapse:collapse;width:100%;margin:12px 0} th,td{border:1px solid #30363d;padding:8px 12px;text-align:left} th{background:#161b22;font-weight:600} hr{border:none;border-top:1px solid #21262d;margin:24px 0} strong{color:#f0f6fc} em{color:#8b949e} ul,ol{padding-left:24px} li{margin:4px 0} </style></head><body> <div class="wrap">${renderedHtml}</div> </body></html>`; Utils.openBlob(html, "text/html"); } function remove() { const el = document.getElementById(ID); if (el) el.remove(); } return { create, remove }; })(); // ════════════════════════════════════════════════════════════════════════════ // NAVIGATION & INITIALIZATION // ════════════════════════════════════════════════════════════════════════════ let lastUrl = ""; function initPage() { const pageInfo = Utils.parsePage(); if (!pageInfo) { log("Not a repo page."); return; } log( `Init: ${pageInfo.type} — ${pageInfo.fullName}${pageInfo.number ? "#" + pageInfo.number : ""}`, ); ReleasesModule.reset(); RepoIndexModule.reset(); Panel.create(); } function cleanup() { Panel.remove(); } function handleNav() { const url = location.href; if (url === lastUrl) return; lastUrl = url; cleanup(); setTimeout(initPage, 300); } // Initial load lastUrl = location.href; initPage(); // SPA navigation document.addEventListener("turbo:load", handleNav); document.addEventListener("turbo:render", handleNav); document.addEventListener("turbo:frame-load", handleNav); window.addEventListener("popstate", () => setTimeout(handleNav, 100)); const navObserver = new MutationObserver(() => { if (location.href !== lastUrl) handleNav(); }); navObserver.observe(document.body, { childList: true, subtree: true }); })();