Skip to content

Commit 5369a85

Browse files
committed
feat(git): 支援以任務環境變數提供 PAT
新增依任務名稱推導的 PAT 環境變數,讓容器內 HTTPS remote push 可透過 GIT_ASKPASS 完成驗證,並讓手動與自動 push 共用同一套流程。 同步補上 Docker Compose 範例、README 說明與 Git 操作測試。
1 parent 12e23aa commit 5369a85

5 files changed

Lines changed: 122 additions & 6 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ Open `http://127.0.0.1:8080`.
3636
- Set `output_subdir` to choose where generated Markdown lives under the target root.
3737
- Each target repo keeps `.office-docs-sync-state.json` at the repo root with per-task source file hashes and output mappings.
3838
- The app still uses filesystem events for low-latency sync, but every task also runs a periodic hash scan to catch missed updates and deletes.
39-
- Auto push uses the system git credential or SSH setup already present on the machine.
39+
- Auto push uses the system git credential or SSH setup already present on the machine by default.
40+
- For HTTPS remotes in containers, set a task-scoped PAT environment variable named from the task name plus `_key`. Non-word characters in the task name become `_`; for example task `Docs Sync` reads `Docs_Sync_key` (uppercase `DOCS_SYNC_KEY` is also accepted). The token is injected only for the git push process through `GIT_ASKPASS`.
4041

4142
## Service install
4243

app/git_ops.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
from __future__ import annotations
22

3+
import os
4+
import re
5+
import stat
6+
import tempfile
7+
from contextlib import contextmanager
38
from datetime import datetime, timedelta, timezone
49
from pathlib import Path
10+
from typing import Iterator
511

612
from git import Repo
713
from git.exc import GitCommandError, InvalidGitRepositoryError, NoSuchPathError
@@ -10,6 +16,11 @@
1016
from .sync_state import MANIFEST_NAME
1117

1218

19+
def git_pat_env_var_name(task_name: str) -> str:
20+
normalized = re.sub(r"\W+", "_", task_name.strip()).strip("_")
21+
return f"{normalized or 'TASK'}_key"
22+
23+
1324
class GitManager:
1425
def __init__(self) -> None:
1526
self._next_push_at: dict[int, datetime] = {}
@@ -53,15 +64,64 @@ def maybe_push(self, task: SyncTask) -> bool:
5364
next_push = self._next_push_at.get(task_id)
5465
if not next_push or datetime.now(timezone.utc) < next_push:
5566
return False
67+
self.push(task)
68+
self._next_push_at.pop(task_id, None)
69+
self._last_push_at[task_id] = datetime.now(timezone.utc)
70+
return True
71+
72+
def push(self, task: SyncTask) -> None:
5673
repo = self.ensure_repo(task)
5774
try:
5875
remote = repo.remote(task.git.remote_name)
59-
remote.push(task.git.branch)
76+
with self._push_environment(task, repo):
77+
remote.push(task.git.branch)
6078
except (ValueError, GitCommandError):
6179
raise
62-
self._next_push_at.pop(task_id, None)
63-
self._last_push_at[task_id] = datetime.now(timezone.utc)
64-
return True
6580

6681
def last_push_at(self, task_id: int) -> datetime | None:
6782
return self._last_push_at.get(task_id)
83+
84+
@contextmanager
85+
def _push_environment(self, task: SyncTask, repo: Repo) -> Iterator[None]:
86+
token = self._pat_for_task(task)
87+
if not token:
88+
yield
89+
return
90+
91+
askpass_path = self._write_askpass_script(token)
92+
try:
93+
with repo.git.custom_environment(GIT_ASKPASS=str(askpass_path), GIT_TERMINAL_PROMPT="0"):
94+
yield
95+
finally:
96+
askpass_path.unlink(missing_ok=True)
97+
98+
def _pat_for_task(self, task: SyncTask) -> str | None:
99+
for env_var in self._pat_env_var_candidates(task):
100+
token = os.getenv(env_var)
101+
if token:
102+
return token
103+
return None
104+
105+
def _pat_env_var_candidates(self, task: SyncTask) -> list[str]:
106+
primary = git_pat_env_var_name(task.name)
107+
candidates = [primary, primary.upper()]
108+
raw = f"{task.name}_key"
109+
if raw not in candidates:
110+
candidates.append(raw)
111+
return candidates
112+
113+
def _write_askpass_script(self, token: str) -> Path:
114+
fd, name = tempfile.mkstemp(prefix="office-docs-git-askpass-", suffix=".sh")
115+
path = Path(name)
116+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
117+
handle.write("#!/bin/sh\n")
118+
handle.write('case "$1" in\n')
119+
handle.write(' *Username*) printf "%s\\n" "x-access-token" ;;\n')
120+
handle.write(f' *) printf "%s\\n" "{self._shell_double_quote(token)}" ;;\n')
121+
handle.write("esac\n")
122+
path.chmod(path.stat().st_mode | stat.S_IXUSR)
123+
return path
124+
125+
@staticmethod
126+
def _shell_double_quote(value: str) -> str:
127+
return value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")

app/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def push_task(task_id: int) -> dict[str, str]:
118118
if not task:
119119
raise HTTPException(status_code=404, detail="Task not found")
120120
try:
121-
git_manager.ensure_repo(task).remote(task.git.remote_name).push(task.git.branch)
121+
git_manager.push(task)
122122
except Exception as exc: # pragma: no cover - external
123123
raise HTTPException(status_code=400, detail=str(exc)) from exc
124124
return {"status": "pushed"}

docker-compose.example.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ services:
88
APP_DATA_DIR: /data
99
HOST: 0.0.0.0
1010
PORT: 8080
11+
# Docs_Sync_key: ghp_xxx
1112
volumes:
1213
- ./data:/data
1314
restart: unless-stopped

tests/test_git_ops.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from pathlib import Path
2+
3+
from git import Repo
4+
5+
from app.git_ops import GitManager, git_pat_env_var_name
6+
from app.models import SyncTask, TaskGitConfig, TaskPaths
7+
8+
9+
def make_task(tmp_path: Path, name: str = "Task API") -> SyncTask:
10+
source = tmp_path / "source"
11+
target = tmp_path / "target"
12+
source.mkdir()
13+
target.mkdir()
14+
return SyncTask(
15+
name=name,
16+
paths=TaskPaths(source_dir=str(source), target_root=str(target), output_subdir="md"),
17+
git=TaskGitConfig(enabled=True, auto_push=True),
18+
)
19+
20+
21+
def test_git_pat_env_var_name_uses_task_name() -> None:
22+
assert git_pat_env_var_name("Task API") == "Task_API_key"
23+
assert git_pat_env_var_name(" docs/sync ") == "docs_sync_key"
24+
25+
26+
def test_git_pat_for_task_reads_task_key_env(tmp_path: Path, monkeypatch) -> None:
27+
task = make_task(tmp_path, name="Task API")
28+
monkeypatch.setenv("Task_API_key", "pat-token")
29+
30+
assert GitManager()._pat_for_task(task) == "pat-token"
31+
32+
33+
def test_git_pat_for_task_accepts_uppercase_env(tmp_path: Path, monkeypatch) -> None:
34+
task = make_task(tmp_path, name="Task API")
35+
monkeypatch.setenv("TASK_API_KEY", "pat-token")
36+
37+
assert GitManager()._pat_for_task(task) == "pat-token"
38+
39+
40+
def test_push_environment_sets_askpass_for_pat(tmp_path: Path, monkeypatch) -> None:
41+
task = make_task(tmp_path)
42+
repo = Repo.init(Path(task.paths.target_root), initial_branch=task.git.branch)
43+
monkeypatch.setenv("Task_API_key", "pat-token")
44+
45+
manager = GitManager()
46+
with manager._push_environment(task, repo):
47+
env = repo.git._environment
48+
askpass = Path(env["GIT_ASKPASS"])
49+
assert env["GIT_TERMINAL_PROMPT"] == "0"
50+
assert askpass.exists()
51+
assert "pat-token" in askpass.read_text(encoding="utf-8")
52+
53+
assert repo.git._environment == {}
54+
assert not askpass.exists()

0 commit comments

Comments
 (0)