Build Reliable Ticket Triage Automation

Build a Python ticket pipeline with validation, SQLite storage, tests, and CI.

Introduction

30 Second Summary

Support teams depend on every ticket reaching the right person. One badly formatted record can leave the rest of a busy queue untouched.

In this project, you will build a reliable support-ticket pipeline in Python. It turns a JSON batch into queryable SQLite results without losing valid work when one record is malformed.

What You'll Build

You'll watch your finished automation complete a mixed-quality ticket batch without losing the valid work.

By the end of this project, you'll have:

  • Resilient batch processing that classifies three valid tickets despite one malformed record. Clear totals show the processed work plus the skipped work.
  • Traceable results that name the skipped ticket in triage.log. A queryable SQLite report shows stable category counts after repeated runs.
  • An automated quality gate that runs three tests locally. The same suite passes in GitHub Actions on your pull request.
  • Secret Mission: Introduce a classification regression. Watch the pull-request check catch it. Restore the shared branch with a revert commit.

Are there any prerequisites?

None. This guide covers every required tool plus account setup.

All you need is a Windows computer with basic Python familiarity.

Before We Start

Before the hands-on work begins, lock in what this ticket pipeline must do and why reliable batch processing matters.

Set Up Your Windows Coding Workspace

The ticket pipeline needs a stable workspace before it can reveal useful code problems. A reproducible setup keeps tool issues from masquerading as automation bugs.

In this step, you will set up Visual Studio Code with Python and Git. You will connect your GitHub tools before publishing the starter workspace.

In this step, get ready to:
  • Install Visual Studio Code, Python, and Git for Windows.
  • Create your GitHub account identity and connect GitHub Copilot Free.
  • Create a virtual environment before publishing the starter repository.
Install and verify the core tools

Your editor runs the project while Python executes it. Git records each reliable change before GitHub shares it.

This project uses Visual Studio Code 1.145.2, Python 3.15.2, and Git for Windows 2.56.0(4).

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Use the product information in Visual Studio Code to read the installed version if the app opens.

The tabs below cover each installation state you may find.

✔️ I see the current version

Visual Studio Code 1.145.2 is ready. Keep the app open for the remaining setup.

ⓧ I see an older version

  • Open the official Visual Studio Code Windows setup guide.
  • Download the User setup for Windows.
  • Complete the installer with its default setup choices.
  • Restart Visual Studio Code through Windows search.
  • Confirm the product information shows version 1.145.2.

ⓧ Visual Studio Code is not installed

  • Open the official Visual Studio Code Windows setup guide.
  • Download the User setup for Windows.
  • Complete the installer with its default setup choices.
  • Press the Windows key after installation.
  • Type Visual Studio Code to open the app.
  • Confirm the product information shows version 1.145.2.

The Python Install Manager manages Python runtimes on Windows. A version check reveals whether the required runtime is already available.

  • Use Visual Studio Code's top menu to open its integrated terminal panel.
  • Check the installed Python 3 version by running this command:
py -3 --version

What does this command check?

The Python launcher checks the installed Python 3 runtime. A successful check prints its version information.

Use the tab that matches the result in your terminal.

✔️ I see the required Python version

Python 3.15.2 is installed. Your workspace can use the project runtime.

ⓧ I see an older Python version

  • Open the official Python downloads page.
  • Install Python 3.15.2 through the Python Install Manager.
  • Return to the Visual Studio Code terminal.
  • Verify the updated Python runtime by running this command:
py -3 --version

What should I see?

The version information should now identify Python 3.15.2.

ⓧ The Python command does not run

  • Open the official Python on Windows guide.
  • Install the Python Install Manager from python.org.
  • Install the current stable runtime by running this command in the Visual Studio Code terminal:
python

What does this command do?

The first launch can install the current Python runtime when no runtime exists. Follow any installation prompt shown by the manager.

  • Verify Python after the installation finishes by running this command:
py -3 --version

What should I see?

The terminal should print version information for Python 3.15.2.

Git provides the history that later commits and pull requests depend on. Its version check confirms that the command is available inside Visual Studio Code.

  • Check the installed Git version by running this command in the Visual Studio Code terminal:
git --version

What does this command check?

The command asks Git to print its installed version. This confirms that Visual Studio Code can reach Git from the terminal.

Use the tab that matches the Git result.

✔️ I see the required Git version

Git for Windows 2.56.0(4) is ready to record your project history.

ⓧ I see an older Git version

  • Open the official Git for Windows installation page.
  • Download Git for Windows 2.56.0(4).
  • Complete the installer with its default setup choices.
  • Restart Visual Studio Code after installation.
  • Verify the updated Git version by running this command:
git --version

What should I see?

The terminal should print version information for Git for Windows 2.56.0(4).

ⓧ The Git command does not run

  • Open the official Git for Windows installation page.
  • Download Git for Windows 2.56.0(4).
  • Complete the installer with its default setup choices.
  • Restart Visual Studio Code so it can detect Git.
  • Verify the installation by running this command:
git --version

What should I see?

The terminal should print version information for Git for Windows 2.56.0(4).

Still missing a tool?

Restart Visual Studio Code after completing an installer. A running app may not detect newly installed commands until it restarts.

If a version check still fails, help me troubleshoot my Windows tool setup.

Connect your GitHub identity

Your GitHub account publishes the repository that later holds the feature branch and pull request. GitHub Copilot acts as an explainer while you remain responsible for reviewing its suggestions.

  • Open your web browser.
  • Go to the official GitHub signup page.
  • Create a personal GitHub account.
  • Complete the email verification sent by GitHub.
  • Return to Visual Studio Code.
  • Select Sign up for Copilot Free.
  • Sign in with the verified GitHub account.
  • Open Copilot Chat in Visual Studio Code.

You should see Copilot Chat open inside Visual Studio Code. The editor is now connected to your GitHub account.

Every Git commit records an author name and email address. Setting that identity globally gives future repositories the same attribution.

  • Replace <your-name> with the name you want recorded on commits.
  • Replace <your-email> with the verified email address from your GitHub account.
  • Configure your global Git author identity by running these commands:
git config --global user.name "<your-name>"
git config --global user.email "<your-email>"

What do these commands configure?

The first command sets the author name stored with future commits. The second command sets the matching author email.

The terminal returns to its prompt when Git accepts both settings. Your next commit can now carry the correct identity.

Having trouble signing in?

Complete GitHub's email verification before starting the Copilot Free sign-in flow. Return to Visual Studio Code after the browser confirms access.

If the sign-in flow stalls, help me connect GitHub Copilot Free in Visual Studio Code.

Create and publish the starter workspace

The project folder keeps source files beside an isolated virtual environment. The first commit creates a clean baseline before the pipeline code arrives.

  • Return to Visual Studio Code.
  • Use the folder-opening option in the top menu.
  • Navigate to your Desktop in the Windows folder picker.
  • Create a folder named ticket-triage-automation.
  • Select the ticket-triage-automation folder as the open workspace.

You should see ticket-triage-automation as the workspace name in Visual Studio Code.

  • Select the extension marketplace from the Visual Studio Code sidebar.
  • Search for the VS Code Python extension.
  • Install the Python extension.

The Python extension adds interpreter selection and environment management to the editor.

  • Press Ctrl+Shift+P to open the Command Palette.
  • Run Python: Create Environment.
  • Select Venv as the environment type.
  • Select Python 3.15.2 as the interpreter if prompted.

