From 434bdafefdf0a95eed175dc89f27fbd91b19c91f Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 22:22:00 +0800 Subject: [PATCH 1/3] Plan MIL-010: gym accounts from a credentials file --- docs/artifact-registry.md | 4 +- docs/milestones/mil-010-credentials-file.md | 73 +++++++++++++++++++ docs/project-plan.md | 10 ++- .../rc-025-mil-010-credentials-file.md | 64 ++++++++++++++++ docs/sqa/traceability-matrix.md | 4 +- 5 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 docs/milestones/mil-010-credentials-file.md create mode 100644 docs/sqa/reviews/rc-025-mil-010-credentials-file.md diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 7bfe6cb..f4dbb3e 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -14,9 +14,9 @@ document of a type. `Primary File` may contain a glob (e.g. | BC | Business Case | docs/business-case.md | 002 | | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 | -| MIL | Milestone / Gateway | docs/milestones/*.md | 010 | +| MIL | Milestone / Gateway | docs/milestones/*.md | 011 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 024 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 026 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | diff --git a/docs/milestones/mil-010-credentials-file.md b/docs/milestones/mil-010-credentials-file.md new file mode 100644 index 0000000..6ed1cd3 --- /dev/null +++ b/docs/milestones/mil-010-credentials-file.md @@ -0,0 +1,73 @@ +# MIL-010 Gym accounts from a credentials file + +## Metadata +| Key | Value | +| --- | --- | +| ID | MIL-010 | +| CrossReference | [BC-001], [MIL-003] | +| Language | en | +| Domain | it | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version, requested by S01 in chat | pending | + +--- + +## Purpose + +Decide whether the bot can read the student and the admin account of the practice site from the personal file `.env.gym`, so that S01 does not set environment variables by hand before each run, while the file stays out of the repository and the tests. + +## Deliverable + +`credentials.py` that loads the student account and, load-only, the admin account from the environment, plus a small reader of the `.env.gym` file in the infrastructure layer that the command line merges under the real environment, unit tests with dummy values only, and README and wiki instructions. + +## Go / No-Go Criteria + +| # | Criterion (objectively checkable) | Go | No-Go | +| --- | --- | --- | --- | +| 1 | Unit tests show that the student and the admin account load from file values, and that a real environment variable wins over the file | 0 failures | A test fails or is missing | +| 2 | Unit tests show that a missing or empty variable raises an error that names the variable and never prints a value, and that the admin account is optional | 0 failures | A test fails or is missing | +| 3 | No test reads `.env` or `.env.gym`; `.env.gym` is in `.gitignore`; no password appears in the repository | `git grep` finds no real credential; tests use `tmp_path` | A real file is read or a credential is committed | +| 4 | All earlier tests pass with at least 80% coverage of the logic modules | Both true | Either false | +| 5 | The code review against `qc-programming-python.md` is `Go` | `RC-*` is `Go` | `No-Go` | + +## Dependencies + +| Depends on | Reason | +| --- | --- | +| MIL-003 | Changes the credentials handling that MIL-003 built | + +## Traceability + +| Business Case objective / KPI / user story | Reference | +| --- | --- | +| O2, O7 | [BC-001] | +| CN-004, CN-005, CN-009 | [SA-001] | + +## Ownership + +| Role | Stakeholder ID (SA) | +| --- | --- | +| Owner | S01 | +| Approving reviewer | S02 (AI as stand-in, autonomous mode, not independent) | + +## Target Date + +2026-11-06 — inside the planned window 2026-10-28 to 2026-11-06 of [PP-001]; the Business Case sets no deadline. + +## Tasks + +| # | Task | Summary | Needs its own Use Case/User Story? | Reference | +| --- | --- | --- | --- | --- | +| 1 | Load the student and the admin account | In `credentials.py` read `SNACK_AND_LIFT_EMAIL` and `SNACK_AND_LIFT_PASSWORD` (required) and `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD` (optional, load-only; nothing uses the admin yet). Errors name the variables, never a value. | No | O2 | +| 2 | Read the `.env.gym` file | Add a small reader (no new dependency) in `infrastructure/` for `KEY=VALUE` lines; the command line merges the file under the real environment, and a missing file is not an error. | No | O2, O7 | +| 3 | Test and document | Unit tests with dummy values in `tmp_path`; update the README and the Getting Started wiki pages (three languages) and keep `.env.gym` in `.gitignore`. | No | O7, O8 | + +--- + +[BC-001]: ../business-case.md +[MIL-003]: ./mil-003-login.md +[PP-001]: ../project-plan.md +[SA-001]: ../stakeholder-analysis.md diff --git a/docs/project-plan.md b/docs/project-plan.md index 01812db..cde0589 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -4,21 +4,21 @@ | Key | Value | | --- | --- | | ID | PP-001 | -| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009] | +| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009], [MIL-010] | | Language | en | | Domain | it | ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version: eight gateways | [50e8331] | | 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-009, a verification fix found in a real run (S01 accepted the plan in chat) | [3b9e381] | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-010, gym accounts from a credentials file (S01 asked for it in chat) | pending | --- ## Purpose -This plan schedules nine gateways (`MIL-001` to `MIL-009`) that take the Snack and Lift booking bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-06 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch. +This plan schedules ten gateways (`MIL-001` to `MIL-010`) that take the Snack and Lift booking bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-06 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch. ## Planning Assumptions @@ -41,6 +41,7 @@ This plan schedules nine gateways (`MIL-001` to `MIL-009`) that take the Snack a | Lecture step 9 | [MIL-007] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (technical tasks) | `retry`, resilient `login`, `book_class`, `get_my_bookings`, screenshot 9 | | | Documentation, wiki and release | [MIL-008] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (documentation) | README, Doxygen, wiki in en, da and ms, final checks | | | Verification fix | [MIL-009] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | Verification compares only the window of the run; new screenshots 7 to 9 | | +| Gym accounts from a file | [MIL-010] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | `.env.gym` credentials for the student and the admin account | | ```plantuml @startgantt @@ -79,7 +80,7 @@ Project starts 2026-10-28 ## Dependencies ``` -MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 → MIL-008 → MIL-009 +MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 → MIL-008 → MIL-009 → MIL-010 ``` A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. The end date then moves by the days spent on the rework; S01 decides a move of the end date. @@ -112,5 +113,6 @@ A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. [MIL-007]: ./milestones/mil-007-resilience.md [MIL-008]: ./milestones/mil-008-documentation.md [MIL-009]: ./milestones/mil-009-verification-fix.md +[MIL-010]: ./milestones/mil-010-credentials-file.md [50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3 [3b9e381]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/3b9e381cd42c56cc4543869c5b80c8e0014b4948 diff --git a/docs/sqa/reviews/rc-025-mil-010-credentials-file.md b/docs/sqa/reviews/rc-025-mil-010-credentials-file.md new file mode 100644 index 0000000..640586a --- /dev/null +++ b/docs/sqa/reviews/rc-025-mil-010-credentials-file.md @@ -0,0 +1,64 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-025 | +| CrossReference | [MIL-010], [QC-MIL-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-010] +- Checklist used: [QC-MIL-001] (`QC-MIL-001`) +- Scope: full review +- Language and domain: en / it +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable section names concrete files, runs or documents. | +| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has objectively checkable criteria with Go and No-Go columns. | +| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table names the preceding milestone and the reason. | +| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability cites O2 and O7 of BC-001 and the concerns of SA-001. | +| 5 | Milestone owner and approving reviewer are identified | Pass | Ownership names S01 as owner and S02 (AI as stand-in) as approving reviewer. | +| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target Date lies inside the window 2026-10-28 to 2026-11-06 of PP-001. | + +## Language and Domain Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Metadata has `Language` = en and `Domain` = it. | +| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. | +| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells of MIL-010 are English (stand-in assessment, see the verdict). | +| 4 | The register matches the one the registry gives for the artifact type | Pass | Register of the type is given in the registry (Languages); the text keeps to it. | +| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | Terms follow DICT-001: bot, booking, verification (stand-in assessment, see the verdict). | +| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Keys, headings, IDs and statuses are English. | +| 7 | No translated twin (`..md`) exists beside the document | Pass | No translated twin exists in `docs/`. | +| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | N-A | First version; no language change. | +| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | The stand-in reviewer reads English and checked the IT terms in context (stand-in assessment, see the verdict). | +| 10 | Abbreviations are spelled out on first use, in the stated language | Pass | Abbreviations are spelled out or defined where first used, or are the framework's own IDs. | + +## Overall Verdict + +Go — MIL-010 meets every Mandatory criterion. No blocking finding. The review is not independent: the AI authored and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary. Criteria 3, 5 and 9 of QC-LANG-001 were assessed by the stand-in only; the framework would hold them `Pending` for an independent language reviewer. S01 is asked to confirm them in the end-of-run summary. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | - | + +--- + +[MIL-010]: ../../milestones/mil-010-credentials-file.md +[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index b1c2f54..a4d81f1 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -23,7 +23,7 @@ Tracks backward and forward links between artifact instances so that the Busines | --- | --- | --- | --- | --- | --- | --- | | [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 | | [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 | -| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-009] | RC-003 | +| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-010] | RC-003 | | [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-004 | | [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-005 | | [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code from MIL-002 onward | RC-006 | @@ -37,6 +37,7 @@ Tracks backward and forward links between artifact instances so that the Busines | [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 | | [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 | | [MIL-009] | Milestone | en | it | [BC-001], [MIL-006] | - | RC-023 | +| [MIL-010] | Milestone | en | it | [BC-001], [MIL-003] | - | RC-025 | | Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 | | Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002], [ADR-0003] | - | RC-017 (Go-with-conditions) | | Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-018 (Go-with-conditions) | @@ -77,4 +78,5 @@ Tracks backward and forward links between artifact instances so that the Busines [MIL-007]: ../milestones/mil-007-resilience.md [MIL-008]: ../milestones/mil-008-documentation.md [MIL-009]: ../milestones/mil-009-verification-fix.md +[MIL-010]: ../milestones/mil-010-credentials-file.md [50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3 -- 2.54.0 From 42f05bdd0f5c300fbd162e8cf50075fc98964e1b Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 22:26:03 +0800 Subject: [PATCH 2/3] Load the gym accounts from the .env.gym file Task: MIL-010#1 Task: MIL-010#2 Task: MIL-010#3 --- README.md | 2 + .../rc-026-code-mil-010-credentials-file.md | 61 ++++++++++++++++ docs/wiki/Getting-Started-da.md | 4 ++ docs/wiki/Getting-Started-ms.md | 4 ++ docs/wiki/Getting-Started.md | 4 ++ src/snack_and_lift/application/credentials.py | 30 +++++++- src/snack_and_lift/cli.py | 24 ++++++- src/snack_and_lift/constants.py | 13 ++++ src/snack_and_lift/infrastructure/env_file.py | 44 ++++++++++++ tests/unit/test_cli.py | 71 +++++++++++++++++++ tests/unit/test_credentials.py | 54 +++++++++++++- tests/unit/test_env_file.py | 59 +++++++++++++++ 12 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 docs/sqa/reviews/rc-026-code-mil-010-credentials-file.md create mode 100644 src/snack_and_lift/infrastructure/env_file.py create mode 100644 tests/unit/test_env_file.py diff --git a/README.md b/README.md index 93320c1..6984265 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com" $env:SNACK_AND_LIFT_PASSWORD = "your password" ``` +Instead of setting variables by hand, you can put the same lines in a file named `.env.gym` in the project folder (`SNACK_AND_LIFT_EMAIL=...` and `SNACK_AND_LIFT_PASSWORD=...`, one per line). The bot reads it at start; `--env-file PATH` names another file, and a real environment variable wins over the file. The file `.env.gym` is ignored by git: never commit it. Two optional lines, `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD`, hold the admin account of the practice site; the bot loads them but does not use them yet. + ### Start the bot With the environment active: diff --git a/docs/sqa/reviews/rc-026-code-mil-010-credentials-file.md b/docs/sqa/reviews/rc-026-code-mil-010-credentials-file.md new file mode 100644 index 0000000..7e1ad87 --- /dev/null +++ b/docs/sqa/reviews/rc-026-code-mil-010-credentials-file.md @@ -0,0 +1,61 @@ +# SQA Review Record + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-026 | +| CrossReference | [MIL-010], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-010 | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-010] +- Checklist used: [QC-PY-001] (`QC-PY-001`) +- Scope: full review of the source and tests of MIL-010 +- Language and domain: n/a +- Language reviewer: none +- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, functions and variables are `snake_case`, classes `PascalCase`, constants `UPPER_SNAKE`; `ruff check` with the N rules passes. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (for example `retry`, `login`, `book_class`, `get_my_bookings`); no single-letter names outside comprehensions. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` leaves 0 files changed and `ruff check` reports 0 findings; there is no suppression. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function and method has annotations (ruff ANN rules pass), including `-> None`. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; specific exceptions are caught, and re-raised with `raise ... from` where the cause matters. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin is shadowed (ruff B and A-style checks pass). | +| 7 | Files, locks and connections are managed with context managers | Pass | Files and the browser are managed with `with`/`try-finally`; no unmanaged file handle exists. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings (`@brief`, `@param`, `@return`). | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No print calls; messages go through `logging`. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; ADR-0001 records that design artifacts are skipped. Classes follow ADR-0002. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 273 unit tests need no browser or network (new: the admin account, half-set variables, the env file parser, precedence of the environment, `--env-file`); an autouse fixture keeps every CLI test in `tmp_path`, so no test can read `.env` or `.env.gym`; 21 container tests pass; coverage of the non-infrastructure modules is 100%. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | No strict type checker is configured; annotations are complete and checked by ruff. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` declares Selenium (runtime) and pytest, pytest-cov, ruff (dev) with lower bounds; none is unused. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Each rule, constant and message exists once (constants in `constants.py`, rules in `domain/`). | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` and `application` import no Selenium; only `infrastructure` imports it; no import cycle exists. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | One reason to change per module; the adapter hides Selenium behind a port; fakes honour the port contract. | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layout follows `src/snack_and_lift/{domain,application,infrastructure}` and `tests/{unit,integration,e2e}`; there is no presentation layer for a command-line bot (ADR-0002). | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | All file and folder names are English lowercase with `_`, `-` and `.` only. | + +## Overall Verdict + +Go — the code of MIL-010 meets every Mandatory criterion of QC-PY-001. No blocking finding. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | - | + +--- + +[MIL-010]: ../../milestones/mil-010-credentials-file.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/docs/wiki/Getting-Started-da.md b/docs/wiki/Getting-Started-da.md index c9c1cf5..e72bd84 100644 --- a/docs/wiki/Getting-Started-da.md +++ b/docs/wiki/Getting-Started-da.md @@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com" $env:SNACK_AND_LIFT_PASSWORD = "your password" ``` +## Kontofilen + +I stedet for at sætte variabler i hånden kan du skrive de samme linjer i en fil `.env.gym` i projektmappen (`SNACK_AND_LIFT_EMAIL=...` og `SNACK_AND_LIFT_PASSWORD=...`). Botten læser den ved start; `--env-file PATH` peger på en anden fil, og en rigtig miljøvariabel vinder over filen. Git ignorerer `.env.gym`: læg den aldrig i repositoriet. De valgfrie `SNACK_AND_LIFT_ADMIN_EMAIL` og `SNACK_AND_LIFT_ADMIN_PASSWORD` gemmer øvelsessidens admin-konto; botten indlæser dem, men bruger dem ikke endnu. + ## Kør og test ```bash diff --git a/docs/wiki/Getting-Started-ms.md b/docs/wiki/Getting-Started-ms.md index e52e67e..a9f0708 100644 --- a/docs/wiki/Getting-Started-ms.md +++ b/docs/wiki/Getting-Started-ms.md @@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com" $env:SNACK_AND_LIFT_PASSWORD = "your password" ``` +## Fail akaun + +Daripada menetapkan pemboleh ubah secara manual, letakkan baris yang sama dalam fail `.env.gym` di folder projek (`SNACK_AND_LIFT_EMAIL=...` dan `SNACK_AND_LIFT_PASSWORD=...`). Bot membacanya semasa bermula; `--env-file PATH` menamakan fail lain, dan pemboleh ubah persekitaran sebenar mengatasi fail. Git mengabaikan `.env.gym`: jangan sekali-kali commit. `SNACK_AND_LIFT_ADMIN_EMAIL` dan `SNACK_AND_LIFT_ADMIN_PASSWORD` yang pilihan menyimpan akaun admin laman latihan; bot memuatkannya tetapi belum menggunakannya. + ## Jalankan dan uji ```bash diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 797291a..492eb97 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com" $env:SNACK_AND_LIFT_PASSWORD = "your password" ``` +## The account file + +Instead of setting variables by hand, put the same lines in a file `.env.gym` in the project folder (`SNACK_AND_LIFT_EMAIL=...` and `SNACK_AND_LIFT_PASSWORD=...`). The bot reads it at start; `--env-file PATH` names another file, and a real environment variable wins over the file. Git ignores `.env.gym`: never commit it. The optional `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD` hold the practice site's admin account; the bot loads them but does not use them yet. + ## Run and test ```bash diff --git a/src/snack_and_lift/application/credentials.py b/src/snack_and_lift/application/credentials.py index 31f2826..859c8ba 100644 --- a/src/snack_and_lift/application/credentials.py +++ b/src/snack_and_lift/application/credentials.py @@ -30,6 +30,34 @@ def load_credentials(environment: Mapping[str, str]) -> Credentials: if not email or not password: raise MissingCredentialsError( f"Set {constants.ACCOUNT_EMAIL_ENV} and {constants.ACCOUNT_PASSWORD_ENV} " - "in the environment (they must not be empty)" + "in the environment or the env file (they must not be empty)" ) return Credentials(email=email, password=password) + + +def load_admin_credentials(environment: Mapping[str, str]) -> Credentials | None: + """@brief Read the optional admin account from the environment. + + @param environment The environment variables, for example `os.environ`. + @return The credentials, or None when neither variable is set. + @exception MissingCredentialsError Only one of the two variables is set; the message + names the missing variable and never a value. + """ + email = environment.get(constants.ADMIN_EMAIL_ENV, "").strip() + password = environment.get(constants.ADMIN_PASSWORD_ENV, "") + if not email and not password: + return None + if not email: + raise MissingCredentialsError(_admin_missing(constants.ADMIN_EMAIL_ENV)) + if not password: + raise MissingCredentialsError(_admin_missing(constants.ADMIN_PASSWORD_ENV)) + return Credentials(email=email, password=password) + + +def _admin_missing(name: str) -> str: + """@brief Build the message for a half-set admin account. + + @param name The variable that is missing or empty. + @return The message; it names the variable only. + """ + return constants.MESSAGE_ADMIN_HALF_SET.format(name=name) diff --git a/src/snack_and_lift/cli.py b/src/snack_and_lift/cli.py index dd53f5b..4887eda 100644 --- a/src/snack_and_lift/cli.py +++ b/src/snack_and_lift/cli.py @@ -12,7 +12,11 @@ from pathlib import Path from snack_and_lift import constants from snack_and_lift.application.booking import book_next_tuesday, book_target_classes -from snack_and_lift.application.credentials import Credentials, load_credentials +from snack_and_lift.application.credentials import ( + Credentials, + load_admin_credentials, + load_credentials, +) from snack_and_lift.application.ports import GymPort from snack_and_lift.application.retry import Sleep from snack_and_lift.application.session import login_with_retry @@ -23,6 +27,7 @@ from snack_and_lift.domain.report import format_detail_list, format_summary from snack_and_lift.domain.verification import format_verification from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir from snack_and_lift.infrastructure.console_recorder import ConsoleRecorder +from snack_and_lift.infrastructure.env_file import read_env_file from snack_and_lift.infrastructure.selenium_gym import SeleniumGym ## Builds the gym site from (headless, profile folder, keep open). @@ -69,7 +74,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.", epilog=( "exit codes: 0 done, 1 a step failed (also when every retry failed), " - "2 credentials missing, 3 the My Bookings page does not match the bookings made" + "2 credentials missing in the environment or the env file, " + "3 the My Bookings page does not match the bookings made" ), ) parser.add_argument( @@ -110,6 +116,16 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: f"drops a request (default: {constants.RETRY_COUNT}, at least 1)" ), ) + parser.add_argument( + "--env-file", + type=Path, + default=Path(constants.ENV_FILE_NAME), + metavar="PATH", + help=( + "file with the gym accounts as KEY=VALUE lines " + f"(default: ./{constants.ENV_FILE_NAME}); a real environment variable wins" + ), + ) args = parser.parse_args(argv) if args.keep_open is None: args.keep_open = not args.headless @@ -242,7 +258,9 @@ def _main( _use_utf8_output() args = parse_args(argv) try: - credentials = load_credentials(os.environ) + environment = {**read_env_file(args.env_file), **os.environ} + credentials = load_credentials(environment) + load_admin_credentials(environment) # load-only for now; nothing uses it yet except MissingCredentialsError as error: logger.error("%s", error) return 2 diff --git a/src/snack_and_lift/constants.py b/src/snack_and_lift/constants.py index cdc3449..ca5ea40 100644 --- a/src/snack_and_lift/constants.py +++ b/src/snack_and_lift/constants.py @@ -70,6 +70,19 @@ ACCOUNT_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_EMAIL" ## Name of the environment variable that holds the account password. ACCOUNT_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_PASSWORD" +## Names of the environment variables that hold the admin account (read, not used yet). +ADMIN_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_ADMIN_EMAIL" +ADMIN_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_ADMIN_PASSWORD" + +## Error for an admin account with one variable only; {name} is the missing variable. +MESSAGE_ADMIN_HALF_SET: Final[str] = ( + "Set {name} too: the admin account needs both " + "SNACK_AND_LIFT_ADMIN_EMAIL and SNACK_AND_LIFT_ADMIN_PASSWORD" +) + +## Default file with the gym accounts, relative to the current directory. +ENV_FILE_NAME: Final[str] = ".env.gym" + ## Folder where screenshots of a container run are written. SCREENSHOT_DIR: Final[str] = "docs/images" diff --git a/src/snack_and_lift/infrastructure/env_file.py b/src/snack_and_lift/infrastructure/env_file.py new file mode 100644 index 0000000..793fb1b --- /dev/null +++ b/src/snack_and_lift/infrastructure/env_file.py @@ -0,0 +1,44 @@ +"""@file env_file.py +@brief Reads `KEY=VALUE` lines from an env file (never logs a value). +""" + +from pathlib import Path + +## Prefix a line may carry so the file can also be sourced by a shell. +_EXPORT_PREFIX = "export " + +## Quote characters that may surround a value. +_QUOTES = ("'", '"') + + +def _unquote(value: str) -> str: + """@brief Strip one pair of matching surrounding quotes. + + @param value The text after the `=`. + @return The value without the surrounding pair, otherwise unchanged. + """ + if len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTES: + return value[1:-1] + return value + + +def read_env_file(path: Path) -> dict[str, str]: + """@brief Read the variables of an env file. + + Blank lines, `#` comments, lines without `=` and an empty key are skipped. + + @param path The file. + @return The variables; empty when the file does not exist. + """ + if not path.is_file(): + return {} + variables: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, _, value = stripped.partition("=") + key = key.strip().removeprefix(_EXPORT_PREFIX).strip() + if key: + variables[key] = _unquote(value.strip()) + return variables diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 1c91472..b9babeb 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -18,6 +18,12 @@ DUMMY_EMAIL = "dummy@example.invalid" DUMMY_PASSWORD = "dummy-password" +@pytest.fixture(autouse=True) +def isolated_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Run from an empty folder so the default env file is never a real one.""" + monkeypatch.chdir(tmp_path) + + @pytest.fixture def credentials_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL) @@ -324,3 +330,68 @@ def test_main_passes_options_to_the_factory(tmp_path: Path) -> None: main(["--headless", "--profile-dir", str(tmp_path)], factory) assert received == [(True, tmp_path, False)] + + +def _write_env_file(path: Path, email: str, password: str) -> Path: + path.write_text( + f"{constants.ACCOUNT_EMAIL_ENV}={email}\n" + f"export {constants.ACCOUNT_PASSWORD_ENV}='{password}'\n", + encoding="utf-8", + ) + return path + + +def test_env_file_defaults_to_the_gym_file_name() -> None: + assert parse_args([]).env_file == Path(constants.ENV_FILE_NAME) + + +def test_env_file_option_is_read(tmp_path: Path) -> None: + assert parse_args(["--env-file", str(tmp_path / "x.env")]).env_file == tmp_path / "x.env" + + +def test_main_reads_the_account_from_the_env_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False) + monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False) + env_file = _write_env_file(tmp_path / "accounts.env", "file@example.invalid", "file-pass") + gym = FakeGym() + + code = main(["--env-file", str(env_file)], lambda *_: gym) + + assert code == 0 + assert gym.logins == [("file@example.invalid", "file-pass")] + + +def test_main_lets_a_real_environment_variable_win_over_the_env_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL) + monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD) + env_file = _write_env_file(tmp_path / "accounts.env", "file@example.invalid", "file-pass") + gym = FakeGym() + + code = main(["--env-file", str(env_file)], lambda *_: gym) + + assert code == 0 + assert gym.logins == [(DUMMY_EMAIL, DUMMY_PASSWORD)] + + +def test_main_exits_2_when_neither_environment_nor_file_has_the_account( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False) + monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False) + + assert main(["--env-file", str(tmp_path / "absent.env")], lambda *_: FakeGym()) == 2 + + +def test_main_exits_2_when_the_admin_account_is_half_set( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL) + monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD) + monkeypatch.setenv(constants.ADMIN_EMAIL_ENV, "admin@example.invalid") + monkeypatch.delenv(constants.ADMIN_PASSWORD_ENV, raising=False) + + assert main(["--env-file", str(tmp_path / "absent.env")], lambda *_: FakeGym()) == 2 diff --git a/tests/unit/test_credentials.py b/tests/unit/test_credentials.py index db7c9d3..ea117dd 100644 --- a/tests/unit/test_credentials.py +++ b/tests/unit/test_credentials.py @@ -5,7 +5,11 @@ import pytest from snack_and_lift import constants -from snack_and_lift.application.credentials import Credentials, load_credentials +from snack_and_lift.application.credentials import ( + Credentials, + load_admin_credentials, + load_credentials, +) from snack_and_lift.domain.errors import MissingCredentialsError DUMMY_PASSWORD = "dummy-secret-value" @@ -42,3 +46,51 @@ def test_load_credentials_rejects_missing_or_empty(environment: dict[str, str]) def test_repr_hides_the_password() -> None: assert DUMMY_PASSWORD not in repr(Credentials("dummy@example.invalid", DUMMY_PASSWORD)) + + +def test_load_admin_credentials_reads_both_variables() -> None: + environment = { + constants.ADMIN_EMAIL_ENV: " admin@example.invalid ", + constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD, + } + + assert load_admin_credentials(environment) == Credentials( + "admin@example.invalid", DUMMY_PASSWORD + ) + + +@pytest.mark.parametrize( + "environment", + [ + {}, + {constants.ADMIN_EMAIL_ENV: "", constants.ADMIN_PASSWORD_ENV: ""}, + {constants.ADMIN_EMAIL_ENV: " "}, + ], +) +def test_load_admin_credentials_is_none_when_not_set(environment: dict[str, str]) -> None: + assert load_admin_credentials(environment) is None + + +@pytest.mark.parametrize( + ("environment", "missing"), + [ + ({constants.ADMIN_EMAIL_ENV: "admin@example.invalid"}, constants.ADMIN_PASSWORD_ENV), + ( + {constants.ADMIN_EMAIL_ENV: "admin@example.invalid", constants.ADMIN_PASSWORD_ENV: ""}, + constants.ADMIN_PASSWORD_ENV, + ), + ({constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD}, constants.ADMIN_EMAIL_ENV), + ( + {constants.ADMIN_EMAIL_ENV: "", constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD}, + constants.ADMIN_EMAIL_ENV, + ), + ], +) +def test_load_admin_credentials_rejects_half_set(environment: dict[str, str], missing: str) -> None: + with pytest.raises(MissingCredentialsError) as caught: + load_admin_credentials(environment) + + message = str(caught.value) + assert message.startswith(f"Set {missing} too") + assert DUMMY_PASSWORD not in message + assert "admin@example.invalid" not in message diff --git a/tests/unit/test_env_file.py b/tests/unit/test_env_file.py new file mode 100644 index 0000000..5e5e88c --- /dev/null +++ b/tests/unit/test_env_file.py @@ -0,0 +1,59 @@ +"""@file test_env_file.py +@brief The env file reader parses KEY=VALUE lines (dummy values in tmp_path only). +""" + +from pathlib import Path + +from snack_and_lift.infrastructure.env_file import read_env_file + + +def _read(tmp_path: Path, text: str) -> dict[str, str]: + path = tmp_path / "accounts.env" + path.write_text(text, encoding="utf-8") + return read_env_file(path) + + +def test_read_env_file_returns_empty_when_file_is_missing(tmp_path: Path) -> None: + assert read_env_file(tmp_path / "absent.env") == {} + + +def test_read_env_file_reads_pairs(tmp_path: Path) -> None: + assert _read(tmp_path, "A=1\nB=two\n") == {"A": "1", "B": "two"} + + +def test_read_env_file_skips_blank_lines_comments_and_lines_without_equals( + tmp_path: Path, +) -> None: + text = "\n# comment\n # indented comment\nnot a pair\nA=1\n" + + assert _read(tmp_path, text) == {"A": "1"} + + +def test_read_env_file_accepts_export_prefix(tmp_path: Path) -> None: + assert _read(tmp_path, "export A=1\n") == {"A": "1"} + + +def test_read_env_file_strips_whitespace_around_key(tmp_path: Path) -> None: + assert _read(tmp_path, " A =1\n") == {"A": "1"} + + +def test_read_env_file_strips_one_pair_of_quotes(tmp_path: Path) -> None: + text = "A=\"dummy one\"\nB='dummy two'\nC=\"'both'\"\n" + + assert _read(tmp_path, text) == {"A": "dummy one", "B": "dummy two", "C": "'both'"} + + +def test_read_env_file_keeps_unmatched_quotes_and_lone_quote(tmp_path: Path) -> None: + assert _read(tmp_path, 'A="dummy\'\nB="\n') == {"A": "\"dummy'", "B": '"'} + + +def test_read_env_file_keeps_equals_and_hash_inside_value(tmp_path: Path) -> None: + assert _read(tmp_path, "A=x=y#z\n") == {"A": "x=y#z"} + + +def test_read_env_file_ignores_empty_key(tmp_path: Path) -> None: + assert _read(tmp_path, "=value\n") == {} + + +def test_read_env_file_keeps_empty_value(tmp_path: Path) -> None: + assert _read(tmp_path, "A=\n") == {"A": ""} -- 2.54.0 From da3f999c6f002f0d4993a93a9e023b7fe28e2806 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 22:26:18 +0800 Subject: [PATCH 3/3] Resolve pending commit links --- docs/milestones/mil-010-credentials-file.md | 3 ++- docs/project-plan.md | 3 ++- docs/sqa/reviews/rc-025-mil-010-credentials-file.md | 3 ++- docs/sqa/reviews/rc-026-code-mil-010-credentials-file.md | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/milestones/mil-010-credentials-file.md b/docs/milestones/mil-010-credentials-file.md index 6ed1cd3..e59e357 100644 --- a/docs/milestones/mil-010-credentials-file.md +++ b/docs/milestones/mil-010-credentials-file.md @@ -11,7 +11,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version, requested by S01 in chat | pending | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version, requested by S01 in chat | [434bdaf] | --- @@ -71,3 +71,4 @@ Decide whether the bot can read the student and the admin account of the practic [MIL-003]: ./mil-003-login.md [PP-001]: ../project-plan.md [SA-001]: ../stakeholder-analysis.md +[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f diff --git a/docs/project-plan.md b/docs/project-plan.md index cde0589..ae84f05 100644 --- a/docs/project-plan.md +++ b/docs/project-plan.md @@ -12,7 +12,7 @@ | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | | 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-009, a verification fix found in a real run (S01 accepted the plan in chat) | [3b9e381] | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-010, gym accounts from a credentials file (S01 asked for it in chat) | pending | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-010, gym accounts from a credentials file (S01 asked for it in chat) | [434bdaf] | --- @@ -116,3 +116,4 @@ A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. [MIL-010]: ./milestones/mil-010-credentials-file.md [50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3 [3b9e381]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/3b9e381cd42c56cc4543869c5b80c8e0014b4948 +[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f diff --git a/docs/sqa/reviews/rc-025-mil-010-credentials-file.md b/docs/sqa/reviews/rc-025-mil-010-credentials-file.md index 640586a..961d148 100644 --- a/docs/sqa/reviews/rc-025-mil-010-credentials-file.md +++ b/docs/sqa/reviews/rc-025-mil-010-credentials-file.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review | pending | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review | [434bdaf] | --- @@ -62,3 +62,4 @@ Go — MIL-010 meets every Mandatory criterion. No blocking finding. The review [MIL-010]: ../../milestones/mil-010-credentials-file.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md +[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f diff --git a/docs/sqa/reviews/rc-026-code-mil-010-credentials-file.md b/docs/sqa/reviews/rc-026-code-mil-010-credentials-file.md index 7e1ad87..9a164e6 100644 --- a/docs/sqa/reviews/rc-026-code-mil-010-credentials-file.md +++ b/docs/sqa/reviews/rc-026-code-mil-010-credentials-file.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-010 | pending | +| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-010 | [42f05bd] | --- @@ -59,3 +59,4 @@ Go — the code of MIL-010 meets every Mandatory criterion of QC-PY-001. No bloc [MIL-010]: ../../milestones/mil-010-credentials-file.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[42f05bd]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/42f05bdd0f5c300fbd162e8cf50075fc98964e1b -- 2.54.0