Add CI workflow and apply ruff formatting (#10)

This commit is contained in:
Adam Moussa 2026-05-08 15:51:22 -04:00 • committed by GitHub
parent 885379f85d
commit 853fb3253f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 50 additions and 20 deletions

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

@ -0,0 +1,12 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
with:
source-dirs: "lambdas cdk shared"
run-cdk-synth: true
run-sam-validate: false

View file

@ -3,7 +3,9 @@ import aws_cdk as cdk
from stack import WorkorderIngestStack from stack import WorkorderIngestStack
app = cdk.App() app = cdk.App()
WorkorderIngestStack(app, "workorder-ingest", WorkorderIngestStack(
app,
"workorder-ingest",
stack_name="WorkorderIngestStack", stack_name="WorkorderIngestStack",
env=cdk.Environment(region="us-east-1"), env=cdk.Environment(region="us-east-1"),
) )

View file

@ -12,7 +12,6 @@ from aws_cdk import (
aws_ses as ses, aws_ses as ses,
aws_ses_actions as ses_actions, aws_ses_actions as ses_actions,
aws_secretsmanager as secretsmanager, aws_secretsmanager as secretsmanager,
aws_iam as iam,
) )
from constructs import Construct from constructs import Construct
@ -23,7 +22,8 @@ class WorkorderIngestStack(Stack):
# --- S3 bucket for raw emails --- # --- S3 bucket for raw emails ---
email_bucket = s3.Bucket( email_bucket = s3.Bucket(
self, "EmailBucket", self,
"EmailBucket",
bucket_name=f"workorder-ingest-emails-{self.account}", bucket_name=f"workorder-ingest-emails-{self.account}",
removal_policy=RemovalPolicy.RETAIN, removal_policy=RemovalPolicy.RETAIN,
lifecycle_rules=[ lifecycle_rules=[
@ -33,7 +33,8 @@ class WorkorderIngestStack(Stack):
# --- DynamoDB tables --- # --- DynamoDB tables ---
work_orders_table = dynamodb.Table( work_orders_table = dynamodb.Table(
self, "WorkOrdersTable", self,
"WorkOrdersTable",
table_name="WorkOrders", table_name="WorkOrders",
partition_key=dynamodb.Attribute( partition_key=dynamodb.Attribute(
name="work_order_id", name="work_order_id",
@ -68,7 +69,8 @@ class WorkorderIngestStack(Stack):
) )
comments_table = dynamodb.Table( comments_table = dynamodb.Table(
self, "CommentsTable", self,
"CommentsTable",
table_name="WorkOrderComments", table_name="WorkOrderComments",
partition_key=dynamodb.Attribute( partition_key=dynamodb.Attribute(
name="work_order_id", name="work_order_id",
@ -84,14 +86,16 @@ class WorkorderIngestStack(Stack):
# --- Secrets Manager for Anthropic API key --- # --- Secrets Manager for Anthropic API key ---
anthropic_secret = secretsmanager.Secret( anthropic_secret = secretsmanager.Secret(
self, "AnthropicApiKey", self,
"AnthropicApiKey",
secret_name="workorder-ingest/anthropic-api-key", secret_name="workorder-ingest/anthropic-api-key",
description="Anthropic API key for work order email parsing", description="Anthropic API key for work order email parsing",
) )
# --- Lambda function --- # --- Lambda function ---
email_processor = lambda_.Function( email_processor = lambda_.Function(
self, "EmailProcessor", self,
"EmailProcessor",
function_name="workorder-email-processor", function_name="workorder-email-processor",
runtime=lambda_.Runtime.PYTHON_3_12, runtime=lambda_.Runtime.PYTHON_3_12,
handler="handler.handler", handler="handler.handler",
@ -101,10 +105,11 @@ class WorkorderIngestStack(Stack):
image=lambda_.Runtime.PYTHON_3_12.bundling_image, image=lambda_.Runtime.PYTHON_3_12.bundling_image,
platform="linux/amd64", platform="linux/amd64",
command=[ command=[
"bash", "-c", "bash",
"-c",
"pip install --platform manylinux2014_x86_64 --only-binary=:all: " "pip install --platform manylinux2014_x86_64 --only-binary=:all: "
"-r requirements.txt -t /asset-output && " "-r requirements.txt -t /asset-output && "
"cp -r . /asset-output/" "cp -r . /asset-output/",
], ],
), ),
), ),
@ -132,7 +137,9 @@ class WorkorderIngestStack(Stack):
# --- SES Receipt Rule --- # --- SES Receipt Rule ---
rule_set = ses.ReceiptRuleSet.from_receipt_rule_set_name( rule_set = ses.ReceiptRuleSet.from_receipt_rule_set_name(
self, "ExistingRuleSet", "INBOUND_MAIL", self,
"ExistingRuleSet",
"INBOUND_MAIL",
) )
rule_set.add_rule( rule_set.add_rule(
@ -148,7 +155,8 @@ class WorkorderIngestStack(Stack):
# --- Web UI Lambda --- # --- Web UI Lambda ---
web_ui = lambda_.Function( web_ui = lambda_.Function(
self, "WebUI", self,
"WebUI",
function_name="workorder-web-ui", function_name="workorder-web-ui",
runtime=lambda_.Runtime.PYTHON_3_12, runtime=lambda_.Runtime.PYTHON_3_12,
handler="handler.handler", handler="handler.handler",
@ -169,4 +177,6 @@ class WorkorderIngestStack(Stack):
auth_type=lambda_.FunctionUrlAuthType.NONE, auth_type=lambda_.FunctionUrlAuthType.NONE,
) )
cdk.CfnOutput(self, "WebUIUrl", value=web_url.url, description="Work Order Dashboard URL") cdk.CfnOutput(
self, "WebUIUrl", value=web_url.url, description="Work Order Dashboard URL"
)

View file

@ -13,7 +13,6 @@ import os
import re import re
from datetime import datetime from datetime import datetime
from email import policy from email import policy
from typing import Optional
import anthropic import anthropic
import boto3 import boto3
@ -253,7 +252,9 @@ def handler(event, context):
# Extract structured data with Claude # Extract structured data with Claude
parsed = extract_with_claude(email_data) parsed = extract_with_claude(email_data)
logger.info(f"Parsed: type={parsed.get('email_type')}, wo={parsed.get('work_order_id')}") logger.info(
f"Parsed: type={parsed.get('email_type')}, wo={parsed.get('work_order_id')}"
)
if not parsed.get("work_order_id"): if not parsed.get("work_order_id"):
logger.warning(f"No work order ID found in email, skipping: {key}") logger.warning(f"No work order ID found in email, skipping: {key}")

View file

@ -1,5 +1,4 @@
import email import email
import json
import logging import logging
import os import os
import re import re
@ -120,6 +119,8 @@ def handler(event, context):
} }
) )
logger.info(f"Saved vendor reply: WO={wo_number} DSP={dispatch_number} from={sender_email}") logger.info(
f"Saved vendor reply: WO={wo_number} DSP={dispatch_number} from={sender_email}"
)
return {"statusCode": 200, "body": "OK"} return {"statusCode": 200, "body": "OK"}

View file

@ -5,9 +5,7 @@ Serves a simple HTML dashboard for viewing work orders and comments.
Accessed via Lambda Function URL. Accessed via Lambda Function URL.
""" """
import json
import os import os
import urllib.parse
import boto3 import boto3
@ -71,7 +69,9 @@ def render_work_order_detail(wo, events):
created = e.get("created_at", "") created = e.get("created_at", "")
text = e.get("text", "") text = e.get("text", "")
badge = render_badge(record_type, RECORD_TYPE_COLORS) badge = render_badge(record_type, RECORD_TYPE_COLORS)
commenter_str = f"<strong>{commenter}</strong> &mdash; " if commenter else "" commenter_str = (
f"<strong>{commenter}</strong> &mdash; " if commenter else ""
)
border_colors = { border_colors = {
"new_work_order": "#3b82f6", "new_work_order": "#3b82f6",
@ -95,7 +95,10 @@ def render_work_order_detail(wo, events):
fields = [ fields = [
("Description", wo.get("description")), ("Description", wo.get("description")),
("Status", render_badge(wo.get("wo_status", "unknown"), STATUS_COLORS)), ("Status", render_badge(wo.get("wo_status", "unknown"), STATUS_COLORS)),
("Record Type", render_badge(wo.get("record_type", "unknown"), RECORD_TYPE_COLORS)), (
"Record Type",
render_badge(wo.get("record_type", "unknown"), RECORD_TYPE_COLORS),
),
("Site Code", wo.get("site_code")), ("Site Code", wo.get("site_code")),
("Building", wo.get("building")), ("Building", wo.get("building")),
("Address", wo.get("address")), ("Address", wo.get("address")),

View file

@ -63,6 +63,7 @@ class WorkOrder:
@dataclass @dataclass
class ParsedEmail: class ParsedEmail:
"""Result of AI parsing an inbound email.""" """Result of AI parsing an inbound email."""
email_type: str # EmailType value email_type: str # EmailType value
work_order_id: Optional[str] = None work_order_id: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None