Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ gh pr merge --rebase --delete-branch

### Core Layer (`core.py`)
- `Phabfive` class: central configuration and API client management
- Loads config from environment variables or `~/.config/phabfive.yaml`
- Loads config from `.arcconfig`, `~/.arcrc`, `~/.config/phabfive.yaml`, and environment variables
- Uses the `phabricator` library for Conduit API calls
- Output formatting: supports `rich` (terminal), `yaml`, and `strict` (machine-readable) modes

Expand All @@ -62,7 +62,18 @@ Complex features use a consistent subpackage structure:
- `transitions/` - state machine for task status/priority/column changes

### Configuration
Required: `PHAB_TOKEN` and `PHAB_URL` (via environment or config file)

Required: `PHAB_TOKEN` and `PHAB_URL`

Config precedence (later overrides earlier):
1. Hard-coded defaults
2. `/etc/phabfive.yaml`
3. `/etc/phabfive.d/*.yaml`
4. `~/.config/phabfive.yaml` (PHAB_URL/PHAB_TOKEN deprecated here, use for PHAB_SPACE/PHAB_FALLBACK/PHABFIVE_DEBUG)
5. `~/.config/phabfive.d/*.yaml`
6. `.arcconfig` in git root (provides PHAB_URL from `phabricator.uri`)
7. `~/.arcrc` (provides PHAB_TOKEN for matched URL)
8. Environment variables

## AI Agent Usage

Expand Down
4 changes: 2 additions & 2 deletions phabfive/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ class LogLevel(str, Enum):
}
VALIDATION_HINTS = {"PHAB_URL": "example: https://we.phorge.it/api/"}
MISSING_CONFIG_HINTS = {
"PHAB_TOKEN": "example: export PHAB_TOKEN=cli-RANDOMRANDOMRANDOMRANDOMRAND",
"PHAB_URL": "example: echo PHAB_URL: https://we.phorge.it/api/ >> ~/.config/phabfive.yaml",
"PHAB_TOKEN": "add token to ~/.arcrc or run: phabfive user setup",
"PHAB_URL": 'create .arcconfig with: {"phabricator.uri": "https://we.phorge.it/"} or run: phabfive user setup',
}

PRIORITY_DEFAULT = "normal"
Expand Down
95 changes: 92 additions & 3 deletions phabfive/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os
import re
import stat
import sys
from urllib.parse import urlparse

# phabfive imports
Expand Down Expand Up @@ -437,6 +438,64 @@ def _load_arcrc(self, current_conf):

return result

def _load_arcconfig(self):
"""
Load PHAB_URL from .arcconfig in the git repository root.

Walks up from the current working directory looking for a .git directory,
then reads .arcconfig in that directory. Extracts `phabricator.uri` and
normalizes it to an API URL.

Returns
-------
dict
Configuration dict with PHAB_URL if found, empty dict otherwise
"""
# Walk up from cwd looking for .git directory
current = os.getcwd()
git_root = None

while True:
if os.path.isdir(os.path.join(current, ".git")):
git_root = current
break
parent = os.path.dirname(current)
if parent == current:
break
current = parent

if git_root is None:
log.debug("Not in a git repository, skipping .arcconfig")
return {}

arcconfig_path = os.path.join(git_root, ".arcconfig")

if not os.path.exists(arcconfig_path):
log.debug(f"No .arcconfig found at {arcconfig_path}")
return {}

log.debug(f"Loading configuration from {arcconfig_path}")

try:
with open(arcconfig_path, "r") as f:
arcconfig_data = json.load(f)
except json.JSONDecodeError as e:
log.warning(f"Failed to parse {arcconfig_path}: {e}")
return {}
except IOError as e:
log.warning(f"Failed to read {arcconfig_path}: {e}")
return {}

uri = arcconfig_data.get("phabricator.uri")

if not uri:
log.debug("No phabricator.uri found in .arcconfig")
return {}

normalized = self._normalize_url(uri)
log.debug(f"Using PHAB_URL from .arcconfig: {normalized}")
return {"PHAB_URL": normalized}

