jitly 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -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 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -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)
@@ -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}.")
@@ -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")
@@ -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}")
@@ -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")
@@ -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)
File without changes
@@ -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
@@ -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
@@ -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
+ )
@@ -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()
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,23 @@
1
+ pyproject.toml
2
+ jitly/__init__.py
3
+ jitly/main.py
4
+ jitly.egg-info/PKG-INFO
5
+ jitly.egg-info/SOURCES.txt
6
+ jitly.egg-info/dependency_links.txt
7
+ jitly.egg-info/entry_points.txt
8
+ jitly.egg-info/requires.txt
9
+ jitly.egg-info/top_level.txt
10
+ jitly/commands/__init__.py
11
+ jitly/commands/auth.py
12
+ jitly/commands/done.py
13
+ jitly/commands/init.py
14
+ jitly/commands/policy.py
15
+ jitly/commands/start.py
16
+ jitly/commands/status.py
17
+ jitly/core/__init__.py
18
+ jitly/core/auth_store.py
19
+ jitly/core/backend_client.py
20
+ jitly/core/config.py
21
+ jitly/core/git_ops.py
22
+ jitly/core/jira_client.py
23
+ jitly/utils/__init__.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jitly = jitly.main:app
@@ -0,0 +1,12 @@
1
+ typer[all]>=0.12.0
2
+ rich>=13.0.0
3
+ httpx>=0.27.0
4
+ gitpython>=3.1.40
5
+ keyring>=25.0.0
6
+ pyyaml>=6.0.1
7
+ requests>=2.31.0
8
+ requests-oauthlib>=1.3.1
9
+ python-dotenv>=1.0.0
10
+ click>=8.1.0
11
+ questionary>=2.0.1
12
+ platformdirs>=4.2.0
@@ -0,0 +1 @@
1
+ jitly
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "jitly"
7
+ version = "0.1.0"
8
+ description = "Bridge between Jira tickets and your local development workflow"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Jitly" }]
13
+ keywords = ["jira", "git", "developer-tools", "workflow"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Version Control",
21
+ ]
22
+ dependencies = [
23
+ "typer[all]>=0.12.0",
24
+ "rich>=13.0.0",
25
+ "httpx>=0.27.0",
26
+ "gitpython>=3.1.40",
27
+ "keyring>=25.0.0",
28
+ "pyyaml>=6.0.1",
29
+ "requests>=2.31.0",
30
+ "requests-oauthlib>=1.3.1",
31
+ "python-dotenv>=1.0.0",
32
+ "click>=8.1.0",
33
+ "questionary>=2.0.1",
34
+ "platformdirs>=4.2.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ jitly = "jitly.main:app"
39
+
40
+ [tool.setuptools]
41
+ py-modules = []
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["."]
45
+ include = ["jitly*"]
jitly-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+