// ==UserScript== // @name GitLab 镜像仓库一键填充 // @namespace ScriptCat Scripts // @version 0.3.4 // @description 在GitLab仓库设置页面一键填充镜像仓库信息,并在镜像列表中添加目标仓库跳转链接 // @author Jetsung Chan // @match https://framagit.org/*/-/settings/repository* // @match https://gitlab.com/*/-/settings/repository* // @match https://framagit.org/*/project_activity // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_notification // @grant GM_xmlhttpRequest // @connect openapi-rdc.aliyuncs.com // @connect codeup.aliyun.com // @connect api.github.com // @connect codeberg.org // @connect atomgit.com // @downloadURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitlab.user.js // @updateURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitlab.user.js // ==/UserScript== (function() { 'use strict'; // 平台配置 const platforms = { github: { name: 'GitHub', url: 'https://github.com/', tokenKey: 'github_token', usernameKey: 'github_username', domain: 'github.com' }, atomgit: { name: 'AtomGit', url: 'https://atomgit.com/', tokenKey: 'atomgit_token', usernameKey: 'atomgit_username', domain: 'atomgit.com' }, codeup: { name: 'CodeUp', url: 'https://codeup.aliyun.com/jetsung/', tokenKey: 'codeup_token', usernameKey: 'codeup_username', domain: 'codeup.aliyun.com' }, codeberg: { name: 'Codeberg', url: 'https://codeberg.org/', tokenKey: 'codeberg_token', usernameKey: 'codeberg_username', domain: 'codeberg.org' } }; // 添加样式 GM_addStyle(` .mirror-helper-container { position: fixed; top: 20px; right: 20px; z-index: 9999; background: white; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); padding: 20px; width: 270px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } .mirror-helper-header { font-size: 18px; font-weight: 600; margin-bottom: 15px; color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; } .mirror-helper-section { margin-bottom: 15px; } .mirror-helper-section h4 { font-size: 14px; margin: 0 0 8px 0; color: #555; } .mirror-helper-input { width: 100%; padding: 8px 10px; margin-bottom: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; } .mirror-helper-button { background: #1f75cb; color: white; border: none; border-radius: 4px; padding: 8px 16px; font-size: 14px; cursor: pointer; margin-right: 8px; margin-bottom: 8px; transition: background 0.2s; } .mirror-helper-button:hover { background: #1b5faa; } .mirror-helper-button.secondary { background: #e6e6e6; color: #333; } .mirror-helper-button.secondary:hover { background: #d0d0d0; } .mirror-helper-select { width: 100%; padding: 8px 10px; margin-bottom: 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; } .mirror-helper-footer { margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee; font-size: 12px; color: #777; } .mirror-helper-close { position: absolute; top: 10px; right: 10px; background: none; border: none; font-size: 18px; cursor: pointer; color: #999; } .mirror-helper-close:hover { color: #333; } /* 目标仓库列样式 */ .target-repo-column { max-width: 200px; } .target-repo-link { color: #0366d6; text-decoration: none; font-weight: 500; display: inline-block; padding: 2px 4px; border-radius: 3px; transition: background-color 0.2s; } .target-repo-link:hover { background-color: rgba(3, 102, 214, 0.1); text-decoration: none; } .target-repo-link svg { width: 14px; height: 14px; margin-right: 4px; vertical-align: middle; } .no-target-link { color: #666; font-style: italic; } .create-repo-td { white-space: nowrap; } .create-repo-btn { background: #28a745; color: #fff; border: none; border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; transition: all 0.2s; } .create-repo-btn:hover { background: #218838; } .create-repo-btn:disabled { background: #6c757d; cursor: not-allowed; } .create-repo-btn.processing { background: #fd7e14 !important; } .create-repo-btn.success { background: #1e7e34 !important; } .create-repo-btn.error { background: #dc3545 !important; } `); // 解析镜像URL获取目标仓库信息 function parseMirrorUrl(mirrorUrl) { try { let cleanUrl = mirrorUrl.replace(/^https?:\/\/[^@]+@/, 'https://') .replace(/\.git$/, ''); for (const [key, platform] of Object.entries(platforms)) { if (cleanUrl.includes(platform.domain)) { return { platform: platform.name, url: cleanUrl, displayText: '打开', key: key }; } } } catch (e) { console.error('解析镜像URL失败:', mirrorUrl, e); } return null; } // 在表格中添加「打开」和「创建仓库」两列 function addTargetRepoColumn() { const table = document.querySelector('table.gl-table'); if (!table) return; const thead = table.querySelector('thead'); const tbody = table.querySelector('tbody'); if (!thead || !tbody) return; // 防止重复添加 if (thead.querySelector('th[data-col="open"]')) return; const firstTh = thead.querySelector('tr th:first-child'); // 1 「打开」列(保持你原来位置) const openTh = document.createElement('th'); openTh.textContent = '目标仓库'; openTh.className = 'target-repo-column'; openTh.setAttribute('data-col', 'open'); firstTh.parentNode.insertBefore(openTh, firstTh.nextSibling); // 2 「创建仓库」列(紧跟在打开后面) const createTh = document.createElement('th'); createTh.textContent = '创建仓库'; createTh.setAttribute('data-col', 'create'); openTh.parentNode.insertBefore(createTh, openTh.nextSibling); // 处理每一行 const rows = tbody.querySelectorAll('tr.rspec-mirrored-repository-row'); rows.forEach(row => { const urlCell = row.querySelector('[data-testid="mirror-repository-url-content"]'); if (!urlCell) return; const mirrorUrl = urlCell.textContent.trim(); const info = parseMirrorUrl(mirrorUrl); // === 打开列(你原来的代码完全不动)=== const openTd = document.createElement('td'); openTd.className = 'target-repo-column'; if (info) { const link = document.createElement('a'); link.href = info.url; link.className = 'target-repo-link'; link.target = '_blank'; link.innerHTML = ` ${info.displayText} `; openTd.appendChild(link); } else { openTd.innerHTML = '无法解析'; } const firstTd = row.querySelector('td:first-child'); firstTd.parentNode.insertBefore(openTd, firstTd.nextSibling); // === 新增:创建仓库列 === const createTd = document.createElement('td'); createTd.className = 'create-repo-td'; if (info && info.key) { const btn = document.createElement('button'); btn.className = 'create-repo-btn'; btn.textContent = '创建仓库'; btn.onclick = async () => { const key = info.key; const p = platforms[key]; const username = GM_getValue(p.usernameKey, ''); const token = GM_getValue(p.tokenKey, ''); if (!username || !token) { alert(`${p.name} 未配置账号信息`); return; } btn.disabled = true; btn.textContent = '创建中...'; btn.classList.add('processing'); try { // === 终极解析:支持多级子组 === const cleanUrl = info.url.replace(/\.git$/, '').replace(/\/+$/, ''); const parts = cleanUrl.split('/').slice(3); // 去掉 https://domain.com if (parts.length < 2) throw new Error('无法解析仓库地址:路径太短'); const repoName = parts.pop(); // 最后一段:仓库名 const ownerPath = parts.join('/'); // 前面所有:完整 owner(支持多级) if (!ownerPath || !repoName) throw new Error('无法解析 owner 或 repo'); let resp, data; // === 统一创建接口 === if (key === 'github') { resp = await fetch('https://api.github.com/user/repos', { method: 'POST', headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/vnd.github.v3+json' }, body: JSON.stringify({ name: repoName, private: false, auto_init: false }) }); } else if (key === 'codeberg') { resp = await fetch('https://codeberg.org/api/v1/user/repos', { method: 'POST', headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: repoName, private: false, auto_init: false }) }); } else if (key === 'atomgit') { console.log(token) resp = await fetch(`https://api.atomgit.com/api/v5/orgs/${encodeURIComponent(ownerPath)}/repos`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: repoName, path: repoName, public: 1, auto_init: false, default_branch: 'main' }) }); } else if (key === 'codeup') { const domain = 'https://openapi-rdc.aliyuncs.com'; const referer = 'https://codeup.aliyun.com'; // Step 1: 获取组织列表 const orgs = await new Promise((res, rej) => GM_xmlhttpRequest({ method: 'GET', url: `${domain}/oapi/v1/platform/organizations?perPage=100`, headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer }, onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`组织列表失败 ${r.status}`)), onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时')) })); // Step 2: 获取群组空间 const org = orgs.find(o => o.name.toLowerCase() === ownerPath.split('/')[0].toLowerCase()); if (!org) throw new Error(`未找到组织 ${ownerPath.split('/')[0]}`); const namespaces = await new Promise((res, rej) => GM_xmlhttpRequest({ method: 'GET', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/namespaces`, headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer }, onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`群组列表失败 ${r.status}`)), onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时')) })); // Step 3: 匹配群组(webUrl / pathWithNamespace / nameWithNamespace) const targetNs = namespaces.find(ns => { const web = (ns.webUrl || '').replace(/https?:\/\/[^\/]+/, ''); const path = ns.pathWithNamespace || ns.nameWithNamespace || ''; return web.includes(ownerPath) || path.toLowerCase() === ownerPath.toLowerCase(); }); if (!targetNs) { const paths = namespaces.map(n => n.pathWithNamespace || n.name).join(', '); throw new Error(`未找到群组 ${ownerPath}\n可用群组:${paths || '无'}`); } console.log('匹配群组成功:', targetNs); console.log(JSON.stringify({ name: repoName, path: repoName, namespaceId: targetNs.id, readMeType: 'NONE' })); // Step 4: 正确创建仓库(使用 organizationId + namespaceId) await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/repositories?createParentPath=true`, headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer }, data: JSON.stringify({ name: repoName, path: repoName, namespaceId: targetNs.id, }), onload: r => { const data = r.responseText ? JSON.parse(r.responseText) : {}; if (r.status >= 200 && r.status < 300 || data.id || data.result?.id) { console.log('CodeUp 仓库创建成功:', data); resolve(data); } else { reject(new Error(data.message || data.error || `创建失败(${r.status})`)); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')) }); }); // 模拟成功响应 resp = { ok: true, status: 200 }; data = { id: 'success' }; } // === 统一成功判断 === if ( resp.status >= 200 && resp.status < 400 || data.id || data.full_name || data.html_url || data.name || data.path || (data.message && /already exists|已存在/i.test(data.message)) ) { btn.textContent = '成功'; btn.classList.add('success'); // 自动填回 URL const input = document.querySelector('#url, #project_remote_mirrors_attributes_0_url'); if (input) input.value = info.url; } else { throw new Error(data.message || data.error || `HTTP ${resp.status}`); } } catch (e) { btn.textContent = '失败'; btn.classList.add('error'); console.error('创建仓库失败:', e); alert(`${p.name} 创建失败:${e.message}`); } // 3秒后恢复 setTimeout(() => { btn.disabled = false; btn.textContent = '创建仓库'; btn.className = 'create-repo-btn'; }, 3000); }; createTd.appendChild(btn); } else { createTd.textContent = '—'; } // 插入到「打开」列的后面 openTd.parentNode.insertBefore(createTd, openTd.nextSibling); }); } // 创建设置面板 function createSettingsPanel() { const panel = document.createElement('div'); panel.className = 'mirror-helper-container'; panel.id = 'mirror-settings-panel'; let html = `
镜像仓库设置
`; // 为每个平台创建输入框 for (const [key, platform] of Object.entries(platforms)) { html += `