You should see a .venv folder inside ticket-triage-automation. That folder isolates the project's Python environment.

Environment not appearing?

Confirm that Visual Studio Code has the Python extension installed. Confirm that ticket-triage-automation is the open workspace.

If the environment command fails, help me create a .venv environment in Visual Studio Code.

The starter README explains what the finished automation does. It also records the commands that future users run.

  • Create README.md inside the ticket-triage-automation workspace using the Visual Studio Code file tree.
  • Paste this content into README.md.
# Ticket Triage Automation

A Python pipeline that validates support tickets, stores clean results in SQLite, logs rejected records, and reports category counts.

## Run

Run `python triage.py` from the project folder.

## Test

Run `python -m unittest` from the project folder.

Generated runtime files `triage.db` and `triage.log` are ignored by Git.

What does this file document?

  • The opening describes the pipeline's validation and persistence behavior.
  • The run section records how to start the automation.
  • The test section records how to run the automated checks.
  • Save README.md.
  • Confirm README.md appears in the Visual Studio Code file tree.

README missing from the file tree?

Confirm the filename is exactly README.md. Confirm the file is saved inside ticket-triage-automation.

If the file remains missing, help me locate my README file in Visual Studio Code.

The ignore file keeps generated environments and runtime data out of Git history. This prevents local artifacts from cluttering the shared repository.

  • Create .gitignore inside the ticket-triage-automation workspace using the Visual Studio Code file tree.
  • Paste these ignore rules into .gitignore.
.venv/
__pycache__/
*.pyc
triage.db
triage.log

What do these rules protect?

  • The .venv/ rule excludes the local virtual environment.
  • The Python cache rules exclude generated interpreter files.
  • The database and log rules exclude runtime output created by the pipeline.
  • Save .gitignore.
  • Confirm .gitignore appears beside README.md in the Visual Studio Code file tree.

Ignore file missing?

Confirm the filename starts with a full stop. Remove any extra file extension added by Windows.

If the filename keeps changing, help me create a .gitignore file in Visual Studio Code.

✔️ Awesome, I've got everything!

Great. Save both starter files before creating the repository snapshot.

ⓧ I'd like to double check the full code

Compare each file with the complete starter versions below.

# Ticket Triage Automation

A Python pipeline that validates support tickets, stores clean results in SQLite, logs rejected records, and reports category counts.

## Run

Run `python triage.py` from the project folder.

## Test

Run `python -m unittest` from the project folder.

Generated runtime files `triage.db` and `triage.log` are ignored by Git.

README checkpoint

Your README.md should match this reference exactly.

.venv/
__pycache__/
*.pyc
triage.db
triage.log

Ignore file checkpoint

Your .gitignore should contain these five runtime exclusions.

The first commit becomes the baseline for every later feature change. Review the two tracked files before publishing that history.

  • Select the Source Control view from the Visual Studio Code sidebar.
  • Select Initialize Repository.
  • Stage README.md.
  • Stage .gitignore.
  • Enter Initialize ticket triage project as the commit message.
  • Select Commit.

You should see a clean Source Control view after the commit. The initial snapshot now contains both starter files.

Publishing connects the local repository to GitHub. The browser may request authorization before Visual Studio Code can push the commit.

  • Confirm the branch indicator shows main.
  • Select Publish to GitHub.
  • Complete the GitHub authorization if your browser requests it.
  • Choose public visibility for the new repository.

Before the final check, what two version numbers do you expect the terminal to confirm?

  • Verify Python and Git together by running these commands in the Visual Studio Code terminal:
py -3 --version
git --version

What should I see?

The first line should identify Python 3.15.2. The second line should identify Git for Windows 2.56.0(4).

  • Open your published repository from GitHub.
  • Confirm the selected branch is main.
  • Confirm the repository lists README.md.
  • Confirm the repository lists .gitignore.
  • Confirm the latest commit is Initialize ticket triage project.

That is the workspace ready. Your tools are verified while the starter project is safely published on GitHub.

Your reproducible workspace is in place. Next, you will build the first ticket classifier and trace where malformed input breaks it.

Build the Happy-Path Pipeline

Your Visual Studio Code workspace already holds a published starting point on main. A separate Git branch keeps this experiment away from that stable snapshot.

In this step, you build a Python pipeline that loads tickets from a file. Then you test its assumptions against the complete batch.

In this step, get ready to:
  • Create an isolated branch for the ticket pipeline.
  • Build a ticket batch with a runnable classifier.
  • Run the naive pipeline against all four tickets.
Create an isolated branch

Your first pipeline is an experiment. A feature branch gives that experiment its own line of history.

  • Open the Command Palette in VS Code.
  • Run Git: Create Branch....
  • Enter feature/ticket-triage as the branch name.
  • Check the branch name in the Status Bar.

Good, the experiment now has its own branch. You should see feature/ticket-triage in the Status Bar.

Branch name not changing?

  • Run Git: Create Branch... again from the Command Palette.
  • Select feature/ticket-triage from the available branches.
  • Ask for help with the branch switch.
Build the first pipeline

The pipeline begins with a JSON batch. Each ticket ID lets you follow one record through the classifier.

  • Create tickets.json inside the ticket-triage-automation folder using the Explorer sidebar.
  • Add the ticket batch by pasting this code:
[
  {
    "id": "T-1001",
    "customer": "Ava",
    "message": "Payment failed twice and I need help now"
  },
  {
    "id": "T-1002",
    "customer": "Noah",
    "message": "How do I update my billing address?"
  },
  {
    "id": "T-1003",
    "customer": "Mia",
    "message": "The app crashes when I upload a file"
  },
  {
    "id": "T-1004",
    "customer": "Leo",
    "details": "This record is missing the required message field"
  }
]

What does this data contain?

  • The outer array holds four ticket objects.
  • Each id gives you a stable value to trace through the pipeline.
  • The customer text identifies the person who submitted each ticket.
  • The support text supplies the classifier with its input.
  • Save tickets.json.

The unsaved-change indicator disappears from the editor tab. Your four-record input batch is now stored on disk.

Seeing a JSON warning?

  • Check that every ticket object starts with { and ends with }.
  • Check that the first three ticket objects have a comma after their closing brace.
  • Ask for help with the JSON structure.

The script now needs a loader plus a loop that classifies each ticket. This creates the first runnable data flow in the project.

  • Create triage.py inside the ticket-triage-automation folder using the Explorer sidebar.
  • Add the runnable classifier by pasting this code:
import json
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
INPUT_PATH = BASE_DIR / "tickets.json"


def load_tickets(path: Path) -> list[object]:
    with path.open(encoding="utf-8") as input_file:
        return json.load(input_file)


def main() -> None:
    for ticket in load_tickets(INPUT_PATH):
        message = ticket["message"]
        category = "incident" if "crash" in message.lower() or "failed" in message.lower() else "question"
        print(f"{ticket['id']}: {category}")


if __name__ == "__main__":
    main()

