bitbucket-manager 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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from bitbucket_manager.tui import run_tui
2
+
3
+ if __name__ == "__main__":
4
+ run_tui()
@@ -0,0 +1,496 @@
1
+ import os
2
+ import subprocess
3
+ import requests
4
+ from datetime import datetime, timezone
5
+
6
+ from bitbucket_manager.config import get_auth, load_env_file
7
+
8
+ load_env_file()
9
+
10
+ BASE_URL = "https://api.bitbucket.org/2.0"
11
+
12
+
13
+ def _http_auth():
14
+ token, username, _ = get_auth()
15
+ if not token or not username:
16
+ return None
17
+ return (username, token)
18
+
19
+ def get_repos(workspace=None):
20
+ auth = _http_auth()
21
+ if not auth:
22
+ print("Error: credenciales no configuradas")
23
+ return []
24
+
25
+ _, _, ws = get_auth()
26
+ workspace = workspace or ws
27
+ if not workspace:
28
+ print("Error: BB_WORKSPACE no está configurado")
29
+ return []
30
+
31
+ dev_dir = os.environ.get("DEV_DIR", os.path.join(os.path.expanduser("~"), "bitbucket-repos"))
32
+ try:
33
+ all_repos = []
34
+ repos_url = f"{BASE_URL}/repositories/{workspace}?pagelen=100&role=member"
35
+
36
+ while repos_url:
37
+ response = requests.get(repos_url, auth=auth, timeout=10)
38
+ if response.status_code != 200:
39
+ print(f"Error: {response.status_code}")
40
+ break
41
+ data = response.json()
42
+ for repo in data.get('values', []):
43
+ updated = repo.get('updated_on', '')
44
+ try:
45
+ updated_dt = datetime.fromisoformat(updated.replace('Z', '+00:00'))
46
+ except Exception:
47
+ updated_dt = datetime.min.replace(tzinfo=timezone.utc)
48
+ all_repos.append({
49
+ 'name': repo['name'],
50
+ 'url': repo['links']['html']['href'],
51
+ 'workspace': workspace,
52
+ 'ws_slug': workspace,
53
+ 'updated': updated_dt,
54
+ 'updated_str': updated[:10] if updated else 'N/A',
55
+ 'cloned': _is_cloned(repo['name'], dev_dir),
56
+ })
57
+ repos_url = data.get('next')
58
+
59
+ all_repos.sort(key=lambda x: x['updated'], reverse=True)
60
+ return all_repos
61
+ except Exception as e:
62
+ print(f"Error de conexión: {e}")
63
+ return []
64
+
65
+ def _is_cloned(repo_name, dev_dir):
66
+ target = os.path.join(dev_dir, repo_name)
67
+ return os.path.exists(target) and os.path.isdir(os.path.join(target, '.git'))
68
+
69
+ def clone_repo(repo_name, workspace=None):
70
+ _, _, ws = get_auth()
71
+ workspace = workspace or ws
72
+ dev_dir = os.environ.get("DEV_DIR", os.path.join(os.path.expanduser("~"), "bitbucket-repos"))
73
+ repo_url = f"git@bitbucket.org:{workspace}/{repo_name}.git"
74
+ target_dir = os.path.join(dev_dir, repo_name)
75
+
76
+ if os.path.exists(target_dir):
77
+ return False, f"El directorio {target_dir} ya existe"
78
+
79
+ try:
80
+ os.makedirs(dev_dir, exist_ok=True)
81
+ result = subprocess.run(['git', 'clone', repo_url, target_dir],
82
+ capture_output=True, text=True)
83
+ if result.returncode == 0:
84
+ subprocess.run(['git', 'config', 'user.name',
85
+ os.environ.get('GIT_USER_NAME', 'Your Name')], cwd=target_dir)
86
+ subprocess.run(['git', 'config', 'user.email',
87
+ os.environ.get('GIT_USER_EMAIL', 'your-email@example.com')], cwd=target_dir)
88
+ return True, f"Repositorio clonado en: {target_dir}"
89
+ return False, result.stderr
90
+ except Exception as e:
91
+ return False, str(e)
92
+
93
+
94
+ def pull_repo(repo_name, workspace=None):
95
+ _, _, ws = get_auth()
96
+ workspace = workspace or ws
97
+ dev_dir = os.environ.get("DEV_DIR", os.path.join(os.path.expanduser("~"), "bitbucket-repos"))
98
+ target_dir = os.path.join(dev_dir, repo_name)
99
+
100
+ if not os.path.isdir(os.path.join(target_dir, '.git')):
101
+ return False, f"{repo_name} no está clonado en {target_dir}"
102
+
103
+ try:
104
+ result = subprocess.run(['git', '-C', target_dir, 'pull'],
105
+ capture_output=True, text=True)
106
+ if result.returncode == 0:
107
+ return True, result.stdout.strip() or "Pull exitoso"
108
+ return False, result.stderr
109
+ except Exception as e:
110
+ return False, str(e)
111
+
112
+
113
+ def get_repo_branches(repo_name):
114
+ dev_dir = os.environ.get("DEV_DIR", os.path.join(os.path.expanduser("~"), "bitbucket-repos"))
115
+ target_dir = os.path.join(dev_dir, repo_name)
116
+
117
+ if not os.path.isdir(os.path.join(target_dir, '.git')):
118
+ return None
119
+
120
+ try:
121
+ result = subprocess.run(['git', '-C', target_dir, 'branch', '-a'],
122
+ capture_output=True, text=True)
123
+ if result.returncode != 0:
124
+ return None
125
+ branches = []
126
+ for line in result.stdout.strip().split("\n"):
127
+ line = line.strip()
128
+ if not line or line.startswith("HEAD") or "HEAD ->" in line:
129
+ continue
130
+ if line.startswith("* "):
131
+ branches.append(line[2:].strip())
132
+ elif line.startswith("remotes/origin/"):
133
+ b = line.replace("remotes/origin/", "").strip()
134
+ if b and b not in branches and b != "HEAD":
135
+ branches.append(b)
136
+ else:
137
+ b = line.strip()
138
+ if b and b not in branches:
139
+ branches.append(b)
140
+ return branches
141
+ except Exception:
142
+ return None
143
+
144
+
145
+ def checkout_repo(repo_name, branch):
146
+ dev_dir = os.environ.get("DEV_DIR", os.path.join(os.path.expanduser("~"), "bitbucket-repos"))
147
+ target_dir = os.path.join(dev_dir, repo_name)
148
+
149
+ if not os.path.isdir(os.path.join(target_dir, '.git')):
150
+ return False, f"{repo_name} no está clonado"
151
+
152
+ try:
153
+ result = subprocess.run(['git', '-C', target_dir, 'checkout', branch],
154
+ capture_output=True, text=True)
155
+ if result.returncode == 0:
156
+ return True, f"Checkout a '{branch}' exitoso"
157
+ return False, result.stderr
158
+ except Exception as e:
159
+ return False, str(e)
160
+
161
+ def get_permissions_users(workspace, repo):
162
+ auth = _http_auth()
163
+ if not auth:
164
+ return None
165
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}/permissions-config/users"
166
+ try:
167
+ r = requests.get(url, auth=auth, timeout=10)
168
+ if r.status_code == 200:
169
+ return r.json().get('values', [])
170
+ if r.status_code in (401, 403):
171
+ return [] # No tenemos permiso de admin en este repo; no es un error fatal
172
+ print(f" Error al obtener permisos de usuarios: {r.status_code}")
173
+ return None
174
+ except Exception as e:
175
+ print(f" Error de conexión: {e}")
176
+ return None
177
+
178
+ def get_permissions_groups(workspace, repo):
179
+ auth = _http_auth()
180
+ if not auth:
181
+ return None
182
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}/permissions-config/groups"
183
+ try:
184
+ r = requests.get(url, auth=auth, timeout=10)
185
+ if r.status_code == 200:
186
+ return r.json().get('values', [])
187
+ if r.status_code in (401, 403):
188
+ return [] # No tenemos permiso de admin en este repo
189
+ print(f" Error al obtener permisos de grupos: {r.status_code}")
190
+ return None
191
+ except Exception as e:
192
+ print(f" Error de conexión: {e}")
193
+ return None
194
+
195
+ def set_user_permission(workspace, repo, user, permission):
196
+ auth = _http_auth()
197
+ if not auth:
198
+ return False, "Credenciales no configuradas"
199
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}/permissions-config/users/{user}"
200
+ try:
201
+ r = requests.put(url, auth=auth, json={"permission": permission.lower()}, timeout=10)
202
+ if r.status_code in (200, 201, 204):
203
+ return True, None
204
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
205
+ except Exception as e:
206
+ return False, str(e)
207
+
208
+ def delete_user_permission(workspace, repo, user):
209
+ auth = _http_auth()
210
+ if not auth:
211
+ return False, "Credenciales no configuradas"
212
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}/permissions-config/users/{user}"
213
+ try:
214
+ r = requests.delete(url, auth=auth, timeout=10)
215
+ if r.status_code in (200, 204):
216
+ return True, None
217
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
218
+ except Exception as e:
219
+ return False, str(e)
220
+
221
+ def set_group_permission(workspace, repo, group, permission):
222
+ auth = _http_auth()
223
+ if not auth:
224
+ return False, "Credenciales no configuradas"
225
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}/permissions-config/groups/{group}"
226
+ try:
227
+ r = requests.put(url, auth=auth, json={"permission": permission.lower()}, timeout=10)
228
+ if r.status_code in (200, 201, 204):
229
+ return True, None
230
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
231
+ except Exception as e:
232
+ return False, str(e)
233
+
234
+ def delete_group_permission(workspace, repo, group):
235
+ auth = _http_auth()
236
+ if not auth:
237
+ return False, "Credenciales no configuradas"
238
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}/permissions-config/groups/{group}"
239
+ try:
240
+ r = requests.delete(url, auth=auth, timeout=10)
241
+ if r.status_code in (200, 204):
242
+ return True, None
243
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
244
+ except Exception as e:
245
+ return False, str(e)
246
+
247
+
248
+ # ─── PRs ────────────────────────────────────────────────────────────────────
249
+
250
+ def get_pullrequests(workspace, repo, state='OPEN', limit=50):
251
+ auth = _http_auth()
252
+ if not auth:
253
+ return None
254
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}/pullrequests?state={state}&pagelen={limit}"
255
+ try:
256
+ r = requests.get(url, auth=auth, timeout=10)
257
+ if r.status_code == 200:
258
+ return r.json().get('values', [])
259
+ if r.status_code == 403:
260
+ print(f" PRs 403 en {repo}: verificá que el token tenga scope 'pullrequest'")
261
+ return None
262
+ print(f" Error al obtener PRs: {r.status_code}")
263
+ return None
264
+ except Exception as e:
265
+ print(f" Error de conexión: {e}")
266
+ return None
267
+
268
+
269
+ def approve_pullrequest(workspace, repo, pr_id):
270
+ auth = _http_auth()
271
+ if not auth:
272
+ return False, "Credenciales no configuradas"
273
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}/pullrequests/{pr_id}/approve"
274
+ try:
275
+ r = requests.post(url, auth=auth, timeout=10)
276
+ if r.status_code in (200, 201, 204):
277
+ return True, None
278
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
279
+ except Exception as e:
280
+ return False, str(e)
281
+
282
+
283
+ def unapprove_pullrequest(workspace, repo, pr_id):
284
+ auth = _http_auth()
285
+ if not auth:
286
+ return False, "Credenciales no configuradas"
287
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}/pullrequests/{pr_id}/approve"
288
+ try:
289
+ r = requests.delete(url, auth=auth, timeout=10)
290
+ if r.status_code in (200, 204):
291
+ return True, None
292
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
293
+ except Exception as e:
294
+ return False, str(e)
295
+
296
+
297
+ def get_branches(workspace, repo, limit=100):
298
+ auth = _http_auth()
299
+ if not auth:
300
+ return None
301
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}/refs/branches?pagelen={limit}"
302
+ try:
303
+ r = requests.get(url, auth=auth, timeout=10)
304
+ if r.status_code == 200:
305
+ return r.json().get('values', [])
306
+ print(f" Error al obtener branches: {r.status_code}")
307
+ return None
308
+ except Exception as e:
309
+ print(f" Error de conexión: {e}")
310
+ return None
311
+
312
+
313
+ def create_repository(workspace, repo_name, is_private=True):
314
+ auth = _http_auth()
315
+ if not auth:
316
+ return False, "Credenciales no configuradas"
317
+ url = f"{BASE_URL}/repositories/{workspace}/{repo_name}"
318
+ try:
319
+ r = requests.post(url, auth=auth,
320
+ json={"scm": "git", "is_private": is_private}, timeout=15)
321
+ if r.status_code in (200, 201):
322
+ return True, r.json()
323
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
324
+ except Exception as e:
325
+ return False, str(e)
326
+
327
+
328
+ def update_repository(workspace, repo, data):
329
+ auth = _http_auth()
330
+ if not auth:
331
+ return False, "Credenciales no configuradas"
332
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}"
333
+ try:
334
+ r = requests.put(url, auth=auth, json=data, timeout=10)
335
+ if r.status_code == 200:
336
+ return True, None
337
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
338
+ except Exception as e:
339
+ return False, str(e)
340
+
341
+
342
+ def get_workspace_projects(workspace):
343
+ auth = _http_auth()
344
+ if not auth:
345
+ return None
346
+ url = f"{BASE_URL}/workspaces/{workspace}/projects"
347
+ try:
348
+ r = requests.get(url, auth=auth, timeout=10)
349
+ if r.status_code == 200:
350
+ return r.json().get('values', [])
351
+ print(f" Error al obtener proyectos: {r.status_code}")
352
+ return None
353
+ except Exception as e:
354
+ print(f" Error de conexión: {e}")
355
+ return None
356
+
357
+
358
+ def upsert_workspace_project(workspace, project_key, name, description=""):
359
+ auth = _http_auth()
360
+ if not auth:
361
+ return None
362
+ url = f"{BASE_URL}/workspaces/{workspace}/projects/{project_key}"
363
+ try:
364
+ r = requests.put(url, auth=auth,
365
+ json={"name": name, "key": project_key, "description": description},
366
+ timeout=10)
367
+ if r.status_code in (200, 201):
368
+ return r.json()
369
+ print(f" Error al crear/actualizar proyecto: {r.status_code}")
370
+ return None
371
+ except Exception as e:
372
+ print(f" Error de conexión: {e}")
373
+ return None
374
+
375
+
376
+ # ─── Groups ──────────────────────────────────────────────────────────────────
377
+
378
+ def get_workspace_groups(workspace):
379
+ auth = _http_auth()
380
+ if not auth:
381
+ return None
382
+ url = f"{BASE_URL}/workspaces/{workspace}/groups"
383
+ try:
384
+ r = requests.get(url, auth=auth, timeout=10)
385
+ if r.status_code == 200:
386
+ return r.json().get('values', [])
387
+ print(f" Error al obtener grupos: {r.status_code}")
388
+ return None
389
+ except Exception as e:
390
+ print(f" Error de conexión: {e}")
391
+ return None
392
+
393
+
394
+ def create_workspace_group(workspace, name):
395
+ auth = _http_auth()
396
+ if not auth:
397
+ return False, "Credenciales no configuradas"
398
+ url = f"{BASE_URL}/workspaces/{workspace}/groups"
399
+ try:
400
+ r = requests.post(url, auth=auth, json={"name": name}, timeout=10)
401
+ if r.status_code in (200, 201):
402
+ return True, r.json()
403
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
404
+ except Exception as e:
405
+ return False, str(e)
406
+
407
+
408
+ def delete_workspace_group(workspace, group_slug):
409
+ auth = _http_auth()
410
+ if not auth:
411
+ return False, "Credenciales no configuradas"
412
+ url = f"{BASE_URL}/workspaces/{workspace}/groups/{group_slug}"
413
+ try:
414
+ r = requests.delete(url, auth=auth, timeout=10)
415
+ if r.status_code in (200, 204):
416
+ return True, None
417
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
418
+ except Exception as e:
419
+ return False, str(e)
420
+
421
+
422
+ def get_group_members(workspace, group_slug):
423
+ auth = _http_auth()
424
+ if not auth:
425
+ return None
426
+ url = f"{BASE_URL}/workspaces/{workspace}/groups/{group_slug}/members"
427
+ try:
428
+ r = requests.get(url, auth=auth, timeout=10)
429
+ if r.status_code == 200:
430
+ return r.json().get('values', [])
431
+ print(f" Error al obtener miembros del grupo: {r.status_code}")
432
+ return None
433
+ except Exception as e:
434
+ print(f" Error de conexión: {e}")
435
+ return None
436
+
437
+
438
+ def add_group_member(workspace, group_slug, member):
439
+ auth = _http_auth()
440
+ if not auth:
441
+ return False, "Credenciales no configuradas"
442
+ url = f"{BASE_URL}/workspaces/{workspace}/groups/{group_slug}/members/{member}"
443
+ try:
444
+ r = requests.put(url, auth=auth, timeout=10)
445
+ if r.status_code in (200, 201, 204):
446
+ return True, None
447
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
448
+ except Exception as e:
449
+ return False, str(e)
450
+
451
+
452
+ def remove_group_member(workspace, group_slug, member):
453
+ auth = _http_auth()
454
+ if not auth:
455
+ return False, "Credenciales no configuradas"
456
+ url = f"{BASE_URL}/workspaces/{workspace}/groups/{group_slug}/members/{member}"
457
+ try:
458
+ r = requests.delete(url, auth=auth, timeout=10)
459
+ if r.status_code in (200, 204):
460
+ return True, None
461
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
462
+ except Exception as e:
463
+ return False, str(e)
464
+
465
+
466
+ # ─── Members ─────────────────────────────────────────────────────────────────
467
+
468
+ def get_workspace_members(workspace):
469
+ auth = _http_auth()
470
+ if not auth:
471
+ return None
472
+ url = f"{BASE_URL}/workspaces/{workspace}/members"
473
+ try:
474
+ r = requests.get(url, auth=auth, timeout=10)
475
+ if r.status_code == 200:
476
+ return r.json().get('values', [])
477
+ print(f" Error al obtener miembros del workspace: {r.status_code}")
478
+ return None
479
+ except Exception as e:
480
+ print(f" Error de conexión: {e}")
481
+ return None
482
+
483
+
484
+ def get_repository(workspace, repo):
485
+ auth = _http_auth()
486
+ if not auth:
487
+ return None
488
+ url = f"{BASE_URL}/repositories/{workspace}/{repo}"
489
+ try:
490
+ r = requests.get(url, auth=auth, timeout=10)
491
+ if r.status_code == 200:
492
+ return r.json()
493
+ return None
494
+ except Exception as e:
495
+ print(f" Error de conexión: {e}")
496
+ return None
@@ -0,0 +1,38 @@
1
+ import os
2
+
3
+ def load_env_file():
4
+ env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', '.env')
5
+ alt_path = os.path.join(os.path.expanduser("~"), ".bbm", ".env")
6
+ new_path = os.path.join(os.path.expanduser("~"), ".config", "bitbucket-manager", "env")
7
+ for path in [env_path, alt_path, new_path]:
8
+ if os.path.exists(path):
9
+ with open(path, 'r') as f:
10
+ for line in f:
11
+ line = line.strip()
12
+ if line and not line.startswith('#') and '=' in line:
13
+ key, value = line.split('=', 1)
14
+ os.environ.setdefault(key.strip(), value.strip())
15
+
16
+ def get_auth():
17
+ token = os.environ.get("BB_TOKEN")
18
+ username = os.environ.get("BB_USERNAME")
19
+ workspace = os.environ.get("BB_WORKSPACE")
20
+ return token, username, workspace
21
+
22
+ def get_config():
23
+ token = os.environ.get("BB_TOKEN")
24
+ username = os.environ.get("BB_USERNAME")
25
+ workspace = os.environ.get("BB_WORKSPACE")
26
+ dev_dir = os.environ.get("DEV_DIR", os.path.expanduser("~/Documents/bitbucket-repos"))
27
+ return token, username, workspace, dev_dir
28
+
29
+ def validate_env():
30
+ token, username, workspace, dev_dir = get_config()
31
+ errors = []
32
+ if not token:
33
+ errors.append("BB_TOKEN no está configurado. Exportalo o creá un .env")
34
+ if not username:
35
+ errors.append("BB_USERNAME no está configurado (usá tu email de Atlassian)")
36
+ if not workspace:
37
+ errors.append("BB_WORKSPACE no está configurado")
38
+ return errors
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import App
4
+
5
+ from .splash import BootScreen
6
+ from .home import HomeScreen
7
+ from .dashboard_screen import DashboardScreen
8
+ from .explorer import ExplorerScreen
9
+ from .permissions_screen import PermissionsScreen
10
+ from .pr_screen import PRScreen
11
+ from .migration_screen import MigrationScreen
12
+ from .archive_screen import ArchiveScreen
13
+ from .deps_screen import DepsScreen
14
+ from .groups_screen import GroupsScreen
15
+ from .members_screen import MembersScreen
16
+
17
+
18
+ class BitbucketManagerApp(App):
19
+ CSS_PATH = "styles.tcss"
20
+ SCREENS = {
21
+ "boot": BootScreen,
22
+ "home": HomeScreen,
23
+ "dashboard": DashboardScreen,
24
+ "explorer": ExplorerScreen,
25
+ "permissions": PermissionsScreen,
26
+ "pr": PRScreen,
27
+ "migration": MigrationScreen,
28
+ "archive": ArchiveScreen,
29
+ "deps": DepsScreen,
30
+ "groups": GroupsScreen,
31
+ "members": MembersScreen,
32
+ }
33
+
34
+ TITLE = "Bitbucket Manager"
35
+ BINDINGS = [
36
+ ("ctrl+q", "quit", "Salir"),
37
+ ]
38
+
39
+ def on_mount(self) -> None:
40
+ self.push_screen("boot")
41
+
42
+
43
+ def run_tui() -> None:
44
+ app = BitbucketManagerApp()
45
+ app.run()