create-ironclaws 1.0.0
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.
- package/README.md +101 -0
- package/bin/create.js +394 -0
- package/package.json +33 -0
- package/template/.env.example +38 -0
- package/template/CLAUDE.md +104 -0
- package/template/agent-credentials.yaml +33 -0
- package/template/agents.yaml +22 -0
- package/template/container/Dockerfile +70 -0
- package/template/container/Dockerfile.argus +34 -0
- package/template/container/agent-runner/package-lock.json +1524 -0
- package/template/container/agent-runner/package.json +23 -0
- package/template/container/agent-runner/src/index.ts +630 -0
- package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
- package/template/container/agent-runner/tsconfig.json +15 -0
- package/template/container/build-argus.sh +25 -0
- package/template/container/build.sh +23 -0
- package/template/container/skills/agent-browser/SKILL.md +159 -0
- package/template/container/skills/agent-status/SKILL.md +69 -0
- package/template/container/skills/capabilities/SKILL.md +100 -0
- package/template/container/skills/edit-agent/SKILL.md +93 -0
- package/template/container/skills/slack-formatting/SKILL.md +92 -0
- package/template/container/skills/status/SKILL.md +104 -0
- package/template/container/tools/elastic_query.py +161 -0
- package/template/container/tools/gdrive_tool.py +185 -0
- package/template/container/tools/jira_tool.py +433 -0
- package/template/container/tools/slack_history_tool.py +144 -0
- package/template/container/tools/youtube_tool.py +174 -0
- package/template/docker-compose.yml +54 -0
- package/template/docs/how-it-works.md +496 -0
- package/template/eslint.config.js +32 -0
- package/template/groups/forge/CLAUDE.md +107 -0
- package/template/package-lock.json +5278 -0
- package/template/package.json +52 -0
- package/template/scripts/github-app-token.py +58 -0
- package/template/scripts/register-expense-agent.sh +121 -0
- package/template/scripts/run-migrations.ts +105 -0
- package/template/scripts/setup-onecli-secrets.sh +252 -0
- package/template/setup-agents.sh +142 -0
- package/template/src/channels/index.ts +13 -0
- package/template/src/channels/registry.test.ts +42 -0
- package/template/src/channels/registry.ts +28 -0
- package/template/src/channels/slack.test.ts +859 -0
- package/template/src/channels/slack.ts +373 -0
- package/template/src/claw-skill.test.ts +45 -0
- package/template/src/config.ts +94 -0
- package/template/src/container-runner.test.ts +221 -0
- package/template/src/container-runner.ts +1029 -0
- package/template/src/container-runtime.test.ts +149 -0
- package/template/src/container-runtime.ts +124 -0
- package/template/src/db-migration.test.ts +67 -0
- package/template/src/db.test.ts +484 -0
- package/template/src/db.ts +837 -0
- package/template/src/env.ts +42 -0
- package/template/src/formatting.test.ts +294 -0
- package/template/src/github-token.ts +48 -0
- package/template/src/google-token.ts +75 -0
- package/template/src/group-folder.test.ts +43 -0
- package/template/src/group-folder.ts +44 -0
- package/template/src/group-queue.test.ts +484 -0
- package/template/src/group-queue.ts +363 -0
- package/template/src/http-server.ts +343 -0
- package/template/src/index.ts +960 -0
- package/template/src/ipc-auth.test.ts +679 -0
- package/template/src/ipc.ts +548 -0
- package/template/src/logger.ts +16 -0
- package/template/src/mount-security.ts +421 -0
- package/template/src/network-policy.ts +119 -0
- package/template/src/remote-control.test.ts +397 -0
- package/template/src/remote-control.ts +224 -0
- package/template/src/router.ts +52 -0
- package/template/src/routing.test.ts +170 -0
- package/template/src/sender-allowlist.test.ts +216 -0
- package/template/src/sender-allowlist.ts +128 -0
- package/template/src/task-scheduler.test.ts +129 -0
- package/template/src/task-scheduler.ts +290 -0
- package/template/src/timezone.test.ts +73 -0
- package/template/src/timezone.ts +37 -0
- package/template/src/types.ts +114 -0
- package/template/src/worktree.ts +206 -0
- package/template/tsconfig.json +20 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standalone Google Drive tool for Argus agents.
|
|
3
|
+
Uses OAuth refresh token — no google-auth library needed, just requests.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
# List files in the standup folder
|
|
7
|
+
python3 gdrive_tool.py list
|
|
8
|
+
|
|
9
|
+
# List only files not yet in processed_transcripts.txt
|
|
10
|
+
python3 gdrive_tool.py list --new-only
|
|
11
|
+
|
|
12
|
+
# Read a Google Doc as plain text
|
|
13
|
+
python3 gdrive_tool.py read --file-id 1abc123...
|
|
14
|
+
|
|
15
|
+
# Mark a file as processed (appends ID to processed_transcripts.txt)
|
|
16
|
+
python3 gdrive_tool.py mark-processed --file-id 1abc123...
|
|
17
|
+
|
|
18
|
+
Environment variables (required):
|
|
19
|
+
GOOGLE_CLIENT_ID
|
|
20
|
+
GOOGLE_CLIENT_SECRET
|
|
21
|
+
GOOGLE_REFRESH_TOKEN
|
|
22
|
+
GOOGLE_DRIVE_STANDUP_FOLDER_ID
|
|
23
|
+
|
|
24
|
+
Optional:
|
|
25
|
+
GDRIVE_PROCESSED_FILE path to processed transcripts list
|
|
26
|
+
default: /workspace/group/processed_transcripts.txt
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import requests
|
|
36
|
+
except ImportError:
|
|
37
|
+
print("ERROR: 'requests' is not installed.", file=sys.stderr)
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
41
|
+
DRIVE_API = "https://www.googleapis.com/drive/v3"
|
|
42
|
+
|
|
43
|
+
PROCESSED_FILE = os.environ.get(
|
|
44
|
+
"GDRIVE_PROCESSED_FILE", "/workspace/group/processed_transcripts.txt"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_access_token():
|
|
49
|
+
"""Get Google access token.
|
|
50
|
+
|
|
51
|
+
Uses GOOGLE_ACCESS_TOKEN injected by the NanoClaw host (short-lived, ~1h TTL).
|
|
52
|
+
The OAuth refresh credentials stay on the host — never in the container.
|
|
53
|
+
"""
|
|
54
|
+
token = os.environ.get("GOOGLE_ACCESS_TOKEN", "").strip()
|
|
55
|
+
if not token:
|
|
56
|
+
print(
|
|
57
|
+
"ERROR: GOOGLE_ACCESS_TOKEN must be set. "
|
|
58
|
+
"The NanoClaw host generates this token — check that Google OAuth "
|
|
59
|
+
"credentials (GOOGLE_CLIENT_ID/SECRET/REFRESH_TOKEN) are in the host .env.",
|
|
60
|
+
file=sys.stderr,
|
|
61
|
+
)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
return token
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_folder_id():
|
|
67
|
+
folder_id = os.environ.get("GOOGLE_DRIVE_STANDUP_FOLDER_ID", "").strip()
|
|
68
|
+
if not folder_id:
|
|
69
|
+
print("ERROR: GOOGLE_DRIVE_STANDUP_FOLDER_ID must be set.", file=sys.stderr)
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
return folder_id
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_processed():
|
|
75
|
+
"""Return set of already-processed file IDs."""
|
|
76
|
+
if not os.path.exists(PROCESSED_FILE):
|
|
77
|
+
return set()
|
|
78
|
+
with open(PROCESSED_FILE) as f:
|
|
79
|
+
return set(line.strip() for line in f if line.strip())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def mark_processed(file_id):
|
|
83
|
+
"""Append a file ID to the processed list."""
|
|
84
|
+
processed = load_processed()
|
|
85
|
+
if file_id not in processed:
|
|
86
|
+
with open(PROCESSED_FILE, "a") as f:
|
|
87
|
+
f.write(file_id + "\n")
|
|
88
|
+
print(f"Marked {file_id} as processed.")
|
|
89
|
+
else:
|
|
90
|
+
print(f"{file_id} was already marked as processed.")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_list(args):
|
|
94
|
+
token = get_access_token()
|
|
95
|
+
folder_id = get_folder_id()
|
|
96
|
+
|
|
97
|
+
resp = requests.get(
|
|
98
|
+
f"{DRIVE_API}/files",
|
|
99
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
100
|
+
params={
|
|
101
|
+
"q": f"'{folder_id}' in parents and trashed=false",
|
|
102
|
+
"fields": "files(id,name,createdTime,mimeType)",
|
|
103
|
+
"orderBy": "createdTime asc",
|
|
104
|
+
"pageSize": 1000,
|
|
105
|
+
"supportsAllDrives": True,
|
|
106
|
+
"includeItemsFromAllDrives": True,
|
|
107
|
+
},
|
|
108
|
+
timeout=30,
|
|
109
|
+
)
|
|
110
|
+
resp.raise_for_status()
|
|
111
|
+
files = resp.json().get("files", [])
|
|
112
|
+
|
|
113
|
+
if args.new_only:
|
|
114
|
+
processed = load_processed()
|
|
115
|
+
files = [f for f in files if f["id"] not in processed]
|
|
116
|
+
|
|
117
|
+
if not files:
|
|
118
|
+
print("No files found." if not args.new_only else "No new files to process.")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
print(json.dumps(files, indent=2))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_read(args):
|
|
125
|
+
token = get_access_token()
|
|
126
|
+
|
|
127
|
+
# Try exporting as plain text (works for Google Docs)
|
|
128
|
+
resp = requests.get(
|
|
129
|
+
f"{DRIVE_API}/files/{args.file_id}/export",
|
|
130
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
131
|
+
params={"mimeType": "text/plain"},
|
|
132
|
+
timeout=60,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if resp.status_code == 400:
|
|
136
|
+
# Not a Google Doc — try downloading directly
|
|
137
|
+
resp = requests.get(
|
|
138
|
+
f"{DRIVE_API}/files/{args.file_id}?alt=media",
|
|
139
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
140
|
+
timeout=60,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
resp.raise_for_status()
|
|
144
|
+
print(resp.text)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_mark_processed(args):
|
|
148
|
+
mark_processed(args.file_id)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_args():
|
|
152
|
+
p = argparse.ArgumentParser(description="Google Drive tool for Argus agents")
|
|
153
|
+
sub = p.add_subparsers(dest="command")
|
|
154
|
+
|
|
155
|
+
l = sub.add_parser("list", help="List files in the standup folder")
|
|
156
|
+
l.add_argument("--new-only", action="store_true", help="Only show unprocessed files")
|
|
157
|
+
|
|
158
|
+
r = sub.add_parser("read", help="Read a Google Doc as plain text")
|
|
159
|
+
r.add_argument("--file-id", required=True)
|
|
160
|
+
|
|
161
|
+
m = sub.add_parser("mark-processed", help="Mark a file as processed")
|
|
162
|
+
m.add_argument("--file-id", required=True)
|
|
163
|
+
|
|
164
|
+
return p.parse_args()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main():
|
|
168
|
+
args = parse_args()
|
|
169
|
+
if not args.command:
|
|
170
|
+
print("ERROR: specify a command. Use --help for usage.", file=sys.stderr)
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
try:
|
|
173
|
+
{"list": cmd_list, "read": cmd_read, "mark-processed": cmd_mark_processed}[
|
|
174
|
+
args.command
|
|
175
|
+
](args)
|
|
176
|
+
except requests.HTTPError as e:
|
|
177
|
+
print(f"ERROR: Google Drive API error: {e}\n{e.response.text}", file=sys.stderr)
|
|
178
|
+
sys.exit(1)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
main()
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jira tool for Argus agents.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
# Get next ticket to work on (highest priority, oldest first)
|
|
6
|
+
python3 jira_tool.py next
|
|
7
|
+
|
|
8
|
+
# Get specific ticket
|
|
9
|
+
python3 jira_tool.py get --issue IAI-42
|
|
10
|
+
|
|
11
|
+
# Search tickets
|
|
12
|
+
python3 jira_tool.py search --status "To Do"
|
|
13
|
+
python3 jira_tool.py search --status "In Progress" --assignee "user@example.com"
|
|
14
|
+
|
|
15
|
+
# Create a ticket
|
|
16
|
+
python3 jira_tool.py create --summary "Bug in BobAIA" --description "..." --type Bug
|
|
17
|
+
python3 jira_tool.py create --summary "Fix login" --type Bug --epic IAI-10
|
|
18
|
+
|
|
19
|
+
# Create an epic
|
|
20
|
+
python3 jira_tool.py create-epic --summary "Salesforce Integration" --description "..."
|
|
21
|
+
python3 jira_tool.py create-epic --summary "Salesforce Integration" --start-date 2026-04-15 --due-date 2026-06-30
|
|
22
|
+
|
|
23
|
+
# Search epics
|
|
24
|
+
python3 jira_tool.py search-epics
|
|
25
|
+
python3 jira_tool.py search-epics --query "Salesforce"
|
|
26
|
+
|
|
27
|
+
# Update a ticket
|
|
28
|
+
python3 jira_tool.py update --issue IAI-42 --status "In Progress"
|
|
29
|
+
python3 jira_tool.py update --issue IAI-42 --description "Updated description"
|
|
30
|
+
python3 jira_tool.py update --issue IAI-42 --comment "Working on this now"
|
|
31
|
+
python3 jira_tool.py update --issue IAI-42 --assignee-email "user@yourcompany.com"
|
|
32
|
+
|
|
33
|
+
# Find Jira account ID by email
|
|
34
|
+
python3 jira_tool.py user --email "user@yourcompany.com"
|
|
35
|
+
|
|
36
|
+
Environment variables (required):
|
|
37
|
+
JIRA_BASE_URL e.g. https://yourcompany.atlassian.net
|
|
38
|
+
JIRA_EMAIL Atlassian account email
|
|
39
|
+
JIRA_API_TOKEN Atlassian API token
|
|
40
|
+
JIRA_PROJECT_KEY e.g. IAI
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import sys
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
import requests
|
|
50
|
+
from requests.auth import HTTPBasicAuth
|
|
51
|
+
except ImportError:
|
|
52
|
+
print("ERROR: 'requests' is not installed. Run: pip3 install requests", file=sys.stderr)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
PRIORITY_ORDER = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Lowest": 4}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_client():
|
|
60
|
+
base_url = os.environ.get("JIRA_BASE_URL", "").rstrip("/")
|
|
61
|
+
project = os.environ.get("JIRA_PROJECT_KEY", "")
|
|
62
|
+
|
|
63
|
+
if not base_url or not project:
|
|
64
|
+
print(
|
|
65
|
+
"ERROR: JIRA_BASE_URL and JIRA_PROJECT_KEY must be set.",
|
|
66
|
+
file=sys.stderr,
|
|
67
|
+
)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
email = os.environ.get("JIRA_EMAIL", "")
|
|
71
|
+
token = os.environ.get("JIRA_API_TOKEN", "")
|
|
72
|
+
# auth is optional — if absent, OneCLI HTTPS proxy injects Authorization header.
|
|
73
|
+
auth = HTTPBasicAuth(email, token) if email and token else None
|
|
74
|
+
return base_url, auth, project
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def jira_get(base_url, auth, path, params=None):
|
|
78
|
+
resp = requests.get(
|
|
79
|
+
f"{base_url}/rest/api/3{path}",
|
|
80
|
+
auth=auth,
|
|
81
|
+
headers={"Accept": "application/json"},
|
|
82
|
+
params=params,
|
|
83
|
+
timeout=30,
|
|
84
|
+
)
|
|
85
|
+
resp.raise_for_status()
|
|
86
|
+
return resp.json()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def jira_search(base_url, auth, jql, max_results=50, fields=None):
|
|
90
|
+
"""POST-based search — returns the issues list directly."""
|
|
91
|
+
if fields is None:
|
|
92
|
+
fields = ["summary", "status", "priority", "issuetype",
|
|
93
|
+
"assignee", "reporter", "description", "created"]
|
|
94
|
+
body = {"jql": jql, "maxResults": max_results, "fields": fields}
|
|
95
|
+
resp = requests.post(
|
|
96
|
+
f"{base_url}/rest/api/3/search/jql",
|
|
97
|
+
auth=auth,
|
|
98
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
99
|
+
json=body,
|
|
100
|
+
timeout=30,
|
|
101
|
+
)
|
|
102
|
+
resp.raise_for_status()
|
|
103
|
+
return resp.json().get("issues", [])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def jira_post(base_url, auth, path, body):
|
|
107
|
+
resp = requests.post(
|
|
108
|
+
f"{base_url}/rest/api/3{path}",
|
|
109
|
+
auth=auth,
|
|
110
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
111
|
+
json=body,
|
|
112
|
+
timeout=30,
|
|
113
|
+
)
|
|
114
|
+
resp.raise_for_status()
|
|
115
|
+
return resp.json()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def jira_put(base_url, auth, path, body):
|
|
119
|
+
resp = requests.put(
|
|
120
|
+
f"{base_url}/rest/api/3{path}",
|
|
121
|
+
auth=auth,
|
|
122
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
123
|
+
json=body,
|
|
124
|
+
timeout=30,
|
|
125
|
+
)
|
|
126
|
+
resp.raise_for_status()
|
|
127
|
+
return resp
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def jira_transition(base_url, auth, issue_key, status_name):
|
|
131
|
+
"""Move an issue to a new status by name."""
|
|
132
|
+
transitions = jira_get(base_url, auth, f"/issue/{issue_key}/transitions")
|
|
133
|
+
match = next(
|
|
134
|
+
(t for t in transitions["transitions"] if t["name"].lower() == status_name.lower()),
|
|
135
|
+
None,
|
|
136
|
+
)
|
|
137
|
+
if not match:
|
|
138
|
+
available = [t["name"] for t in transitions["transitions"]]
|
|
139
|
+
print(
|
|
140
|
+
f"ERROR: Status '{status_name}' not found. Available: {available}",
|
|
141
|
+
file=sys.stderr,
|
|
142
|
+
)
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
jira_post(base_url, auth, f"/issue/{issue_key}/transitions", {"transition": {"id": match["id"]}})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def format_issue(issue):
|
|
148
|
+
fields = issue.get("fields", {})
|
|
149
|
+
priority = (fields.get("priority") or {}).get("name", "None")
|
|
150
|
+
status = (fields.get("status") or {}).get("name", "?")
|
|
151
|
+
assignee = (fields.get("assignee") or {}).get("emailAddress", "unassigned")
|
|
152
|
+
reporter = (fields.get("reporter") or {}).get("emailAddress", "?")
|
|
153
|
+
issue_type = (fields.get("issuetype") or {}).get("name", "?")
|
|
154
|
+
summary = fields.get("summary", "?")
|
|
155
|
+
key = issue.get("key", "?")
|
|
156
|
+
|
|
157
|
+
# Extract plain text from description (Atlassian Document Format)
|
|
158
|
+
desc_raw = fields.get("description") or {}
|
|
159
|
+
description = extract_adf_text(desc_raw)
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"key": key,
|
|
163
|
+
"type": issue_type,
|
|
164
|
+
"status": status,
|
|
165
|
+
"priority": priority,
|
|
166
|
+
"summary": summary,
|
|
167
|
+
"description": description,
|
|
168
|
+
"reporter": reporter,
|
|
169
|
+
"assignee": assignee,
|
|
170
|
+
"url": f"{os.environ.get('JIRA_BASE_URL', '').rstrip('/')}/browse/{key}",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def extract_adf_text(node):
|
|
175
|
+
"""Extract plain text from Atlassian Document Format (ADF) JSON."""
|
|
176
|
+
if not node:
|
|
177
|
+
return ""
|
|
178
|
+
if isinstance(node, str):
|
|
179
|
+
return node
|
|
180
|
+
if node.get("type") == "text":
|
|
181
|
+
return node.get("text", "")
|
|
182
|
+
parts = []
|
|
183
|
+
for child in node.get("content", []):
|
|
184
|
+
parts.append(extract_adf_text(child))
|
|
185
|
+
return " ".join(p for p in parts if p).strip()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cmd_get(args):
|
|
189
|
+
base_url, auth, _ = get_client()
|
|
190
|
+
issue = jira_get(base_url, auth, f"/issue/{args.issue}")
|
|
191
|
+
print(json.dumps(format_issue(issue), indent=2))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def cmd_next(args):
|
|
195
|
+
base_url, auth, project = get_client()
|
|
196
|
+
jql = f'project = {project} AND status = "To Do" ORDER BY priority ASC, created ASC'
|
|
197
|
+
issues = jira_search(base_url, auth, jql, max_results=10)
|
|
198
|
+
if not issues:
|
|
199
|
+
print("No tickets in To Do.")
|
|
200
|
+
return
|
|
201
|
+
def sort_key(i):
|
|
202
|
+
p = (i.get("fields", {}).get("priority") or {}).get("name", "Low")
|
|
203
|
+
return (PRIORITY_ORDER.get(p, 99), i.get("fields", {}).get("created", ""))
|
|
204
|
+
issues.sort(key=sort_key)
|
|
205
|
+
print(json.dumps(format_issue(issues[0]), indent=2))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def cmd_search(args):
|
|
209
|
+
base_url, auth, project = get_client()
|
|
210
|
+
conditions = [f"project = {project}"]
|
|
211
|
+
if args.status:
|
|
212
|
+
conditions.append(f'status = "{args.status}"')
|
|
213
|
+
if args.assignee:
|
|
214
|
+
conditions.append(f'assignee = "{args.assignee}"')
|
|
215
|
+
jql = " AND ".join(conditions) + " ORDER BY priority ASC, created ASC"
|
|
216
|
+
issues = jira_search(base_url, auth, jql)
|
|
217
|
+
print(json.dumps([format_issue(i) for i in issues], indent=2))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cmd_create(args):
|
|
221
|
+
base_url, auth, project = get_client()
|
|
222
|
+
|
|
223
|
+
# Build description in ADF format
|
|
224
|
+
description_adf = {
|
|
225
|
+
"type": "doc",
|
|
226
|
+
"version": 1,
|
|
227
|
+
"content": [
|
|
228
|
+
{
|
|
229
|
+
"type": "paragraph",
|
|
230
|
+
"content": [{"type": "text", "text": args.description or ""}],
|
|
231
|
+
}
|
|
232
|
+
],
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
body = {
|
|
236
|
+
"fields": {
|
|
237
|
+
"project": {"key": project},
|
|
238
|
+
"summary": args.summary,
|
|
239
|
+
"description": description_adf,
|
|
240
|
+
"issuetype": {"name": args.type or "Task"},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
# Set reporter by account ID if provided
|
|
245
|
+
if args.reporter_account_id:
|
|
246
|
+
body["fields"]["reporter"] = {"id": args.reporter_account_id}
|
|
247
|
+
|
|
248
|
+
# Link to parent epic if provided
|
|
249
|
+
if args.epic:
|
|
250
|
+
body["fields"]["parent"] = {"key": args.epic}
|
|
251
|
+
|
|
252
|
+
result = jira_post(base_url, auth, "/issue", body)
|
|
253
|
+
key = result.get("key")
|
|
254
|
+
url = f"{base_url}/browse/{key}"
|
|
255
|
+
print(json.dumps({"key": key, "url": url, "epic": args.epic or None}))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_create_epic(args):
|
|
259
|
+
base_url, auth, project = get_client()
|
|
260
|
+
|
|
261
|
+
description_adf = {
|
|
262
|
+
"type": "doc",
|
|
263
|
+
"version": 1,
|
|
264
|
+
"content": [
|
|
265
|
+
{
|
|
266
|
+
"type": "paragraph",
|
|
267
|
+
"content": [{"type": "text", "text": args.description or ""}],
|
|
268
|
+
}
|
|
269
|
+
],
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
body = {
|
|
273
|
+
"fields": {
|
|
274
|
+
"project": {"key": project},
|
|
275
|
+
"summary": args.summary,
|
|
276
|
+
"description": description_adf,
|
|
277
|
+
"issuetype": {"name": "Epic"},
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if args.reporter_account_id:
|
|
282
|
+
body["fields"]["reporter"] = {"id": args.reporter_account_id}
|
|
283
|
+
|
|
284
|
+
if args.start_date:
|
|
285
|
+
# customfield_10015 is the standard Epic start date in Jira Cloud
|
|
286
|
+
body["fields"]["customfield_10015"] = args.start_date
|
|
287
|
+
|
|
288
|
+
if args.due_date:
|
|
289
|
+
body["fields"]["duedate"] = args.due_date
|
|
290
|
+
|
|
291
|
+
result = jira_post(base_url, auth, "/issue", body)
|
|
292
|
+
key = result.get("key")
|
|
293
|
+
url = f"{base_url}/browse/{key}"
|
|
294
|
+
print(json.dumps({"key": key, "url": url, "type": "Epic"}))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_search_epics(args):
|
|
298
|
+
base_url, auth, project = get_client()
|
|
299
|
+
conditions = [f"project = {project}", "issuetype = Epic"]
|
|
300
|
+
if args.query:
|
|
301
|
+
# Escape quotes in query for JQL
|
|
302
|
+
safe_query = args.query.replace('"', '\\"')
|
|
303
|
+
conditions.append(f'summary ~ "{safe_query}"')
|
|
304
|
+
jql = " AND ".join(conditions) + " ORDER BY created DESC"
|
|
305
|
+
issues = jira_search(base_url, auth, jql)
|
|
306
|
+
print(json.dumps([format_issue(i) for i in issues], indent=2))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def cmd_update(args):
|
|
310
|
+
base_url, auth, _ = get_client()
|
|
311
|
+
|
|
312
|
+
if args.status:
|
|
313
|
+
jira_transition(base_url, auth, args.issue, args.status)
|
|
314
|
+
print(f"Moved {args.issue} to '{args.status}'")
|
|
315
|
+
|
|
316
|
+
if args.description:
|
|
317
|
+
description_adf = {
|
|
318
|
+
"type": "doc",
|
|
319
|
+
"version": 1,
|
|
320
|
+
"content": [
|
|
321
|
+
{
|
|
322
|
+
"type": "paragraph",
|
|
323
|
+
"content": [{"type": "text", "text": args.description}],
|
|
324
|
+
}
|
|
325
|
+
],
|
|
326
|
+
}
|
|
327
|
+
jira_put(base_url, auth, f"/issue/{args.issue}", {"fields": {"description": description_adf}})
|
|
328
|
+
print(f"Updated description on {args.issue}")
|
|
329
|
+
|
|
330
|
+
if args.comment:
|
|
331
|
+
comment_adf = {
|
|
332
|
+
"type": "doc",
|
|
333
|
+
"version": 1,
|
|
334
|
+
"content": [
|
|
335
|
+
{
|
|
336
|
+
"type": "paragraph",
|
|
337
|
+
"content": [{"type": "text", "text": args.comment}],
|
|
338
|
+
}
|
|
339
|
+
],
|
|
340
|
+
}
|
|
341
|
+
jira_post(base_url, auth, f"/issue/{args.issue}/comment", {"body": comment_adf})
|
|
342
|
+
print(f"Added comment to {args.issue}")
|
|
343
|
+
|
|
344
|
+
if args.assignee_email:
|
|
345
|
+
result = jira_get(base_url, auth, "/user/search", params={"query": args.assignee_email})
|
|
346
|
+
matches = [u for u in result if u.get("emailAddress", "").lower() == args.assignee_email.lower()]
|
|
347
|
+
if not matches:
|
|
348
|
+
print(f"ERROR: No Jira user found for email '{args.assignee_email}'", file=sys.stderr)
|
|
349
|
+
sys.exit(1)
|
|
350
|
+
account_id = matches[0]["accountId"]
|
|
351
|
+
jira_put(base_url, auth, f"/issue/{args.issue}", {"fields": {"assignee": {"id": account_id}}})
|
|
352
|
+
print(f"Assigned {args.issue} to {args.assignee_email}")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def cmd_user(args):
|
|
356
|
+
"""Find Jira account ID by email — used to set reporter/assignee."""
|
|
357
|
+
base_url, auth, _ = get_client()
|
|
358
|
+
result = jira_get(base_url, auth, "/user/search", params={"query": args.email})
|
|
359
|
+
matches = [u for u in result if u.get("emailAddress", "").lower() == args.email.lower()]
|
|
360
|
+
if not matches:
|
|
361
|
+
print(f"ERROR: No Jira user found for email '{args.email}'", file=sys.stderr)
|
|
362
|
+
sys.exit(1)
|
|
363
|
+
user = matches[0]
|
|
364
|
+
print(json.dumps({
|
|
365
|
+
"account_id": user.get("accountId"),
|
|
366
|
+
"display_name": user.get("displayName"),
|
|
367
|
+
"email": user.get("emailAddress"),
|
|
368
|
+
}))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def parse_args():
|
|
372
|
+
p = argparse.ArgumentParser(description="Jira tool for Argus agents")
|
|
373
|
+
sub = p.add_subparsers(dest="command")
|
|
374
|
+
|
|
375
|
+
sub.add_parser("next", help="Get next To Do ticket by priority")
|
|
376
|
+
|
|
377
|
+
g = sub.add_parser("get", help="Get a specific ticket")
|
|
378
|
+
g.add_argument("--issue", required=True)
|
|
379
|
+
|
|
380
|
+
s = sub.add_parser("search", help="Search tickets")
|
|
381
|
+
s.add_argument("--status")
|
|
382
|
+
s.add_argument("--assignee")
|
|
383
|
+
|
|
384
|
+
c = sub.add_parser("create", help="Create a ticket")
|
|
385
|
+
c.add_argument("--summary", required=True)
|
|
386
|
+
c.add_argument("--description", default="")
|
|
387
|
+
c.add_argument("--type", default="Task", help="Bug, Task, Story, etc.")
|
|
388
|
+
c.add_argument("--reporter-account-id", dest="reporter_account_id", default=None)
|
|
389
|
+
c.add_argument("--epic", default=None, help="Parent epic key (e.g. IAI-10) to link this ticket under")
|
|
390
|
+
|
|
391
|
+
ce = sub.add_parser("create-epic", help="Create an epic")
|
|
392
|
+
ce.add_argument("--summary", required=True)
|
|
393
|
+
ce.add_argument("--description", default="")
|
|
394
|
+
ce.add_argument("--reporter-account-id", dest="reporter_account_id", default=None)
|
|
395
|
+
ce.add_argument("--start-date", dest="start_date", default=None, help="Start date (YYYY-MM-DD)")
|
|
396
|
+
ce.add_argument("--due-date", dest="due_date", default=None, help="Due date (YYYY-MM-DD)")
|
|
397
|
+
|
|
398
|
+
se = sub.add_parser("search-epics", help="Search epics in the project")
|
|
399
|
+
se.add_argument("--query", default=None, help="Search term to filter epic summaries")
|
|
400
|
+
|
|
401
|
+
u = sub.add_parser("update", help="Update a ticket")
|
|
402
|
+
u.add_argument("--issue", required=True)
|
|
403
|
+
u.add_argument("--status", help="Transition to this status")
|
|
404
|
+
u.add_argument("--description", help="Update description")
|
|
405
|
+
u.add_argument("--comment", help="Add a comment")
|
|
406
|
+
u.add_argument("--assignee-email", dest="assignee_email", help="Assign to this email address")
|
|
407
|
+
|
|
408
|
+
usr = sub.add_parser("user", help="Find Jira account ID by email")
|
|
409
|
+
usr.add_argument("--email", required=True)
|
|
410
|
+
|
|
411
|
+
return p.parse_args()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def main():
|
|
415
|
+
args = parse_args()
|
|
416
|
+
if not args.command:
|
|
417
|
+
print("ERROR: specify a command. Use --help for usage.", file=sys.stderr)
|
|
418
|
+
sys.exit(1)
|
|
419
|
+
try:
|
|
420
|
+
{"next": cmd_next, "get": cmd_get, "search": cmd_search,
|
|
421
|
+
"create": cmd_create, "create-epic": cmd_create_epic,
|
|
422
|
+
"search-epics": cmd_search_epics,
|
|
423
|
+
"update": cmd_update, "user": cmd_user}[args.command](args)
|
|
424
|
+
except requests.HTTPError as e:
|
|
425
|
+
print(f"ERROR: Jira API error: {e}\n{e.response.text}", file=sys.stderr)
|
|
426
|
+
sys.exit(1)
|
|
427
|
+
except Exception as e:
|
|
428
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
429
|
+
sys.exit(1)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if __name__ == "__main__":
|
|
433
|
+
main()
|