Compare commits

..
Author SHA1 Message Date
Tirsvad c09106961c Merge pull request 'Update the framework to 0.18.0+' (#60) from update-framework into main
Publish accepted milestones / publish (push) Failing after 4s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-28 17:11:26 +01:00
Tirsvad 0f4c6989a4 Resolve pending commit links 2026-10-29 00:11:16 +08:00
Tirsvad 2cfcd02588 Update the framework to 150819a, follow the new milestone workflow template, review RC-027 2026-10-29 00:11:15 +08:00
Tirsvad 332d65255c Merge pull request 'MIL-010: gym accounts from a credentials file' (#59) from mil-010-credentials-file into main
Publish accepted milestones / publish (push) Failing after 4s
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 16s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
Publish wiki / publish-wiki (push) Successful in 14s
Publish wiki / wiki (push) Successful in 14s
2026-10-28 15:26:25 +01:00
Tirsvad da3f999c6f Resolve pending commit links 2026-10-28 22:26:18 +08:00
Tirsvad 42f05bdd0f Load the gym accounts from the .env.gym file
Task: MIL-010#1
Task: MIL-010#2
Task: MIL-010#3
2026-10-28 22:26:03 +08:00
Tirsvad 434bdafefd Plan MIL-010: gym accounts from a credentials file 2026-10-28 22:22:00 +08:00
Tirsvad a248541e58 Merge pull request 'MIL-009: screenshots after the fix' (#55) from mil-009-screenshots into main
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 12s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
Publish wiki / publish-wiki (push) Successful in 10s
Publish wiki / wiki (push) Successful in 11s
2026-10-28 14:48:45 +01:00
39 changed files with 608 additions and 63 deletions
-41
View File
@@ -1,41 +0,0 @@
# Publish the project's wiki pages to the git host's wiki.
#
# On a push to main that changes docs/wiki/, this workflow calls the reusable
# workflow of TirSystem/github-action, which copies the Markdown pages of
# docs/wiki/ to the wiki of this repository (framework/process/wiki.md says
# what the folder holds: English and the PO language as a minimum).
#
# INSTALLING THIS FILE IS THE OPT-IN. There is no marker file. A project that
# does not copy it publishes nothing.
#
# Copy it to .gitea/workflows/publish-wiki.yml. Then:
# 1. Create the secret TOKEN_FOR_GITEA_WIKI in the organization of the
# repository: a Gitea token with scope write:repository for a user that
# can write this repository (see guides/credentials.md of github-action).
# 2. Enable the wiki in the repository settings.
# 3. Keep @v1, or pin the exact release tag together with tools-ref.
# A runner must be registered for the repository (or its organization).
#
# prune: "true" deletes wiki pages that are not in docs/wiki/, so the wiki
# always equals the folder. Leave it "false" to keep hand-written pages.
name: Publish wiki
on:
push:
branches: [ main ]
paths:
- "docs/wiki/**"
workflow_dispatch:
permissions:
contents: read
jobs:
wiki:
uses: TirSystem/git-workflows/.gitea/workflows/publish-wiki-pages.yml@v1
with:
wiki_dir: docs/wiki
prune: "true"
secrets:
TOKEN_FOR_GITEA_WIKI: ${{ secrets.TOKEN_FOR_GITEA_WIKI }}
+20 -8
View File
@@ -14,7 +14,8 @@
# Copy it to .gitea/workflows/sync-milestones.yml (Gitea Actions) or # Copy it to .gitea/workflows/sync-milestones.yml (Gitea Actions) or
# .github/workflows/sync-milestones.yml (GitHub Actions). Then: # .github/workflows/sync-milestones.yml (GitHub Actions). Then:
# 1. Create a CI secret and put its name in the two `secrets.` lines below # 1. Create a CI secret and put its name in the two `secrets.` lines below
# (SYNC_TOKEN is a placeholder). Gitea does not accept secret names that # (TOKEN_FOR_GITEA_ISSUES is the name the
# framework uses). Gitea does not accept secret names that
# start with GITEA_ or GITHUB_. # start with GITEA_ or GITHUB_.
# 2. Give the token the smallest scope that works: read and write on issues # 2. Give the token the smallest scope that works: read and write on issues
# and milestones. If the framework submodule is a private repository, the # and milestones. If the framework submodule is a private repository, the
@@ -53,21 +54,32 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Check out the project and its framework submodule - name: Check out the project
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
with: with:
submodules: recursive token: ${{ secrets.TOKEN_FOR_GITEA_ISSUES }}
token: ${{ secrets.SYNC_TOKEN }}
# The framework submodule is registered with an SSH address, which the runner
# cannot use. The address is rewritten to HTTPS for this step only, and the token
# goes in through the environment, never on a command line. .gitmodules stays.
- name: Fetch the framework submodule over HTTPS
env:
GIT_CONFIG_COUNT: "2"
GIT_CONFIG_KEY_0: url.https://git.tirsystem.com/.insteadOf
GIT_CONFIG_VALUE_0: ssh://git@git.tirsystem.com:10022/
GIT_CONFIG_KEY_1: http.https://git.tirsystem.com/.extraheader
GIT_CONFIG_VALUE_1: "Authorization: token ${{ secrets.TOKEN_FOR_GITEA_ISSUES }}"
run: git submodule update --init --recursive
- name: Make sure curl and jq are available - name: Make sure curl and jq are available
run: bash framework/scripts/ci-publish-milestones.sh ensure-tools run: bash framework/scripts/ci-publish-milestones.sh ensure-tools
- name: Publish accepted milestones - name: Publish accepted milestones
env: env:
# Gitea reads GITEA_TOKEN, GitHub's gh reads GH_TOKEN; both come from # Gitea reads TOKEN_FOR_GITEA_ISSUES, GitHub's gh reads GH_TOKEN; both
# the one secret named above. # come from the one secret named above.
GITEA_TOKEN: ${{ secrets.SYNC_TOKEN }} TOKEN_FOR_GITEA_ISSUES: ${{ secrets.TOKEN_FOR_GITEA_ISSUES }}
GH_TOKEN: ${{ secrets.SYNC_TOKEN }} GH_TOKEN: ${{ secrets.TOKEN_FOR_GITEA_ISSUES }}
# Empty on a push (a real run); "true" or "false" on a manual run. # Empty on a push (a real run); "true" or "false" on a manual run.
DRY_RUN: ${{ inputs.dry_run }} DRY_RUN: ${{ inputs.dry_run }}
run: bash framework/scripts/ci-publish-milestones.sh publish run: bash framework/scripts/ci-publish-milestones.sh publish
+2
View File
@@ -92,6 +92,8 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password" $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 ### Start the bot
With the environment active: With the environment active:
+2 -2
View File
@@ -14,9 +14,9 @@ document of a type. `Primary File` may contain a glob (e.g.
| BC | Business Case | docs/business-case.md | 002 | | BC | Business Case | docs/business-case.md | 002 |
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.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 | | 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 | 028 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -0,0 +1,74 @@
# 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 | [434bdaf] |
---
## 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
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
+7 -4
View File
@@ -4,21 +4,21 @@
| Key | Value | | Key | Value |
| --- | --- | | --- | --- |
| ID | PP-001 | | 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 | | Language | en |
| Domain | it | | Domain | it |
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | 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-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) | [434bdaf] |
--- ---
## Purpose ## 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 ## 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 | | | 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 | | | 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 | | | 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 ```plantuml
@startgantt @startgantt
@@ -79,7 +80,7 @@ Project starts 2026-10-28
## Dependencies ## 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. 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,7 @@ 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-007]: ./milestones/mil-007-resilience.md
[MIL-008]: ./milestones/mil-008-documentation.md [MIL-008]: ./milestones/mil-008-documentation.md
[MIL-009]: ./milestones/mil-009-verification-fix.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 [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 [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
@@ -0,0 +1,65 @@
# 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 | [434bdaf] |
---
## 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 (`<name>.<language>.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
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
@@ -0,0 +1,62 @@
# 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 | [42f05bd] |
---
## 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
[42f05bd]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/42f05bdd0f5c300fbd162e8cf50075fc98964e1b
@@ -0,0 +1,69 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-027 |
| CrossReference | [MIL-008], [QC-WF-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-29 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, the workflow sync-milestones.yml after the framework update to 0.18.0 and the removal of publish-wiki.yml | [2cfcd02] |
---
## Artifact Under Review
- Instance reviewed: [MIL-008]
- Checklist used: [QC-WF-001] (`QC-WF-001`)
- Scope: the workflow sync-milestones.yml after the framework update to 0.18.0 and the removal of publish-wiki.yml
- 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 | The file meets the YAML checklist ([QC-YML-001]) | Pending | `yamllint` is not installed on the stand-in's machine, so the YAML checklist QC-YML-001 was not run; the files parse (actionlint reads them) and use 2-space indentation. |
| 2 | The file is in `.gitea/workflows/` or `.github/workflows/` with the extension `.yml` or `.yaml`, and the same workflow does not exist in both directories | Pass | `sync-milestones.yml` is in `.gitea/workflows/` with the extension `.yml` and not in `.github/workflows/`. `publish-wiki.yml` was removed (see the verdict). |
| 3 | The file declares `name`, `on` and `jobs`; `on` lists explicit events and, where it matters, `branches` or `paths` | Pass | The file declares `name`, `on` and `jobs`; `on` lists `push` on `main` with `paths`, and `workflow_dispatch`. |
| 4 | A header comment states the purpose, the triggers, the inputs, the secrets it needs, what it changes outside the repository, and the decision or milestone it implements | Pass | The header states the purpose, triggers, secrets, what changes outside the repository and the installation steps (from the framework template); the decision is MIL-008 task 5. The added step has its own comment. |
| 5 | Every job and step has a descriptive `name`, unique within its job | Pass | Every step has a descriptive name. |
| 6 | The file passes `actionlint` (or the project's workflow validation test) and the tool version is stated | Pass | `actionlint` 1.7.12 reports no finding (exit code 0). |
| 7 | Every job declares `permissions` explicitly with the least access that works (`contents: read` or none by default); write access only on the job that needs it | Pass | The job sets `contents: read`. |
| 8 | Every action used is pinned to a full commit SHA or an exact release tag, never to a branch; a moving major tag (`@v1`) is accepted only for an action published by the same organisation under a stated release policy | Pass | `actions/checkout@v4.2.2` is an exact release tag. |
| 9 | No secret is written in the file; a secret is read as `secrets.NAME`, passed to a step through `env:`, never placed on a command line or echoed | Pass | The file reads `secrets.TOKEN_FOR_GITEA_ISSUES` only; no secret value is in the repository. The token reaches git through `GIT_CONFIG_*` environment variables, never a command line. |
| 10 | Untrusted text (`inputs.*`, event fields such as a title, branch name or label, step outputs derived from them) is never interpolated into a `run:` script; it is passed through `env:` and used as a quoted variable | Pass | No `run:` block interpolates `inputs.*` or event text; `DRY_RUN` is passed through `env:`. |
| 11 | A trigger that gives a run secrets or a write token on code from a fork or an untrusted branch (`pull_request_target`, `workflow_run`) does not check out or execute that code | Pass | The workflow does not use `pull_request_target` or `workflow_run`. |
| 12 | A workflow that changes state outside the repository (release, tag, publish, sync, deploy) is started by hand or by a protected branch, and a manual run defaults to a dry run with an explicit input to apply | Pass | It runs on a push to `main` or by hand; the manual run defaults to a dry run. |
| 13 | The token the workflow uses has only the scope the job needs, and where the host's built-in token is enough it is used instead of a personal token | Pending | The scope of the token is set by S01; the dry run on 2026-10-28 got HTTP 403 on the framework repository, so the token lacks read access to it. |
| 14 | Every job sets `runs-on` to an explicit label and `timeout-minutes` | Pass | The job sets `runs-on: ubuntu-latest` and `timeout-minutes: 15`. |
| 15 | `concurrency` is set where two runs would conflict: a check cancels the older run (`cancel-in-progress: true`); a publishing job queues (`cancel-in-progress: false`) | Pass | `concurrency` is set with `cancel-in-progress: false`. |
| 16 | Triggers are filtered (`branches`, `paths`, `types`) so the workflow runs only for the changes it concerns | Pass | The triggers are filtered by `branches: [main]` and `paths`. |
| 17 | Tools and dependencies are installed at pinned versions; a version is a workflow input with a default or a value in one place | Pass | The workflow installs nothing itself; `curl` and `jq` are checked by the framework script. |
| 18 | A dependency cache is used where the install is slow enough to matter | N-A | No dependency is installed that a cache would speed up. |
| 19 | A failing step names the tool, the file and the reason in its log (annotation or `::error::` line) | Pending | Not verified: the log messages come from the framework script and the reusable workflow, which the stand-in did not run. |
| 20 | A `run:` block is within the limit on embedded program text of criterion 7 of [QC-YML-001]; a longer script is a file (`tools/`, or a shared repository fetched at a pinned ref) that the step calls with one command, and the workflow passes its settings through `env:` | Pass | Each `run:` block is one command; the logic is in `framework/scripts/ci-publish-milestones.sh`. |
| 21 | Logic used by more than one workflow or repository is a reusable workflow (`workflow_call`) or a composite action; each input has a `description`, a `type` and a `default` where one is sensible | Pass | The workflow calls one framework script; the input of `workflow_dispatch` has a description, type and default. |
| 22 | Every workflow feature used is verified on the instance that runs it, and the host and version are recorded; a feature the target hosts differ on is noted in the header | Pending | A dry run on the instance (2026-10-28, run 3034, branch `fix-sync-milestones-submodule`) passed the HTTPS rewrite and then failed with 403 on the framework repository, so the feature is not yet verified end to end. |
| 23 | A reusable workflow states, in its header, how a caller pins it and the script it fetches, so the workflow and its script are the same release | N-A | The file is not a reusable workflow. |
| 24 | A static test of the repository's workflows exists and runs on every pull request: valid YAML, required keys, explicit permissions and runner, no inline script above the limit | N-A | No static test of the workflows exists in this project; the checks are `actionlint` on request. |
## Overall Verdict
Go-with-conditions - the milestone workflow is the framework template (0.18.0) plus one step that fetches the framework submodule over HTTPS, and it passes `actionlint`. `publish-wiki.yml` is removed because the framework retired the caller: the instance publishes `docs/wiki/` with its own scoped workflow (RC-022 reviewed the old caller). Mandatory criteria 1 (`yamllint` was not run) and 22 (feature checked on the instance) are Pending: the token lacks read access to the framework repository. 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 |
| --- | --- | --- |
| Give the token `TOKEN_FOR_GITEA_ISSUES` read access to the repository TirSystem/SQA-QC-Framework, create the secret in the organization and run the workflow once by hand with dry_run on | S01 | before the next merge to main |
| Run `yamllint` on `.gitea/workflows/` | S01 | when convenient |
---
[MIL-008]: ../../milestones/mil-008-documentation.md
[QC-WF-001]: ../../../framework/qc/code/qc-workflow.md
[2cfcd02]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/2cfcd0258897aeb90cea48baf59a8af258df19dd
+4 -2
View File
@@ -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 | | [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 |
| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 | | [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 | | [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-004 |
| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-005 | | [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-005 |
| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code from MIL-002 onward | RC-006 | | [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code from MIL-002 onward | RC-006 |
@@ -37,13 +37,14 @@ 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-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 |
| [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 | | [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 |
| [MIL-009] | Milestone | en | it | [BC-001], [MIL-006] | - | RC-023 | | [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-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-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) | | Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-018 (Go-with-conditions) |
| Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-019 (Go-with-conditions) | | Source and tests of [MIL-005] | Python source code | - | - | [MIL-005], [ADR-0002] | - | RC-019 (Go-with-conditions) |
| Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-020 (Go-with-conditions) | | Source and tests of [MIL-006] | Python source code | - | - | [MIL-006], [ADR-0002] | - | RC-020 (Go-with-conditions) |
| Source and tests of [MIL-007] | Python source code | - | - | [MIL-007], [ADR-0002] | - | RC-021 (Go-with-conditions) | | Source and tests of [MIL-007] | Python source code | - | - | [MIL-007], [ADR-0002] | - | RC-021 (Go-with-conditions) |
| Workflows and wiki of [MIL-008] | Workflow files, wiki pages | - | - | [MIL-008] | - | RC-022 (Go-with-conditions) | | Workflows and wiki of [MIL-008] | Workflow files, wiki pages | - | - | [MIL-008] | - | RC-022, RC-027 (Go-with-conditions) |
## Concern Coverage ## Concern Coverage
@@ -77,4 +78,5 @@ Tracks backward and forward links between artifact instances so that the Busines
[MIL-007]: ../milestones/mil-007-resilience.md [MIL-007]: ../milestones/mil-007-resilience.md
[MIL-008]: ../milestones/mil-008-documentation.md [MIL-008]: ../milestones/mil-008-documentation.md
[MIL-009]: ../milestones/mil-009-verification-fix.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 [50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3
+4
View File
@@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password" $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 ## Kør og test
```bash ```bash
+4
View File
@@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password" $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 ## Jalankan dan uji
```bash ```bash
+4
View File
@@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password" $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 ## Run and test
```bash ```bash
+29 -1
View File
@@ -30,6 +30,34 @@ def load_credentials(environment: Mapping[str, str]) -> Credentials:
if not email or not password: if not email or not password:
raise MissingCredentialsError( raise MissingCredentialsError(
f"Set {constants.ACCOUNT_EMAIL_ENV} and {constants.ACCOUNT_PASSWORD_ENV} " 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) 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)
+21 -3
View File
@@ -12,7 +12,11 @@ from pathlib import Path
from snack_and_lift import constants from snack_and_lift import constants
from snack_and_lift.application.booking import book_next_tuesday, book_target_classes 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.ports import GymPort
from snack_and_lift.application.retry import Sleep from snack_and_lift.application.retry import Sleep
from snack_and_lift.application.session import login_with_retry 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.domain.verification import format_verification
from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir 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.console_recorder import ConsoleRecorder
from snack_and_lift.infrastructure.env_file import read_env_file
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
## Builds the gym site from (headless, profile folder, keep open). ## 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.", description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.",
epilog=( epilog=(
"exit codes: 0 done, 1 a step failed (also when every retry failed), " "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( 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)" 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) args = parser.parse_args(argv)
if args.keep_open is None: if args.keep_open is None:
args.keep_open = not args.headless args.keep_open = not args.headless
@@ -242,7 +258,9 @@ def _main(
_use_utf8_output() _use_utf8_output()
args = parse_args(argv) args = parse_args(argv)
try: 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: except MissingCredentialsError as error:
logger.error("%s", error) logger.error("%s", error)
return 2 return 2
+13
View File
@@ -70,6 +70,19 @@ ACCOUNT_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_EMAIL"
## Name of the environment variable that holds the account password. ## Name of the environment variable that holds the account password.
ACCOUNT_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_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. ## Folder where screenshots of a container run are written.
SCREENSHOT_DIR: Final[str] = "docs/images" SCREENSHOT_DIR: Final[str] = "docs/images"
@@ -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
+71
View File
@@ -18,6 +18,12 @@ DUMMY_EMAIL = "dummy@example.invalid"
DUMMY_PASSWORD = "dummy-password" 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 @pytest.fixture
def credentials_env(monkeypatch: pytest.MonkeyPatch) -> None: def credentials_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL) 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) main(["--headless", "--profile-dir", str(tmp_path)], factory)
assert received == [(True, tmp_path, False)] 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
+53 -1
View File
@@ -5,7 +5,11 @@
import pytest import pytest
from snack_and_lift import constants 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 from snack_and_lift.domain.errors import MissingCredentialsError
DUMMY_PASSWORD = "dummy-secret-value" 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: def test_repr_hides_the_password() -> None:
assert DUMMY_PASSWORD not in repr(Credentials("dummy@example.invalid", DUMMY_PASSWORD)) 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
+59
View File
@@ -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": ""}