${platform.name}

`; } html += ` `; panel.innerHTML = html; document.body.appendChild(panel); // 添加事件监听 document.getElementById('close-settings').addEventListener('click', () => { panel.remove(); }); document.getElementById('save-settings').addEventListener('click', () => { for (const [key, platform] of Object.entries(platforms)) { const username = document.getElementById(`${key}-username`).value; const token = document.getElementById(`${key}-token`).value; GM_setValue(platform.usernameKey, username); GM_setValue(platform.tokenKey, token); } alert('设置已保存!'); panel.remove(); location.reload(); // 刷新页面以应用新设置 }); document.getElementById('clear-settings').addEventListener('click', () => { if (confirm('确定要清除所有设置吗?')) { for (const platform of Object.values(platforms)) { GM_deleteValue(platform.usernameKey); GM_deleteValue(platform.tokenKey); } alert('设置已清除!'); panel.remove(); location.reload(); } }); } // 创建填充按钮 function createFillButton() { const container = document.createElement('div'); container.className = 'mirror-helper-container'; container.id = 'mirror-fill-container'; let html = `
镜像仓库填充

选择平台

`; container.innerHTML = html; document.body.appendChild(container); // 添加事件监听 document.getElementById('close-fill').addEventListener('click', () => { container.remove(); }); document.getElementById('open-settings').addEventListener('click', () => { container.remove(); createSettingsPanel(); }); document.getElementById('fill-mirror').addEventListener('click', () => { const selectedPlatform = document.getElementById('platform-select').value; fillMirrorForm(selectedPlatform); }); } // 填充表单 function fillMirrorForm(platformKey) { const platform = platforms[platformKey]; const username = GM_getValue(platform.usernameKey, ''); const token = GM_getValue(platform.tokenKey, ''); if (!username || !token) { alert(`请先设置 ${platform.name} 的用户名和Token!`); return; } // 获取当前仓库路径 let [, group, project] = window.location.pathname.split('/'); if (!group || !project) { alert('无法获取仓库路径!'); return; } switch (group) { case 'idev': if (platformKey === 'github') { group = 'idevsig' } break; case 'tiny': if (platformKey === 'github' || platformKey === 'atomgit') { group = 'tinyzen' } break; } // 构建镜像URL const mirrorUrl = `${platform.url}${group}/${project}.git`; // 填充表单 document.getElementById('url').value = mirrorUrl; document.getElementById('project_remote_mirrors_attributes_0_url').value = mirrorUrl; document.getElementById('project_remote_mirrors_attributes_0_user').value = username; document.getElementById('project_remote_mirrors_attributes_0_password').value = token; // 勾选"仅镜像受保护的分支" const protectedCheckbox = document.getElementById('only_protected_branches'); if (protectedCheckbox && !protectedCheckbox.checked) { protectedCheckbox.click(); } // 显示成功消息 const successMsg = document.createElement('div'); successMsg.style.cssText = ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: #4caf50; color: white; padding: 12px 24px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); z-index: 10000; font-size: 14px; `; successMsg.textContent = `已填充 ${platform.name} 镜像仓库信息!`; document.body.appendChild(successMsg); setTimeout(() => { successMsg.remove(); }, 3000); } // 初始化 function init() { // 添加目标仓库列(适用于镜像列表页面) if (document.querySelector('.js-mirrors-table-body')) { // 延迟执行以确保DOM完全加载 setTimeout(() => { addTargetRepoColumn(); // 监听动态加载的行 const observer = new MutationObserver(() => { addTargetRepoColumn(); }); observer.observe(document.body, { childList: true, subtree: true }); }, 1000); } // 创建填充按钮(适用于设置页面) if (document.querySelector('.js-mirror-form')) { createFillButton(); } // 注册菜单命令 GM_registerMenuCommand('镜像仓库设置', createSettingsPanel); } // 页面加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // 监听页面导航(SPA应用) let currentUrl = location.href; new MutationObserver(() => { if (location.href !== currentUrl) { currentUrl = location.href; setTimeout(init, 500); } }).observe(document, { subtree: true, childList: true }); })();