def load_config(self):
"""
Load configuration from configuration files and environment variables.
Expand All @@ -448,8 +507,9 @@ def load_config(self):
3. `/etc/phabfive.d/*.yaml`
4. `~/.config/phabfive.yaml`
5. `~/.config/phabfive.d/*.yaml`
6. `~/.arcrc` (Arcanist configuration)
7. environment variables
6. `.arcconfig` in git root
7. `~/.arcrc` (Arcanist configuration)
8. environment variables
"""
environ = os.environ.copy()

Expand Down Expand Up @@ -487,6 +547,10 @@ def load_config(self):
},
)

# Track config state before user yaml files to detect deprecated keys
pre_user_url = conf.get("PHAB_URL", "")
pre_user_token = conf.get("PHAB_TOKEN", "")

user_conf_file = os.path.join(f"{appdirs.user_config_dir('phabfive')}.yaml")
log.debug(f"Loading configuration file: {user_conf_file}")
self._check_secure_permissions(user_conf_file)
Expand Down Expand Up @@ -520,8 +584,33 @@ def load_config(self):
},
)

# Warn if PHAB_URL or PHAB_TOKEN were set from user yaml config files
deprecated_keys = []
if conf.get("PHAB_URL", "") != pre_user_url and conf.get("PHAB_URL", ""):
deprecated_keys.append("PHAB_URL")
if conf.get("PHAB_TOKEN", "") != pre_user_token and conf.get("PHAB_TOKEN", ""):
deprecated_keys.append("PHAB_TOKEN")
if deprecated_keys:
keys_str = "/".join(deprecated_keys)
print(
f"WARNING: ~/.config/phabfive.yaml contains {keys_str} which is deprecated. "
"Migrate credentials to ~/.arcrc using: phabfive user setup",
file=sys.stderr,
)

# Load from .arcconfig in git repository root
arcconfig_conf = self._load_arcconfig()
if arcconfig_conf:
log.debug("Merging configuration from .arcconfig")
anyconfig.merge(conf, arcconfig_conf)

# Load from Arcanist .arcrc file (supports single or multiple hosts)
arcrc_conf = self._load_arcrc(conf)
# Include PHAB_URL from environment so .arcrc can match the right host
# even though env vars are formally merged later
arcrc_lookup_conf = dict(conf)
if "PHAB_URL" in environ and environ["PHAB_URL"]:
arcrc_lookup_conf["PHAB_URL"] = environ["PHAB_URL"]
arcrc_conf = self._load_arcrc(arcrc_lookup_conf)
if arcrc_conf:
log.debug("Merging configuration from ~/.arcrc")
anyconfig.merge(conf, arcrc_conf)
Expand Down
144 changes: 123 additions & 21 deletions phabfive/setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
"""Interactive first-run configuration setup for phabfive."""

import json
import logging
import os
import re
Expand All @@ -9,7 +10,6 @@
from phabricator import Phabricator, APIError
from rich.console import Console
from rich.prompt import Prompt, Confirm
from ruamel.yaml import YAML

from phabfive.constants import VALIDATORS

Expand Down Expand Up @@ -92,7 +92,7 @@ def _read_password_with_dots(prompt: str = "") -> str:
class SetupWizard:
"""Interactive setup wizard for phabfive configuration."""

CONFIG_PATH = os.path.expanduser("~/.config/phabfive.yaml")
CONFIG_PATH = os.path.expanduser("~/.arcrc")

def __init__(self):
self.console = Console()
Expand Down Expand Up @@ -278,29 +278,26 @@ def _verify_connection(self) -> bool:
return False

def _save_config(self) -> bool:
"""Save configuration to file with secure permissions."""
"""Save credentials to ~/.arcrc in Arcanist-compatible JSON format."""
try:
# Ensure ~/.config directory exists
config_dir = os.path.dirname(self.CONFIG_PATH)
os.makedirs(config_dir, mode=0o700, exist_ok=True)

# Load existing config or create new
yaml = YAML()
yaml.default_flow_style = False

# Load existing .arcrc or create new
if os.path.exists(self.CONFIG_PATH):
with open(self.CONFIG_PATH, "r") as f:
config = yaml.load(f) or {}
arcrc = json.load(f)
else:
config = {}
arcrc = {}

# Ensure hosts key exists
if "hosts" not in arcrc:
arcrc["hosts"] = {}

