Skip to content

Instantly share code, notes, and snippets.

@simonw
Created October 23, 2024 20:26
Show Gist options
  • Save simonw/1aab96fefec312cc9476786315633b4b to your computer and use it in GitHub Desktop.
Save simonw/1aab96fefec312cc9476786315633b4b to your computer and use it in GitHub Desktop.

GitHub Issue Viewer with Comment Fetching

human (Oct 23, 2024, 01:20 PM)

Build an artifact which accepts the URL to a GitHub issue e.g. https:/github.com/simonw/simonwillisonblog/issues/503 and from that derives the URL of the issue in the API https://api.github.com/repos/simonw/simonwillisonblog/issues/503 - it then fetches the issue and extracts the .title and .body and .state and created_at and user.login and composes those into Markdown which it displays on the page (not rendered, in a pre instead)

Then it hits the https://api.github.com/repos/simonw/simonwillisonblog/issues/503/comments API endpoint to fetch the issue comments for that post and adds those to the markdown as well - again with the issue comment body and the user.login and the created_at

assistant (Oct 23, 2024, 01:20 PM)

I'll create a React component that fetches and displays GitHub issue data in the requested format.

GitHub Issue Viewer

import React, { useState, useEffect } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';

const GitHubIssueViewer = () => {
  const [url, setUrl] = useState('');
  const [markdown, setMarkdown] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const convertUrlToApi = (githubUrl) => {
    try {
      const match = githubUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/);
      if (!match) throw new Error('Invalid GitHub issue URL');
      const [_, owner, repo, issue] = match;
      return {
        issueUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${issue}`,
        commentsUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${issue}/comments`
      };
    } catch (e) {
      throw new Error('Could not parse GitHub URL');
    }
  };

  const formatDate = (dateString) => {
    return new Date(dateString).toISOString().replace('T', ' ').replace('Z', ' UTC');
  };

  const fetchIssueData = async (inputUrl) => {
    setLoading(true);
    setError('');
    try {
      const { issueUrl, commentsUrl } = convertUrlToApi(inputUrl);
      
      // Fetch issue data
      const issueResponse = await fetch(issueUrl);
      if (!issueResponse.ok) throw new Error('Failed to fetch issue');
      const issue = await issueResponse.json();

      // Start building markdown
      let md = `# ${issue.title}\n\n`;
      md += `**State:** ${issue.state}\n`;
      md += `**Created by:** ${issue.user.login}\n`;
      md += `**Created at:** ${formatDate(issue.created_at)}\n\n`;
      md += `${issue.body}\n\n`;
      
      // Fetch comments
      const commentsResponse = await fetch(commentsUrl);
      if (!commentsResponse.ok) throw new Error('Failed to fetch comments');
      const comments = await commentsResponse.json();

      // Add comments to markdown
      if (comments.length > 0) {
        md += `## Comments\n\n`;
        comments.forEach(comment => {
          md += `### Comment by ${comment.user.login} at ${formatDate(comment.created_at)}\n\n`;
          md += `${comment.body}\n\n`;
        });
      }

      setMarkdown(md);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Card className="w-full max-w-4xl">
      <CardContent className="p-6">
        <div className="flex gap-4 mb-4">
          <Input 
            type="text" 
            placeholder="Enter GitHub issue URL"
            value={url}
            onChange={(e) => setUrl(e.target.value)}
            className="flex-1"
          />
          <Button 
            onClick={() => fetchIssueData(url)}
            disabled={loading}
          >
            {loading ? 'Loading...' : 'Fetch Issue'}
          </Button>
        </div>

        {error && (
          <Alert variant="destructive" className="mb-4">
            <AlertDescription>{error}</AlertDescription>
          </Alert>
        )}

        {markdown && (
          <pre className="p-4 bg-gray-100 rounded-lg overflow-x-auto whitespace-pre-wrap">
            {markdown}
          </pre>
        )}
      </CardContent>
    </Card>
  );
};

export default GitHubIssueViewer;

