mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 04:33:10 +00:00
ci(workflows): call org reusable CI and Fargate CD (#276)
* ci(workflows): call org reusable CI and Fargate CD Local CI and the image deploy duplicated the org workflows and still required ci / ci. Pin the callers to those workflows and trust the reusable deploy ref. * test(ci): probe ruff with an undefined name * test(ci): remove the undefined-name ruff probe * ci: retrigger checks after removing the ruff probe * test(ci): probe ruff with an unused import * style: apply formatter * test(ci): remove the autofix probe --------- Co-authored-by: sea-haven-auto-fix[bot] <332630863+sea-haven-auto-fix[bot]@users.noreply.github.com>
This commit is contained in:
parent
e600dd47a3
commit
8a23d06a64
18 changed files with 224 additions and 335 deletions
96
.github/workflows/ci.yaml
vendored
96
.github/workflows/ci.yaml
vendored
|
|
@ -2,15 +2,35 @@ name: CI
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, hotfix/**, release/**]
|
||||
merge_group:
|
||||
push:
|
||||
branches: [hotfix/**, release/**]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
name: Pytest
|
||||
autofix:
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
permissions:
|
||||
contents: write
|
||||
secrets: inherit
|
||||
with:
|
||||
presets: ruff,terraform
|
||||
terraform-version: "1.16.0"
|
||||
|
||||
lint:
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
test:
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
|
|
@ -37,33 +57,16 @@ jobs:
|
|||
run: pytest
|
||||
|
||||
terraform:
|
||||
name: Terraform
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: terraform
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
with:
|
||||
terraform_version: "1.16.0"
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Terraform fmt
|
||||
run: terraform fmt -check -recursive
|
||||
|
||||
- name: Terraform init
|
||||
run: terraform init -backend=false
|
||||
|
||||
- name: Terraform validate
|
||||
run: terraform validate
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
with:
|
||||
terraform-version: "1.16.0"
|
||||
|
||||
openapi:
|
||||
name: OpenAPI
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
|
@ -82,35 +85,22 @@ jobs:
|
|||
- name: Lint OpenAPI
|
||||
run: npm run openapi:lint
|
||||
|
||||
ci:
|
||||
name: ci / ci
|
||||
needs: [pytest, terraform, openapi]
|
||||
if: ${{ always() && !cancelled() }}
|
||||
ci-complete:
|
||||
name: ci-complete
|
||||
needs: [autofix, lint, test, terraform, openapi]
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check jobs
|
||||
- name: Require portions
|
||||
env:
|
||||
PYTEST_RESULT: ${{ needs.pytest.result }}
|
||||
TERRAFORM_RESULT: ${{ needs.terraform.result }}
|
||||
OPENAPI_RESULT: ${{ needs.openapi.result }}
|
||||
LINT: ${{ needs.lint.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
TERRAFORM: ${{ needs.terraform.result }}
|
||||
OPENAPI: ${{ needs.openapi.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail=0
|
||||
check() {
|
||||
local name="$1"
|
||||
local result="$2"
|
||||
case "${result}" in
|
||||
success)
|
||||
echo "${name}: ${result}"
|
||||
;;
|
||||
*)
|
||||
echo "${name}: ${result}" >&2
|
||||
fail=1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
check pytest "${PYTEST_RESULT}"
|
||||
check terraform "${TERRAFORM_RESULT}"
|
||||
check openapi "${OPENAPI_RESULT}"
|
||||
exit "${fail}"
|
||||
test "${LINT}" = success
|
||||
test "${TEST}" = success
|
||||
test "${TERRAFORM}" = success
|
||||
test "${OPENAPI}" = success
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -7,4 +7,4 @@ permissions:
|
|||
|
||||
jobs:
|
||||
review:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
|
|
|
|||
229
.github/workflows/deploy-api.yaml
vendored
229
.github/workflows/deploy-api.yaml
vendored
|
|
@ -1,8 +1,8 @@
|
|||
name: Deploy API
|
||||
|
||||
# Fargate image CD (PLAT-216). GitHub Actions builds the Flask image, pushes
|
||||
# to ECR, and registers a new task definition. Terraform owns the cluster,
|
||||
# service, ALB, and ignores container_definitions / task_definition.
|
||||
# Fargate image CD (PLAT-216). GitHub Actions builds the Flask image, pushes to ECR,
|
||||
# and registers a new task definition. Terraform owns the cluster, service,
|
||||
# ALB, and ignores container_definitions / task_definition.
|
||||
#
|
||||
# push to main -> dev, at github.sha
|
||||
# release: published -> prod, at the release tag
|
||||
|
|
@ -40,206 +40,31 @@ permissions:
|
|||
contents: read
|
||||
|
||||
jobs:
|
||||
target:
|
||||
name: Resolve target
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
environment: ${{ steps.resolve.outputs.environment }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- id: resolve
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_REF_NAME_IN: ${{ github.ref }}
|
||||
GITHUB_SHA_IN: ${{ github.sha }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_ENVIRONMENT: ${{ inputs.environment }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${EVENT_NAME}" in
|
||||
push)
|
||||
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
|
||||
echo "push deploys only run from main" >&2
|
||||
exit 1
|
||||
fi
|
||||
environment=dev
|
||||
ref="${GITHUB_SHA_IN}"
|
||||
;;
|
||||
release)
|
||||
environment=prod
|
||||
ref="${RELEASE_TAG}"
|
||||
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
|
||||
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
|
||||
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
workflow_dispatch)
|
||||
environment="${INPUT_ENVIRONMENT}"
|
||||
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
|
||||
;;
|
||||
*)
|
||||
echo "unsupported event ${EVENT_NAME}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
{
|
||||
echo "environment=${environment}"
|
||||
echo "ref=${ref}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
echo "Deploying ${ref} to ${environment}"
|
||||
|
||||
deploy:
|
||||
name: Deploy API to ${{ needs.target.outputs.environment }}
|
||||
needs: target
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment: ${{ needs.target.outputs.environment }}
|
||||
concurrency:
|
||||
group: deploy-api-${{ needs.target.outputs.environment }}
|
||||
cancel-in-progress: false
|
||||
deploy-dev:
|
||||
name: Deploy API to dev
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.target.outputs.ref }}
|
||||
persist-credentials: false
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: dev
|
||||
ref: ${{ inputs.ref }}
|
||||
ssm-prefix: /afterhours-shift-manager/deploy
|
||||
docker-platform: linux/arm64
|
||||
|
||||
- name: Resolve commit
|
||||
id: commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha="$(git rev-parse HEAD)"
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Building ${sha}"
|
||||
|
||||
- name: Configure AWS credentials using OIDC
|
||||
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
|
||||
with:
|
||||
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
||||
aws-region: us-east-1
|
||||
audience: sts.amazonaws.com
|
||||
|
||||
- name: Get deploy parameters
|
||||
id: deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
get_param() {
|
||||
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
|
||||
}
|
||||
CLUSTER=$(get_param /afterhours-shift-manager/deploy/cluster)
|
||||
SERVICE=$(get_param /afterhours-shift-manager/deploy/service)
|
||||
FAMILY=$(get_param /afterhours-shift-manager/deploy/task-family)
|
||||
ECR=$(get_param /afterhours-shift-manager/deploy/ecr-repository)
|
||||
CONTAINER=$(get_param /afterhours-shift-manager/deploy/container-name)
|
||||
API_URL=$(get_param /afterhours-shift-manager/deploy/api-url)
|
||||
{
|
||||
echo "cluster=${CLUSTER}"
|
||||
echo "service=${SERVICE}"
|
||||
echo "family=${FAMILY}"
|
||||
echo "ecr=${ECR}"
|
||||
echo "container=${CONTAINER}"
|
||||
echo "api_url=${API_URL}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
|
||||
|
||||
- name: Build and push image
|
||||
env:
|
||||
ECR: ${{ steps.deploy.outputs.ecr }}
|
||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||
ENVIRONMENT: ${{ needs.target.outputs.environment }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--build-arg "GIT_SHA=${GIT_SHA}" \
|
||||
-t "${ECR}:${GIT_SHA}" \
|
||||
-t "${ECR}:${ENVIRONMENT}" \
|
||||
--push \
|
||||
.
|
||||
|
||||
- name: Register task definition and update service
|
||||
env:
|
||||
CLUSTER: ${{ steps.deploy.outputs.cluster }}
|
||||
SERVICE: ${{ steps.deploy.outputs.service }}
|
||||
FAMILY: ${{ steps.deploy.outputs.family }}
|
||||
CONTAINER: ${{ steps.deploy.outputs.container }}
|
||||
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
|
||||
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
aws ecs describe-task-definition \
|
||||
--task-definition "${FAMILY}" \
|
||||
--query taskDefinition \
|
||||
--output json \
|
||||
| python3 -c '
|
||||
import json, os, sys
|
||||
td = json.load(sys.stdin)
|
||||
for key in (
|
||||
"taskDefinitionArn",
|
||||
"revision",
|
||||
"status",
|
||||
"requiresAttributes",
|
||||
"compatibilities",
|
||||
"registeredAt",
|
||||
"registeredBy",
|
||||
"deregisteredAt",
|
||||
):
|
||||
td.pop(key, None)
|
||||
image = os.environ["IMAGE"]
|
||||
sha = os.environ["GIT_SHA"]
|
||||
name = os.environ["CONTAINER"]
|
||||
for container in td["containerDefinitions"]:
|
||||
if container["name"] != name:
|
||||
continue
|
||||
container["image"] = image
|
||||
env = {item["name"]: item["value"] for item in container.get("environment", [])}
|
||||
env["GIT_SHA"] = sha
|
||||
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
|
||||
container.pop("command", None)
|
||||
json.dump(td, sys.stdout)
|
||||
' > /tmp/task-def.json
|
||||
REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
|
||||
aws ecs update-service \
|
||||
--cluster "${CLUSTER}" \
|
||||
--service "${SERVICE}" \
|
||||
--task-definition "${FAMILY}:${REV}" \
|
||||
--force-new-deployment \
|
||||
>/dev/null
|
||||
aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
|
||||
|
||||
- name: Verify health SHA
|
||||
env:
|
||||
API_URL: ${{ steps.deploy.outputs.api_url }}
|
||||
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
BODY="$(curl -fsS "${API_URL}/api/health" || true)"
|
||||
echo "${BODY}"
|
||||
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
echo "health SHA did not match ${EXPECTED_SHA}" >&2
|
||||
exit 1
|
||||
deploy-prod:
|
||||
name: Deploy API to prod
|
||||
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
secrets: inherit
|
||||
with:
|
||||
environment: prod
|
||||
ref: ${{ github.event.release.tag_name || inputs.ref }}
|
||||
ssm-prefix: /afterhours-shift-manager/deploy
|
||||
docker-platform: linux/arm64
|
||||
ship-gate: true
|
||||
|
|
|
|||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
|
|
@ -10,4 +10,4 @@ permissions:
|
|||
|
||||
jobs:
|
||||
label:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
|
||||
|
|
|
|||
|
|
@ -64,8 +64,16 @@ def main() -> int:
|
|||
|
||||
src = _client(args.src_profile, args.region)
|
||||
dst = _client(args.dst_profile, args.region)
|
||||
src_id = boto3.Session(profile_name=args.src_profile).client("sts").get_caller_identity()["Account"]
|
||||
dst_id = boto3.Session(profile_name=args.dst_profile).client("sts").get_caller_identity()["Account"]
|
||||
src_id = (
|
||||
boto3.Session(profile_name=args.src_profile)
|
||||
.client("sts")
|
||||
.get_caller_identity()["Account"]
|
||||
)
|
||||
dst_id = (
|
||||
boto3.Session(profile_name=args.dst_profile)
|
||||
.client("sts")
|
||||
.get_caller_identity()["Account"]
|
||||
)
|
||||
if src_id != SRC_ACCOUNT:
|
||||
print(f"src account {src_id} is not mgmt {SRC_ACCOUNT}", file=sys.stderr)
|
||||
return 2
|
||||
|
|
|
|||
|
|
@ -35,11 +35,17 @@ _NO_VALUE_CODES = frozenset({"ResourceNotFoundException", "InvalidRequestExcepti
|
|||
|
||||
|
||||
def _client(profile: str, region: str):
|
||||
return boto3.Session(profile_name=profile, region_name=region).client("secretsmanager")
|
||||
return boto3.Session(profile_name=profile, region_name=region).client(
|
||||
"secretsmanager"
|
||||
)
|
||||
|
||||
|
||||
def _account(profile: str) -> str:
|
||||
return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"]
|
||||
return (
|
||||
boto3.Session(profile_name=profile)
|
||||
.client("sts")
|
||||
.get_caller_identity()["Account"]
|
||||
)
|
||||
|
||||
|
||||
def secret_string(client, name: str) -> str | None:
|
||||
|
|
@ -72,10 +78,15 @@ def copy_secrets(src, dst, *, execute: bool) -> int:
|
|||
for name in VERIFY_ONLY:
|
||||
value = secret_string(dst, name)
|
||||
if value is None:
|
||||
print(f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr)
|
||||
print(
|
||||
f"missing prod secret {name} (expected from PLAT-76)", file=sys.stderr
|
||||
)
|
||||
rc = 1
|
||||
elif not value.strip():
|
||||
print(f"empty prod 3cx secret {name} (do not overwrite from mgmt)", file=sys.stderr)
|
||||
print(
|
||||
f"empty prod 3cx secret {name} (do not overwrite from mgmt)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
rc = 1
|
||||
else:
|
||||
print(f"keep existing prod secret {name}")
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ from botocore.exceptions import ClientError
|
|||
|
||||
SRC_ACCOUNT = "328440206208"
|
||||
DST_ACCOUNT = "011934824531"
|
||||
PROD_ROUTER_ARN = "arn:aws:lambda:us-east-1:011934824531:function:afterhours-holiday-router"
|
||||
PROD_ROUTER_ARN = (
|
||||
"arn:aws:lambda:us-east-1:011934824531:function:afterhours-holiday-router"
|
||||
)
|
||||
PROD_ROLE_ARN = "arn:aws:iam::011934824531:role/tf-managed/afterhours-shift-manager-holiday-scheduler"
|
||||
PREFIXES = ("holiday-activate-", "holiday-deactivate-")
|
||||
_AT = re.compile(r"^at\((\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\)$")
|
||||
|
|
@ -30,7 +32,11 @@ def _client(profile: str, region: str):
|
|||
|
||||
|
||||
def _account(profile: str) -> str:
|
||||
return boto3.Session(profile_name=profile).client("sts").get_caller_identity()["Account"]
|
||||
return (
|
||||
boto3.Session(profile_name=profile)
|
||||
.client("sts")
|
||||
.get_caller_identity()["Account"]
|
||||
)
|
||||
|
||||
|
||||
def schedule_when(detail: dict) -> datetime | None:
|
||||
|
|
@ -123,13 +129,17 @@ def main() -> int:
|
|||
if code == "ConflictException":
|
||||
print(f"exists {name}")
|
||||
elif code == "ValidationException":
|
||||
print(f"skip invalid {name}: {exc.response['Error'].get('Message', code)}")
|
||||
print(
|
||||
f"skip invalid {name}: {exc.response['Error'].get('Message', code)}"
|
||||
)
|
||||
skipped += 1
|
||||
else:
|
||||
print(f"failed {name}: {code}", file=sys.stderr)
|
||||
failed += 1
|
||||
|
||||
print(f"created={created} skipped_past={skipped} failed={failed} execute={args.execute}")
|
||||
print(
|
||||
f"created={created} skipped_past={skipped} failed={failed} execute={args.execute}"
|
||||
)
|
||||
if not args.execute:
|
||||
print("dry-run; pass --execute to CreateSchedule")
|
||||
return 1 if failed else 0
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ def _copy_tree(src: Path, dest: Path) -> None:
|
|||
if item.is_dir():
|
||||
if item.name == "__pycache__":
|
||||
continue
|
||||
shutil.copytree(item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
shutil.copytree(
|
||||
item, target, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
|
||||
)
|
||||
else:
|
||||
shutil.copy2(item, target)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,11 @@ from __future__ import annotations
|
|||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule
|
||||
from shared.shift_clock import (
|
||||
EASTERN,
|
||||
holiday_window_active,
|
||||
is_today,
|
||||
shift_end,
|
||||
shift_ended,
|
||||
shift_start,
|
||||
|
|
@ -70,7 +68,9 @@ def _json_safe(value):
|
|||
return value
|
||||
|
||||
|
||||
def _slot_payload(schedule: ShiftSchedule, date_str: str, day_name: str, shift_type: str, my_ext: str) -> dict:
|
||||
def _slot_payload(
|
||||
schedule: ShiftSchedule, date_str: str, day_name: str, shift_type: str, my_ext: str
|
||||
) -> dict:
|
||||
ctx = schedule.get_shift_context(date_str, day_name, shift_type)
|
||||
assignees = [
|
||||
{"extension": a["extension"], "name": a["name"]} for a in ctx["assignees"]
|
||||
|
|
@ -86,8 +86,13 @@ def _slot_payload(schedule: ShiftSchedule, date_str: str, day_name: str, shift_t
|
|||
"multiplier": _json_safe(ctx.get("multiplier") or 1),
|
||||
"assignees": assignees,
|
||||
"mine": mine,
|
||||
"canPick": (not mine) and open_slots > 0 and not shift_ended(date_str, shift_type),
|
||||
"canDrop": mine and not within_drop_lock(date_str, "day" if ctx["kind"] == "holiday" else shift_type),
|
||||
"canPick": (not mine)
|
||||
and open_slots > 0
|
||||
and not shift_ended(date_str, shift_type),
|
||||
"canDrop": mine
|
||||
and not within_drop_lock(
|
||||
date_str, "day" if ctx["kind"] == "holiday" else shift_type
|
||||
),
|
||||
"latePickup": (not mine)
|
||||
and open_slots > 0
|
||||
and shift_started(date_str, shift_type)
|
||||
|
|
@ -192,7 +197,9 @@ def _pickup_sk_parts(sk: str) -> tuple[str, str, str]:
|
|||
return date_str, shift_type, ext
|
||||
|
||||
|
||||
def pick(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None) -> dict:
|
||||
def pick(
|
||||
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None
|
||||
) -> dict:
|
||||
date = _parse_date(date_str)
|
||||
date_str = date.strftime(DATE_FMT)
|
||||
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
||||
|
|
@ -203,7 +210,9 @@ def pick(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str
|
|||
token = effects.slack_token()
|
||||
if ctx["kind"] == "holiday":
|
||||
return _pick_holiday(schedule, employee, date, date_str, ctx, token)
|
||||
return _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx, token)
|
||||
return _pick_regular(
|
||||
schedule, employee, date, date_str, day_name, shift_type, ctx, token
|
||||
)
|
||||
|
||||
|
||||
def _resolve_pick_type(schedule, date_str, day_name, explicit) -> str:
|
||||
|
|
@ -219,7 +228,9 @@ def _resolve_pick_type(schedule, date_str, day_name, explicit) -> str:
|
|||
return "night"
|
||||
|
||||
|
||||
def _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx, token) -> dict:
|
||||
def _pick_regular(
|
||||
schedule, employee, date, date_str, day_name, shift_type, ctx, token
|
||||
) -> dict:
|
||||
assignees = ctx["assignees"]
|
||||
if assignees and assignees[0]["extension"] != employee["extension"]:
|
||||
raise ActionError(
|
||||
|
|
@ -230,14 +241,20 @@ def _pick_regular(schedule, employee, date, date_str, day_name, shift_type, ctx,
|
|||
if shift_ended(date_str, shift_type):
|
||||
raise ActionError(400, "ENDED", "That shift has already ended.")
|
||||
if shift_started(date_str, shift_type):
|
||||
return _request_late_pickup(schedule, employee, date_str, shift_type, False, token)
|
||||
already_mine = bool(assignees) and assignees[0]["extension"] == employee["extension"]
|
||||
return _request_late_pickup(
|
||||
schedule, employee, date_str, shift_type, False, token
|
||||
)
|
||||
already_mine = (
|
||||
bool(assignees) and assignees[0]["extension"] == employee["extension"]
|
||||
)
|
||||
if not already_mine:
|
||||
claimed = schedule.claim_open_shift(
|
||||
date_str, employee["extension"], employee["name"], shift_type
|
||||
)
|
||||
if not claimed:
|
||||
raise ActionError(409, "TAKEN", "That shift was just picked up by someone else.")
|
||||
raise ActionError(
|
||||
409, "TAKEN", "That shift was just picked up by someone else."
|
||||
)
|
||||
effects.maybe_repoint_today(date_str, shift_type, employee["extension"])
|
||||
effects.post_shift_change(
|
||||
token,
|
||||
|
|
@ -278,7 +295,9 @@ def _pick_holiday(schedule, employee, date, date_str, ctx, token) -> dict:
|
|||
return {"ok": True, "latePickup": False, "message": "Holiday slot claimed."}
|
||||
|
||||
|
||||
def _request_late_pickup(schedule, employee, date_str, shift_type, is_holiday, token) -> dict:
|
||||
def _request_late_pickup(
|
||||
schedule, employee, date_str, shift_type, is_holiday, token
|
||||
) -> dict:
|
||||
schedule.create_pickup_request(
|
||||
date_str,
|
||||
shift_type,
|
||||
|
|
@ -303,7 +322,9 @@ def _request_late_pickup(schedule, employee, date_str, shift_type, is_holiday, t
|
|||
}
|
||||
|
||||
|
||||
def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None) -> dict:
|
||||
def drop(
|
||||
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str | None
|
||||
) -> dict:
|
||||
date = _parse_date(date_str)
|
||||
date_str = date.strftime(DATE_FMT)
|
||||
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
||||
|
|
@ -317,7 +338,9 @@ def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str
|
|||
elif target in ("day", "night"):
|
||||
target = target
|
||||
else:
|
||||
raise ActionError(400, "INVALID_SHIFT", "Shift type must be day, night, or holiday.")
|
||||
raise ActionError(
|
||||
400, "INVALID_SHIFT", "Shift type must be day, night, or holiday."
|
||||
)
|
||||
if target == "day" and "holiday" in held:
|
||||
target = "holiday"
|
||||
if target not in held:
|
||||
|
|
@ -450,7 +473,11 @@ def swap(
|
|||
|
||||
|
||||
def respond_swap(
|
||||
schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str, accept: bool
|
||||
schedule: ShiftSchedule,
|
||||
employee: dict,
|
||||
date_str: str,
|
||||
shift_type: str,
|
||||
accept: bool,
|
||||
) -> dict:
|
||||
date = _parse_date(date_str)
|
||||
date_str = date.strftime(DATE_FMT)
|
||||
|
|
@ -474,11 +501,16 @@ def respond_swap(
|
|||
return {"ok": True, "message": "Swap declined."}
|
||||
if shift_started(date_str, shift_type):
|
||||
schedule.clear_swap(date_str, shift_type)
|
||||
raise ActionError(409, "EXPIRED", "This swap expired because the shift has started.")
|
||||
raise ActionError(
|
||||
409, "EXPIRED", "This swap expired because the shift has started."
|
||||
)
|
||||
is_holiday = shift_type == "day" and schedule.get_holiday(date_str) is not None
|
||||
if is_holiday:
|
||||
moved = schedule.swap_holiday_assignee(
|
||||
date_str, swap_row["requester_ext"], swap_row["target_ext"], employee["name"]
|
||||
date_str,
|
||||
swap_row["requester_ext"],
|
||||
swap_row["target_ext"],
|
||||
employee["name"],
|
||||
)
|
||||
if not moved:
|
||||
schedule.clear_swap(date_str, shift_type)
|
||||
|
|
@ -583,11 +615,15 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
|
|||
date = _parse_date(date_str)
|
||||
date_str = date.strftime(DATE_FMT)
|
||||
if date_str < datetime.now(EASTERN).strftime(DATE_FMT):
|
||||
raise ActionError(400, "PAST_SHIFT", "You can't schedule a holiday in the past.")
|
||||
raise ActionError(
|
||||
400, "PAST_SHIFT", "You can't schedule a holiday in the past."
|
||||
)
|
||||
try:
|
||||
slot_count = int(slots)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ActionError(400, "INVALID_SLOTS", "Slots must be a whole number.") from exc
|
||||
raise ActionError(
|
||||
400, "INVALID_SLOTS", "Slots must be a whole number."
|
||||
) from exc
|
||||
if slot_count < 1:
|
||||
raise ActionError(400, "INVALID_SLOTS", "Slots must be at least 1.")
|
||||
name = (label or "").strip()
|
||||
|
|
@ -620,7 +656,11 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
|
|||
effects.activate_holiday_inline(schedule, date_str)
|
||||
holiday = schedule.get_holiday(date_str)
|
||||
effects.post_holiday_added(
|
||||
token, date_str, name, slot_count, holiday["multiplier"] if holiday else multiplier
|
||||
token,
|
||||
date_str,
|
||||
name,
|
||||
slot_count,
|
||||
holiday["multiplier"] if holiday else multiplier,
|
||||
)
|
||||
effects.refresh_schedule_post(schedule, token)
|
||||
return {"ok": True, "message": f"Scheduled {name}."}
|
||||
|
|
@ -639,7 +679,9 @@ def admin_holiday_remove(schedule, employee, date_str) -> dict:
|
|||
return {"ok": True, "message": "Holiday removed."}
|
||||
|
||||
|
||||
def admin_pickup(schedule, employee, date_str, shift_type, extension, approve: bool) -> dict:
|
||||
def admin_pickup(
|
||||
schedule, employee, date_str, shift_type, extension, approve: bool
|
||||
) -> dict:
|
||||
require_admin(schedule, employee)
|
||||
date = _parse_date(date_str)
|
||||
date_str = date.strftime(DATE_FMT)
|
||||
|
|
|
|||
|
|
@ -293,7 +293,9 @@ class ShiftSchedule:
|
|||
|
||||
def list_pending_swaps(self) -> list[dict]:
|
||||
resp = self.table.query(KeyConditionExpression=Key("PK").eq("SWAP"))
|
||||
return [item for item in resp.get("Items", []) if item.get("status") == "pending"]
|
||||
return [
|
||||
item for item in resp.get("Items", []) if item.get("status") == "pending"
|
||||
]
|
||||
|
||||
# ── Late-pickup requests ─────────────────────────────────────────────
|
||||
|
||||
|
|
@ -381,7 +383,9 @@ class ShiftSchedule:
|
|||
|
||||
def list_pending_pickup_requests(self) -> list[dict]:
|
||||
resp = self.table.query(KeyConditionExpression=Key("PK").eq("PICKUP_REQUEST"))
|
||||
return [item for item in resp.get("Items", []) if item.get("status") == "pending"]
|
||||
return [
|
||||
item for item in resp.get("Items", []) if item.get("status") == "pending"
|
||||
]
|
||||
|
||||
# ── Holidays ────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# GitHub Actions OIDC role for .github/workflows/deploy-api.yaml.
|
||||
# GitHub Actions OIDC role for the thin deploy-api.yaml caller of
|
||||
# org reusable cd-hcp-fargate.yaml.
|
||||
#
|
||||
# One role: GitHub Environments have a single DEPLOY_ROLE_ARN. Trust is pinned
|
||||
# to Environments dev and prod (immutable and classic subject forms) and
|
||||
# job_workflow_ref to deploy-api.yaml at main and v*.
|
||||
# to Environments dev and prod. job_workflow_ref matches the reusable at any ref.
|
||||
# AWS STS GitHub condition keys include job_workflow_ref, not workflow_ref.
|
||||
|
||||
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||
statement {
|
||||
|
|
@ -31,8 +32,7 @@ data "aws_iam_policy_document" "github_deploy_assume" {
|
|||
test = "StringLike"
|
||||
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
||||
values = [
|
||||
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/heads/${var.github_deploy_branch}",
|
||||
"${var.github_repo}/.github/workflows/deploy-api.yaml@refs/tags/v*",
|
||||
"Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,12 +76,6 @@ variable "github_repo" {
|
|||
default = "Sea-Haven-Industries/afterhours-shift-manager"
|
||||
}
|
||||
|
||||
variable "github_deploy_branch" {
|
||||
description = "Git branch pinned in job_workflow_ref for the deploy role."
|
||||
type = string
|
||||
default = "main"
|
||||
}
|
||||
|
||||
variable "checkcomponents_queue_url" {
|
||||
description = "paychex-checkcomponents SQS URL. Empty skips the weekly SendMessage."
|
||||
type = string
|
||||
|
|
|
|||
|
|
@ -77,13 +77,13 @@ def test_stack_owns_a_vpc_instead_of_looking_up_default():
|
|||
assert "aws_vpc.this.id" in ecs
|
||||
assert "aws_subnet.public[*].id" in ecs
|
||||
assert "ec2:CreateVpc" in HCP_IAM
|
||||
assert "sid = \"RefreshVpc\"" in HCP_IAM
|
||||
assert 'sid = "RefreshVpc"' in HCP_IAM
|
||||
assert "afterhours-shift-manager-ecs" in HCP_IAM
|
||||
assert "hcptf_apply_ecs" in HCP_IAM
|
||||
assert 'resource "aws_iam_policy" "hcptf_apply_ecs"' in HCP_IAM
|
||||
assert 'resource "aws_iam_role_policy" "hcptf_apply_ecs"' not in HCP_IAM
|
||||
assert "aws_iam_policy.hcptf_apply_ecs.arn" in HCP_IAM
|
||||
assert "sid = \"CreateElbAndEcsServiceLinkedRoles\"" in HCP_IAM
|
||||
assert 'sid = "CreateElbAndEcsServiceLinkedRoles"' in HCP_IAM
|
||||
assert "iam:CreateServiceLinkedRole" in HCP_IAM
|
||||
|
||||
|
||||
|
|
@ -101,10 +101,13 @@ def test_ecs_task_boundary_uses_static_arns():
|
|||
|
||||
def test_deploy_api_workflow_exists():
|
||||
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
|
||||
assert "environment: ${{ needs.target.outputs.environment }}" in deploy_api
|
||||
assert "/afterhours-shift-manager/deploy/cluster" in deploy_api
|
||||
assert "linux/arm64" in deploy_api
|
||||
pin = "cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16"
|
||||
assert deploy_api.count(pin) == 2
|
||||
assert "ssm-prefix: /afterhours-shift-manager/deploy" in deploy_api
|
||||
assert "docker-platform: linux/arm64" in deploy_api
|
||||
assert "ship-gate: true" in deploy_api
|
||||
assert "gh release create" in deploy_api
|
||||
assert "needs.target.outputs.environment" not in deploy_api
|
||||
|
||||
|
||||
def test_in_repo_hcptf_roles():
|
||||
|
|
@ -116,10 +119,12 @@ def test_in_repo_hcptf_roles():
|
|||
|
||||
def test_ci_runs_pytest_and_terraform_validate():
|
||||
assert "ci-python-sam" not in CI
|
||||
assert "ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
|
||||
assert "ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
|
||||
assert "ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
|
||||
assert "name: ci-complete" in CI
|
||||
assert "pytest" in CI
|
||||
assert "terraform fmt -check" in CI
|
||||
assert "terraform init -backend=false" in CI
|
||||
assert "terraform validate" in CI
|
||||
assert "terraform fmt -check" not in CI
|
||||
assert "openapi:lint" in CI
|
||||
assert "npm ci" in CI
|
||||
|
||||
|
|
@ -138,7 +143,7 @@ def test_openapi_uses_redocly_recommended():
|
|||
health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0]
|
||||
assert '"403":' in health
|
||||
assert '"400":' not in health
|
||||
assert 'root: openapi.yaml' in redocly
|
||||
assert "root: openapi.yaml" in redocly
|
||||
assert '"openapi:lint"' in package
|
||||
assert '"@redocly/cli": "2.52.1"' in package
|
||||
assert "openapi: 3.1.0" in spec
|
||||
|
|
@ -191,8 +196,8 @@ def test_github_deploy_trust_covers_image_only():
|
|||
assert "environment:prod" in iam or "environment:prod" in LOCALS
|
||||
assert "environment:dev" in iam or "environment:dev" in LOCALS
|
||||
assert "deploy.yaml@" not in iam
|
||||
assert "deploy-api.yaml@refs/heads/${var.github_deploy_branch}" in iam
|
||||
assert "deploy-api.yaml@refs/tags/v*" in iam
|
||||
assert "deploy-api.yaml@" not in iam
|
||||
assert "Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@*" in iam
|
||||
assert "ecs:ListTasks" in iam
|
||||
|
||||
|
||||
|
|
@ -205,10 +210,10 @@ def test_plan_refresh_includes_provider6_s3_gets():
|
|||
def test_origins_use_fargate_url():
|
||||
outputs = (TERRAFORM / "outputs.tf").read_text()
|
||||
assert "aws_apigatewayv2_api.http" not in outputs
|
||||
assert '${local.api_url}/slack/events' in outputs
|
||||
assert "${local.api_url}/slack/events" in outputs
|
||||
assert "value = local.api_url" in outputs
|
||||
assert "output \"vpc_id\"" in outputs
|
||||
assert "output \"public_subnet_ids\"" in outputs
|
||||
assert 'output "vpc_id"' in outputs
|
||||
assert 'output "public_subnet_ids"' in outputs
|
||||
assert "aws_vpc.this.id" in outputs
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -60,9 +60,7 @@ def test_adds_removes_and_preserves_links(
|
|||
def test_renames_changed_member_preserving_link(
|
||||
rostersync_app, schedule, seed, env, fake_3cx
|
||||
):
|
||||
seed.roster(
|
||||
"114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com"
|
||||
)
|
||||
seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
|
||||
fake_3cx([{"Number": "114", "MemberName": "Alicia", "Type": "Extension"}])
|
||||
|
||||
result = rostersync_app.handler({"force": True}, None)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ def test_execute_puts_into_empty_terraform_shells():
|
|||
rc = mod.copy_secrets(src, dst, execute=True)
|
||||
assert rc == 0
|
||||
assert [name for name, _ in dst.puts] == list(mod.COPY)
|
||||
assert all(value.endswith("-value") and not value.endswith("\n") for _, value in dst.puts)
|
||||
assert all(
|
||||
value.endswith("-value") and not value.endswith("\n") for _, value in dst.puts
|
||||
)
|
||||
|
||||
|
||||
def test_skip_populated_copy_targets_and_never_write_3cx():
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ def test_schedule_when_parses_at_expression_in_eastern():
|
|||
"ScheduleExpressionTimezone": "America/New_York",
|
||||
}
|
||||
when = mod.schedule_when(detail)
|
||||
expected = datetime(2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York")).astimezone(
|
||||
timezone.utc
|
||||
)
|
||||
expected = datetime(
|
||||
2026, 7, 4, 8, 0, 0, tzinfo=ZoneInfo("America/New_York")
|
||||
).astimezone(timezone.utc)
|
||||
assert when == expected
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,7 @@ def quiet_slack(monkeypatch):
|
|||
|
||||
|
||||
def _alice(schedule, seed):
|
||||
seed.roster(
|
||||
"114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com"
|
||||
)
|
||||
seed.roster("114", "Alice", slack_user_id="U_ALICE", email="alice@seahavenind.com")
|
||||
seed.roster("115", "Bob", slack_user_id="U_BOB", email="bob@seahavenind.com")
|
||||
return schedule.get_employee_by_extension("114")
|
||||
|
||||
|
|
@ -143,10 +141,9 @@ def test_admin_open_repoints_active_shift_to_fallback(
|
|||
monkeypatch.setattr(
|
||||
effects,
|
||||
"maybe_repoint_today",
|
||||
lambda date, shift_type, extension: calls.append(
|
||||
(date, shift_type, extension)
|
||||
)
|
||||
or True,
|
||||
lambda date, shift_type, extension: (
|
||||
calls.append((date, shift_type, extension)) or True
|
||||
),
|
||||
)
|
||||
with freezegun.freeze_time("2026-06-10 20:00:00-04:00"):
|
||||
result = admin_open(schedule, employee, "2026-06-10", "night")
|
||||
|
|
@ -164,10 +161,9 @@ def test_admin_clear_repoints_active_shift_to_resolved_holder(
|
|||
monkeypatch.setattr(
|
||||
effects,
|
||||
"maybe_repoint_today",
|
||||
lambda date, shift_type, extension: calls.append(
|
||||
(date, shift_type, extension)
|
||||
)
|
||||
or True,
|
||||
lambda date, shift_type, extension: (
|
||||
calls.append((date, shift_type, extension)) or True
|
||||
),
|
||||
)
|
||||
with freezegun.freeze_time("2026-06-10 20:00:00-04:00"):
|
||||
result = admin_clear(schedule, employee, "2026-06-10", "night")
|
||||
|
|
|
|||
|
|
@ -226,7 +226,9 @@ class TestRoster:
|
|||
assert emp["email"] == "Alice@Seahavenind.com"
|
||||
|
||||
def test_upsert_without_email_preserves_existing_email(self, schedule, seed):
|
||||
seed.roster("114", "Alice", slack_user_id="U_OLD", email="alice@seahavenind.com")
|
||||
seed.roster(
|
||||
"114", "Alice", slack_user_id="U_OLD", email="alice@seahavenind.com"
|
||||
)
|
||||
schedule.upsert_roster_entry("114", "Alicia", "U_ALICE")
|
||||
emp = schedule.get_employee_by_extension("114")
|
||||
assert emp["email"] == "alice@seahavenind.com"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue