jitly 0.1.0__py3-none-any.whl
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.
- jitly/__init__.py +1 -0
- jitly/commands/__init__.py +0 -0
- jitly/commands/auth.py +76 -0
- jitly/commands/done.py +130 -0
- jitly/commands/init.py +64 -0
- jitly/commands/policy.py +90 -0
- jitly/commands/start.py +153 -0
- jitly/commands/status.py +45 -0
- jitly/core/__init__.py +0 -0
- jitly/core/auth_store.py +48 -0
- jitly/core/backend_client.py +59 -0
- jitly/core/config.py +71 -0
- jitly/core/git_ops.py +93 -0
- jitly/core/jira_client.py +276 -0
- jitly/main.py +27 -0
- jitly/utils/__init__.py +0 -0
- jitly-0.1.0.dist-info/METADATA +27 -0
- jitly-0.1.0.dist-info/RECORD +21 -0
- jitly-0.1.0.dist-info/WHEEL +5 -0
- jitly-0.1.0.dist-info/entry_points.txt +2 -0
- jitly-0.1.0.dist-info/top_level.txt +1 -0
jitly/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
jitly/commands/auth.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from jitly.core.jira_client import interactive_login, JiraClient
|
|
5
|
+
from jitly.core.auth_store import save_credentials, load_credentials, delete_credentials
|
|
6
|
+
from jitly.core.config import GlobalConfig
|
|
7
|
+
from jitly.core import backend_client
|
|
8
|
+
|
|
9
|
+
app = typer.Typer()
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("login")
|
|
14
|
+
def login():
|
|
15
|
+
"""Authenticate with Jira (SSO, API token, or PAT)."""
|
|
16
|
+
console.print("\n[bold cyan]Welcome to Jitly![/] Let's connect your Jira account.\n")
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
creds = interactive_login()
|
|
20
|
+
except Exception as e:
|
|
21
|
+
console.print(f"[red]Login failed:[/] {e}")
|
|
22
|
+
raise typer.Exit(1)
|
|
23
|
+
|
|
24
|
+
client = JiraClient(creds)
|
|
25
|
+
try:
|
|
26
|
+
me = client.get_myself()
|
|
27
|
+
except Exception as e:
|
|
28
|
+
console.print(f"[red]Could not verify Jira credentials:[/] {e}")
|
|
29
|
+
raise typer.Exit(1)
|
|
30
|
+
|
|
31
|
+
display_name = me.get("displayName", "Unknown")
|
|
32
|
+
email = me.get("emailAddress", creds.get("email", ""))
|
|
33
|
+
|
|
34
|
+
save_credentials(creds)
|
|
35
|
+
console.print(f"\n[bold green]✓ Logged in as {display_name}[/] ({email})")
|
|
36
|
+
|
|
37
|
+
# Register/sync user with backend
|
|
38
|
+
try:
|
|
39
|
+
result = backend_client.register_user(creds, display_name, email)
|
|
40
|
+
token = result.get("token")
|
|
41
|
+
if token:
|
|
42
|
+
GlobalConfig().set("backend_token", token)
|
|
43
|
+
console.print("[dim]✓ Synced with Jitly backend[/]")
|
|
44
|
+
except Exception:
|
|
45
|
+
console.print("[dim yellow]⚠ Could not reach Jitly backend — working in offline mode[/]")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command("logout")
|
|
49
|
+
def logout():
|
|
50
|
+
"""Remove stored Jira credentials."""
|
|
51
|
+
delete_credentials()
|
|
52
|
+
GlobalConfig().delete("backend_token")
|
|
53
|
+
console.print("[green]✓ Logged out successfully[/]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("whoami")
|
|
57
|
+
def whoami():
|
|
58
|
+
"""Show current authenticated user."""
|
|
59
|
+
creds = load_credentials()
|
|
60
|
+
if not creds:
|
|
61
|
+
console.print("[yellow]Not logged in. Run:[/] jitly auth login")
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
|
|
64
|
+
client = JiraClient(creds)
|
|
65
|
+
try:
|
|
66
|
+
me = client.get_myself()
|
|
67
|
+
except Exception as e:
|
|
68
|
+
console.print(f"[red]Failed to fetch user info:[/] {e}")
|
|
69
|
+
raise typer.Exit(1)
|
|
70
|
+
|
|
71
|
+
table = Table(show_header=False, box=None)
|
|
72
|
+
table.add_row("[dim]Name[/]", me.get("displayName", "-"))
|
|
73
|
+
table.add_row("[dim]Email[/]", me.get("emailAddress", "-"))
|
|
74
|
+
table.add_row("[dim]Auth type[/]", creds.get("auth_type", "-"))
|
|
75
|
+
table.add_row("[dim]Site[/]", creds.get("site_url", creds.get("site_name", "-")))
|
|
76
|
+
console.print(table)
|
jitly/commands/done.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""jitly done — commit, push, and wrap up the current ticket."""
|
|
2
|
+
import typer
|
|
3
|
+
import questionary
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from jitly.core.auth_store import load_credentials
|
|
8
|
+
from jitly.core.jira_client import JiraClient
|
|
9
|
+
from jitly.core.config import GlobalConfig, ProjectConfig
|
|
10
|
+
from jitly.core.git_ops import (
|
|
11
|
+
get_repo, is_dirty, current_branch, add_and_commit, push_branch,
|
|
12
|
+
)
|
|
13
|
+
from jitly.core import backend_client
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_commit(template: str, ticket_id: str, desc: str, message: str) -> str:
|
|
19
|
+
return (
|
|
20
|
+
template
|
|
21
|
+
.replace("{ticket}", ticket_id)
|
|
22
|
+
.replace("{desc}", desc)
|
|
23
|
+
.replace("{message}", message)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def done(
|
|
28
|
+
message: Optional[str] = typer.Option(None, "--message", "-m", help="Custom commit message"),
|
|
29
|
+
remote: str = typer.Option("origin", "--remote", "-r"),
|
|
30
|
+
no_push: bool = typer.Option(False, "--no-push", help="Commit only, don't push"),
|
|
31
|
+
):
|
|
32
|
+
"""Commit & push current work and mark the Jira ticket as done."""
|
|
33
|
+
creds = load_credentials()
|
|
34
|
+
if not creds:
|
|
35
|
+
console.print("[red]Not logged in. Run:[/] jitly auth login")
|
|
36
|
+
raise typer.Exit(1)
|
|
37
|
+
|
|
38
|
+
proj_cfg = ProjectConfig()
|
|
39
|
+
ticket_id = proj_cfg.get("active_ticket")
|
|
40
|
+
if not ticket_id:
|
|
41
|
+
console.print("[yellow]No active ticket. Did you run:[/] jitly start <TICKET>")
|
|
42
|
+
ticket_id = questionary.text("Enter ticket ID manually (or leave blank to skip):").ask()
|
|
43
|
+
if not ticket_id:
|
|
44
|
+
ticket_id = ""
|
|
45
|
+
|
|
46
|
+
policy = GlobalConfig().get("policy") or {}
|
|
47
|
+
commit_template = policy.get("commit_template", "{ticket}: {desc}")
|
|
48
|
+
|
|
49
|
+
# Fetch ticket summary for default commit message
|
|
50
|
+
summary = ""
|
|
51
|
+
if ticket_id and creds:
|
|
52
|
+
try:
|
|
53
|
+
client = JiraClient(creds)
|
|
54
|
+
issue = client.get_ticket(ticket_id)
|
|
55
|
+
summary = issue.get("fields", {}).get("summary", "")
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
branch = current_branch(get_repo())
|
|
60
|
+
console.print(f"\n[bold]Branch:[/] {branch}")
|
|
61
|
+
if ticket_id:
|
|
62
|
+
console.print(f"[bold]Ticket:[/] {ticket_id} — {summary}")
|
|
63
|
+
|
|
64
|
+
repo = get_repo()
|
|
65
|
+
if not is_dirty(repo):
|
|
66
|
+
console.print("[yellow]Nothing to commit.[/]")
|
|
67
|
+
push_anyway = questionary.confirm("Push current branch anyway?", default=True).ask()
|
|
68
|
+
if push_anyway and not no_push:
|
|
69
|
+
push_branch(repo, branch, remote)
|
|
70
|
+
console.print(f"[green]✓ Pushed[/] {branch}")
|
|
71
|
+
raise typer.Exit(0)
|
|
72
|
+
|
|
73
|
+
# Build commit message
|
|
74
|
+
if message:
|
|
75
|
+
final_message = message
|
|
76
|
+
else:
|
|
77
|
+
default_msg = _format_commit(commit_template, ticket_id or "", summary, summary)
|
|
78
|
+
final_message = questionary.text(
|
|
79
|
+
"Commit message:",
|
|
80
|
+
default=default_msg,
|
|
81
|
+
).ask()
|
|
82
|
+
if not final_message:
|
|
83
|
+
final_message = default_msg
|
|
84
|
+
|
|
85
|
+
# Show summary before committing
|
|
86
|
+
console.print(
|
|
87
|
+
Panel(
|
|
88
|
+
f"[bold]Message:[/] {final_message}\n[bold]Branch:[/] {branch}",
|
|
89
|
+
title="About to commit",
|
|
90
|
+
border_style="cyan",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
confirmed = questionary.confirm("Proceed?", default=True).ask()
|
|
94
|
+
if not confirmed:
|
|
95
|
+
console.print("[yellow]Aborted.[/]")
|
|
96
|
+
raise typer.Exit(0)
|
|
97
|
+
|
|
98
|
+
add_and_commit(repo, final_message)
|
|
99
|
+
console.print(f"[green]✓ Committed[/]")
|
|
100
|
+
|
|
101
|
+
if not no_push:
|
|
102
|
+
with console.status(f"Pushing to {remote}/{branch}..."):
|
|
103
|
+
push_branch(repo, branch, remote)
|
|
104
|
+
console.print(f"[green]✓ Pushed[/] to {remote}/{branch}")
|
|
105
|
+
|
|
106
|
+
# Transition Jira ticket
|
|
107
|
+
if ticket_id:
|
|
108
|
+
done_status = questionary.select(
|
|
109
|
+
"Mark ticket as:",
|
|
110
|
+
choices=["Done", "In Review", "Ready for QA", "Keep current status"],
|
|
111
|
+
).ask()
|
|
112
|
+
if done_status != "Keep current status":
|
|
113
|
+
try:
|
|
114
|
+
client = JiraClient(creds)
|
|
115
|
+
client.transition_ticket(ticket_id, done_status)
|
|
116
|
+
console.print(f"[dim]✓ Ticket moved to {done_status}[/]")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
console.print(f"[yellow]⚠ Could not update Jira status: {e}[/]")
|
|
119
|
+
|
|
120
|
+
# Clear active ticket
|
|
121
|
+
proj_cfg.set("active_ticket", None)
|
|
122
|
+
proj_cfg.set("active_branch", None)
|
|
123
|
+
|
|
124
|
+
backend_client.track_event("ticket.done", {
|
|
125
|
+
"ticket_id": ticket_id,
|
|
126
|
+
"branch": branch,
|
|
127
|
+
"commit_message": final_message,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
console.print(f"\n[bold green]All done![/] Great work on {ticket_id or branch}.")
|
jitly/commands/init.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""jitly init — link current git repo to Jira project."""
|
|
2
|
+
import typer
|
|
3
|
+
import questionary
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from jitly.core.config import ProjectConfig
|
|
7
|
+
from jitly.core.git_ops import get_repo, get_remote_url
|
|
8
|
+
from jitly.core.auth_store import load_credentials
|
|
9
|
+
from jitly.core.jira_client import JiraClient
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def init():
|
|
15
|
+
"""Link this git repository to a Jira project."""
|
|
16
|
+
creds = load_credentials()
|
|
17
|
+
if not creds:
|
|
18
|
+
console.print("[red]Not logged in. Run:[/] jitly auth login")
|
|
19
|
+
raise typer.Exit(1)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
repo = get_repo()
|
|
23
|
+
except Exception:
|
|
24
|
+
console.print("[red]Not inside a git repository.[/]")
|
|
25
|
+
raise typer.Exit(1)
|
|
26
|
+
|
|
27
|
+
cfg = ProjectConfig()
|
|
28
|
+
remote_url = get_remote_url(repo)
|
|
29
|
+
console.print(f"\n[bold]Setting up Jitly for this repository[/]")
|
|
30
|
+
if remote_url:
|
|
31
|
+
console.print(f"[dim]Remote:[/] {remote_url}")
|
|
32
|
+
|
|
33
|
+
# Fetch Jira projects the user has access to
|
|
34
|
+
client = JiraClient(creds)
|
|
35
|
+
try:
|
|
36
|
+
resp = __import__("httpx").get(
|
|
37
|
+
f"{client._base_url}/project/search",
|
|
38
|
+
headers=client._headers,
|
|
39
|
+
params={"maxResults": 50, "orderBy": "name"},
|
|
40
|
+
)
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
projects = resp.json().get("values", [])
|
|
43
|
+
choices = [f"{p['key']} — {p['name']}" for p in projects]
|
|
44
|
+
except Exception:
|
|
45
|
+
choices = []
|
|
46
|
+
|
|
47
|
+
if choices:
|
|
48
|
+
selected = questionary.select("Select Jira project for this repo:", choices=choices).ask()
|
|
49
|
+
project_key = selected.split(" — ")[0]
|
|
50
|
+
project_name = selected.split(" — ")[1]
|
|
51
|
+
else:
|
|
52
|
+
project_key = questionary.text("Jira project key (e.g. ABC):").ask().upper()
|
|
53
|
+
project_name = project_key
|
|
54
|
+
|
|
55
|
+
cfg.set("project_key", project_key)
|
|
56
|
+
cfg.set("project_name", project_name)
|
|
57
|
+
if remote_url:
|
|
58
|
+
cfg.set("remote_url", remote_url)
|
|
59
|
+
|
|
60
|
+
console.print(f"\n[green]✓ Initialized![/] This repo is linked to Jira project [bold]{project_key}[/]")
|
|
61
|
+
console.print(f"[dim]Config saved to .jitly.yaml[/]")
|
|
62
|
+
console.print("\nNext steps:")
|
|
63
|
+
console.print(" [cyan]jitly policy setup[/] — configure your workflow policies")
|
|
64
|
+
console.print(" [cyan]jitly start ABC-123[/] — start working on a ticket")
|
jitly/commands/policy.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""jitly policy — manage development workflow policies."""
|
|
2
|
+
import typer
|
|
3
|
+
import questionary
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from jitly.core.config import GlobalConfig
|
|
7
|
+
from jitly.core import backend_client
|
|
8
|
+
|
|
9
|
+
app = typer.Typer()
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
DEFAULT_BRANCH_TEMPLATE = "feature/{ticket_lower}-{desc}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_policy() -> dict:
|
|
16
|
+
cfg = GlobalConfig()
|
|
17
|
+
return cfg.get("policy") or {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _save_policy(policy: dict) -> None:
|
|
21
|
+
cfg = GlobalConfig()
|
|
22
|
+
cfg.set("policy", policy)
|
|
23
|
+
try:
|
|
24
|
+
backend_client.save_policy(policy)
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("setup")
|
|
30
|
+
def setup():
|
|
31
|
+
"""Interactively configure your development policies."""
|
|
32
|
+
console.print("\n[bold cyan]Development Policy Setup[/]\n")
|
|
33
|
+
policy = _load_policy()
|
|
34
|
+
|
|
35
|
+
base_branch = questionary.text(
|
|
36
|
+
"Base branch to pull from before creating new branches:",
|
|
37
|
+
default=policy.get("base_branch", "main"),
|
|
38
|
+
).ask()
|
|
39
|
+
|
|
40
|
+
branch_template = questionary.text(
|
|
41
|
+
"Branch name template (tokens: {ticket}, {ticket_lower}, {desc}):",
|
|
42
|
+
default=policy.get("branch_template", DEFAULT_BRANCH_TEMPLATE),
|
|
43
|
+
).ask()
|
|
44
|
+
|
|
45
|
+
auto_pull = questionary.confirm(
|
|
46
|
+
"Auto-pull base branch before creating a new branch?",
|
|
47
|
+
default=policy.get("auto_pull", True),
|
|
48
|
+
).ask()
|
|
49
|
+
|
|
50
|
+
commit_template = questionary.text(
|
|
51
|
+
"Commit message template (tokens: {ticket}, {desc}, {message}):",
|
|
52
|
+
default=policy.get("commit_template", "{ticket}: {desc}"),
|
|
53
|
+
).ask()
|
|
54
|
+
|
|
55
|
+
policy = {
|
|
56
|
+
"base_branch": base_branch,
|
|
57
|
+
"branch_template": branch_template,
|
|
58
|
+
"auto_pull": auto_pull,
|
|
59
|
+
"commit_template": commit_template,
|
|
60
|
+
}
|
|
61
|
+
_save_policy(policy)
|
|
62
|
+
console.print("\n[green]✓ Policy saved![/]")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command("show")
|
|
66
|
+
def show():
|
|
67
|
+
"""Display current development policies."""
|
|
68
|
+
policy = _load_policy()
|
|
69
|
+
if not policy:
|
|
70
|
+
console.print("[yellow]No policy configured. Run:[/] jitly policy setup")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
table = Table(title="Development Policy", show_header=False, box=None, padding=(0, 2))
|
|
74
|
+
for k, v in policy.items():
|
|
75
|
+
table.add_row(f"[dim]{k}[/]", str(v))
|
|
76
|
+
console.print(table)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command("set")
|
|
80
|
+
def set_policy(
|
|
81
|
+
key: str = typer.Argument(..., help="Policy key"),
|
|
82
|
+
value: str = typer.Argument(..., help="Policy value"),
|
|
83
|
+
):
|
|
84
|
+
"""Set a single policy value."""
|
|
85
|
+
policy = _load_policy()
|
|
86
|
+
if value.lower() in ("true", "false"):
|
|
87
|
+
value = value.lower() == "true"
|
|
88
|
+
policy[key] = value
|
|
89
|
+
_save_policy(policy)
|
|
90
|
+
console.print(f"[green]✓[/] {key} = {value}")
|
jitly/commands/start.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""jitly start <TICKET-ID> — begin work on a Jira ticket."""
|
|
2
|
+
import typer
|
|
3
|
+
import questionary
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from jitly.core.auth_store import load_credentials
|
|
8
|
+
from jitly.core.jira_client import JiraClient
|
|
9
|
+
from jitly.core.config import GlobalConfig, ProjectConfig
|
|
10
|
+
from jitly.core.git_ops import (
|
|
11
|
+
get_repo, is_dirty, current_branch, branch_exists_local, branch_exists_remote,
|
|
12
|
+
checkout_branch, fetch_and_checkout_remote_branch, pull_base_branch,
|
|
13
|
+
format_branch_name, stash_changes, push_branch, add_and_commit,
|
|
14
|
+
)
|
|
15
|
+
from jitly.core import backend_client
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_policy() -> dict:
|
|
21
|
+
cfg = GlobalConfig()
|
|
22
|
+
policy = cfg.get("policy")
|
|
23
|
+
if not policy:
|
|
24
|
+
console.print("[yellow]No policy configured yet. Let's set it up first.[/]\n")
|
|
25
|
+
from jitly.commands.policy import setup
|
|
26
|
+
setup()
|
|
27
|
+
policy = cfg.get("policy") or {}
|
|
28
|
+
return policy
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def start(
|
|
32
|
+
ticket_id: str = typer.Argument(..., help="Jira ticket ID (e.g. ABC-123)"),
|
|
33
|
+
remote: str = typer.Option("origin", "--remote", "-r", help="Git remote name"),
|
|
34
|
+
):
|
|
35
|
+
"""Start working on a Jira ticket — creates/switches to the right branch."""
|
|
36
|
+
creds = load_credentials()
|
|
37
|
+
if not creds:
|
|
38
|
+
console.print("[red]Not logged in. Run:[/] jitly auth login")
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
|
|
41
|
+
ticket_id = ticket_id.upper()
|
|
42
|
+
|
|
43
|
+
# Fetch ticket info
|
|
44
|
+
client = JiraClient(creds)
|
|
45
|
+
with console.status(f"Fetching ticket [bold]{ticket_id}[/]..."):
|
|
46
|
+
try:
|
|
47
|
+
issue = client.get_ticket(ticket_id)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
console.print(f"[red]Could not fetch ticket {ticket_id}:[/] {e}")
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
|
|
52
|
+
fields = issue.get("fields", {})
|
|
53
|
+
summary = fields.get("summary", "")
|
|
54
|
+
status = fields.get("status", {}).get("name", "")
|
|
55
|
+
issue_type = fields.get("issuetype", {}).get("name", "feature").lower()
|
|
56
|
+
|
|
57
|
+
console.print(
|
|
58
|
+
Panel(
|
|
59
|
+
f"[bold]{ticket_id}[/] — {summary}\n[dim]Status:[/] {status} [dim]Type:[/] {issue_type}",
|
|
60
|
+
title="Jira Ticket",
|
|
61
|
+
border_style="cyan",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
policy = _get_policy()
|
|
66
|
+
branch_name = format_branch_name(
|
|
67
|
+
policy["branch_template"], ticket_id, summary
|
|
68
|
+
).replace("{type}", issue_type)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
repo = get_repo()
|
|
72
|
+
except Exception:
|
|
73
|
+
console.print("[red]Not inside a git repository. Run:[/] jitly init")
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
# Handle dirty working directory — always ask at runtime
|
|
77
|
+
if is_dirty(repo):
|
|
78
|
+
dirty_action = questionary.select(
|
|
79
|
+
f"You have uncommitted changes on [bold]{current_branch(repo)}[/]. What would you like to do?",
|
|
80
|
+
choices=[
|
|
81
|
+
"stash — save changes and continue to new ticket",
|
|
82
|
+
"push — commit & push current changes first",
|
|
83
|
+
"cancel — abort",
|
|
84
|
+
],
|
|
85
|
+
).ask()
|
|
86
|
+
if not dirty_action or "cancel" in dirty_action:
|
|
87
|
+
console.print("[yellow]Aborted.[/]")
|
|
88
|
+
raise typer.Exit(0)
|
|
89
|
+
dirty_action = dirty_action.split(" — ")[0]
|
|
90
|
+
|
|
91
|
+
if dirty_action == "stash":
|
|
92
|
+
stash_changes(repo, f"jitly: before switching to {ticket_id}")
|
|
93
|
+
console.print("[dim]✓ Changes stashed[/]")
|
|
94
|
+
elif dirty_action == "push":
|
|
95
|
+
current = current_branch(repo)
|
|
96
|
+
msg = questionary.text(
|
|
97
|
+
"Commit message:", default=f"WIP: saving before {ticket_id}"
|
|
98
|
+
).ask()
|
|
99
|
+
add_and_commit(repo, msg)
|
|
100
|
+
push_branch(repo, current, remote)
|
|
101
|
+
console.print(f"[dim]✓ Changes committed and pushed to {current}[/]")
|
|
102
|
+
|
|
103
|
+
# Check if branch already exists remotely (someone else worked on this)
|
|
104
|
+
base_branch = policy["base_branch"]
|
|
105
|
+
already_existed_remote = branch_exists_remote(repo, branch_name, remote)
|
|
106
|
+
already_existed_local = branch_exists_local(repo, branch_name)
|
|
107
|
+
|
|
108
|
+
if already_existed_remote and not already_existed_local:
|
|
109
|
+
console.print(
|
|
110
|
+
f"\n[yellow]Branch [bold]{branch_name}[/] exists on remote.[/]\n"
|
|
111
|
+
"[dim]Someone may have already started this ticket. Fetching their work...[/]"
|
|
112
|
+
)
|
|
113
|
+
fetch_and_checkout_remote_branch(repo, branch_name, remote)
|
|
114
|
+
console.print(f"[green]✓ Checked out existing branch[/] [bold]{branch_name}[/]")
|
|
115
|
+
|
|
116
|
+
elif already_existed_local:
|
|
117
|
+
console.print(f"[dim]Branch exists locally, switching to[/] [bold]{branch_name}[/]")
|
|
118
|
+
checkout_branch(repo, branch_name)
|
|
119
|
+
|
|
120
|
+
else:
|
|
121
|
+
# Fresh branch: pull base first
|
|
122
|
+
if policy.get("auto_pull", True):
|
|
123
|
+
with console.status(f"Pulling latest [bold]{base_branch}[/]..."):
|
|
124
|
+
try:
|
|
125
|
+
pull_base_branch(repo, base_branch, remote)
|
|
126
|
+
console.print(f"[dim]✓ Pulled latest {base_branch}[/]")
|
|
127
|
+
except Exception as e:
|
|
128
|
+
console.print(f"[yellow]⚠ Could not pull {base_branch}: {e}[/]")
|
|
129
|
+
|
|
130
|
+
checkout_branch(repo, branch_name, create=True)
|
|
131
|
+
console.print(f"[green]✓ Created and checked out[/] [bold]{branch_name}[/]")
|
|
132
|
+
|
|
133
|
+
# Store active ticket in project config
|
|
134
|
+
proj_cfg = ProjectConfig()
|
|
135
|
+
proj_cfg.set("active_ticket", ticket_id)
|
|
136
|
+
proj_cfg.set("active_branch", branch_name)
|
|
137
|
+
|
|
138
|
+
# Track in backend
|
|
139
|
+
backend_client.track_event("ticket.started", {
|
|
140
|
+
"ticket_id": ticket_id,
|
|
141
|
+
"branch": branch_name,
|
|
142
|
+
"summary": summary,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
# Optionally transition ticket to "In Progress"
|
|
146
|
+
try:
|
|
147
|
+
client.transition_ticket(ticket_id, "In Progress")
|
|
148
|
+
console.print(f"[dim]✓ Ticket moved to In Progress[/]")
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
console.print(f"\n[bold green]Ready![/] You're now on [bold]{branch_name}[/]")
|
|
153
|
+
console.print(f"[dim]When done, run:[/] jitly done")
|
jitly/commands/status.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""jitly status — show current ticket and branch state."""
|
|
2
|
+
import typer
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from jitly.core.config import ProjectConfig
|
|
6
|
+
from jitly.core.git_ops import get_repo, current_branch, is_dirty
|
|
7
|
+
from jitly.core.auth_store import load_credentials
|
|
8
|
+
from jitly.core.jira_client import JiraClient
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def status():
|
|
14
|
+
"""Show current ticket, branch, and working directory status."""
|
|
15
|
+
proj_cfg = ProjectConfig()
|
|
16
|
+
ticket_id = proj_cfg.get("active_ticket")
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
repo = get_repo()
|
|
20
|
+
branch = current_branch(repo)
|
|
21
|
+
dirty = is_dirty(repo)
|
|
22
|
+
except Exception:
|
|
23
|
+
branch = "—"
|
|
24
|
+
dirty = False
|
|
25
|
+
|
|
26
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
27
|
+
table.add_row("[dim]Branch[/]", branch)
|
|
28
|
+
table.add_row("[dim]Working tree[/]", "[yellow]dirty[/]" if dirty else "[green]clean[/]")
|
|
29
|
+
|
|
30
|
+
if ticket_id:
|
|
31
|
+
table.add_row("[dim]Active ticket[/]", ticket_id)
|
|
32
|
+
creds = load_credentials()
|
|
33
|
+
if creds:
|
|
34
|
+
try:
|
|
35
|
+
client = JiraClient(creds)
|
|
36
|
+
issue = client.get_ticket(ticket_id)
|
|
37
|
+
fields = issue.get("fields", {})
|
|
38
|
+
table.add_row("[dim]Summary[/]", fields.get("summary", "—"))
|
|
39
|
+
table.add_row("[dim]Jira status[/]", fields.get("status", {}).get("name", "—"))
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
else:
|
|
43
|
+
table.add_row("[dim]Active ticket[/]", "[dim]none[/]")
|
|
44
|
+
|
|
45
|
+
console.print(table)
|
jitly/core/__init__.py
ADDED
|
File without changes
|
jitly/core/auth_store.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Secure credential storage using system keyring with YAML fallback."""
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import keyring
|
|
7
|
+
_KEYRING_AVAILABLE = True
|
|
8
|
+
except Exception:
|
|
9
|
+
_KEYRING_AVAILABLE = False
|
|
10
|
+
|
|
11
|
+
from jitly.core.config import GlobalConfig
|
|
12
|
+
|
|
13
|
+
SERVICE_NAME = "jitly"
|
|
14
|
+
CRED_KEY = "credentials"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def save_credentials(creds: dict) -> None:
|
|
18
|
+
if _KEYRING_AVAILABLE:
|
|
19
|
+
try:
|
|
20
|
+
keyring.set_password(SERVICE_NAME, CRED_KEY, json.dumps(creds))
|
|
21
|
+
return
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
# fallback: store in config (less secure but works everywhere)
|
|
25
|
+
cfg = GlobalConfig()
|
|
26
|
+
cfg.set("credentials", creds)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_credentials() -> Optional[dict]:
|
|
30
|
+
if _KEYRING_AVAILABLE:
|
|
31
|
+
try:
|
|
32
|
+
val = keyring.get_password(SERVICE_NAME, CRED_KEY)
|
|
33
|
+
if val:
|
|
34
|
+
return json.loads(val)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
cfg = GlobalConfig()
|
|
38
|
+
return cfg.get("credentials")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def delete_credentials() -> None:
|
|
42
|
+
if _KEYRING_AVAILABLE:
|
|
43
|
+
try:
|
|
44
|
+
keyring.delete_password(SERVICE_NAME, CRED_KEY)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
cfg = GlobalConfig()
|
|
48
|
+
cfg.delete("credentials")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""HTTP client for Jitly backend API."""
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import httpx
|
|
4
|
+
from jitly.core.config import get_backend_url, GlobalConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _get_token() -> Optional[str]:
|
|
8
|
+
return GlobalConfig().get("backend_token")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _headers() -> dict:
|
|
12
|
+
token = _get_token()
|
|
13
|
+
return {"Authorization": f"Bearer {token}"} if token else {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_user(jira_creds: dict, display_name: str, email: str) -> dict:
|
|
17
|
+
resp = httpx.post(
|
|
18
|
+
f"{get_backend_url()}/api/users/register",
|
|
19
|
+
json={"jira_creds": jira_creds, "display_name": display_name, "email": email},
|
|
20
|
+
timeout=15,
|
|
21
|
+
)
|
|
22
|
+
resp.raise_for_status()
|
|
23
|
+
return resp.json()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def track_event(event: str, payload: dict) -> None:
|
|
27
|
+
try:
|
|
28
|
+
httpx.post(
|
|
29
|
+
f"{get_backend_url()}/api/activity",
|
|
30
|
+
json={"event": event, "payload": payload},
|
|
31
|
+
headers=_headers(),
|
|
32
|
+
timeout=5,
|
|
33
|
+
)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass # activity tracking is non-blocking
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def save_policy(policy: dict) -> None:
|
|
39
|
+
resp = httpx.post(
|
|
40
|
+
f"{get_backend_url()}/api/policy",
|
|
41
|
+
json=policy,
|
|
42
|
+
headers=_headers(),
|
|
43
|
+
timeout=10,
|
|
44
|
+
)
|
|
45
|
+
resp.raise_for_status()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_policy() -> Optional[dict]:
|
|
49
|
+
try:
|
|
50
|
+
resp = httpx.get(
|
|
51
|
+
f"{get_backend_url()}/api/policy",
|
|
52
|
+
headers=_headers(),
|
|
53
|
+
timeout=10,
|
|
54
|
+
)
|
|
55
|
+
if resp.status_code == 200:
|
|
56
|
+
return resp.json()
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
return None
|
jitly/core/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Manages local config stored in ~/.jitly/config.yaml and per-project .jitly.yaml"""
|
|
2
|
+
import os
|
|
3
|
+
import yaml
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
from platformdirs import user_config_dir
|
|
7
|
+
|
|
8
|
+
GLOBAL_CONFIG_DIR = Path(user_config_dir("jitly"))
|
|
9
|
+
GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.yaml"
|
|
10
|
+
PROJECT_CONFIG_FILE = ".jitly.yaml"
|
|
11
|
+
|
|
12
|
+
# BACKEND_URL_DEFAULT = "https://api.jitly.dev" # override via JITLY_BACKEND_URL env
|
|
13
|
+
BACKEND_URL_DEFAULT = "http://localhost:8000"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_backend_url() -> str:
|
|
17
|
+
return os.environ.get("JITLY_BACKEND_URL", BACKEND_URL_DEFAULT)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_yaml(path: Path) -> dict:
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return {}
|
|
23
|
+
with open(path) as f:
|
|
24
|
+
return yaml.safe_load(f) or {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _save_yaml(path: Path, data: dict) -> None:
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
with open(path, "w") as f:
|
|
30
|
+
yaml.dump(data, f, default_flow_style=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GlobalConfig:
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self._data = _load_yaml(GLOBAL_CONFIG_FILE)
|
|
36
|
+
|
|
37
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
38
|
+
return self._data.get(key, default)
|
|
39
|
+
|
|
40
|
+
def set(self, key: str, value: Any) -> None:
|
|
41
|
+
self._data[key] = value
|
|
42
|
+
_save_yaml(GLOBAL_CONFIG_FILE, self._data)
|
|
43
|
+
|
|
44
|
+
def delete(self, key: str) -> None:
|
|
45
|
+
self._data.pop(key, None)
|
|
46
|
+
_save_yaml(GLOBAL_CONFIG_FILE, self._data)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def all(self) -> dict:
|
|
50
|
+
return self._data
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ProjectConfig:
|
|
54
|
+
def __init__(self, path: Optional[Path] = None):
|
|
55
|
+
self._path = Path(path or Path.cwd()) / PROJECT_CONFIG_FILE
|
|
56
|
+
self._data = _load_yaml(self._path)
|
|
57
|
+
|
|
58
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
59
|
+
return self._data.get(key, default)
|
|
60
|
+
|
|
61
|
+
def set(self, key: str, value: Any) -> None:
|
|
62
|
+
self._data[key] = value
|
|
63
|
+
_save_yaml(self._path, self._data)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def exists(self) -> bool:
|
|
67
|
+
return self._path.exists()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def all(self) -> dict:
|
|
71
|
+
return self._data
|
jitly/core/git_ops.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Git operations wrapper using GitPython."""
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import git
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_repo(path: Optional[Path] = None) -> git.Repo:
|
|
12
|
+
return git.Repo(path or Path.cwd(), search_parent_directories=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_dirty(repo: git.Repo) -> bool:
|
|
16
|
+
return repo.is_dirty(untracked_files=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def current_branch(repo: git.Repo) -> str:
|
|
20
|
+
return repo.active_branch.name
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def branch_exists_local(repo: git.Repo, branch: str) -> bool:
|
|
24
|
+
return branch in [b.name for b in repo.branches]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def branch_exists_remote(repo: git.Repo, branch: str, remote: str = "origin") -> bool:
|
|
28
|
+
try:
|
|
29
|
+
remote_obj = repo.remote(remote)
|
|
30
|
+
remote_obj.fetch()
|
|
31
|
+
return any(ref.name == f"{remote}/{branch}" for ref in remote_obj.refs)
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def checkout_branch(repo: git.Repo, branch: str, create: bool = False) -> None:
|
|
37
|
+
if create:
|
|
38
|
+
repo.git.checkout("-b", branch)
|
|
39
|
+
else:
|
|
40
|
+
repo.git.checkout(branch)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pull_base_branch(repo: git.Repo, base_branch: str, remote: str = "origin") -> None:
|
|
44
|
+
current = current_branch(repo)
|
|
45
|
+
repo.git.checkout(base_branch)
|
|
46
|
+
repo.git.pull(remote, base_branch)
|
|
47
|
+
repo.git.checkout(current)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fetch_and_checkout_remote_branch(repo: git.Repo, branch: str, remote: str = "origin") -> None:
|
|
51
|
+
repo.git.fetch(remote, branch)
|
|
52
|
+
repo.git.checkout("-b", branch, f"{remote}/{branch}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def stash_changes(repo: git.Repo, message: str = "jitly: auto-stash") -> None:
|
|
56
|
+
repo.git.stash("push", "-m", message)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pop_stash(repo: git.Repo) -> None:
|
|
60
|
+
repo.git.stash("pop")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def push_branch(repo: git.Repo, branch: str, remote: str = "origin") -> None:
|
|
64
|
+
repo.git.push("--set-upstream", remote, branch)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def add_and_commit(repo: git.Repo, message: str) -> None:
|
|
68
|
+
repo.git.add("-A")
|
|
69
|
+
repo.index.commit(message)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_remote_url(repo: git.Repo, remote: str = "origin") -> Optional[str]:
|
|
73
|
+
try:
|
|
74
|
+
return repo.remote(remote).url
|
|
75
|
+
except Exception:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_branch_name(template: str, ticket_id: str, description: str = "") -> str:
|
|
80
|
+
"""
|
|
81
|
+
Template tokens:
|
|
82
|
+
{ticket} → ticket ID (e.g. ABC-123)
|
|
83
|
+
{ticket_lower}→ lowercase ticket ID
|
|
84
|
+
{desc} → slugified ticket description
|
|
85
|
+
{type} → issue type (feature/bug/etc) — passed explicitly
|
|
86
|
+
"""
|
|
87
|
+
slug = re.sub(r"[^a-z0-9]+", "-", description.lower()).strip("-")[:40]
|
|
88
|
+
return (
|
|
89
|
+
template
|
|
90
|
+
.replace("{ticket}", ticket_id)
|
|
91
|
+
.replace("{ticket_lower}", ticket_id.lower())
|
|
92
|
+
.replace("{desc}", slug)
|
|
93
|
+
)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Jira API client supporting cloud (OAuth2/API token) and server (PAT)."""
|
|
2
|
+
import os
|
|
3
|
+
import webbrowser
|
|
4
|
+
import http.server
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import secrets
|
|
8
|
+
import base64
|
|
9
|
+
from typing import Optional
|
|
10
|
+
import httpx
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.prompt import Prompt
|
|
13
|
+
import questionary
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
|
|
16
|
+
load_dotenv()
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
JIRA_CLOUD_AUTH_URL = "https://auth.atlassian.com/authorize"
|
|
21
|
+
JIRA_CLOUD_TOKEN_URL = "https://auth.atlassian.com/oauth/token"
|
|
22
|
+
JIRA_CLOUD_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"
|
|
23
|
+
|
|
24
|
+
OAUTH_CLIENT_ID = os.environ.get("JITLY_OAUTH_CLIENT_ID", "")
|
|
25
|
+
OAUTH_CLIENT_SECRET = os.environ.get("JITLY_OAUTH_CLIENT_SECRET", "")
|
|
26
|
+
OAUTH_REDIRECT_PORT = int(os.environ.get("JITLY_OAUTH_PORT", "8727"))
|
|
27
|
+
OAUTH_REDIRECT_URI = f"http://localhost:{OAUTH_REDIRECT_PORT}/callback"
|
|
28
|
+
OAUTH_SCOPES = "read:me read:jira-work write:jira-work offline_access"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
32
|
+
code: Optional[str] = None
|
|
33
|
+
state: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
def do_GET(self):
|
|
36
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
37
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
38
|
+
OAuthCallbackHandler.code = params.get("code", [None])[0]
|
|
39
|
+
OAuthCallbackHandler.state = params.get("state", [None])[0]
|
|
40
|
+
self.send_response(200)
|
|
41
|
+
self.send_header("Content-type", "text/html")
|
|
42
|
+
self.end_headers()
|
|
43
|
+
self.wfile.write(b"<h2>Jitly: Authentication successful! You can close this tab.</h2>")
|
|
44
|
+
|
|
45
|
+
def log_message(self, *args):
|
|
46
|
+
pass # suppress server logs
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _oauth2_login() -> dict:
|
|
50
|
+
"""Atlassian OAuth2 3LO flow — opens browser, captures callback."""
|
|
51
|
+
if not OAUTH_CLIENT_ID or not OAUTH_CLIENT_SECRET:
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
"OAuth2 credentials not set.\n"
|
|
54
|
+
"Add JITLY_OAUTH_CLIENT_ID and JITLY_OAUTH_CLIENT_SECRET to your .env file."
|
|
55
|
+
)
|
|
56
|
+
state = secrets.token_urlsafe(16)
|
|
57
|
+
params = {
|
|
58
|
+
"audience": "api.atlassian.com",
|
|
59
|
+
"client_id": OAUTH_CLIENT_ID,
|
|
60
|
+
"scope": OAUTH_SCOPES,
|
|
61
|
+
"redirect_uri": OAUTH_REDIRECT_URI,
|
|
62
|
+
"state": state,
|
|
63
|
+
"response_type": "code",
|
|
64
|
+
"prompt": "consent",
|
|
65
|
+
}
|
|
66
|
+
url = f"{JIRA_CLOUD_AUTH_URL}?{urllib.parse.urlencode(params)}"
|
|
67
|
+
|
|
68
|
+
OAuthCallbackHandler.code = None
|
|
69
|
+
server = http.server.HTTPServer(("localhost", OAUTH_REDIRECT_PORT), OAuthCallbackHandler)
|
|
70
|
+
thread = threading.Thread(target=server.handle_request)
|
|
71
|
+
thread.daemon = True
|
|
72
|
+
thread.start()
|
|
73
|
+
|
|
74
|
+
console.print(f"\n[bold cyan]Opening browser for Jira SSO login...[/]")
|
|
75
|
+
console.print(f"If browser doesn't open, visit:\n[link]{url}[/link]\n")
|
|
76
|
+
webbrowser.open(url)
|
|
77
|
+
thread.join(timeout=120)
|
|
78
|
+
server.server_close()
|
|
79
|
+
|
|
80
|
+
code = OAuthCallbackHandler.code
|
|
81
|
+
if not code:
|
|
82
|
+
raise RuntimeError("OAuth2 login timed out or was cancelled.")
|
|
83
|
+
|
|
84
|
+
# Exchange code for tokens
|
|
85
|
+
resp = httpx.post(
|
|
86
|
+
JIRA_CLOUD_TOKEN_URL,
|
|
87
|
+
json={
|
|
88
|
+
"grant_type": "authorization_code",
|
|
89
|
+
"client_id": OAUTH_CLIENT_ID,
|
|
90
|
+
"client_secret": OAUTH_CLIENT_SECRET,
|
|
91
|
+
"code": code,
|
|
92
|
+
"redirect_uri": OAUTH_REDIRECT_URI,
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
tokens = resp.json()
|
|
97
|
+
|
|
98
|
+
# Fetch accessible Jira sites
|
|
99
|
+
sites_resp = httpx.get(
|
|
100
|
+
JIRA_CLOUD_RESOURCES_URL,
|
|
101
|
+
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
|
102
|
+
)
|
|
103
|
+
sites_resp.raise_for_status()
|
|
104
|
+
sites = sites_resp.json()
|
|
105
|
+
|
|
106
|
+
site = sites[0] if len(sites) == 1 else _pick_site(sites)
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"auth_type": "oauth2",
|
|
110
|
+
"access_token": tokens["access_token"],
|
|
111
|
+
"refresh_token": tokens.get("refresh_token"),
|
|
112
|
+
"cloud_id": site["id"],
|
|
113
|
+
"site_url": site["url"],
|
|
114
|
+
"site_name": site["name"],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _pick_site(sites: list) -> dict:
|
|
119
|
+
choices = [s["name"] for s in sites]
|
|
120
|
+
name = questionary.select("Select your Jira site:", choices=choices).ask()
|
|
121
|
+
return next(s for s in sites if s["name"] == name)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _api_token_login() -> dict:
|
|
125
|
+
"""Classic Jira Cloud: email + API token."""
|
|
126
|
+
console.print("\n[bold]Generate an API token at:[/] https://id.atlassian.com/manage-profile/security/api-tokens")
|
|
127
|
+
site_url = Prompt.ask("Jira site URL (e.g. https://yourcompany.atlassian.net)").rstrip("/")
|
|
128
|
+
email = Prompt.ask("Atlassian email")
|
|
129
|
+
token = Prompt.ask("API token", password=True)
|
|
130
|
+
|
|
131
|
+
creds_b64 = base64.b64encode(f"{email}:{token}".encode()).decode()
|
|
132
|
+
resp = httpx.get(
|
|
133
|
+
f"{site_url}/rest/api/3/myself",
|
|
134
|
+
headers={"Authorization": f"Basic {creds_b64}", "Accept": "application/json"},
|
|
135
|
+
)
|
|
136
|
+
resp.raise_for_status()
|
|
137
|
+
me = resp.json()
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
"auth_type": "api_token",
|
|
141
|
+
"site_url": site_url,
|
|
142
|
+
"email": email,
|
|
143
|
+
"api_token": token,
|
|
144
|
+
"account_id": me["accountId"],
|
|
145
|
+
"display_name": me.get("displayName", email),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _pat_login() -> dict:
|
|
150
|
+
"""Jira Server / Data Center: PAT or username+password."""
|
|
151
|
+
site_url = Prompt.ask("Jira Server URL (e.g. https://jira.mycompany.com)").rstrip("/")
|
|
152
|
+
method = questionary.select(
|
|
153
|
+
"Auth method for Jira Server:",
|
|
154
|
+
choices=["Personal Access Token (PAT)", "Username + Password"],
|
|
155
|
+
).ask()
|
|
156
|
+
|
|
157
|
+
if method == "Personal Access Token (PAT)":
|
|
158
|
+
pat = Prompt.ask("PAT", password=True)
|
|
159
|
+
headers = {"Authorization": f"Bearer {pat}", "Accept": "application/json"}
|
|
160
|
+
else:
|
|
161
|
+
username = Prompt.ask("Username")
|
|
162
|
+
password = Prompt.ask("Password", password=True)
|
|
163
|
+
creds_b64 = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
164
|
+
headers = {"Authorization": f"Basic {creds_b64}", "Accept": "application/json"}
|
|
165
|
+
|
|
166
|
+
resp = httpx.get(f"{site_url}/rest/api/2/myself", headers=headers)
|
|
167
|
+
resp.raise_for_status()
|
|
168
|
+
me = resp.json()
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"auth_type": "pat" if "PAT" in method else "basic",
|
|
172
|
+
"site_url": site_url,
|
|
173
|
+
"pat": pat if "PAT" in method else None,
|
|
174
|
+
"username": me.get("name") or me.get("displayName"),
|
|
175
|
+
"account_id": me.get("accountId") or me.get("key"),
|
|
176
|
+
"display_name": me.get("displayName"),
|
|
177
|
+
**({"api_token": password} if "Password" in method else {}),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def interactive_login() -> dict:
|
|
182
|
+
"""Prompt user to choose auth method then perform login."""
|
|
183
|
+
method = questionary.select(
|
|
184
|
+
"How would you like to login to Jira?",
|
|
185
|
+
choices=[
|
|
186
|
+
"SSO / OAuth2 (browser-based) — Jira Cloud",
|
|
187
|
+
"API Token — Jira Cloud",
|
|
188
|
+
"Personal Access Token / Password — Jira Server or Data Center",
|
|
189
|
+
],
|
|
190
|
+
).ask()
|
|
191
|
+
|
|
192
|
+
if not method:
|
|
193
|
+
raise SystemExit("Login cancelled.")
|
|
194
|
+
|
|
195
|
+
if "SSO" in method:
|
|
196
|
+
return _oauth2_login()
|
|
197
|
+
elif "API Token" in method:
|
|
198
|
+
return _api_token_login()
|
|
199
|
+
else:
|
|
200
|
+
return _pat_login()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class JiraClient:
|
|
204
|
+
def __init__(self, creds: dict):
|
|
205
|
+
self.creds = creds
|
|
206
|
+
self._base_url = self._resolve_base_url()
|
|
207
|
+
self._headers = self._resolve_headers()
|
|
208
|
+
|
|
209
|
+
def _resolve_base_url(self) -> str:
|
|
210
|
+
auth_type = self.creds["auth_type"]
|
|
211
|
+
if auth_type == "oauth2":
|
|
212
|
+
return f"https://api.atlassian.com/ex/jira/{self.creds['cloud_id']}/rest/api/3"
|
|
213
|
+
return f"{self.creds['site_url']}/rest/api/3"
|
|
214
|
+
|
|
215
|
+
def _resolve_headers(self) -> dict:
|
|
216
|
+
auth_type = self.creds["auth_type"]
|
|
217
|
+
if auth_type == "oauth2":
|
|
218
|
+
return {
|
|
219
|
+
"Authorization": f"Bearer {self.creds['access_token']}",
|
|
220
|
+
"Accept": "application/json",
|
|
221
|
+
}
|
|
222
|
+
elif auth_type == "api_token":
|
|
223
|
+
creds_b64 = base64.b64encode(
|
|
224
|
+
f"{self.creds['email']}:{self.creds['api_token']}".encode()
|
|
225
|
+
).decode()
|
|
226
|
+
return {"Authorization": f"Basic {creds_b64}", "Accept": "application/json"}
|
|
227
|
+
else: # pat or basic
|
|
228
|
+
if self.creds.get("pat"):
|
|
229
|
+
return {"Authorization": f"Bearer {self.creds['pat']}", "Accept": "application/json"}
|
|
230
|
+
creds_b64 = base64.b64encode(
|
|
231
|
+
f"{self.creds['username']}:{self.creds['api_token']}".encode()
|
|
232
|
+
).decode()
|
|
233
|
+
return {"Authorization": f"Basic {creds_b64}", "Accept": "application/json"}
|
|
234
|
+
|
|
235
|
+
def get_ticket(self, ticket_id: str) -> dict:
|
|
236
|
+
resp = httpx.get(
|
|
237
|
+
f"{self._base_url}/issue/{ticket_id}",
|
|
238
|
+
headers=self._headers,
|
|
239
|
+
params={"fields": "summary,status,assignee,issuetype,project"},
|
|
240
|
+
)
|
|
241
|
+
resp.raise_for_status()
|
|
242
|
+
return resp.json()
|
|
243
|
+
|
|
244
|
+
def get_myself(self) -> dict:
|
|
245
|
+
# OAuth2 tokens use the Atlassian identity endpoint for user info
|
|
246
|
+
if self.creds.get("auth_type") == "oauth2":
|
|
247
|
+
resp = httpx.get(
|
|
248
|
+
"https://api.atlassian.com/me",
|
|
249
|
+
headers=self._headers,
|
|
250
|
+
)
|
|
251
|
+
resp.raise_for_status()
|
|
252
|
+
data = resp.json()
|
|
253
|
+
# Normalize to same shape as Jira /myself response
|
|
254
|
+
return {
|
|
255
|
+
"accountId": data.get("account_id"),
|
|
256
|
+
"displayName": data.get("name"),
|
|
257
|
+
"emailAddress": data.get("email"),
|
|
258
|
+
}
|
|
259
|
+
resp = httpx.get(f"{self._base_url}/myself", headers=self._headers)
|
|
260
|
+
resp.raise_for_status()
|
|
261
|
+
return resp.json()
|
|
262
|
+
|
|
263
|
+
def transition_ticket(self, ticket_id: str, status_name: str) -> None:
|
|
264
|
+
transitions_resp = httpx.get(
|
|
265
|
+
f"{self._base_url}/issue/{ticket_id}/transitions",
|
|
266
|
+
headers=self._headers,
|
|
267
|
+
)
|
|
268
|
+
transitions_resp.raise_for_status()
|
|
269
|
+
transitions = transitions_resp.json()["transitions"]
|
|
270
|
+
match = next((t for t in transitions if t["name"].lower() == status_name.lower()), None)
|
|
271
|
+
if match:
|
|
272
|
+
httpx.post(
|
|
273
|
+
f"{self._base_url}/issue/{ticket_id}/transitions",
|
|
274
|
+
headers={**self._headers, "Content-Type": "application/json"},
|
|
275
|
+
json={"transition": {"id": match["id"]}},
|
|
276
|
+
)
|
jitly/main.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from jitly.commands import auth, init, start, done, policy, status
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(
|
|
6
|
+
name="jitly",
|
|
7
|
+
help="Bridge between Jira tickets and your local development workflow.",
|
|
8
|
+
no_args_is_help=True,
|
|
9
|
+
rich_markup_mode="rich",
|
|
10
|
+
)
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
app.add_typer(auth.app, name="auth", help="Manage Jira authentication")
|
|
14
|
+
app.add_typer(policy.app, name="policy", help="Manage development policies")
|
|
15
|
+
|
|
16
|
+
app.command()(init.init)
|
|
17
|
+
app.command()(start.start)
|
|
18
|
+
app.command()(done.done)
|
|
19
|
+
app.command()(status.status)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
app()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|
jitly/utils/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jitly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bridge between Jira tickets and your local development workflow
|
|
5
|
+
Author: Jitly
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: jira,git,developer-tools,workflow
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: typer[all]>=0.12.0
|
|
17
|
+
Requires-Dist: rich>=13.0.0
|
|
18
|
+
Requires-Dist: httpx>=0.27.0
|
|
19
|
+
Requires-Dist: gitpython>=3.1.40
|
|
20
|
+
Requires-Dist: keyring>=25.0.0
|
|
21
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
22
|
+
Requires-Dist: requests>=2.31.0
|
|
23
|
+
Requires-Dist: requests-oauthlib>=1.3.1
|
|
24
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
25
|
+
Requires-Dist: click>=8.1.0
|
|
26
|
+
Requires-Dist: questionary>=2.0.1
|
|
27
|
+
Requires-Dist: platformdirs>=4.2.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
jitly/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
jitly/main.py,sha256=psx3Ts7xAB2pkKM_t5DozvRC1r8TTRXifGfATblfRf4,626
|
|
3
|
+
jitly/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
jitly/commands/auth.py,sha256=D2qBHCsILHbs2Nm-aVCT1xsdcxkyhrjlw_hfbwSZtYM,2519
|
|
5
|
+
jitly/commands/done.py,sha256=70nBjlydKoObDb1Tsi-qXO9v9qNP685AM0VPIl_msJU,4523
|
|
6
|
+
jitly/commands/init.py,sha256=uoVDUocMe4aWuIVcCNaUGrWP6_g-CafWtiFZe7NCEdE,2282
|
|
7
|
+
jitly/commands/policy.py,sha256=ZCwsQ6B5mjlCmRYATErfZCLO5cg05VaIcxmZ8MT0xLQ,2601
|
|
8
|
+
jitly/commands/start.py,sha256=_R_eTZSv6nLuwSADNw_E8A8FUC_rwcmHopE3c9V6-5w,5859
|
|
9
|
+
jitly/commands/status.py,sha256=YGFYgJVTfqj7DOztH_Qj8yyyFex1Pg0hvay5HAKAexE,1519
|
|
10
|
+
jitly/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
jitly/core/auth_store.py,sha256=uSoGW5KZ_z4gJLUVVv9Kdgt8aNqKQTdMEVRrm0_-ZSw,1201
|
|
12
|
+
jitly/core/backend_client.py,sha256=YcfX3gK7h82XXIgYOlWhJ7iI7asIhSasnC7MzpwZxsA,1496
|
|
13
|
+
jitly/core/config.py,sha256=pIOv11qZyOOrXJhRU4oFjUYmT0GnjTVSJRlPkd8CyAQ,2005
|
|
14
|
+
jitly/core/git_ops.py,sha256=cYYyzrwOxPa_fQoXQ3A5C05JFnRs117OVrL7g9UV6xY,2669
|
|
15
|
+
jitly/core/jira_client.py,sha256=FGhfR-xILCwaZy111LABJpfdko52QAR_jLwFr28nI9k,10017
|
|
16
|
+
jitly/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
jitly-0.1.0.dist-info/METADATA,sha256=_gJW-s_OqiL-huyss0VEC_l_DfIQHbyDuQelW5tCuu0,924
|
|
18
|
+
jitly-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
19
|
+
jitly-0.1.0.dist-info/entry_points.txt,sha256=AIEOvvr-_kCmH2uO-Ky4PzP19HqHqBdceO-KzxY1gmU,41
|
|
20
|
+
jitly-0.1.0.dist-info/top_level.txt,sha256=O1xlfgTU33pTqIIazAFZ-Pe97aVgpolWjnBw4h1xjWE,6
|
|
21
|
+
jitly-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jitly
|