2026-03-16 18:11:34 +08:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Upload one or more files as attachments to a Jira Cloud issue via REST API v3.
|
|
|
|
|
|
2026-03-23 13:38:41 +08:00
|
|
|
Config: from jira_upload.env (current dir, script dir, or skill parent dir) or env vars.
|
2026-03-16 18:11:34 +08:00
|
|
|
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 sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import requests
|
|
|
|
|
except ImportError:
|
|
|
|
|
print("ERROR: 'requests' is required. Run: pip install requests", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
2026-03-23 13:38:41 +08:00
|
|
|
from jira_env import CONFIG_FILENAME, auth_header, load_config, require_credentials
|
2026-03-16 18:11:34 +08:00
|
|
|
|
2026-03-23 13:38:41 +08:00
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
2026-03-16 18:11:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)")
|
2026-03-23 13:38:41 +08:00
|
|
|
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, script dir, or skill dir)",
|
|
|
|
|
)
|
2026-03-16 18:11:34 +08:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
2026-03-23 13:38:41 +08:00
|
|
|
try:
|
|
|
|
|
cfg = load_config(args.config, SCRIPT_DIR)
|
|
|
|
|
base_url, email, token = require_credentials(cfg)
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
|
|
|
print(f" Use --config PATH or create {CONFIG_FILENAME}", file=sys.stderr)
|
2026-03-16 18:11:34 +08:00
|
|
|
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"
|
|
|
|
|
headers = {
|
2026-03-23 13:38:41 +08:00
|
|
|
"Authorization": f"Basic {auth_header(email, token)}",
|
2026-03-16 18:11:34 +08:00
|
|
|
"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())
|