Add CI workflow (#18)

* Add CI workflow and apply ruff formatting

* Disable cdk synth — email_processor uses pre-built package dir

The email_processor Lambda bundles deps into a gitignored package/
directory. cdk synth fails in CI without a build step to recreate it.
Disabling until packaging is standardized.

* Use CDK BundlingOptions for email_processor Lambda packaging

Replaces the pre-built gitignored package/ directory with CDK's
built-in bundling. Deps are now installed inside a Docker container
during cdk synth, so the build works identically locally and in CI.
Re-enables run-cdk-synth in the CI workflow.
This commit is contained in:
Adam Moussa 2026-05-08 16:01:21 -04:00 • committed by GitHub
parent d1c1cb2f8b
commit abdf2aa035
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 84 additions and 32 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"
run-cdk-synth: true
run-sam-validate: false

View file

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

View file

@ -14,7 +14,6 @@ from aws_cdk import (
aws_ses as ses,
aws_ses_actions as ses_actions,
aws_secretsmanager as secretsmanager,
aws_iam as iam,
)
from constructs import Construct
@ -25,7 +24,8 @@ class PoIngestStack(Stack):
# --- S3 bucket for raw emails ---
email_bucket = s3.Bucket(
self, "EmailBucket",
self,
"EmailBucket",
bucket_name=f"po-ingest-emails-{self.account}",
removal_policy=RemovalPolicy.RETAIN,
lifecycle_rules=[
@ -37,7 +37,8 @@ class PoIngestStack(Stack):
# Owned by this stack. Streams enabled for the site-extractor pipeline.
# Other stacks (seahaven-slack-bot) reference this table via fromTableName().
po_table = dynamodb.Table(
self, "PurchaseOrdersTable",
self,
"PurchaseOrdersTable",
table_name="purchase-orders",
partition_key=dynamodb.Attribute(
name="po_number",
@ -50,7 +51,8 @@ class PoIngestStack(Stack):
# --- Secrets Manager for Anthropic API key ---
anthropic_secret = secretsmanager.Secret(
self, "AnthropicApiKey",
self,
"AnthropicApiKey",
secret_name="po-ingest/anthropic-api-key",
description="Anthropic API key for Coupa PO email parsing",
removal_policy=RemovalPolicy.RETAIN,
@ -58,12 +60,23 @@ class PoIngestStack(Stack):
# --- Lambda function ---
email_processor = lambda_.Function(
self, "EmailProcessor",
self,
"EmailProcessor",
function_name="po-email-processor",
runtime=lambda_.Runtime.PYTHON_3_12,
architecture=lambda_.Architecture.ARM_64,
handler="handler.handler",
code=lambda_.Code.from_asset("../lambdas/email_processor/package"),
code=lambda_.Code.from_asset(
"../lambdas/email_processor",
bundling=cdk.BundlingOptions(
image=lambda_.Runtime.PYTHON_3_12.bundling_image,
command=[
"bash",
"-c",
"pip install -r requirements.txt -t /asset-output && cp handler.py /asset-output/",
],
),
),
timeout=Duration.seconds(60),
memory_size=256,
log_retention=logs.RetentionDays.TWO_MONTHS,
@ -88,7 +101,9 @@ class PoIngestStack(Stack):
# --- SES Receipt Rule ---
# Reuse the existing INBOUND_MAIL rule set (shared with workorder-ingest)
rule_set = ses.ReceiptRuleSet.from_receipt_rule_set_name(
self, "ExistingRuleSet", "INBOUND_MAIL",
self,
"ExistingRuleSet",
"INBOUND_MAIL",
)
rule_set.add_rule(
@ -104,7 +119,8 @@ class PoIngestStack(Stack):
# --- Web UI Lambda ---
web_ui = lambda_.Function(
self, "WebUI",
self,
"WebUI",
function_name="po-web-ui",
runtime=lambda_.Runtime.PYTHON_3_12,
architecture=lambda_.Architecture.ARM_64,
@ -125,11 +141,14 @@ class PoIngestStack(Stack):
auth_type=lambda_.FunctionUrlAuthType.NONE,
)
cdk.CfnOutput(self, "WebUIUrl", value=web_url.url, description="PO Dashboard URL")
cdk.CfnOutput(
self, "WebUIUrl", value=web_url.url, description="PO Dashboard URL"
)
# --- Verified sites table (extracted from PO ship-to addresses) ---
verified_sites_table = dynamodb.Table(
self, "VerifiedSitesTable",
self,
"VerifiedSitesTable",
table_name="verified-sites",
partition_key=dynamodb.Attribute(
name="siteCode",
@ -149,7 +168,8 @@ class PoIngestStack(Stack):
# --- Site extractor Lambda (DynamoDB Streams → verified-sites) ---
site_extractor = lambda_.Function(
self, "SiteExtractor",
self,
"SiteExtractor",
function_name="po-ingest-site-extractor",
runtime=lambda_.Runtime.PYTHON_3_12,
architecture=lambda_.Architecture.ARM_64,
@ -177,14 +197,17 @@ class PoIngestStack(Stack):
)
)
cdk.CfnOutput(self, "VerifiedSitesTableName",
cdk.CfnOutput(
self,
"VerifiedSitesTableName",
value=verified_sites_table.table_name,
description="Verified site addresses extracted from POs",
)
# --- Pending site review table (POs with no extractable site code) ---
pending_review_table = dynamodb.Table(
self, "PendingSiteReviewTable",
self,
"PendingSiteReviewTable",
table_name="pending-site-review",
partition_key=dynamodb.Attribute(
name="po_number",

View file

@ -290,7 +290,7 @@ def pad_zip(zip_code: str | None) -> str | None:
return zip_code
clean = zip_code.strip().split("-")[0]
if clean.isdigit() and len(clean) < 5:
return clean.zfill(5) + zip_code.strip()[len(clean):]
return clean.zfill(5) + zip_code.strip()[len(clean) :]
return zip_code
@ -369,7 +369,9 @@ def handler(event, context):
logger.info(f"Subject: {email_data['subject']}")
parsed = extract_with_claude(email_data)
logger.info(f"Parsed: type={parsed.get('email_type')}, po={parsed.get('po_number')}")
logger.info(
f"Parsed: type={parsed.get('email_type')}, po={parsed.get('po_number')}"
)
if not parsed.get("po_number"):
logger.warning(f"No PO number found in email, skipping: {key}")

View file

@ -49,9 +49,7 @@ def extract_site_code(record: dict) -> str | None:
if m and SITE_CODE_PATTERN.match(m.group(1)):
return m.group(1)
m = re.search(
r"(?:Station|DS)\s*[-–]?\s*([A-Z0-9]{3,5})\b", ship_to_name
)
m = re.search(r"(?:Station|DS)\s*[-–]?\s*([A-Z0-9]{3,5})\b", ship_to_name)
if m and SITE_CODE_PATTERN.match(m.group(1)):
return m.group(1)
@ -85,7 +83,11 @@ def parse_address(record: dict) -> dict:
result["city"] = ship_to.get("city")
result["state"] = ship_to["state"]
result["zip"] = ship_to.get("zip")
parts = [p for p in [result["address"], result["city"], result["state"], result["zip"]] if p]
parts = [
p
for p in [result["address"], result["city"], result["state"], result["zip"]]
if p
]
result["fullAddress"] = ", ".join(parts)
return result
@ -104,11 +106,13 @@ def parse_address(record: dict) -> dict:
result["city"] = m.group(1).strip()
result["state"] = m.group(2)
result["zip"] = m.group(3)
result["fullAddress"] = f"{result['address']}, {result['city']}, {result['state']} {result['zip']}"
result["fullAddress"] = (
f"{result['address']}, {result['city']}, {result['state']} {result['zip']}"
)
return result
# Fallback: try newline-separated format
lines = [l.strip() for l in clean.split("\n") if l.strip()]
lines = [line.strip() for line in clean.split("\n") if line.strip()]
city_re = re.compile(r"^(.+?),\s+([A-Z]{2})\s+(\d{5}(?:-\d{4})?)")
for i, line in enumerate(lines):
m = city_re.match(line)
@ -118,7 +122,9 @@ def parse_address(record: dict) -> dict:
result["state"] = m.group(2)
result["zip"] = m.group(3)
street = result["address"] or result["city"]
result["fullAddress"] = f"{street}, {result['city']}, {result['state']} {result['zip']}"
result["fullAddress"] = (
f"{street}, {result['city']}, {result['state']} {result['zip']}"
)
return result
result["address"] = clean
@ -278,7 +284,10 @@ def handler(event, context):
ship_to_name = (po.get("ship_to") or {}).get("name")
write_pending_review(po_number, address, ship_to_name)
pending += 1
logger.info("PO %s added to pending review (no site code or address match)", po_number)
logger.info(
"PO %s added to pending review (no site code or address match)",
po_number,
)
logger.info(
"Batch complete: %d processed, %d extracted (%d via address), %d pending review",

View file

@ -5,7 +5,6 @@ Serves a simple HTML dashboard for viewing purchase orders.
Accessed via Lambda Function URL.
"""
import json
import os
from decimal import Decimal
@ -103,7 +102,12 @@ def render_po_detail(po):
view_url = po.get("view_order_url")
if view_url:
fields.append(("Coupa Link", f'<a href="{view_url}" target="_blank" style="color:#3b82f6;">View in Coupa</a>'))
fields.append(
(
"Coupa Link",
f'<a href="{view_url}" target="_blank" style="color:#3b82f6;">View in Coupa</a>',
)
)
details_html = ""
for label, value in fields:
@ -120,17 +124,17 @@ def render_po_detail(po):
if line_items:
rows = ""
for item in line_items:
qty = item.get('quantity', '') or ''
unit = item.get('unit', '') or ''
price = item.get('price', '') or ''
qty = item.get("quantity", "") or ""
unit = item.get("unit", "") or ""
price = item.get("price", "") or ""
rows += f"""
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:10px;font-size:14px;">{item.get('description', '')}</td>
<td style="padding:10px;font-size:14px;">{item.get("description", "")}</td>
<td style="padding:10px;font-size:13px;text-align:right;">{qty}</td>
<td style="padding:10px;font-size:13px;">{unit}</td>
<td style="padding:10px;font-size:13px;text-align:right;">{price}</td>
<td style="padding:10px;font-size:14px;text-align:right;">{fmt_currency(item.get('amount'))}</td>
<td style="padding:10px;font-size:13px;color:#64748b;">{item.get('need_by', '') or ''}</td>
<td style="padding:10px;font-size:14px;text-align:right;">{fmt_currency(item.get("amount"))}</td>
<td style="padding:10px;font-size:13px;color:#64748b;">{item.get("need_by", "") or ""}</td>
</tr>"""
items_html = f"""