@tmpxinchen/tmp1 1.0.56 → 1.0.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1088 @@
1
+ # issue-bot 对接公司 GitLab 8.16 改造说明
2
+
3
+ ## 0. 目标与边界
4
+
5
+ 目标是把当前本地 `ai-coding-pilot` 改成:
6
+
7
+ ```text
8
+ 本地 GitLab:issue / 队列 / AI 工作区 / 本地门禁
9
+ 公司 GitLab:真实代码 / AI 分支 / 正式 MR
10
+ ```
11
+
12
+ 并使用一个单向只读镜像,让本地 GitLab 也能看到公司代码和分支。
13
+
14
+ ```text
15
+ 公司 GitLab(代码主库)
16
+ ↓ 只读镜像
17
+ 本地 GitLab(issue + 公司代码镜像)
18
+ ↑
19
+ │ 本地 issue 触发
20
+ issue-bot
21
+ │ clone / push / API v3
22
+ ↓
23
+ 公司 GitLab(正式 MR)
24
+ ```
25
+
26
+ 本次涉及:
27
+
28
+ ```text
29
+ automation/issue_bot.py
30
+ automation/company-projects.example.json
31
+ scripts/mirror-company.sh
32
+ docker-compose.yml
33
+ .env
34
+ .env.example
35
+ scripts/gitlab-init.sh
36
+ scripts/selfcheck.sh
37
+ start.sh
38
+ README.md
39
+ automation/README.md
40
+ docs/自动化流程.md
41
+ ```
42
+
43
+ 本次不做:
44
+
45
+ ```text
46
+ 公司 GitLab issue 接入
47
+ 公司 GitLab webhook 配置
48
+ 公司 GitLab runner 管理
49
+ 公司 CI 变量修改
50
+ 双向同步
51
+ main 自动同步
52
+ 整仓 mirror 到公司
53
+ 本地正式 MR
54
+ 自动合并
55
+ ```
56
+
57
+ 前提已经确认:
58
+
59
+ ```text
60
+ 公司 GitLab 8.16.8 API v3 可访问
61
+ PAT 可用
62
+ 可以 push feature 分支
63
+ 可以创建 MR
64
+ ```
65
+
66
+ ---
67
+
68
+ ## 1. 给内部模型的操作要求
69
+
70
+ 内部代码已经分叉,**不要整文件覆盖 `automation/issue_bot.py`**。
71
+
72
+ 请按函数/代码块修改:
73
+
74
+ ```text
75
+ 1. 先备份
76
+ 2. 用 grep 找函数
77
+ 3. 只替换本文明确指出的函数/代码块
78
+ 4. 保留内部已有的标签名、路径、日志、变量名
79
+ 5. 不要删除内部已有的其他功能
80
+ 6. 改完执行 py_compile / bash -n / 端到端测试
81
+ ```
82
+
83
+ 建议先定位:
84
+
85
+ ```bash
86
+ grep -n '^def \|^class \|^PROJECT_PATH\|^TASKS\|^SEEN' automation/issue_bot.py
87
+ grep -n 'issue-bot:' docker-compose.yml
88
+ grep -n 'setup_automation\|ensure_mirror_token' scripts/gitlab-init.sh
89
+ ```
90
+
91
+ 如果内部已有 `COMPANY_*` 或类似变量,保持语义一致即可,不要求和本文变量名完全相同。
92
+
93
+ ---
94
+
95
+ # 第一部分:配置
96
+
97
+ ## 2. `.env` 和 `.env.example`
98
+
99
+ 增加:
100
+
101
+ ```bash
102
+ # 公司 GitLab 模式:配置后代码和 MR 都走公司 GitLab,本地只保留 issue
103
+ COMPANY_GITLAB_URL=http://gitlab.company.internal
104
+ COMPANY_TOKEN=
105
+ COMPANY_TOKEN_FILE=/run/automation/company-token
106
+ COMPANY_PROJECTS_FILE=/run/automation/company-projects.json
107
+
108
+ # 本地 GitLab 只读镜像 token
109
+ GITLAB_MIRROR_TOKEN_FILE=/run/automation/mirror-token
110
+ MIRROR_INTERVAL_SECONDS=300
111
+
112
+ # 门禁脚本:默认用公司仓库内的 scripts/check-convention.sh
113
+ # 公司仓库没有该脚本时,改成挂载进来的绝对路径
114
+ AUTOMATION_GATE_SCRIPT=scripts/check-convention.sh
115
+ ```
116
+
117
+ 说明:
118
+
119
+ - `COMPANY_TOKEN` 可以留空,优先用 `COMPANY_TOKEN_FILE`;
120
+ - `COMPANY_TOKEN_FILE` 和 `COMPANY_PROJECTS_FILE` 在容器内路径;
121
+ - 本地 issue 仍由 `AUTOMATION_PROJECTS` 和 `GITLAB_PROJECTS` 控制;
122
+ - `MIRROR_INTERVAL_SECONDS` 只影响镜像服务。
123
+
124
+ ## 3. 公司项目映射
125
+
126
+ 新增:
127
+
128
+ ```text
129
+ automation/company-projects.example.json
130
+ ```
131
+
132
+ 内容:
133
+
134
+ ```json
135
+ {
136
+ "local-ai/ai-tasks-payment": {
137
+ "project_id": 456,
138
+ "path": "company-group/payment-service",
139
+ "target_branch": "main"
140
+ }
141
+ }
142
+ ```
143
+
144
+ 部署时复制到:
145
+
146
+ ```bash
147
+ cp automation/company-projects.example.json \
148
+ data/automation/company-projects.json
149
+ ```
150
+
151
+ 规则:
152
+
153
+ ```text
154
+ key = 本地 GitLab 的 project 路径
155
+ project_id = 公司 GitLab 的 project ID(数字)
156
+ path = 公司 GitLab 的 project 路径
157
+ target_branch = 公司目标分支
158
+ clone_url = 可选,SSH clone 地址
159
+ ```
160
+
161
+ 本地 project、公司 project、group 名字都不需要一致。
162
+
163
+ 必须一致的是:
164
+
165
+ ```text
166
+ 本地实际 project 路径
167
+ =
168
+ AUTOMATION_PROJECTS 中的本地路径
169
+ =
170
+ company-projects.json 的 key
171
+ ```
172
+
173
+ 公司侧:
174
+
175
+ ```text
176
+ company-projects.json 的 path
177
+ =
178
+ 公司实际 project 路径
179
+ ```
180
+
181
+ `project_id` 必须是公司项目 ID,不是本地 `.gitlab-project-id`。
182
+
183
+ ## 4. token 文件
184
+
185
+ 公司 token:
186
+
187
+ ```bash
188
+ printf '%s' '公司PAT' > data/automation/company-token
189
+ chmod 600 data/automation/company-token
190
+ ```
191
+
192
+ 本地镜像 token:
193
+
194
+ ```bash
195
+ # 由 gitlab-init.sh 生成,见后文
196
+ data/automation/mirror-token
197
+ ```
198
+
199
+ 不要:
200
+
201
+ ```text
202
+ 把 token 写进 git remote URL
203
+ 把 token 提交到仓库
204
+ 把 token 打到日志
205
+ ```
206
+
207
+ ---
208
+
209
+ # 第二部分:`automation/issue_bot.py`
210
+
211
+ ## 5. 新增公司模式常量
212
+
213
+ 在 `GITLAB_URL`、`PROJECT_PATH` 附近增加:
214
+
215
+ ```python
216
+ COMPANY_GITLAB_URL = os.environ.get("COMPANY_GITLAB_URL", "").rstrip("/")
217
+ COMPANY_TOKEN_FILE = os.environ.get("COMPANY_TOKEN_FILE", "/run/automation/company-token")
218
+ COMPANY_PROJECTS_FILE = Path(os.environ.get(
219
+ "COMPANY_PROJECTS_FILE", "/run/automation/company-projects.json"))
220
+ COMPANY_PROJECTS = {}
221
+ ```
222
+
223
+ `Path` 已经由原文件导入,不要重复导入。
224
+
225
+ 增加门禁脚本常量:
226
+
227
+ ```python
228
+ # 门禁脚本:默认用仓库内的 scripts/check-convention.sh;
229
+ # 公司仓库没有该脚本时,挂载本地门禁并配成绝对路径。
230
+ GATE_SCRIPT = os.environ.get("AUTOMATION_GATE_SCRIPT", "scripts/check-convention.sh")
231
+ ```
232
+
233
+ ---
234
+
235
+ ## 6. 加载公司项目映射
236
+
237
+ 在 `log = logging.getLogger(...)` 后面增加:
238
+
239
+ ```python
240
+ def load_company_projects():
241
+ """加载本地 issue 到公司 GitLab 项目的映射。"""
242
+ global COMPANY_PROJECTS
243
+ COMPANY_PROJECTS = {}
244
+ if not COMPANY_PROJECTS_FILE.exists():
245
+ return
246
+ try:
247
+ raw = json.loads(COMPANY_PROJECTS_FILE.read_text(encoding="utf-8"))
248
+ except Exception as e:
249
+ log.error("读取公司 GitLab 项目映射失败 %s: %s", COMPANY_PROJECTS_FILE, e)
250
+ return
251
+ if not isinstance(raw, dict):
252
+ log.error("公司 GitLab 项目映射必须是 JSON 对象: %s", COMPANY_PROJECTS_FILE)
253
+ return
254
+ for local_project, cfg in raw.items():
255
+ if not isinstance(cfg, dict):
256
+ continue
257
+ project_id = cfg.get("project_id")
258
+ project_path = cfg.get("path")
259
+ if not isinstance(project_id, int) or not project_path:
260
+ log.error("公司 GitLab 项目映射缺少 project_id/path: %s", local_project)
261
+ continue
262
+ COMPANY_PROJECTS[local_project] = {
263
+ "project_id": project_id,
264
+ "path": project_path,
265
+ "target_branch": cfg.get("target_branch") or TARGET_BRANCH or "main",
266
+ "clone_url": cfg.get("clone_url") or "",
267
+ }
268
+ ```
269
+
270
+ 注意:
271
+
272
+ - `project_id` 必须是 int;
273
+ - 映射文件损坏时只记日志,不要杀掉进程;
274
+ - 真正执行任务时如果公司模式已开启但项目没有映射,要 fail closed,见 `company_for()`。
275
+
276
+ ---
277
+
278
+ ## 7. 公司 token / 项目路由 / API v3
279
+
280
+ 新增:
281
+
282
+ ```python
283
+ def company_token():
284
+ t = os.environ.get("COMPANY_TOKEN", "").strip()
285
+ if t:
286
+ return t
287
+ try:
288
+ return Path(COMPANY_TOKEN_FILE).read_text(encoding="utf-8").strip()
289
+ except OSError:
290
+ return ""
291
+
292
+
293
+ def company_for(project):
294
+ """公司 GitLab 模式:返回该项目配置;未配置公司地址时返回 None。"""
295
+ if not COMPANY_GITLAB_URL:
296
+ return None
297
+ cfg = COMPANY_PROJECTS.get(project)
298
+ if not cfg:
299
+ raise RuntimeError("公司 GitLab 模式未配置本地 project 映射: " + project)
300
+ if not company_token():
301
+ raise RuntimeError("公司 GitLab token 为空:检查 COMPANY_TOKEN 或 " + COMPANY_TOKEN_FILE)
302
+ return cfg
303
+
304
+
305
+ def company_clone_url(cfg):
306
+ if cfg.get("clone_url"):
307
+ return cfg["clone_url"]
308
+ u = urllib.parse.urlparse(COMPANY_GITLAB_URL)
309
+ tok = urllib.parse.quote(company_token(), safe="")
310
+ return "%s://oauth2:%s@%s/%s.git" % (u.scheme, tok, u.netloc, cfg["path"])
311
+
312
+
313
+ def company_api_raw(cfg, method, path, data=None, params=None, timeout=30):
314
+ t = company_token()
315
+ if not t:
316
+ raise RuntimeError("公司 GitLab token 为空:检查 COMPANY_TOKEN 或 " + COMPANY_TOKEN_FILE)
317
+ q = {"private_token": t}
318
+ if params:
319
+ q.update(params)
320
+ url = COMPANY_GITLAB_URL + "/api/v3/projects/%d%s" % (int(cfg["project_id"]), path)
321
+ url += "?" + urllib.parse.urlencode(q)
322
+ body = urllib.parse.urlencode(data).encode("utf-8") if data is not None else None
323
+ req = urllib.request.Request(url, data=body, method=method)
324
+ if body is not None:
325
+ req.add_header("Content-Type", "application/x-www-form-urlencoded")
326
+ try:
327
+ with urllib.request.urlopen(req, timeout=timeout) as r:
328
+ raw = r.read()
329
+ return json.loads(raw) if raw else {}
330
+ except urllib.error.HTTPError as e:
331
+ detail = e.read().decode("utf-8", "replace")[:500]
332
+ raise RuntimeError("Company GitLab API %s %s -> %s: %s" %
333
+ (method, path, e.code, mask(detail)))
334
+
335
+
336
+ def company_api(cfg, method, path, data=None, params=None, timeout=30):
337
+ return company_api_raw(cfg, method, path, data=data, params=params, timeout=timeout)
338
+
339
+
340
+ def company_note_mr(cfg, mr_iid, body):
341
+ try:
342
+ company_api(cfg, "POST", "/merge_requests/%d/notes" % mr_iid, {"body": body})
343
+ except Exception as e:
344
+ log.warning("评论公司 MR !%s 失败: %s", mr_iid, mask(e))
345
+ ```
346
+
347
+ 关键点:
348
+
349
+ ```text
350
+ 公司 API 固定 /api/v3,不要用 /api/v4
351
+ 认证参数用 private_token
352
+ POST 用 application/x-www-form-urlencoded
353
+ project_id 用公司 ID
354
+ ```
355
+
356
+ 如果公司 8.16 对 `private_token` 参数有限制,可以在 `company_api_raw()` 里改成已验证可用的认证方式,但不要把 token 打到日志。
357
+
358
+ ---
359
+
360
+ ## 8. 日志中屏蔽公司 token
361
+
362
+ 找到原 `mask()`:
363
+
364
+ ```python
365
+ def mask(text):
366
+ t = token()
367
+ if t:
368
+ return str(text).replace(t, "***")
369
+ return str(text)
370
+ ```
371
+
372
+ 改为:
373
+
374
+ ```python
375
+ def mask(text):
376
+ out = str(text)
377
+ for value in (token(), company_token()):
378
+ if value:
379
+ out = out.replace(value, "***")
380
+ return out
381
+ ```
382
+
383
+ ---
384
+
385
+ ## 9. 门禁脚本可配置
386
+
387
+ 原 `build_prompt()` 中:
388
+
389
+ ```text
390
+ 4. 完成后运行 ./scripts/check-convention.sh,确保通过;
391
+ ```
392
+
393
+ 改为:
394
+
395
+ ```text
396
+ 4. 完成后运行 %s,确保通过;
397
+ ```
398
+
399
+ 并把 `GATE_SCRIPT` 加入格式化参数。
400
+
401
+ 原 `_handle()` 中:
402
+
403
+ ```python
404
+ r = run(["bash", "scripts/check-convention.sh"], cwd=ws, timeout=300)
405
+ ```
406
+
407
+ 改为:
408
+
409
+ ```python
410
+ gate = GATE_SCRIPT
411
+ if not os.path.isabs(gate):
412
+ gate = str(ws / gate)
413
+ r = run(["bash", gate], cwd=ws, timeout=300)
414
+ ```
415
+
416
+ 公司仓库没有 `scripts/check-convention.sh` 时:
417
+
418
+ ```bash
419
+ AUTOMATION_GATE_SCRIPT=/opt/automation/check-convention.sh
420
+ ```
421
+
422
+ 并把脚本挂进 issue-bot 容器。
423
+
424
+ ---
425
+
426
+ ## 10. `_handle()`:公司模式和重复 MR 检查
427
+
428
+ 原 `_handle()` 在加载 state 后、发送“已接单”前,增加:
429
+
430
+ ```python
431
+ # 公司 GitLab 模式:代码从公司仓库拉,MR 也开到公司;本地 GitLab 只保留 issue。
432
+ company = company_for(project)
433
+ company_mode = company is not None
434
+
435
+ if not company_mode:
436
+ # 同一 issue 已有打开的本地 MR 时不再重复开
437
+ for mr in api_get(project, "/merge_requests", state="opened", per_page=100):
438
+ if mr.get("source_branch", "").startswith("ai/issue-%d-" % iid) or \
439
+ ("Closes #%d" % iid) in (mr.get("description") or ""):
440
+ note_issue(project, iid, "已有打开的 MR:%s,跳过重复处理。" % mr.get("web_url", ""))
441
+ set_labels_safe(project, iid, add=[DONE_LABEL], remove=[PROCESSING_LABEL])
442
+ return
443
+ else:
444
+ # 公司模式下必须查公司 MR,避免重启/重复触发后重复开 MR
445
+ try:
446
+ open_mrs = company_api(company, "GET", "/merge_requests",
447
+ params={"state": "opened"})
448
+ except Exception as e:
449
+ raise RuntimeError("查询公司 MR 失败,已停止以避免重复开 MR: " + mask(e))
450
+ for mr in open_mrs:
451
+ if mr.get("source_branch", "").startswith("ai/issue-%d-" % iid):
452
+ note_issue(project, iid, "已有打开的公司 MR:%s,跳过重复处理。"
453
+ % mr.get("web_url", ""))
454
+ set_labels_safe(project, iid, add=[DONE_LABEL], remove=[PROCESSING_LABEL])
455
+ return
456
+ ```
457
+
458
+ 注意:
459
+
460
+ - 公司模式必须查公司 MR,不能查本地 MR;
461
+ - 查询失败时停止,不要盲目重复创建;
462
+ - 重复时本地 issue 标 `ai-done`,不重新推送。
463
+
464
+ ---
465
+
466
+ ## 11. `_handle()`:clone 来源
467
+
468
+ 原代码:
469
+
470
+ ```python
471
+ project_info = api_get(project, "")
472
+ target = TARGET_BRANCH or project_info.get("default_branch") or "main"
473
+ ws.parent.mkdir(parents=True, exist_ok=True)
474
+ log.info("clone %s 到 %s", project, ws)
475
+ r = run(["git", "clone", "--depth", "1", "--branch", target, clone_url(project), str(ws)],
476
+ cwd="/tmp", timeout=300, log_cmd=False)
477
+ ```
478
+
479
+ 改为:
480
+
481
+ ```python
482
+ if company_mode:
483
+ target = company["target_branch"]
484
+ source_url = company_clone_url(company)
485
+ log.info("使用公司 GitLab 仓库: %s", company["path"])
486
+ else:
487
+ project_info = api_get(project, "")
488
+ target = TARGET_BRANCH or project_info.get("default_branch") or "main"
489
+ source_url = clone_url(project)
490
+ ws.parent.mkdir(parents=True, exist_ok=True)
491
+ log.info("clone %s 到 %s", project, ws)
492
+ r = run(["git", "clone", "--depth", "1", "--branch", target, source_url, str(ws)],
493
+ cwd="/tmp", timeout=300, log_cmd=False)
494
+ ```
495
+
496
+ 这样公司模式会直接从公司仓库 clone,不再碰本地仓库代码。
497
+
498
+ ---
499
+
500
+ ## 12. `_handle()`:推送和开 MR
501
+
502
+ 原代码在 `git push` 之后,会创建本地 MR 并等待本地 pipeline。
503
+
504
+ 公司模式必须改成:
505
+
506
+ ```text
507
+ push 到公司 origin
508
+ → 调公司 API v3 创建 MR
509
+ → 本地 issue 评论公司 MR 链接
510
+ → 本地 issue 标 ai-done
511
+ → 不创建本地 MR
512
+ → 不等待本地 pipeline
513
+ ```
514
+
515
+ 在 `git push` 成功后,先提取 assumptions:
516
+
517
+ ```python
518
+ assumptions = extract_block(output, "ASSUMPTIONS", "END_ASSUMPTIONS")
519
+ ```
520
+
521
+ 然后插入公司模式分支:
522
+
523
+ ```python
524
+ if company_mode:
525
+ desc = "由 AI 自动化生成,关联本地任务 #%d。" % iid
526
+ if assumptions:
527
+ desc += "\n\n### AI 假设与待确认点\n\n" + assumptions[:4000]
528
+ mr = company_api(company, "POST", "/merge_requests", {
529
+ "source_branch": branch,
530
+ "target_branch": target,
531
+ "title": "feat(ai): %s (#%d)" % (title, iid),
532
+ "description": desc,
533
+ })
534
+ mr_iid = mr.get("iid")
535
+ mr_url = mr.get("web_url", "")
536
+ clear_state(project, iid)
537
+ set_labels_safe(project, iid, add=[DONE_LABEL],
538
+ remove=[PROCESSING_LABEL, TRIGGER_LABEL, RESUME_LABEL,
539
+ WAIT_LABEL, FAILED_LABEL])
540
+ note_issue(project, iid, "✅ 公司 MR 已创建:%s" % mr_url)
541
+ if mr_iid:
542
+ company_note_mr(company, mr_iid,
543
+ "🤖 本 MR 由本地 issue #%d 自动生成。" % iid)
544
+ log.info("%s issue #%s 完成,公司 MR=%s", project, iid, mr_url)
545
+ return
546
+ ```
547
+
548
+ 本地模式的原有逻辑保留在 `return` 之后:
549
+
550
+ ```python
551
+ # 以下只用于未配置公司模式的本地模式
552
+ try:
553
+ api(project, "POST", "/labels", {"name": "path::ai", "color": "#428bca"})
554
+ except Exception:
555
+ pass
556
+ ...
557
+ ```
558
+
559
+ 注意:
560
+
561
+ - 公司 MR 描述不要写 `Closes #iid`,因为公司 issue 编号和本地不同;
562
+ - 公司模式不调用 `wait_pipeline()`;
563
+ - 公司 CI 由公司 GitLab 自己执行。
564
+
565
+ ---
566
+
567
+ ## 13. 启动恢复
568
+
569
+ 原 `recover_stale_processing()` 只查本地 MR:
570
+
571
+ ```python
572
+ open_mrs = api_get(project, "/merge_requests", state="opened", per_page=100)
573
+ ```
574
+
575
+ 改为:
576
+
577
+ ```python
578
+ try:
579
+ company = company_for(project)
580
+ except Exception as e:
581
+ log.warning("恢复 %s 时检查公司 GitLab 配置失败: %s", project, mask(e))
582
+ return
583
+ try:
584
+ if company:
585
+ open_mrs = company_api(company, "GET", "/merge_requests",
586
+ params={"state": "opened"})
587
+ else:
588
+ open_mrs = api_get(project, "/merge_requests", state="opened", per_page=100)
589
+ except Exception as e:
590
+ log.warning("查询 %s 打开 MR 失败,跳过启动恢复以避免误标: %s", project, mask(e))
591
+ return
592
+ ```
593
+
594
+ 其余判断保持不变:
595
+
596
+ ```python
597
+ has_mr = any(
598
+ m.get("source_branch", "").startswith("ai/issue-%d-" % iid)
599
+ or ("Closes #%d" % iid) in (m.get("description") or "")
600
+ for m in open_mrs
601
+ )
602
+ ```
603
+
604
+ 这样:
605
+
606
+ ```text
607
+ 公司模式:查看公司 MR
608
+ 本地模式:查看本地 MR
609
+ ```
610
+
611
+ ## 14. `main()` 加载公司配置
612
+
613
+ 在 `main()` 开始处增加:
614
+
615
+ ```python
616
+ load_company_projects()
617
+ ```
618
+
619
+ 并增加日志:
620
+
621
+ ```python
622
+ if COMPANY_GITLAB_URL:
623
+ if not company_token():
624
+ log.error("COMPANY_GITLAB_URL 已配置,但公司 GitLab token 为空")
625
+ log.info("公司 GitLab 模式:%s,映射 %d 个本地 project",
626
+ COMPANY_GITLAB_URL, len(COMPANY_PROJECTS))
627
+ ```
628
+
629
+ `/healthz` 可以增加:
630
+
631
+ ```python
632
+ "company": bool(COMPANY_GITLAB_URL),
633
+ "company_projects": sorted(COMPANY_PROJECTS)
634
+ ```
635
+
636
+ 方便验证配置是否加载。
637
+
638
+ ---
639
+
640
+ # 第三部分:本地只读镜像
641
+
642
+ ## 15. 新增 `scripts/mirror-company.sh`
643
+
644
+ 新增完整文件:
645
+
646
+ ```bash
647
+ #!/usr/bin/env bash
648
+ # ============================================================================
649
+ # 公司 GitLab → 本地 GitLab 单向只读镜像
650
+ #
651
+ # 只同步公司仓库的分支和 tag 到本地同名 project。
652
+ # 本地 project 只用于查看代码和 issue,不允许在本地提交/开正式 MR。
653
+ #
654
+ # 用法:
655
+ # ./scripts/mirror-company.sh --once
656
+ # ./scripts/mirror-company.sh --loop
657
+ # ============================================================================
658
+ set -euo pipefail
659
+ umask 077
660
+
661
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
662
+ cd "$ROOT_DIR"
663
+
664
+ COMPANY_GITLAB_URL=${COMPANY_GITLAB_URL:-}
665
+ COMPANY_TOKEN_FILE=${COMPANY_TOKEN_FILE:-/run/automation/company-token}
666
+ COMPANY_PROJECTS_FILE=${COMPANY_PROJECTS_FILE:-/run/automation/company-projects.json}
667
+ LOCAL_GITLAB_URL=${LOCAL_GITLAB_URL:-${GITLAB_URL:-http://gitlab.local:8929}}
668
+ LOCAL_TOKEN_FILE=${GITLAB_MIRROR_TOKEN_FILE:-/run/automation/mirror-token}
669
+ MIRROR_CACHE_DIR=${MIRROR_CACHE_DIR:-data/mirror}
670
+ MIRROR_INTERVAL_SECONDS=${MIRROR_INTERVAL_SECONDS:-300}
671
+
672
+ log() { echo "[$(date '+%F %T')] $*"; }
673
+
674
+ read_secret() {
675
+ local file=$1
676
+ [ -s "$file" ] && tr -d '\r\n' < "$file" || true
677
+ }
678
+
679
+ repo_url() {
680
+ local base=${1%/} path=$2
681
+ printf '%s/%s.git\n' "$base" "$path"
682
+ }
683
+
684
+ write_askpass() {
685
+ local script=$1 token_file=$2
686
+ cat > "$script" <<-ASKPASS
687
+ #!/bin/sh
688
+ case "$1" in
689
+ *Username*) printf '%s\n' oauth2 ;;
690
+ *Password*) cat "$token_file" ;;
691
+ *) exit 1 ;;
692
+ esac
693
+ ASKPASS
694
+ chmod 700 "$script"
695
+ }
696
+
697
+ sync_one() {
698
+ local local_project=$1 company_path=$2
699
+ local cache company_url local_url hash
700
+ hash=$(printf '%s' "$local_project" | sha256sum | cut -c1-16)
701
+ cache="$MIRROR_CACHE_DIR/$hash.git"
702
+ company_url=$(repo_url "$COMPANY_GITLAB_URL" "$company_path")
703
+ local_url=$(repo_url "$LOCAL_GITLAB_URL" "$local_project")
704
+
705
+ mkdir -p "$MIRROR_CACHE_DIR"
706
+ chmod 700 "$MIRROR_CACHE_DIR" 2>/dev/null || true
707
+ if [ ! -d "$cache" ]; then
708
+ log "初始化镜像 $local_project ← $company_path"
709
+ GIT_ASKPASS="$COMPANY_ASKPASS" GIT_ASKPASS_REQUIRE=force GIT_TERMINAL_PROMPT=0 \
710
+ git clone --mirror "$company_url" "$cache" >/dev/null 2>&1 || {
711
+ log "ERROR clone 公司仓库失败:$company_path"
712
+ return 1
713
+ }
714
+ else
715
+ git -C "$cache" remote set-url origin "$company_url" >/dev/null 2>&1 || return 1
716
+ # clone --mirror 的 fetch refspec 是 +refs/*:refs/*,--prune 会删除公司已删的分支/tag
717
+ GIT_ASKPASS="$COMPANY_ASKPASS" GIT_ASKPASS_REQUIRE=force GIT_TERMINAL_PROMPT=0 \
718
+ git -C "$cache" fetch --prune origin >/dev/null 2>&1 || {
719
+ log "ERROR fetch 公司仓库失败:$company_path"
720
+ return 1
721
+ }
722
+ fi
723
+ chmod 700 "$cache" 2>/dev/null || true
724
+
725
+ GIT_ASKPASS="$LOCAL_ASKPASS" GIT_ASKPASS_REQUIRE=force GIT_TERMINAL_PROMPT=0 \
726
+ git -C "$cache" push --mirror "$local_url" >/dev/null 2>&1 || {
727
+ log "ERROR push 本地镜像失败:$local_project"
728
+ return 1
729
+ }
730
+ log "OK $local_project ← $company_path"
731
+ }
732
+
733
+ sync_all() {
734
+ if [ -z "$COMPANY_GITLAB_URL" ]; then
735
+ log "COMPANY_GITLAB_URL 为空,跳过镜像"
736
+ return 0
737
+ fi
738
+ if [ ! -s "$COMPANY_TOKEN_FILE" ] && [ -z "${COMPANY_TOKEN:-}" ]; then
739
+ log "公司 token 为空,跳过镜像"
740
+ return 1
741
+ fi
742
+ if [ ! -s "$LOCAL_TOKEN_FILE" ] && [ -z "${GITLAB_MIRROR_TOKEN:-}" ]; then
743
+ log "本地镜像 token 为空:$LOCAL_TOKEN_FILE"
744
+ return 1
745
+ fi
746
+
747
+ COMPANY_TOKEN=${COMPANY_TOKEN:-$(read_secret "$COMPANY_TOKEN_FILE")}
748
+ LOCAL_TOKEN=${GITLAB_MIRROR_TOKEN:-$(read_secret "$LOCAL_TOKEN_FILE")}
749
+
750
+ ASKPASS_DIR=$(mktemp -d)
751
+ trap 'rm -rf "$ASKPASS_DIR"' RETURN
752
+ printf '%s' "$COMPANY_TOKEN" > "$ASKPASS_DIR/company.token"
753
+ printf '%s' "$LOCAL_TOKEN" > "$ASKPASS_DIR/local.token"
754
+ chmod 700 "$ASKPASS_DIR"; chmod 600 "$ASKPASS_DIR"/*.token
755
+ COMPANY_ASKPASS="$ASKPASS_DIR/company-askpass"
756
+ LOCAL_ASKPASS="$ASKPASS_DIR/local-askpass"
757
+ write_askpass "$COMPANY_ASKPASS" "$ASKPASS_DIR/company.token"
758
+ write_askpass "$LOCAL_ASKPASS" "$ASKPASS_DIR/local.token"
759
+
760
+ local lines
761
+ lines=$(python3 - "$COMPANY_PROJECTS_FILE" <<'PY'
762
+ import json, sys
763
+ try:
764
+ data = json.load(open(sys.argv[1], encoding="utf-8"))
765
+ except Exception as e:
766
+ print("ERROR\t%s" % e)
767
+ raise SystemExit(1)
768
+ for local_project, cfg in data.items():
769
+ if isinstance(cfg, dict) and cfg.get("path"):
770
+ print("%s\t%s" % (local_project, cfg["path"]))
771
+ PY
772
+ ) || { log "读取 $COMPANY_PROJECTS_FILE 失败"; return 1; }
773
+
774
+ local failed=0
775
+ while IFS=$'\t' read -r local_project company_path; do
776
+ [ -n "$local_project" ] || continue
777
+ sync_one "$local_project" "$company_path" || failed=1
778
+ done <<< "$lines"
779
+ return "$failed"
780
+ }
781
+
782
+ case "${1:-}" in
783
+ --once)
784
+ sync_all
785
+ ;;
786
+ --loop|"")
787
+ while true; do
788
+ sync_all || true
789
+ sleep "$MIRROR_INTERVAL_SECONDS"
790
+ done
791
+ ;;
792
+ *)
793
+ echo "用法: $0 [--once|--loop]" >&2
794
+ exit 2
795
+ ;;
796
+ esac
797
+ ```
798
+
799
+ 关键点:
800
+
801
+ ```text
802
+ 公司 → 本地 单向
803
+ clone --mirror + fetch --prune + push --mirror
804
+ token 用 GIT_ASKPASS 临时提供,不写进 git remote
805
+ 缓存目录 700,token 文件 600
806
+ 公司删分支,本地也删
807
+ ```
808
+
809
+ 本地 project 必须提前创建,否则 push 镜像会失败。
810
+
811
+ 本地镜像 project 建议:
812
+
813
+ ```text
814
+ 关闭 CI/CD
815
+ 不要在本地提交
816
+ 不要在本地创建正式 MR
817
+ ```
818
+
819
+ ---
820
+
821
+ ## 16. `docker-compose.yml` 增加 company-mirror
822
+
823
+ 在 issue-bot 服务后面增加:
824
+
825
+ ```yaml
826
+ company-mirror:
827
+ image: ${AUTOMATION_BASE_IMAGE:-ai-coding-python:2026.10}
828
+ container_name: ai-coding-pilot-company-mirror
829
+ profiles: ["automation"]
830
+ restart: unless-stopped
831
+ depends_on:
832
+ gitlab:
833
+ condition: service_started
834
+ networks:
835
+ - internal
836
+ extra_hosts:
837
+ - "gitlab.local:host-gateway"
838
+ environment:
839
+ GITLAB_URL: http://${GITLAB_HOST:-gitlab.local}:${GITLAB_PORT:-8929}
840
+ GITLAB_MIRROR_TOKEN_FILE: /run/automation/mirror-token
841
+ GITLAB_MIRROR_TOKEN: ${GITLAB_MIRROR_TOKEN:-}
842
+ COMPANY_GITLAB_URL: ${COMPANY_GITLAB_URL:-}
843
+ COMPANY_TOKEN: ${COMPANY_TOKEN:-}
844
+ COMPANY_TOKEN_FILE: /run/automation/company-token
845
+ COMPANY_PROJECTS_FILE: /run/automation/company-projects.json
846
+ MIRROR_CACHE_DIR: /mirror
847
+ MIRROR_INTERVAL_SECONDS: ${MIRROR_INTERVAL_SECONDS:-300}
848
+ HOME: /root
849
+ volumes:
850
+ - ./scripts/mirror-company.sh:/usr/local/bin/mirror-company.sh:ro
851
+ - ./data/automation:/run/automation:ro
852
+ - ./data/mirror:/mirror
853
+ entrypoint: ["bash", "/usr/local/bin/mirror-company.sh", "--loop"]
854
+ ```
855
+
856
+ 如果内部 compose 没有 `AUTOMATION_BASE_IMAGE`,就用实际包含 `git/bash/python3/sha256sum` 的镜像。不要使用没有 `git` 的纯 Python 镜像。
857
+
858
+ 同时 issue-bot 环境变量增加:
859
+
860
+ ```yaml
861
+ COMPANY_GITLAB_URL: ${COMPANY_GITLAB_URL:-}
862
+ COMPANY_TOKEN: ${COMPANY_TOKEN:-}
863
+ COMPANY_TOKEN_FILE: /run/automation/company-token
864
+ COMPANY_PROJECTS_FILE: /run/automation/company-projects.json
865
+ AUTOMATION_GATE_SCRIPT: ${AUTOMATION_GATE_SCRIPT:-scripts/check-convention.sh}
866
+ ```
867
+
868
+ ---
869
+
870
+ ## 17. `scripts/gitlab-init.sh` 生成本地镜像 token
871
+
872
+ 增加函数:
873
+
874
+ ```bash
875
+ ensure_mirror_token() {
876
+ mkdir -p data/automation
877
+ if [ -n "${GITLAB_MIRROR_TOKEN:-}" ]; then
878
+ info "使用 .env 里的 GITLAB_MIRROR_TOKEN"
879
+ return 0
880
+ fi
881
+ if [ -s .gitlab-pat ] && [ ! -s data/automation/mirror-token ]; then
882
+ cp .gitlab-pat data/automation/mirror-token
883
+ chmod 600 data/automation/mirror-token
884
+ info "本地 GitLab 镜像 token 已写入 data/automation/mirror-token"
885
+ elif [ -s data/automation/mirror-token ]; then
886
+ info "复用已有 data/automation/mirror-token"
887
+ fi
888
+ }
889
+ ```
890
+
891
+ 调用位置:
892
+
893
+ ```bash
894
+ start_stack
895
+ mint_pat
896
+ ensure_mirror_token
897
+ create_project
898
+ ```
899
+
900
+ 生产环境建议换成:
901
+
902
+ ```text
903
+ 只对镜像 project 有 write_repository 权限的专用 token
904
+ ```
905
+
906
+ 不要长期使用 root PAT。
907
+
908
+ ---
909
+
910
+ ## 18. `start.sh` 自动启动镜像
911
+
912
+ 在启动 issue-bot 后增加:
913
+
914
+ ```bash
915
+ if grep -qE '^COMPANY_GITLAB_URL=.' .env; then
916
+ echo "启动公司 GitLab 只读镜像 ..."
917
+ docker compose --profile automation up -d company-mirror \
918
+ || { echo -e "${RED}✘ company-mirror 启动失败,检查 automation/README.md${NC}"; exit 1; }
919
+ fi
920
+ ```
921
+
922
+ ---
923
+
924
+ ## 19. `scripts/selfcheck.sh`
925
+
926
+ ### 19.1 group webhook 路径编码
927
+
928
+ 原来自检用 group path 直接拼 URL,子组会失败。改成:
929
+
930
+ ```bash
931
+ local hooks hook_ok group_path group_enc
932
+ group_path=$(grep -E '^AUTOMATION_GROUP=' .env 2>/dev/null | cut -d= -f2)
933
+ group_path=${group_path:-observability}
934
+ group_enc=$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$group_path")
935
+ hooks=$(curl -fsS -m 10 --header "PRIVATE-TOKEN: $pat" \
936
+ "${GITLAB_URL}/api/v4/groups/${group_enc}/hooks" 2>/dev/null || echo '[]')
937
+ ```
938
+
939
+ 保留原来的项目级 webhook 回退逻辑。
940
+
941
+ ### 19.2 检查 company-mirror
942
+
943
+ 在 automation 检查块中增加:
944
+
945
+ ```bash
946
+ if grep -qE '^COMPANY_GITLAB_URL=.' .env 2>/dev/null; then
947
+ if docker ps --format '{{.Names}}' | grep -q '^ai-coding-pilot-company-mirror$'; then
948
+ ok "company-mirror 容器在跑"
949
+ else
950
+ bad "COMPANY_GITLAB_URL 已配置,但 company-mirror 容器没跑"
951
+ fi
952
+ fi
953
+ ```
954
+
955
+ ---
956
+
957
+ # 第四部分:测试与验收
958
+
959
+ ## 20. 语法检查
960
+
961
+ ```bash
962
+ python3 -m py_compile automation/issue_bot.py
963
+ bash -n scripts/mirror-company.sh
964
+ bash -n scripts/gitlab-init.sh
965
+ bash -n scripts/selfcheck.sh
966
+ bash -n start.sh
967
+ docker compose --profile automation config >/dev/null
968
+ ```
969
+
970
+ ## 21. 公司 API v3 验证
971
+
972
+ 先在公司内网验证:
973
+
974
+ ```bash
975
+ curl -i -sS -G "$COMPANY_GITLAB_URL/api/v3/user" \
976
+ --data-urlencode "private_token=$COMPANY_TOKEN"
977
+ ```
978
+
979
+ 以及:
980
+
981
+ ```bash
982
+ curl -i -sS -G \
983
+ "$COMPANY_GITLAB_URL/api/v3/projects/$COMPANY_PROJECT_ID" \
984
+ --data-urlencode "private_token=$COMPANY_TOKEN"
985
+ ```
986
+
987
+ ## 22. 公司模式端到端
988
+
989
+ 1. 本地建 project;
990
+ 2. `.env` 配 `AUTOMATION_PROJECTS`;
991
+ 3. `company-projects.json` 配映射;
992
+ 4. 放公司 token;
993
+ 5. 启动 issue-bot;
994
+ 6. 本地 issue 打 `ai-fix`;
995
+ 7. 预期:
996
+
997
+ ```text
998
+ 本地 issue:ai-processing
999
+ 公司 GitLab:出现 ai/issue-xxx 分支
1000
+ 公司 GitLab:出现正式 MR
1001
+ 本地 issue:评论公司 MR 链接
1002
+ 本地 issue:ai-done
1003
+ 本地 GitLab:不出现新 MR
1004
+ ```
1005
+
1006
+ ## 23. 镜像验收
1007
+
1008
+ ```bash
1009
+ docker compose --profile automation up -d company-mirror
1010
+ docker logs -f ai-coding-pilot-company-mirror
1011
+ ```
1012
+
1013
+ 应看到:
1014
+
1015
+ ```text
1016
+ OK local-ai/ai-tasks-payment ← company-group/payment-service
1017
+ ```
1018
+
1019
+ 本地 project 的 Code / Branches 应出现公司分支。
1020
+
1021
+ 测试分支新增和删除:
1022
+
1023
+ ```text
1024
+ 公司新增 feature 分支 → 本地出现
1025
+ 公司删除 feature 分支 → 本地删除
1026
+ ```
1027
+
1028
+ ## 24. 失败场景
1029
+
1030
+ 必须验证:
1031
+
1032
+ ```text
1033
+ 公司 project_id 写错 → 不 push、不开 MR、ai-failed
1034
+ 公司 path 写错 → clone 失败、ai-failed
1035
+ target_branch 不存在 → clone/MR 失败、ai-failed
1036
+ token 无权限 → 401/403、ai-failed
1037
+ 重复打 ai-fix → 已有公司 MR 时不重复开
1038
+ 处理中重启 → 有公司 MR 恢复 ai-done
1039
+ 门禁失败 → 不 push、ai-failed
1040
+ ```
1041
+
1042
+ ---
1043
+
1044
+ # 第五部分:安全与限制
1045
+
1046
+ ## 25. 安全要求
1047
+
1048
+ ```text
1049
+ 公司 token 只放文件,600 权限
1050
+ 本地镜像 token 只放文件,600 权限
1051
+ 不要把 token 写进 git remote
1052
+ 不要把 token 打进日志
1053
+ 本地镜像 project 只读
1054
+ 本地镜像 project 关闭 CI/CD
1055
+ 不自动合并
1056
+ 不 force push main
1057
+ 不双向同步
1058
+ ```
1059
+
1060
+ ## 26. 当前限制
1061
+
1062
+ ```text
1063
+ 只支持同 project 的 source_branch → target_branch
1064
+ fork MR 需要额外适配 source_project_id
1065
+ 公司 GitLab 8.16 API 的 MR 字段需以实测为准
1066
+ 镜像默认只支持 HTTP(S) clone;SSH 需挂私钥
1067
+ 镜像默认 300 秒同步一次,不是实时
1068
+ 镜像会 force update,不能用于本地开发仓库
1069
+ issue-bot 配置启动时加载,新增映射后需重启
1070
+ 公司 issue 不接入
1071
+ 公司 webhook / runner / CI 配置不修改
1072
+ ```
1073
+
1074
+ ## 27. 完成标准
1075
+
1076
+ ```text
1077
+ [ ] /api/v3 验证通过
1078
+ [ ] 公司模式 healthz 显示 company=true
1079
+ [ ] 本地 issue 能触发公司仓库修改
1080
+ [ ] 公司只出现一个正式 MR
1081
+ [ ] 本地不出现正式 MR
1082
+ [ ] 本地 issue 收到公司 MR 链接
1083
+ [ ] 公司分支能镜像回本地
1084
+ [ ] 重复触发不会重复开 MR
1085
+ [ ] 重启恢复能识别公司 MR
1086
+ [ ] 门禁失败不会 push
1087
+ [ ] 本地镜像 project 只读/关闭 CI
1088
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmpxinchen/tmp1",
3
- "version": "1.0.56",
3
+ "version": "1.0.58",
4
4
  "description": "gitlab-ce-18.11.12.tar transfer chunk 44/44",
5
5
  "license": "ISC",
6
6
  "author": "xinchen12"