I've created a React component that:

  1. Takes a GitHub issue URL as input
  2. Converts it to the corresponding GitHub API URLs
  3. Fetches the issue data and comments
  4. Formats everything into Markdown
  5. Displays the raw Markdown in a pre tag

The component includes:

  • Error handling for invalid URLs or failed API requests
  • Loading states
  • Date formatting
  • A clean UI using shadcn/ui components
  • Responsive design

To use it, simply paste a GitHub issue URL and click "Fetch Issue". The raw Markdown will be displayed below.

Would you like me to modify anything about the component? For example, I could:

  • Add support for GitHub authentication tokens
  • Include more issue metadata
  • Modify the Markdown formatting
  • Add pagination for comments

human (Oct 23, 2024, 01:21 PM)

rewrite that to not use react or any other libraries

assistant (Oct 23, 2024, 01:21 PM)

I'll create a vanilla JavaScript version that can run in any browser.

GitHub Issue Viewer (Vanilla JS)

<!DOCTYPE html>
<html>
<head>
    <title>GitHub Issue Viewer</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            max-width: 800px;
            margin: 20px auto;
            padding: 0 20px;
        }
        .container {
            border: 1px solid #ddd;
            border-radius: 8px;
            padding: 20px;
        }
        .input-group {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }
        input {
            flex: 1;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 16px;
        }
        button {
            padding: 8px 16px;
            background: #0366d6;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
        }
        button:disabled {
            background: #ccc;
            cursor: not-allowed;
        }
        .error {
            background: #ffebe9;
            border: 1px solid #ff8182;
            color: #cf222e;
            padding: 10px;
            border-radius: 4px;
            margin-bottom: 20px;
            display: none;
        }
        pre {
            background: #f6f8fa;
            padding: 16px;
            border-radius: 4px;
            overflow-x: auto;
            white-space: pre-wrap;
            word-wrap: break-word;
        }
        .loading {
            display: none;
            margin: 20px 0;
            color: #666;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="input-group">
            <input type="text" id="urlInput" placeholder="Enter GitHub issue URL">
            <button id="fetchButton">Fetch Issue</button>
        </div>
        <div id="error" class="error"></div>
        <div id="loading" class="loading">Loading...</div>
        <pre id="output"></pre>
    </div>

    <script>
        const urlInput = document.getElementById('urlInput');
        const fetchButton = document.getElementById('fetchButton');
        const errorDiv = document.getElementById('error');
        const loadingDiv = document.getElementById('loading');
        const output = document.getElementById('output');

        function convertUrlToApi(githubUrl) {
            const match = githubUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/);
            if (!match) throw new Error('Invalid GitHub issue URL');
            const [_, owner, repo, issue] = match;
            return {
                issueUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${issue}`,
                commentsUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${issue}/comments`
            };
        }

        function formatDate(dateString) {
            return new Date(dateString)
                .toISOString()
                .replace('T', ' ')
                .replace('Z', ' UTC');
        }

        async function fetchIssueData(inputUrl) {
            errorDiv.style.display = 'none';
            loadingDiv.style.display = 'block';
            output.textContent = '';
            fetchButton.disabled = true;

            try {
                const { issueUrl, commentsUrl } = convertUrlToApi(inputUrl);
                
                // Fetch issue data
                const issueResponse = await fetch(issueUrl);
                if (!issueResponse.ok) throw new Error('Failed to fetch issue');
                const issue = await issueResponse.json();

                // Build markdown
                let md = `# ${issue.title}\n\n`;
                md += `**State:** ${issue.state}\n`;
                md += `**Created by:** ${issue.user.login}\n`;
                md += `**Created at:** ${formatDate(issue.created_at)}\n\n`;
                md += `${issue.body}\n\n`;
                
                // Fetch comments
                const commentsResponse = await fetch(commentsUrl);
                if (!commentsResponse.ok) throw new Error('Failed to fetch comments');
                const comments = await commentsResponse.json();

                // Add comments to markdown
                if (comments.length > 0) {
                    md += `## Comments\n\n`;
                    comments.forEach(comment => {
                        md += `### Comment by ${comment.user.login} at ${formatDate(comment.created_at)}\n\n`;
                        md += `${comment.body}\n\n`;
                    });
                }

                output.textContent = md;
            } catch (err) {
                errorDiv.textContent = err.message;
                errorDiv.style.display = 'block';
            } finally {
                loadingDiv.style.display = 'none';
                fetchButton.disabled = false;
            }
        }

        fetchButton.addEventListener('click', () => {
            fetchIssueData(urlInput.value);
        });

        urlInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                fetchIssueData(urlInput.value);
            }
        });
    </script>
</body>
</html>

I've created a vanilla JavaScript version that:

  1. Uses no external libraries or frameworks
  2. Has all the same functionality as the React version
  3. Includes a clean, minimal UI with CSS
  4. Handles loading states and errors
  5. Supports both button click and Enter key to fetch data

Key features:

  • Convert GitHub web URLs to API URLs
  • Fetch issue data and comments
  • Format everything into Markdown
  • Display raw Markdown in a pre tag
  • Error handling
  • Loading states
  • Date formatting

You can use this by:

  1. Opening the HTML file in a browser
  2. Pasting a GitHub issue URL
  3. Clicking "Fetch Issue" or pressing Enter

Would you like me to modify anything? For example, I could:

  • Add more styling options
  • Include additional issue metadata
  • Modify the Markdown formatting
  • Add rate limiting handling
  • Include GitHub authentication support

human (Oct 23, 2024, 01:21 PM)

An error occurred while trying to run the generated artifact. Unexpected token (85:13)

Can you fix this error in github-issue-viewer?

assistant (Oct 23, 2024, 01:21 PM)

The error is likely due to using async/await syntax without properly setting up the module type. Let me fix that.

GitHub Issue Viewer (Vanilla JS)

<!DOCTYPE html>
<html>
<head>
    <title>GitHub Issue Viewer</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            max-width: 800px;
            margin: 20px auto;
            padding: 0 20px;
        }
        .container {
            border: 1px solid #ddd;
            border-radius: 8px;
            padding: 20px;
        }
        .input-group {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }
        input {
            flex: 1;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 16px;
        }
        button {
            padding: 8px 16px;
            background: #0366d6;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
        }
        button:disabled {
            background: #ccc;
            cursor: not-allowed;
        }
        .error {
            background: #ffebe9;
            border: 1px solid #ff8182;
            color: #cf222e;
            padding: 10px;
            border-radius: 4px;
            margin-bottom: 20px;
            display: none;
        }
        pre {
            background: #f6f8fa;
            padding: 16px;
            border-radius: 4px;
            overflow-x: auto;
            white-space: pre-wrap;
            word-wrap: break-word;
        }
        .loading {
            display: none;
            margin: 20px 0;
            color: #666;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="input-group">
            <input type="text" id="urlInput" placeholder="Enter GitHub issue URL">
            <button id="fetchButton">Fetch Issue</button>
        </div>
        <div id="error" class="error"></div>
        <div id="loading" class="loading">Loading...</div>
        <pre id="output"></pre>
    </div>

    <script>
        const urlInput = document.getElementById('urlInput');
        const fetchButton = document.getElementById('fetchButton');
        const errorDiv = document.getElementById('error');
        const loadingDiv = document.getElementById('loading');
        const output = document.getElementById('output');

        function convertUrlToApi(githubUrl) {
            const match = githubUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/);
            if (!match) throw new Error('Invalid GitHub issue URL');
            const [_, owner, repo, issue] = match;
            return {
                issueUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${issue}`,
                commentsUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${issue}/comments`
            };
        }

        function formatDate(dateString) {
            return new Date(dateString)
                .toISOString()
                .replace('T', ' ')
                .replace('Z', ' UTC');
        }

        function fetchIssueData(inputUrl) {
            errorDiv.style.display = 'none';
            loadingDiv.style.display = 'block';
            output.textContent = '';
            fetchButton.disabled = true;

            let apiUrls;
            try {
                apiUrls = convertUrlToApi(inputUrl);
            } catch (err) {
                errorDiv.textContent = err.message;
                errorDiv.style.display = 'block';
                loadingDiv.style.display = 'none';
                fetchButton.disabled = false;
                return;
            }

            // Fetch issue data
            fetch(apiUrls.issueUrl)
                .then(response => {
                    if (!response.ok) throw new Error('Failed to fetch issue');
                    return response.json();
                })
                .then(issue => {
                    // Build markdown
                    let md = `# ${issue.title}\n\n`;
                    md += `**State:** ${issue.state}\n`;
                    md += `**Created by:** ${issue.user.login}\n`;
                    md += `**Created at:** ${formatDate(issue.created_at)}\n\n`;
                    md += `${issue.body}\n\n`;

                    // Fetch comments
                    return fetch(apiUrls.commentsUrl)
                        .then(response => {
                            if (!response.ok) throw new Error('Failed to fetch comments');
                            return response.json();
                        })
                        .then(comments => {
                            // Add comments to markdown
                            if (comments.length > 0) {
                                md += `## Comments\n\n`;
                                comments.forEach(comment => {
                                    md += `### Comment by ${comment.user.login} at ${formatDate(comment.created_at)}\n\n`;
                                    md += `${comment.body}\n\n`;
                                });
                            }
                            return md;
                        });
                })
                .then(markdown => {
                    output.textContent = markdown;
                })
                .catch(err => {
                    errorDiv.textContent = err.message;
                    errorDiv.style.display = 'block';
                })
                .finally(() => {
                    loadingDiv.style.display = 'none';
                    fetchButton.disabled = false;
                });
        }

        fetchButton.addEventListener('click', () => {
            fetchIssueData(urlInput.value);
        });

        urlInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                fetchIssueData(urlInput.value);
            }
        });
    </script>
</body>
</html>

I've updated the code to:

  1. Replace async/await with traditional Promise syntax
  2. Keep all the same functionality
  3. Maintain error handling and loading states
  4. Use proper Promise chaining for the sequential API calls

The code should now work in any modern browser without any syntax errors. You can test it by:

  1. Opening the HTML file in a browser
  2. Pasting a GitHub issue URL
  3. Clicking "Fetch Issue" or pressing Enter

Let me know if you encounter any other issues!

human (Oct 23, 2024, 01:23 PM)

Add a "Copy to clipboard" button at the bottom which, when clicked, changes its text to "Copied!" for 1.5s

assistant (Oct 23, 2024, 01:23 PM)

I'll add a copy button with the requested behavior.

GitHub Issue Viewer (Vanilla JS)

<!DOCTYPE html>
<html>
<head>
    <title>GitHub Issue Viewer</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            max-width: 800px;
            margin: 20px auto;
            padding: 0 20px;
        }
        .container {
            border: 1px solid #ddd;
            border-radius: 8px;
            padding: 20px;
        }
        .input-group {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }
        input {
            flex: 1;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 16px;
        }
        button {
            padding: 8px 16px;
            background: #0366d6;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
        }
        button:disabled {
            background: #ccc;
            cursor: not-allowed;
        }
        .error {
            background: #ffebe9;
            border: 1px solid #ff8182;
            color: #cf222e;
            padding: 10px;
            border-radius: 4px;
            margin-bottom: 20px;
            display: none;
        }
        pre {
            background: #f6f8fa;
            padding: 16px;
            border-radius: 4px;
            overflow-x: auto;
            white-space: pre-wrap;
            word-wrap: break-word;
            margin-bottom: 16px;
        }
        .loading {
            display: none;
            margin: 20px 0;
            color: #666;
        }
        .copy-button {
            display: none;
            margin-top: 10px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="input-group">
            <input type="text" id="urlInput" placeholder="Enter GitHub issue URL">
            <button id="fetchButton">Fetch Issue</button>
        </div>
        <div id="error" class="error"></div>
        <div id="loading" class="loading">Loading...</div>
        <pre id="output"></pre>
        <button id="copyButton" class="copy-button">Copy to clipboard</button>
    </div>

    <script>
        const urlInput = document.getElementById('urlInput');
        const fetchButton = document.getElementById('fetchButton');
        const errorDiv = document.getElementById('error');
        const loadingDiv = document.getElementById('loading');
        const output = document.getElementById('output');
        const copyButton = document.getElementById('copyButton');

        function convertUrlToApi(githubUrl) {
            const match = githubUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/);
            if (!match) throw new Error('Invalid GitHub issue URL');
            const [_, owner, repo, issue] = match;
            return {
                issueUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${issue}`,
                commentsUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${issue}/comments`
            };
        }

        function formatDate(dateString) {
            return new Date(dateString)
                .toISOString()
                .replace('T', ' ')
                .replace('Z', ' UTC');
        }

        function copyToClipboard() {
            const text = output.textContent;
            navigator.clipboard.writeText(text).then(() => {
                const originalText = copyButton.textContent;
                copyButton.textContent = 'Copied!';
                copyButton.disabled = true;
                
                setTimeout(() => {
                    copyButton.textContent = originalText;
                    copyButton.disabled = false;
                }, 1500);
            });
        }

        function fetchIssueData(inputUrl) {
            errorDiv.style.display = 'none';
            loadingDiv.style.display = 'block';
            output.textContent = '';
            copyButton.style.display = 'none';
            fetchButton.disabled = true;

            let apiUrls;
            try {
                apiUrls = convertUrlToApi(inputUrl);
            } catch (err) {
                errorDiv.textContent = err.message;
                errorDiv.style.display = 'block';
                loadingDiv.style.display = 'none';
                fetchButton.disabled = false;
                return;
            }

            // Fetch issue data
            fetch(apiUrls.issueUrl)
                .then(response => {
                    if (!response.ok) throw new Error('Failed to fetch issue');
                    return response.json();
                })
                .then(issue => {
                    // Build markdown
                    let md = `# ${issue.title}\n\n`;
                    md += `**State:** ${issue.state}\n`;
                    md += `**Created by:** ${issue.user.login}\n`;
                    md += `**Created at:** ${formatDate(issue.created_at)}\n\n`;
                    md += `${issue.body}\n\n`;

                    // Fetch comments
                    return fetch(apiUrls.commentsUrl)
                        .then(response => {
                            if (!response.ok) throw new Error('Failed to fetch comments');
                            return response.json();
                        })
                        .then(comments => {
                            // Add comments to markdown
                            if (comments.length > 0) {
                                md += `## Comments\n\n`;
                                comments.forEach(comment => {
                                    md += `### Comment by ${comment.user.login} at ${formatDate(comment.created_at)}\n\n`;
                                    md += `${comment.body}\n\n`;
                                });
                            }
                            return md;
                        });
                })
                .then(markdown => {
                    output.textContent = markdown;
                    copyButton.style.display = 'block';
                })
                .catch(err => {
                    errorDiv.textContent = err.message;
                    errorDiv.style.display = 'block';
                })
                .finally(() => {
                    loadingDiv.style.display = 'none';
                    fetchButton.disabled = false;
                });
        }

        fetchButton.addEventListener('click', () => {
            fetchIssueData(urlInput.value);
        });

        urlInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                fetchIssueData(urlInput.value);
            }
        });

        copyButton.addEventListener('click', copyToClipboard);
    </script>
