feat: support private repo cloning with HTTP basic auth
Deploy PR-Helper / deploy (push) Successful in 30s
Deploy PR-Helper / deploy (push) Successful in 30s
- Add SSH URL detection with friendly prompt to use HTTPS instead - Add private repo toggle (default off) with username/password fields - Persist auth_type and credential in repositories table - Pass stored credentials to PullRepo for authenticated pulls - Incremental migration adds auth_type and credential columns
This commit is contained in:
@@ -99,6 +99,14 @@ func (db *DB) migrate() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Incremental migrations — ignore "duplicate column" errors for idempotency.
|
||||||
|
alterStmts := []string{
|
||||||
|
"ALTER TABLE repositories ADD COLUMN auth_type VARCHAR(20) NOT NULL DEFAULT 'none'",
|
||||||
|
"ALTER TABLE repositories ADD COLUMN credential TEXT",
|
||||||
|
}
|
||||||
|
for _, s := range alterStmts {
|
||||||
|
db.conn.Exec(s) // ignore error (column already exists)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-5
@@ -149,8 +149,9 @@ func (h *ReposHandler) PullRepo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
var localPath string
|
var localPath, authType string
|
||||||
err := h.db.QueryRow(`SELECT local_path FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&localPath)
|
var credential sql.NullString
|
||||||
|
err := h.db.QueryRow(`SELECT local_path, auth_type, credential FROM repositories WHERE id = ? AND user_id = ?`, id, user.ID).Scan(&localPath, &authType, &credential)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"})
|
||||||
return
|
return
|
||||||
@@ -160,7 +161,19 @@ func (h *ReposHandler) PullRepo(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := services.PullRepo(localPath)
|
// Parse stored credentials
|
||||||
|
var username, password string
|
||||||
|
if authType == "basic" && credential.Valid {
|
||||||
|
var cred struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
json.Unmarshal([]byte(credential.String), &cred)
|
||||||
|
username = cred.Username
|
||||||
|
password = cred.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := services.PullRepo(localPath, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -238,10 +251,23 @@ func (h *ReposHandler) CloneRepo(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine auth type and credential for persistence
|
||||||
|
authType := "none"
|
||||||
|
var credential *string
|
||||||
|
if req.Username != "" || req.Password != "" {
|
||||||
|
authType = "basic"
|
||||||
|
credJSON, _ := json.Marshal(map[string]string{
|
||||||
|
"username": req.Username,
|
||||||
|
"password": req.Password,
|
||||||
|
})
|
||||||
|
s := string(credJSON)
|
||||||
|
credential = &s
|
||||||
|
}
|
||||||
|
|
||||||
// Save to database with user_id
|
// Save to database with user_id
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
res, err := h.db.Exec(`INSERT INTO repositories (user_id, url, local_path, size_bytes, cloned_at, last_used) VALUES (?, ?, ?, ?, ?, ?)`,
|
res, err := h.db.Exec(`INSERT INTO repositories (user_id, url, local_path, size_bytes, cloned_at, last_used, auth_type, credential) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
user.ID, req.URL, repoDir, result.SizeBytes, now, now)
|
user.ID, req.URL, repoDir, result.SizeBytes, now, now, authType, credential)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent("error", map[string]interface{}{"message": "save to db: " + err.Error()})
|
sendEvent("error", map[string]interface{}{"message": "save to db: " + err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ type Repository struct {
|
|||||||
SizeBytes int64 `json:"size_bytes"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
ClonedAt time.Time `json:"cloned_at"`
|
ClonedAt time.Time `json:"cloned_at"`
|
||||||
LastUsed time.Time `json:"last_used"`
|
LastUsed time.Time `json:"last_used"`
|
||||||
|
AuthType string `json:"auth_type"`
|
||||||
|
Credential string `json:"-"` // never expose in JSON responses
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -442,7 +442,8 @@ func GetBranchCommits(repoPath, branchName string, maxCommits int) ([]CommitInfo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PullRepo fetches and merges latest changes from origin into the current branch.
|
// PullRepo fetches and merges latest changes from origin into the current branch.
|
||||||
func PullRepo(repoPath string) (*CloneResult, error) {
|
// username and password are optional; when non-empty they authenticate the pull.
|
||||||
|
func PullRepo(repoPath, username, password string) (*CloneResult, error) {
|
||||||
repo, err := OpenRepo(repoPath)
|
repo, err := OpenRepo(repoPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -453,7 +454,15 @@ func PullRepo(repoPath string) (*CloneResult, error) {
|
|||||||
return nil, fmt.Errorf("worktree: %w", err)
|
return nil, fmt.Errorf("worktree: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = w.Pull(&git.PullOptions{})
|
pullOpts := &git.PullOptions{}
|
||||||
|
if username != "" {
|
||||||
|
pullOpts.Auth = &http.BasicAuth{
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = w.Pull(pullOpts)
|
||||||
if err != nil && err != git.NoErrAlreadyUpToDate {
|
if err != nil && err != git.NoErrAlreadyUpToDate {
|
||||||
return nil, fmt.Errorf("git pull: %w", err)
|
return nil, fmt.Errorf("git pull: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,31 @@
|
|||||||
<!-- Clone Form -->
|
<!-- Clone Form -->
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
||||||
<h2 class="text-lg font-semibold mb-4">克隆仓库</h2>
|
<h2 class="text-lg font-semibold mb-4">克隆仓库</h2>
|
||||||
<form id="clone-form" class="flex gap-3">
|
<form id="clone-form" class="space-y-3">
|
||||||
|
<div class="flex gap-3">
|
||||||
<input type="text" id="repo-url" name="url" placeholder="https://github.com/user/repo.git"
|
<input type="text" id="repo-url" name="url" placeholder="https://github.com/user/repo.git"
|
||||||
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" required>
|
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" required>
|
||||||
<button type="submit" id="clone-btn"
|
<button type="submit" id="clone-btn"
|
||||||
class="bg-indigo-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
class="bg-indigo-600 text-white px-5 py-2 rounded-md text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||||
克隆
|
克隆
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="ssh-warning" class="hidden rounded-md bg-yellow-50 border border-yellow-200 px-4 py-3 text-sm text-yellow-800">
|
||||||
|
检测到 SSH 格式地址,请使用 HTTPS 格式。例如:<code class="bg-yellow-100 px-1 rounded">https://github.com/user/repo.git</code>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" id="private-toggle" class="sr-only peer">
|
||||||
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
<span class="text-sm text-gray-600">私有仓库</span>
|
||||||
|
</div>
|
||||||
|
<div id="auth-fields" class="hidden grid grid-cols-2 gap-3">
|
||||||
|
<input type="text" id="repo-username" placeholder="用户名(可选)"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<input type="password" id="repo-password" placeholder="密码或 Personal Access Token"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div id="clone-progress" class="hidden mt-4">
|
<div id="clone-progress" class="hidden mt-4">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
@@ -74,10 +92,30 @@
|
|||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
// Toggle private repo auth fields
|
||||||
|
document.getElementById('private-toggle').addEventListener('change', function() {
|
||||||
|
document.getElementById('auth-fields').classList.toggle('hidden', !this.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detect SSH URLs on input
|
||||||
|
document.getElementById('repo-url').addEventListener('input', function() {
|
||||||
|
const val = this.value.trim();
|
||||||
|
const isSSH = /^(git@|ssh:\/\/)/.test(val);
|
||||||
|
document.getElementById('ssh-warning').classList.toggle('hidden', !isSSH);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('clone-form').addEventListener('submit', async function(e) {
|
document.getElementById('clone-form').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const url = document.getElementById('repo-url').value.trim();
|
const url = document.getElementById('repo-url').value.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
|
// Block SSH URLs
|
||||||
|
if (/^(git@|ssh:\/\/)/.test(url)) {
|
||||||
|
const sshWarn = document.getElementById('ssh-warning');
|
||||||
|
sshWarn.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const btn = document.getElementById('clone-btn');
|
const btn = document.getElementById('clone-btn');
|
||||||
const progress = document.getElementById('clone-progress');
|
const progress = document.getElementById('clone-progress');
|
||||||
const status = document.getElementById('clone-status');
|
const status = document.getElementById('clone-status');
|
||||||
@@ -90,12 +128,16 @@ document.getElementById('clone-form').addEventListener('submit', async function(
|
|||||||
bar.style.width = '0%';
|
bar.style.width = '0%';
|
||||||
status.textContent = '正在连接...';
|
status.textContent = '正在连接...';
|
||||||
|
|
||||||
|
const isPrivate = document.getElementById('private-toggle').checked;
|
||||||
|
const username = isPrivate ? document.getElementById('repo-username').value.trim() : '';
|
||||||
|
const password = isPrivate ? document.getElementById('repo-password').value : '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/repos', {
|
const resp = await fetch('/api/repos', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({url: url})
|
body: JSON.stringify({url, username, password})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user