What does this code do?

  • The imports provide the JSON loader plus a cross-platform path class.
  • BASE_DIR anchors the input path to the folder containing triage.py.
  • load_tickets() reads the batch from tickets.json.
  • main() loops through the loaded records.
  • message = ticket["message"] retrieves the text used for classification.
  • The final line prints each ticket ID with its category.
  • Save triage.py.
  • Return to GitHub Copilot Chat from the previous step.
  • Ask Copilot to describe the loop's input.
  • Ask Copilot to describe the loop's transformation.
  • Ask Copilot to describe the loop's output.
  • Compare its explanation with the code before accepting any suggestion.

A matching explanation identifies tickets.json as the input. It identifies category assignment as the transformation.

The printed ticket ID plus category is the output. You have now traced the intended flow before running it.

Copilot explanation incomplete?

  • Keep triage.py active in the editor before asking again.
  • Select the loop inside main() to focus the explanation.
  • Ask for help tracing the data flow.

✔️ Awesome, I've got everything!

Your project files are saved. Keep triage.py open for the first run.

ⓧ I'd like to double check the full code

Your unchanged .gitignore should contain:

.venv/
__pycache__/
*.pyc
triage.db
triage.log

Your unchanged README.md should contain:

# Ticket Triage Automation

A Python pipeline that validates support tickets, stores clean results in SQLite, logs rejected records, and reports category counts.

## Run

Run `python triage.py` from the project folder.

## Test

Run `python -m unittest` from the project folder.

Generated runtime files `triage.db` and `triage.log` are ignored by Git.

Your new tickets.json should contain:

[
  {
    "id": "T-1001",
    "customer": "Ava",
    "message": "Payment failed twice and I need help now"
  },
  {
    "id": "T-1002",
    "customer": "Noah",
    "message": "How do I update my billing address?"
  },
  {
    "id": "T-1003",
    "customer": "Mia",
    "message": "The app crashes when I upload a file"
  },
  {
    "id": "T-1004",
    "customer": "Leo",
    "details": "This record is missing the required message field"
  }
]

Your new triage.py should contain:

import json
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
INPUT_PATH = BASE_DIR / "tickets.json"


def load_tickets(path: Path) -> list[object]:
    with path.open(encoding="utf-8") as input_file:
        return json.load(input_file)


def main() -> None:
    for ticket in load_tickets(INPUT_PATH):
        message = ticket["message"]
        category = "incident" if "crash" in message.lower() or "failed" in message.lower() else "question"
        print(f"{ticket['id']}: {category}")


if __name__ == "__main__":
    main()
Run and trace the pipeline

Before you run the pipeline, do you expect every record to reach the print statement?

  • Test the complete batch by running this command in the VS Code terminal:
python triage.py

You'll see T-1001: incident. The next line is T-1002: question.

The third classification is T-1003: incident. The traceback ends with KeyError: 'message'.

This failure is intentional

main() reads ticket["message"] for every record. T-1004 contains details instead.

The unchecked lookup stops the batch at the fourth record. This is the intended shortfall of the happy-path pipeline.

  • Trace T-1001 back to its record in tickets.json.
  • Trace T-1002 back to its record in tickets.json.
  • Trace T-1003 back to its record in tickets.json.
  • Read the final traceback from the bottom upward.
  • Identify message as the missing key.
  • Locate message = ticket["message"] as the failing line.
  • Leave the failure in place for the next step.

That is the key debugging win: you can trace the failure from input to traceback. Next, you'll protect each record with a validation boundary that logs rejected data.

Make Bad Records Non-Fatal

Your last run classified three tickets before T-1004 raised KeyError: 'message'. The traceback proved that the loop trusts every record.

This step adds an input validation boundary around each ticket. It also uses structured logging to preserve the reason for every rejected record.

In this step, get ready to:
  • Diagnose where per-record exception handling belongs.
  • Add explicit ticket validation with file logging.
  • Record the verified reliability fix in Git.
Diagnose the failure boundary

Per-record exception handling gives each ticket its own validation boundary. A malformed ticket can fail that check without stopping valid tickets from completing.

  • Switch back to GitHub Copilot Chat in VS Code.
  • Ask Copilot why the exception inside the naive loop stops later work.
  • Ask Copilot where validation belongs if one malformed record should be skipped.
  • Compare its explanation with the input-to-output flow you traced earlier.
  • Reject any suggestion that wraps the entire batch in except Exception.

Why handle one record at a time?

A per-record boundary catches the validation failure produced by one ticket. Unexpected failures remain visible because the pipeline catches only ValueError.

A broad catch around the entire batch would stop processing at the first rejected record. It could also conceal unrelated programming errors.

Add validation and logging

The logger needs a file destination before the pipeline can record rejected tickets. Shared validation constants keep every required field in one place.

  • In triage.py, replace everything from import json through INPUT_PATH = BASE_DIR / "tickets.json" with this module setup:
import json
import logging
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
INPUT_PATH = BASE_DIR / "tickets.json"
LOG_PATH = BASE_DIR / "triage.log"
REQUIRED_FIELDS = ("id", "customer", "message")
INCIDENT_KEYWORDS = ("failed", "crash", "error", "broken")
QUESTION_STARTERS = ("how ", "what ", "where ", "when ", "why ")

logger = logging.getLogger(__name__)

What does this setup define?

  • LOG_PATH keeps the failure log beside the script.
  • REQUIRED_FIELDS defines the values every valid ticket must contain.
  • INCIDENT_KEYWORDS centralizes the words used for incident classification.
  • QUESTION_STARTERS identifies common question openings.
  • logger gives this module one place to record skipped tickets.
  • Save triage.py.
  • Check the edited section for red syntax-error underlines.

You should see no red syntax-error underlines in the module setup.

  • Add configure_logging() below the module logger:
def configure_logging() -> None:
    logging.basicConfig(filename=LOG_PATH, level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", encoding="utf-8", force=True)

What does the logging configuration do?

The configuration sends log entries to triage.log. Each entry includes a timestamp with its level.

The UTF-8 encoding keeps text handling predictable. The forced configuration ensures this setup applies when the script starts.

  • Save triage.py.

Before you run the pipeline, do you expect module-level logging to stop the naive ticket lookup from failing?

  • Check the current execution path by running this command in the VS Code terminal:
python triage.py

The known failure remains

You will still see classifications for T-1001, T-1002, and T-1003.

The run still ends with KeyError: 'message' because the naive loop does not use a validation boundary. This expected failure confirms that configuration alone does not protect the batch.

Validation should also reject a JSON document whose top-level value is not a list. That check keeps the rest of the pipeline focused on ticket records.

  • In triage.py, replace the current load_tickets() function with this version:
def load_tickets(path: Path) -> list[object]:
    with path.open(encoding="utf-8") as input_file:
        data = json.load(input_file)
    if not isinstance(data, list):
        raise ValueError("Ticket input must be a JSON array")
    return data

What does the input check protect?

The function loads the JSON document into data. It raises a clear validation error if the document is not an array of records.

A valid array continues into ticket-level validation. The pipeline now has a boundary for the batch shape.

  • Save triage.py.
  • Check the updated function for red syntax-error underlines.

Each record now needs its own normalization function. This function checks structure before it reads required values.

  • Add normalize_ticket() below load_tickets() by copying this function:
def normalize_ticket(ticket: object) -> dict[str, str]:
    if not isinstance(ticket, dict):
        raise ValueError("Ticket must be a JSON object")
    missing = [field for field in REQUIRED_FIELDS if field not in ticket]
    if missing:
        raise ValueError(f"Missing required fields: {', '.join(missing)}")
    for field in REQUIRED_FIELDS:
        if not isinstance(ticket[field], str) or not ticket[field].strip():
            raise ValueError(f"Field must be a non-empty string: {field}")
    message = ticket["message"].strip()
    lowered = message.lower()
    is_incident = any(keyword in lowered for keyword in INCIDENT_KEYWORDS)
    is_question = message.endswith("?") or lowered.startswith(QUESTION_STARTERS)
    return {"ticket_id": ticket["id"].strip(), "customer": ticket["customer"].strip(), "message": message, "category": "incident" if is_incident else "question" if is_question else "general", "priority": "high" if is_incident or "urgent" in lowered else "normal"}

What does ticket normalization do?

  • The first check requires each ticket to be a JSON object.
  • The missing-field check produces a specific reason before any required value is read.
  • The field loop rejects empty values or values with the wrong type.
  • The final dictionary gives every valid ticket a consistent shape for later processing.
  • Save triage.py.

Before you run the script, does the current main() call normalize_ticket() yet?

  • Test the current execution path by running the pipeline again:
python triage.py

The old loop still bypasses validation

The script still ends with KeyError: 'message'. The validation function exists now, but the old loop still reads ticket["message"] directly.

This is the final expected failure in the edit sequence. The next substep routes every record through the new boundary.

Connect per-record handling

The processing loop is where one rejected ticket becomes non-fatal. Only the ValueError raised by validation is handled there.

  • Add process_tickets() below normalize_ticket() by copying this function:
def process_tickets(input_path: Path = INPUT_PATH) -> tuple[int, int]:
    processed = 0
    skipped = 0
    for raw_ticket in load_tickets(input_path):
        try:
            ticket = normalize_ticket(raw_ticket)
        except ValueError as error:
            ticket_id = raw_ticket.get("id", "unknown") if isinstance(raw_ticket, dict) else "unknown"
            logger.warning("Skipped ticket %s: %s", ticket_id, error)
            skipped += 1
            continue
        print(f"{ticket['ticket_id']}: {ticket['category']}")
        processed += 1
    return processed, skipped

How does one ticket fail safely?

Each raw ticket enters its own try block. A validation error records the ticket ID with its reason.

The continue statement moves processing to the next record. Valid tickets still print their classifications and increase the processed count.

  • Save triage.py.
  • Check the new function for red syntax-error underlines.

The two counters need a compact report after processing finishes. This makes the reliability result visible in every run.

  • Add print_report() below process_tickets() by copying this function:
def print_report(processed: int, skipped: int) -> None:
    print(f"Processed: {processed}")
    print(f"Skipped: {skipped}")

What does the report show?

The report prints separate totals for accepted records and rejected records. Those totals reveal whether the batch completed beyond a malformed ticket.

  • Save triage.py.

Before you run the script, do you think the old main() has started using the new processing function?

  • Check the remaining execution gap by running the pipeline:
python triage.py

The entry point still needs rewiring

You will still reach KeyError: 'message' because the old main() remains active. The validation path is ready, but the entry point has not called it.

The final edit connects logging, processing, and the visible totals. It removes the naive loop from the execution path.

  • Replace the current main() function with this version:
def main() -> None:
    configure_logging()
    processed, skipped = process_tickets()
    print_report(processed, skipped)

How does the completed flow run?

The entry point configures file logging before processing starts. It then sends the two returned counters to the report.

Every ticket now passes through normalize_ticket() before classification. The malformed record cannot reach the unsafe message lookup.

  • Save triage.py.

Before the final run, do you expect T-1004 to stop the batch or increase the skipped count?

  • Verify the completed reliability boundary by running the pipeline:
python triage.py

What should you see?

You should see classifications for T-1001, T-1002, and T-1003.

The final lines should read Processed: 3 and Skipped: 1. The batch now completes after rejecting the malformed record.

  • In the VS Code Explorer, select triage.log.
  • Find the entry containing Skipped ticket T-1004: Missing required fields: message.

That is the reliability boundary working. The terminal preserves progress while the log preserves the reason for the rejected ticket.

Still seeing the batch crash?

  • Confirm that main() calls process_tickets() instead of looping over load_tickets(INPUT_PATH) directly.
  • Confirm that the except ValueError block is indented inside the ticket loop.
  • Confirm that continue is inside the validation-error branch.
  • Help me debug why my per-record validation still lets one malformed ticket stop the Python batch.

Use the completed file below to check every definition before committing the fix.

✔️ Awesome, I've got everything!

Great. Double-check that you saved triage.py before reviewing the diff.

ⓧ I'd like to double check the full code

import json
import logging
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
INPUT_PATH = BASE_DIR / "tickets.json"
LOG_PATH = BASE_DIR / "triage.log"
REQUIRED_FIELDS = ("id", "customer", "message")
INCIDENT_KEYWORDS = ("failed", "crash", "error", "broken")
QUESTION_STARTERS = ("how ", "what ", "where ", "when ", "why ")

logger = logging.getLogger(__name__)


def configure_logging() -> None:
    logging.basicConfig(filename=LOG_PATH, level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", encoding="utf-8", force=True)


def load_tickets(path: Path) -> list[object]:
    with path.open(encoding="utf-8") as input_file:
        data = json.load(input_file)
    if not isinstance(data, list):
        raise ValueError("Ticket input must be a JSON array")
    return data


def normalize_ticket(ticket: object) -> dict[str, str]:
    if not isinstance(ticket, dict):
        raise ValueError("Ticket must be a JSON object")
    missing = [field for field in REQUIRED_FIELDS if field not in ticket]
    if missing:
        raise ValueError(f"Missing required fields: {', '.join(missing)}")
    for field in REQUIRED_FIELDS:
        if not isinstance(ticket[field], str) or not ticket[field].strip():
            raise ValueError(f"Field must be a non-empty string: {field}")
    message = ticket["message"].strip()
    lowered = message.lower()
    is_incident = any(keyword in lowered for keyword in INCIDENT_KEYWORDS)
    is_question = message.endswith("?") or lowered.startswith(QUESTION_STARTERS)
    return {"ticket_id": ticket["id"].strip(), "customer": ticket["customer"].strip(), "message": message, "category": "incident" if is_incident else "question" if is_question else "general", "priority": "high" if is_incident or "urgent" in lowered else "normal"}


def process_tickets(input_path: Path = INPUT_PATH) -> tuple[int, int]:
    processed = 0
    skipped = 0
    for raw_ticket in load_tickets(input_path):
        try:
            ticket = normalize_ticket(raw_ticket)
        except ValueError as error:
            ticket_id = raw_ticket.get("id", "unknown") if isinstance(raw_ticket, dict) else "unknown"
            logger.warning("Skipped ticket %s: %s", ticket_id, error)
            skipped += 1
            continue
        print(f"{ticket['ticket_id']}: {ticket['category']}")
        processed += 1
    return processed, skipped


def print_report(processed: int, skipped: int) -> None:
    print(f"Processed: {processed}")
    print(f"Skipped: {skipped}")


def main() -> None:
    configure_logging()
    processed, skipped = process_tickets()
    print_report(processed, skipped)


if __name__ == "__main__":
    main()

What should match?

Your file should contain the logging setup, input checks, ticket normalization, per-record processing, summary report, and updated entry point in this order.

The exception boundary should catch only ValueError inside the loop. That detail keeps unexpected failures visible.

The terminal and log prove the behavior works. The Source Control diff now lets you confirm that the commit contains only the reliability fix.

  • Select the Source Control view in VS Code.
  • Select triage.py to inspect its diff.
  • Review the validation changes in the diff.
  • Review the logging changes in the diff.
  • Stage only triage.py.
  • Enter Add ticket validation and logging in the Source Control commit message box.
  • Create the commit.

You should see no pending change for triage.py in Source Control. The Status Bar should still show feature/ticket-triage.

Your automation now finishes the batch while preserving a queryable record of the rejected ticket. Next, you will keep the successful classifications in SQLite and report their category totals.

Persist Results with SQLite

Your feature branch now processes three valid tickets despite the malformed fourth record. The failure log preserves the reason that record was skipped.

Console output disappears when the terminal closes. In this step, SQLite gives the pipeline durable results that a SQL query can summarize after every run.

In this step, get ready to:
  • Create a database table with constraints for every stored ticket value.
  • Store normalized tickets through parameterized UPSERTs inside one batch transaction.
  • Query stable category totals after repeated pipeline runs.
Prepare the data for SQLite

The database needs a predictable path beside the script. The normalized values also need clear names that map to the table columns.

  • In triage.py, select the code from import json through LOG_PATH = BASE_DIR / "triage.log".
  • Replace the selected code with this updated import and path block:
import json
import logging
import sqlite3
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
INPUT_PATH = BASE_DIR / "tickets.json"
DATABASE_PATH = BASE_DIR / "triage.db"
LOG_PATH = BASE_DIR / "triage.log"

What does this code add?

  • The sqlite3 module lets Python create a database connection and execute SQL statements.
  • DATABASE_PATH places the generated database beside triage.py.
  • The existing .gitignore rule keeps triage.db outside version control.
  • Save triage.py.
  • Confirm the updated imports load by running this command in the VS Code terminal from earlier:
python triage.py

What should you see?

You should still see classifications for the three valid tickets. The final lines should report Processed: 3 and Skipped: 1.

Seeing an import or path error?

  • Check that the import reads import sqlite3.
  • Check that DATABASE_PATH appears below INPUT_PATH.
  • Confirm that the terminal is using the ticket-triage-automation folder from earlier.
  • Help me diagnose the SQLite import or database path error in triage.py.

The current normalizer returns the correct values in one expression. Named local values make the path from input fields to database columns easier to inspect.

  • In triage.py, select the complete normalize_ticket() function.
  • Replace the selected function with this version:
def normalize_ticket(ticket: object) -> dict[str, str]:
    if not isinstance(ticket, dict):
        raise ValueError("Ticket must be a JSON object")
    missing = [field for field in REQUIRED_FIELDS if field not in ticket]
    if missing:
        raise ValueError(f"Missing required fields: {', '.join(missing)}")
    for field in REQUIRED_FIELDS:
        value = ticket[field]
        if not isinstance(value, str) or not value.strip():
            raise ValueError(f"Field must be a non-empty string: {field}")
    ticket_id = ticket["id"].strip()
    customer = ticket["customer"].strip()
    message = ticket["message"].strip()
    lowered = message.lower()
    is_incident = any(keyword in lowered for keyword in INCIDENT_KEYWORDS)
    is_question = message.endswith("?") or lowered.startswith(QUESTION_STARTERS)
    category = "incident" if is_incident else "question" if is_question else "general"
    priority = "high" if is_incident or "urgent" in lowered else "normal"
    return {"ticket_id": ticket_id, "customer": customer, "message": message, "category": category, "priority": priority}

How does normalization support storage?

  • ticket_id holds the stable identity used for one stored ticket.
  • customer and message hold the trimmed input values.
  • category and priority hold the pipeline's classification results.
  • The returned dictionary now mirrors the five database columns.
  • Save triage.py.
  • Confirm that normalization still produces the expected classifications by running:
python triage.py

What should remain stable?

You should still see three valid classifications. The summary should remain Processed: 3 and Skipped: 1.

Seeing a normalization error?

  • Check that each named local value appears before the returned dictionary.
  • Check that the returned keys match ticket_id, customer, message, category, and priority.
  • Help me compare my normalizer with the expected stored ticket shape.
Persist and report the tickets

The storage boundary creates the table before processing begins. Parameter placeholders bind ticket values without assembling SQL from input strings.

  • In triage.py, place the cursor below the closing line of normalize_ticket().
  • Add the database helper functions by pasting this block:
def setup_database(path: str | Path) -> sqlite3.Connection:
    connection = sqlite3.connect(path, autocommit=False)
    connection.execute("CREATE TABLE IF NOT EXISTS tickets (ticket_id TEXT PRIMARY KEY NOT NULL, customer TEXT NOT NULL, message TEXT NOT NULL, category TEXT NOT NULL, priority TEXT NOT NULL)")
    connection.commit()
    return connection


def save_ticket(connection: sqlite3.Connection, ticket: dict[str, str]) -> None:
    connection.execute("INSERT INTO tickets (ticket_id, customer, message, category, priority) VALUES (?, ?, ?, ?, ?) ON CONFLICT(ticket_id) DO UPDATE SET customer = excluded.customer, message = excluded.message, category = excluded.category, priority = excluded.priority", (ticket["ticket_id"], ticket["customer"], ticket["message"], ticket["category"], ticket["priority"]))


def category_report(connection: sqlite3.Connection) -> list[tuple[str, int]]:
    return connection.execute("SELECT category, COUNT(*) AS ticket_count FROM tickets GROUP BY category ORDER BY ticket_count DESC, category ASC").fetchall()

How do the database helpers work?

  • setup_database() opens a connection with explicit transaction control. It creates the tickets table when needed.
  • PRIMARY KEY makes each ticket_id unique. The NOT NULL constraints protect the required text columns.
  • save_ticket() binds five ticket values through question-mark placeholders. A conflict on ticket_id updates the existing row.
  • category_report() groups the stored rows by category. Its ordering keeps the report deterministic.
  • Save triage.py.
  • Confirm that Python loads the database helpers by running:
python triage.py

What does this check prove?

The script should still process three tickets and skip one ticket. This output confirms that Python can load all three helper definitions.

Seeing a SQL syntax error?

  • Check that the table statement defines five columns inside its parentheses.
  • Check that the insert contains five question-mark placeholders.
  • Check that the parameter tuple contains five ticket values.
  • Help me locate the SQL syntax or parameter mismatch in my database helpers.

The batch now needs to send each valid ticket through the storage boundary. One commit after the loop keeps all valid writes inside the same transaction.

  • In triage.py, select the code from the existing process_tickets() signature through the closing line of main().
  • Replace the selection with the first part of the updated pipeline:
def process_tickets(input_path: Path = INPUT_PATH, database_path: str | Path = DATABASE_PATH) -> tuple[int, int, list[tuple[str, int]]]:
    raw_tickets = load_tickets(input_path)
    connection = setup_database(database_path)
    processed = 0
    skipped = 0
    try:
        for raw_ticket in raw_tickets:
            try:
                ticket = normalize_ticket(raw_ticket)
            except ValueError as error:
                ticket_id = raw_ticket.get("id", "unknown") if isinstance(raw_ticket, dict) else "unknown"
                logger.warning("Skipped ticket %s: %s", ticket_id, error)
                skipped += 1
                continue
            save_ticket(connection, ticket)
            processed += 1
        connection.commit()
        report = category_report(connection)
    finally:
        connection.close()
    return processed, skipped, report

What changes in the batch?

  • setup_database() opens the connection before record processing begins.
  • save_ticket() writes each valid ticket. The malformed record still follows the existing skip path.
  • connection.commit() commits the valid writes after the loop finishes.
  • finally closes the connection when processing succeeds. It also closes the connection when an unexpected failure surfaces.
  • Place the cursor below the updated process_tickets() function.
  • Add the matching report function and entry point from this block:
def print_report(processed: int, skipped: int, report: list[tuple[str, int]]) -> None:
    print(f"Processed: {processed}")
    print(f"Skipped: {skipped}")
    print("Tickets by category:")
    for category, count in report:
        print(f"- {category}: {count}")


def main() -> None:
    configure_logging()
    processed, skipped, report = process_tickets()
    print_report(processed, skipped, report)
    logger.info("Completed batch: processed=%s skipped=%s", processed, skipped)

How does the report reach the terminal?

  • print_report() receives the grouped rows with both counters.
  • The report loop prints one line for every stored category.
  • main() passes the three returned values into the updated report function.
  • The final logger call records the completed batch totals.
  • Save triage.py.

Before you run the updated pipeline, do you think the skipped record appears in the database report?

  • Create the database and print its first grouped report by running:
python triage.py

What should you see?

  • The terminal should report Processed: 3.
  • The terminal should report Skipped: 1.
  • The category report should show incident: 2.
  • The category report should show question: 1.

That is the storage boundary working. Three normalized tickets now remain queryable after the script exits.

  • Confirm that triage.db appears in the VS Code file list.
  • Select triage.log in the file list.

The log should still contain Skipped ticket T-1004: Missing required fields: message.

Missing the database or report?

  • Check that process_tickets() calls setup_database() before the loop.
  • Check that save_ticket() is inside the loop after validation succeeds.
  • Check that connection.commit() is aligned after the loop.
  • Help me trace why the database or category report is missing.

✔️ Awesome, I've got everything!

Your saved triage.py now validates, stores, reports, logs, and closes its database connection.

ⓧ I'd like to double check the full code

  • Compare your complete triage.py file with this reference:
import json
import logging
import sqlite3
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
INPUT_PATH = BASE_DIR / "tickets.json"
DATABASE_PATH = BASE_DIR / "triage.db"
LOG_PATH = BASE_DIR / "triage.log"
REQUIRED_FIELDS = ("id", "customer", "message")
INCIDENT_KEYWORDS = ("failed", "crash", "error", "broken")
QUESTION_STARTERS = ("how ", "what ", "where ", "when ", "why ")

logger = logging.getLogger(__name__)


def configure_logging() -> None:
    logging.basicConfig(filename=LOG_PATH, level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", encoding="utf-8", force=True)


def load_tickets(path: Path) -> list[object]:
    with path.open(encoding="utf-8") as input_file:
        data = json.load(input_file)
    if not isinstance(data, list):
        raise ValueError("Ticket input must be a JSON array")
    return data


def normalize_ticket(ticket: object) -> dict[str, str]:
    if not isinstance(ticket, dict):
        raise ValueError("Ticket must be a JSON object")
    missing = [field for field in REQUIRED_FIELDS if field not in ticket]
    if missing:
        raise ValueError(f"Missing required fields: {', '.join(missing)}")
    for field in REQUIRED_FIELDS:
        value = ticket[field]
        if not isinstance(value, str) or not value.strip():
            raise ValueError(f"Field must be a non-empty string: {field}")
    ticket_id = ticket["id"].strip()
    customer = ticket["customer"].strip()
    message = ticket["message"].strip()
    lowered = message.lower()
    is_incident = any(keyword in lowered for keyword in INCIDENT_KEYWORDS)
    is_question = message.endswith("?") or lowered.startswith(QUESTION_STARTERS)
    category = "incident" if is_incident else "question" if is_question else "general"
    priority = "high" if is_incident or "urgent" in lowered else "normal"
    return {"ticket_id": ticket_id, "customer": customer, "message": message, "category": category, "priority": priority}


def setup_database(path: str | Path) -> sqlite3.Connection:
    connection = sqlite3.connect(path, autocommit=False)
    connection.execute("CREATE TABLE IF NOT EXISTS tickets (ticket_id TEXT PRIMARY KEY NOT NULL, customer TEXT NOT NULL, message TEXT NOT NULL, category TEXT NOT NULL, priority TEXT NOT NULL)")
    connection.commit()
    return connection


def save_ticket(connection: sqlite3.Connection, ticket: dict[str, str]) -> None:
    connection.execute("INSERT INTO tickets (ticket_id, customer, message, category, priority) VALUES (?, ?, ?, ?, ?) ON CONFLICT(ticket_id) DO UPDATE SET customer = excluded.customer, message = excluded.message, category = excluded.category, priority = excluded.priority", (ticket["ticket_id"], ticket["customer"], ticket["message"], ticket["category"], ticket["priority"]))


def category_report(connection: sqlite3.Connection) -> list[tuple[str, int]]:
    return connection.execute("SELECT category, COUNT(*) AS ticket_count FROM tickets GROUP BY category ORDER BY ticket_count DESC, category ASC").fetchall()


def process_tickets(input_path: Path = INPUT_PATH, database_path: str | Path = DATABASE_PATH) -> tuple[int, int, list[tuple[str, int]]]:
    raw_tickets = load_tickets(input_path)
    connection = setup_database(database_path)
    processed = 0
    skipped = 0
    try:
        for raw_ticket in raw_tickets:
            try:
                ticket = normalize_ticket(raw_ticket)
            except ValueError as error:
                ticket_id = raw_ticket.get("id", "unknown") if isinstance(raw_ticket, dict) else "unknown"
                logger.warning("Skipped ticket %s: %s", ticket_id, error)
                skipped += 1
                continue
            save_ticket(connection, ticket)
            processed += 1
        connection.commit()
        report = category_report(connection)
    finally:
        connection.close()
    return processed, skipped, report


def print_report(processed: int, skipped: int, report: list[tuple[str, int]]) -> None:
    print(f"Processed: {processed}")
    print(f"Skipped: {skipped}")
    print("Tickets by category:")
    for category, count in report:
        print(f"- {category}: {count}")


def main() -> None:
    configure_logging()
    processed, skipped, report = process_tickets()
    print_report(processed, skipped, report)
    logger.info("Completed batch: processed=%s skipped=%s", processed, skipped)


if __name__ == "__main__":
    main()

How should you use this reference?

Check the function order first. Compare the indentation, parameter names, SQL text, and return values next.

Prove repeatable storage and commit

A repeatable pipeline produces the same stored totals when the same batch runs again. The ticket primary key lets each UPSERT update its existing row.

Before the second run, do you think the category totals stay fixed or double?

  • Run the pipeline a second time with this command:
python triage.py

What should the second run report?

  • The second run should show Processed: 3.
  • The second run should show Skipped: 1.
  • The second run should show incident: 2.
  • The second run should show question: 1.

The counts stay fixed because each ticket ID identifies one row. The malformed ticket remains visible in the failure log.

Did the totals double?

  • Check that ticket_id is the table's primary key.
  • Check that the insert contains ON CONFLICT(ticket_id) DO UPDATE.
  • Check that the report query contains GROUP BY category.
  • Help me diagnose why repeated UPSERTs increase the ticket totals.

Your stable report proves that repeated runs preserve one row per ticket. A focused commit can now capture the complete storage boundary.

  • Return to the Source Control view from earlier.
  • Select triage.py in the changed-file list.
  • Review the complete diff.
  • Stage triage.py.
  • Enter Persist triage results in SQLite as the commit message.
  • Select Commit.

Strong work. The Source Control view should now show no pending source changes. The generated triage.db remains excluded by the existing ignore rule.

Your ticket results now survive the terminal session with stable SQL totals. Next, you will protect this reliability contract with automated tests and a pull-request check.

Add Tests and Open a Pull Request

Your pipeline now stores validated tickets in SQLite. Its category report stays stable across repeated runs.

Local success protects only your current machine. Shared changes need a repeatable check that runs for every reviewer.

This step captures the pipeline's reliability contract with unit tests. GitHub Actions runs the same suite when you open a pull request.

In this step, get ready to:
  • Test classification, validation, and SQLite reporting.
  • Run the test suite locally and through GitHub Actions.
  • Publish the feature branch and open a passing pull request.
Write focused unit tests

Each test protects one important behavior. Together they check the classifier, the validation boundary, and the database report without using the generated triage.db file.

  • In the VS Code file sidebar, select the new-folder icon.
  • Enter tests as the folder name.

You will see the tests folder inside ticket-triage-automation.

  • Select the tests folder in the file sidebar.
  • Select the new-file icon at the top of the file sidebar.
  • Enter __init__.py as the file name.
  • Mark the folder as the project's test package by pasting this code into tests/__init__.py:
"""Tests for the ticket triage automation."""

Why This File Exists

The module docstring identifies the purpose of the package. The file also gives Python a clear package boundary for the tests.

  • Save tests/__init__.py.

You will see __init__.py nested under the tests folder.

File in the Wrong Folder?

Check the indentation in the file sidebar. __init__.py must appear directly beneath tests.

If it appears beside the folder, move it into tests. Help me place tests/__init__.py in the correct VS Code folder.

  • Select the tests folder in the file sidebar.
  • Select the new-file icon at the top of the file sidebar.
  • Enter test_triage.py as the file name.
  • Add the first classification test by pasting this code into tests/test_triage.py:
import unittest

from triage import category_report, normalize_ticket, save_ticket, setup_database


class TriageTests(unittest.TestCase):
    def test_normalizes_incident_ticket(self) -> None:
        result = normalize_ticket({"id": "T-1", "customer": "Ava", "message": "The app crashes during upload"})
        self.assertEqual(result["category"], "incident")
        self.assertEqual(result["priority"], "high")

What Does This Test Prove?

  • The import gives the suite direct access to the production functions in triage.py.
  • TriageTests groups the pipeline checks under unittest.TestCase.
  • test_normalizes_incident_ticket() proves that a crash report becomes a high-priority incident.
  • Save tests/test_triage.py.

Before you run the suite, predict how many tests Python can discover at this point.

  • Run the current test suite in the VS Code terminal with this command:
python -m unittest

What Should You See?

The output reports that one test ran successfully. This proves Python can discover the package and import the pipeline.

Test Module Not Found?

Confirm that the terminal is still inside the ticket-triage-automation folder. The folder should contain both triage.py and tests.

Check that both test filenames use underscores exactly as shown. Help me troubleshoot unittest discovery in this project.

  • In tests/test_triage.py, find the final assertion in test_normalizes_incident_ticket().
  • Add the missing-message test directly below the first test method by pasting this code:
    def test_rejects_missing_message(self) -> None:
        with self.assertRaisesRegex(ValueError, "Missing required fields: message"):
            normalize_ticket({"id": "T-2", "customer": "Noah"})

What Does This Test Prove?

assertRaisesRegex() requires the expected exception type and reason. The test protects the exact validation rule that keeps malformed records out of the database.

  • Save tests/test_triage.py.
  • Run the expanded suite with this command:
python -m unittest

Check the Expanded Suite

The output now reports that two tests ran successfully. Classification and missing-message validation are both protected.

Missing-Message Test Failing?

Compare the expected reason with the validation text in normalize_ticket(). Capitalization and spacing must match.

Make sure the test ticket omits message completely. Help me diagnose the missing-message unittest failure.

  • In tests/test_triage.py, find test_rejects_missing_message().
  • Add the database test and module runner directly below it by pasting this code:
    def test_stores_ticket_and_builds_report(self) -> None:
        connection = setup_database(":memory:")
        try:
            ticket = normalize_ticket({"id": "T-3", "customer": "Mia", "message": "How do I change my password?"})
            save_ticket(connection, ticket)
            connection.commit()
            self.assertEqual(category_report(connection), [("question", 1)])
        finally:
            connection.close()


if __name__ == "__main__":
    unittest.main()

How Does the Database Test Work?

  • The in-memory connection creates an isolated SQLite database for this test run.
  • save_ticket() exercises the same parameterized UPSERT used by the pipeline.
  • category_report() must return one question after the transaction commits.
  • The finally block closes the temporary connection even if an assertion fails.
  • Save tests/test_triage.py.

Before you run the completed suite, predict whether all three behaviors now have independent protection.

  • Run all three tests with this command:
python -m unittest

Your Local Test Gate

You will see Ran 3 tests followed by OK. The suite now protects classification, rejection, and persistence.

One of the Three Tests Failing?

Read the failing test name first. That name identifies whether the mismatch belongs to classification, validation, or SQLite reporting.

Compare the first differing expected value with the corresponding function in triage.py. Help me understand my failing ticket-triage unittest.

That is your local reliability contract working. A change that breaks any protected behavior now produces a specific failing test.

✔️ Awesome, I've got everything!

Great. Save both files in the tests folder before you continue.

ⓧ I'd like to double check the full code

Your package file should match this code.

"""Tests for the ticket triage automation."""

This docstring identifies the purpose of the test package.

Your test module should match this code.

import unittest

from triage import category_report, normalize_ticket, save_ticket, setup_database


class TriageTests(unittest.TestCase):
    def test_normalizes_incident_ticket(self) -> None:
        result = normalize_ticket({"id": "T-1", "customer": "Ava", "message": "The app crashes during upload"})
        self.assertEqual(result["category"], "incident")
        self.assertEqual(result["priority"], "high")

    def test_rejects_missing_message(self) -> None:
        with self.assertRaisesRegex(ValueError, "Missing required fields: message"):
            normalize_ticket({"id": "T-2", "customer": "Noah"})

    def test_stores_ticket_and_builds_report(self) -> None:
        connection = setup_database(":memory:")
        try:
            ticket = normalize_ticket({"id": "T-3", "customer": "Mia", "message": "How do I change my password?"})
            save_ticket(connection, ticket)
            connection.commit()
            self.assertEqual(category_report(connection), [("question", 1)])
        finally:
            connection.close()


if __name__ == "__main__":
    unittest.main()

This complete module defines the three tests that produced your passing local result.

Add the GitHub Actions workflow

The local command now has a reliable result. A workflow gives GitHub the same Python version and the same test command for every pull request.

  • In the VS Code file sidebar, select the new-folder icon.
  • Enter .github as the folder name.

You will see .github inside ticket-triage-automation.

  • Select the .github folder.
  • Select the new-folder icon at the top of the file sidebar.
  • Enter workflows as the folder name.

You will see workflows nested beneath .github.

  • Select the workflows folder.
  • Select the new-file icon at the top of the file sidebar.
  • Enter tests.yml as the file name.
  • Define the automated test job by pasting this workflow into .github/workflows/tests.yml:
name: Python tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository
        uses: actions/checkout@v7.0.1
      - name: Set up Python
        uses: actions/setup-python@v7.1.0
        with:
          python-version: "3.15.2"
      - name: Run tests
        run: python -m unittest

What Does This Workflow Do?

  • The triggers run the workflow for changes to main and pull requests targeting main.
  • The permissions block limits repository contents to read-only access.
  • actions/checkout@v7.0.1 places the repository files on the hosted runner.
  • actions/setup-python@v7.1.0 configures Python 3.15.2 before the job runs the local test command.
  • Save .github/workflows/tests.yml.
  • Select Source Control in the VS Code activity bar.

You will see tests/__init__.py, tests/test_triage.py, and .github/workflows/tests.yml in the changed-files list.

Workflow File Missing?

Check the file sidebar nesting. tests.yml must be inside .github/workflows.

Confirm that the filename ends in .yml. Help me verify my GitHub Actions workflow path.

✔️ Awesome, I've got everything!

Your workflow is saved in the path GitHub checks for automation files.

ⓧ I'd like to double check the full code

Your workflow should match this complete file.

name: Python tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository
        uses: actions/checkout@v7.0.1
      - name: Set up Python
        uses: actions/setup-python@v7.1.0
        with:
          python-version: "3.15.2"
      - name: Run tests
        run: python -m unittest

This complete workflow runs the same three-test suite used in your local terminal.

Publish the branch and open the pull request

The feature branch now contains a test gate that another machine can run. Reviewing the diff before publishing keeps generated files and unrelated edits out of the shared commit.

  • In the Source Control panel, select tests/__init__.py to review its diff.
  • Select tests/test_triage.py to review its diff.
  • Select .github/workflows/tests.yml to review its diff.
  • Stage tests/__init__.py with the plus icon beside the file.
  • Stage tests/test_triage.py with the plus icon beside the file.
  • Stage .github/workflows/tests.yml with the plus icon beside the file.

The staged-files list should contain only the two test files and the workflow file.

  • Enter Add automated test gate in the Source Control message box.
  • Select Commit to create the commit.

Your branch now includes a focused commit for the local and hosted test gate.

Review Workflow Spending First

Publishing the branch allows GitHub to run the hosted workflow after the pull request opens. GitHub-hosted workflow usage follows the billing and spending settings on your account.

  • Review your GitHub Actions billing and spending settings to confirm the limits you want before publishing.
  • Return to VS Code.
  • Select Publish Branch in the Source Control panel.

The feature/ticket-triage branch is now available in the published GitHub repository.

Branch Not Publishing?

Confirm that VS Code is still signed in to the GitHub account that owns the repository. Check that the Status Bar still shows feature/ticket-triage.

Retry Publish Branch after restoring the account connection. Help me troubleshoot publishing my VS Code branch to GitHub.

  • Return to the published GitHub repository from earlier.
  • Select Compare & pull request.
  • Confirm that main is the base branch.
  • Confirm that feature/ticket-triage is the compare branch.
  • Select Create Pull Request.

Before you refresh the pull request, predict whether the shared runner should match your local test result.

  • Refresh the pull request page.
  • Wait for the pull request check to finish.
  • Open the check details.
  • Select the test job.
  • Read the workflow log from the checkout step through the test step.

You will see the test job passing. Its log shows the same three-test suite succeeding on GitHub.

Pull Request Check Failing?

Open the failed step in the workflow log. A YAML problem points to the workflow file, while a test failure names the protected behavior that changed.

Compare the hosted command with the local command before changing the tests. Help me diagnose my failing GitHub Actions test job.

You have closed the reliability loop. The same test contract now protects your machine and the shared branch.

Secret mission

Break It, Detect It, Revert It

Push a controlled classification regression to your published branch. Watch the pull request check catch it, then restore the working pipeline with an auditable revert commit.

Clean Up Your Resources

Clean Up Your Resources

Your automation runs locally with no Azure resources. Choose whether to keep the local workspace with its GitHub repository, pause new work, or delete both.

Cost warning

No paid service was intentionally provisioned. Hosted GitHub Actions usage follows the billing and spending settings on your account.

  • Review your GitHub Actions billing and spending settings before running additional CI jobs.
  • Choose Pause or Delete if you do not plan to run more workflow jobs.

Resources you used:

  • Local ticket-triage-automation folder containing the source code, tests, .venv environment, triage.log failure log, and triage.db database.
  • Published GitHub repository containing the feature/ticket-triage branch, the open pull request, the .github/workflows/tests.yml workflow, and its run history.

Keep everything running

No action is needed. Choose this if you want to rerun the automation or inspect its reliability history later.

  • Keep the ticket-triage-automation folder to preserve the source files, generated database, failure log, tests, and virtual environment.
  • Keep the published repository to preserve the feature branch, pull request, passing workflow check, regression commit, and revert commit.

Pause - I'll come back to this later

Pause new work while keeping every local file and shared commit ready for your return.

  • Close Visual Studio Code to free the memory used by the open workspace.
  • Stop pushing commits to feature/ticket-triage.
  • Leave the ticket-triage-automation folder unchanged.
  • Leave the published repository and pull request unchanged.

The workflow has no new job to run until another matching push or pull_request event occurs.

Delete - I don't want to use this again

Deleting both copies is permanent. This option fits when you no longer need the code or its history.

  • Close the open pull request with its close control on GitHub.
  • Delete the published GitHub repository with the repository deletion control on its settings page.
  • Load the former repository URL to verify that the repository is unavailable.

You will see that the repository is no longer available. This confirms that the branch, pull request, workflow, and commit history are gone with it.

The remote copy is gone. Your local ticket-triage-automation folder is now the remaining project copy.

  • Use the folder context menu in the open VS Code Explorer to reveal ticket-triage-automation in File Explorer.
  • Close Visual Studio Code.
  • Select the ticket-triage-automation folder in File Explorer.
  • Press the Delete key to move the folder to the Recycle Bin.
  • Empty the Recycle Bin to remove the folder permanently.
  • Check the folder's previous location to confirm that ticket-triage-automation is no longer listed.

That completes the cleanup. Both the local workspace and published repository are gone.

Nice Work!

Nice Work!

You made it! Your ticket triage pipeline now survives malformed input while preserving every valid ticket.

You've learned how to:

  • Build a reliable Python automation that validates JSON tickets before processing them. Add per-record exception handling so one malformed record is logged without stopping the batch.
  • Persist normalized tickets in SQLite through parameterized SQL. Use UPSERT behavior to keep category counts stable across repeated runs.
  • Protect ticket classification through three focused unit tests. Run the same suite in GitHub Actions on an open pull request.
  • Complete the optional Secret Mission by introducing a controlled regression. Watch continuous integration catch the failure. Restore the shared branch with an auditable revert commit.

Ready to quiz yourself?