Add CI workflow and apply ruff formatting (#57)

This commit is contained in:
Adam Moussa 2026-05-08 15:46:51 -04:00 • committed by GitHub
parent 7aefcb9d68
commit c741924ee7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 225 additions and 69 deletions

10
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,10 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
with:
source-dirs: "src"

View file

@ -22,7 +22,15 @@ from src.schedule import FALLBACK_EXTENSION, ShiftSchedule
logger = logging.getLogger(__name__)
EASTERN = ZoneInfo("America/New_York")
DAY_NAMES = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
DAY_NAMES = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
]
def parse_date(text: str) -> datetime | None:
@ -78,7 +86,9 @@ def is_today(date_str: str) -> bool:
return date_str == datetime.now(EASTERN).strftime("%Y-%m-%d")
def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None = None) -> App:
def create_app(
bot_token: str, signing_secret: str, schedule_channel: str | None = None
) -> App:
app = App(
token=bot_token,
signing_secret=signing_secret,
@ -110,11 +120,17 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
elif text.startswith("register"):
_handle_register(respond, schedule, user_id, text)
elif text.startswith("pick"):
_handle_pick(respond, schedule, user_id, text, schedule_channel or channel_id, client)
_handle_pick(
respond, schedule, user_id, text, schedule_channel or channel_id, client
)
elif text.startswith("drop"):
_handle_drop(respond, schedule, user_id, text, schedule_channel or channel_id, client)
_handle_drop(
respond, schedule, user_id, text, schedule_channel or channel_id, client
)
elif text.startswith("swap"):
_handle_swap(respond, schedule, user_id, text, schedule_channel or channel_id, client)
_handle_swap(
respond, schedule, user_id, text, schedule_channel or channel_id, client
)
else:
respond(text="Unknown command. Try `/oncall help`")
@ -144,7 +160,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
)
return
claimed = schedule.claim_open_shift(date_str, employee["extension"], employee["name"], shift_type)
claimed = schedule.claim_open_shift(
date_str, employee["extension"], employee["name"], shift_type
)
if not claimed:
client.chat_postEphemeral(
channel=channel_id,
@ -159,7 +177,12 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
blocks = build_shift_change_message(
user_id, date_str, "picked_up", employee["extension"], employee["name"]
)
respond(response_type="in_channel", replace_original=False, blocks=blocks, text=f"Shift picked up for {date_str}")
respond(
response_type="in_channel",
replace_original=False,
blocks=blocks,
text=f"Shift picked up for {date_str}",
)
# ── Subcommand handlers ─────────────────────────────────────────────
@ -185,13 +208,17 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
pay_record = schedule.get_pay_record(week_key)
if pay_record and pay_record.get("breakdown"):
prev_sunday = prev_monday + timedelta(days=6)
week_label = f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
week_label = (
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
)
blocks = build_pay_summary_blocks(
week_label, pay_record["breakdown"], pay_record["totals"]
)
respond(blocks=blocks)
else:
respond(text=f"No pay record found for the week of {prev_monday.strftime('%b %-d')}.")
respond(
text=f"No pay record found for the week of {prev_monday.strftime('%b %-d')}."
)
def _handle_rate(respond, schedule, text):
parts = text.split()
@ -200,7 +227,11 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
default_rate = schedule.get_shift_rate()
roster = schedule.get_roster()
lines = [f"*Default rate:* ${default_rate:.2f}/shift\n"]
custom = [(e["SK"], e.get("name", "Unknown"), float(e["shift_rate"])) for e in roster if e.get("shift_rate")]
custom = [
(e["SK"], e.get("name", "Unknown"), float(e["shift_rate"]))
for e in roster
if e.get("shift_rate")
]
if custom:
lines.append("*Per-person rates:*")
for ext, name, rate in sorted(custom, key=lambda x: x[0]):
@ -213,7 +244,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
# /oncall rate default <amount>
if parts[1] == "default":
if len(parts) < 3:
respond(text="Usage: `/oncall rate default <amount>` (e.g. `/oncall rate default 50`)")
respond(
text="Usage: `/oncall rate default <amount>` (e.g. `/oncall rate default 50`)"
)
return
try:
amount = float(parts[2].replace("$", ""))
@ -226,7 +259,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
# /oncall rate <extension> <amount>
if len(parts) < 3:
respond(text="Usage: `/oncall rate <extension> <amount>` (e.g. `/oncall rate 114 75`)")
respond(
text="Usage: `/oncall rate <extension> <amount>` (e.g. `/oncall rate 114 75`)"
)
return
ext = parts[1]
@ -242,18 +277,24 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
return
schedule.set_employee_shift_rate(ext, amount)
respond(text=f"Shift rate for *{employee['name']}* (Ext {ext}) set to *${amount:.2f}*.")
respond(
text=f"Shift rate for *{employee['name']}* (Ext {ext}) set to *${amount:.2f}*."
)
def _handle_register(respond, schedule, user_id, text):
parts = text.split()
if len(parts) < 2:
respond(text="Usage: `/oncall register <extension>` (e.g. `/oncall register 114`)")
respond(
text="Usage: `/oncall register <extension>` (e.g. `/oncall register 114`)"
)
return
ext = parts[1].strip()
employee = schedule.register_user(user_id, ext)
if not employee:
respond(text=f"Extension {ext} not found in the roster. Check `/oncall roster`.")
respond(
text=f"Extension {ext} not found in the roster. Check `/oncall roster`."
)
return
respond(text=f"Linked your account to *{employee['name']}* (Ext {ext}).")
@ -266,12 +307,16 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
employee = schedule.get_employee_by_slack_id(user_id)
if not employee:
respond(text="You're not registered. Use `/oncall register <extension>` first.")
respond(
text="You're not registered. Use `/oncall register <extension>` first."
)
return
date = parse_date(parts[1])
if not date:
respond(text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05")
respond(
text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05"
)
return
date_str = date.strftime("%Y-%m-%d")
@ -280,7 +325,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
# Already assigned to someone else (not open)
if source in ("weekly", "override") and ext != FALLBACK_EXTENSION:
respond(text=f"That shift is already covered by {name} (Ext {ext}). They'd need to drop it first.")
respond(
text=f"That shift is already covered by {name} (Ext {ext}). They'd need to drop it first."
)
return
schedule.set_override(date_str, employee["extension"], employee["name"])
@ -294,7 +341,11 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
user_id, date_str, "picked_up", employee["extension"], employee["name"]
)
try:
client.chat_postMessage(channel=channel_id, blocks=blocks, text=f"Shift picked up for {date_str}")
client.chat_postMessage(
channel=channel_id,
blocks=blocks,
text=f"Shift picked up for {date_str}",
)
except Exception:
logger.exception("Failed to post pickup notification to channel")
@ -306,12 +357,16 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
employee = schedule.get_employee_by_slack_id(user_id)
if not employee:
respond(text="You're not registered. Use `/oncall register <extension>` first.")
respond(
text="You're not registered. Use `/oncall register <extension>` first."
)
return
date = parse_date(parts[1])
if not date:
respond(text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05")
respond(
text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05"
)
return
date_str = date.strftime("%Y-%m-%d")
@ -327,11 +382,15 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
if is_today(date_str):
invoke_3cx_scheduler(FALLBACK_EXTENSION)
respond(text=f"You dropped the shift for *{date.strftime('%A, %b %-d')}*. It's now open for pickup.")
respond(
text=f"You dropped the shift for *{date.strftime('%A, %b %-d')}*. It's now open for pickup."
)
blocks = build_shift_change_message(user_id, date_str, "dropped", ext, name)
try:
client.chat_postMessage(channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}")
client.chat_postMessage(
channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}"
)
except Exception:
logger.exception("Failed to post drop notification to channel")
@ -339,12 +398,16 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
# Expected format: swap <date> @user OR swap <date> <extension>
parts = text.split(maxsplit=2)
if len(parts) < 3:
respond(text="Usage: `/oncall swap <date> @person` (e.g. `/oncall swap friday @sarah`)")
respond(
text="Usage: `/oncall swap <date> @person` (e.g. `/oncall swap friday @sarah`)"
)
return
employee = schedule.get_employee_by_slack_id(user_id)
if not employee:
respond(text="You're not registered. Use `/oncall register <extension>` first.")
respond(
text="You're not registered. Use `/oncall register <extension>` first."
)
return
date = parse_date(parts[1])
@ -357,7 +420,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
ext, name, source = schedule.resolve_shift(date_str, day_name)
if ext != employee["extension"]:
respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts.")
respond(
text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts."
)
return
# Resolve target user — could be <@U12345> or an extension number
@ -367,7 +432,9 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
target_slack_id = slack_id_match.group(1)
target = schedule.get_employee_by_slack_id(target_slack_id)
if not target:
respond(text=f"<@{target_slack_id}> isn't registered. They need to run `/oncall register <extension>`.")
respond(
text=f"<@{target_slack_id}> isn't registered. They need to run `/oncall register <extension>`."
)
return
else:
target = schedule.get_employee_by_extension(target_text)
@ -387,10 +454,14 @@ def create_app(bot_token: str, signing_secret: str, schedule_channel: str | None
target["extension"],
target["name"],
)
respond(text=f"Swapped *{date.strftime('%A, %b %-d')}* to {target['name']} (Ext {target['extension']}).")
respond(
text=f"Swapped *{date.strftime('%A, %b %-d')}* to {target['name']} (Ext {target['extension']})."
)
try:
client.chat_postMessage(channel=channel_id, blocks=blocks, text=f"Shift swapped for {date_str}")
client.chat_postMessage(
channel=channel_id, blocks=blocks, text=f"Shift swapped for {date_str}"
)
except Exception:
logger.exception("Failed to post swap notification to channel")

View file

@ -8,7 +8,14 @@ EASTERN = ZoneInfo("America/New_York")
WEEKEND_DAYS = {"Saturday", "Sunday"}
def _format_shift_line(date: datetime, ext: str, name: str, source: str, is_today: bool, shift_type: str = "night") -> str:
def _format_shift_line(
date: datetime,
ext: str,
name: str,
source: str,
is_today: bool,
shift_type: str = "night",
) -> str:
day_label = date.strftime("%a %b %-d")
if shift_type == "day":
day_label += " (Day 8a–5p)"
@ -65,7 +72,10 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
blocks = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"After-Hours Schedule — {start_label} to {end_label}"},
"text": {
"type": "plain_text",
"text": f"After-Hours Schedule — {start_label} to {end_label}",
},
},
{
"type": "section",
@ -84,14 +94,19 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
elements.append(
{
"type": "button",
"text": {"type": "plain_text", "text": f"Pick up {dt.strftime('%a %b %-d')}{suffix}"},
"text": {
"type": "plain_text",
"text": f"Pick up {dt.strftime('%a %b %-d')}{suffix}",
},
"action_id": f"pickup_{date_str}{action_suffix}",
"style": "primary",
}
)
# Slack allows max 5 buttons per actions block — use multiple blocks
for chunk_start in range(0, len(elements), 5):
blocks.append({"type": "actions", "elements": elements[chunk_start:chunk_start + 5]})
blocks.append(
{"type": "actions", "elements": elements[chunk_start : chunk_start + 5]}
)
return blocks
@ -136,7 +151,9 @@ def build_help_blocks() -> list[dict]:
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def build_pay_summary_blocks(week_label: str, pay_breakdown: list[dict], total_by_person: dict[str, dict]) -> list[dict]:
def build_pay_summary_blocks(
week_label: str, pay_breakdown: list[dict], total_by_person: dict[str, dict]
) -> list[dict]:
"""Build Block Kit blocks for the weekly pay summary.
Args:
@ -146,12 +163,16 @@ def build_pay_summary_blocks(week_label: str, pay_breakdown: list[dict], total_b
"""
lines = []
for entry in pay_breakdown:
lines.append(f"{entry['day']} {entry['date_label']} — {entry['name']} (Ext {entry['extension']}) — ${entry['rate']:.2f}")
lines.append(
f"{entry['day']} {entry['date_label']} — {entry['name']} (Ext {entry['extension']}) — ${entry['rate']:.2f}"
)
lines.append("\n*Totals:*")
for name, info in sorted(total_by_person.items()):
rate_note = f" @ ${info['rate']:.2f}/shift" if info.get("rate") else ""
lines.append(f"• {name} — {info['shifts']} shift{'s' if info['shifts'] != 1 else ''}{rate_note} — *${info['total']:.2f}*")
lines.append(
f"• {name} — {info['shifts']} shift{'s' if info['shifts'] != 1 else ''}{rate_note} — *${info['total']:.2f}*"
)
blocks = [
{
@ -169,11 +190,18 @@ def build_pay_summary_blocks(week_label: str, pay_breakdown: list[dict], total_b
def build_roster_blocks(roster: list[dict]) -> list[dict]:
"""Build roster display blocks."""
if not roster:
return [{"type": "section", "text": {"type": "mrkdwn", "text": "No employees in the roster."}}]
return [
{
"type": "section",
"text": {"type": "mrkdwn", "text": "No employees in the roster."},
}
]
lines = ["*On-Call Roster*\n"]
for emp in sorted(roster, key=lambda x: x["SK"]):
slack = f"<@{emp['slack_user_id']}>" if emp.get("slack_user_id") else "_not linked_"
slack = (
f"<@{emp['slack_user_id']}>" if emp.get("slack_user_id") else "_not linked_"
)
lines.append(f"Ext {emp['SK']} — {emp.get('name', 'Unknown')} ({slack})")
return [{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}]

View file

@ -12,7 +12,9 @@ logger = logging.getLogger()
logger.setLevel(logging.INFO)
SlackRequestHandler.clear_all_log_handlers()
logging.basicConfig(format="%(asctime)s %(levelname)s %(name)s: %(message)s", level=logging.INFO)
logging.basicConfig(
format="%(asctime)s %(levelname)s %(name)s: %(message)s", level=logging.INFO
)
# Lazy-initialized app singleton
_slack_handler = None

View file

@ -41,12 +41,16 @@ def handler(event, context):
# DST guard — two EventBridge rules fire, only one is at 6am ET
force = event.get("force", False)
if not force and now.hour != 6:
logger.info("ET hour is %d, not 6am — skipping (wrong DST rule fired)", now.hour)
logger.info(
"ET hour is %d, not 6am — skipping (wrong DST rule fired)", now.hour
)
return {"skipped": True}
group_name = os.environ.get("SYNC_GROUP", "DEFAULT")
logger.info("Starting roster sync from 3CX group '%s' at %s", group_name, now.isoformat())
logger.info(
"Starting roster sync from 3CX group '%s' at %s", group_name, now.isoformat()
)
creds = get_3cx_credentials()
client = ThreeCXClient(
@ -59,10 +63,16 @@ def handler(event, context):
members = client.get_group_members(group_name)
# Only sync actual phone extensions, not ring groups, IVRs, etc.
extensions = [
m for m in members
m
for m in members
if m.get("Type") == "Extension" and m.get("MemberName") not in EXCLUDE_NAMES
]
logger.info("Found %d extensions in 3CX group '%s' (filtered from %d members)", len(extensions), group_name, len(members))
logger.info(
"Found %d extensions in 3CX group '%s' (filtered from %d members)",
len(extensions),
group_name,
len(members),
)
schedule = ShiftSchedule()
current_roster = {item["SK"]: item for item in schedule.get_roster()}
@ -111,7 +121,9 @@ def handler(event, context):
schedule.table.delete_item(Key={"PK": "ROSTER", "SK": ext_number})
removed.append(f"Ext {ext_number}: {item.get('name', 'Unknown')}")
elif current_roster:
logger.warning("No 3CX extensions found but roster is non-empty — skipping removal to prevent data loss")
logger.warning(
"No 3CX extensions found but roster is non-empty — skipping removal to prevent data loss"
)
result = {
"group": group_name,

View file

@ -62,7 +62,9 @@ class ShiftSchedule:
resp = self.table.get_item(Key={"PK": "OVERRIDE", "SK": sk})
return resp.get("Item")
def set_override(self, date_str: str, extension: str, name: str, shift_type: str = "night") -> None:
def set_override(
self, date_str: str, extension: str, name: str, shift_type: str = "night"
) -> None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.put_item(
Item={
@ -73,7 +75,9 @@ class ShiftSchedule:
}
)
def claim_open_shift(self, date_str: str, extension: str, name: str, shift_type: str = "night") -> bool:
def claim_open_shift(
self, date_str: str, extension: str, name: str, shift_type: str = "night"
) -> bool:
"""Atomically claim a shift only if it is currently open.
Succeeds when no override exists (weekly fallback) or the override
@ -112,7 +116,9 @@ class ShiftSchedule:
# ── Resolution ──────────────────────────────────────────────────────
def resolve_shift(self, date_str: str, day_name: str, shift_type: str = "night") -> tuple[str, str, str]:
def resolve_shift(
self, date_str: str, day_name: str, shift_type: str = "night"
) -> tuple[str, str, str]:
"""Return (extension, name, source) for a given date and shift type.
shift_type: 'night' (after-hours) or 'day' (weekend 8am-5pm)
@ -145,9 +151,7 @@ class ShiftSchedule:
def save_pay_record(self, week_key: str, record: dict) -> None:
"""Save a weekly pay summary. week_key is the Monday date string."""
self.table.put_item(
Item={"PK": "PAY", "SK": week_key, **record}
)
self.table.put_item(Item={"PK": "PAY", "SK": week_key, **record})
# ── Config ──────────────────────────────────────────────────────────

View file

@ -16,13 +16,17 @@ class ThreeCXClient:
"""
self.base_url = f"https://{domain}"
self.session = requests.Session()
self.session.headers.update({
"OData-Version": "4.0",
"Content-Type": "application/json",
})
self.session.headers.update(
{
"OData-Version": "4.0",
"Content-Type": "application/json",
}
)
if auth_mode == "oauth":
self._authenticate_oauth(auth_kwargs["client_id"], auth_kwargs["client_secret"])
self._authenticate_oauth(
auth_kwargs["client_id"], auth_kwargs["client_secret"]
)
else:
self._authenticate_user(auth_kwargs["username"], auth_kwargs["password"])

View file

@ -18,7 +18,15 @@ logger.setLevel(logging.INFO)
EASTERN = ZoneInfo("America/New_York")
DAY_ORDER = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
DAY_ORDER = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]
WEEKEND_DAYS = {"Saturday", "Sunday"}
@ -35,17 +43,24 @@ def _add_shift_to_pay(schedule, breakdown, totals, date, day_name, shift_type="n
rate = schedule.get_shift_rate(ext)
label_suffix = " (Day)" if shift_type == "day" else ""
breakdown.append({
"date": date_str,
"date_label": date.strftime("%b %-d"),
"day": f"{day_name[:3]}{label_suffix}",
"name": name,
"extension": ext,
"rate": Decimal(str(rate)),
})
breakdown.append(
{
"date": date_str,
"date_label": date.strftime("%b %-d"),
"day": f"{day_name[:3]}{label_suffix}",
"name": name,
"extension": ext,
"rate": Decimal(str(rate)),
}
)
if name not in totals:
totals[name] = {"shifts": 0, "total": Decimal("0"), "extension": ext, "rate": Decimal(str(rate))}
totals[name] = {
"shifts": 0,
"total": Decimal("0"),
"extension": ext,
"rate": Decimal(str(rate)),
}
totals[name]["shifts"] += 1
totals[name]["total"] += Decimal(str(rate))
@ -127,7 +142,9 @@ def handler(event, context):
# DST guard — same pattern as the 3CX scheduler
force = event.get("force", False)
if not force and now.hour != 7:
logger.info("ET hour is %d, not 7am — skipping (wrong DST rule fired)", now.hour)
logger.info(
"ET hour is %d, not 7am — skipping (wrong DST rule fired)", now.hour
)
return {"skipped": True}
ssm = boto3.client("ssm")
@ -153,7 +170,9 @@ def handler(event, context):
schedule.save_pay_record(week_key, pay_record)
prev_sunday = prev_monday + timedelta(days=6)
week_label = f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
week_label = (
f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}"
)
pay_blocks = build_pay_summary_blocks(
week_label, pay_record["breakdown"], pay_record["totals"]
)
@ -165,7 +184,9 @@ def handler(event, context):
blocks=pay_blocks,
text=f"After-Hours Pay — {week_label}",
)
logger.info("Sent pay summary DM to %s for week of %s", pay_dm_user, week_key)
logger.info(
"Sent pay summary DM to %s for week of %s", pay_dm_user, week_key
)
else:
logger.warning("PAY_REPORT_USER not set — skipping Slack pay summary")
@ -184,4 +205,8 @@ def handler(event, context):
)
logger.info("Posted weekly schedule to channel %s", channel_id)
return {"posted": True, "channel": channel_id, "pay_calculated": bool(pay_record["breakdown"])}
return {
"posted": True,
"channel": channel_id,
"pay_calculated": bool(pay_record["breakdown"]),
}