最后活跃于 1 month ago

在GitLab仓库设置页面一键填充镜像仓库信息

修订 2cdf5b3462b0e3f6fd3f3582d9cf7778ca0ff808

gitea.user.js 原始文件
1// ==UserScript==
2// @name Gitea 镜像仓库一键填充
3// @namespace ScriptCat Scripts
4// @version 0.1.2
5// @description 在Gitea仓库设置页面一键填充镜像仓库信息,并在镜像列表中添加目标仓库跳转链接
6// @author Jetsung Chan <[email protected]>
7// @match https://git.idev.top/*/*/settings
8// @match https://git.asfd.cn/*/*/settings
9// @match https://codeberg.org/*/*/settings
10// @grant GM_getValue
11// @grant GM_setValue
12// @grant GM_deleteValue
13// @grant GM_addStyle
14// @grant GM_registerMenuCommand
15// @grant GM_unregisterMenuCommand
16// @grant GM_notification
17// @grant GM_xmlhttpRequest
18// @connect openapi-rdc.aliyuncs.com
19// @connect codeup.aliyun.com
20// @connect api.github.com
21// @connect codeberg.org
22// @connect gitcode.com
23// @connect framagit.org
24// @downloadURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitea.user.js
25// @updateURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitea.user.js
26// ==/UserScript==
27
28(function() {
29 'use strict';
30
31 // 平台配置
32 const platforms = {
33 github: {
34 name: 'GitHub',
35 url: 'https://github.com/',
36 tokenKey: 'github_token',
37 usernameKey: 'github_username',
38 domain: 'github.com'
39 },
40 gitcode: {
41 name: 'GitCode',
42 url: 'https://gitcode.com/',
43 tokenKey: 'gitcode_token',
44 usernameKey: 'gitcode_username',
45 domain: 'gitcode.com'
46 },
47 codeup: {
48 name: 'CodeUp',
49 url: 'https://codeup.aliyun.com/jetsung/',
50 tokenKey: 'codeup_token',
51 usernameKey: 'codeup_username',
52 domain: 'codeup.aliyun.com'
53 },
54 codeberg: {
55 name: 'Codeberg',
56 url: 'https://codeberg.org/',
57 tokenKey: 'codeberg_token',
58 usernameKey: 'codeberg_username',
59 domain: 'codeberg.org',
60 type: 'gitea'
61 },
62 framagit: {
63 name: 'Framagit',
64 url: 'https://framagit.org/',
65 tokenKey: 'framagit_token',
66 usernameKey: 'framagit_username',
67 domain: 'framagit.org',
68 type: 'gitlab'
69 }
70 };
71
72 // 添加样式
73 GM_addStyle(`
74 .mirror-helper-container {
75 position: fixed;
76 top: 20px;
77 right: 20px;
78 z-index: 9999;
79 background: white;
80 border: 1px solid #ddd;
81 border-radius: 8px;
82 box-shadow: 0 4px 12px rgba(0,0,0,0.15);
83 padding: 20px;
84 width: 270px;
85 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
86 }
87
88 .mirror-helper-header {
89 font-size: 18px;
90 font-weight: 600;
91 margin-bottom: 15px;
92 color: #333;
93 border-bottom: 1px solid #eee;
94 padding-bottom: 10px;
95 }
96
97 .mirror-helper-section {
98 margin-bottom: 15px;
99 }
100
101 .mirror-helper-section h4 {
102 font-size: 14px;
103 margin: 0 0 8px 0;
104 color: #555;
105 }
106
107 .mirror-helper-input {
108 width: 100%;
109 padding: 8px 10px;
110 margin-bottom: 8px;
111 border: 1px solid #ddd;
112 border-radius: 4px;
113 font-size: 14px;
114 box-sizing: border-box;
115 }
116
117 .mirror-helper-button {
118 background: #1f75cb;
119 color: white;
120 border: none;
121 border-radius: 4px;
122 padding: 8px 16px;
123 font-size: 14px;
124 cursor: pointer;
125 margin-right: 8px;
126 margin-bottom: 8px;
127 transition: background 0.2s;
128 }
129
130 .mirror-helper-button:hover {
131 background: #1b5faa;
132 }
133
134 .mirror-helper-button.secondary {
135 background: #e6e6e6;
136 color: #333;
137 }
138
139 .mirror-helper-button.secondary:hover {
140 background: #d0d0d0;
141 }
142
143 .mirror-helper-select {
144 width: 100%;
145 padding: 8px 10px;
146 margin-bottom: 12px;
147 border: 1px solid #ddd;
148 border-radius: 4px;
149 font-size: 14px;
150 box-sizing: border-box;
151 }
152
153 .mirror-helper-footer {
154 margin-top: 15px;
155 padding-top: 10px;
156 border-top: 1px solid #eee;
157 font-size: 12px;
158 color: #777;
159 }
160
161 .mirror-helper-close {
162 position: absolute;
163 top: 10px;
164 right: 10px;
165 background: none;
166 border: none;
167 font-size: 18px;
168 cursor: pointer;
169 color: #999;
170 }
171
172 .mirror-helper-close:hover {
173 color: #333;
174 }
175
176 /* 目标仓库列样式 */
177 .target-repo-column {
178 max-width: 200px;
179 }
180
181 .target-repo-link {
182 color: #0366d6;
183 text-decoration: none;
184 font-weight: 500;
185 display: inline-block;
186 padding: 2px 4px;
187 border-radius: 3px;
188 transition: background-color 0.2s;
189 }
190
191 .target-repo-link:hover {
192 background-color: rgba(3, 102, 214, 0.1);
193 text-decoration: none;
194 }
195
196 .target-repo-link svg {
197 width: 14px;
198 height: 14px;
199 margin-right: 4px;
200 vertical-align: middle;
201 }
202
203 .no-target-link {
204 color: #666;
205 font-style: italic;
206 }
207
208 .create-repo-td { white-space: nowrap; }
209 .create-repo-btn {
210 background: #28a745; color: #fff; border: none; border-radius: 4px;
211 padding: 4px 10px; font-size: 12px; cursor: pointer; transition: all 0.2s;
212 }
213 .create-repo-btn:hover { background: #218838; }
214 .create-repo-btn:disabled { background: #6c757d; cursor: not-allowed; }
215 .create-repo-btn.processing { background: #fd7e14 !important; }
216 .create-repo-btn.success { background: #1e7e34 !important; }
217 .create-repo-btn.error { background: #dc3545 !important; }
218 `);
219
220 // 解析镜像URL获取目标仓库信息
221 function parseMirrorUrl(mirrorUrl) {
222 try {
223 let cleanUrl = mirrorUrl.replace(/^https?:\/\/[^@]+@/, 'https://')
224 .replace(/\.git$/, '');
225
226 console.log(cleanUrl)
227 for (const [key, platform] of Object.entries(platforms)) {
228 if (cleanUrl.includes(platform.domain)) {
229 return {
230 platform: platform.name,
231 url: cleanUrl,
232 key: key
233 };
234 }
235 }
236 } catch (e) {
237 console.error('解析镜像URL失败:', mirrorUrl, e);
238 }
239 return null;
240 }
241
242 // 在推送镜像表格的操作列中添加「创建仓库」按钮
243 function addTargetRepoColumn() {
244 // 匹配推送镜像的表格
245 const table = document.querySelector('table.ui.table');
246 if (!table) return;
247
248 const tbody = table.querySelector('tbody');
249 if (!tbody) return;
250
251 // 获取所有行,过滤掉最后的“添加镜像”表单行
252 const rows = tbody.querySelectorAll('tr');
253 rows.forEach(row => {
254 // 如果行内包含 form 或者 colspan="4",说明是添加镜像的输入框行,跳过
255 if (row.querySelector('form[action*="push-mirror-add"]') || row.querySelector('td[colspan]')) return;
256
257 // 查找操作列(右对齐的那个 td)
258 const actionTd = row.querySelector('td.tw-text-right');
259 if (!actionTd || actionTd.querySelector('.create-repo-btn')) return;
260
261 // 获取当前行的仓库 URL
262 const urlTd = row.querySelector('td.tw-break-anywhere');
263 if (!urlTd) return;
264
265 const mirrorUrl = urlTd.textContent.trim();
266 const info = parseMirrorUrl(mirrorUrl);
267
268 // 创建「创建仓库」按钮
269 const btn = document.createElement('button');
270 btn.className = 'create-repo-btn ui primary tiny button';
271 btn.style.marginRight = '4px'; // 留点间距
272 btn.innerHTML = `<svg viewBox="0 0 16 16" class="svg octicon-plus" width="14" height="14" style="vertical-align: middle; margin-right: 2px;"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"></path></svg>`;
273 btn.title = `创建仓库`;
274
275 console.log(info)
276
277 btn.onclick = async () => {
278 const key = info.key;
279 const p = platforms[key];
280 const username = GM_getValue(p.usernameKey, '');
281 const token = GM_getValue(p.tokenKey, '');
282
283 if (!username || !token) {
284 alert(`${p.name} 未配置账号信息`);
285 return;
286 }
287
288 btn.disabled = true;
289 btn.textContent = '创建中...';
290 btn.classList.add('processing');
291
292 try {
293 // === 终极解析:支持多级子组 ===
294 const cleanUrl = info.url.replace(/\.git$/, '').replace(/\/+$/, '');
295 const parts = cleanUrl.split('/').slice(3); // 去掉 https://domain.com
296 if (parts.length < 2) throw new Error('无法解析仓库地址:路径太短');
297 const repoName = parts.pop(); // 最后一段:仓库名
298 const ownerPath = parts.join('/'); // 前面所有:完整 owner(支持多级)
299
300 if (!ownerPath || !repoName) throw new Error('无法解析 owner 或 repo');
301
302 let resp, data;
303
304 // === 统一创建接口 ===
305 if (key === 'github') {
306 resp = await fetch('https://api.github.com/user/repos', {
307 method: 'POST',
308 headers: {
309 'Authorization': `token ${token}`,
310 'Content-Type': 'application/json',
311 'Accept': 'application/vnd.github.v3+json'
312 },
313 body: JSON.stringify({ name: repoName, private: false, auto_init: false })
314 });
315 } else if (p.type === 'gitea' || key === 'codeberg') {
316 resp = await fetch(p.url + 'api/v1/user/repos', {
317 method: 'POST',
318 headers: {
319 'Authorization': `token ${token}`,
320 'Content-Type': 'application/json'
321 },
322 body: JSON.stringify({ name: repoName, private: false, auto_init: false })
323 });
324 } else if (p.type === 'gitlab' || key === 'framagit') {
325 console.log(token)
326 resp = await fetch(p.url + `api/v4/groups/${encodeURIComponent(ownerPath)}`, {
327 method: 'POST',
328 headers: {
329 'Authorization': `PRIVATE-TOKEN: ${token}`,
330 'Content-Type': 'application/json'
331 },
332 body: JSON.stringify({
333 name: repoName,
334 visibility: 'private'
335 })
336 });
337 } else if (key === 'gitcode') {
338 console.log(token)
339 resp = await fetch(`https://api.gitcode.com/api/v5/orgs/${encodeURIComponent(ownerPath)}/repos`, {
340 method: 'POST',
341 headers: {
342 'Authorization': `Bearer ${token}`,
343 'Content-Type': 'application/json'
344 },
345 body: JSON.stringify({
346 name: repoName,
347 path: repoName,
348 public: 1,
349 auto_init: false,
350 default_branch: 'main'
351 })
352 });
353 } else if (key === 'codeup') {
354 const domain = 'https://openapi-rdc.aliyuncs.com';
355 const referer = 'https://codeup.aliyun.com';
356
357 // Step 1: 获取组织列表
358 const orgs = await new Promise((res, rej) => GM_xmlhttpRequest({
359 method: 'GET', url: `${domain}/oapi/v1/platform/organizations?perPage=100`,
360 headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
361 onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`组织列表失败 ${r.status}`)),
362 onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
363 }));
364
365 // Step 2: 获取群组空间
366 const org = orgs.find(o => o.name.toLowerCase() === ownerPath.split('/')[0].toLowerCase());
367 if (!org) throw new Error(`未找到组织 ${ownerPath.split('/')[0]}`);
368
369 const namespaces = await new Promise((res, rej) => GM_xmlhttpRequest({
370 method: 'GET', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/namespaces`,
371 headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
372 onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`群组列表失败 ${r.status}`)),
373 onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
374 }));
375
376 // Step 3: 匹配群组(webUrl / pathWithNamespace / nameWithNamespace)
377 const targetNs = namespaces.find(ns => {
378 const web = (ns.webUrl || '').replace(/https?:\/\/[^\/]+/, '');
379 const path = ns.pathWithNamespace || ns.nameWithNamespace || '';
380 return web.includes(ownerPath) || path.toLowerCase() === ownerPath.toLowerCase();
381 });
382
383 if (!targetNs) {
384 const paths = namespaces.map(n => n.pathWithNamespace || n.name).join(', ');
385 throw new Error(`未找到群组 ${ownerPath}\n可用群组:${paths || '无'}`);
386 }
387
388 console.log('匹配群组成功:', targetNs);
389 console.log(JSON.stringify({
390 name: repoName,
391 path: repoName,
392 namespaceId: targetNs.id,
393 readMeType: 'NONE'
394 }));
395
396 // Step 4: 正确创建仓库(使用 organizationId + namespaceId)
397 await new Promise((resolve, reject) => {
398 GM_xmlhttpRequest({
399 method: 'POST',
400 url: `${domain}/oapi/v1/codeup/organizations/${org.id}/repositories?createParentPath=true`,
401 headers: {
402 'x-yunxiao-token': token,
403 'Content-Type': 'application/json',
404 'Referer': referer,
405 'Origin': referer
406 },
407 data: JSON.stringify({
408 name: repoName,
409 path: repoName,
410 namespaceId: targetNs.id,
411 }),
412 onload: r => {
413 const data = r.responseText ? JSON.parse(r.responseText) : {};
414 if (r.status >= 200 && r.status < 300 || data.id || data.result?.id) {
415 console.log('CodeUp 仓库创建成功:', data);
416 resolve(data);
417 } else {
418 reject(new Error(data.message || data.error || `创建失败(${r.status}`));
419 }
420 },
421 onerror: () => reject(new Error('网络错误')),
422 ontimeout: () => reject(new Error('请求超时'))
423 });
424 });
425
426 // 模拟成功响应
427 resp = { ok: true, status: 200 };
428 data = { id: 'success' };
429 }
430
431 // === 统一成功判断 ===
432 if (
433 resp.status >= 200 && resp.status < 400 ||
434 data.id || data.full_name || data.html_url || data.name || data.path ||
435 (data.message && /already exists|已存在/i.test(data.message))
436 ) {
437 btn.textContent = '成功';
438 btn.classList.add('success');
439
440 // 自动填回 URL
441 const input = document.querySelector('#url, #project_remote_mirrors_attributes_0_url');
442 if (input) input.value = info.url;
443 } else {
444 throw new Error(data.message || data.error || `HTTP ${resp.status}`);
445 }
446
447 } catch (e) {
448 btn.textContent = '失败';
449 btn.classList.add('error');
450 console.error('创建仓库失败:', e);
451 alert(`${p.name} 创建失败:${e.message}`);
452 }
453
454 // 3秒后恢复
455 setTimeout(() => {
456 btn.disabled = false;
457 btn.textContent = '创建仓库';
458 btn.className = 'create-repo-btn';
459 }, 3000);
460 };
461
462 // 插入到操作按钮组的最前面
463 actionTd.insertBefore(btn, actionTd.firstChild);
464 });
465 }
466
467 // 创建设置面板
468 function createSettingsPanel() {
469 const panel = document.createElement('div');
470 panel.className = 'mirror-helper-container';
471 panel.id = 'mirror-settings-panel';
472
473 let html = `
474 <button class="mirror-helper-close" id="close-settings">×</button>
475 <div class="mirror-helper-header">镜像仓库设置</div>
476 `;
477
478 // 为每个平台创建输入框
479 for (const [key, platform] of Object.entries(platforms)) {
480 html += `
481 <div class="mirror-helper-section">
482 <h4>${platform.name}</h4>
483 <input type="text" class="mirror-helper-input" id="${key}-username"
484 placeholder="用户名" value="${GM_getValue(platform.usernameKey, '')}">
485 <input type="password" class="mirror-helper-input" id="${key}-token"
486 placeholder="Token" value="${GM_getValue(platform.tokenKey, '')}">
487 </div>
488 `;
489 }
490
491 html += `
492 <button class="mirror-helper-button" id="save-settings">保存设置</button>
493 <button class="mirror-helper-button secondary" id="clear-settings">清除设置</button>
494 <div class="mirror-helper-footer">设置将保存在浏览器本地</div>
495 `;
496
497 panel.innerHTML = html;
498 document.body.appendChild(panel);
499
500 // 添加事件监听
501 document.getElementById('close-settings').addEventListener('click', () => {
502 panel.remove();
503 });
504
505 document.getElementById('save-settings').addEventListener('click', () => {
506 for (const [key, platform] of Object.entries(platforms)) {
507 const username = document.getElementById(`${key}-username`).value;
508 const token = document.getElementById(`${key}-token`).value;
509
510 GM_setValue(platform.usernameKey, username);
511 GM_setValue(platform.tokenKey, token);
512 }
513
514 alert('设置已保存!');
515 panel.remove();
516 location.reload(); // 刷新页面以应用新设置
517 });
518
519 document.getElementById('clear-settings').addEventListener('click', () => {
520 if (confirm('确定要清除所有设置吗?')) {
521 for (const platform of Object.values(platforms)) {
522 GM_deleteValue(platform.usernameKey);
523 GM_deleteValue(platform.tokenKey);
524 }
525 alert('设置已清除!');
526 panel.remove();
527 location.reload();
528 }
529 });
530 }
531
532 // 创建填充按钮
533 function createFillButton() {
534 const container = document.createElement('div');
535 container.className = 'mirror-helper-container';
536 container.id = 'mirror-fill-container';
537
538 let html = `
539 <button class="mirror-helper-close" id="close-fill">×</button>
540 <div class="mirror-helper-header">镜像仓库填充</div>
541 <div class="mirror-helper-section">
542 <h4>选择平台</h4>
543 <select class="mirror-helper-select" id="platform-select">
544 `;
545
546 // 添加平台选项
547 for (const [key, platform] of Object.entries(platforms)) {
548 const username = GM_getValue(platform.usernameKey, '');
549 const token = GM_getValue(platform.tokenKey, '');
550 const status = (username && token) ? '✓' : '✗';
551 html += `<option value="${key}">${platform.name} ${status}</option>`;
552 }
553
554 html += `
555 </select>
556 </div>
557 <button class="mirror-helper-button" id="fill-mirror">填充镜像仓库</button>
558 <button class="mirror-helper-button secondary" id="open-settings">设置账号</button>
559 <div class="mirror-helper-footer">点击填充按钮自动填写表单</div>
560 `;
561
562 container.innerHTML = html;
563 document.body.appendChild(container);
564
565 // 添加事件监听
566 document.getElementById('close-fill').addEventListener('click', () => {
567 container.remove();
568 });
569
570 document.getElementById('open-settings').addEventListener('click', () => {
571 container.remove();
572 createSettingsPanel();
573 });
574
575 document.getElementById('fill-mirror').addEventListener('click', () => {
576 const selectedPlatform = document.getElementById('platform-select').value;
577 fillMirrorForm(selectedPlatform);
578 });
579 }
580
581 // 填充表单
582 function fillMirrorForm(platformKey) {
583 const platform = platforms[platformKey];
584 const username = GM_getValue(platform.usernameKey, '');
585 const token = GM_getValue(platform.tokenKey, '');
586
587 if (!username || !token) {
588 alert(`请先设置 ${platform.name} 的用户名和Token!`);
589 return;
590 }
591
592 // 获取当前仓库路径
593 let [, group, project] = window.location.pathname.split('/');
594 if (!group || !project) {
595 alert('无法获取仓库路径!');
596 return;
597 }
598
599 switch (group) {
600 case 'idev':
601 if (platformKey === 'github') {
602 group = 'idevsig'
603 }
604 break;
605
606 case 'tiny':
607 if (platformKey === 'github' || platformKey === 'gitcode') {
608 group = 'tinyzen'
609 }
610 break;
611 }
612
613 // 构建镜像URL
614 const mirrorUrl = `${platform.url}${group}/${project}.git`;
615
616 // 填充表单
617 document.getElementById('push_mirror_address').value = mirrorUrl;
618 document.getElementById('push_mirror_username').value = username;
619 document.getElementById('push_mirror_password').value = token;
620 document.getElementById('push_mirror_interval').value = 0;
621
622 // 勾选"推送提交时同步"
623 const syncCheckbox = document.getElementById('push_mirror_sync_on_commit');
624 if (syncCheckbox && !syncCheckbox.checked) {
625 syncCheckbox.click();
626 }
627
628 // 显示成功消息
629 const successMsg = document.createElement('div');
630 successMsg.style.cssText = `
631 position: fixed;
632 top: 20px;
633 left: 50%;
634 transform: translateX(-50%);
635 background: #4caf50;
636 color: white;
637 padding: 12px 24px;
638 border-radius: 4px;
639 box-shadow: 0 2px 8px rgba(0,0,0,0.2);
640 z-index: 10000;
641 font-size: 14px;
642 `;
643 successMsg.textContent = `已填充 ${platform.name} 镜像仓库信息!`;
644 document.body.appendChild(successMsg);
645
646 setTimeout(() => {
647 successMsg.remove();
648 }, 3000);
649 }
650
651 // 新增一个专门处理「推送镜像」列表中 URL 变成可点击链接的函数
652 function makePushMirrorUrlsClickable() {
653 // 找到所有推送镜像的 URL 单元格
654 const urlCells = document.querySelectorAll(
655 'table.ui.table tbody tr:not([style*="display: none"]) td.tw-break-anywhere'
656 );
657
658 urlCells.forEach(cell => {
659 // 避免重复处理
660 if (cell.querySelector('a')) return;
661
662 const urlText = cell.textContent.trim();
663
664 // 简单验证是否像 Git URL
665 if (urlText && (urlText.startsWith('http://') || urlText.startsWith('https://'))) {
666 const link = document.createElement('a');
667 link.href = urlText;
668 link.textContent = urlText;
669 link.target = '_blank'; // 新标签页打开
670 link.rel = 'noopener noreferrer'; // 安全最佳实践
671 link.style.color = '#0366d6'; // 模仿 Gitea 的蓝色链接
672 link.style.textDecoration = 'none';
673
674 // hover 效果(可选,更像原生链接)
675 link.addEventListener('mouseover', () => {
676 link.style.textDecoration = 'underline';
677 });
678 link.addEventListener('mouseout', () => {
679 link.style.textDecoration = 'none';
680 });
681
682 // 清空原文字并替换成链接
683 cell.innerHTML = '';
684 cell.appendChild(link);
685 }
686 });
687 }
688
689 // 初始化
690 function init() {
691 // 添加目标仓库列(适用于镜像列表页面)
692 if (document.querySelector('table.ui.table')) {
693 // 延迟执行以确保DOM完全加载
694 setTimeout(() => {
695 addTargetRepoColumn();
696 // 监听动态加载的行
697 const observer = new MutationObserver(() => {
698 addTargetRepoColumn();
699 });
700 observer.observe(document.body, { childList: true, subtree: true });
701 }, 1000);
702 }
703
704 // 创建填充按钮(适用于设置页面)
705 if (document.querySelector('table.ui.table')) {
706 createFillButton();
707 makePushMirrorUrlsClickable();
708 }
709
710 // 注册菜单命令
711 GM_registerMenuCommand('镜像仓库设置', createSettingsPanel);
712 }
713
714 // 页面加载完成后初始化
715 if (document.readyState === 'loading') {
716 document.addEventListener('DOMContentLoaded', init);
717 } else {
718 init();
719 }
720
721 // 监听页面导航(SPA应用)
722 let currentUrl = location.href;
723 new MutationObserver(() => {
724 if (location.href !== currentUrl) {
725 currentUrl = location.href;
726 setTimeout(init, 500);
727 }
728 }).observe(document, { subtree: true, childList: true });
729})();
730
gitlab.user.js 原始文件
1// ==UserScript==
2// @name GitLab 镜像仓库一键填充
3// @namespace ScriptCat Scripts
4// @version 0.3.3
5// @description 在GitLab仓库设置页面一键填充镜像仓库信息,并在镜像列表中添加目标仓库跳转链接
6// @author Jetsung Chan <[email protected]>
7// @match https://framagit.org/*/-/settings/repository*
8// @match https://gitlab.com/*/-/settings/repository*
9// @match https://framagit.org/*/project_activity
10// @grant GM_getValue
11// @grant GM_setValue
12// @grant GM_deleteValue
13// @grant GM_addStyle
14// @grant GM_registerMenuCommand
15// @grant GM_unregisterMenuCommand
16// @grant GM_notification
17// @grant GM_xmlhttpRequest
18// @connect openapi-rdc.aliyuncs.com
19// @connect codeup.aliyun.com
20// @connect api.github.com
21// @connect codeberg.org
22// @connect gitcode.com
23// @downloadURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitlab.user.js
24// @updateURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitlab.user.js
25// ==/UserScript==
26
27(function() {
28 'use strict';
29
30 // 平台配置
31 const platforms = {
32 github: {
33 name: 'GitHub',
34 url: 'https://github.com/',
35 tokenKey: 'github_token',
36 usernameKey: 'github_username',
37 domain: 'github.com'
38 },
39 gitcode: {
40 name: 'GitCode',
41 url: 'https://gitcode.com/',
42 tokenKey: 'gitcode_token',
43 usernameKey: 'gitcode_username',
44 domain: 'gitcode.com'
45 },
46 codeup: {
47 name: 'CodeUp',
48 url: 'https://codeup.aliyun.com/jetsung/',
49 tokenKey: 'codeup_token',
50 usernameKey: 'codeup_username',
51 domain: 'codeup.aliyun.com'
52 },
53 codeberg: {
54 name: 'Codeberg',
55 url: 'https://codeberg.org/',
56 tokenKey: 'codeberg_token',
57 usernameKey: 'codeberg_username',
58 domain: 'codeberg.org'
59 }
60 };
61
62 // 添加样式
63 GM_addStyle(`
64 .mirror-helper-container {
65 position: fixed;
66 top: 20px;
67 right: 20px;
68 z-index: 9999;
69 background: white;
70 border: 1px solid #ddd;
71 border-radius: 8px;
72 box-shadow: 0 4px 12px rgba(0,0,0,0.15);
73 padding: 20px;
74 width: 270px;
75 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
76 }
77
78 .mirror-helper-header {
79 font-size: 18px;
80 font-weight: 600;
81 margin-bottom: 15px;
82 color: #333;
83 border-bottom: 1px solid #eee;
84 padding-bottom: 10px;
85 }
86
87 .mirror-helper-section {
88 margin-bottom: 15px;
89 }
90
91 .mirror-helper-section h4 {
92 font-size: 14px;
93 margin: 0 0 8px 0;
94 color: #555;
95 }
96
97 .mirror-helper-input {
98 width: 100%;
99 padding: 8px 10px;
100 margin-bottom: 8px;
101 border: 1px solid #ddd;
102 border-radius: 4px;
103 font-size: 14px;
104 box-sizing: border-box;
105 }
106
107 .mirror-helper-button {
108 background: #1f75cb;
109 color: white;
110 border: none;
111 border-radius: 4px;
112 padding: 8px 16px;
113 font-size: 14px;
114 cursor: pointer;
115 margin-right: 8px;
116 margin-bottom: 8px;
117 transition: background 0.2s;
118 }
119
120 .mirror-helper-button:hover {
121 background: #1b5faa;
122 }
123
124 .mirror-helper-button.secondary {
125 background: #e6e6e6;
126 color: #333;
127 }
128
129 .mirror-helper-button.secondary:hover {
130 background: #d0d0d0;
131 }
132
133 .mirror-helper-select {
134 width: 100%;
135 padding: 8px 10px;
136 margin-bottom: 12px;
137 border: 1px solid #ddd;
138 border-radius: 4px;
139 font-size: 14px;
140 box-sizing: border-box;
141 }
142
143 .mirror-helper-footer {
144 margin-top: 15px;
145 padding-top: 10px;
146 border-top: 1px solid #eee;
147 font-size: 12px;
148 color: #777;
149 }
150
151 .mirror-helper-close {
152 position: absolute;
153 top: 10px;
154 right: 10px;
155 background: none;
156 border: none;
157 font-size: 18px;
158 cursor: pointer;
159 color: #999;
160 }
161
162 .mirror-helper-close:hover {
163 color: #333;
164 }
165
166 /* 目标仓库列样式 */
167 .target-repo-column {
168 max-width: 200px;
169 }
170
171 .target-repo-link {
172 color: #0366d6;
173 text-decoration: none;
174 font-weight: 500;
175 display: inline-block;
176 padding: 2px 4px;
177 border-radius: 3px;
178 transition: background-color 0.2s;
179 }
180
181 .target-repo-link:hover {
182 background-color: rgba(3, 102, 214, 0.1);
183 text-decoration: none;
184 }
185
186 .target-repo-link svg {
187 width: 14px;
188 height: 14px;
189 margin-right: 4px;
190 vertical-align: middle;
191 }
192
193 .no-target-link {
194 color: #666;
195 font-style: italic;
196 }
197
198 .create-repo-td { white-space: nowrap; }
199 .create-repo-btn {
200 background: #28a745; color: #fff; border: none; border-radius: 4px;
201 padding: 4px 10px; font-size: 12px; cursor: pointer; transition: all 0.2s;
202 }
203 .create-repo-btn:hover { background: #218838; }
204 .create-repo-btn:disabled { background: #6c757d; cursor: not-allowed; }
205 .create-repo-btn.processing { background: #fd7e14 !important; }
206 .create-repo-btn.success { background: #1e7e34 !important; }
207 .create-repo-btn.error { background: #dc3545 !important; }
208 `);
209
210 // 解析镜像URL获取目标仓库信息
211 function parseMirrorUrl(mirrorUrl) {
212 try {
213 let cleanUrl = mirrorUrl.replace(/^https?:\/\/[^@]+@/, 'https://')
214 .replace(/\.git$/, '');
215
216 for (const [key, platform] of Object.entries(platforms)) {
217 if (cleanUrl.includes(platform.domain)) {
218 return {
219 platform: platform.name,
220 url: cleanUrl,
221 displayText: '打开',
222 key: key
223 };
224 }
225 }
226 } catch (e) {
227 console.error('解析镜像URL失败:', mirrorUrl, e);
228 }
229 return null;
230 }
231
232 // 在表格中添加「打开」和「创建仓库」两列
233 function addTargetRepoColumn() {
234 const table = document.querySelector('table.gl-table');
235 if (!table) return;
236
237 const thead = table.querySelector('thead');
238 const tbody = table.querySelector('tbody');
239 if (!thead || !tbody) return;
240
241 // 防止重复添加
242 if (thead.querySelector('th[data-col="open"]')) return;
243
244 const firstTh = thead.querySelector('tr th:first-child');
245
246 // 1 「打开」列(保持你原来位置)
247 const openTh = document.createElement('th');
248 openTh.textContent = '目标仓库';
249 openTh.className = 'target-repo-column';
250 openTh.setAttribute('data-col', 'open');
251 firstTh.parentNode.insertBefore(openTh, firstTh.nextSibling);
252
253 // 2 「创建仓库」列(紧跟在打开后面)
254 const createTh = document.createElement('th');
255 createTh.textContent = '创建仓库';
256 createTh.setAttribute('data-col', 'create');
257 openTh.parentNode.insertBefore(createTh, openTh.nextSibling);
258
259 // 处理每一行
260 const rows = tbody.querySelectorAll('tr.rspec-mirrored-repository-row');
261 rows.forEach(row => {
262 const urlCell = row.querySelector('[data-testid="mirror-repository-url-content"]');
263 if (!urlCell) return;
264
265 const mirrorUrl = urlCell.textContent.trim();
266 const info = parseMirrorUrl(mirrorUrl);
267
268 // === 打开列(你原来的代码完全不动)===
269 const openTd = document.createElement('td');
270 openTd.className = 'target-repo-column';
271 if (info) {
272 const link = document.createElement('a');
273 link.href = info.url;
274 link.className = 'target-repo-link';
275 link.target = '_blank';
276 link.innerHTML = `
277 <svg viewBox="0 0 24 24" fill="currentColor">
278 <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
279 </svg>
280 ${info.displayText}
281 `;
282 openTd.appendChild(link);
283 } else {
284 openTd.innerHTML = '<span class="no-target-link">无法解析</span>';
285 }
286 const firstTd = row.querySelector('td:first-child');
287 firstTd.parentNode.insertBefore(openTd, firstTd.nextSibling);
288
289 // === 新增:创建仓库列 ===
290 const createTd = document.createElement('td');
291 createTd.className = 'create-repo-td';
292 if (info && info.key) {
293 const btn = document.createElement('button');
294 btn.className = 'create-repo-btn';
295 btn.textContent = '创建仓库';
296
297 btn.onclick = async () => {
298 const key = info.key;
299 const p = platforms[key];
300 const username = GM_getValue(p.usernameKey, '');
301 const token = GM_getValue(p.tokenKey, '');
302
303 if (!username || !token) {
304 alert(`${p.name} 未配置账号信息`);
305 return;
306 }
307
308 btn.disabled = true;
309 btn.textContent = '创建中...';
310 btn.classList.add('processing');
311
312 try {
313 // === 终极解析:支持多级子组 ===
314 const cleanUrl = info.url.replace(/\.git$/, '').replace(/\/+$/, '');
315 const parts = cleanUrl.split('/').slice(3); // 去掉 https://domain.com
316 if (parts.length < 2) throw new Error('无法解析仓库地址:路径太短');
317 const repoName = parts.pop(); // 最后一段:仓库名
318 const ownerPath = parts.join('/'); // 前面所有:完整 owner(支持多级)
319
320 if (!ownerPath || !repoName) throw new Error('无法解析 owner 或 repo');
321
322 let resp, data;
323
324 // === 统一创建接口 ===
325 if (key === 'github') {
326 resp = await fetch('https://api.github.com/user/repos', {
327 method: 'POST',
328 headers: {
329 'Authorization': `token ${token}`,
330 'Content-Type': 'application/json',
331 'Accept': 'application/vnd.github.v3+json'
332 },
333 body: JSON.stringify({ name: repoName, private: false, auto_init: false })
334 });
335 } else if (key === 'codeberg') {
336 resp = await fetch('https://codeberg.org/api/v1/user/repos', {
337 method: 'POST',
338 headers: {
339 'Authorization': `token ${token}`,
340 'Content-Type': 'application/json'
341 },
342 body: JSON.stringify({ name: repoName, private: false, auto_init: false })
343 });
344 } else if (key === 'gitcode') {
345 console.log(token)
346 resp = await fetch(`https://api.gitcode.com/api/v5/orgs/${encodeURIComponent(ownerPath)}/repos`, {
347 method: 'POST',
348 headers: {
349 'Authorization': `Bearer ${token}`,
350 'Content-Type': 'application/json'
351 },
352 body: JSON.stringify({
353 name: repoName,
354 path: repoName,
355 public: 1,
356 auto_init: false,
357 default_branch: 'main'
358 })
359 });
360 } else if (key === 'codeup') {
361 const domain = 'https://openapi-rdc.aliyuncs.com';
362 const referer = 'https://codeup.aliyun.com';
363
364 // Step 1: 获取组织列表
365 const orgs = await new Promise((res, rej) => GM_xmlhttpRequest({
366 method: 'GET', url: `${domain}/oapi/v1/platform/organizations?perPage=100`,
367 headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
368 onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`组织列表失败 ${r.status}`)),
369 onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
370 }));
371
372 // Step 2: 获取群组空间
373 const org = orgs.find(o => o.name.toLowerCase() === ownerPath.split('/')[0].toLowerCase());
374 if (!org) throw new Error(`未找到组织 ${ownerPath.split('/')[0]}`);
375
376 const namespaces = await new Promise((res, rej) => GM_xmlhttpRequest({
377 method: 'GET', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/namespaces`,
378 headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer },
379 onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`群组列表失败 ${r.status}`)),
380 onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时'))
381 }));
382
383 // Step 3: 匹配群组(webUrl / pathWithNamespace / nameWithNamespace)
384 const targetNs = namespaces.find(ns => {
385 const web = (ns.webUrl || '').replace(/https?:\/\/[^\/]+/, '');
386 const path = ns.pathWithNamespace || ns.nameWithNamespace || '';
387 return web.includes(ownerPath) || path.toLowerCase() === ownerPath.toLowerCase();
388 });
389
390 if (!targetNs) {
391 const paths = namespaces.map(n => n.pathWithNamespace || n.name).join(', ');
392 throw new Error(`未找到群组 ${ownerPath}\n可用群组:${paths || '无'}`);
393 }
394
395 console.log('匹配群组成功:', targetNs);
396 console.log(JSON.stringify({
397 name: repoName,
398 path: repoName,
399 namespaceId: targetNs.id,
400 readMeType: 'NONE'
401 }));
402
403 // Step 4: 正确创建仓库(使用 organizationId + namespaceId)
404 await new Promise((resolve, reject) => {
405 GM_xmlhttpRequest({
406 method: 'POST',
407 url: `${domain}/oapi/v1/codeup/organizations/${org.id}/repositories?createParentPath=true`,
408 headers: {
409 'x-yunxiao-token': token,
410 'Content-Type': 'application/json',
411 'Referer': referer,
412 'Origin': referer
413 },
414 data: JSON.stringify({
415 name: repoName,
416 path: repoName,
417 namespaceId: targetNs.id,
418 }),
419 onload: r => {
420 const data = r.responseText ? JSON.parse(r.responseText) : {};
421 if (r.status >= 200 && r.status < 300 || data.id || data.result?.id) {
422 console.log('CodeUp 仓库创建成功:', data);
423 resolve(data);
424 } else {
425 reject(new Error(data.message || data.error || `创建失败(${r.status}`));
426 }
427 },
428 onerror: () => reject(new Error('网络错误')),
429 ontimeout: () => reject(new Error('请求超时'))
430 });
431 });
432
433 // 模拟成功响应
434 resp = { ok: true, status: 200 };
435 data = { id: 'success' };
436 }
437
438 // === 统一成功判断 ===
439 if (
440 resp.status >= 200 && resp.status < 400 ||
441 data.id || data.full_name || data.html_url || data.name || data.path ||
442 (data.message && /already exists|已存在/i.test(data.message))
443 ) {
444 btn.textContent = '成功';
445 btn.classList.add('success');
446
447 // 自动填回 URL
448 const input = document.querySelector('#url, #project_remote_mirrors_attributes_0_url');
449 if (input) input.value = info.url;
450 } else {
451 throw new Error(data.message || data.error || `HTTP ${resp.status}`);
452 }
453
454 } catch (e) {
455 btn.textContent = '失败';
456 btn.classList.add('error');
457 console.error('创建仓库失败:', e);
458 alert(`${p.name} 创建失败:${e.message}`);
459 }
460
461 // 3秒后恢复
462 setTimeout(() => {
463 btn.disabled = false;
464 btn.textContent = '创建仓库';
465 btn.className = 'create-repo-btn';
466 }, 3000);
467 };
468
469 createTd.appendChild(btn);
470 } else {
471 createTd.textContent = '—';
472 }
473 // 插入到「打开」列的后面
474 openTd.parentNode.insertBefore(createTd, openTd.nextSibling);
475 });
476 }
477
478 // 创建设置面板
479 function createSettingsPanel() {
480 const panel = document.createElement('div');
481 panel.className = 'mirror-helper-container';
482 panel.id = 'mirror-settings-panel';
483
484 let html = `
485 <button class="mirror-helper-close" id="close-settings">×</button>
486 <div class="mirror-helper-header">镜像仓库设置</div>
487 `;
488
489 // 为每个平台创建输入框
490 for (const [key, platform] of Object.entries(platforms)) {
491 html += `
492 <div class="mirror-helper-section">
493 <h4>${platform.name}</h4>
494 <input type="text" class="mirror-helper-input" id="${key}-username"
495 placeholder="用户名" value="${GM_getValue(platform.usernameKey, '')}">
496 <input type="password" class="mirror-helper-input" id="${key}-token"
497 placeholder="Token" value="${GM_getValue(platform.tokenKey, '')}">
498 </div>
499 `;
500 }
501
502 html += `
503 <button class="mirror-helper-button" id="save-settings">保存设置</button>
504 <button class="mirror-helper-button secondary" id="clear-settings">清除设置</button>
505 <div class="mirror-helper-footer">设置将保存在浏览器本地</div>
506 `;
507
508 panel.innerHTML = html;
509 document.body.appendChild(panel);
510
511 // 添加事件监听
512 document.getElementById('close-settings').addEventListener('click', () => {
513 panel.remove();
514 });
515
516 document.getElementById('save-settings').addEventListener('click', () => {
517 for (const [key, platform] of Object.entries(platforms)) {
518 const username = document.getElementById(`${key}-username`).value;
519 const token = document.getElementById(`${key}-token`).value;
520
521 GM_setValue(platform.usernameKey, username);
522 GM_setValue(platform.tokenKey, token);
523 }
524
525 alert('设置已保存!');
526 panel.remove();
527 location.reload(); // 刷新页面以应用新设置
528 });
529
530 document.getElementById('clear-settings').addEventListener('click', () => {
531 if (confirm('确定要清除所有设置吗?')) {
532 for (const platform of Object.values(platforms)) {
533 GM_deleteValue(platform.usernameKey);
534 GM_deleteValue(platform.tokenKey);
535 }
536 alert('设置已清除!');
537 panel.remove();
538 location.reload();
539 }
540 });
541 }
542
543 // 创建填充按钮
544 function createFillButton() {
545 const container = document.createElement('div');
546 container.className = 'mirror-helper-container';
547 container.id = 'mirror-fill-container';
548
549 let html = `
550 <button class="mirror-helper-close" id="close-fill">×</button>
551 <div class="mirror-helper-header">镜像仓库填充</div>
552 <div class="mirror-helper-section">
553 <h4>选择平台</h4>
554 <select class="mirror-helper-select" id="platform-select">
555 `;
556
557 // 添加平台选项
558 for (const [key, platform] of Object.entries(platforms)) {
559 const username = GM_getValue(platform.usernameKey, '');
560 const token = GM_getValue(platform.tokenKey, '');
561 const status = (username && token) ? '✓' : '✗';
562 html += `<option value="${key}">${platform.name} ${status}</option>`;
563 }
564
565 html += `
566 </select>
567 </div>
568 <button class="mirror-helper-button" id="fill-mirror">填充镜像仓库</button>
569 <button class="mirror-helper-button secondary" id="open-settings">设置账号</button>
570 <div class="mirror-helper-footer">点击填充按钮自动填写表单</div>
571 `;
572
573 container.innerHTML = html;
574 document.body.appendChild(container);
575
576 // 添加事件监听
577 document.getElementById('close-fill').addEventListener('click', () => {
578 container.remove();
579 });
580
581 document.getElementById('open-settings').addEventListener('click', () => {
582 container.remove();
583 createSettingsPanel();
584 });
585
586 document.getElementById('fill-mirror').addEventListener('click', () => {
587 const selectedPlatform = document.getElementById('platform-select').value;
588 fillMirrorForm(selectedPlatform);
589 });
590 }
591
592 // 填充表单
593 function fillMirrorForm(platformKey) {
594 const platform = platforms[platformKey];
595 const username = GM_getValue(platform.usernameKey, '');
596 const token = GM_getValue(platform.tokenKey, '');
597
598 if (!username || !token) {
599 alert(`请先设置 ${platform.name} 的用户名和Token!`);
600 return;
601 }
602
603 // 获取当前仓库路径
604 let [, group, project] = window.location.pathname.split('/');
605 if (!group || !project) {
606 alert('无法获取仓库路径!');
607 return;
608 }
609
610 switch (group) {
611 case 'idev':
612 if (platformKey === 'github') {
613 group = 'idevsig'
614 }
615 break;
616
617 case 'tiny':
618 if (platformKey === 'github' || platformKey === 'gitcode') {
619 group = 'tinyzen'
620 }
621 break;
622 }
623
624 // 构建镜像URL
625 const mirrorUrl = `${platform.url}${group}/${project}.git`;
626
627 // 填充表单
628 document.getElementById('url').value = mirrorUrl;
629 document.getElementById('project_remote_mirrors_attributes_0_url').value = mirrorUrl;
630 document.getElementById('project_remote_mirrors_attributes_0_user').value = username;
631 document.getElementById('project_remote_mirrors_attributes_0_password').value = token;
632
633 // 勾选"仅镜像受保护的分支"
634 const protectedCheckbox = document.getElementById('only_protected_branches');
635 if (protectedCheckbox && !protectedCheckbox.checked) {
636 protectedCheckbox.click();
637 }
638
639 // 显示成功消息
640 const successMsg = document.createElement('div');
641 successMsg.style.cssText = `
642 position: fixed;
643 top: 20px;
644 left: 50%;
645 transform: translateX(-50%);
646 background: #4caf50;
647 color: white;
648 padding: 12px 24px;
649 border-radius: 4px;
650 box-shadow: 0 2px 8px rgba(0,0,0,0.2);
651 z-index: 10000;
652 font-size: 14px;
653 `;
654 successMsg.textContent = `已填充 ${platform.name} 镜像仓库信息!`;
655 document.body.appendChild(successMsg);
656
657 setTimeout(() => {
658 successMsg.remove();
659 }, 3000);
660 }
661
662 // 初始化
663 function init() {
664 // 添加目标仓库列(适用于镜像列表页面)
665 if (document.querySelector('.js-mirrors-table-body')) {
666 // 延迟执行以确保DOM完全加载
667 setTimeout(() => {
668 addTargetRepoColumn();
669 // 监听动态加载的行
670 const observer = new MutationObserver(() => {
671 addTargetRepoColumn();
672 });
673 observer.observe(document.body, { childList: true, subtree: true });
674 }, 1000);
675 }
676
677 // 创建填充按钮(适用于设置页面)
678 if (document.querySelector('.js-mirror-form')) {
679 createFillButton();
680 }
681
682 // 注册菜单命令
683 GM_registerMenuCommand('镜像仓库设置', createSettingsPanel);
684 }
685
686 // 页面加载完成后初始化
687 if (document.readyState === 'loading') {
688 document.addEventListener('DOMContentLoaded', init);
689 } else {
690 init();
691 }
692
693 // 监听页面导航(SPA应用)
694 let currentUrl = location.href;
695 new MutationObserver(() => {
696 if (location.href !== currentUrl) {
697 currentUrl = location.href;
698 setTimeout(init, 500);
699 }
700 }).observe(document, { subtree: true, childList: true });
701})();
702