Better GitHub Co-Authors

Adds a button to GitHub pull requests to make it simple to add co-author credits for all PR participants before merging.

目前為 2025-05-27 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Better GitHub Co-Authors
// @name:zh-CN   GitHub 共同协作者
// @description  Adds a button to GitHub pull requests to make it simple to add co-author credits for all PR participants before merging.
// @description:zh-CN 在 GitHub PR中添加一个按钮,以便在合并之前将所有 PR 参与者添加为共同作者.
// @author       delucis,人民的勤务员 <[email protected]>
// @namespace    https://github.com/ChinaGodMan/UserScripts
// @supportURL   https://github.com/ChinaGodMan/UserScripts/issues
// @homepageURL  https://github.com/ChinaGodMan/UserScripts
// @license      MIT
// @match        https://github.com/*
// @icon        https://raw.githubusercontent.com/ChinaGodMan/UserScriptsHistory/main/scriptsIcon/github-co-authors.png
// @compatible   chrome
// @compatible   firefox
// @compatible   edge
// @compatible   opera
// @compatible   safari
// @compatible   kiwi
// @compatible   qq
// @compatible   via
// @compatible   brave
// @version      2025.5.27.1
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==
/**
 * File: github-co-authors.user.js
 * Project: UserScripts
 * File Created: 2025/05/27,Tuesday 22:47:12
 * Author: Chris Swithinbank([email protected]),人民的勤务员@ChinaGodMan ([email protected])
 * -----
 * Last Modified: 2025/05/27,Tuesday 23:16:14
 * Modified By: 人民的勤务员@ChinaGodMan ([email protected])
 * -----
 * License: MIT License
 * Copyright © 2024 - 2025 ChinaGodMan,Inc
 */

let GITHUB_TOKEN = GM_getValue('GITHUB_TOKEN', '')
if (!GITHUB_TOKEN) {
    GM_setValue('GITHUB_TOKEN', prompt('Please enter your GitHub Token'))
    GITHUB_TOKEN = GM_getValue('GITHUB_TOKEN', '')
}

// Run the script
addCoAuthorsButtonToMergeForm()
// Re-run the script when navigating between pages
document.addEventListener('turbo:render', addCoAuthorsButtonToMergeForm)

/**
 * Create the UI for adding co-authors.
 * @param {HTMLElement} root
 * @param {Element | null} newMergeExperienceContainer
 */
function createCoAuthorsUI(root, newMergeExperienceContainer) {
    const banner = document.createElement('div')
    banner.setAttribute('aria-live', 'polite')
    banner.classList.add('color-fg-subtle')

    let bannerTimeout
    /**
     * Display a temporary status update about the progress adding co-authors.
     * @param {string} msg The message to display
     * @param {'subtle' | 'success' | 'danger'} color The status color to use.
     */
    const displayStatus = (msg, color = 'subtle') => {
        clearTimeout(bannerTimeout)
        banner.textContent = msg
        banner.className = `color-fg-${color}`
        bannerTimeout = setTimeout(() => (banner.textContent = ''), 5000)
    }

    const button = document.createElement('button')
    button.textContent = 'Add co-authors'
    button.classList.add('Button', 'Button--secondary', 'Button--small')
    button.type = 'button'
    button.addEventListener('click', async () => {
        displayStatus('Loading co-authors…')
        try {
            const { message, count } = await getCoAuthors()
            /** @type {HTMLTextAreaElement | null} */
            const textArea = newMergeExperienceContainer?.querySelector('textarea') ??
                root.querySelector('textarea#merge_message_field')
            if (!textArea) {
                throw new Error('Couldn’t find commit message <textarea>')
            }
            // Append co-authors to textarea content.
            textArea.value = (textArea.value + '\n\n' + message).trim()
            // Notify event listeners that the <textarea> content changed.
            textArea.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }))

            if (count === 0) {
                displayStatus('Found no co-authors to add')
            } else {
                displayStatus(`Added ${count} co-author${count === 1 ? '' : 's'}`, 'success')
            }
        } catch (error) {
            console.error('Error adding co-authors:', error)
            displayStatus('Something went wrong.', 'danger')
        }
    })
    // Build container
    const container = document.createElement('div')
    container.classList.add('d-flex', 'flex-items-center', 'gap-2')
    if (newMergeExperienceContainer) container.classList.add('mt-3')
    container.append(button)
    container.append(banner)
    return container
}

/** Get participants for the current PR and generate `Co-authored-by` messages for them. */
async function getCoAuthors() {
    const [, owner, repo, _pull, id] = window.location.pathname.split('/')
    const pullNumber = parseInt(id || '', 10)

    const [prData, comments, reviewComments, reviews] = await Promise.all([
        fetchGitHubAPI(`/repos/${owner}/${repo}/pulls/${pullNumber}`),
        fetchGitHubAPI(`/repos/${owner}/${repo}/issues/${pullNumber}/comments`),
        fetchGitHubAPI(`/repos/${owner}/${repo}/pulls/${pullNumber}/comments`),
        fetchGitHubAPI(`/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`)
    ])

    const participants = /** @type {Map<string, { name: string; email: string }>} */ (new Map())

    // Add commenters
    for (const { user } of [...comments, ...reviewComments, ...reviews]) {
        // Skip bot comments
        if (user.type === 'Bot') continue
        // Skip PR author
        if (user.login === prData.user.login) continue
        // Add commenters
        if (!participants.has(user.login)) {
            participants.set(user.login, {
                name: user.name || user.login,
                email: `${user.id}+${user.login}@users.noreply.github.com`
            })
        }
    }

    const lines = Array.from(participants.values()).map(
        (p) => `Co-authored-by: ${p.name} <${p.email}>`
    )
    return { message: lines.join('\n'), count: lines.length }
}

/**
 * Simple wrapper around `fetch()` for making GitHub API requests, e.g. `fetchGitHubAPI('/repos/withastro')`.
 * Throws an error if the fetch does not succeed.
 * @param {string} endpoint GitHub API endpoint to fetch
 */
async function fetchGitHubAPI(endpoint) {
    const response = await fetch(`https://api.github.com${endpoint}`, {
        headers: {
            Accept: 'application/vnd.github.v3+json',
            Authorization: `token ${GITHUB_TOKEN}`
        }
    })
    if (!response.ok) {
        throw new Error(`GitHub API request failed: ${response.statusText}`)
    }
    return response.json()
}

/**
 * Create and add the co-authors button.
 * @param {HTMLElement} root Element to search within and add the co-authors button to.
 */
function addCoAuthorsButton(root) {
    const newMergeExperienceContainer = root.querySelector('react-partial[partial-name="mergebox-partial"]')
    const commitTitleInput = newMergeExperienceContainer?.querySelector('div:has(> label):nth-child(1)') ??
        root.querySelector('input[name="commit_title"]')
    if (!commitTitleInput || root.querySelector('[data-coauthors-button]')) {
        return
    }
    const button = createCoAuthorsUI(root, newMergeExperienceContainer)
    button.setAttribute('data-coauthors-button', '')
    commitTitleInput.insertAdjacentElement('afterend', button)
}

/**
 * Look for the PR merge form and add monitor it to add the co-authors button when possible.
 */
function addCoAuthorsButtonToMergeForm() {
    const element = /** @type {HTMLDivElement | null} */ (
        document.querySelector('.discussion-timeline-actions')
    )
    if (!element) return

    const observer = new MutationObserver(() => {
        addCoAuthorsButton(element)
    })
    observer.observe(element, { subtree: true, childList: true })
    addCoAuthorsButton(element)
}