Tools for the Atmosphere tools.slices.network
quickslice atproto html

feat(bugs): add bug-to-tangled-issue linking

Allow namespace authorities to link bugs to Tangled issues:
- Add "Link Issue" button in bug detail overlay
- Modal flow: select repo → create new issue or link existing
- Auto-generate Tangled issues from bug title/description/steps
- Display linked issues in bug detail with "View on Tangled" links
- Unlink functionality for namespace authorities

Also includes OAuth scope updates for bug.issue and tangled issue
records, and new GraphQL queries/mutations for the feature.

+1437 -24
+461 -24
bugs.html
··· 803 word-break: break-word; 804 } 805 806 /* ============================================================================= 807 MOBILE RESPONSIVE STYLES 808 ============================================================================= */ ··· 875 } 876 877 .overlay-header { 878 - flex-direction: column; 879 - align-items: flex-start; 880 - gap: 0.75rem; 881 position: relative; 882 padding-right: 3.5rem; 883 } 884 885 - .overlay-title { 886 - margin-right: 0; 887 } 888 889 .overlay-actions { 890 - width: 100%; 891 - justify-content: flex-start; 892 flex-wrap: wrap; 893 - } 894 - 895 - .overlay-header > .overlay-actions > .overlay-close { 896 - position: absolute; 897 - top: 1rem; 898 - right: 1rem; 899 } 900 901 /* Modal - nearly full screen */ ··· 963 existingAttachments: [], // Track which existing attachments to keep when editing 964 confirmedNamespace: false, // Track if user confirmed a domain-like namespace 965 handleSuggestions: [], // Handle autocomplete suggestions 966 - handleSuggestionIndex: -1 // Currently selected suggestion 967 }; 968 969 // ============================================================================= ··· 1208 } 1209 } 1210 } 1211 } 1212 } 1213 pageInfo { ··· 1288 } 1289 `; 1290 1291 // ============================================================================= 1292 // DATA FETCHING 1293 // ============================================================================= ··· 1564 <span class="status-badge ${displayStatus.cssClass}">${esc(displayStatus.label)}</span> 1565 <h2 class="overlay-title">${esc(bug.title)}</h2> 1566 </div> 1567 - <div class="overlay-actions"> 1568 - <button class="btn btn-secondary" onclick="shareBug()">Share</button> 1569 - ${canEdit ? ` 1570 - <button class="btn btn-secondary" onclick="openEditModal('${esc(bug.uri)}')">Edit</button> 1571 - <button class="btn btn-danger" onclick="handleDeleteBug('${esc(bug.uri)}')">Delete</button> 1572 - ` : ""} 1573 - <button class="overlay-close" onclick="closeOverlay()">×</button> 1574 - </div> 1575 </div> 1576 <div class="overlay-body"> 1577 <div class="overlay-meta"> ··· 1581 ${bug.appUsed ? `<span>·</span><span>${esc(bug.appUsed)}</span>` : ""} 1582 </div> 1583 1584 <div class="overlay-section"> 1585 <h3>Description</h3> 1586 <p>${esc(bug.description)}</p> ··· 1601 </div> 1602 </div> 1603 1604 ${canAddResponse ? renderResponseForm() : ""} 1605 </div> 1606 `; 1607 } 1608 1609 function renderAttachments(attachments) { 1610 if (!attachments || !attachments.images || attachments.images.length === 0) { 1611 return ""; ··· 2513 client = await QuicksliceClient.createQuicksliceClient({ 2514 server: SERVER_URL, 2515 clientId: CLIENT_ID, 2516 - scope: "atproto repo:network.slices.tools.bug repo:network.slices.tools.bugResponse blob:image/*" 2517 }); 2518 } 2519 return client; ··· 2683 await initClient(); 2684 await client.loginWithRedirect({ 2685 handle, 2686 - scope: "atproto repo:network.slices.tools.bug repo:network.slices.tools.bugResponse blob:image/*" 2687 }); 2688 } catch (err) { 2689 console.error("Login failed:", err);
··· 803 word-break: break-word; 804 } 805 806 + /* Linked issues */ 807 + .linked-issues-list { 808 + display: flex; 809 + flex-direction: column; 810 + gap: 0.5rem; 811 + margin-top: 0.5rem; 812 + } 813 + 814 + .linked-issues-list .card { 815 + padding: 0.75rem; 816 + margin-bottom: 0; 817 + } 818 + 819 + .linked-issues-list .card:hover { 820 + transform: none; 821 + box-shadow: none; 822 + } 823 + 824 + .linked-issues-list a { 825 + color: var(--accent); 826 + text-decoration: none; 827 + } 828 + 829 + .linked-issues-list a:hover { 830 + text-decoration: underline; 831 + } 832 + 833 + /* Repo and issue lists in modal */ 834 + .repo-list, 835 + .issue-list { 836 + display: flex; 837 + flex-direction: column; 838 + gap: 0.5rem; 839 + } 840 + 841 + .repo-list .card, 842 + .issue-list .card { 843 + margin-bottom: 0; 844 + } 845 + 846 /* ============================================================================= 847 MOBILE RESPONSIVE STYLES 848 ============================================================================= */ ··· 915 } 916 917 .overlay-header { 918 position: relative; 919 padding-right: 3.5rem; 920 } 921 922 + .overlay-header > .overlay-close { 923 + position: absolute; 924 + top: 1rem; 925 + right: 1rem; 926 } 927 928 .overlay-actions { 929 flex-wrap: wrap; 930 } 931 932 /* Modal - nearly full screen */ ··· 994 existingAttachments: [], // Track which existing attachments to keep when editing 995 confirmedNamespace: false, // Track if user confirmed a domain-like namespace 996 handleSuggestions: [], // Handle autocomplete suggestions 997 + handleSuggestionIndex: -1, // Currently selected suggestion 998 + linkIssueState: { 999 + bugUri: null, 1000 + step: "repos", // "repos" | "issues" 1001 + repos: [], 1002 + selectedRepo: null, 1003 + issues: [], 1004 + isLoading: false 1005 + } 1006 }; 1007 1008 // ============================================================================= ··· 1247 } 1248 } 1249 } 1250 + networkSlicesToolsBugIssueViaBug { 1251 + edges { 1252 + node { 1253 + uri 1254 + issue 1255 + issueResolved { 1256 + ... on ShTangledRepoIssue { 1257 + uri 1258 + title 1259 + repo 1260 + repoResolved { 1261 + ... on ShTangledRepo { 1262 + name 1263 + actorHandle 1264 + } 1265 + } 1266 + } 1267 + } 1268 + } 1269 + } 1270 + } 1271 } 1272 } 1273 pageInfo { ··· 1348 } 1349 `; 1350 1351 + const VIEWER_REPOS_QUERY = ` 1352 + query { 1353 + viewer { 1354 + shTangledRepoByDid(sortBy: [{ field: createdAt, direction: DESC }]) { 1355 + edges { 1356 + node { 1357 + uri 1358 + name 1359 + description 1360 + actorHandle 1361 + } 1362 + } 1363 + } 1364 + } 1365 + } 1366 + `; 1367 + 1368 + const REPO_ISSUES_QUERY = ` 1369 + query GetRepoIssues($repoUri: String!) { 1370 + shTangledRepoIssue( 1371 + where: { repo: { eq: $repoUri } } 1372 + sortBy: [{ field: createdAt, direction: DESC }] 1373 + ) { 1374 + edges { 1375 + node { 1376 + uri 1377 + title 1378 + createdAt 1379 + } 1380 + } 1381 + } 1382 + } 1383 + `; 1384 + 1385 + const CREATE_TANGLED_ISSUE_MUTATION = ` 1386 + mutation CreateTangledIssue($input: ShTangledRepoIssueInput!) { 1387 + createShTangledRepoIssue(input: $input) { 1388 + uri 1389 + } 1390 + } 1391 + `; 1392 + 1393 + const CREATE_BUG_ISSUE_MUTATION = ` 1394 + mutation CreateBugIssue($input: NetworkSlicesToolsBugIssueInput!) { 1395 + createNetworkSlicesToolsBugIssue(input: $input) { 1396 + uri 1397 + } 1398 + } 1399 + `; 1400 + 1401 + const DELETE_BUG_ISSUE_MUTATION = ` 1402 + mutation DeleteBugIssue($rkey: String!) { 1403 + deleteNetworkSlicesToolsBugIssue(rkey: $rkey) { 1404 + uri 1405 + } 1406 + } 1407 + `; 1408 + 1409 // ============================================================================= 1410 // DATA FETCHING 1411 // ============================================================================= ··· 1682 <span class="status-badge ${displayStatus.cssClass}">${esc(displayStatus.label)}</span> 1683 <h2 class="overlay-title">${esc(bug.title)}</h2> 1684 </div> 1685 + <button class="overlay-close" onclick="closeOverlay()">×</button> 1686 </div> 1687 <div class="overlay-body"> 1688 <div class="overlay-meta"> ··· 1692 ${bug.appUsed ? `<span>·</span><span>${esc(bug.appUsed)}</span>` : ""} 1693 </div> 1694 1695 + <div class="overlay-actions" style="margin-bottom: 1.5rem;"> 1696 + <button class="btn btn-secondary" onclick="shareBug()">Share</button> 1697 + ${canAddResponse ? `<button class="btn btn-secondary" onclick="openLinkIssueModal('${esc(bug.uri)}')">Link Issue</button>` : ""} 1698 + ${canEdit ? ` 1699 + <button class="btn btn-secondary" onclick="openEditModal('${esc(bug.uri)}')">Edit</button> 1700 + <button class="btn btn-danger" onclick="handleDeleteBug('${esc(bug.uri)}')">Delete</button> 1701 + ` : ""} 1702 + </div> 1703 + 1704 <div class="overlay-section"> 1705 <h3>Description</h3> 1706 <p>${esc(bug.description)}</p> ··· 1721 </div> 1722 </div> 1723 1724 + ${renderLinkedIssues(bug, canAddResponse)} 1725 + 1726 ${canAddResponse ? renderResponseForm() : ""} 1727 </div> 1728 `; 1729 } 1730 1731 + async function openLinkIssueModal(bugUri) { 1732 + state.linkIssueState = { 1733 + bugUri, 1734 + step: "repos", 1735 + repos: [], 1736 + selectedRepo: null, 1737 + issues: [], 1738 + isLoading: true 1739 + }; 1740 + 1741 + renderLinkIssueModal(); 1742 + 1743 + try { 1744 + const data = await gqlMutation(VIEWER_REPOS_QUERY, {}); 1745 + state.linkIssueState.repos = data.viewer?.shTangledRepoByDid?.edges?.map(e => e.node) || []; 1746 + } catch (err) { 1747 + console.error("Failed to fetch repos:", err); 1748 + showError(`Failed to load repos: ${err.message}`); 1749 + } finally { 1750 + state.linkIssueState.isLoading = false; 1751 + renderLinkIssueModal(); 1752 + } 1753 + } 1754 + 1755 + function renderLinkIssueModal() { 1756 + const modal = document.getElementById("modal"); 1757 + const { step, repos, selectedRepo, issues, isLoading } = state.linkIssueState; 1758 + 1759 + let content = ""; 1760 + 1761 + if (step === "repos") { 1762 + content = ` 1763 + <div class="modal-header"> 1764 + <h2>Link to Tangled Issue</h2> 1765 + <button class="modal-close" onclick="closeLinkIssueModal()">×</button> 1766 + </div> 1767 + <div class="modal-body"> 1768 + ${isLoading ? ` 1769 + <div class="loading-container"> 1770 + <div class="spinner"></div> 1771 + <span>Loading repos...</span> 1772 + </div> 1773 + ` : repos.length === 0 ? ` 1774 + <div class="loading-container"> 1775 + <p>No Tangled repos found.</p> 1776 + <p class="text-secondary">Create a repo on <a href="https://tangled.sh" target="_blank">tangled.sh</a> first.</p> 1777 + </div> 1778 + ` : ` 1779 + <div class="form-group"> 1780 + <label for="repo-select">Select a repository:</label> 1781 + <select id="repo-select" onchange="if(this.value) selectRepoForLinking(this.value)"> 1782 + <option value="">Choose a repo...</option> 1783 + ${repos.map(repo => ` 1784 + <option value="${esc(repo.uri)}">${esc(repo.name)}</option> 1785 + `).join("")} 1786 + </select> 1787 + </div> 1788 + `} 1789 + </div> 1790 + `; 1791 + } else if (step === "issues") { 1792 + const bug = state.bugs.find(b => b.uri === state.linkIssueState.bugUri); 1793 + content = ` 1794 + <div class="modal-header"> 1795 + <h2>Link to Tangled Issue</h2> 1796 + <button class="modal-close" onclick="closeLinkIssueModal()">×</button> 1797 + </div> 1798 + <div class="modal-body"> 1799 + <button class="btn btn-secondary" onclick="goBackToRepos()" style="margin-bottom: 1rem;">← Back to repos</button> 1800 + <p style="margin-bottom: 0.5rem;"><strong>${esc(selectedRepo.name)}</strong></p> 1801 + 1802 + <button class="btn btn-primary" style="width: 100%; margin-bottom: 1rem;" onclick="createAndLinkIssue()" ${isLoading ? 'disabled' : ''}> 1803 + + Create New Issue from Bug 1804 + </button> 1805 + 1806 + ${isLoading ? ` 1807 + <div class="loading-container"> 1808 + <div class="spinner"></div> 1809 + <span>Loading issues...</span> 1810 + </div> 1811 + ` : issues.length === 0 ? ` 1812 + <p class="text-secondary">No existing issues in this repo.</p> 1813 + ` : ` 1814 + <p class="text-secondary" style="margin-bottom: 0.5rem;">Or link to existing issue:</p> 1815 + <div class="issue-list"> 1816 + ${issues.map(issue => ` 1817 + <div class="card" style="cursor: pointer;" onclick="linkToExistingIssue('${esc(issue.uri)}')"> 1818 + <div style="font-weight: 500;">${esc(issue.title)}</div> 1819 + <div class="text-secondary" style="font-size: 0.75rem;">${formatTime(issue.createdAt)}</div> 1820 + </div> 1821 + `).join("")} 1822 + </div> 1823 + `} 1824 + </div> 1825 + `; 1826 + } 1827 + 1828 + modal.innerHTML = ` 1829 + <div class="modal-backdrop" onclick="closeLinkIssueModal()"></div> 1830 + <div class="modal-content scrollable"> 1831 + ${content} 1832 + </div> 1833 + `; 1834 + modal.classList.remove("hidden"); 1835 + } 1836 + 1837 + function closeLinkIssueModal() { 1838 + state.linkIssueState = { 1839 + bugUri: null, 1840 + step: "repos", 1841 + repos: [], 1842 + selectedRepo: null, 1843 + issues: [], 1844 + isLoading: false 1845 + }; 1846 + closeModal(); 1847 + } 1848 + 1849 + async function selectRepoForLinking(repoUri) { 1850 + const repo = state.linkIssueState.repos.find(r => r.uri === repoUri); 1851 + state.linkIssueState.selectedRepo = repo; 1852 + state.linkIssueState.step = "issues"; 1853 + state.linkIssueState.isLoading = true; 1854 + state.linkIssueState.issues = []; 1855 + 1856 + renderLinkIssueModal(); 1857 + 1858 + try { 1859 + const data = await gqlQuery(REPO_ISSUES_QUERY, { repoUri }); 1860 + state.linkIssueState.issues = data.shTangledRepoIssue?.edges?.map(e => e.node) || []; 1861 + } catch (err) { 1862 + console.error("Failed to fetch issues:", err); 1863 + showError(`Failed to load issues: ${err.message}`); 1864 + } finally { 1865 + state.linkIssueState.isLoading = false; 1866 + renderLinkIssueModal(); 1867 + } 1868 + } 1869 + 1870 + function goBackToRepos() { 1871 + state.linkIssueState.step = "repos"; 1872 + state.linkIssueState.selectedRepo = null; 1873 + state.linkIssueState.issues = []; 1874 + renderLinkIssueModal(); 1875 + } 1876 + 1877 + async function createAndLinkIssue() { 1878 + const { bugUri, selectedRepo } = state.linkIssueState; 1879 + const bug = state.bugs.find(b => b.uri === bugUri); 1880 + 1881 + if (!bug || !selectedRepo) return; 1882 + 1883 + state.linkIssueState.isLoading = true; 1884 + renderLinkIssueModal(); 1885 + 1886 + try { 1887 + // Build issue body from bug data 1888 + const bugUrl = `https://tools.slices.network/bugs?ns=${encodeURIComponent(bug.namespace)}&bug=${encodeURIComponent(bug.uri)}`; 1889 + const issueBody = `**Description:** 1890 + ${bug.description} 1891 + 1892 + **Steps to Reproduce:** 1893 + ${bug.stepsToReproduce} 1894 + 1895 + --- 1896 + Linked from bug: ${bugUrl}`; 1897 + 1898 + // Create the Tangled issue 1899 + const issueInput = { 1900 + repo: selectedRepo.uri, 1901 + title: bug.title, 1902 + body: issueBody, 1903 + createdAt: new Date().toISOString() 1904 + }; 1905 + 1906 + const issueResult = await gqlMutation(CREATE_TANGLED_ISSUE_MUTATION, { input: issueInput }); 1907 + const newIssueUri = issueResult.createShTangledRepoIssue.uri; 1908 + 1909 + // Create the bug.issue link 1910 + const linkInput = { 1911 + bug: bugUri, 1912 + issue: newIssueUri, 1913 + createdAt: new Date().toISOString() 1914 + }; 1915 + 1916 + await gqlMutation(CREATE_BUG_ISSUE_MUTATION, { input: linkInput }); 1917 + 1918 + // Close modal and refresh 1919 + closeLinkIssueModal(); 1920 + 1921 + // Refresh bugs to get updated linked issues 1922 + await loadBugs(); 1923 + 1924 + // Re-open the overlay to show the new linked issue 1925 + if (state.bugUri) { 1926 + renderOverlay(); 1927 + } 1928 + 1929 + showSuccess("Issue created and linked!"); 1930 + } catch (err) { 1931 + console.error("Failed to create and link issue:", err); 1932 + showError(`Failed to create issue: ${err.message}`); 1933 + state.linkIssueState.isLoading = false; 1934 + renderLinkIssueModal(); 1935 + } 1936 + } 1937 + 1938 + async function linkToExistingIssue(issueUri) { 1939 + const { bugUri } = state.linkIssueState; 1940 + 1941 + if (!bugUri) return; 1942 + 1943 + state.linkIssueState.isLoading = true; 1944 + renderLinkIssueModal(); 1945 + 1946 + try { 1947 + const linkInput = { 1948 + bug: bugUri, 1949 + issue: issueUri, 1950 + createdAt: new Date().toISOString() 1951 + }; 1952 + 1953 + await gqlMutation(CREATE_BUG_ISSUE_MUTATION, { input: linkInput }); 1954 + 1955 + closeLinkIssueModal(); 1956 + 1957 + // Refresh bugs to get updated linked issues 1958 + await loadBugs(); 1959 + 1960 + // Re-open the overlay to show the linked issue 1961 + if (state.bugUri) { 1962 + renderOverlay(); 1963 + } 1964 + 1965 + showSuccess("Issue linked!"); 1966 + } catch (err) { 1967 + console.error("Failed to link issue:", err); 1968 + showError(`Failed to link issue: ${err.message}`); 1969 + state.linkIssueState.isLoading = false; 1970 + renderLinkIssueModal(); 1971 + } 1972 + } 1973 + 1974 + function getTangledIssueUrl(issue) { 1975 + if (!issue.issueResolved) return null; 1976 + const resolved = issue.issueResolved; 1977 + if (!resolved.repoResolved) return null; 1978 + 1979 + const handle = resolved.repoResolved.actorHandle; 1980 + const repoName = resolved.repoResolved.name; 1981 + 1982 + // Tangled doesn't support linking to specific issues by rkey yet, 1983 + // so link to the repo's issues page instead 1984 + return `https://tangled.sh/${handle}/${repoName}/issues`; 1985 + } 1986 + 1987 + function renderLinkedIssues(bug, canUnlink) { 1988 + const linkedIssues = bug.networkSlicesToolsBugIssueViaBug?.edges?.map(e => e.node) || []; 1989 + 1990 + if (linkedIssues.length === 0) { 1991 + return ""; 1992 + } 1993 + 1994 + return ` 1995 + <div class="overlay-section"> 1996 + <h3>Linked Issues (${linkedIssues.length})</h3> 1997 + <div class="linked-issues-list"> 1998 + ${linkedIssues.map(link => { 1999 + const issue = link.issueResolved; 2000 + const url = getTangledIssueUrl(link); 2001 + const title = issue?.title || "Unknown Issue"; 2002 + const repoName = issue?.repoResolved?.name || "Unknown Repo"; 2003 + 2004 + return ` 2005 + <div class="card" style="display: flex; justify-content: space-between; align-items: center; cursor: default;"> 2006 + <div> 2007 + <div style="font-weight: 500;">${esc(title)}</div> 2008 + <div class="text-secondary" style="font-size: 0.75rem;"> 2009 + ${esc(repoName)} 2010 + ${url ? ` · <a href="${esc(url)}" target="_blank" onclick="event.stopPropagation()">View on Tangled</a>` : ''} 2011 + </div> 2012 + </div> 2013 + ${canUnlink ? `<button class="btn-icon btn-danger-text" onclick="unlinkIssue('${esc(link.uri)}')" title="Unlink">×</button>` : ''} 2014 + </div> 2015 + `; 2016 + }).join("")} 2017 + </div> 2018 + </div> 2019 + `; 2020 + } 2021 + 2022 + async function unlinkIssue(linkUri) { 2023 + if (!confirm("Unlink this issue?")) { 2024 + return; 2025 + } 2026 + 2027 + try { 2028 + const rkey = getRkeyFromUri(linkUri); 2029 + await gqlMutation(DELETE_BUG_ISSUE_MUTATION, { rkey }); 2030 + 2031 + // Refresh bugs to update linked issues 2032 + await loadBugs(); 2033 + 2034 + // Re-render overlay 2035 + if (state.bugUri) { 2036 + renderOverlay(); 2037 + } 2038 + 2039 + showSuccess("Issue unlinked!"); 2040 + } catch (err) { 2041 + console.error("Failed to unlink issue:", err); 2042 + showError(`Failed to unlink: ${err.message}`); 2043 + } 2044 + } 2045 + 2046 function renderAttachments(attachments) { 2047 if (!attachments || !attachments.images || attachments.images.length === 0) { 2048 return ""; ··· 2950 client = await QuicksliceClient.createQuicksliceClient({ 2951 server: SERVER_URL, 2952 clientId: CLIENT_ID, 2953 + scope: "atproto repo:network.slices.tools.bug repo:network.slices.tools.bug.response repo:network.slices.tools.bug.issue repo:sh.tangled.repo.issue blob:image/*" 2954 }); 2955 } 2956 return client; ··· 3120 await initClient(); 3121 await client.loginWithRedirect({ 3122 handle, 3123 + scope: "atproto repo:network.slices.tools.bug repo:network.slices.tools.bug.response repo:network.slices.tools.bug.issue repo:sh.tangled.repo.issue blob:image/*" 3124 }); 3125 } catch (err) { 3126 console.error("Login failed:", err);
+976
docs/plans/2025-12-17-bug-issue-linking.md
···
··· 1 + # Bug-to-Tangled-Issue Linking Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Allow namespace authorities to link bugs to Tangled issues (existing or newly created from bug data). 6 + 7 + **Architecture:** Add a modal flow in bugs.html: click "Link Issue" → select repo → choose existing issue or create new → creates `bug.issue` record. Display linked issues in bug detail with unlink option. 8 + 9 + **Tech Stack:** Vanilla JS, GraphQL mutations via Quickslice client, existing modal/overlay patterns in bugs.html. 10 + 11 + --- 12 + 13 + ### Task 1: Update OAuth Scope 14 + 15 + **Files:** 16 + - Modify: `bugs.html:2519` (scope in initClient) 17 + - Modify: `bugs.html:2686` (scope in handleLogin) 18 + 19 + **Step 1: Add bug.issue and tangled issue scopes to initClient** 20 + 21 + In the `initClient` function around line 2519, update the scope: 22 + 23 + ```javascript 24 + client = await QuicksliceClient.createQuicksliceClient({ 25 + server: SERVER_URL, 26 + clientId: CLIENT_ID, 27 + scope: "atproto repo:network.slices.tools.bug repo:network.slices.tools.bug.response repo:network.slices.tools.bug.issue repo:sh.tangled.repo.issue blob:image/*" 28 + }); 29 + ``` 30 + 31 + **Step 2: Update scope in handleLogin** 32 + 33 + In the `handleLogin` function around line 2686, update the scope: 34 + 35 + ```javascript 36 + await client.loginWithRedirect({ 37 + handle, 38 + scope: "atproto repo:network.slices.tools.bug repo:network.slices.tools.bug.response repo:network.slices.tools.bug.issue repo:sh.tangled.repo.issue blob:image/*" 39 + }); 40 + ``` 41 + 42 + **Step 3: Verify by logging in** 43 + 44 + 1. Open bugs.html in browser 45 + 2. Log out if logged in 46 + 3. Log in again 47 + 4. Check browser console for OAuth errors (should be none) 48 + 49 + **Step 4: Commit** 50 + 51 + ```bash 52 + git add bugs.html 53 + git commit -m "feat(bugs): add oauth scopes for bug.issue and tangled issues" 54 + ``` 55 + 56 + --- 57 + 58 + ### Task 2: Add GraphQL Queries and Mutations 59 + 60 + **Files:** 61 + - Modify: `bugs.html` (add after line ~1289, after DELETE_RESPONSE_MUTATION) 62 + 63 + **Step 1: Add query for viewer's repos** 64 + 65 + Add this query constant: 66 + 67 + ```javascript 68 + const VIEWER_REPOS_QUERY = ` 69 + query { 70 + viewer { 71 + shTangledRepoByDid(sortBy: [{ field: createdAt, direction: DESC }]) { 72 + edges { 73 + node { 74 + uri 75 + name 76 + description 77 + actorHandle 78 + } 79 + } 80 + } 81 + } 82 + } 83 + `; 84 + ``` 85 + 86 + **Step 2: Add query for repo issues** 87 + 88 + ```javascript 89 + const REPO_ISSUES_QUERY = ` 90 + query GetRepoIssues($repoUri: String!) { 91 + shTangledRepoIssue( 92 + where: { repo: { eq: $repoUri } } 93 + sortBy: [{ field: createdAt, direction: DESC }] 94 + ) { 95 + edges { 96 + node { 97 + uri 98 + title 99 + createdAt 100 + } 101 + } 102 + } 103 + } 104 + `; 105 + ``` 106 + 107 + **Step 3: Add mutation to create Tangled issue** 108 + 109 + ```javascript 110 + const CREATE_TANGLED_ISSUE_MUTATION = ` 111 + mutation CreateTangledIssue($input: ShTangledRepoIssueInput!) { 112 + createShTangledRepoIssue(input: $input) { 113 + uri 114 + } 115 + } 116 + `; 117 + ``` 118 + 119 + **Step 4: Add mutation to create bug.issue link** 120 + 121 + ```javascript 122 + const CREATE_BUG_ISSUE_MUTATION = ` 123 + mutation CreateBugIssue($input: NetworkSlicesToolsBugIssueInput!) { 124 + createNetworkSlicesToolsBugIssue(input: $input) { 125 + uri 126 + } 127 + } 128 + `; 129 + ``` 130 + 131 + **Step 5: Add mutation to delete bug.issue link** 132 + 133 + ```javascript 134 + const DELETE_BUG_ISSUE_MUTATION = ` 135 + mutation DeleteBugIssue($rkey: String!) { 136 + deleteNetworkSlicesToolsBugIssue(rkey: $rkey) { 137 + uri 138 + } 139 + } 140 + `; 141 + ``` 142 + 143 + **Step 6: Verify syntax** 144 + 145 + Open bugs.html in browser, check console for JS syntax errors (should be none). 146 + 147 + **Step 7: Commit** 148 + 149 + ```bash 150 + git add bugs.html 151 + git commit -m "feat(bugs): add graphql queries/mutations for issue linking" 152 + ``` 153 + 154 + --- 155 + 156 + ### Task 3: Update BUGS_QUERY to Include Linked Issues 157 + 158 + **Files:** 159 + - Modify: `bugs.html:1170-1220` (BUGS_QUERY) 160 + 161 + **Step 1: Add linked issues to the bugs query** 162 + 163 + Update BUGS_QUERY to include `networkSlicesToolsBugIssueViaBug` with resolved issue data: 164 + 165 + ```javascript 166 + const BUGS_QUERY = ` 167 + query GetBugs($first: Int!, $after: String, $namespace: String) { 168 + networkSlicesToolsBug( 169 + where: { namespace: { eq: $namespace } } 170 + sortBy: [{ field: createdAt, direction: DESC }] 171 + first: $first 172 + after: $after 173 + ) { 174 + edges { 175 + node { 176 + uri 177 + did 178 + title 179 + description 180 + stepsToReproduce 181 + severity 182 + appUsed 183 + namespace 184 + createdAt 185 + actorHandle 186 + attachments { 187 + ... on NetworkSlicesToolsDefsImages { 188 + images { 189 + alt 190 + image { 191 + ref 192 + mimeType 193 + size 194 + url(preset: "feed_fullsize") 195 + } 196 + } 197 + } 198 + } 199 + networkSlicesToolsBugResponseViaBug { 200 + edges { 201 + node { 202 + status 203 + createdAt 204 + } 205 + } 206 + } 207 + networkSlicesToolsBugIssueViaBug { 208 + edges { 209 + node { 210 + uri 211 + issue 212 + issueResolved { 213 + ... on ShTangledRepoIssue { 214 + uri 215 + title 216 + repo 217 + repoResolved { 218 + ... on ShTangledRepo { 219 + name 220 + actorHandle 221 + } 222 + } 223 + } 224 + } 225 + } 226 + } 227 + } 228 + } 229 + } 230 + pageInfo { 231 + hasNextPage 232 + endCursor 233 + } 234 + } 235 + } 236 + `; 237 + ``` 238 + 239 + **Step 2: Verify query works** 240 + 241 + 1. Refresh bugs.html 242 + 2. Navigate to a namespace with bugs 243 + 3. Check Network tab for GraphQL response (should include `networkSlicesToolsBugIssueViaBug` field, even if empty) 244 + 245 + **Step 3: Commit** 246 + 247 + ```bash 248 + git add bugs.html 249 + git commit -m "feat(bugs): include linked issues in bugs query" 250 + ``` 251 + 252 + --- 253 + 254 + ### Task 4: Add "Link Issue" Button to Bug Detail Overlay 255 + 256 + **Files:** 257 + - Modify: `bugs.html` (renderOverlay function, around line 1559-1575) 258 + 259 + **Step 1: Add the Link Issue button** 260 + 261 + In the `renderOverlay` function, find the overlay-actions div and add the Link Issue button (only for namespace authority): 262 + 263 + Update this section: 264 + 265 + ```javascript 266 + <div class="overlay-actions"> 267 + <button class="btn btn-secondary" onclick="shareBug()">Share</button> 268 + ${canAddResponse ? `<button class="btn btn-secondary" onclick="openLinkIssueModal('${esc(bug.uri)}')">Link Issue</button>` : ""} 269 + ${canEdit ? ` 270 + <button class="btn btn-secondary" onclick="openEditModal('${esc(bug.uri)}')">Edit</button> 271 + <button class="btn btn-danger" onclick="handleDeleteBug('${esc(bug.uri)}')">Delete</button> 272 + ` : ""} 273 + <button class="overlay-close" onclick="closeOverlay()">×</button> 274 + </div> 275 + ``` 276 + 277 + **Step 2: Add placeholder function** 278 + 279 + Add this function after the `renderOverlay` function: 280 + 281 + ```javascript 282 + function openLinkIssueModal(bugUri) { 283 + console.log("Opening link issue modal for:", bugUri); 284 + // TODO: implement 285 + } 286 + ``` 287 + 288 + **Step 3: Verify button appears** 289 + 290 + 1. Refresh bugs.html 291 + 2. Log in as the namespace authority (e.g., @grain.social for social.grain bugs) 292 + 3. Open a bug detail 293 + 4. Verify "Link Issue" button appears next to Share 294 + 5. Click it, check console shows the log message 295 + 296 + **Step 4: Commit** 297 + 298 + ```bash 299 + git add bugs.html 300 + git commit -m "feat(bugs): add link issue button to bug detail overlay" 301 + ``` 302 + 303 + --- 304 + 305 + ### Task 5: Implement Link Issue Modal - Repo Selection Step 306 + 307 + **Files:** 308 + - Modify: `bugs.html` (add new function and state) 309 + 310 + **Step 1: Add state for link issue modal** 311 + 312 + In the state object (around line 950), add: 313 + 314 + ```javascript 315 + linkIssueState: { 316 + bugUri: null, 317 + step: "repos", // "repos" | "issues" 318 + repos: [], 319 + selectedRepo: null, 320 + issues: [], 321 + isLoading: false 322 + }, 323 + ``` 324 + 325 + **Step 2: Implement openLinkIssueModal function** 326 + 327 + Replace the placeholder with the full implementation: 328 + 329 + ```javascript 330 + async function openLinkIssueModal(bugUri) { 331 + state.linkIssueState = { 332 + bugUri, 333 + step: "repos", 334 + repos: [], 335 + selectedRepo: null, 336 + issues: [], 337 + isLoading: true 338 + }; 339 + 340 + renderLinkIssueModal(); 341 + 342 + try { 343 + const data = await gqlMutation(VIEWER_REPOS_QUERY, {}); 344 + state.linkIssueState.repos = data.viewer?.shTangledRepoByDid?.edges?.map(e => e.node) || []; 345 + } catch (err) { 346 + console.error("Failed to fetch repos:", err); 347 + showError(`Failed to load repos: ${err.message}`); 348 + } finally { 349 + state.linkIssueState.isLoading = false; 350 + renderLinkIssueModal(); 351 + } 352 + } 353 + ``` 354 + 355 + **Step 3: Implement renderLinkIssueModal function** 356 + 357 + ```javascript 358 + function renderLinkIssueModal() { 359 + const modal = document.getElementById("modal"); 360 + const { step, repos, selectedRepo, issues, isLoading } = state.linkIssueState; 361 + 362 + let content = ""; 363 + 364 + if (step === "repos") { 365 + content = ` 366 + <div class="modal-header"> 367 + <h2>Link to Tangled Issue</h2> 368 + <button class="modal-close" onclick="closeLinkIssueModal()">×</button> 369 + </div> 370 + <div class="modal-body"> 371 + ${isLoading ? ` 372 + <div class="loading-container"> 373 + <div class="spinner"></div> 374 + <span>Loading repos...</span> 375 + </div> 376 + ` : repos.length === 0 ? ` 377 + <div class="loading-container"> 378 + <p>No Tangled repos found.</p> 379 + <p class="text-secondary">Create a repo on <a href="https://tangled.sh" target="_blank">tangled.sh</a> first.</p> 380 + </div> 381 + ` : ` 382 + <p class="text-secondary" style="margin-bottom: 1rem;">Select a repository:</p> 383 + <div class="repo-list"> 384 + ${repos.map(repo => ` 385 + <div class="card" style="cursor: pointer;" onclick="selectRepoForLinking('${esc(repo.uri)}')"> 386 + <div style="font-weight: 500;">${esc(repo.name)}</div> 387 + ${repo.description ? `<div class="text-secondary" style="font-size: 0.875rem;">${esc(repo.description.substring(0, 100))}${repo.description.length > 100 ? '...' : ''}</div>` : ''} 388 + </div> 389 + `).join("")} 390 + </div> 391 + `} 392 + </div> 393 + `; 394 + } 395 + 396 + modal.innerHTML = ` 397 + <div class="modal-backdrop" onclick="closeLinkIssueModal()"></div> 398 + <div class="modal-content scrollable"> 399 + ${content} 400 + </div> 401 + `; 402 + modal.classList.remove("hidden"); 403 + } 404 + ``` 405 + 406 + **Step 4: Add closeLinkIssueModal function** 407 + 408 + ```javascript 409 + function closeLinkIssueModal() { 410 + state.linkIssueState = { 411 + bugUri: null, 412 + step: "repos", 413 + repos: [], 414 + selectedRepo: null, 415 + issues: [], 416 + isLoading: false 417 + }; 418 + closeModal(); 419 + } 420 + ``` 421 + 422 + **Step 5: Add placeholder for selectRepoForLinking** 423 + 424 + ```javascript 425 + async function selectRepoForLinking(repoUri) { 426 + console.log("Selected repo:", repoUri); 427 + // TODO: implement step 2 428 + } 429 + ``` 430 + 431 + **Step 6: Verify repo list shows** 432 + 433 + 1. Refresh bugs.html 434 + 2. Log in as namespace authority 435 + 3. Open a bug, click "Link Issue" 436 + 4. Verify modal shows with your Tangled repos (or empty state if none) 437 + 438 + **Step 7: Commit** 439 + 440 + ```bash 441 + git add bugs.html 442 + git commit -m "feat(bugs): implement link issue modal repo selection" 443 + ``` 444 + 445 + --- 446 + 447 + ### Task 6: Implement Issue Selection Step 448 + 449 + **Files:** 450 + - Modify: `bugs.html` (selectRepoForLinking and renderLinkIssueModal) 451 + 452 + **Step 1: Implement selectRepoForLinking** 453 + 454 + Replace the placeholder: 455 + 456 + ```javascript 457 + async function selectRepoForLinking(repoUri) { 458 + const repo = state.linkIssueState.repos.find(r => r.uri === repoUri); 459 + state.linkIssueState.selectedRepo = repo; 460 + state.linkIssueState.step = "issues"; 461 + state.linkIssueState.isLoading = true; 462 + state.linkIssueState.issues = []; 463 + 464 + renderLinkIssueModal(); 465 + 466 + try { 467 + const data = await gqlQuery(REPO_ISSUES_QUERY, { repoUri }); 468 + state.linkIssueState.issues = data.shTangledRepoIssue?.edges?.map(e => e.node) || []; 469 + } catch (err) { 470 + console.error("Failed to fetch issues:", err); 471 + showError(`Failed to load issues: ${err.message}`); 472 + } finally { 473 + state.linkIssueState.isLoading = false; 474 + renderLinkIssueModal(); 475 + } 476 + } 477 + ``` 478 + 479 + **Step 2: Add issues step to renderLinkIssueModal** 480 + 481 + In the `renderLinkIssueModal` function, add an else-if for the issues step after the repos step: 482 + 483 + ```javascript 484 + } else if (step === "issues") { 485 + const bug = state.bugs.find(b => b.uri === state.linkIssueState.bugUri); 486 + content = ` 487 + <div class="modal-header"> 488 + <h2>Link to Tangled Issue</h2> 489 + <button class="modal-close" onclick="closeLinkIssueModal()">×</button> 490 + </div> 491 + <div class="modal-body"> 492 + <button class="btn btn-secondary" onclick="goBackToRepos()" style="margin-bottom: 1rem;">← Back to repos</button> 493 + <p style="margin-bottom: 0.5rem;"><strong>${esc(selectedRepo.name)}</strong></p> 494 + 495 + <button class="btn btn-primary" style="width: 100%; margin-bottom: 1rem;" onclick="createAndLinkIssue()" ${isLoading ? 'disabled' : ''}> 496 + + Create New Issue from Bug 497 + </button> 498 + 499 + ${isLoading ? ` 500 + <div class="loading-container"> 501 + <div class="spinner"></div> 502 + <span>Loading issues...</span> 503 + </div> 504 + ` : issues.length === 0 ? ` 505 + <p class="text-secondary">No existing issues in this repo.</p> 506 + ` : ` 507 + <p class="text-secondary" style="margin-bottom: 0.5rem;">Or link to existing issue:</p> 508 + <div class="issue-list"> 509 + ${issues.map(issue => ` 510 + <div class="card" style="cursor: pointer;" onclick="linkToExistingIssue('${esc(issue.uri)}')"> 511 + <div style="font-weight: 500;">${esc(issue.title)}</div> 512 + <div class="text-secondary" style="font-size: 0.75rem;">${formatTime(issue.createdAt)}</div> 513 + </div> 514 + `).join("")} 515 + </div> 516 + `} 517 + </div> 518 + `; 519 + } 520 + ``` 521 + 522 + **Step 3: Add goBackToRepos function** 523 + 524 + ```javascript 525 + function goBackToRepos() { 526 + state.linkIssueState.step = "repos"; 527 + state.linkIssueState.selectedRepo = null; 528 + state.linkIssueState.issues = []; 529 + renderLinkIssueModal(); 530 + } 531 + ``` 532 + 533 + **Step 4: Add placeholder functions** 534 + 535 + ```javascript 536 + async function createAndLinkIssue() { 537 + console.log("Creating and linking issue"); 538 + // TODO: implement 539 + } 540 + 541 + async function linkToExistingIssue(issueUri) { 542 + console.log("Linking to existing issue:", issueUri); 543 + // TODO: implement 544 + } 545 + ``` 546 + 547 + **Step 5: Verify issues step works** 548 + 549 + 1. Refresh bugs.html 550 + 2. Open link issue modal, select a repo 551 + 3. Verify you see "Create New Issue" button and list of existing issues (or empty state) 552 + 4. Verify back button works 553 + 554 + **Step 6: Commit** 555 + 556 + ```bash 557 + git add bugs.html 558 + git commit -m "feat(bugs): implement link issue modal issue selection step" 559 + ``` 560 + 561 + --- 562 + 563 + ### Task 7: Implement Create and Link Issue 564 + 565 + **Files:** 566 + - Modify: `bugs.html` (createAndLinkIssue function) 567 + 568 + **Step 1: Implement createAndLinkIssue** 569 + 570 + Replace the placeholder: 571 + 572 + ```javascript 573 + async function createAndLinkIssue() { 574 + const { bugUri, selectedRepo } = state.linkIssueState; 575 + const bug = state.bugs.find(b => b.uri === bugUri); 576 + 577 + if (!bug || !selectedRepo) return; 578 + 579 + state.linkIssueState.isLoading = true; 580 + renderLinkIssueModal(); 581 + 582 + try { 583 + // Build issue body from bug data 584 + const bugUrl = `${window.location.origin}${window.location.pathname}?ns=${encodeURIComponent(bug.namespace)}&bug=${encodeURIComponent(bug.uri)}`; 585 + const issueBody = `**Description:** 586 + ${bug.description} 587 + 588 + **Steps to Reproduce:** 589 + ${bug.stepsToReproduce} 590 + 591 + --- 592 + Linked from bug: ${bugUrl}`; 593 + 594 + // Create the Tangled issue 595 + const issueInput = { 596 + repo: selectedRepo.uri, 597 + title: bug.title, 598 + body: issueBody, 599 + createdAt: new Date().toISOString() 600 + }; 601 + 602 + const issueResult = await gqlMutation(CREATE_TANGLED_ISSUE_MUTATION, { input: issueInput }); 603 + const newIssueUri = issueResult.createShTangledRepoIssue.uri; 604 + 605 + // Create the bug.issue link 606 + const linkInput = { 607 + bug: bugUri, 608 + issue: newIssueUri, 609 + createdAt: new Date().toISOString() 610 + }; 611 + 612 + await gqlMutation(CREATE_BUG_ISSUE_MUTATION, { input: linkInput }); 613 + 614 + // Close modal and refresh 615 + closeLinkIssueModal(); 616 + 617 + // Refresh bugs to get updated linked issues 618 + await loadBugs(); 619 + 620 + // Re-open the overlay to show the new linked issue 621 + if (state.bugUri) { 622 + renderOverlay(); 623 + } 624 + 625 + showSuccess("Issue created and linked!"); 626 + } catch (err) { 627 + console.error("Failed to create and link issue:", err); 628 + showError(`Failed to create issue: ${err.message}`); 629 + state.linkIssueState.isLoading = false; 630 + renderLinkIssueModal(); 631 + } 632 + } 633 + ``` 634 + 635 + **Step 2: Verify create and link works** 636 + 637 + 1. Refresh bugs.html 638 + 2. Open a bug as namespace authority 639 + 3. Click "Link Issue", select a repo 640 + 4. Click "Create New Issue from Bug" 641 + 5. Verify success message appears 642 + 6. Check Tangled to verify issue was created 643 + 644 + **Step 3: Commit** 645 + 646 + ```bash 647 + git add bugs.html 648 + git commit -m "feat(bugs): implement create and link tangled issue" 649 + ``` 650 + 651 + --- 652 + 653 + ### Task 8: Implement Link to Existing Issue 654 + 655 + **Files:** 656 + - Modify: `bugs.html` (linkToExistingIssue function) 657 + 658 + **Step 1: Implement linkToExistingIssue** 659 + 660 + Replace the placeholder: 661 + 662 + ```javascript 663 + async function linkToExistingIssue(issueUri) { 664 + const { bugUri } = state.linkIssueState; 665 + 666 + if (!bugUri) return; 667 + 668 + state.linkIssueState.isLoading = true; 669 + renderLinkIssueModal(); 670 + 671 + try { 672 + const linkInput = { 673 + bug: bugUri, 674 + issue: issueUri, 675 + createdAt: new Date().toISOString() 676 + }; 677 + 678 + await gqlMutation(CREATE_BUG_ISSUE_MUTATION, { input: linkInput }); 679 + 680 + closeLinkIssueModal(); 681 + 682 + // Refresh bugs to get updated linked issues 683 + await loadBugs(); 684 + 685 + // Re-open the overlay to show the linked issue 686 + if (state.bugUri) { 687 + renderOverlay(); 688 + } 689 + 690 + showSuccess("Issue linked!"); 691 + } catch (err) { 692 + console.error("Failed to link issue:", err); 693 + showError(`Failed to link issue: ${err.message}`); 694 + state.linkIssueState.isLoading = false; 695 + renderLinkIssueModal(); 696 + } 697 + } 698 + ``` 699 + 700 + **Step 2: Verify linking existing issue works** 701 + 702 + 1. Refresh bugs.html 703 + 2. Open a bug as namespace authority 704 + 3. Click "Link Issue", select a repo with existing issues 705 + 4. Click on an existing issue 706 + 5. Verify success message appears 707 + 708 + **Step 3: Commit** 709 + 710 + ```bash 711 + git add bugs.html 712 + git commit -m "feat(bugs): implement link to existing tangled issue" 713 + ``` 714 + 715 + --- 716 + 717 + ### Task 9: Display Linked Issues in Bug Detail 718 + 719 + **Files:** 720 + - Modify: `bugs.html` (renderOverlay function) 721 + 722 + **Step 1: Add helper function to build Tangled issue URL** 723 + 724 + Add this function: 725 + 726 + ```javascript 727 + function getTangledIssueUrl(issue) { 728 + if (!issue.issueResolved) return null; 729 + const resolved = issue.issueResolved; 730 + if (!resolved.repoResolved) return null; 731 + 732 + const handle = resolved.repoResolved.actorHandle; 733 + const repoName = resolved.repoResolved.name; 734 + const rkey = resolved.uri.split('/').pop(); 735 + 736 + return `https://tangled.sh/${handle}/${repoName}/issues/${rkey}`; 737 + } 738 + ``` 739 + 740 + **Step 2: Add renderLinkedIssues function** 741 + 742 + ```javascript 743 + function renderLinkedIssues(bug, canUnlink) { 744 + const linkedIssues = bug.networkSlicesToolsBugIssueViaBug?.edges?.map(e => e.node) || []; 745 + 746 + if (linkedIssues.length === 0) { 747 + return ""; 748 + } 749 + 750 + return ` 751 + <div class="overlay-section"> 752 + <h3>Linked Issues (${linkedIssues.length})</h3> 753 + <div class="linked-issues-list"> 754 + ${linkedIssues.map(link => { 755 + const issue = link.issueResolved; 756 + const url = getTangledIssueUrl(link); 757 + const title = issue?.title || "Unknown Issue"; 758 + const repoName = issue?.repoResolved?.name || "Unknown Repo"; 759 + 760 + return ` 761 + <div class="card" style="display: flex; justify-content: space-between; align-items: center; cursor: default;"> 762 + <div> 763 + <div style="font-weight: 500;">${esc(title)}</div> 764 + <div class="text-secondary" style="font-size: 0.75rem;"> 765 + ${esc(repoName)} 766 + ${url ? ` · <a href="${esc(url)}" target="_blank" onclick="event.stopPropagation()">View on Tangled</a>` : ''} 767 + </div> 768 + </div> 769 + ${canUnlink ? `<button class="btn-icon btn-danger-text" onclick="unlinkIssue('${esc(link.uri)}')" title="Unlink">×</button>` : ''} 770 + </div> 771 + `; 772 + }).join("")} 773 + </div> 774 + </div> 775 + `; 776 + } 777 + ``` 778 + 779 + **Step 3: Add renderLinkedIssues to overlay** 780 + 781 + In the `renderOverlay` function, add the linked issues section after the responses section. Find this line: 782 + 783 + ```javascript 784 + ${canAddResponse ? renderResponseForm() : ""} 785 + ``` 786 + 787 + And add before it: 788 + 789 + ```javascript 790 + ${renderLinkedIssues(bug, canAddResponse)} 791 + ``` 792 + 793 + **Step 4: Verify linked issues display** 794 + 795 + 1. Refresh bugs.html 796 + 2. Open a bug that has linked issues 797 + 3. Verify "Linked Issues" section appears with issue cards 798 + 4. Verify "View on Tangled" link works 799 + 800 + **Step 5: Commit** 801 + 802 + ```bash 803 + git add bugs.html 804 + git commit -m "feat(bugs): display linked issues in bug detail" 805 + ``` 806 + 807 + --- 808 + 809 + ### Task 10: Implement Unlink Issue 810 + 811 + **Files:** 812 + - Modify: `bugs.html` (add unlinkIssue function) 813 + 814 + **Step 1: Implement unlinkIssue function** 815 + 816 + ```javascript 817 + async function unlinkIssue(linkUri) { 818 + if (!confirm("Unlink this issue?")) { 819 + return; 820 + } 821 + 822 + try { 823 + const rkey = getRkeyFromUri(linkUri); 824 + await gqlMutation(DELETE_BUG_ISSUE_MUTATION, { rkey }); 825 + 826 + // Refresh bugs to update linked issues 827 + await loadBugs(); 828 + 829 + // Re-render overlay 830 + if (state.bugUri) { 831 + renderOverlay(); 832 + } 833 + 834 + showSuccess("Issue unlinked!"); 835 + } catch (err) { 836 + console.error("Failed to unlink issue:", err); 837 + showError(`Failed to unlink: ${err.message}`); 838 + } 839 + } 840 + ``` 841 + 842 + **Step 2: Verify unlinking works** 843 + 844 + 1. Refresh bugs.html 845 + 2. Open a bug with linked issues as namespace authority 846 + 3. Click the × button on a linked issue 847 + 4. Confirm the prompt 848 + 5. Verify success message and issue is removed from list 849 + 850 + **Step 3: Commit** 851 + 852 + ```bash 853 + git add bugs.html 854 + git commit -m "feat(bugs): implement unlink issue functionality" 855 + ``` 856 + 857 + --- 858 + 859 + ### Task 11: Add CSS for Linked Issues 860 + 861 + **Files:** 862 + - Modify: `bugs.html` (add CSS in style section) 863 + 864 + **Step 1: Add CSS for linked issues list** 865 + 866 + Add these styles in the `<style>` section (around line 800, before the mobile responsive styles): 867 + 868 + ```css 869 + /* Linked issues */ 870 + .linked-issues-list { 871 + display: flex; 872 + flex-direction: column; 873 + gap: 0.5rem; 874 + margin-top: 0.5rem; 875 + } 876 + 877 + .linked-issues-list .card { 878 + padding: 0.75rem; 879 + margin-bottom: 0; 880 + } 881 + 882 + .linked-issues-list .card:hover { 883 + transform: none; 884 + box-shadow: none; 885 + } 886 + 887 + .linked-issues-list a { 888 + color: var(--accent); 889 + text-decoration: none; 890 + } 891 + 892 + .linked-issues-list a:hover { 893 + text-decoration: underline; 894 + } 895 + 896 + /* Repo and issue lists in modal */ 897 + .repo-list, 898 + .issue-list { 899 + display: flex; 900 + flex-direction: column; 901 + gap: 0.5rem; 902 + } 903 + 904 + .repo-list .card, 905 + .issue-list .card { 906 + margin-bottom: 0; 907 + } 908 + ``` 909 + 910 + **Step 2: Verify styling looks good** 911 + 912 + 1. Refresh bugs.html 913 + 2. Open link issue modal, verify repo/issue cards look good 914 + 3. Open a bug with linked issues, verify they display nicely 915 + 916 + **Step 3: Commit** 917 + 918 + ```bash 919 + git add bugs.html 920 + git commit -m "style(bugs): add css for linked issues and modal lists" 921 + ``` 922 + 923 + --- 924 + 925 + ### Task 12: Final Testing & Polish 926 + 927 + **Step 1: Test complete flow - create and link** 928 + 929 + 1. Log in as namespace authority 930 + 2. Open a bug 931 + 3. Click "Link Issue" 932 + 4. Select a repo 933 + 5. Click "Create New Issue from Bug" 934 + 6. Verify issue appears in Linked Issues section 935 + 7. Click "View on Tangled" link, verify it opens correct issue 936 + 937 + **Step 2: Test complete flow - link existing** 938 + 939 + 1. Open another bug 940 + 2. Click "Link Issue" 941 + 3. Select a repo with existing issues 942 + 4. Click an existing issue 943 + 5. Verify it appears in Linked Issues section 944 + 945 + **Step 3: Test unlink** 946 + 947 + 1. Click × on a linked issue 948 + 2. Confirm 949 + 3. Verify it's removed 950 + 951 + **Step 4: Test permissions** 952 + 953 + 1. Log out 954 + 2. Open a bug - verify "Link Issue" button is NOT visible 955 + 3. Log in as different user (not namespace authority) 956 + 4. Verify "Link Issue" button is NOT visible 957 + 958 + **Step 5: Final commit** 959 + 960 + ```bash 961 + git add bugs.html 962 + git commit -m "feat(bugs): complete bug-to-tangled-issue linking feature" 963 + ``` 964 + 965 + --- 966 + 967 + ## Summary 968 + 969 + This implementation adds: 970 + - "Link Issue" button in bug detail (namespace authority only) 971 + - Modal flow: select repo → create new issue or link existing 972 + - Auto-generated Tangled issues from bug title/description/steps 973 + - Linked issues display in bug detail with Tangled links 974 + - Unlink functionality 975 + 976 + All changes are in `bugs.html` - no new files needed.