# Update with new values
config["PHAB_URL"] = self.phab_url
config["PHAB_TOKEN"] = self.phab_token
# Add/update the host entry
arcrc["hosts"][self.phab_url] = {"token": self.phab_token}

# Write config file
# Write JSON with indentation
with open(self.CONFIG_PATH, "w") as f:
yaml.dump(config, f)
json.dump(arcrc, f, indent=2)
f.write("\n")

# Set secure permissions (Unix only)
if os.name != "nt":
Expand Down Expand Up @@ -332,9 +329,105 @@ def _normalize_url(self, url: str) -> str:
return url


def _find_git_root():
"""Find the git repository root by walking up from cwd.

Returns:
str or None: Path to git root, or None if not in a git repo
"""
current = os.getcwd()
while True:
if os.path.isdir(os.path.join(current, ".git")):
return current
parent = os.path.dirname(current)
if parent == current:
return None
current = parent


def _setup_arcconfig(console) -> bool:
"""Interactive setup to create .arcconfig in the git repo root.

Args:
console: Rich Console instance for output

Returns:
bool: True if .arcconfig was created successfully, False otherwise
"""
git_root = _find_git_root()

if git_root is None:
console.print(
"[yellow]Not inside a git repository. Cannot create .arcconfig.[/yellow]\n"
)
console.print("Either:")
console.print(" 1. Run phabfive from inside a git repository")
console.print(" 2. Set PHAB_URL environment variable")
console.print(
" 3. Run [bold]phabfive user setup[/bold] to configure ~/.arcrc\n"
)
return False

arcconfig_path = os.path.join(git_root, ".arcconfig")

if os.path.exists(arcconfig_path):
console.print(
f"[yellow].arcconfig already exists at {arcconfig_path}[/yellow]\n"
)
try:
with open(arcconfig_path, "r") as f:
data = json.load(f)
uri = data.get("phabricator.uri", "(not set)")
console.print(f" Current phabricator.uri: [bold]{uri}[/bold]\n")
except (json.JSONDecodeError, IOError):
pass

if not Confirm.ask("Do you want to overwrite it?", default=False):
return False

console.print("[bold]Create .arcconfig[/bold]")
console.print(f"This will create .arcconfig in: {git_root}\n")

while True:
url = Prompt.ask(
"Enter your Phabricator URL (e.g., https://phorge.example.com)"
)

if not url:
console.print("[red]URL cannot be empty[/red]")
continue

# Strip trailing slashes and /api/ suffix for .arcconfig
# .arcconfig stores the base URL, not the API URL
url = url.rstrip("/")
if url.endswith("/api"):
url = url[:-4].rstrip("/")

console.print(f"[green]> Using URL: {url}[/green]\n")
break

try:
arcconfig_data = {"phabricator.uri": url + "/"}
with open(arcconfig_path, "w") as f:
json.dump(arcconfig_data, f, indent=2)
f.write("\n")

console.print(f"[green].arcconfig created at {arcconfig_path}[/green]")
console.print("[dim]Remember to commit .arcconfig to your repository.[/dim]\n")
return True

except Exception as e:
console.print(f"[red]Failed to create .arcconfig: {e}[/red]")
return False


def offer_setup_on_error(error_message: str) -> bool:
"""Offer to run setup when configuration error occurs.

Routes to the appropriate wizard based on what's missing:
- Missing PHAB_URL: offer to create .arcconfig
- Missing PHAB_TOKEN or other errors: offer full setup wizard (~/.arcrc)

Args:
error_message: The configuration error message to display

Expand All @@ -350,8 +443,17 @@ def offer_setup_on_error(error_message: str) -> bool:
console = Console()
console.print(f"\n[red]ERROR: {error_message}[/red]\n")

if Confirm.ask("Would you like to run interactive setup now?", default=True):
wizard = SetupWizard()
return wizard.run()
is_url_error = "PHAB_URL" in error_message and "PHAB_TOKEN" not in error_message

if is_url_error:
if Confirm.ask(
"Would you like to create .arcconfig for this repository?",
default=True,
):
return _setup_arcconfig(console)
else:
if Confirm.ask("Would you like to run interactive setup now?", default=True):
wizard = SetupWizard()
return wizard.run()

return False
Loading