diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..3c66f09 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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" diff --git a/src/app.py b/src/app.py index 2114608..0dd8f51 100644 --- a/src/app.py +++ b/src/app.py @@ -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 if parts[1] == "default": if len(parts) < 3: - respond(text="Usage: `/oncall rate default ` (e.g. `/oncall rate default 50`)") + respond( + text="Usage: `/oncall rate default ` (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 if len(parts) < 3: - respond(text="Usage: `/oncall rate ` (e.g. `/oncall rate 114 75`)") + respond( + text="Usage: `/oncall rate ` (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 ` (e.g. `/oncall register 114`)") + respond( + text="Usage: `/oncall register ` (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 ` first.") + respond( + text="You're not registered. Use `/oncall register ` 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 ` first.") + respond( + text="You're not registered. Use `/oncall register ` 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 @user OR swap parts = text.split(maxsplit=2) if len(parts) < 3: - respond(text="Usage: `/oncall swap @person` (e.g. `/oncall swap friday @sarah`)") + respond( + text="Usage: `/oncall swap @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 ` first.") + respond( + text="You're not registered. Use `/oncall register ` 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 `.") + respond( + text=f"<@{target_slack_id}> isn't registered. They need to run `/oncall register `." + ) 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") diff --git a/src/blocks.py b/src/blocks.py index db3d056..704fa1c 100644 --- a/src/blocks.py +++ b/src/blocks.py @@ -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)}}] diff --git a/src/handler.py b/src/handler.py index fe6abe3..08a3f3d 100644 --- a/src/handler.py +++ b/src/handler.py @@ -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 diff --git a/src/roster_sync.py b/src/roster_sync.py index f10327a..72bfb68 100644 --- a/src/roster_sync.py +++ b/src/roster_sync.py @@ -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, diff --git a/src/schedule.py b/src/schedule.py index dd88326..45dc81e 100644 --- a/src/schedule.py +++ b/src/schedule.py @@ -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 ────────────────────────────────────────────────────────── diff --git a/src/three_cx_client.py b/src/three_cx_client.py index e068883..09b5c35 100644 --- a/src/three_cx_client.py +++ b/src/three_cx_client.py @@ -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"]) diff --git a/src/weekly_post.py b/src/weekly_post.py index 100a334..a7cc5a3 100644 --- a/src/weekly_post.py +++ b/src/weekly_post.py @@ -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"]), + }