</body>
</html>

I've made the following changes:

  1. Added a "Copy to clipboard" button that's hidden by default
  2. The button appears only when there's content to copy
  3. When clicked, it:
    • Copies the content to clipboard
    • Changes text to "Copied!"
    • Disables itself
    • Reverts back after 1.5 seconds
  4. Added appropriate styling for the button
  5. Hides the copy button when fetching new data

The copy functionality uses the Clipboard API, which is supported in all modern browsers. Let me know if you'd like any adjustments to the button's behavior or styling!

human (Oct 23, 2024, 01:25 PM)

add an optional password field at the top for the user to enter their personal access token for GitHub. This token should be stored in localStorage and loaded from localStorage when the page is loaded again in the future. If they change that token or empty out the field localStorage should be updated too.

assistant (Oct 23, 2024, 01:25 PM)

I'll add the GitHub token functionality with localStorage persistence.

GitHub Issue Viewer (Vanilla JS)

<!DOCTYPE html>
<html>
<head>
    <title>GitHub Issue Viewer</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            max-width: 800px;
            margin: 20px auto;
            padding: 0 20px;
        }
        .container {
            border: 1px solid #ddd;
            border-radius: 8px;
            padding: 20px;
        }
        .input-group {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }
        .token-group {
            margin-bottom: 20px;
        }
        input {
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 16px;
        }
        .url-input {
            flex: 1;
        }
        .token-input {
            width: 100%;
            max-width: 360px;
        }
        button {
            padding: 8px 16px;
            background: #0366d6;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
        }
        button:disabled {
            background: #ccc;
            cursor: not-allowed;
        }
        .error {
            background: #ffebe9;
            border: 1px solid #ff8182;
            color: #cf222e;
            padding: 10px;
            border-radius: 4px;
            margin-bottom: 20px;
            display: none;
        }
        pre {
            background: #f6f8fa;
            padding: 16px;
            border-radius: 4px;
            overflow-x: auto;
            white-space: pre-wrap;
            word-wrap: break-word;
            margin-bottom: 16px;
        }
        .loading {
            display: none;
            margin: 20px 0;
            color: #666;
        }
        .copy-button {
            display: none;
            margin-top: 10px;
        }
        label {
            display: block;
            margin-bottom: 8px;
            color: #444;
        }
        .token-help {
            font-size: 14px;
            color: #666;
            margin-top: 4px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="token-group">
            <label for="tokenInput">GitHub Personal Access Token (optional)</label>
            <input 
                type="password" 
                id="tokenInput" 
                placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 
                class="token-input">
            <div class="token-help">Token will be saved in your browser for future use</div>
        </div>
        <div class="input-group">
            <input type="text" id="urlInput" placeholder="Enter GitHub issue URL" class="url-input">
            <button id="fetchButton">Fetch Issue</button>
        </div>
        <div id="error" class="error"></div>
        <div id="loading" class="loading">Loading...</div>
        <pre id="output"></pre>
        <button id="copyButton" class="copy-button">Copy to clipboard</button>
    </div>

    <script>
        const urlInput = document.getElementById('urlInput');
        const tokenInput = document.getElementById('tokenInput');
        const fetchButton = document.getElementById('fetchButton');
        const errorDiv = document.getElementById('error');
        const loadingDiv = document.getElementById('loading');
        const output = document.getElementById('output');
        const copyButton = document.getElementById('copyButton');

        // Load token from localStorage on page load
        document.addEventListener('DOMContentLoaded', () => {
            const savedToken = localStorage.getItem('githubToken');
            if (savedToken) {
                tokenInput.value = savedToken;
            }
        });

        // Save token to localStorage when it changes
        tokenInput.addEventListener('input', () => {
            const token = tokenInput.value.trim();
            if (token) {
                localStorage.setItem('githubToken', token);
            } else {
                localStorage.removeItem('githubToken');
            }
        });

        function convertUrlToApi(githubUrl) {
            const match = githubUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/);
            if (!match) throw new Error('Invalid GitHub issue URL');
            const [_, owner, repo, issue] = match;
            return {
                issueUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${issue}`,
                commentsUrl: `https://api.github.com/repos/${owner}/${repo}/issues/${issue}/comments`
            };
        }

        function formatDate(dateString) {
            return new Date(dateString)
                .toISOString()
                .replace('T', ' ')
                .replace('Z', ' UTC');
        }

        function getRequestHeaders() {
            const headers = {
                'Accept': 'application/vnd.github.v3+json'
            };
            const token = tokenInput.value.trim();
            if (token) {
                headers['Authorization'] = `token ${token}`;
            }
            return headers;
        }

        function copyToClipboard() {
            const text = output.textContent;
            navigator.clipboard.writeText(text).then(() => {
                const originalText = copyButton.textContent;
                copyButton.textContent = 'Copied!';
                copyButton.disabled = true;
                
                setTimeout(() => {
                    copyButton.textContent = originalText;
                    copyButton.disabled = false;
                }, 1500);
            });
        }

        function fetchIssueData(inputUrl) {
            errorDiv.style.display = 'none';
            loadingDiv.style.display = 'block';
            output.textContent = '';
            copyButton.style.display = 'none';
            fetchButton.disabled = true;

            let apiUrls;
            try {
                apiUrls = convertUrlToApi(inputUrl);
            } catch (err) {
                errorDiv.textContent = err.message;
                errorDiv.style.display = 'block';
                loadingDiv.style.display = 'none';
                fetchButton.disabled = false;
                return;
            }

            const headers = getRequestHeaders();

            // Fetch issue data
            fetch(apiUrls.issueUrl, { headers })
                .then(response => {
                    if (!response.ok) {
                        if (response.status === 401) {
                            throw new Error('Invalid GitHub token');
                        } else if (response.status === 403) {
                            throw new Error('Rate limit exceeded. Try adding a GitHub token');
                        } else if (response.status === 404) {
                            throw new Error('Issue not found or private repository');
                        }
                        throw new Error('Failed to fetch issue');
                    }
                    return response.json();
                })
                .then(issue => {
                    // Build markdown
                    let md = `# ${issue.title}\n\n`;
                    md += `**State:** ${issue.state}\n`;
                    md += `**Created by:** ${issue.user.login}\n`;
                    md += `**Created at:** ${formatDate(issue.created_at)}\n\n`;
                    md += `${issue.body}\n\n`;

                    // Fetch comments
                    return fetch(apiUrls.commentsUrl, { headers })
                        .then(response => {
                            if (!response.ok) throw new Error('Failed to fetch comments');
                            return response.json();
                        })
                        .then(comments => {
                            // Add comments to markdown
                            if (comments.length > 0) {
                                md += `## Comments\n\n`;
                                comments.forEach(comment => {
                                    md += `### Comment by ${comment.user.login} at ${formatDate(comment.created_at)}\n\n`;
                                    md += `${comment.body}\n\n`;
                                });
                            }
                            return md;
                        });
                })
                .then(markdown => {
                    output.textContent = markdown;
                    copyButton.style.display = 'block';
                })
                .catch(err => {
                    errorDiv.textContent = err.message;
                    errorDiv.style.display = 'block';
                })
                .finally(() => {
                    loadingDiv.style.display = 'none';
                    fetchButton.disabled = false;
                });
        }

        fetchButton.addEventListener('click', () => {
            fetchIssueData(urlInput.value);
        });

        urlInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                fetchIssueData(urlInput.value);
            }
        });

        copyButton.addEventListener('click', copyToClipboard);
    </script>
</body>
</html>

I've made the following changes:

  1. Added a password input field for the GitHub token

  2. Added localStorage management:

    • Token loads automatically when the page loads
    • Token saves whenever it changes
    • Token is removed from localStorage if field is emptied
  3. Enhanced the API requests:

    • Added token to request headers when available
    • Improved error handling for various authentication scenarios:
      • Invalid token
      • Rate limit exceeded
      • Private repository access
      • General fetch failures
  4. Updated the UI:

    • Added helpful placeholder text for the token
    • Added a help message explaining the token storage
    • Styled the token input to match the existing design
    • Made the layout responsive
  5. Security considerations:

    • Used password input type to mask the token
    • Token is only stored in localStorage when explicitly entered
    • Token is only sent to GitHub API endpoints

The token will persist across page reloads and will be used for all API requests when present. This allows access to:

  • Private repositories (if token has appropriate permissions)
  • Higher rate limits
  • Protected API endpoints

Let me know if you'd like any adjustments to the token handling or UI!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment