Skip to content

Commit ef25c9b

Browse files
robbiet480claude
andcommitted
Add CI/CD, test suite, demo screenshot, version flag, and pre-built binaries
- Add GitHub Actions test workflow (build + test on PRs and main) - Add GitHub Actions release workflow (cross-platform binaries with AI release notes) - Add release notes generation script using Claude API - Add demo asset script for creating/tearing down Snipe-IT screenshot data - Add demo screenshot to README - Add pre-built binary download instructions to README - Add version variable and --version flag (set via ldflags at build time) - Add test suite covering all pure functions: cleanPrice, formatPrice, parseDate, deviceTypeFromDesc, groupByOrder, matchWarrantiesToHardware, parseXLSX, and data constants (24 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 65ab381 commit ef25c9b

8 files changed

Lines changed: 1060 additions & 3 deletions

File tree

.github/assets/demo-asset.png

587 KB
Loading
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
#!/usr/bin/env python3
2+
"""
3+
create_demo_asset.py — Create a demo fieldset, model, and asset in Snipe-IT
4+
for README screenshot purposes.
5+
6+
Usage:
7+
SNIPE_URL=https://your-instance.snipe-it.io \\
8+
SNIPE_KEY=your-api-key \\
9+
python3 .github/scripts/create_demo_asset.py
10+
11+
The script is idempotent: if a fieldset/model/asset with the same name already
12+
exists it prints the existing ID and skips creation.
13+
14+
To clean up afterwards, run with --delete:
15+
python3 .github/scripts/create_demo_asset.py --delete
16+
"""
17+
18+
import html
19+
import json
20+
import os
21+
import sys
22+
import urllib.request
23+
import urllib.error
24+
25+
SNIPE_URL = os.environ.get("SNIPE_URL", "").rstrip("/")
26+
SNIPE_KEY = os.environ.get("SNIPE_KEY", "")
27+
28+
if not SNIPE_URL or not SNIPE_KEY:
29+
print("ERROR: set SNIPE_URL and SNIPE_KEY environment variables", file=sys.stderr)
30+
sys.exit(1)
31+
32+
HEADERS = {
33+
"Authorization": f"Bearer {SNIPE_KEY}",
34+
"Accept": "application/json",
35+
"Content-Type": "application/json",
36+
}
37+
38+
# IDs that must already exist in the Snipe-IT instance
39+
MANUFACTURER_ID = 1 # Apple
40+
STATUS_ID = 2 # Ready to Deploy
41+
CATEGORY_ID = 2 # Computers
42+
SUPPLIER_ID = 1 # CDW-G
43+
44+
FIELDSET_NAME = "cdw2snipe Demo"
45+
MODEL_NAME = "MacBook Pro 16\" M4 Pro"
46+
MODEL_NUMBER = "Mac16,8"
47+
ASSET_TAG = "DEMO-CDW-001"
48+
SERIAL = "C02ZR4XHMD6V"
49+
50+
# CDW custom field definitions (created by `cdw2snipe setup`)
51+
CDW_FIELDS = [
52+
{"name": "CDW: Order Date", "element": "text", "format": "DATE", "help_text": "CDW order date (YYYY-MM-DD)"},
53+
{"name": "CDW: Invoice Date", "element": "text", "format": "DATE", "help_text": "CDW invoice date (YYYY-MM-DD)"},
54+
{"name": "CDW: Purchaser", "element": "text", "format": "ANY", "help_text": "CDW order purchaser name"},
55+
{"name": "CDW: Purchase Order #", "element": "text", "format": "ANY", "help_text": "CDW purchase order number"},
56+
{"name": "CDW: Invoice #", "element": "text", "format": "ANY", "help_text": "CDW invoice number"},
57+
{"name": "CDW: Ship Date", "element": "text", "format": "DATE", "help_text": "CDW ship date (YYYY-MM-DD)"},
58+
]
59+
60+
61+
def api(method, path, body=None, fatal=True):
62+
url = f"{SNIPE_URL}/api/v1{path}"
63+
data = json.dumps(body).encode() if body else None
64+
req = urllib.request.Request(url, data=data, headers=HEADERS, method=method)
65+
try:
66+
with urllib.request.urlopen(req) as resp:
67+
return json.loads(resp.read())
68+
except urllib.error.HTTPError as e:
69+
msg = f"HTTP {e.code} {method} {path}: {e.read().decode()}"
70+
if fatal:
71+
print(msg, file=sys.stderr)
72+
sys.exit(1)
73+
print(f" WARNING: {msg}", file=sys.stderr)
74+
return {}
75+
76+
77+
def find_by_name(rows, name):
78+
"""Match by name, handling Snipe-IT's HTML entity encoding (e.g. &quot;)."""
79+
for r in rows:
80+
row_name = html.unescape(r.get("name", ""))
81+
if row_name == name:
82+
return r
83+
return None
84+
85+
86+
def create():
87+
# ── 1. Fieldset + CDW custom fields ───────────────────────────────────────
88+
print(f"\n[1/3] Fieldset: {FIELDSET_NAME!r}")
89+
fs = find_by_name(api("GET", "/fieldsets")["rows"], FIELDSET_NAME)
90+
if fs:
91+
fieldset_id = fs["id"]
92+
print(f" → already exists (id={fieldset_id}), skipping")
93+
else:
94+
result = api("POST", "/fieldsets", {"name": FIELDSET_NAME})
95+
if result.get("status") != "success":
96+
print(f" ERROR: {result}", file=sys.stderr); sys.exit(1)
97+
fieldset_id = result["payload"]["id"]
98+
print(f" → created (id={fieldset_id})")
99+
100+
# Create CDW custom fields and associate with fieldset
101+
cdw_field_db_cols = {}
102+
existing_fields = api("GET", "/fields?limit=500")["rows"]
103+
for cf in CDW_FIELDS:
104+
existing = find_by_name(existing_fields, cf["name"])
105+
if existing:
106+
fid = existing["id"]
107+
db_col = existing.get("db_column_name", "")
108+
print(f" field {cf['name']!r} already exists (id={fid}, db_col={db_col})")
109+
else:
110+
result = api("POST", "/fields", {
111+
"name": cf["name"],
112+
"element": cf["element"],
113+
"format": cf["format"],
114+
"help_text": cf["help_text"],
115+
})
116+
if result.get("status") != "success":
117+
print(f" ERROR creating field {cf['name']!r}: {result}", file=sys.stderr); sys.exit(1)
118+
fid = result["payload"]["id"]
119+
db_col = result["payload"].get("db_column_name", "")
120+
print(f" → created field {cf['name']!r} (id={fid}, db_col={db_col})")
121+
122+
# Associate with fieldset
123+
r = api("POST", f"/fields/{fid}/associate", {"fieldset_id": fieldset_id})
124+
print(f" associate field {fid}: {r.get('status', '?')}")
125+
126+
if db_col:
127+
cdw_field_db_cols[cf["name"]] = db_col
128+
129+
# Re-fetch fields to get db_column_name if we didn't have them
130+
if len(cdw_field_db_cols) < len(CDW_FIELDS):
131+
all_fields = api("GET", "/fields?limit=500")["rows"]
132+
for cf in CDW_FIELDS:
133+
if cf["name"] not in cdw_field_db_cols:
134+
f = find_by_name(all_fields, cf["name"])
135+
if f and f.get("db_column_name"):
136+
cdw_field_db_cols[cf["name"]] = f["db_column_name"]
137+
138+
# ── 2. Model ──────────────────────────────────────────────────────────────
139+
print(f"\n[2/3] Model: {MODEL_NAME!r}")
140+
mdl = find_by_name(api("GET", "/models?limit=500")["rows"], MODEL_NAME)
141+
if mdl:
142+
model_id = mdl["id"]
143+
print(f" → already exists (id={model_id}), skipping")
144+
else:
145+
result = api("POST", "/models", {
146+
"name": MODEL_NAME,
147+
"model_number": MODEL_NUMBER,
148+
"manufacturer_id": MANUFACTURER_ID,
149+
"category_id": CATEGORY_ID,
150+
"fieldset_id": fieldset_id,
151+
})
152+
if result.get("status") != "success":
153+
print(f" ERROR: {result}", file=sys.stderr); sys.exit(1)
154+
model_id = result["payload"]["id"]
155+
print(f" → created (id={model_id})")
156+
157+
# ── 3. Asset ──────────────────────────────────────────────────────────────
158+
print(f"\n[3/3] Asset: {ASSET_TAG!r} (serial {SERIAL})")
159+
existing = api("GET", f"/hardware/byserial/{SERIAL}")
160+
if existing.get("total", 0) > 0:
161+
asset_id = existing["rows"][0]["id"]
162+
print(f" → already exists (id={asset_id}), skipping")
163+
else:
164+
# Build asset payload with CDW custom fields
165+
asset_data = {
166+
"asset_tag": ASSET_TAG,
167+
"serial": SERIAL,
168+
"name": "MacBook Pro 16\" M4 Pro (Space Black)",
169+
"model_id": model_id,
170+
"status_id": STATUS_ID,
171+
"supplier_id": SUPPLIER_ID,
172+
"purchase_date": "2025-01-14",
173+
"purchase_cost": "4838.11",
174+
}
175+
176+
# Map CDW field values by db_column_name
177+
cdw_values = {
178+
"CDW: Order Date": "2025-01-14",
179+
"CDW: Invoice Date": "2025-01-15",
180+
"CDW: Purchaser": "Jane Smith",
181+
"CDW: Purchase Order #": "PO-2025-0042",
182+
"CDW: Invoice #": "INV-9876543",
183+
"CDW: Ship Date": "2025-01-17",
184+
}
185+
for field_name, value in cdw_values.items():
186+
db_col = cdw_field_db_cols.get(field_name)
187+
if db_col:
188+
asset_data[db_col] = value
189+
190+
result = api("POST", "/hardware", asset_data)
191+
if result.get("status") != "success":
192+
print(f" ERROR: {result}", file=sys.stderr); sys.exit(1)
193+
asset_id = result["payload"]["id"]
194+
print(f" → created (id={asset_id})")
195+
196+
print(f"\nDone.")
197+
print(f" Asset: {SNIPE_URL}/hardware/{asset_id}")
198+
print(f" Fieldset: {SNIPE_URL}/fields/fieldsets/{fieldset_id}/edit")
199+
200+
201+
def delete():
202+
print("Deleting demo data...\n")
203+
204+
# Delete asset by serial
205+
existing = api("GET", f"/hardware/byserial/{SERIAL}")
206+
if existing.get("total", 0) > 0:
207+
asset_id = existing["rows"][0]["id"]
208+
api("DELETE", f"/hardware/{asset_id}", fatal=False)
209+
print(f" Deleted asset id={asset_id}")
210+
else:
211+
print(" Asset not found, skipping")
212+
213+
# Delete model by name
214+
mdl = find_by_name(api("GET", "/models?limit=500")["rows"], MODEL_NAME)
215+
if mdl:
216+
api("DELETE", f"/models/{mdl['id']}", fatal=False)
217+
print(f" Deleted model id={mdl['id']}")
218+
else:
219+
print(" Model not found, skipping")
220+
221+
# Delete fieldset by name (disassociate fields first, but do NOT delete the
222+
# fields themselves — they may be shared with the real cdw2snipe setup)
223+
fs = find_by_name(api("GET", "/fieldsets")["rows"], FIELDSET_NAME)
224+
if fs:
225+
fid = fs["id"]
226+
for field in api("GET", f"/fieldsets/{fid}").get("fields", {}).get("rows", []):
227+
api("POST", f"/fields/{field['id']}/disassociate", {"fieldset_id": fid}, fatal=False)
228+
print(f" Disassociated field {field['id']} from fieldset")
229+
api("DELETE", f"/fieldsets/{fid}", fatal=False)
230+
print(f" Deleted fieldset id={fid}")
231+
else:
232+
print(" Fieldset not found, skipping")
233+
234+
print("\nDone.")
235+
236+
237+
if "--delete" in sys.argv:
238+
delete()
239+
else:
240+
create()
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#!/usr/bin/env python3
2+
"""Generate AI-written release notes using merged PRs and Claude."""
3+
import sys
4+
import os
5+
import json
6+
import urllib.request
7+
import subprocess
8+
9+
version = sys.argv[1]
10+
prev = sys.argv[2]
11+
since = os.environ["SINCE"]
12+
repo = os.environ["REPO"]
13+
14+
15+
def run_gh(*args):
16+
result = subprocess.run(["gh"] + list(args), capture_output=True, text=True)
17+
try:
18+
data = json.loads(result.stdout) if result.stdout.strip() else []
19+
return data if isinstance(data, list) else []
20+
except (json.JSONDecodeError, ValueError):
21+
return []
22+
23+
24+
prs = run_gh(
25+
"pr", "list", "--repo", repo,
26+
"--state", "merged",
27+
"--search", "merged:>=" + since,
28+
"--json", "number,title,author,body,mergedAt",
29+
"--limit", "100",
30+
)
31+
32+
contributors = sorted({
33+
pr["author"]["login"]
34+
for pr in prs
35+
if pr.get("author") and pr["author"].get("login")
36+
and not pr["author"]["login"].endswith("[bot]")
37+
})
38+
39+
pr_lines = "\n".join(
40+
"- #" + str(pr["number"]) + ": " + pr["title"] +
41+
" (@" + (pr["author"]["login"] if pr.get("author") and pr["author"].get("login") else "unknown") + ")"
42+
for pr in sorted(prs, key=lambda p: p["number"])
43+
)
44+
45+
prev_prs = run_gh(
46+
"pr", "list", "--repo", repo,
47+
"--state", "merged",
48+
"--search", "merged:<" + since,
49+
"--json", "author",
50+
"--limit", "500",
51+
)
52+
prev_contributors = {
53+
p["author"]["login"] for p in prev_prs
54+
if p.get("author") and p["author"].get("login")
55+
}
56+
new_contributors = [c for c in contributors if c not in prev_contributors]
57+
58+
repo_url = "https://github.com/" + repo
59+
contributors_str = ", ".join("@" + c for c in contributors) or "none"
60+
new_contributors_str = ", ".join("@" + c for c in new_contributors) if new_contributors else "none"
61+
pr_lines_str = pr_lines if pr_lines else "(no PRs found)"
62+
63+
prompt = (
64+
"You are writing GitHub release notes for cdw2snipe, a Go CLI tool that imports "
65+
"CDW order data from xlsx exports into Snipe-IT asset management.\n\n"
66+
"New version: " + version + "\n"
67+
"Previous version: " + (prev if prev else "first release") + "\n"
68+
"Repository: " + repo_url + "\n\n"
69+
"Merged pull requests in this release:\n" + pr_lines_str + "\n\n"
70+
"Contributors in this release: " + contributors_str + "\n"
71+
"New contributors (first-time): " + new_contributors_str + "\n\n"
72+
"Write concise, user-facing release notes in GitHub Markdown following this structure exactly:\n\n"
73+
"## What's New\n"
74+
"(grouped bullet points by theme, written from a user perspective, "
75+
"referencing PR numbers as links like [#123](url) and @author)\n\n"
76+
"## Contributors\n"
77+
"(bullet list of all contributors as @username GitHub profile links; "
78+
"mark first-time contributors with a party popper emoji)\n\n"
79+
"**Full Changelog**: " + repo_url + "/compare/" + prev + "..." + version + "\n\n"
80+
"Be specific but brief. Do not invent features not present in the PR list."
81+
)
82+
83+
payload = {
84+
"model": "claude-haiku-4-5-20251001",
85+
"max_tokens": 1024,
86+
"messages": [{"role": "user", "content": prompt}],
87+
}
88+
89+
req = urllib.request.Request(
90+
"https://api.anthropic.com/v1/messages",
91+
data=json.dumps(payload).encode(),
92+
headers={
93+
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
94+
"anthropic-version": "2023-06-01",
95+
"content-type": "application/json",
96+
},
97+
method="POST",
98+
)
99+
with urllib.request.urlopen(req) as resp:
100+
data = json.loads(resp.read())
101+
print(data["content"][0]["text"])

0 commit comments

Comments
 (0)