diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f4bb467 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,26 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: ["main"] + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Upload static site + uses: actions/upload-pages-artifact@v2 + with: + path: . + + - name: Deploy to GitHub Pages + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..02a8452 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Add Release Notes to README + +on: + pull_request: + types: [opened] + +permissions: + contents: write + pull-requests: write +jobs: + update_readme: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Increase version number + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + python deployment/update_version_number.py + + - name: Update release notes + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + python deployment/update_release_notes.py + + - name: Commit changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "Add release notes for PR #${{ github.event.pull_request.number }}" || echo "No changes to commit" + git push diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fe6ece7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Python CI Pipeline + +on: + push: + branches: ["development"] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: pytest -q diff --git a/.vscode/settings.json b/.vscode/settings.json index 8fc7789..37e28eb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,35 @@ "coverage-gutters.showLineCoverage": true, "python.testing.pytestArgs": [ "tests" - ] + ], + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/*.pyc": { + "when": "$(basename).py" + }, + "**/__pycache__": true, + "**/app.egg-info": true, + "**/env": true, + "**/.env.dist": true, + "**/*.log": true, + "**/.0": true + }, + "workbench.colorCustomizations": { + "tab.activeBorder": "#ff0000", + "tab.unfocusedActiveBorder": "#000000", + "tab.activeBackground": "#045980" + }, + "workbench.editor.wrapTabs": true, + "debug.toolBarLocation": "docked", + "python.formatting.provider": "autopep8", + "editor.formatOnSave": true, + "[python]": { + "editor.defaultFormatter": "ms-python.autopep8" + }, + "python.REPL.enableREPLSmartSend": false } \ No newline at end of file diff --git a/README.md b/README.md index f283c09..ada291d 100644 --- a/README.md +++ b/README.md @@ -125,3 +125,40 @@ After you created the file and copied the action above, push the change to the r * **Task C**: Add a test case to either test file and push your changes to your repository. Check the run of the action to see what status is finishes with. * **Task D**: You will notice that the action shows a red x after it has completed its run. Investigate why that action failed. Resolve the issue and push to the repository to trigger the action again. + + + + +Task 1 + +Automated testing on commit + +a) Create a new branch called `test` and push it to the repository. Then create a PR for that branch. +b) Observe the test results. If there is a bug, update the test case to match the value that is returned by the function. +c) Push the updated code. Observe the test results. + + + +Task 2 + +Add release notes to README + +a) Create a new branch called `release` and push it to the repository. +b) Then create a PR for that branch and add a bulleted list of changes. +b) Check that the release notes are added to the `README.md` file. + + +Task 3 + +Deploy on merge + +a) Create a new branch called `deploy1` and push it to the repository. Then create a PR for that branch. +b) Merge the PR into `main`. +c) Navigate to the deployed app +d) Change the app title and create a new PR. Merge the PR into `main`. Navigate to the deployed app again.Observe the changes. + + +## Release Notes — v2.0.0 — Major changes (2025-12-06) + +- Big deal changes +- Minor bug fixes diff --git a/__version__.py b/__version__.py new file mode 100644 index 0000000..c046acc --- /dev/null +++ b/__version__.py @@ -0,0 +1,7 @@ +# coding: utf-8 + +__title__ = 'enpm611-ghactions' +__version__ = '1.3.8' +__author__ = 'ENPM611' +__url__ = 'https://github.com/enpm611/github-actions' +__description__ = ("Exercise to use GitHub Actions for CI/CD task.") diff --git a/app/string_utils.py b/app/string_utils.py deleted file mode 100644 index 3a57a95..0000000 --- a/app/string_utils.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Utility to perform advanced string comparisons. -""" - -from difflib import SequenceMatcher -import textdistance - - -def calculate_match_degree(txt1:str,txt2:str) -> float: - """ - Given two strings, finds the longest common substring. - Returns the degree of the match based on that longest - substring. - """ - match = SequenceMatcher(None, txt1, txt2).find_longest_match() - return match.size/max(len(txt1),len(txt2)) - -def calcualte_text_distance(txt1:str,txt2:str) -> float: - """ - Uses a text distance metric to calculate the similarity - between two texts. This is not a sub-string match but a - comparison of similar terms occurring in both texts. - """ - algs = textdistance.algorithms - return algs.levenshtein.normalized_similarity(txt1, txt2) \ No newline at end of file diff --git a/deployment/update_release_notes.py b/deployment/update_release_notes.py new file mode 100644 index 0000000..20e6112 --- /dev/null +++ b/deployment/update_release_notes.py @@ -0,0 +1,50 @@ + + +import os +import re +from datetime import datetime +from pathlib import Path + + +def get_current_version() -> str: + # Read version file + VERSION_FILE = Path("__version__.py") + version_text = VERSION_FILE.read_text() + # Extract current version string + match = re.search(r"__version__\s*=\s*['\"](\d+\.\d+\.\d+)['\"]", version_text) + if not match: + raise ValueError("Could not find __version__ in file.") + return match.group(1) + + +def update_release_notes() -> None: + + # Read the environment variables that were passed in from + # the GitHub Action workflow. + pr_title: str = os.environ.get("PR_TITLE", "").strip() + pr_body: str = os.environ.get("PR_BODY", "").strip() + # Check for missing environment variables + if not pr_title: + raise ValueError("PR_TITLE environment variable is missing.") + + # Get current version from version file + version: str = get_current_version() + + # Create release note entry + date_str: str = datetime.utcnow().strftime("%Y-%m-%d") + entry_lines: list[str] = [ + "", + f"## Release Notes — v{version} — {pr_title} ({date_str})", + "", + pr_body, + "", + ] + + # Append to README + with open("README.md", "a", encoding="utf-8") as f: + f.write("\n".join(entry_lines)) + + print("README updated with release notes.") + +if __name__ == "__main__": + update_release_notes() diff --git a/deployment/update_version_number.py b/deployment/update_version_number.py new file mode 100644 index 0000000..7ad249a --- /dev/null +++ b/deployment/update_version_number.py @@ -0,0 +1,70 @@ + +""" +Bumps the version number in the `__version__.py` file when a Pull +Request is created. +""" + + +import re +import os +from pathlib import Path +from typing import Any, Literal + + +def get_current_version(version_text:str) -> str: + # Extract current version string + match = re.search(r"__version__\s*=\s*['\"](\d+\.\d+\.\d+)['\"]", version_text) + if not match: + raise ValueError("Could not find __version__ in file.") + return match.group(1) + +def get_release_type() -> Literal["patch", "minor", "major"]: + """ Determine the type of release based on the PR title """ + + # Read release title from env + pr_title: str = os.environ.get("PR_TITLE", "").strip() + # Determine release type based on terms in title + if "minor" in pr_title.lower(): + return "minor" + elif "major" in pr_title.lower(): + return "major" + else: + return "patch" + +def bump_version(): + + # Read version file + VERSION_FILE = Path("__version__.py") + version_text = VERSION_FILE.read_text() + + old_version: str = get_current_version(version_text) + major, minor, patch = map(int, old_version.split(".")) + + # Increment based on the requested level + level: Literal['patch', 'minor', 'major'] = get_release_type() + if level == "patch": + patch += 1 + elif level == "minor": + minor += 1 + patch = 0 + elif level == "major": + major += 1 + minor = 0 + patch = 0 + else: + raise ValueError("level must be 'major', 'minor', or 'patch'") + + new_version = f"{major}.{minor}.{patch}" + + # Replace the version string in the file + new_text = version_text.replace(old_version,new_version) + + VERSION_FILE.write_text(new_text) + print(f"Version bumped to {new_version}") + return new_version + + + + +if __name__ == "__main__": + bump_version() diff --git a/requirements.txt b/requirements.txt index 282786f..f9ddc40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -pytest==8.3.3 -textdistance==4.6.3 \ No newline at end of file +pytest==8.3.3 \ No newline at end of file diff --git a/spacedelivery/delivery.py b/spacedelivery/delivery.py new file mode 100644 index 0000000..07394dd --- /dev/null +++ b/spacedelivery/delivery.py @@ -0,0 +1,69 @@ +import math +from typing import Callable + + +class DeliveryMode: + NORMAL = 1 + TURBO = 2 + HYPERJUMP = 3 + + +MODE_SPEED = { + DeliveryMode.NORMAL: 10, # light-minutes per hour + DeliveryMode.TURBO: 25, + DeliveryMode.HYPERJUMP: 100, +} + +# Planet distances from “Galactic Pizza Hub” in light-minutes +PLANET_DISTANCE = { + "Mercury": 3, + "Venus": 2, + "Earth": 0.5, + "Mars": 4, + "Jupiter": 25, + "Saturn": 50, + "Neptune": 200, +} + + +def estimate_delivery_time( + planet: str, + mode: int = DeliveryMode.NORMAL, + surge_load: float = 1.0, + weather_delay_fn: Callable[[], float] = lambda: 0.0, +) -> float: + """ + Estimate total delivery time in hours. + + - `planet`: destination planet name + - `mode`: delivery mode speed multiplier + - `surge_load`: factor (>=1); simulates high-order load + - `weather_delay_fn`: returns number of hours to add (stochastic) + """ + + if planet not in PLANET_DISTANCE: + raise ValueError(f"Unknown destination: {planet}") + + if surge_load < 1: + raise ValueError("surge_load must be >= 1") + + if mode not in MODE_SPEED: + raise ValueError("Invalid delivery mode") + + distance = PLANET_DISTANCE[planet] # light-minutes + + # Base time + speed = MODE_SPEED[mode] + travel_time = distance / speed + + # If distance is extremely large, apply nonlinear fatigue penalty + if distance > 100: + travel_time *= 1.2 # 20% fatigue penalty + + # Weather delay (provided by injected function) + weather_delay = weather_delay_fn() + if weather_delay < 0: + raise ValueError("weather_delay_fn returned negative delay") + + total_time = (travel_time * surge_load) + weather_delay + return round(total_time, 2) diff --git a/spacedelivery/web/estimator.js b/spacedelivery/web/estimator.js new file mode 100644 index 0000000..d456db9 --- /dev/null +++ b/spacedelivery/web/estimator.js @@ -0,0 +1,70 @@ +const PLANET_DISTANCE = { + Mercury: 3, + Venus: 2, + Earth: 0.5, + Mars: 4, + Jupiter: 25, + Saturn: 50, + Neptune: 200, +}; + +const MODE_SPEED = { + 1: 10, // NORMAL + 2: 25, // TURBO + 3: 100, // HYPERJUMP +}; + +// Populate planet dropdown +window.onload = () => { + const select = document.getElementById("planet"); + Object.keys(PLANET_DISTANCE).forEach((planet) => { + const option = document.createElement("option"); + option.value = planet; + option.textContent = planet; + select.appendChild(option); + }); +}; + +function estimateDeliveryTime(planet, mode, surgeLoad, weatherDelay) { + if (!(planet in PLANET_DISTANCE)) { + throw new Error("Unknown destination"); + } + if (!(mode in MODE_SPEED)) { + throw new Error("Invalid delivery mode"); + } + if (surgeLoad < 1) { + throw new Error("surgeLoad must be >= 1"); + } + if (weatherDelay < 0) { + throw new Error("weatherDelay must be >= 0"); + } + + const distance = PLANET_DISTANCE[planet]; + const speed = MODE_SPEED[mode]; + + let travelTime = distance / speed; + + // Fatigue penalty for extreme distances + if (distance > 100) { + travelTime *= 1.2; + } + + const total = travelTime * surgeLoad + weatherDelay; + return Math.round(total * 100) / 100; +} + +// Wire UI to estimator +document.getElementById("estimate").onclick = () => { + const planet = document.getElementById("planet").value; + const mode = Number(document.getElementById("mode").value); + const surge = Number(document.getElementById("surge").value); + const weather = Number(document.getElementById("weather").value); + + try { + const time = estimateDeliveryTime(planet, mode, surge, weather); + document.getElementById("result").textContent = + `Estimated delivery time: ${time} hours`; + } catch (err) { + document.getElementById("result").textContent = "Error: " + err.message; + } +}; diff --git a/spacedelivery/web/index.html b/spacedelivery/web/index.html new file mode 100644 index 0000000..df4c568 --- /dev/null +++ b/spacedelivery/web/index.html @@ -0,0 +1,39 @@ + + + + + + Galactic Pizza Delivery Estimator + + + + +
+

🚀 Galactic Pizza Delivery Estimator

+ +
+ + + + + + + + + + + + + +
+ +
+
+ + + + diff --git a/spacedelivery/web/style.css b/spacedelivery/web/style.css new file mode 100644 index 0000000..26363cd --- /dev/null +++ b/spacedelivery/web/style.css @@ -0,0 +1,54 @@ +body { + font-family: Arial, sans-serif; + background: #0f0f22; + color: #fff; + margin: 0; + padding: 0; +} + +.container { + max-width: 500px; + margin: 50px auto; + background: #1c1c3c; + padding: 20px; + border-radius: 10px; +} + +h1 { + text-align: center; + margin-bottom: 20px; +} + +label { + font-weight: bold; + margin-top: 10px; + display: block; +} + +input, select { + width: 100%; + padding: 8px; + margin-top: 4px; + border-radius: 5px; + border: none; +} + +button { + width: 100%; + padding: 10px; + margin-top: 15px; + background: #00b7ff; + border: none; + border-radius: 5px; + font-size: 16px; + cursor: pointer; +} + +button:hover { + background: #0090cc; +} + +.result { + margin-top: 20px; + font-size: 18px; +} diff --git a/tests/test_delivery.py b/tests/test_delivery.py new file mode 100644 index 0000000..94ec226 --- /dev/null +++ b/tests/test_delivery.py @@ -0,0 +1,88 @@ + +# Make sure the test finds the application code +import os +import sys +sys.path.insert(0, os.path.abspath('.')) + +import pytest +from spacedelivery.delivery import estimate_delivery_time, DeliveryMode + + +def no_weather(): + return 0.0 + + +def fixed_weather(): + return 2.5 + + +# ------------------------------ +# Black-box functional tests +# ------------------------------ + +def test_basic_delivery_normal_mode(): + """Earth is 0.5 lm away; NORMAL speed 10 lm/hr → 0.05 hr.""" + assert estimate_delivery_time("Earth", DeliveryMode.NORMAL, 1.0, no_weather) == 0.05 + + +def test_turbo_delivery(): + """Mars (4 lm) at TURBO speed 25 → 0.16.""" + assert estimate_delivery_time("Mars", DeliveryMode.TURBO, 1.0, no_weather) == 0.16 + + +def test_weather_delay_added(): + """Jupiter (25 lm at 10 lm/hr = 2.5 hr) + 2.5 hr weather.""" + assert estimate_delivery_time("Jupiter", DeliveryMode.NORMAL, 1.0, fixed_weather) == 5.0 + + +# ------------------------------ +# Error-handling tests +# ------------------------------ + +def test_invalid_planet(): + with pytest.raises(ValueError): + estimate_delivery_time("Pluto", DeliveryMode.NORMAL, 1.0, no_weather) + + +def test_invalid_mode(): + with pytest.raises(ValueError): + estimate_delivery_time("Earth", 999, 1.0, no_weather) + + +def test_invalid_surge_load(): + with pytest.raises(ValueError): + estimate_delivery_time("Earth", DeliveryMode.NORMAL, 0.5, no_weather) + + +def test_negative_weather_delay(): + with pytest.raises(ValueError): + estimate_delivery_time("Earth", DeliveryMode.NORMAL, 1.0, lambda: -1) + + +# ------------------------------ +# Boundary value tests +# ------------------------------ + +def test_boundary_no_fatigue_penalty(): + """Distance = 50 → below 100, so no fatigue penalty.""" + assert estimate_delivery_time("Saturn", DeliveryMode.HYPERJUMP, 1.0, no_weather) == 0.5 + + +def test_fatigue_penalty_kicks_in(): + """Neptune = 200 lm → fatigue penalty applied (×1.2).""" + base = 200 / 100 # HYPERJUMP speed + expected = round((base * 1.2), 2) + assert estimate_delivery_time("Neptune", DeliveryMode.HYPERJUMP, 1.0, no_weather) == expected + + +# ------------------------------ +# Category partitioning tests +# ------------------------------ + +@pytest.mark.parametrize("planet", ["Earth", "Mars"]) +@pytest.mark.parametrize("mode", [DeliveryMode.NORMAL, DeliveryMode.TURBO]) +@pytest.mark.parametrize("surge", [1.0, 1.5]) +def test_category_partitioning(planet, mode, surge): + """Planet × mode × surge cartesian test.""" + result = estimate_delivery_time(planet, mode, surge, no_weather) + assert result > 0 diff --git a/tests/test_string_distance.py b/tests/test_string_distance.py deleted file mode 100644 index 61a6bd3..0000000 --- a/tests/test_string_distance.py +++ /dev/null @@ -1,23 +0,0 @@ - -# Make sure the test finds the application code -import os -import sys -sys.path.insert(0, os.path.abspath('.')) - -import unittest -from app import string_utils - -class TestStringDistance(unittest.TestCase): - - def test_similar_text(self): - score:float = string_utils.calcualte_text_distance("Software engineering is the cornerstone of a successful software project.","Building engineering is the cornerstone of a successful building project.") - self.assertGreater(score, 0.7) - self.assertLess(score,0.8) - - def test_non_similar_text(self): - score:float = string_utils.calcualte_text_distance("Requirements are a first level entity in Agile development","Microservices have helped address scalibility issues in today's cloud environments.") - self.assertGreater(score, 0.3) - self.assertLess(score,0.4) - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/test_substring.py b/tests/test_substring.py deleted file mode 100644 index 70d2b40..0000000 --- a/tests/test_substring.py +++ /dev/null @@ -1,21 +0,0 @@ - -# Make sure the test finds the application code -import os -import sys -sys.path.insert(0, os.path.abspath('.')) - -import unittest -from app import string_utils - -class TestSubstring(unittest.TestCase): - - def test_equal_substrings(self): - score:float = string_utils.calculate_match_degree("software engineering","software engineering") - self.assertEqual(score, 1) - - def test_partial_substrings(self): - score:float = string_utils.calculate_match_degree("software engineering","building engineering") - self.assertEqual(score, 0.9) - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/.github/workflows/autotag.yaml b/workflows-archive/autotag.yaml similarity index 100% rename from .github/workflows/autotag.yaml rename to workflows-archive/autotag.yaml diff --git a/.github/workflows/autotest.yml b/workflows-archive/autotest.yml similarity index 100% rename from .github/workflows/autotest.yml rename to workflows-archive/autotest.yml diff --git a/workflows-archive/build_docker.yml b/workflows-archive/build_docker.yml new file mode 100644 index 0000000..a470636 --- /dev/null +++ b/workflows-archive/build_docker.yml @@ -0,0 +1,22 @@ +name: Build and save Docker image artifact + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build the Docker image + run: docker build . --tag spacedelivery:latest + + - name: Save image to a tar file + run: docker save spacedelivery:latest -o ${{ runner.temp }}/spacedelivery.tar + + - name: Upload Docker image artifact + uses: actions/upload-artifact@v4 + with: + name: docker-image-artifact + path: ${{ runner.temp }}/spacedelivery.tar \ No newline at end of file diff --git a/config/issue-rules.yml b/workflows-archive/config/issue-rules.yml similarity index 100% rename from config/issue-rules.yml rename to workflows-archive/config/issue-rules.yml diff --git a/.github/workflows/triage_issues.yml b/workflows-archive/triage_issues.yml similarity index 100% rename from .github/workflows/triage_issues.yml rename to workflows-archive/triage_issues.yml