Auto Close SonarCloud PR Comments

Automatically closes SonarQube comments on Azure DevOps Pull Requests using REST API

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name         Auto Close SonarCloud PR Comments
// @namespace    http://tampermonkey.net/
// @version      1.0.1
// @description  Automatically closes SonarQube comments on Azure DevOps Pull Requests using REST API
// @author       VictorRos
// @match        https://dev.azure.com/*/*/_git/*/pullrequest/*
// @icon         https://www.google.com/s2/favicons?domain=azure.com
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

/* jshint esversion:11 */

(function() {
    "use strict";

    const LOG_PREFIX = "[Close-SonarCloud-Comments]";

    /**
     * Extracts PR information from URL
     */
    function extractPRInfo() {
        const url = window.location.href;
        const match = url.match(/dev\.azure\.com\/([^\/]+)\/([^\/]+)\/_git\/([^\/]+)\/pullrequest\/(\d+)/);

        if (!match) {
            console.error(`${LOG_PREFIX} Could not extract PR info from URL: ${url}`);
            return null;
        }

        return {
            organization: match[1],
            project: match[2],
            repository: match[3],
            pullRequestId: match[4]
        };
    }

    /**
     * Gets repository ID from repository name
     */
    async function getRepositoryId(organization, project, repositoryName) {
        try {
            const url = `https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repositoryName}?api-version=7.1`;
            const response = await fetch(url);

            if (!response.ok) {
                throw new Error(`HTTP ${response.status}: ${response.statusText}`);
            }

            const data = await response.json();
            return data.id;
        } catch (error) {
            console.error(`${LOG_PREFIX} Error getting repository ID:`, error);
            // Try to extract from DOM as fallback
            const repoElement = document.querySelector("[data-repository-id]");
            if (repoElement) {
                return repoElement.getAttribute("data-repository-id");
            }
            return repositoryName; // Fallback to name
        }
    }

    /**
     * Fetches all comment threads from PR using REST API
     */
    async function fetchCommentThreads(organization, project, repositoryId, pullRequestId) {
        try {
            const url = `https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repositoryId}/pullRequests/${pullRequestId}/threads?api-version=7.1`;
            console.log(`${LOG_PREFIX} Fetching comments from API for PR #${pullRequestId}: ${url}`);

            const response = await fetch(url);

            if (!response.ok) {
                throw new Error(`HTTP ${response.status}: ${response.statusText}`);
            }

            const data = await response.json();
            console.log(`${LOG_PREFIX} API returned ${data.value?.length || 0} thread(s)`);

            return data.value || [];
        } catch (error) {
            console.error(`${LOG_PREFIX} Error fetching comments:`, error);
            throw error;
        }
    }

    /**
     * Shows a modern notification
     */
    function showNotification(message, type = "info") {
        const notification = document.createElement("div");
        notification.style.cssText = `
            position: fixed;
            top: 20px;
            right: 20px;
            padding: 12px 20px;
            background-color: ${type === "error" ? "#d13438" : type === "success" ? "#107c10" : "#0078d4"};
            color: white;
            border-radius: 4px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            z-index: 10000;
            font-family: "Segoe UI", sans-serif;
            font-size: 14px;
            max-width: 400px;
            animation: slideIn 0.3s ease-out;
        `;

        // Add animation
        const style = document.createElement("style");
        style.textContent = `
            @keyframes slideIn {
                from { transform: translateX(100%); opacity: 0; }
                to { transform: translateX(0); opacity: 1; }
            }
        `;
        document.head.appendChild(style);

        notification.textContent = message;
        document.body.appendChild(notification);

        // Auto remove after 5 seconds
        setTimeout(() => {
            notification.style.animation = "slideIn 0.3s ease-out reverse";
            setTimeout(() => notification.remove(), 300);
        }, 5000);
    }

    /**
     * Checks if a comment is from SonarQube by analyzing its content
     * @param {Object} comment - The comment object from Azure DevOps API
     * @returns {boolean} - True if the comment contains SonarQube link
     */
    function isSonarQubeComment(comment) {
        const content = comment.content || "";
        return content.toLowerCase().includes("sonarqube cloud");
    }

    /**
     * Filters threads to find SonarQube comments that need to be closed (not already Closed)
     */
    function filterActiveSonarQubeComments(threads) {
        const commentsToClose = [];
        const allSonarQubeComments = [];
        const statusCounts = {};

        console.log(`${LOG_PREFIX} Analyzing ${threads.length} thread(s) from API...`);

        // Log first few SonarQube threads to see exact API response
        const sonarQubeThreads = threads.filter(thread => {
            if (!thread.comments || thread.comments.length === 0) return false;
            const firstComment = thread.comments[0];
            return isSonarQubeComment(firstComment);
        }).slice(0, 5);

        if (sonarQubeThreads.length > 0) {
            console.log(`${LOG_PREFIX} Sample SonarQube threads from API (first ${sonarQubeThreads.length}):`, sonarQubeThreads.map(t => ({
                id: t.id,
                status: t.status,
                statusType: typeof t.status,
                statusValue: JSON.stringify(t.status),
                fullThread: t
            })));
        }

        threads.forEach(thread => {
            // Check if thread has comments
            if (!thread.comments || thread.comments.length === 0) {
                return;
            }

            // Skip deleted threads
            if (thread.isDeleted) {
                return;
            }

            // Get the first comment (usually the main comment)
            const firstComment = thread.comments[0];

            // Skip deleted comments
            if (firstComment.isDeleted) {
                return;
            }

            const threadStatus = thread.status;

            // Normalize status to lowercase string for comparison
            const statusStr = String(threadStatus).toLowerCase();
            let statusName;

            switch (statusStr) {
                case "active":
                    statusName = "Active";
                    break;
                case "fixed":
                    statusName = "Fixed";
                    break;
                case "wontfix":
                    statusName = "WontFix";
                    break;
                case "closed":
                    statusName = "Closed";
                    break;
                case "resolved":
                    statusName = "Resolved";
                    break;
                case "pending":
                    statusName = "Pending";
                    break;
                default:
                    statusName = `Status-${threadStatus}`;
                    break;
            }
            statusCounts[statusName] = (statusCounts[statusName] || 0) + 1;

            // Check if comment is from SonarQube by analyzing content
            if (isSonarQubeComment(firstComment)) {
                allSonarQubeComments.push({
                    threadId: thread.id,
                    status: threadStatus,
                    statusName: statusName
                });

                console.log(`${LOG_PREFIX} Found SonarQube comment: Thread ${thread.id}, Status: ${threadStatus} (${statusName})`);

                // Close SonarQube comments that are NOT already logically closed
                // Logically closed statuses: Closed, Won't Fix (not treated), Resolved (treated)
                // Statuses to close: Active, Pending, Fixed, and any other status
                const isLogicallyClosed = ["closed", "wontfix", "resolved"].includes(statusStr);
                if (!isLogicallyClosed) {
                    commentsToClose.push({
                        threadId: thread.id,
                        commentId: firstComment.id,
                        thread: thread,
                        currentStatus: statusStr,
                        statusName: statusName
                    });
                    console.log(`${LOG_PREFIX} → Will close thread ${thread.id} (current status: ${statusName})`);
                } else {
                    console.log(`${LOG_PREFIX} → Skipping thread ${thread.id} (already logically closed: ${statusName})`);
                }
            }
        });

        console.log(`${LOG_PREFIX} Summary:`);
        console.log(`  - Total threads: ${threads.length}`);
        console.log(`  - SonarQube comments found: ${allSonarQubeComments.length}`);
        console.log(`  - SonarQube comments to close: ${commentsToClose.length}`);
        console.log(`  - Status breakdown:`, statusCounts);

        if (allSonarQubeComments.length > 0) {
            console.log(`${LOG_PREFIX} All SonarQube comments:`, allSonarQubeComments);
        }

        // Log comments that will NOT be closed (for debugging)
        // These are comments that are logically closed: Closed, Won't Fix, Resolved
        const alreadyClosed = allSonarQubeComments.filter(c => {
            const statusStr = String(c.status).toLowerCase();
            return statusStr === "closed" || statusStr === "wontfix" || statusStr === "resolved";
        });
        if (alreadyClosed.length > 0) {
            console.log(`${LOG_PREFIX} Already logically closed SonarQube comments (${alreadyClosed.length}):`, alreadyClosed);
        }

        return commentsToClose;
    }

    /**
     * Updates thread status to "closed" using REST API
     */
    async function updateThreadStatus(organization, project, repositoryId, pullRequestId, threadId) {
        try {
            const url = `https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repositoryId}/pullRequests/${pullRequestId}/threads/${threadId}?api-version=7.1`;

            console.log(`${LOG_PREFIX} Updating thread ${threadId} status to closed via API: ${url}`);

            const response = await fetch(url, {
                method: "PATCH",
                headers: {
                    "Content-Type": "application/json"
                },
                body: JSON.stringify({
                    status: "closed"
                })
            });

            if (!response.ok) {
                const errorText = await response.text();
                throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`);
            }

            const data = await response.json();
            console.log(`${LOG_PREFIX} ✓ Thread ${threadId} status updated successfully`);
            return true;
        } catch (error) {
            console.error(`${LOG_PREFIX} Error updating thread ${threadId} status:`, error);
            return false;
        }
    }

    /**
     * Creates the trigger button
     */
    function createButton() {

        if (document.getElementById("close-sonar-cloud-btn")) {
            return; // Button already exists
        }

        // Find toolbar using the specific selector
        const toolbar = document.querySelector(".bolt-header-commandbar");

        if (!toolbar) {
            console.warn(`${LOG_PREFIX} Toolbar not found, will retry...`);
            return;
        }

        const button = document.createElement("button");
        button.id = "close-sonar-cloud-btn";
        button.textContent = "Close SonarCloud Comments";
        button.style.cssText = `
            margin-left: 8px;
            padding: 4px 12px;
            background-color: #0078d4;
            color: white;
            border: none;
            border-radius: 2px;
            cursor: pointer;
            font-size: 14px;
            font-family: "Segoe UI", sans-serif;
        `;

        button.addEventListener("click", async () => {
            if (button.disabled) {
                return;
            }

            button.disabled = true;
            button.textContent = "Processing...";
            button.style.backgroundColor = "#a6a6a6";

            try {
                // Extract PR info
                const prInfo = extractPRInfo();
                if (!prInfo) {
                    throw new Error("Could not extract PR information from URL");
                }

                console.log(`${LOG_PREFIX} PR Info:`, prInfo);

                // Get repository ID
                const repositoryId = await getRepositoryId(prInfo.organization, prInfo.project, prInfo.repository);
                console.log(`${LOG_PREFIX} Repository ID: ${repositoryId}`);

                // Fetch comment threads from API
                const threads = await fetchCommentThreads(
                    prInfo.organization,
                    prInfo.project,
                    repositoryId,
                    prInfo.pullRequestId
                );

                // Filter for SonarQube comments that need to be closed (not already Closed)
                const commentsToClose = filterActiveSonarQubeComments(threads);

                if (commentsToClose.length === 0) {
                    console.log(`${LOG_PREFIX} No SonarQube comments to close - all are already closed`);
                    showNotification(`No SonarQube comments to close.\nAll comments are already closed.`, "info");
                    return;
                }

                console.log(`${LOG_PREFIX} Found ${commentsToClose.length} SonarQube comment(s) to close`);

                // Process each comment sequentially using API
                let successCount = 0;
                let failedThreads = [];
                for (let i = 0; i < commentsToClose.length; i++) {
                    const commentInfo = commentsToClose[i];
                    console.log(`${LOG_PREFIX} Processing ${i + 1}/${commentsToClose.length} (thread ${commentInfo.threadId}, current status: ${commentInfo.statusName})`);

                    // Update status via API REST
                    const success = await updateThreadStatus(
                        prInfo.organization,
                        prInfo.project,
                        repositoryId,
                        prInfo.pullRequestId,
                        commentInfo.threadId
                    );

                    if (success) {
                        successCount++;
                    } else {
                        failedThreads.push(commentInfo.threadId);
                        console.warn(`${LOG_PREFIX} Failed to close thread ${commentInfo.threadId}`);
                    }

                    // Small delay between API calls to avoid rate limiting
                    await new Promise(resolve => setTimeout(resolve, 200));
                }

                console.log(`${LOG_PREFIX} ✓ Completed! Closed ${successCount}/${commentsToClose.length} comment(s)`);

                if (failedThreads.length > 0) {
                    console.warn(`${LOG_PREFIX} Failed threads: ${failedThreads.join(", ")}`);
                }

                if (successCount === commentsToClose.length) {
                    showNotification(`✓ Successfully closed ${successCount} SonarQube comment(s)`, "success");
                } else if (successCount === 0) {
                    showNotification(`Failed to close any of the ${commentsToClose.length} SonarQube comment(s).\nCheck console for errors.`, "error");
                } else {
                    showNotification(`Closed ${successCount} out of ${commentsToClose.length} SonarQube comment(s).\n${failedThreads.length} failed.`, "info");
                }
            } catch (error) {
                console.error(`${LOG_PREFIX} Error:`, error);
                showNotification(`Error: ${error.message}`, "error");
            } finally {
                button.disabled = false;
                button.textContent = "Close SonarCloud Comments";
                button.style.backgroundColor = "#0078d4";
            }
        });

        // Insert button into toolbar
        try {
            toolbar.appendChild(button);
            console.log(`${LOG_PREFIX} ✓ Button created and inserted successfully`);
        } catch (error) {
            console.error(`${LOG_PREFIX} Error inserting button:`, error);
        }
    }

    // Initialize
    function init() {
        console.log(`${LOG_PREFIX} Script initialized (API version)`);
        console.log(`${LOG_PREFIX} Current URL: ${window.location.href}`);

        setTimeout(() => {
            console.log(`${LOG_PREFIX} Attempting to create button...`);
            createButton();
        }, 2000);

        // Retry button creation with more attempts
        let retries = 0;
        const maxRetries = 10;
        const checkButton = setInterval(() => {
            const existingButton = document.getElementById("close-sonar-cloud-btn");
            if (existingButton) {
                console.log(`${LOG_PREFIX} Button found, stopping retries`);
                clearInterval(checkButton);
            } else if (retries >= maxRetries) {
                console.warn(`${LOG_PREFIX} Max retries reached (${maxRetries}), stopping`);
                clearInterval(checkButton);
            } else {
                retries++;
                console.log(`${LOG_PREFIX} Retry ${retries}/${maxRetries} to create button...`);
                createButton();
            }
        }, 1000);
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
    } else {
        init();
    }
})();