Merge pull request #547 from AIOSAI/work/system-fix-doctorwire-hook-format-drone-push-error-handli
feat(system): Fix doctor_wire hook format + drone push error handling + Dockerfile update
This commit is contained in:
+6
-2
@@ -26,7 +26,11 @@ ENV PATH="/home/testuser/.local/bin:$PATH"
|
||||
RUN python3 -m pip install --upgrade pip --break-system-packages 2>/dev/null || true
|
||||
|
||||
USER testuser
|
||||
RUN mkdir -p /home/testuser/workspace /home/testuser/.claude
|
||||
WORKDIR /home/testuser
|
||||
RUN mkdir -p /home/testuser/Projects /home/testuser/.claude
|
||||
WORKDIR /home/testuser/Projects
|
||||
|
||||
ENV HOME=/home/testuser
|
||||
ENV PATH="/home/testuser/.local/bin:$PATH"
|
||||
|
||||
RUN echo 'export HOME=/home/testuser' >> /home/testuser/.bashrc && \
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/testuser/.bashrc
|
||||
|
||||
@@ -158,7 +158,7 @@ def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[s
|
||||
settings["hooks"][event] = event_hooks
|
||||
|
||||
already_wired = any(
|
||||
isinstance(h, dict) and script in h.get("command", "")
|
||||
isinstance(h, dict) and script in json.dumps(h)
|
||||
for h in event_hooks
|
||||
)
|
||||
if not already_wired:
|
||||
@@ -166,15 +166,17 @@ def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[s
|
||||
hook_path = f"~/.claude/hooks/{script}"
|
||||
else:
|
||||
hook_path = f".claude/hooks/{script}"
|
||||
entry: Dict[str, object] = {
|
||||
cmd_entry: Dict[str, object] = {
|
||||
"type": "command",
|
||||
"command": f"{sys.executable} {hook_path}",
|
||||
}
|
||||
if hook.get("matcher"):
|
||||
entry["matcher"] = hook["matcher"]
|
||||
if hook.get("timeout"):
|
||||
entry["timeout"] = hook["timeout"]
|
||||
event_hooks.append(entry)
|
||||
cmd_entry["timeout"] = hook["timeout"]
|
||||
wrapper: Dict[str, object] = {
|
||||
"matcher": hook.get("matcher", ""),
|
||||
"hooks": [cmd_entry],
|
||||
}
|
||||
event_hooks.append(wrapper)
|
||||
actions.append(f"Wired hook {script} -> {event}")
|
||||
|
||||
# Env vars
|
||||
|
||||
@@ -98,6 +98,8 @@ drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, devpulse (
|
||||
- **Know what to build vs delegate.** Things you own (watchdog, feedback, your DPLANs/FPLANs, memories, prompts, small fixes across the codebase) → build directly. Multi-file new features or heavy refactors → delegate to a sub-agent so your context stays clean.
|
||||
- **CWD is identity.** You move in and out of branches all day. Always know which branch you're standing in — the CWD determines everything (drone routing, git operations, mailbox, passport lookups). Never cd into another branch and forget to come back. Visit, don't move in.
|
||||
- **Git awareness as a natural habit.** After completing a feature or wrapping up a chunk of work, run `git status`. If changes look coherent (upgrade, fix cycle, config update), suggest a commit or PR. Don't force it every turn, but don't let files pile up silently either.
|
||||
- **Never `docker cp` into test containers.** It dirties the git tree and blocks future pulls. Test flow: merge PR → `git pull` in container → test. If code isn't merged yet, it's not ready to test in Docker.
|
||||
- **Sub-agents build, managers PR.** Sub-agents never create PRs. They build code and run tests. The manager reviews the work, then creates the PR.
|
||||
## Watchdog — Directed Wake (devpulse module)
|
||||
|
||||
Watchdog is a real devpulse module now (not a bash one-liner). After dispatching, arm it as a background task — it polls the dispatch lock file and exits when the agent process finishes (success, silent-finish, OR crash). The exit wakes you.
|
||||
|
||||
@@ -30,48 +30,56 @@ from aipass.drone.apps.handlers.git.lock_handler import (
|
||||
)
|
||||
|
||||
|
||||
def _is_permission_error(stderr: str) -> bool:
|
||||
"""Check if a push failure is a permission/auth error (fork scenario)."""
|
||||
indicators = ["403", "permission", "denied", "could not read Username"]
|
||||
def _has_credential_helper() -> bool:
|
||||
"""Check whether git has a credential.helper configured at any level."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "config", "--get-all", "credential.helper"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return r.returncode == 0 and bool(r.stdout.strip())
|
||||
except OSError as exc:
|
||||
logger.warning("credential helper check failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _diagnose_push_failure(stderr: str, branch: str) -> str:
|
||||
"""Produce an actionable error message for a failed git push.
|
||||
|
||||
Distinguishes between:
|
||||
- No credential helper (most common on fresh setups)
|
||||
- Expired or invalid token
|
||||
- Actual permission / fork issues
|
||||
"""
|
||||
lower = stderr.lower()
|
||||
return any(ind.lower() in lower for ind in indicators)
|
||||
auth_indicators = ["403", "could not read username", "terminal prompts disabled"]
|
||||
permission_indicators = ["permission", "denied"]
|
||||
|
||||
|
||||
def _fork_recovery_message(branch: str, repo_root: str = ".") -> str:
|
||||
"""Build a dynamic fork recovery message with actual repo/user info."""
|
||||
origin = "AIOSAI/AIPass"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=repo_root,
|
||||
if not _has_credential_helper():
|
||||
return (
|
||||
"Push failed: no git credential helper configured.\n"
|
||||
" git cannot obtain auth tokens for push.\n"
|
||||
" Fix: gh auth setup-git\n"
|
||||
' Then retry: drone @git pr "<description>"'
|
||||
)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
origin = r.stdout.strip()
|
||||
except OSError as exc:
|
||||
logger.info("Could not detect origin repo: %s", exc)
|
||||
|
||||
gh_user = "<your-user>"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["gh", "api", "user", "-q", ".login"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
if any(ind in lower for ind in auth_indicators):
|
||||
return (
|
||||
"Push failed: authentication error (token may be expired or invalid).\n"
|
||||
f" stderr: {stderr}\n"
|
||||
" Check: gh auth status\n"
|
||||
" Fix: gh auth login"
|
||||
)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
gh_user = r.stdout.strip()
|
||||
except OSError as exc:
|
||||
logger.info("Could not detect gh user: %s", exc)
|
||||
|
||||
return f"""Push failed due to insufficient permissions. You may be working on a fork.
|
||||
if any(ind in lower for ind in permission_indicators):
|
||||
return (
|
||||
f"Push failed: permission denied for branch '{branch}'.\n"
|
||||
f" stderr: {stderr}\n"
|
||||
" Check repo write access: gh api repos/:owner/:repo --jq '.permissions'"
|
||||
)
|
||||
|
||||
To contribute from a fork:
|
||||
1. Create a fork: gh repo fork {origin} --remote=false --clone=false
|
||||
2. Add fork as remote: git remote add fork https://github.com/{gh_user}/{origin.split("/")[-1]}.git
|
||||
3. Push to your fork: git push -u fork {branch}
|
||||
4. Open cross-repo PR: gh pr create -R {origin} -H {gh_user}:{branch} -B main
|
||||
"""
|
||||
return f"Push failed: {stderr}"
|
||||
|
||||
|
||||
def _slugify(description: str) -> str:
|
||||
@@ -227,11 +235,7 @@ def create_pr(branch_name: str, description: str, branch_dir: Path) -> dict:
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if push.returncode != 0:
|
||||
stderr = push.stderr.strip()
|
||||
if _is_permission_error(stderr):
|
||||
result["message"] = _fork_recovery_message(feature_branch, str(repo_root))
|
||||
else:
|
||||
result["message"] = f"Push failed: {stderr}"
|
||||
result["message"] = _diagnose_push_failure(push.stderr.strip(), feature_branch)
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ from aipass.drone.apps.handlers.git.lock_handler import (
|
||||
release_lock,
|
||||
)
|
||||
from aipass.drone.apps.handlers.git.pr_handler import (
|
||||
_is_permission_error,
|
||||
_fork_recovery_message,
|
||||
_diagnose_push_failure,
|
||||
)
|
||||
|
||||
|
||||
@@ -216,11 +215,7 @@ def create_system_pr(description: str, caller: str) -> dict:
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if push.returncode != 0:
|
||||
stderr = push.stderr.strip()
|
||||
if _is_permission_error(stderr):
|
||||
result["message"] = _fork_recovery_message(feature_branch, str(repo_root))
|
||||
else:
|
||||
result["message"] = f"Push failed: {stderr}"
|
||||
result["message"] = _diagnose_push_failure(push.stderr.strip(), feature_branch)
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
|
||||
@@ -26,7 +26,11 @@ from aipass.drone.apps.handlers.git.lock_handler import (
|
||||
)
|
||||
from aipass.drone.apps.handlers.git.status_handler import get_branch_status
|
||||
from aipass.drone.apps.handlers.git.sync_handler import sync_main
|
||||
from aipass.drone.apps.handlers.git.pr_handler import create_pr
|
||||
from aipass.drone.apps.handlers.git.pr_handler import (
|
||||
_diagnose_push_failure,
|
||||
_has_credential_helper,
|
||||
create_pr,
|
||||
)
|
||||
from aipass.drone.apps.modules.git_module import (
|
||||
DRONE_MODULE,
|
||||
_detect_branch_dir,
|
||||
@@ -511,6 +515,82 @@ class TestPRHandler:
|
||||
assert "src/aipass/api" in pathspec, f"pathspec should target branch_dir, got: {pathspec}"
|
||||
|
||||
|
||||
class TestDiagnosePushFailure:
|
||||
"""Tests for _has_credential_helper and _diagnose_push_failure."""
|
||||
|
||||
def test_has_credential_helper_true(self) -> None:
|
||||
"""Returns True when git credential.helper is configured."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "store\n"
|
||||
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", return_value=mock_result):
|
||||
assert _has_credential_helper() is True
|
||||
|
||||
def test_has_credential_helper_false_no_config(self) -> None:
|
||||
"""Returns False when git config returns non-zero (no helper set)."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
mock_result.stdout = ""
|
||||
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", return_value=mock_result):
|
||||
assert _has_credential_helper() is False
|
||||
|
||||
def test_has_credential_helper_false_empty_stdout(self) -> None:
|
||||
"""Returns False when git config returns 0 but stdout is whitespace-only."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = " \n"
|
||||
with patch("aipass.drone.apps.handlers.git.pr_handler.subprocess.run", return_value=mock_result):
|
||||
assert _has_credential_helper() is False
|
||||
|
||||
def test_has_credential_helper_oserror(self) -> None:
|
||||
"""Returns False when subprocess raises OSError."""
|
||||
with patch(
|
||||
"aipass.drone.apps.handlers.git.pr_handler.subprocess.run",
|
||||
side_effect=OSError("git not found"),
|
||||
):
|
||||
assert _has_credential_helper() is False
|
||||
|
||||
def test_diagnose_no_credential_helper(self) -> None:
|
||||
"""Suggests gh auth setup-git when no credential helper is configured."""
|
||||
with patch("aipass.drone.apps.handlers.git.pr_handler._has_credential_helper", return_value=False):
|
||||
msg = _diagnose_push_failure("fatal: could not read Username", "feat/test")
|
||||
assert "no git credential helper configured" in msg.lower()
|
||||
assert "gh auth setup-git" in msg
|
||||
|
||||
def test_diagnose_auth_error_403(self) -> None:
|
||||
"""Identifies 403 as an authentication/token error."""
|
||||
with patch("aipass.drone.apps.handlers.git.pr_handler._has_credential_helper", return_value=True):
|
||||
msg = _diagnose_push_failure("The requested URL returned error: 403", "feat/test")
|
||||
assert "authentication error" in msg.lower()
|
||||
assert "gh auth login" in msg
|
||||
|
||||
def test_diagnose_auth_error_terminal_prompts(self) -> None:
|
||||
"""Identifies terminal prompts disabled as an auth error."""
|
||||
with patch("aipass.drone.apps.handlers.git.pr_handler._has_credential_helper", return_value=True):
|
||||
msg = _diagnose_push_failure("terminal prompts disabled", "feat/test")
|
||||
assert "authentication error" in msg.lower()
|
||||
|
||||
def test_diagnose_permission_denied(self) -> None:
|
||||
"""Identifies permission denied as a repo access issue."""
|
||||
with patch("aipass.drone.apps.handlers.git.pr_handler._has_credential_helper", return_value=True):
|
||||
msg = _diagnose_push_failure("Permission denied to AIOSAI/repo", "feat/test")
|
||||
assert "permission denied" in msg.lower()
|
||||
assert "feat/test" in msg
|
||||
|
||||
def test_diagnose_unknown_error_passthrough(self) -> None:
|
||||
"""Passes through unrecognized errors with stderr content."""
|
||||
with patch("aipass.drone.apps.handlers.git.pr_handler._has_credential_helper", return_value=True):
|
||||
msg = _diagnose_push_failure("some unknown git error", "feat/test")
|
||||
assert msg == "Push failed: some unknown git error"
|
||||
|
||||
def test_diagnose_credential_check_takes_priority(self) -> None:
|
||||
"""No credential helper is checked first, even if stderr also matches auth indicators."""
|
||||
with patch("aipass.drone.apps.handlers.git.pr_handler._has_credential_helper", return_value=False):
|
||||
msg = _diagnose_push_failure("403 forbidden", "feat/test")
|
||||
assert "credential helper" in msg.lower()
|
||||
assert "403" not in msg
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. git_module — command routing, unknown commands, help/introspection
|
||||
# ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user