#!/usr/bin/env python3 """ Upload one or more files as attachments to a Jira Cloud issue via REST API v3. Config: from jira_upload.env (current dir or script dir) or env vars. Usage: python upload_attachment.py --issue ISSUE_KEY --file path1 [--file path2 ...] python upload_attachment.py --config /path/to/jira_upload.env --issue KEY --file path1 """ import argparse import base64 import os import sys from pathlib import Path from typing import Dict, Optional try: import requests except ImportError: print("ERROR: 'requests' is required. Run: pip install requests", file=sys.stderr) sys.exit(1) CONFIG_FILENAME = "jira_upload.env" def _find_config_file(explicit_path: Optional[str], script_dir: Path) -> Optional[Path]: if explicit_path: p = Path(explicit_path) return p if p.exists() and p.is_file() else None cwd_file = Path.cwd() / CONFIG_FILENAME if cwd_file.exists(): return cwd_file script_dir_file = script_dir / CONFIG_FILENAME if script_dir_file.exists(): return script_dir_file return None def load_config(config_path: Optional[str]) -> Dict[str, str]: """Load KEY=VALUE from jira_upload.env. Env vars override file values.""" script_dir = Path(__file__).resolve().parent path = _find_config_file(config_path, script_dir) out = {} if path: with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue if "=" in line: k, _, v = line.partition("=") out[k.strip()] = v.strip().strip('"').strip("'") for key in ("JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"): if key in os.environ: out[key] = os.environ[key] return out def main() -> int: parser = argparse.ArgumentParser(description="Upload files as attachments to a Jira issue") parser.add_argument("--issue", required=True, help="Jira issue key (e.g. G3SF-123)") parser.add_argument("--file", action="append", required=True, dest="files", help="Path to file to upload (can be repeated)") parser.add_argument("--config", default=None, help=f"Path to config file (default: {CONFIG_FILENAME} in cwd or script dir)") args = parser.parse_args() cfg = load_config(args.config) base_url = (cfg.get("JIRA_BASE_URL") or "").rstrip("/") email = cfg.get("JIRA_EMAIL") token = cfg.get("JIRA_API_TOKEN") if not base_url or not email or not token: print("ERROR: Set JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN in config file or environment.", file=sys.stderr) print(f" Config file: {CONFIG_FILENAME} (in current dir or script dir), or use --config PATH", file=sys.stderr) return 1 issue_key = args.issue.strip() files_to_upload = [] for p in args.files: path = Path(p) if not path.exists(): print(f"ERROR: File not found: {path}", file=sys.stderr) return 1 if not path.is_file(): print(f"ERROR: Not a file: {path}", file=sys.stderr) return 1 files_to_upload.append(path) url = f"{base_url}/rest/api/3/issue/{issue_key}/attachments" auth_str = base64.b64encode(f"{email}:{token}".encode()).decode() headers = { "Authorization": f"Basic {auth_str}", "X-Atlassian-Token": "no-check", } for path in files_to_upload: with open(path, "rb") as f: files = {"file": (path.name, f)} try: resp = requests.post(url, headers=headers, files=files, timeout=60) except requests.RequestException as e: print(f"ERROR: Request failed for {path.name}: {e}", file=sys.stderr) return 1 if resp.status_code not in (200, 201): print(f"ERROR: Upload failed for {path.name}: HTTP {resp.status_code}", file=sys.stderr) if resp.text: print(resp.text[:500], file=sys.stderr) return 1 data = resp.json() if isinstance(data, list) and data: for att in data: print(f"OK: {att.get('filename', path.name)} (id={att.get('id')}, size={att.get('size', '?')})") else: print(f"OK: {path.name}") return 0 if __name__ == "__main__": sys.exit(main())