Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
736cdb42fc | ||
|
|
903c948d85 | ||
|
|
9413ec3e4c | ||
|
|
87e399188b | ||
|
|
47e41705b3 | ||
|
|
bede04c841 | ||
|
|
f4a397c1ec | ||
|
|
76b91c3caf | ||
|
|
4b6e5b6c67 | ||
|
|
102dd2473d | ||
|
|
5ac230df7e | ||
|
|
d727f7ae60 | ||
|
|
7508db7bea | ||
|
|
bc90f10151 | ||
|
|
02875aee5f |
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
@@ -0,0 +1,22 @@
|
||||
# RepoFoundry credentials. Placeholders only: never put a real value in this
|
||||
# file or commit one.
|
||||
#
|
||||
# Copy this file to .env, fill in the values and keep it private
|
||||
# (chmod 600 .env on Linux and macOS). .env is ignored by git. The file is
|
||||
# read as plain KEY=VALUE lines and never executed. Values may be wrapped in
|
||||
# single or double quotes.
|
||||
|
||||
# GitHub personal access token. Needed only when you choose to create a
|
||||
# GitHub repository. It creates the repository and is also the password of the
|
||||
# Gitea push mirror, so it needs permission to create repositories for the
|
||||
# chosen owner and to push to the new one. Prefer a fine-grained token.
|
||||
GITHUB_PAT=
|
||||
|
||||
# GitHub account the token belongs to. It identifies who authenticates; it is
|
||||
# only a default suggestion for the owner prompt, because the repository can
|
||||
# belong to an organization.
|
||||
GITHUB_USER=
|
||||
|
||||
# Gitea access token (required). It needs permission to create repositories
|
||||
# for the chosen owner and to manage the repository's push mirror.
|
||||
GITEA_TOKEN=
|
||||
+13
@@ -174,3 +174,16 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
# RepoFoundry: credentials and temporary files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
tmp/
|
||||
repofoundry.*/
|
||||
|
||||
|
||||
!src/lib
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# shellcheck configuration for the RepoFoundry scripts
|
||||
shell=bash
|
||||
# follow the "source" lines of create-project.sh into src/lib/; lint from the
|
||||
# entry point so that variables shared between the files are seen as used
|
||||
external-sources=true
|
||||
source-path=SCRIPTDIR
|
||||
@@ -1,2 +1,142 @@
|
||||
# repo_foundry
|
||||
# RepoFoundry
|
||||
|
||||
RepoFoundry (`src/create-project.sh`) sets up a new project: a Gitea
|
||||
repository, optionally an empty GitHub repository with a push mirror from
|
||||
Gitea to GitHub, and (in a later phase) a local project with the
|
||||
SQA-QC-Framework.
|
||||
|
||||
> **Status: work in progress.** The script can validate its configuration,
|
||||
> check both hosts and create the repositories and the mirror. Creating the
|
||||
> local project, and the full installation guide, come in a later phase
|
||||
> (MIL-003). This file so far documents what is needed to run the host steps
|
||||
> safely.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cp config.env.example config.env # service addresses, not secret
|
||||
cp .env.example .env # credentials: keep private
|
||||
chmod 600 .env # Linux and macOS
|
||||
|
||||
src/create-project.sh # dry run: reads from the hosts, creates nothing
|
||||
src/create-project.sh --apply # creates the repositories and the mirror
|
||||
```
|
||||
|
||||
Without `--apply` the script only reads from GitHub and Gitea (it checks the
|
||||
tokens, the owners, the name, the license and SSH) and prints a plan. With
|
||||
`--apply` it prints the plan again and asks a final question before it creates
|
||||
anything. Nothing is ever deleted by the script.
|
||||
|
||||
Choosing GitHub also applies the AGPL-3.0 license to the Gitea repository, so
|
||||
that repository is not empty. Without GitHub the Gitea repository is created
|
||||
empty and has no license.
|
||||
|
||||
## Token permissions
|
||||
|
||||
Both tokens go in `.env` (never in `config.env`, never in a remote URL). The
|
||||
script sends them only in a request header, through a private temporary file,
|
||||
and never prints them.
|
||||
|
||||
### GitHub token (`GITHUB_PAT`, only when you choose GitHub)
|
||||
|
||||
The same token has two jobs: it creates the repository, and it is the
|
||||
password Gitea uses to push the mirror. It therefore needs to create
|
||||
repositories for the chosen owner and to push to the new one.
|
||||
|
||||
| Need | Token | Source |
|
||||
| --- | --- | --- |
|
||||
| Create a private repository | classic token with the `repo` scope | GitHub REST documentation, "Create a repository" |
|
||||
| Create a public repository only | classic token with `public_repo` is enough | same |
|
||||
| Push from the Gitea mirror | covered by `repo` | |
|
||||
| Check that you belong to the organization owner | probably `read:org` | **Not confirmed**: the GitHub documentation names no scope for this call. If the script says you do not belong to an organization that you do belong to, add `read:org`. |
|
||||
|
||||
- **Organization owners:** you must be an active member who is allowed to
|
||||
create repositories in the organization. Organizations that require SSO or
|
||||
approval of tokens need the token authorised first.
|
||||
- **Fine-grained tokens:** the GitHub documentation lists no fine-grained
|
||||
permission for creating a repository, and this has not been tested.
|
||||
Use a classic token until it has been.
|
||||
- **`GITHUB_USER`:** names the account the token belongs to. It is only a
|
||||
default for the owner prompt; the repository may belong to an organization.
|
||||
If it differs from the account the token belongs to, the script warns and
|
||||
uses the account the token belongs to.
|
||||
|
||||
### Gitea token (`GITEA_TOKEN`)
|
||||
|
||||
| Need | Scope | Source |
|
||||
| --- | --- | --- |
|
||||
| Read the account the token belongs to | `read:user` | Gitea documentation |
|
||||
| Create repositories, manage the push mirror | `write:repository` | Gitea documentation |
|
||||
| Look up an organization and your permissions in it | `read:organization` | Gitea documentation |
|
||||
| Create a repository in an organization | probably `write:organization` as well | **Not confirmed**: expected from how the Gitea API groups organization calls; the end-to-end test in MIL-003 will confirm it. |
|
||||
|
||||
A missing scope shows up as an HTTP 403 with the server's own message. The
|
||||
script stops before it creates anything when a preflight check is refused.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **The mirror password is stored on the Gitea server.** Gitea needs the
|
||||
GitHub token to push, so it keeps it. How it is protected depends on the
|
||||
Gitea version and its administrators. Use a token that is only meant for
|
||||
this, and revoke it if the Gitea server is ever in doubt.
|
||||
- **`sync_on_commit` may be ignored.** When a push mirror is created through
|
||||
the API, some Gitea versions ignore `sync_on_commit` (upstream issue
|
||||
go-gitea/gitea#22990). The script reads the mirror back and warns if the
|
||||
setting was not applied; the mirror then syncs on its interval
|
||||
(`MIRROR_INTERVAL`, default 10 minutes). The first sync is requested right
|
||||
after the mirror is created.
|
||||
- **The server decides the shortest interval** and whether push mirrors are
|
||||
allowed at all. A refused mirror stops the run with the server's message;
|
||||
the repositories created so far are kept.
|
||||
- **The license commit.** Gitea adds the license file when the repository is
|
||||
created with `auto_init`. The script sends no README, so the repository
|
||||
should hold only `LICENSE`; this is checked in the MIL-003 end-to-end test.
|
||||
- **No rollback.** If a step fails, the script reports what exists and how to
|
||||
continue. A repeated run offers to reuse a repository it created earlier
|
||||
(empty, or in Gitea's case holding only the license). Delete what you do not
|
||||
want in the web interface.
|
||||
- **Mirror direction is Gitea to GitHub only.** Push to Gitea; GitHub is a
|
||||
copy.
|
||||
- **Requirements:** bash 4.4 or later, `git`, `curl` and `mktemp`; `jq` and
|
||||
`ssh` are optional. Without `jq` the script reads the few JSON fields it
|
||||
needs with a simple built-in reader.
|
||||
|
||||
## Code layout
|
||||
|
||||
`src/create-project.sh` is the entry point: the header, strict mode, loading
|
||||
and `main`. The work is split by responsibility into `src/lib/`, one job per
|
||||
file. The files are loaded from that directory only, by a fixed path.
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `constants.sh` | constants and the shared state of a run |
|
||||
| `output.sh` | messages for the user and redaction of secrets |
|
||||
| `temp.sh` | private temporary files and their cleanup |
|
||||
| `util.sh` | small string and list helpers |
|
||||
| `validate.sh` | validators for names, URLs, tokens, ports, intervals |
|
||||
| `config.sh` | reading and checking `config.env` and `.env` (never sourced) |
|
||||
| `tools.sh` | checking the required tools |
|
||||
| `json.sh` | the little JSON the script reads and writes |
|
||||
| `http.sh` | the one place that runs `curl`; tokens stay off the command line |
|
||||
| `api.sh` | GitHub and Gitea API calls and reporting a refused call |
|
||||
| `prompts.sh` | interactive questions with validation |
|
||||
| `project.sh` | the project details: asking for them and showing them |
|
||||
| `hosts.sh` | names and links of the repositories on each host |
|
||||
| `preflight.sh` | read-only checks of both hosts |
|
||||
| `steps.sh` | the outcome of each step and the final report |
|
||||
| `plan.sh` | printing what the script is about to do |
|
||||
| `repositories.sh` | creating the GitHub and Gitea repositories |
|
||||
| `mirror.sh` | the Gitea to GitHub push mirror |
|
||||
| `apply.sh` | confirmations and the apply flow; the only code that changes anything |
|
||||
| `cli.sh` | usage text and option parsing |
|
||||
|
||||
Each file names its responsibility and lists the functions it provides in its
|
||||
first lines. `tests/test-structure.sh` keeps it that way: every file in
|
||||
`src/lib/` is loaded, no function is defined twice, and a file does nothing
|
||||
when it is loaded.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bash tests/run-tests.sh # shellcheck, shfmt and all tests, no network
|
||||
```
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# RepoFoundry service addresses (not secret).
|
||||
#
|
||||
# Copy this file to config.env and adjust it. create-project.sh reads it as
|
||||
# plain KEY=VALUE lines; it is never executed, so no shell syntax, $variables
|
||||
# or command substitution works here. Credentials do NOT belong in this file:
|
||||
# put them in .env (see .env.example). A credential key in this file is
|
||||
# rejected.
|
||||
#
|
||||
# Every URL must start with https:// and must not contain a user name, a
|
||||
# password, a query string or a fragment.
|
||||
|
||||
# GitHub REST API base URL.
|
||||
GITHUB_API_URL=https://api.github.com
|
||||
|
||||
# GitHub web base URL, used for the repository links the script prints and
|
||||
# for the push-mirror address.
|
||||
GITHUB_WEB_URL=https://github.com
|
||||
|
||||
# Gitea instance base URL. Repository links are derived from it.
|
||||
GITEA_URL=https://git.tirsystem.com/
|
||||
|
||||
# Gitea REST API base URL. If you leave this out it is GITEA_URL + /api/v1.
|
||||
GITEA_API_URL=https://git.tirsystem.com/api/v1
|
||||
|
||||
# Optional. SSH port of the Gitea server, used for the SSH check and later
|
||||
# for the framework submodule. Default: 10022.
|
||||
#GITEA_SSH_PORT=10022
|
||||
|
||||
# Optional. How often Gitea pushes to GitHub, as a Go duration (10m0s, 8h0m0s).
|
||||
# The server may enforce a minimum. Default: 10m0s.
|
||||
#MIRROR_INTERVAL=10m0s
|
||||
@@ -18,6 +18,13 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| US | User Story | docs/user-stories.md | 002 |
|
||||
| UC | Use Case | docs/uc-*/uc.md | 002 |
|
||||
| SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 |
|
||||
| OC | Operation Contract | docs/uc-*/oc.md | 002 |
|
||||
| SD | Sequence Diagram | docs/uc-*/sd.md | 002 |
|
||||
| DM | Domain Model | docs/domain-model.md | 003 |
|
||||
| DICT | Domain Dictionary (PO and IT terms) | docs/dictionary.md | 002 |
|
||||
| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 017 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
|
||||
## Languages
|
||||
|
||||
|
||||
+18
-11
@@ -4,12 +4,13 @@
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | BC-001 |
|
||||
| CrossReference | [SA-001] |
|
||||
| CrossReference | [SA-001], [UCD-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Optional GitHub; choosing GitHub applies the AGPL license to the Gitea repository<br>Cited UCD-001<br>Justified the qualitative cost-benefit; stakeholder roles replaced by interests; success criteria 2 and 3 reworded for optional GitHub<br>Added objective 7 (documentation) and its success criterion | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
@@ -33,12 +34,13 @@ One repeatable, reviewed procedure gives every new project the same secure basel
|
||||
|
||||
## Objectives
|
||||
|
||||
1. Create an empty GitHub repository under a chosen user or organization.
|
||||
2. Create a matching empty Gitea repository under a chosen user or organization.
|
||||
3. Configure the Gitea repository as a push mirror to GitHub (direction Gitea to GitHub).
|
||||
4. Create the local project directory with `origin` (Gitea) and `github` remotes that contain no credentials.
|
||||
1. Optionally create an empty GitHub repository under a chosen user or organization.
|
||||
2. Create a Gitea repository under a chosen user or organization, empty, or with the AGPL license when GitHub is chosen.
|
||||
3. When GitHub was chosen, configure the Gitea repository as a push mirror to GitHub (direction Gitea to GitHub).
|
||||
4. Create the local project directory with an `origin` (Gitea) remote and, when GitHub was chosen, a `github` remote, neither containing credentials.
|
||||
5. Add the SQA-QC-Framework as the `framework` submodule, install its skills and git hooks, and copy its templates, optionally enabling the plan gate.
|
||||
6. Never print or persist a token, and never overwrite existing files or directories without consent.
|
||||
7. Document installation, configuration, usage, security decisions and error handling in clear English for GitHub readers.
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -46,7 +48,7 @@ One repeatable, reviewed procedure gives every new project the same secure basel
|
||||
|
||||
- `create-project.sh`, `config.env.example`, `.env.example`, `.gitignore` and `README.md`.
|
||||
- Safe parsing and validation of `config.env` and `.env` (never `source`d).
|
||||
- Prompts for name, description, visibility and owner on both hosts.
|
||||
- Prompts for name, description, visibility and owner on each chosen host, and whether to use GitHub (which also applies the AGPL license).
|
||||
- Checks for required tools (`git`, `curl`, optional `jq`) before any change.
|
||||
- A check that the project name is not already taken on GitHub.
|
||||
- Partial-failure reporting with a documented way to continue.
|
||||
@@ -81,11 +83,12 @@ Supports developing on self-hosted Gitea while publishing to GitHub, and adoptin
|
||||
| # | Criterion | Target | Measure |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Credential exposure | 0 occurrences of a token in output, saved remote URLs, config files or leftover temp files | Test run with log review; `git config --get-regexp remote` inspected |
|
||||
| 2 | Repository ownership | Both repositories are created under the owner chosen at the prompt, never silently under `GITHUB_USER` | Test run with a user owner and with an organization owner |
|
||||
| 3 | Mirror direction | Gitea is the source, GitHub the target; a push to `origin` appears on GitHub | Push a test commit and compare |
|
||||
| 2 | Repository ownership | Each repository created is under the owner chosen at the prompt for that host, never silently under `GITHUB_USER` | Test run with a user owner and with an organization owner |
|
||||
| 3 | Mirror direction | When GitHub is chosen, Gitea is the source and GitHub the target; a push to `origin` appears on GitHub | Push a test commit and compare |
|
||||
| 4 | Partial failure | When one host fails, the output lists what was created and the command to continue | Forced failure test (invalid token for one host) |
|
||||
| 5 | No overwrite | An existing directory or file is never replaced without a yes | Run twice in the same location |
|
||||
| 6 | Lint | `shellcheck` reports no errors on `create-project.sh` | `shellcheck create-project.sh` |
|
||||
| 7 | Documentation | `README.md` covers installation, configuration, usage, security decisions, error handling and stakeholders | Review by S02 against MIL-003 Go/No-Go criterion 6 |
|
||||
|
||||
## Risks
|
||||
|
||||
@@ -117,12 +120,14 @@ Supports developing on self-hosted Gitea while publishing to GitHub, and adoptin
|
||||
| --- | --- |
|
||||
| Three planned phases of maintainer time; ongoing maintenance when the GitHub or Gitea API changes | Repeatable secure setup for every future project; fewer setup mistakes; reusable by GitHub readers |
|
||||
|
||||
The assessment is qualitative on purpose: this is internal tooling with no revenue, and there is no measurement of how long the manual setup takes today to compare against. The cost is maintainer time.
|
||||
|
||||
## Stakeholders
|
||||
|
||||
| Stakeholder ID (SA) | Interest in this project |
|
||||
| --- | --- |
|
||||
| S01 | Product Owner and maintainer; sets scope and accepts the result |
|
||||
| S02 | DevOps, cybersecurity and maintainer; reviews credential handling and git host integration |
|
||||
| S01 | Sets the scope and accepts the result |
|
||||
| S02 | Reviews credential handling and the git host integration |
|
||||
| S03 | Reads and may reuse the published project on GitHub |
|
||||
|
||||
## Recommendation
|
||||
@@ -132,4 +137,6 @@ Proceed — the procedure is small, well bounded and removes a repeated, securit
|
||||
---
|
||||
|
||||
[SA-001]: ./stakeholder-analysis.md
|
||||
[UCD-001]: ./use-case-diagram.md
|
||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Domain Dictionary (PO and IT terms)
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | DICT-001 |
|
||||
| CrossReference | [BC-001], [SA-001], [DM-001], [DM-002] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version, terms of UC-001 | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose and Scope
|
||||
|
||||
Maps each Product Owner (PO) term to its professional IT term. PO language: English (`en`), from the registry's `Languages` section. It covers the concepts of [DM-001] and [DM-002].
|
||||
|
||||
## Dictionary
|
||||
|
||||
| PO term | Language | IT term | Definition | Used as PO term in | Used as IT term in |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Maintainer | en | Maintainer | The person who creates a new project. | DM, UC, US | OC, SD |
|
||||
| Project | en | ProjectRequest | The new software project being set up, with its name, description and visibility. | DM, UC, US | OC, SD |
|
||||
| Configuration | en | Configuration | The service addresses and access tokens set up before starting. | DM, UC | OC, SD |
|
||||
| Git Host | en | GitHost | A service that holds repositories: Gitea or GitHub. | DM, UC | OC, SD |
|
||||
| Access Token | en | Credential | A secret that lets the Maintainer act on a Git Host; never part of an address. | DM, UC | OC, SD |
|
||||
| Owner | en | Owner | The user or organization on a Git Host that owns repositories. | DM, UC | OC, SD |
|
||||
| Repository | en | Repository | A place on a Git Host that holds a project's history. | DM, UC | OC, SD |
|
||||
| Gitea Repository | en | GiteaRepository | The repository on Gitea; the source of truth. | DM, UC | OC, SD |
|
||||
| GitHub Repository | en | GitHubRepository | The repository on GitHub; it receives its content from the mirror. | DM, UC | OC, SD |
|
||||
| License | en | LicenseFile | The legal terms file (AGPL-3.0) added to the Gitea repository when GitHub is chosen. | DM, UC | OC, SD |
|
||||
| Mirror | en | PushMirror | The push mirror that copies a Gitea repository to a GitHub repository. | DM, UC | OC, SD |
|
||||
| Local Project | en | LocalProject | The project directory on the Maintainer's machine. | DM, UC | OC, SD |
|
||||
| Remote | en | Remote | A named link from a local project to a repository. | DM, UC | OC, SD |
|
||||
| Framework | en | Submodule | The SQA-QC-Framework added to a local project; the IT term names how it is attached. | DM, UC | OC, SD |
|
||||
| Framework Setup | en | HookSetup | The skills and git hooks installed from the framework, with the plan gate on or off. | DM, UC | OC, SD |
|
||||
| Template | en | Template | A framework file copied into a project. | DM, UC | OC, SD |
|
||||
| Summary | en | Summary | The report of what was created, skipped or failed and how to continue. | DM, UC | OC, SD |
|
||||
|
||||
## Rules
|
||||
|
||||
- The Domain Model, use cases and user stories use the PO term; the Operation
|
||||
Contract, Sequence Diagram, Design Class Diagram and ERD use the IT term.
|
||||
- One IT term per PO term and one PO term per IT term; no synonyms.
|
||||
- `Run`, `ToolCheck`, `PreflightResult` and `PromptSet` appear in [OC-001] but
|
||||
have no PO term: they are system concepts, not domain concepts, and are not
|
||||
in the Domain Model.
|
||||
- A new concept in a Domain Model gets a row here in the same change.
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ./business-case.md
|
||||
[SA-001]: ./stakeholder-analysis.md
|
||||
[DM-001]: ./uc-001/dm.md
|
||||
[DM-002]: ./domain-model.md
|
||||
[OC-001]: ./uc-001/oc.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,172 @@
|
||||
# Domain Model
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | DM-002 |
|
||||
| CrossReference | [UC-001], [SSD-001], [DICT-001], [DM-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version, created from [DM-001] (UC-001) | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose and Scope
|
||||
|
||||
The consolidated model of the project. Use-case models are scoped views; when one changes, this model is checked and updated in the same change. It currently covers [UC-001] "Create a new project" ([DM-001]), which it was created from. Concept names are the PO terms recorded in [DICT-001].
|
||||
|
||||
## Diagram
|
||||
|
||||
Concepts, attributes and associations only — no operations.
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
class Maintainer {
|
||||
name
|
||||
}
|
||||
class Project {
|
||||
name
|
||||
description
|
||||
visibility
|
||||
}
|
||||
class Configuration
|
||||
class "Git Host" as GitHost {
|
||||
name
|
||||
web address
|
||||
API address
|
||||
}
|
||||
class "Access Token" as AccessToken {
|
||||
kind
|
||||
}
|
||||
class Owner {
|
||||
name
|
||||
kind
|
||||
}
|
||||
class Repository {
|
||||
name
|
||||
description
|
||||
visibility
|
||||
address
|
||||
}
|
||||
class "Gitea Repository" as GiteaRepository
|
||||
class "GitHub Repository" as GitHubRepository
|
||||
class License {
|
||||
name
|
||||
}
|
||||
class Mirror {
|
||||
interval
|
||||
sync on commit
|
||||
}
|
||||
class "Local Project" as LocalProject {
|
||||
directory
|
||||
}
|
||||
class Remote {
|
||||
name
|
||||
address
|
||||
}
|
||||
class Framework {
|
||||
name
|
||||
address
|
||||
}
|
||||
class "Framework Setup" as FrameworkSetup {
|
||||
plan gate enabled
|
||||
}
|
||||
class Template {
|
||||
name
|
||||
}
|
||||
class Summary {
|
||||
created items
|
||||
skipped items
|
||||
next steps
|
||||
}
|
||||
|
||||
Repository <|-- GiteaRepository
|
||||
Repository <|-- GitHubRepository
|
||||
|
||||
Maintainer "1" --> "0..*" Project : creates
|
||||
Configuration "1" --> "1..2" GitHost : defines
|
||||
Configuration "1" --> "1..2" AccessToken : holds
|
||||
AccessToken "1" --> "1" GitHost : gives access to
|
||||
GitHost "1" --> "0..*" Owner : has
|
||||
Owner "1" --> "0..*" Repository : owns
|
||||
Project "1" --> "1" GiteaRepository : is stored in
|
||||
Project "1" --> "0..1" GitHubRepository : is also stored in
|
||||
GiteaRepository "1" --> "0..1" License : has
|
||||
Mirror "1" --> "1" GiteaRepository : copies from
|
||||
Mirror "1" --> "1" GitHubRepository : copies to
|
||||
Mirror "0..*" --> "1" AccessToken : is authorised by
|
||||
LocalProject "1" --> "1" Project : is the working copy of
|
||||
LocalProject "1" --> "1..2" Remote : has
|
||||
Remote "0..*" --> "1" Repository : points to
|
||||
LocalProject "1" --> "1" Framework : includes
|
||||
LocalProject "1" --> "1" FrameworkSetup : has
|
||||
FrameworkSetup "0..*" --> "1" Framework : is installed from
|
||||
Framework "1" --> "1..*" Template : provides
|
||||
LocalProject "1" --> "0..*" Template : contains a copy of
|
||||
Summary "1" --> "1" Project : reports on
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Concept Table
|
||||
|
||||
| Concept | Definition | Attributes | Source (use case / glossary) |
|
||||
| --- | --- | --- | --- |
|
||||
| Maintainer | The person who creates a new project (S01 or S02) | name | [UC-001] primary actor |
|
||||
| Project | The new software project being set up | name, description, visibility | [UC-001] "new project", step 3 |
|
||||
| Configuration | The service addresses and access tokens the Maintainer has set up before starting | none | [UC-001] precondition, step 2 "configuration and credentials" |
|
||||
| Git Host | A service that holds repositories: Gitea or GitHub | name, web address, API address | [UC-001] steps 5 to 7 "GitHub", "Gitea" |
|
||||
| Access Token | A secret that lets the Maintainer act on a Git Host; it is never part of an address | kind | [UC-001] precondition "Gitea token", "GitHub PAT" |
|
||||
| Owner | The user or organization on a Git Host that owns repositories | name, kind (user or organization) | [UC-001] step 3 "owner" |
|
||||
| Repository | A place on a Git Host that holds a project's history | name, description, visibility, address | [UC-001] steps 5 and 6 "repository" |
|
||||
| Gitea Repository | The Repository on Gitea; the source of truth | none beyond Repository | [UC-001] step 6 |
|
||||
| GitHub Repository | The Repository on GitHub; receives its content from the Mirror | none beyond Repository | [UC-001] step 5 |
|
||||
| License | The legal terms file added to a Gitea Repository (AGPL-3.0) when GitHub is chosen | name | [UC-001] step 6 "AGPL license" |
|
||||
| Mirror | The push mirror that copies a Gitea Repository to a GitHub Repository | interval, sync on commit | [UC-001] step 7 "push mirror" |
|
||||
| Local Project | The project directory on the Maintainer's machine | directory | [UC-001] step 8 "local project" |
|
||||
| Remote | A named link from a Local Project to a Repository (`origin`, `github`) | name, address | [UC-001] step 8 "remote" |
|
||||
| Framework | The SQA-QC-Framework added to a Local Project | name, address | [UC-001] step 9 "framework submodule" |
|
||||
| Framework Setup | The skills and git hooks installed from the Framework, with the plan gate on or off | plan gate enabled | [UC-001] step 9 "skills and hooks", "plan gate" |
|
||||
| Template | A file the Framework provides to copy into a project (`AGENTS.md`, artifact registry) | name | [UC-001] step 9 "templates" |
|
||||
| Summary | The report of what was created, skipped or failed and how to continue | created items, skipped items, next steps | [UC-001] step 10 "summary" |
|
||||
|
||||
## Association Table
|
||||
|
||||
| From | Association (reading direction) | To | Multiplicity |
|
||||
| --- | --- | --- | --- |
|
||||
| Maintainer | creates | Project | 1 to 0..* |
|
||||
| Configuration | defines | Git Host | 1 to 1..2 (GitHub is optional) |
|
||||
| Configuration | holds | Access Token | 1 to 1..2 |
|
||||
| Access Token | gives access to | Git Host | 1 to 1 |
|
||||
| Git Host | has | Owner | 1 to 0..* |
|
||||
| Owner | owns | Repository | 1 to 0..* |
|
||||
| Project | is stored in | Gitea Repository | 1 to 1 |
|
||||
| Project | is also stored in | GitHub Repository | 1 to 0..1 |
|
||||
| Gitea Repository | has | License | 1 to 0..1 (1 when GitHub is chosen) |
|
||||
| Mirror | copies from | Gitea Repository | 1 to 1 |
|
||||
| Mirror | copies to | GitHub Repository | 1 to 1 |
|
||||
| Mirror | is authorised by | Access Token | 0..* to 1 |
|
||||
| Local Project | is the working copy of | Project | 1 to 1 |
|
||||
| Local Project | has | Remote | 1 to 1..2 |
|
||||
| Remote | points to | Repository | 0..* to 1 |
|
||||
| Local Project | includes | Framework | 1 to 1 |
|
||||
| Local Project | has | Framework Setup | 1 to 1 |
|
||||
| Framework Setup | is installed from | Framework | 0..* to 1 |
|
||||
| Framework | provides | Template | 1 to 1..* |
|
||||
| Local Project | contains a copy of | Template | 1 to 0..* |
|
||||
| Summary | reports on | Project | 1 to 1 |
|
||||
|
||||
## Generalizations
|
||||
|
||||
| General | Specializations | Is-a justification |
|
||||
| --- | --- | --- |
|
||||
| Repository | Gitea Repository, GitHub Repository | Each is a Repository with the same name, visibility and owner rules; they differ in role (source of truth against mirror target) |
|
||||
|
||||
---
|
||||
|
||||
[UC-001]: ./uc-001/uc.md
|
||||
[SSD-001]: ./uc-001/ssd.md
|
||||
[DICT-001]: ./dictionary.md
|
||||
[DM-001]: ./uc-001/dm.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -9,7 +9,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Optional GitHub; choosing GitHub applies the AGPL license to the Gitea repository<br>Cited US-001.01<br>Target date accepted | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
@@ -30,6 +31,7 @@ Decide whether the secure base of `create-project.sh` is sound enough to build t
|
||||
| 3 | No token appears in stdout, stderr or a log in any test, including failure paths | None found | Any found |
|
||||
| 4 | Missing `git` or `curl` stops the script before any change | Stops with a clear message | Continues |
|
||||
| 5 | `.env` is ignored by git; both example files contain placeholders only | Verified | Real value present |
|
||||
| 6 | All acceptance criteria of US-001.01 in [US-001] are met | Verified | Any unmet |
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -41,6 +43,7 @@ Decide whether the secure base of `create-project.sh` is sound enough to build t
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| User story US-001.01 | [US-001] |
|
||||
| Objective 6 (no credential exposure, no overwrite) | [BC-001] |
|
||||
| Success criteria 1 and 6 | [BC-001] |
|
||||
|
||||
@@ -53,7 +56,7 @@ Decide whether the secure base of `create-project.sh` is sound enough to build t
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-10-16 — proposed; the Business Case sets no deadline.
|
||||
2026-10-16 — the Business Case sets no deadline, so it does not constrain this date; accepted together with PP-001.
|
||||
|
||||
## Tasks
|
||||
|
||||
@@ -63,7 +66,7 @@ Decide whether the secure base of `create-project.sh` is sound enough to build t
|
||||
| 2 | Script skeleton with strict mode and safe helpers | `set -Eeuo pipefail`, an ERR/EXIT trap, `mktemp` with `umask 077` and cleanup on exit, small single-purpose functions, logging helpers that redact known secret values, and no `rm -rf`. Follow the framework `coding-conventions` Shell rules. | No | |
|
||||
| 3 | Safe parser for config.env and .env | Read `KEY=VALUE` lines without `source` or `eval`; accept only whitelisted keys, strip optional quotes, reject control characters, and validate that service URLs are well-formed `https` and that credentials are non-empty. Warn when `.env` is readable by other users. | No | |
|
||||
| 4 | Tool check and HTTP helper | Check `git` and `curl` (and optional `jq`, with a fallback parser for the few JSON fields needed) before any change. Wrap `curl` so tokens go through a private curl config file or stdin rather than the command line (visible in process lists), with `--fail-with-body` handling, timeouts, and error messages that carry the HTTP status but never the credential. | No | |
|
||||
| 5 | Interactive prompts and input validation | Prompt for repository name, description, visibility and the owner or organization separately for GitHub and Gitea, with defaults taken from configuration. Validate names against both hosts' allowed characters. `GITHUB_USER` is only the authenticating account and is never assumed to be the owner. | Yes | [UC-001] |
|
||||
| 5 | Interactive prompts and input validation | Prompt for repository name, description, visibility, whether to also create a GitHub repository (which also applies the AGPL license to the Gitea repository), and the owner or organization separately for Gitea and (if chosen) GitHub, with defaults taken from configuration. Validate names against both hosts' allowed characters. `GITHUB_USER` is only the authenticating account and is never assumed to be the owner. | Yes | [UC-001] |
|
||||
| 6 | .gitignore and test harness | Add `.env` and temporary files to `.gitignore`. Add a test harness with stubbed `curl` and `git` that covers parser rejection cases and the no-token-in-output check, run alongside `shellcheck`. | No | |
|
||||
|
||||
---
|
||||
@@ -72,3 +75,4 @@ Decide whether the secure base of `create-project.sh` is sound enough to build t
|
||||
[US-001]: ../user-stories.md
|
||||
[UC-001]: ../uc-001/uc.md
|
||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
|
||||
@@ -9,28 +9,30 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Optional GitHub; choosing GitHub applies the AGPL license to the Gitea repository<br>Cited US-001.02<br>Purpose and criterion 1 reworded for optional GitHub<br>Target date accepted | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Decide whether the script creates both remote repositories under the correct owners and configures the Gitea to GitHub push mirror reliably, including when something fails halfway.
|
||||
Decide whether the script creates the Gitea repository and, if GitHub is chosen, the GitHub repository under the correct owners, and configures the Gitea to GitHub push mirror reliably, including when something fails halfway.
|
||||
|
||||
## Deliverable
|
||||
|
||||
`create-project.sh` creating an empty GitHub repository and an empty Gitea repository, configuring the push mirror, verifying it, and printing a summary of what exists, with documented token permissions.
|
||||
`create-project.sh` creating a Gitea repository (with the AGPL license when GitHub is chosen, otherwise empty) and, if chosen, an empty GitHub repository, configuring the push mirror, verifying it, and printing a summary of what exists, with documented token permissions.
|
||||
|
||||
## Go / No-Go Criteria
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Repositories are created under the owner chosen at the prompt, for a user owner and for an organization owner, on both hosts | Both verified | Any under the wrong owner |
|
||||
| 2 | Both repositories are empty (no README, licence or `.gitignore` generated by the host) | Verified | Any commit present |
|
||||
| 3 | A commit pushed to Gitea appears on GitHub; nothing flows the other way | Verified | Wrong direction or no sync |
|
||||
| 1 | Repositories are created under the owner chosen at the prompt, for a user owner and for an organization owner, on each host used | Both verified | Any under the wrong owner |
|
||||
| 2 | The GitHub repository is created empty. The Gitea repository holds only the AGPL license file when GitHub is chosen, otherwise it is empty. Neither has a generated README or `.gitignore` | Verified | Any other commit present |
|
||||
| 3 | When GitHub is chosen, a commit pushed to Gitea (including the license file) appears on GitHub; nothing flows the other way. When GitHub is not chosen, no GitHub call is made | Verified | Wrong direction, no sync, or a GitHub call without the choice |
|
||||
| 4 | Mirror credentials are never part of a remote URL, log or output | None found | Any found |
|
||||
| 5 | With an invalid token on one host, the script stops before creating anything or reports exactly what was created and how to continue | Verified | Silent or misleading |
|
||||
| 6 | Required token scopes and the `sync_on_commit` limitation are documented | Present in README draft | Missing |
|
||||
| 7 | All acceptance criteria of US-001.02 in [US-001] are met | Verified | Any unmet |
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -42,6 +44,7 @@ Decide whether the script creates both remote repositories under the correct own
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| User story US-001.02 | [US-001] |
|
||||
| Objectives 1, 2 and 3 | [BC-001] |
|
||||
| Success criteria 2, 3 and 4 | [BC-001] |
|
||||
|
||||
@@ -54,16 +57,16 @@ Decide whether the script creates both remote repositories under the correct own
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-10-30 — proposed.
|
||||
2026-10-30 — the Business Case sets no deadline, so it does not constrain this date; accepted together with PP-001.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Preflight checks before any creation | With read-only calls, verify both tokens (`GET /user` on each host), that the owner exists and the token may create repositories there, and that the name is free on both hosts, so one host is not created and the other refused. Also test SSH to `git.tirsystem.com` on port 10022 (needed for the submodule); its result decides whether `origin` later uses SSH (test passed) or HTTPS (default). | Yes | [UC-001] |
|
||||
| 2 | Create the empty GitHub repository | `POST /user/repos` when the owner is the authenticated user, otherwise `POST /orgs/{org}/repos`, with `auto_init` false. Use the visibility from the prompt. Report the HTTP status and a hint on failure, without exposing the token. | Yes | [UC-001] |
|
||||
| 3 | Create the empty Gitea repository | `POST /user/repos` or `POST /orgs/{org}/repos` on the Gitea API base, with `auto_init` false and no template, readme, licence or gitignore. Derive the clone URL from `GITEA_URL` and the selected owner. | Yes | [UC-001] |
|
||||
| 4 | Configure the Gitea to GitHub push mirror | Call `POST /repos/{owner}/{repo}/push_mirrors` with `remote_address` (the GitHub HTTPS URL built from `GITHUB_WEB_URL` and the GitHub owner, without credentials), `remote_username` (`GITHUB_USER`), `remote_password` (`GITHUB_PAT`), an interval and `sync_on_commit`. Gitea has a known issue where `sync_on_commit` can be ignored on API creation, so read the result back, trigger `push_mirrors-sync`, and report the effective setting. The PAT needs push access to the target repository (classic `repo` scope, or a fine-grained token with Contents write). Stop with a clear error if it is missing. Confirm the mirror feature is enabled on the Gitea server. | Yes | [UC-001] |
|
||||
| 1 | Preflight checks before any creation | With read-only calls, verify the tokens needed for the chosen hosts (`GET /user`; GitHub only when chosen), that the owner exists and the token may create repositories there, and that the name is free on both hosts, so one host is not created and the other refused. Also test SSH to `git.tirsystem.com` on port 10022 (needed for the submodule); its result decides whether `origin` later uses SSH (test passed) or HTTPS (default). | Yes | [UC-001] |
|
||||
| 2 | Create the empty GitHub repository (optional) | Only when the Maintainer chose GitHub. `POST /user/repos` when the owner is the authenticated user, otherwise `POST /orgs/{org}/repos`, with `auto_init` false. Use the visibility from the prompt. Report the HTTP status and a hint on failure, without exposing the token. | Yes | [UC-001] |
|
||||
| 3 | Create the Gitea repository, with AGPL license when GitHub is chosen | `POST /user/repos` or `POST /orgs/{org}/repos` on the Gitea API base, with no template, readme or gitignore. When GitHub is chosen, send `license` `AGPL-3.0` (listed by `GET /licenses`; check it exists first) with `auto_init` true so the license file is committed and the repository is not empty; otherwise `auto_init` false and the repository stays empty. Derive the clone URL from `GITEA_URL` and the selected owner. | Yes | [UC-001] |
|
||||
| 4 | Configure the Gitea to GitHub push mirror (when GitHub is chosen) | Skip this step when GitHub was not chosen. Otherwise call `POST /repos/{owner}/{repo}/push_mirrors` with `remote_address` (the GitHub HTTPS URL built from `GITHUB_WEB_URL` and the GitHub owner, without credentials), `remote_username` (`GITHUB_USER`), `remote_password` (`GITHUB_PAT`), an interval and `sync_on_commit`. Gitea has a known issue where `sync_on_commit` can be ignored on API creation, so read the result back, trigger `push_mirrors-sync`, and report the effective setting. The PAT needs push access to the target repository (classic `repo` scope, or a fine-grained token with Contents write). Stop with a clear error if it is missing. Confirm the mirror feature is enabled on the Gitea server. | Yes | [UC-001] |
|
||||
| 5 | Partial-failure reporting and resume | Track each step (GitHub repo, Gitea repo, mirror) in a state summary. When a step fails, print what succeeded, what did not, and the exact way to continue. When rerun and the repository already exists and is empty, offer to reuse it instead of failing. Never delete anything automatically. | No | |
|
||||
| 6 | Document token permissions and API limitations | Draft the README sections on required GitHub PAT permissions (create in user or org, push), Gitea token scopes (repository write, organization write for org repos), the fact that Gitea stores the mirror password server-side, and the `sync_on_commit` limitation. | No | |
|
||||
|
||||
@@ -74,3 +77,4 @@ Decide whether the script creates both remote repositories under the correct own
|
||||
[UC-001]: ../uc-001/uc.md
|
||||
[MIL-001]: ./mil-001-foundation.md
|
||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Optional GitHub; choosing GitHub applies the AGPL license to the Gitea repository<br>Cited US-001.03<br>Traces to objective 7<br>Criterion 2 names the framework URL<br>Target date accepted | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
@@ -25,13 +26,14 @@ Complete `create-project.sh` (local directory, credential-free remotes, framewor
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `git remote -v` shows `origin` (Gitea) and `github` with no credentials in either URL | Verified | Any credential |
|
||||
| 2 | `framework` is a submodule at the configured URL and the install scripts have run once, in the documented order | Verified | Missing or repeated |
|
||||
| 1 | `git remote -v` shows `origin` (Gitea) and, when GitHub was chosen, `github`, with no credentials in any URL; with GitHub chosen the local history contains the license commit | Verified | Any credential, or a missing license commit |
|
||||
| 2 | `framework` is a submodule of `ssh://git@git.tirsystem.com:10022/TirSystem/SQA-QC-Framework.git` and the install scripts have run once, in the documented order | Verified | Missing or repeated |
|
||||
| 3 | An existing directory, `AGENTS.md` or `docs/artifact-registry.md` is never overwritten without a yes | Verified by a second run | Overwritten |
|
||||
| 4 | With the plan gate enabled, a commit touching `src/` or `tests/` without a `Task: MIL-NNN#N` trailer is refused | Verified | Accepted |
|
||||
| 5 | An existing `core.hooksPath` is reported and not replaced without consent | Verified | Replaced silently |
|
||||
| 6 | README covers installation, configuration, usage examples, security decisions, error handling and stakeholders, in clear English | Reviewed by S02 | Section missing |
|
||||
| 7 | End-to-end run on disposable repositories passes and the final review records no open security finding | Recorded in an `RC-*` | Open finding |
|
||||
| 8 | All acceptance criteria of US-001.03 in [US-001] are met | Verified | Any unmet |
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -43,8 +45,9 @@ Complete `create-project.sh` (local directory, credential-free remotes, framewor
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| Objectives 4, 5 and 6 | [BC-001] |
|
||||
| Success criteria 1, 5 and 6 | [BC-001] |
|
||||
| User story US-001.03 | [US-001] |
|
||||
| Objectives 4, 5, 6 and 7 | [BC-001] |
|
||||
| Success criteria 1, 5, 6 and 7 | [BC-001] |
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -55,13 +58,13 @@ Complete `create-project.sh` (local directory, credential-free remotes, framewor
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-11-13 — proposed.
|
||||
2026-11-13 — the Business Case sets no deadline, so it does not constrain this date; accepted together with PP-001.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Create the local project directory and credential-free remotes | After consent, create the directory (refuse to reuse an existing one without a yes), run `git init` on `main`, and add `origin` (Gitea) and `github` using URLs derived from the configured base URLs and the selected owners, with no token in any URL. `origin` uses HTTPS derived from `GITEA_URL`, unless the SSH test from the preflight passed, in which case it uses SSH on port 10022. Do not make a commit. | Yes | [UC-001] |
|
||||
| 1 | Create the local project directory and credential-free remotes | After consent, create the directory (refuse to reuse an existing one without a yes), run `git init` on `main`, and add `origin` (Gitea) and, only if GitHub was chosen, `github` using URLs derived from the configured base URLs and the selected owners, with no token in any URL. `origin` uses HTTPS derived from `GITEA_URL`, unless the SSH test from the preflight passed, in which case it uses SSH on port 10022. When the Gitea repository is not empty (GitHub chosen, AGPL license), fetch it and check out its default branch so the local history starts from the license commit. Do not make a commit. | Yes | [UC-001] |
|
||||
| 2 | Add the framework submodule | From the project directory run `git submodule add ssh://git@git.tirsystem.com:10022/TirSystem/SQA-QC-Framework.git framework`. Check beforehand that SSH on port 10022 works and stop with an actionable message if not. Document that this SSH access must be configured. | Yes | [UC-001] |
|
||||
| 3 | Install skills and git hooks, with optional plan gate | Run `framework/scripts/install-skills.sh` and `install-git-hooks.sh`. The hook installer only sets `core.hooksPath` to `framework/githooks` and is safe to rerun, but it would replace a different existing value, so read the current value first and ask. Offer `--enable-plan-gate` as an optional choice, which requires a `Task: MIL-NNN#N` trailer on commits changing `src/` or `tests/`. | Yes | [UC-001] |
|
||||
| 4 | Copy the framework templates without overwriting | Copy `AGENTS-template.md` to `AGENTS.md` and `artifact-registry-template.md` to `docs/artifact-registry.md` after `mkdir -p docs`, asking before replacing an existing file. | Yes | [UC-001] |
|
||||
@@ -75,3 +78,4 @@ Complete `create-project.sh` (local directory, credential-free remotes, framewor
|
||||
[UC-001]: ../uc-001/uc.md
|
||||
[MIL-002]: ./mil-002-repositories-and-mirror.md
|
||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
|
||||
+11
-6
@@ -9,7 +9,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Stories per phase: US-001.01 to US-001.03<br>Dates accepted | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
@@ -28,9 +29,9 @@ Schedule the three phases that deliver RepoFoundry (`create-project.sh` and its
|
||||
|
||||
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| Foundation | [MIL-001] | 2026-10-05 to 2026-10-16 | 2026-10-16 | S01 | US-001.01 | Safe skeleton, config parsing, prompts, tests | |
|
||||
| Repositories and Mirror | [MIL-002] | 2026-10-19 to 2026-10-30 | 2026-10-30 | S02 | US-001.01 | GitHub and Gitea repositories and the push mirror | |
|
||||
| Scaffold and Release | [MIL-003] | 2026-11-02 to 2026-11-13 | 2026-11-13 | S01 | US-001.01 | Local project, framework, README, final review | |
|
||||
| Foundation | [MIL-001] | 2026-10-05 to 2026-10-16 | 2026-10-16 | S01 | US-001.01 | Safe skeleton, config parsing, prompts, tests | [Milestone 43] |
|
||||
| Repositories and Mirror | [MIL-002] | 2026-10-19 to 2026-10-30 | 2026-10-30 | S02 | US-001.02 | GitHub and Gitea repositories and the push mirror | [Milestone 44] |
|
||||
| Scaffold and Release | [MIL-003] | 2026-11-02 to 2026-11-13 | 2026-11-13 | S01 | US-001.03 | Local project, framework, README, final review | [Milestone 45] |
|
||||
|
||||
```plantuml
|
||||
@startgantt
|
||||
@@ -72,11 +73,11 @@ A No-Go moves every later date by the time needed to rework the failed criteria.
|
||||
|
||||
## Open Issues
|
||||
|
||||
- Confirm the proposed dates (S01).
|
||||
- Decided: the proposed dates are accepted with this plan (S01 asked for its acceptance on 2026-10-05); a change needs a new Version History row.
|
||||
- Decided: `origin` uses HTTPS derived from `GITEA_URL`, unless the SSH test passed, in which case it uses SSH on port 10022.
|
||||
- Decided: use case [UC-001] "Create a new project" is created, with [SSD-001]; tasks that implement its steps reference it.
|
||||
- Decided: S01 and S02 are both held by one person for now.
|
||||
- Open: this plan says the script does not make the first commit; confirm.
|
||||
- Open: this plan says the script does not make the first commit; confirm before MIL-003 starts.
|
||||
|
||||
---
|
||||
|
||||
@@ -89,3 +90,7 @@ A No-Go moves every later date by the time needed to rework the failed criteria.
|
||||
[UC-001]: ./uc-001/uc.md
|
||||
[SSD-001]: ./uc-001/ssd.md
|
||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
[Milestone 43]: https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry/milestone/43
|
||||
[Milestone 44]: https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry/milestone/44
|
||||
[Milestone 45]: https://git.tirsystem.com/TirSystem-BashScript/RepoFoundry/milestone/45
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# SQA Review Record: US-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-001 |
|
||||
| CrossReference | [US-001], [QC-US-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version; re-reviewed after UCD-001 and the split into three stories | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [US-001]
|
||||
- Checklist used: [QC-US-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Follows INVEST criteria (Independent, Negotiable, Valuable, Estimable, Small, Testable) | Pass | INVEST check recorded; the only exception (Independent, on US-001.02 and US-001.03) is stated and follows the milestone order. |
|
||||
| 2 | Written in "As a / I want / So that" form | Pass | All three statements follow As a / I want / So that. |
|
||||
| 3 | Clear, testable acceptance criteria are included | Pass | Three Given/When/Then criteria per story. |
|
||||
| 4 | Traceable to a use case or epic | Pass | Each story traces to UC-001 steps and one milestone. |
|
||||
| 5 | Story is sized to fit within a single iteration | Pass | Re-checked: the epic was split into three stories, one per milestone, each fitting one two-week phase. |
|
||||
| 6 | Story statement avoids technical implementation detail | Pass | Domain terms only (repository, mirror, framework); no tools or commands. |
|
||||
| 7 | Role named in the story matches an actor defined in the Use Case Diagram | Pass | The role Maintainer matches the actor in UCD-001 (re-checked after UCD-001 was created). |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass and the optional criterion 5 now passes after the split. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework's independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Create a Use Case Diagram (UCD-001) and re-check criterion 7 | S01 | Done 2026-10-05 |
|
||||
| Split US-001.01 into per-phase stories | S01 | Done 2026-10-05 |
|
||||
|
||||
---
|
||||
|
||||
[US-001]: ../../user-stories.md
|
||||
[QC-US-001]: ../../../framework/qc/qc-user-story.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,53 @@
|
||||
# SQA Review Record: UC-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-002 |
|
||||
| CrossReference | [UC-001], [QC-UC-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version; criterion 10 re-checked after UCD-001 | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [UC-001]
|
||||
- Checklist used: [QC-UC-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Consists of a single, concise paragraph summarizing only the primary success scenario | N-A | Format is Fully Dressed. |
|
||||
| 2 | Written as an informal multi-paragraph narrative; may mention some alternate flows without formal structure | N-A | Format is Fully Dressed. |
|
||||
| 3 | All standard sections are present: actors, preconditions, postconditions, main success scenario, alternative/exception flows | Pass | Scope, level, primary actor, stakeholders, preconditions, postconditions, main scenario and extensions are present. |
|
||||
| 4 | Preconditions and postconditions are explicitly defined | Pass | Preconditions and postconditions are defined. |
|
||||
| 5 | Primary actor is explicitly stated | Pass | Primary actor: Maintainer. |
|
||||
| 6 | Stakeholders and their interests are stated | Pass | S01, S02 and S03 with their interests. |
|
||||
| 7 | Main success scenario is written as clear, numbered steps | Pass | Ten numbered steps; optional steps 5 and 7 are marked. |
|
||||
| 8 | Alternative/exception flows correctly reference `<<include>>`/`<<extend>>` use cases where relevant, per UML 2.5.1 | N-A | No include or extend relationships are used. |
|
||||
| 9 | Explicit business rules are captured per step where applicable, rather than embedded loosely in narrative text | Pass | Business rules table per step. |
|
||||
| 10 | Naming of actors and use case title is consistent with the corresponding Use Case Diagram and User Stories | Pass | Title and actor match US-001 and UCD-001 (re-checked after UCD-001 was created). |
|
||||
| 11 | Scope/level (e.g. summary, user-goal, subfunction) is explicitly stated | Pass | Scope RepoFoundry, level user-goal. |
|
||||
| 12 | Use case is written from the actor's goal perspective, free of UI or implementation detail | Pass | Written from the Maintainer's goal. A reference to a hooks setting option was reworded during this review; HTTPS or SSH, the submodule and the AGPL license remain because they are the Maintainer's stated requirements. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass after UCD-001 was created. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework's independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Create the Use Case Diagram (see RC-001) and re-check criterion 10 | S01 | Done 2026-10-05 |
|
||||
|
||||
---
|
||||
|
||||
[UC-001]: ../../uc-001/uc.md
|
||||
[QC-UC-001]: ../../../framework/qc/qc-use-case.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,48 @@
|
||||
# SQA Review Record: SSD-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-003 |
|
||||
| CrossReference | [SSD-001], [QC-SSD-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [SSD-001]
|
||||
- Checklist used: [QC-SSD-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Input/output messages match the corresponding Use Case's main success scenario step-for-step | Pass | Two operations cover the actor-system exchange of steps 1 to 3; steps 4 to 10 are internal to the system and the SSD says so. |
|
||||
| 2 | Actor and System are treated strictly as black boxes (system shown as `:System`) | Pass | Only the Maintainer and `:System` appear. |
|
||||
| 3 | Object creation/destruction of the System instance handled explicitly where relevant | Pass | Lifecycle Notes describe one run of the system. |
|
||||
| 4 | Return values are shown for operations that produce one, using dashed return arrows | Pass | Dashed returns for the prompt set, the checks and the summary. |
|
||||
| 5 | Alternate/exceptional flows are represented separately (or explicitly out of scope noted) | Pass | Failure flows are stated as out of scope. |
|
||||
| 6 | Message names are verb phrases consistent with the use case's system responsibilities | Pass | startProjectCreation and provideProjectDetails. |
|
||||
| 7 | Diagram references the specific Use Case (name and ID) it depicts | Pass | Names UC-001 and the main success scenario. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework's independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[SSD-001]: ../../uc-001/ssd.md
|
||||
[QC-SSD-001]: ../../../framework/qc/qc-ssd.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,48 @@
|
||||
# SQA Review Record: DM-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-004 |
|
||||
| CrossReference | [DM-001], [QC-DM-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [DM-001]
|
||||
- Checklist used: [QC-DM-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Uses ubiquitous/business language throughout; no technical or implementation jargon (e.g. no "table", "class", "pointer") | Pass | Business terms; `API address` is the only technical word and is the data the Maintainer configures. |
|
||||
| 2 | Multiplicities on associations are correct and complete (e.g. `1..*`, `0..1`) | Pass | All 21 associations have both multiplicities, including the optional GitHub paths. |
|
||||
| 3 | No operation/method signatures shown — attributes and associations only | Pass | Attributes and associations only. |
|
||||
| 4 | Associations are named with an unambiguous reading direction | Pass | Each association has a reading direction. |
|
||||
| 5 | Generalization/specialization used correctly, reflecting true "is-a" relationships, not misused for code reuse | Pass | Only Repository specializes into Gitea Repository and GitHub Repository, justified as is-a. |
|
||||
| 6 | Every concept traces to a noun phrase found in the use cases or glossary | Pass | The concept table traces every concept to a UC-001 step or precondition. |
|
||||
| 7 | Attributes are simple domain data (no foreign-key-like references or object pointers modeled as attributes) | Pass | Attributes are simple data; links are associations. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass. Project DM checked: see RC-005. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework's independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[DM-001]: ../../uc-001/dm.md
|
||||
[QC-DM-001]: ../../../framework/qc/qc-domain-model.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,48 @@
|
||||
# SQA Review Record: DM-002
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-005 |
|
||||
| CrossReference | [DM-002], [QC-DM-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [DM-002]
|
||||
- Checklist used: [QC-DM-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Uses ubiquitous/business language throughout; no technical or implementation jargon (e.g. no "table", "class", "pointer") | Pass | Same wording as DM-001. |
|
||||
| 2 | Multiplicities on associations are correct and complete (e.g. `1..*`, `0..1`) | Pass | Same associations as DM-001, all with multiplicities. |
|
||||
| 3 | No operation/method signatures shown — attributes and associations only | Pass | No operations. |
|
||||
| 4 | Associations are named with an unambiguous reading direction | Pass | Reading directions present. |
|
||||
| 5 | Generalization/specialization used correctly, reflecting true "is-a" relationships, not misused for code reuse | Pass | Same single generalization. |
|
||||
| 6 | Every concept traces to a noun phrase found in the use cases or glossary | Pass | Sources cite UC-001; no concept without a source. |
|
||||
| 7 | Attributes are simple domain data (no foreign-key-like references or object pointers modeled as attributes) | Pass | Same attributes as DM-001. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — Consolidated model created from DM-001; with one use case there is nothing to reconcile and no conflict. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework's independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[DM-002]: ../../domain-model.md
|
||||
[QC-DM-001]: ../../../framework/qc/qc-domain-model.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,48 @@
|
||||
# SQA Review Record: OC-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-006 |
|
||||
| CrossReference | [OC-001], [QC-OC-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [OC-001]
|
||||
- Checklist used: [QC-OC-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Method signature is complete: operation name, parameter types, and return type | Pass | Both signatures give name, parameter types and return type. |
|
||||
| 2 | Preconditions explicitly list required state before execution | Pass | Preconditions stated; the first operation has none, stated explicitly. |
|
||||
| 3 | Postconditions explicitly describe resulting state using Larman's "instance created/associated/attribute modified" style | Pass | Postconditions P1 to P4 and P1 to P13 describe instances created, associated or set. |
|
||||
| 4 | Exceptions and error conditions are documented, including the triggering precondition failure | Pass | Exceptions list the failing precondition and the outcome. |
|
||||
| 5 | Operation is explicitly traceable to a single SSD message | Pass | One contract per SSD message. |
|
||||
| 6 | Contract avoids specifying implementation/algorithmic details (declarative, not procedural) | Pass | Declarative state changes; no algorithm. |
|
||||
| 7 | Cross-references the Domain Model classes/associations affected by pre/postconditions | Pass | Uses the IT terms of DICT-001 for the concepts of DM-001. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework's independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[OC-001]: ../../uc-001/oc.md
|
||||
[QC-OC-001]: ../../../framework/qc/qc-operation-contract.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,49 @@
|
||||
# SQA Review Record: SD-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-007 |
|
||||
| CrossReference | [SD-001], [QC-SD-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [SD-001]
|
||||
- Checklist used: [QC-SD-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Message passing strictly follows UML sync/async/return arrow syntax | Pass | Solid arrows for calls and dashed arrows for returns. |
|
||||
| 2 | GRASP/GoF patterns applied and explicitly annotated where used (e.g. Controller, Observer, Mediator, Factory) | Pass | Controller, Pure Fabrication, Creator, Facade and Protection from variations are annotated. |
|
||||
| 3 | Lifelines show activation bars matching actual processing time/call nesting | Pass | Activations follow the call nesting. |
|
||||
| 4 | Object creation and destruction shown with correct UML notation (`create`/`destroy` messages, X on lifeline) | Pass | Creation and destruction are shown for every transient object. Fixed during this review: creation and destruction of the two host clients were missing. |
|
||||
| 5 | Diagram realizes the postconditions of a specific Operation Contract | Pass | The coverage tables map every postcondition P1 to P4 and P1 to P13 to a message. |
|
||||
| 6 | Responsibility assignment favors low coupling/high cohesion (no god-object receiving all messages) | Pass | The controller only sequences; work sits in the helpers. |
|
||||
| 7 | Loop, alt, and opt combined fragments used correctly for conditional/repeated behavior | Pass | `alt` for the license choice, `opt` for the optional GitHub steps. |
|
||||
| 8 | Each exception of the realized Operation Contract is shown as an `alt` or `opt` fragment, or its absence is justified | Pass | Failure paths are not drawn and the Responsibility Check says why. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass after the fix noted at criterion 4. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework's independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[SD-001]: ../../uc-001/sd.md
|
||||
[QC-SD-001]: ../../../framework/qc/qc-sequence-diagram.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,48 @@
|
||||
# SQA Review Record: DICT-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-008 |
|
||||
| CrossReference | [DICT-001], [QC-DICT-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [DICT-001]
|
||||
- Checklist used: [QC-DICT-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Every row has a PO term, its language, an IT term and a definition | Pass | All 17 rows are complete. |
|
||||
| 2 | Each PO term maps to exactly one IT term and the reverse (no synonyms) | Pass | 17 PO terms map to 17 distinct IT terms. |
|
||||
| 3 | Every Domain Model concept has a row, and the Domain Model uses its PO term | Pass | All 17 concepts of DM-001 and DM-002 have a row and the models use the PO terms. |
|
||||
| 4 | The Operation Contracts, Sequence Diagrams, Design Class Diagrams and ERD use the IT term, not the PO term | Pass | OC-001 and SD-001 use the IT terms. |
|
||||
| 5 | Definitions are written in the PO language and are one sentence | Pass | One sentence each, in English. |
|
||||
| 6 | "Used as PO term in" and "Used as IT term in" name artifact types that exist in the project | Pass | Fixed during this review: DCD was removed from the IT-term column because no DCD exists. |
|
||||
| 7 | Translated artifacts (`<artifact>.<language>.md`) use the PO terms of the dictionary | N-A | The PO language is English; no translations. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework's independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[DICT-001]: ../../dictionary.md
|
||||
[QC-DICT-001]: ../../../framework/qc/qc-dictionary.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,49 @@
|
||||
# SQA Review Record: UCD-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-009 |
|
||||
| CrossReference | [UCD-001], [QC-UCD-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [UCD-001]
|
||||
- Checklist used: [QC-UCD-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Actors are defined with correct UML stereotypes (e.g. `<<System>>`, `<<Actor>>`) | Pass | Actor `<<Actor>>`, system boundary `<<System>>`. |
|
||||
| 2 | System boundary is clearly drawn and labeled | Pass | The boundary is drawn and labelled RepoFoundry. |
|
||||
| 3 | Include/extend relationships are used correctly per UML 2.5.1, not as generic "uses" arrows | N-A | No include or extend is used; the Relationships section says why. |
|
||||
| 4 | Every actor participates in at least one use case (no orphan actors) | Pass | The only actor, Maintainer, takes part in Create a new project. |
|
||||
| 5 | Diagram is traceable to a documented stakeholder need | Pass | The Maintainer is S01 and S02 in SA-001. |
|
||||
| 6 | Use case names are verb phrases describing actor goals, not internal system operations | Pass | Create a new project is a verb phrase and a goal. |
|
||||
| 7 | Diagram is free of implementation detail (e.g. UI widgets, database tables) | Pass | No tools, screens or tables. |
|
||||
| 8 | Actor and use case naming is consistent with corresponding Use Case and User Story documents | Pass | Names match US-001 and UC-001. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[UCD-001]: ../../use-case-diagram.md
|
||||
[QC-UCD-001]: ../../../framework/qc/qc-use-case-diagram.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,49 @@
|
||||
# SQA Review Record: BC-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-010 |
|
||||
| CrossReference | [BC-001], [QC-BC-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version; re-checked after objective 7 was added | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [BC-001]
|
||||
- Checklist used: [QC-BC-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | ROI/Cost-Benefit analysis is quantitative, or where qualitative, is explicitly justified | Pass | Qualitative, and now explicitly justified (internal tooling, no revenue, no baseline). Fixed during this review. |
|
||||
| 2 | Risks are identified with documented impact and mitigation | Pass | Six risks, each with impact and mitigation. |
|
||||
| 3 | Success criteria are measurable, stating explicit targets rather than vague aspirations | Pass | Seven criteria with targets and measures; criteria 2 and 3 were reworded during this review for the optional GitHub path. |
|
||||
| 4 | Scope explicitly separates In Scope vs Out of Scope | Pass | In Scope and Out of Scope are separate subsections. Objective 7 (documentation) was added during the SA-001 review; it is covered by the README scope item and success criterion 7. |
|
||||
| 5 | Stakeholders are cross-referenced to Stakeholder Analysis IDs rather than re-described inline | Pass | The Stakeholders table cites S01, S02 and S03 with their interest only. Roles that had been re-described inline were removed during this review. |
|
||||
| 6 | Methodology and quality-standard foundation are stated explicitly (e.g. ISO/IEC 25010, Larman) | Pass | The framework, ISO/IEC 25010:2023 and the shell coding conventions are stated. |
|
||||
| 7 | Assumptions and constraints are explicit and clearly distinguished from one another | Pass | Assumptions and Constraints are separate lists. |
|
||||
| 8 | Document supports executive decision-making with a clear, unambiguous recommendation | Pass | Recommendation: Proceed. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass after the three fixes noted. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../../business-case.md
|
||||
[QC-BC-001]: ../../../framework/qc/qc-business-case.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,47 @@
|
||||
# SQA Review Record: MIL-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-011 |
|
||||
| CrossReference | [MIL-001], [QC-MIL-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-001]
|
||||
- Checklist used: [QC-MIL-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | The script skeleton with safe parsing, prompts, example files and a test harness. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Six criteria, each with an objective Go and No-Go (including all acceptance criteria of US-001.01). |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | None; it is the first phase. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Maps to Business Case objective 6, success criteria 1 and 6, and US-001.01. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Owner S01, approving reviewer S02. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | 2026-10-16, matches PP-001. The Business Case sets no duration, so nothing conflicts; the date is accepted together with the plan. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-001]: ../../milestones/mil-001-foundation.md
|
||||
[QC-MIL-001]: ../../../framework/qc/qc-milestones-gateways.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,49 @@
|
||||
# SQA Review Record: PP-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-012 |
|
||||
| CrossReference | [PP-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [PP-001]
|
||||
- Checklist used: none: no QC checklist exists for PP; the checks below come from the PP reference (Required sections, Validating)
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Required sections are present in order | Pass | All eight required sections are present, in order. |
|
||||
| 2 | Gateway Schedule agrees with each MIL-* (window, decision date, owner) | Pass | Three rows, one per MIL-*; windows, decision dates and owners match each milestone. |
|
||||
| 3 | Timeline diagram shows each phase and Go/No-Go decision | Pass | The Gantt has one bar and one Go/No-Go marker per phase. |
|
||||
| 4 | Plan fits the Business Case constraint | Pass | The Business Case sets no duration constraint, so there is no conflict; the plan states its dates are accepted by S01. |
|
||||
| 5 | Scope Coverage maps each Business Case scope item to a gateway | Pass | Every in-scope item of BC-001 maps to a gateway. |
|
||||
| 6 | Dependencies and the effect of a No-Go are stated | Pass | The chain MIL-001, MIL-002, MIL-003 and the effect of a No-Go are stated. |
|
||||
| 7 | Owners use stakeholder IDs | Pass | Owners are S01 and S02. |
|
||||
| 8 | Stories column matches the stories each gateway delivers | Pass | US-001.01, .02 and .03 match the milestones that deliver them. |
|
||||
| 9 | Open Issues is accurate | Pass | Open Issues lists one open item (first commit), assigned to MIL-003. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All checks pass. PP has no QC checklist, so the checks come from the PP reference (required sections and Validating). Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[PP-001]: ../../project-plan.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,49 @@
|
||||
# SQA Review Record: SA-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-013 |
|
||||
| CrossReference | [SA-001], [QC-SA-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [SA-001]
|
||||
- Checklist used: [QC-SA-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Power/Interest grid is filled for every stakeholder, with no gaps or unclassified entries | Pass | S01, S02 and S03 all have Power, Interest and a quadrant. |
|
||||
| 2 | Each stakeholder is assigned a unique, stable ID (e.g. S01-S11 style) reusable for RACI assignments in other artifacts | Pass | IDs S01 to S03, unique and used by the other artifacts. |
|
||||
| 3 | Roles and organizational context are defined with explicit Power and Interest levels, not just narrative description | Pass | Roles and levels are explicit. The organization is recorded as Not stated: the Product Owner gave no organization and none was invented. |
|
||||
| 4 | Communication needs (channel, frequency, deliverable type) are mapped to project phases or milestones | Pass | Channel, frequency and deliverable for each stakeholder, tied to MIL-001 to MIL-003. |
|
||||
| 5 | Conflicting stakeholder interests are identified with documented mitigation or resolution strategies | Pass | One conflict (convenience against consent prompts) with a mitigation. The mitigation was corrected during this review to point at the UC-001 business rules instead of an undefined option. |
|
||||
| 6 | Stakeholder concerns are explicitly traced to Business Case objectives | Pass | Every stakeholder traces to a Business Case objective. S03 had no objective; fixed during this review by adding objective 7 (documentation) to BC-001. |
|
||||
| 7 | Primary concerns are expressed in both business language and a recognized quality-attribute mapping (e.g. FURPS+) | Pass | Each concern has a business statement and a FURPS+ attribute. |
|
||||
| 8 | Document is understandable and navigable by non-technical stakeholders reviewing their own entry | Pass | Short tables in plain language. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass after the two fixes noted. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[SA-001]: ../../stakeholder-analysis.md
|
||||
[QC-SA-001]: ../../../framework/qc/qc-stakeholder-analysis.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,47 @@
|
||||
# SQA Review Record: MIL-002
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-014 |
|
||||
| CrossReference | [MIL-002], [QC-MIL-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-002]
|
||||
- Checklist used: [QC-MIL-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | The script creating the repositories and mirror, with a printed summary and documented token permissions. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Seven criteria, each with an objective Go and No-Go (including all acceptance criteria of US-001.02). Criterion 1 was reworded during this review for the optional GitHub path. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Depends on MIL-001, with the reason. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Maps to Business Case objectives 1 to 3, success criteria 2 to 4, and US-001.02. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Owner S02, approving reviewer S01. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | 2026-10-30, matches PP-001; the Business Case sets no duration, so nothing conflicts. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass after the wording fixes noted (Purpose and criterion 1). Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-002]: ../../milestones/mil-002-repositories-and-mirror.md
|
||||
[QC-MIL-001]: ../../../framework/qc/qc-milestones-gateways.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,47 @@
|
||||
# SQA Review Record: MIL-003
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-015 |
|
||||
| CrossReference | [MIL-003], [QC-MIL-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-003]
|
||||
- Checklist used: [QC-MIL-001]
|
||||
- Review date: 2026-10-05
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | The complete script, the README and a written final review with the external prerequisites. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Eight criteria, each with an objective Go and No-Go (including all acceptance criteria of US-001.03). Criterion 2 now names the framework URL; it said only the configured URL, which no configuration key defines. Fixed during this review. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Depends on MIL-002, with the reason. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Maps to Business Case objectives 4 to 7, success criteria 1, 5, 6 and 7, and US-001.03. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Owner S01, approving reviewer S02. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | 2026-11-13, matches PP-001; the Business Case sets no duration, so nothing conflicts. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria pass after the fix noted (criterion 2). Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| None | - | - |
|
||||
|
||||
---
|
||||
|
||||
[MIL-003]: ../../milestones/mil-003-scaffold-and-release.md
|
||||
[QC-MIL-001]: ../../../framework/qc/qc-milestones-gateways.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,80 @@
|
||||
# SQA Review Record: create-project.sh (MIL-001)
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-016 |
|
||||
| CrossReference | [MIL-001], [QC-SH-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [f4a397c] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: `create-project.sh` and `tests/` on branch `mil-001-foundation` (reviewed at `102dd24` plus the fixes listed below), the deliverable of [MIL-001]
|
||||
- Checklist used: [QC-SH-001]
|
||||
- Review date: 2026-10-05
|
||||
- Tool versions: bash 5.2.37, shellcheck 0.11.0, shfmt 3.14.1 (Windows, Git Bash)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Starts with `#!/usr/bin/env bash` and `set -euo pipefail` (or a comment explains the exception) | Pass | `set -Eeuo pipefail` follows the header comment. |
|
||||
| 2 | Every expansion is quoted; lists are arrays; tests use `[[ ]]` and `$(...)` | Pass | `shellcheck` is clean; no backticks or `[ ]`. |
|
||||
| 3 | Names follow the conventions: `kebab-case.sh` files, `snake_case` functions and variables, `UPPER_SNAKE` constants and environment variables | Pass | Fixed during this review: the boolean keys `use_github` and `plan_gate` were renamed `has_github` and `is_plan_gate_enabled` (the `is_` / `has_` rule). |
|
||||
| 4 | Passes `shellcheck` and `bash -n` with no unexplained `disable` comments | Pass | Clean with the tools above. Every `disable` carries its reason: SC2034 (namerefs and results read by callers), SC2094 (loop only uses the file name in messages), SC2004 (associative array key), and in `tests/` SC2016 and SC2034 (literal snippet text, results read by other files). |
|
||||
| 5 | Errors go to standard error with an `error:` message and a non-zero exit code; bad or missing arguments print a usage line | Pass | `die` and `usage_error` (exit 1 and 2); tests cover an unknown option and an option without a value. |
|
||||
| 6 | Temporary files use `mktemp` with a `trap ... EXIT` cleanup; no fixed `/tmp` names | Pass | Private directory created with `umask 077`; files removed one by one, directory with `rmdir`. Verified after a normal run, a failed run and SIGTERM (test added). SIGINT was not verified: see the action items. |
|
||||
| 7 | No secret is written in the script, echoed, or put on a command line; secrets come from the environment or a gitignored file | Pass | Fixed during this review: under `bash -x` the script printed the token 47 times in the trace. Tracing is now switched off with a warning, and a test fails if the guard is removed (checked by mutation). Tokens go through a private curl config file, never the command line; output is redacted; error messages name the key and line, never the value. |
|
||||
| 8 | A script that changes state outside its own directory defaults to a dry run or needs an explicit flag, and says so in its header | Pass | This version contacts no host and changes nothing; the header says so. It only creates a private temporary directory, removed on exit. |
|
||||
| 9 | A header comment states purpose, usage, options, environment variables and exit codes | Pass | All present, plus files, requirements, tracing and what the script implements; `--help` prints it. |
|
||||
| 10 | The script implements a task or design it cites; deviations are recorded | Pass | Fixed during this review: the header now cites MIL-001 tasks 1 to 6 (issues #3 to #8), US-001.01 and UC-001, and records the one deviation (the second `GITEA_URL` key is `GITEA_API_URL`). |
|
||||
| 11 | Behaviour is tested for success, failure and any disabled or bypass path | Pass | 209 checks: successful runs with and without GitHub, bad input, missing tools, network failure, redaction, tracing, termination. The script has no bypass flag. Mutation checks: a planted token leak and the removed tracing guard were both caught. |
|
||||
| 12 | Formatted with `shfmt` (or the project's formatter) | Pass | `shfmt -i 2 -ci` reports no difference. |
|
||||
| 13 | Safe to re-run: a second run does not duplicate or corrupt what the first did | Pass | The script keeps no state. |
|
||||
| 14 | Bash version and external tools it needs are stated; GNU-only options are named | Pass | Fixed during this review: the header now lists bash 4.4, git, curl, mktemp, optional jq, the base tools it calls and the GNU or BSD `stat` form. |
|
||||
|
||||
## Defects found and fixed during this review
|
||||
|
||||
| Defect | Fix | Test |
|
||||
| --- | --- | --- |
|
||||
| `bash -x` printed the tokens in the trace | Tracing is switched off with a warning | `test_tracing_does_not_leak_secrets` |
|
||||
| A byte order mark on the first line of a config file gave an unclear "expected KEY=VALUE" error | The mark is ignored | `test_byte_order_mark_is_accepted` |
|
||||
| `jq` on Windows added a carriage return to every value `json_get` returned | The carriage return is stripped | `test_json_get_with_and_without_jq` |
|
||||
| `json_get` without `jq` returned the last occurrence of a key on one-line JSON | It returns the first occurrence | `test_json_get_with_and_without_jq` |
|
||||
|
||||
## MIL-001 Go/No-Go check
|
||||
|
||||
| # | Criterion | Result |
|
||||
| --- | --- | --- |
|
||||
| 1 | `shellcheck create-project.sh` reports no errors | Go: clean |
|
||||
| 2 | Neither config file is `source`d; unknown keys and malformed lines are rejected | Go: parser tests, including values that would run a command |
|
||||
| 3 | No token appears in stdout, stderr or a log in any test, including failure paths | Go: end-to-end and trace tests |
|
||||
| 4 | Missing `git` or `curl` stops the script before any change | Go: empty `PATH` test, nothing created |
|
||||
| 5 | `.env` is ignored by git; both example files contain placeholders only | Go: tested |
|
||||
| 6 | All acceptance criteria of US-001.01 are met | Go: validation without execution, stop on a missing tool or bad value, prompts for every detail |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — All mandatory criteria of QC-SH-001 pass after the fixes above, and all six MIL-001 Go/No-Go criteria are met. Author and reviewer are the same person for now (S01 and S02 are both held by the Maintainer), so the framework independence rule is not met; re-review when a second person takes S02.
|
||||
|
||||
## Action Items
|
||||
|
||||
These are follow-ups, not conditions on the Go.
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Run `tests/run-tests.sh` on Linux and macOS (bash 4.4 or later), including the `.env` permission warning, which is skipped on Windows | S02 | 2026-10-30 |
|
||||
| Check by hand that Ctrl-C removes the temporary directory (a background test cannot send SIGINT) | S02 | 2026-10-30 |
|
||||
| Check the repository name rules of GitHub and Gitea in the MIL-002 preflight; the script only checks a common safe subset | S02 | 2026-10-30 |
|
||||
|
||||
---
|
||||
|
||||
[MIL-001]: ../milestones/mil-001-foundation.md
|
||||
[QC-SH-001]: ../../../framework/qc/qc-programming-shell.md
|
||||
[f4a397c]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/f4a397c1ecb839e390dd11972ea88c6bc3783964
|
||||
@@ -0,0 +1,80 @@
|
||||
# Traceability Matrix
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | TM-001 |
|
||||
| CrossReference | [BC-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version, UC-001 artifacts and baseline | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Tracks backward/forward links between artifact instances so that the Business Case's
|
||||
cross-artifact traceability success criterion is measurable. A row is added or
|
||||
updated whenever an artifact instance is created or reviewed.
|
||||
|
||||
## Traceability Table
|
||||
|
||||
| Artifact Instance | Type | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [BC-001] | BC | - | [SA-001], [PP-001], [MIL-001], [MIL-002], [MIL-003], [US-001], [UCD-001] | [RC-010] |
|
||||
| [SA-001] | SA | [BC-001] | [UCD-001], [UC-001], [DICT-001] | [RC-013] |
|
||||
| [PP-001] | PP | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003] | [RC-012] |
|
||||
| [MIL-001] | MIL | [BC-001], [PP-001] | [US-001] | [RC-011], [RC-016] |
|
||||
| [MIL-002] | MIL | [BC-001], [PP-001] | [US-001] | [RC-014] |
|
||||
| [MIL-003] | MIL | [BC-001], [PP-001] | [US-001] | [RC-015] |
|
||||
| [UCD-001] | UCD | [BC-001], [SA-001] | [US-001], [UC-001] | [RC-009] |
|
||||
| [US-001] | US | [BC-001], [UCD-001], [MIL-001], [MIL-002], [MIL-003] | [UC-001] | [RC-001] |
|
||||
| [UC-001] | UC | [UCD-001], [US-001], [SA-001] | [SSD-001], [DM-001] | [RC-002] |
|
||||
| [SSD-001] | SSD | [UC-001] | [OC-001] | [RC-003] |
|
||||
| [DM-001] | DM | [UC-001], [SSD-001] | [DM-002], [DICT-001], [OC-001] | [RC-004] |
|
||||
| [DM-002] | DM | [DM-001] | [DICT-001] | [RC-005] |
|
||||
| [DICT-001] | DICT | [BC-001], [SA-001], [DM-001], [DM-002] | [OC-001], [SD-001] | [RC-008] |
|
||||
| [OC-001] | OC | [SSD-001], [DM-001] | [SD-001] | [RC-006] |
|
||||
| [SD-001] | SD | [OC-001] | - | [RC-007] |
|
||||
|
||||
## Coverage Notes
|
||||
|
||||
- Reviewed so far: every artifact in the project (see the Last Reviewed column).
|
||||
- No Design Class Diagram, ERD, KPI, BMC or BPMN exists yet. `-` in Downstream means nothing is built on the artifact yet.
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[SA-001]: ../stakeholder-analysis.md
|
||||
[PP-001]: ../project-plan.md
|
||||
[MIL-001]: ../milestones/mil-001-foundation.md
|
||||
[MIL-002]: ../milestones/mil-002-repositories-and-mirror.md
|
||||
[MIL-003]: ../milestones/mil-003-scaffold-and-release.md
|
||||
[UCD-001]: ../use-case-diagram.md
|
||||
[US-001]: ../user-stories.md
|
||||
[UC-001]: ../uc-001/uc.md
|
||||
[SSD-001]: ../uc-001/ssd.md
|
||||
[DM-001]: ../uc-001/dm.md
|
||||
[DM-002]: ../domain-model.md
|
||||
[DICT-001]: ../dictionary.md
|
||||
[OC-001]: ../uc-001/oc.md
|
||||
[SD-001]: ../uc-001/sd.md
|
||||
[RC-001]: ./reviews/rc-001-user-story.md
|
||||
[RC-002]: ./reviews/rc-002-uc-001.md
|
||||
[RC-003]: ./reviews/rc-003-ssd-001.md
|
||||
[RC-004]: ./reviews/rc-004-dm-001.md
|
||||
[RC-005]: ./reviews/rc-005-dm-002.md
|
||||
[RC-006]: ./reviews/rc-006-oc-001.md
|
||||
[RC-007]: ./reviews/rc-007-sd-001.md
|
||||
[RC-008]: ./reviews/rc-008-dictionary.md
|
||||
[RC-009]: ./reviews/rc-009-ucd-001.md
|
||||
[RC-010]: ./reviews/rc-010-bc-001.md
|
||||
[RC-011]: ./reviews/rc-011-mil-001.md
|
||||
[RC-012]: ./reviews/rc-012-pp-001.md
|
||||
[RC-013]: ./reviews/rc-013-sa-001.md
|
||||
[RC-014]: ./reviews/rc-014-mil-002.md
|
||||
[RC-015]: ./reviews/rc-015-mil-003.md
|
||||
[RC-016]: ./reviews/rc-016-create-project-sh.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -9,7 +9,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Mitigation points to the UC-001 business rules<br>S03 traced to BC objective 7<br>Sign-off recorded | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
@@ -51,7 +52,7 @@ Identify who is affected by RepoFoundry and what each needs from it, so owners a
|
||||
|
||||
| Conflict | Stakeholders | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Convenience of one-step setup against strict consent prompts for every overwrite | S01, S02 | Prompt only where something would be changed; offer `--yes` only for non-destructive steps (to be decided in MIL-001) |
|
||||
| Convenience of one-step setup against strict consent prompts for every overwrite | S01, S02 | Prompt only where something would be replaced, as set out in the business rules of [UC-001]; steps that only create new items run without asking |
|
||||
|
||||
## Traceability Analysis
|
||||
|
||||
@@ -61,13 +62,16 @@ Identify who is affected by RepoFoundry and what each needs from it, so owners a
|
||||
| --- | --- | --- |
|
||||
| S01 | One-command setup | [BC-001] objectives 1–5 |
|
||||
| S02 | Credential safety, no overwrite | [BC-001] objective 6 |
|
||||
| S03 | Reusable documentation | [BC-001] objective 6 and the README deliverable |
|
||||
| S03 | Reusable documentation | [BC-001] objective 7 |
|
||||
|
||||
## Sign-Off
|
||||
|
||||
Pending review by S02.
|
||||
Reviewed in [RC-013] on 2026-10-05: Go.
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ./business-case.md
|
||||
[UC-001]: ./uc-001/uc.md
|
||||
[RC-013]: ./sqa/reviews/rc-013-sa-001.md
|
||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Domain Model (UC-001)
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | DM-001 |
|
||||
| CrossReference | [UC-001], [SSD-001], [DICT-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose and Scope
|
||||
|
||||
Covers [UC-001] "Create a new project". The concepts come from the nouns of that use case. Concept names are the PO terms recorded in [DICT-001]; the project-level model that consolidates all use cases is [DM-002].
|
||||
|
||||
## Diagram
|
||||
|
||||
Concepts, attributes and associations only — no operations.
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
class Maintainer {
|
||||
name
|
||||
}
|
||||
class Project {
|
||||
name
|
||||
description
|
||||
visibility
|
||||
}
|
||||
class Configuration
|
||||
class "Git Host" as GitHost {
|
||||
name
|
||||
web address
|
||||
API address
|
||||
}
|
||||
class "Access Token" as AccessToken {
|
||||
kind
|
||||
}
|
||||
class Owner {
|
||||
name
|
||||
kind
|
||||
}
|
||||
class Repository {
|
||||
name
|
||||
description
|
||||
visibility
|
||||
address
|
||||
}
|
||||
class "Gitea Repository" as GiteaRepository
|
||||
class "GitHub Repository" as GitHubRepository
|
||||
class License {
|
||||
name
|
||||
}
|
||||
class Mirror {
|
||||
interval
|
||||
sync on commit
|
||||
}
|
||||
class "Local Project" as LocalProject {
|
||||
directory
|
||||
}
|
||||
class Remote {
|
||||
name
|
||||
address
|
||||
}
|
||||
class Framework {
|
||||
name
|
||||
address
|
||||
}
|
||||
class "Framework Setup" as FrameworkSetup {
|
||||
plan gate enabled
|
||||
}
|
||||
class Template {
|
||||
name
|
||||
}
|
||||
class Summary {
|
||||
created items
|
||||
skipped items
|
||||
next steps
|
||||
}
|
||||
|
||||
Repository <|-- GiteaRepository
|
||||
Repository <|-- GitHubRepository
|
||||
|
||||
Maintainer "1" --> "0..*" Project : creates
|
||||
Configuration "1" --> "1..2" GitHost : defines
|
||||
Configuration "1" --> "1..2" AccessToken : holds
|
||||
AccessToken "1" --> "1" GitHost : gives access to
|
||||
GitHost "1" --> "0..*" Owner : has
|
||||
Owner "1" --> "0..*" Repository : owns
|
||||
Project "1" --> "1" GiteaRepository : is stored in
|
||||
Project "1" --> "0..1" GitHubRepository : is also stored in
|
||||
GiteaRepository "1" --> "0..1" License : has
|
||||
Mirror "1" --> "1" GiteaRepository : copies from
|
||||
Mirror "1" --> "1" GitHubRepository : copies to
|
||||
Mirror "0..*" --> "1" AccessToken : is authorised by
|
||||
LocalProject "1" --> "1" Project : is the working copy of
|
||||
LocalProject "1" --> "1..2" Remote : has
|
||||
Remote "0..*" --> "1" Repository : points to
|
||||
LocalProject "1" --> "1" Framework : includes
|
||||
LocalProject "1" --> "1" FrameworkSetup : has
|
||||
FrameworkSetup "0..*" --> "1" Framework : is installed from
|
||||
Framework "1" --> "1..*" Template : provides
|
||||
LocalProject "1" --> "0..*" Template : contains a copy of
|
||||
Summary "1" --> "1" Project : reports on
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Concept Table
|
||||
|
||||
| Concept | Definition | Attributes | Source (use case / glossary) |
|
||||
| --- | --- | --- | --- |
|
||||
| Maintainer | The person who creates a new project (S01 or S02) | name | [UC-001] primary actor |
|
||||
| Project | The new software project being set up | name, description, visibility | [UC-001] "new project", step 3 |
|
||||
| Configuration | The service addresses and access tokens the Maintainer has set up before starting | none | [UC-001] precondition, step 2 "configuration and credentials" |
|
||||
| Git Host | A service that holds repositories: Gitea or GitHub | name, web address, API address | [UC-001] steps 5 to 7 "GitHub", "Gitea" |
|
||||
| Access Token | A secret that lets the Maintainer act on a Git Host; it is never part of an address | kind | [UC-001] precondition "Gitea token", "GitHub PAT" |
|
||||
| Owner | The user or organization on a Git Host that owns repositories | name, kind (user or organization) | [UC-001] step 3 "owner" |
|
||||
| Repository | A place on a Git Host that holds a project's history | name, description, visibility, address | [UC-001] steps 5 and 6 "repository" |
|
||||
| Gitea Repository | The Repository on Gitea; the source of truth | none beyond Repository | [UC-001] step 6 |
|
||||
| GitHub Repository | The Repository on GitHub; receives its content from the Mirror | none beyond Repository | [UC-001] step 5 |
|
||||
| License | The legal terms file added to a Gitea Repository (AGPL-3.0) when GitHub is chosen | name | [UC-001] step 6 "AGPL license" |
|
||||
| Mirror | The push mirror that copies a Gitea Repository to a GitHub Repository | interval, sync on commit | [UC-001] step 7 "push mirror" |
|
||||
| Local Project | The project directory on the Maintainer's machine | directory | [UC-001] step 8 "local project" |
|
||||
| Remote | A named link from a Local Project to a Repository (`origin`, `github`) | name, address | [UC-001] step 8 "remote" |
|
||||
| Framework | The SQA-QC-Framework added to a Local Project | name, address | [UC-001] step 9 "framework submodule" |
|
||||
| Framework Setup | The skills and git hooks installed from the Framework, with the plan gate on or off | plan gate enabled | [UC-001] step 9 "skills and hooks", "plan gate" |
|
||||
| Template | A file the Framework provides to copy into a project (`AGENTS.md`, artifact registry) | name | [UC-001] step 9 "templates" |
|
||||
| Summary | The report of what was created, skipped or failed and how to continue | created items, skipped items, next steps | [UC-001] step 10 "summary" |
|
||||
|
||||
## Association Table
|
||||
|
||||
| From | Association (reading direction) | To | Multiplicity |
|
||||
| --- | --- | --- | --- |
|
||||
| Maintainer | creates | Project | 1 to 0..* |
|
||||
| Configuration | defines | Git Host | 1 to 1..2 (GitHub is optional) |
|
||||
| Configuration | holds | Access Token | 1 to 1..2 |
|
||||
| Access Token | gives access to | Git Host | 1 to 1 |
|
||||
| Git Host | has | Owner | 1 to 0..* |
|
||||
| Owner | owns | Repository | 1 to 0..* |
|
||||
| Project | is stored in | Gitea Repository | 1 to 1 |
|
||||
| Project | is also stored in | GitHub Repository | 1 to 0..1 |
|
||||
| Gitea Repository | has | License | 1 to 0..1 (1 when GitHub is chosen) |
|
||||
| Mirror | copies from | Gitea Repository | 1 to 1 |
|
||||
| Mirror | copies to | GitHub Repository | 1 to 1 |
|
||||
| Mirror | is authorised by | Access Token | 0..* to 1 |
|
||||
| Local Project | is the working copy of | Project | 1 to 1 |
|
||||
| Local Project | has | Remote | 1 to 1..2 |
|
||||
| Remote | points to | Repository | 0..* to 1 |
|
||||
| Local Project | includes | Framework | 1 to 1 |
|
||||
| Local Project | has | Framework Setup | 1 to 1 |
|
||||
| Framework Setup | is installed from | Framework | 0..* to 1 |
|
||||
| Framework | provides | Template | 1 to 1..* |
|
||||
| Local Project | contains a copy of | Template | 1 to 0..* |
|
||||
| Summary | reports on | Project | 1 to 1 |
|
||||
|
||||
## Generalizations
|
||||
|
||||
| General | Specializations | Is-a justification |
|
||||
| --- | --- | --- |
|
||||
| Repository | Gitea Repository, GitHub Repository | Each is a Repository with the same name, visibility and owner rules; they differ in role (source of truth against mirror target) |
|
||||
|
||||
---
|
||||
|
||||
[UC-001]: ./uc.md
|
||||
[SSD-001]: ./ssd.md
|
||||
[DICT-001]: ../dictionary.md
|
||||
[DM-002]: ../domain-model.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,91 @@
|
||||
# Operation Contract
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | OC-001 |
|
||||
| CrossReference | [SSD-001], [DM-001], [SD-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
Concepts below use the IT terms of [DICT-001] for the PO concepts of [DM-001]. `Run`, `ToolCheck`, `PreflightResult` and `PromptSet` are system concepts with no PO term and are not in the Domain Model.
|
||||
|
||||
## Contract: startProjectCreation
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Operation | `startProjectCreation(): PromptSet` |
|
||||
| Traces to | `startProjectCreation` in [SSD-001] |
|
||||
| Concepts | Run, Configuration, ToolCheck |
|
||||
|
||||
**Preconditions**
|
||||
|
||||
- None; this is the first operation of a Run.
|
||||
|
||||
**Postconditions**
|
||||
|
||||
- P1. A `Run` instance was created.
|
||||
- P2. A `Configuration` instance was created from `config.env` and `.env`, with every value validated and the credentials held only in memory.
|
||||
- P3. A `ToolCheck` instance was created and associated with the `Run`, recording that `git` and `curl` are present and whether `jq` is present.
|
||||
- P4. The `Run` was associated with a `PromptSet` that is returned.
|
||||
|
||||
**Exceptions**
|
||||
|
||||
| Condition (failing precondition) | Outcome |
|
||||
| --- | --- |
|
||||
| `config.env` or `.env` is missing, or a value is missing or malformed | The `Run` ends with an error naming the key, never its value; nothing was changed |
|
||||
| `git` or `curl` is missing | The `Run` ends with an error naming the tool; nothing was changed |
|
||||
|
||||
## Contract: provideProjectDetails
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Operation | `provideProjectDetails(name: String, description: String, visibility: Visibility, giteaOwner: Owner, githubOwner: Owner [0..1], directory: Path, enablePlanGate: Boolean): Summary` |
|
||||
| Traces to | `provideProjectDetails` in [SSD-001] |
|
||||
| Concepts | ProjectRequest, PreflightResult, GiteaRepository, GitHubRepository, LicenseFile, PushMirror, LocalProject, Remote, Submodule, HookSetup, Summary |
|
||||
|
||||
**Preconditions**
|
||||
|
||||
- A `Run` exists and its `Configuration` is valid (from `startProjectCreation`).
|
||||
- `githubOwner` is present exactly when the Maintainer chose GitHub.
|
||||
|
||||
**Postconditions**
|
||||
|
||||
- P1. A `ProjectRequest` instance was created with the given attributes and associated with the `Run`.
|
||||
- P2. A `PreflightResult` instance was created and associated with the `ProjectRequest`, recording that each token needed for the chosen hosts works, that each owner accepts new repositories, that the name is free on the chosen hosts, that `AGPL-3.0` is offered by Gitea when GitHub was chosen, and the outcome of the SSH test to Gitea on port 10022.
|
||||
- P3. A `GiteaRepository` instance was created under `giteaOwner` with the given name, description and visibility, and associated with the `ProjectRequest`.
|
||||
- P4. If `githubOwner` is present, a `LicenseFile` instance for `AGPL-3.0` was created and associated with the `GiteaRepository`, so that repository is not empty. Otherwise the `GiteaRepository` has no `LicenseFile` and is empty.
|
||||
- P5. If `githubOwner` is present, an empty `GitHubRepository` instance was created under `githubOwner` and associated with the `ProjectRequest`.
|
||||
- P6. If `githubOwner` is present, a `PushMirror` instance was created, associated with the `GiteaRepository` as source and the `GitHubRepository` as target, with its effective sync setting recorded, and a first sync was requested.
|
||||
- P7. A `LocalProject` instance was created at `directory`, associated with the `ProjectRequest`. If the `GiteaRepository` is not empty, the `LocalProject` holds its history, including the `LicenseFile` commit.
|
||||
- P8. A `Remote` named `origin` was associated with the `LocalProject`, pointing at the `GiteaRepository` over SSH if the SSH test passed, otherwise over HTTPS, with no credential in its URL.
|
||||
- P9. If `githubOwner` is present, a `Remote` named `github` was associated with the `LocalProject`, pointing at the `GitHubRepository`, with no credential in its URL.
|
||||
- P10. A `Submodule` named `framework` was associated with the `LocalProject`.
|
||||
- P11. A `HookSetup` instance was associated with the `LocalProject`, recording that skills and git hooks were installed once and, if `enablePlanGate`, that the plan gate was enabled.
|
||||
- P12. `AGENTS.md` and `docs/artifact-registry.md` exist in the `LocalProject`, each either newly copied from the framework templates or left as it was because the Maintainer declined to replace it.
|
||||
- P13. A `Summary` instance was created listing every created item, every skipped item and the next step for anything that failed, and is returned. It contains no credential.
|
||||
|
||||
**Exceptions**
|
||||
|
||||
| Condition (failing precondition) | Outcome |
|
||||
| --- | --- |
|
||||
| A token is invalid, an owner refuses new repositories, or the name is taken on a chosen host (P2) | The `Run` ends before P3; nothing was created; the error names the failed check |
|
||||
| GitHub was chosen and Gitea does not offer `AGPL-3.0` (P2) | The `Run` ends before P3; nothing was created |
|
||||
| `GiteaRepository` creation fails after a `GitHubRepository` was created (P5, P3 ordering) | The `Summary` lists the `GitHubRepository` as created, the `GiteaRepository` as failed and how to continue |
|
||||
| `PushMirror` creation fails (P6) | The `Summary` lists both repositories as created, the mirror as failed and how to continue; the local steps are not run |
|
||||
| `directory` exists, or a target file exists, and the Maintainer declines replacing it (P7, P12) | That item is skipped and listed in the `Summary` |
|
||||
| A different `core.hooksPath` exists and the Maintainer declines replacing it (P11) | Hooks are not installed and this is listed in the `Summary` |
|
||||
| SSH to port 10022 fails and the `Submodule` cannot be added (P10) | The `Summary` lists the repositories as created, the submodule as failed, and the SSH prerequisite |
|
||||
|
||||
---
|
||||
|
||||
[SSD-001]: ./ssd.md
|
||||
[DM-001]: ./dm.md
|
||||
[DICT-001]: ../dictionary.md
|
||||
[SD-001]: ./sd.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
@@ -0,0 +1,190 @@
|
||||
# Sequence Diagram
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | SD-001 |
|
||||
| CrossReference | [OC-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
Design objects are conceptual; in `create-project.sh` each becomes a small function group. No Design Class Diagram exists yet.
|
||||
|
||||
## Sequence: startProjectCreation
|
||||
|
||||
**Realizes:** `startProjectCreation` in [OC-001]
|
||||
|
||||
### Diagram
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
actor Maintainer
|
||||
participant ":ProjectCreator" as PC
|
||||
participant ":ConfigLoader" as CL
|
||||
participant ":ToolChecker" as TC
|
||||
|
||||
Maintainer -> PC : startProjectCreation()
|
||||
activate PC
|
||||
create CL
|
||||
PC -> CL : load(config.env, .env)
|
||||
activate CL
|
||||
CL --> PC : configuration
|
||||
deactivate CL
|
||||
create TC
|
||||
PC -> TC : check(git, curl, jq)
|
||||
activate TC
|
||||
TC --> PC : toolCheck
|
||||
deactivate TC
|
||||
PC --> Maintainer : promptSet
|
||||
deactivate PC
|
||||
destroy CL
|
||||
destroy TC
|
||||
@enduml
|
||||
```
|
||||
|
||||
### Pattern Annotations
|
||||
|
||||
| Pattern (GRASP / GoF) | Applied to | Rationale |
|
||||
| --- | --- | --- |
|
||||
| Controller (GRASP) | `ProjectCreator` | Receives the system operations and coordinates, without doing the work itself |
|
||||
| Pure Fabrication (GRASP) | `ConfigLoader`, `ToolChecker` | No domain concept owns parsing or tool checks; separate small units keep cohesion high |
|
||||
| Creator (GRASP) | `ConfigLoader` creates `Configuration` | It holds the data needed to build and validate it |
|
||||
|
||||
### Postcondition Coverage
|
||||
|
||||
| Postcondition (from contract) | Satisfied by message |
|
||||
| --- | --- |
|
||||
| P1 Run created | `startProjectCreation` received by `ProjectCreator` |
|
||||
| P2 Configuration created and validated | `load(config.env, .env)` |
|
||||
| P3 ToolCheck created | `check(git, curl, jq)` |
|
||||
| P4 PromptSet returned | `promptSet` return to the Maintainer |
|
||||
|
||||
### Responsibility Check
|
||||
|
||||
`ProjectCreator` only sequences two calls; parsing and validation sit in `ConfigLoader`, tool detection in `ToolChecker`. No object receives every message.
|
||||
|
||||
## Sequence: provideProjectDetails
|
||||
|
||||
**Realizes:** `provideProjectDetails` in [OC-001]
|
||||
|
||||
### Diagram
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
actor Maintainer
|
||||
participant ":ProjectCreator" as PC
|
||||
participant ":Preflight" as PF
|
||||
participant ":GiteaClient" as GT
|
||||
participant ":GitHubClient" as GH
|
||||
participant ":LocalProjectBuilder" as LB
|
||||
participant ":FrameworkInstaller" as FI
|
||||
participant ":SummaryReport" as SR
|
||||
|
||||
Maintainer -> PC : provideProjectDetails(name, description, visibility, giteaOwner, githubOwner, directory, enablePlanGate)
|
||||
activate PC
|
||||
create GT
|
||||
PC -> GT : new(configuration)
|
||||
opt githubOwner present
|
||||
create GH
|
||||
PC -> GH : new(configuration)
|
||||
end
|
||||
create PF
|
||||
PC -> PF : check(request)
|
||||
activate PF
|
||||
PF -> GT : verifyToken(), ownerAccepts(giteaOwner), nameFree(name), hasLicense(AGPL-3.0)
|
||||
opt githubOwner present
|
||||
PF -> GH : verifyToken(), ownerAccepts(githubOwner), nameFree(name)
|
||||
end
|
||||
PF --> PC : preflightResult
|
||||
deactivate PF
|
||||
|
||||
opt githubOwner present
|
||||
PC -> GH : createEmptyRepository(githubOwner, name)
|
||||
activate GH
|
||||
GH --> PC : gitHubRepository
|
||||
deactivate GH
|
||||
end
|
||||
|
||||
alt githubOwner present
|
||||
PC -> GT : createRepository(giteaOwner, name, license=AGPL-3.0)
|
||||
else no GitHub
|
||||
PC -> GT : createRepository(giteaOwner, name, license=none)
|
||||
end
|
||||
activate GT
|
||||
GT --> PC : giteaRepository
|
||||
deactivate GT
|
||||
|
||||
opt githubOwner present
|
||||
PC -> GT : addPushMirror(giteaRepository, gitHubRepository)
|
||||
activate GT
|
||||
GT -> GT : requestSync()
|
||||
GT --> PC : pushMirror
|
||||
deactivate GT
|
||||
end
|
||||
|
||||
create LB
|
||||
PC -> LB : build(directory, giteaRepository, gitHubRepository, sshPassed)
|
||||
activate LB
|
||||
LB --> PC : localProject (remotes origin, github)
|
||||
deactivate LB
|
||||
|
||||
create FI
|
||||
PC -> FI : install(localProject, enablePlanGate)
|
||||
activate FI
|
||||
FI --> PC : submodule, hookSetup, templates
|
||||
deactivate FI
|
||||
|
||||
create SR
|
||||
PC -> SR : compose(all results)
|
||||
SR --> PC : summary
|
||||
PC --> Maintainer : summary
|
||||
deactivate PC
|
||||
destroy PF
|
||||
destroy GT
|
||||
destroy GH
|
||||
destroy LB
|
||||
destroy FI
|
||||
destroy SR
|
||||
@enduml
|
||||
```
|
||||
|
||||
### Pattern Annotations
|
||||
|
||||
| Pattern (GRASP / GoF) | Applied to | Rationale |
|
||||
| --- | --- | --- |
|
||||
| Controller (GRASP) | `ProjectCreator` | Single entry for the system operation; sequences the steps and stops on the first failure |
|
||||
| Pure Fabrication (GRASP) | `Preflight`, `LocalProjectBuilder`, `FrameworkInstaller`, `SummaryReport` | Each groups one responsibility that no domain concept owns |
|
||||
| Facade (GoF) | `GiteaClient`, `GitHubClient` | Hide each host's HTTP API and credential handling behind a small interface; tokens never leave them |
|
||||
| Protection from variations (GRASP) | Client classes | The `github`-optional and license variations are decided by the controller's `alt` and `opt`, not inside the clients |
|
||||
|
||||
### Postcondition Coverage
|
||||
|
||||
| Postcondition (from contract) | Satisfied by message |
|
||||
| --- | --- |
|
||||
| P1 ProjectRequest created | `provideProjectDetails` received by `ProjectCreator` |
|
||||
| P2 PreflightResult created | `check(request)` |
|
||||
| P3 GiteaRepository created | `createRepository(giteaOwner, name, license)` |
|
||||
| P4 LicenseFile when GitHub chosen, otherwise empty | `createRepository(..., license=AGPL-3.0)` and the `alt` branch `license=none` |
|
||||
| P5 empty GitHubRepository when chosen | `createEmptyRepository(githubOwner, name)` |
|
||||
| P6 PushMirror and first sync | `addPushMirror(...)` and `requestSync()` |
|
||||
| P7 LocalProject created, history from Gitea when not empty | `build(directory, ...)` |
|
||||
| P8 origin remote (SSH if the test passed, else HTTPS) | `build(..., sshPassed)` |
|
||||
| P9 github remote when chosen | `build(...)` |
|
||||
| P10 framework Submodule | `install(localProject, ...)` |
|
||||
| P11 HookSetup, plan gate if chosen | `install(localProject, enablePlanGate)` |
|
||||
| P12 AGENTS.md and registry copied or kept | `install(...)` returning `templates` |
|
||||
| P13 Summary created and returned | `compose(all results)` and the final return |
|
||||
|
||||
### Responsibility Check
|
||||
|
||||
`ProjectCreator` sequences and decides on the optional paths but performs no HTTP, git or file work. Host calls are in the two clients, local work in `LocalProjectBuilder` and `FrameworkInstaller`, reporting in `SummaryReport`, so cohesion stays high and no object receives all messages. Failure handling (exceptions in [OC-001]) is the controller's single stop-and-report rule and is not drawn.
|
||||
|
||||
---
|
||||
|
||||
[OC-001]: ./oc.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
+8
-4
@@ -4,12 +4,13 @@
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | SSD-001 |
|
||||
| CrossReference | [UC-001] |
|
||||
| CrossReference | [UC-001], [DM-001], [OC-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Rejected | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Optional GitHub; choosing GitHub applies the AGPL license to the Gitea repository<br>Cited OC-001 and DM-001 | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
@@ -25,7 +26,7 @@ actor Maintainer as A
|
||||
participant ":System" as S
|
||||
A -> S : startProjectCreation()
|
||||
S --> A : prompts for project details
|
||||
A -> S : provideProjectDetails(name, description, visibility, githubOwner, giteaOwner, directory, enablePlanGate)
|
||||
A -> S : provideProjectDetails(name, description, visibility, giteaOwner, githubOwner, directory, enablePlanGate)
|
||||
S --> A : checks passed
|
||||
S --> A : creation summary
|
||||
@enduml
|
||||
@@ -36,7 +37,7 @@ S --> A : creation summary
|
||||
| Step | Message | Parameters | Return | Use case step |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | startProjectCreation | none | prompts for project details (after configuration and tool checks) | 1, 2 |
|
||||
| 2 | provideProjectDetails | name, description, visibility, githubOwner, giteaOwner, directory, enablePlanGate | checks passed, then a creation summary | 3 to 10 |
|
||||
| 2 | provideProjectDetails | name, description, visibility, giteaOwner, githubOwner (optional; given means GitHub is chosen and the Gitea repository gets the AGPL license; omitted means no GitHub and no license), directory, enablePlanGate | checks passed, then a creation summary | 3 to 10 |
|
||||
|
||||
Steps 4 to 9 are internal to the system, so one operation covers them. A consent question (step 8a, 9a, 9b) is a prompt from the system and is out of scope for this diagram; failure flows are out of scope here.
|
||||
|
||||
@@ -47,4 +48,7 @@ The system is one script run. It starts with the first operation and ends after
|
||||
---
|
||||
|
||||
[UC-001]: ./uc.md
|
||||
[DM-001]: ./dm.md
|
||||
[OC-001]: ./oc.md
|
||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
|
||||
+24
-16
@@ -4,12 +4,13 @@
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | UC-001 |
|
||||
| CrossReference | [US-001], [SA-001] |
|
||||
| CrossReference | [UCD-001], [US-001], [SA-001], [DM-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Optional GitHub; choosing GitHub applies the AGPL license to the Gitea repository<br>Cited DM-001 and UCD-001 | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
@@ -27,23 +28,23 @@
|
||||
- **Preconditions:**
|
||||
- `config.env` and `.env` exist and are valid.
|
||||
- `git` and `curl` are installed.
|
||||
- The Maintainer has a GitHub PAT, a Gitea token and SSH access to Gitea on port 10022.
|
||||
- The Maintainer has a Gitea token, a GitHub PAT (only when GitHub is chosen) and SSH access to Gitea on port 10022.
|
||||
- **Postconditions (success guarantee):**
|
||||
- An empty repository exists on GitHub and on Gitea under the chosen owners.
|
||||
- The Gitea repository is a push mirror to GitHub.
|
||||
- A local project directory exists with credential-free remotes `origin` (Gitea) and `github`, the `framework` submodule, installed skills and hooks, and the copied templates.
|
||||
- A repository exists on Gitea under the chosen owner. It is empty, or, when the Maintainer chose GitHub, it holds the AGPL license file.
|
||||
- When the Maintainer chose to create a GitHub repository, an empty repository exists on GitHub under the chosen owner, the Gitea repository is a push mirror to it, and the AGPL license file reaches GitHub through the mirror.
|
||||
- A local project directory exists with credential-free remotes `origin` (Gitea) and, when GitHub was chosen, `github`, the `framework` submodule, installed skills and hooks, and the copied templates.
|
||||
- The Maintainer has a summary of what was created.
|
||||
|
||||
### Main Success Scenario
|
||||
|
||||
1. The Maintainer starts the project creation.
|
||||
2. The system loads and validates the configuration and credentials and checks that the required tools exist.
|
||||
3. The Maintainer provides the repository name, description, visibility, the GitHub owner, the Gitea owner, the local directory, and whether to enable the plan gate.
|
||||
4. The system checks that both tokens work, that the owners accept new repositories, that the name is free on both hosts, and whether SSH to Gitea works.
|
||||
5. The system creates the empty GitHub repository.
|
||||
6. The system creates the empty Gitea repository.
|
||||
7. The system configures the Gitea repository as a push mirror to GitHub and verifies it.
|
||||
8. The system creates the local project with the `origin` and `github` remotes.
|
||||
3. The Maintainer provides the repository name, description, visibility, the Gitea owner, whether to also create a GitHub repository (and if so its owner), the local directory, and whether to enable the plan gate.
|
||||
4. The system checks that the tokens needed for the chosen hosts work, that the owners accept new repositories, that the name is free on those hosts, and whether SSH to Gitea works.
|
||||
5. Optional: if the Maintainer chose GitHub, the system creates the empty GitHub repository.
|
||||
6. The system creates the Gitea repository. If the Maintainer chose GitHub, the repository is created with the AGPL license file and so is not empty; otherwise it is empty and has no license.
|
||||
7. Optional: if GitHub was chosen, the system configures the Gitea repository as a push mirror to GitHub and verifies it. A license file in the Gitea repository is pushed to GitHub by the mirror.
|
||||
8. The system creates the local project with the `origin` remote and, if GitHub was chosen, the `github` remote.
|
||||
9. The system adds the framework submodule, installs its skills and hooks (and the plan gate if chosen) and copies the templates.
|
||||
10. The system reports a summary of what was created.
|
||||
|
||||
@@ -52,14 +53,16 @@
|
||||
- 2a. A required tool is missing, or a configuration value is missing or malformed:
|
||||
1. The system stops before any change and names the problem without showing a credential.
|
||||
- 4a. A token is invalid, an owner does not accept the repository, or the name is taken:
|
||||
1. The system stops before creating anything and says which check failed.
|
||||
1. The system stops before creating anything and says which check failed. The GitHub token is only checked when GitHub was chosen.
|
||||
- 4c. GitHub was chosen and the Gitea server does not offer the `AGPL-3.0` license:
|
||||
1. The system stops before creating anything and names the missing license.
|
||||
- 4b. SSH to Gitea does not work:
|
||||
1. The system uses HTTPS for `origin` and warns that the framework submodule step will fail until SSH is configured.
|
||||
- 5a, 6a, 7a. A step fails after an earlier one succeeded:
|
||||
1. The system stops and reports what exists, what failed and how to continue.
|
||||
- 8a, 9a. The target directory or a target file already exists:
|
||||
1. The system asks the Maintainer before replacing it; on no, it skips that item and reports it.
|
||||
- 9b. A different `core.hooksPath` is already set:
|
||||
- 9b. A different git hooks setup is already configured in the project:
|
||||
1. The system asks before replacing it.
|
||||
|
||||
### Special Requirements / Business Rules
|
||||
@@ -68,8 +71,10 @@
|
||||
| --- | --- |
|
||||
| 2, 4 | A token never appears in output, logs, command lines, remote URLs or temporary files left behind |
|
||||
| 3 | The GitHub owner and the Gitea owner are chosen separately; `GITHUB_USER` is only the authenticating account |
|
||||
| 7 | The mirror direction is Gitea to GitHub |
|
||||
| 8 | `origin` uses HTTPS derived from `GITEA_URL`, or SSH when the SSH test in step 4 passed |
|
||||
| 3, 5, 7 | GitHub is optional; without it no GitHub repository, mirror or `github` remote is created and the GitHub credentials are not required |
|
||||
| 6 | Choosing GitHub applies the AGPL license (key `AGPL-3.0`) to the Gitea repository when it is created, so that repository is not empty; without GitHub there is no license and the repository is empty |
|
||||
| 7 | The mirror direction is Gitea to GitHub; the GitHub repository stays empty and receives its content from the mirror |
|
||||
| 8 | `origin` uses HTTPS derived from `GITEA_URL`, or SSH when the SSH test in step 4 passed; when the Gitea repository is not empty (GitHub chosen) the local project is created by fetching it, not by an unrelated `git init` history |
|
||||
| 8, 9 | Nothing is overwritten or deleted without consent, and no commit is made |
|
||||
|
||||
### Open Issues
|
||||
@@ -78,6 +83,9 @@
|
||||
|
||||
---
|
||||
|
||||
[UCD-001]: ../use-case-diagram.md
|
||||
[US-001]: ../user-stories.md
|
||||
[SA-001]: ../stakeholder-analysis.md
|
||||
[DM-001]: ./dm.md
|
||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Use Case Diagram
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | UCD-001 |
|
||||
| CrossReference | [SA-001], [BC-001], [US-001], [UC-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Initial version | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose and Scope
|
||||
|
||||
The system boundary is RepoFoundry, the `create-project.sh` script. Inside it is one goal: creating a new project. The only actor is the Maintainer. GitHub, Gitea and the framework repository are services the system calls; they are outside the boundary and are not actors, because no use case describes their goals and the Stakeholder Analysis has no stakeholder for them.
|
||||
|
||||
## Diagram
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
left to right direction
|
||||
actor "Maintainer" as M <<Actor>>
|
||||
rectangle "RepoFoundry" <<System>> {
|
||||
usecase "Create a new project" as UC1
|
||||
}
|
||||
M --> UC1
|
||||
@enduml
|
||||
```
|
||||
|
||||
## Actor Table
|
||||
|
||||
| Actor | Stereotype | Stakeholder ID (SA) | Goals (use cases) |
|
||||
| --- | --- | --- | --- |
|
||||
| Maintainer | `<<Actor>>` | S01, S02 | Create a new project |
|
||||
|
||||
## Use Case Table
|
||||
|
||||
| Use Case | Actor(s) | Goal |
|
||||
| --- | --- | --- |
|
||||
| Create a new project ([UC-001]) | Maintainer | Start a new project with a Gitea repository, optionally a GitHub repository and mirror, and a local project with the SQA-QC-Framework |
|
||||
|
||||
## Relationships
|
||||
|
||||
| From | Relationship (`<<include>>` / `<<extend>>`) | To | Justification |
|
||||
| --- | --- | --- | --- |
|
||||
| None | - | - | The optional GitHub steps are steps 5 and 7 of [UC-001], not a separate goal of the Maintainer, so they are not modelled as an `<<extend>>` use case |
|
||||
|
||||
---
|
||||
|
||||
[SA-001]: ./stakeholder-analysis.md
|
||||
[BC-001]: ./business-case.md
|
||||
[US-001]: ./user-stories.md
|
||||
[UC-001]: ./uc-001/uc.md
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
+44
-12
@@ -4,46 +4,78 @@
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | US-001 |
|
||||
| CrossReference | [BC-001], [MIL-001], [MIL-002], [MIL-003] |
|
||||
| CrossReference | [BC-001], [UCD-001], [MIL-001], [MIL-002], [MIL-003] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-05 | Proposed | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Deprecated | Jens Tirsvad Nielsen | S02 | Initial version | [424f14f] |
|
||||
| 2026-10-05 | Accepted | Jens Tirsvad Nielsen | S02 | Optional GitHub; choosing GitHub applies the AGPL license to the Gitea repository<br>Cited UCD-001<br>Split the epic into three stories, one per milestone | [02875ae] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose and Scope
|
||||
|
||||
One epic: setting up a new project on GitHub and Gitea with the SQA-QC-Framework in place. The actor is the Maintainer (S01 or S02; for now one person holds both roles). No Use Case Diagram exists yet, so the actor name is defined here and must be reused by the use case.
|
||||
One epic: "Create a new project" ([UC-001]), setting up a new project on Gitea, optionally on GitHub, with the SQA-QC-Framework in place. The actor is the Maintainer, as in [UCD-001] (S01 or S02; for now one person holds both roles).
|
||||
|
||||
The epic is split into three stories, one per milestone. Each story fits one two-week phase and can be shown working at the end of it.
|
||||
|
||||
## Story List
|
||||
|
||||
### US-001.01 — Create a new project
|
||||
### US-001.01 — Create a new project: check and collect
|
||||
|
||||
**As a** Maintainer, **I want** to create a new project with empty GitHub and Gitea repositories, a Gitea to GitHub push mirror and a local project with the SQA-QC-Framework, **so that** every new project starts from the same secure, repeatable baseline.
|
||||
**As a** Maintainer, **I want** the script to check my configuration, credentials and tools and ask for the project details before it changes anything, **so that** a mistake or a missing prerequisite is caught early and no token is ever exposed.
|
||||
|
||||
**Acceptance Criteria**
|
||||
|
||||
- Given valid configuration and credentials, when the Maintainer answers the prompts, then an empty GitHub repository and an empty Gitea repository exist under the chosen owners.
|
||||
- Given both repositories exist, when the mirror step finishes, then the Gitea repository is a push mirror to GitHub and no credential is stored in any remote URL.
|
||||
- Given the repositories exist, when the local step finishes, then the project directory has `origin` (Gitea) and `github` remotes, the `framework` submodule, installed skills and hooks, and the copied templates.
|
||||
- Given a step fails, when the script stops, then it reports what was created and how to continue.
|
||||
- Given a target directory or file already exists, when the script would replace it, then it asks first.
|
||||
- Given `config.env` and `.env`, when the Maintainer starts the script, then the values are read and validated without being executed, and no token appears in any output.
|
||||
- Given a missing tool, or a missing or malformed value, when the script starts, then it stops before any change and names the problem without showing a credential.
|
||||
- Given valid configuration, when the script asks for the details, then the Maintainer can give the repository name, description, visibility, the Gitea owner, whether to also use GitHub (and its owner), the local directory and whether to enable the plan gate.
|
||||
|
||||
| Traces to | Size | INVEST exceptions |
|
||||
| --- | --- | --- |
|
||||
| [UC-001], [MIL-001], [MIL-002], [MIL-003] | spans three phases; delivered by the tasks of each | Small: the story is split into tasks per phase |
|
||||
| [UC-001] steps 1 to 3, [MIL-001] | fits one phase | none |
|
||||
|
||||
### US-001.02 — Create a new project: repositories and mirror
|
||||
|
||||
**As a** Maintainer, **I want** the script to create the Gitea repository and, if I chose GitHub, an empty GitHub repository with a mirror from Gitea, **so that** the project starts with its repositories in place under the owners I chose.
|
||||
|
||||
**Acceptance Criteria**
|
||||
|
||||
- Given valid tokens and owners, when the script runs, then a Gitea repository exists under the chosen owner: empty, or holding the AGPL license when GitHub was chosen.
|
||||
- Given GitHub was chosen, when the script runs, then an empty GitHub repository exists under its chosen owner (not assumed to be `GITHUB_USER`) and Gitea mirrors to it, and no credential is stored in any address.
|
||||
- Given a step fails, when the script stops, then it reports what was created and how to continue.
|
||||
|
||||
| Traces to | Size | INVEST exceptions |
|
||||
| --- | --- | --- |
|
||||
| [UC-001] steps 4 to 7, [MIL-002] | fits one phase | Independent: needs the checked input of US-001.01 |
|
||||
|
||||
### US-001.03 — Create a new project: local project
|
||||
|
||||
**As a** Maintainer, **I want** the script to create the local project with its remotes and the SQA-QC-Framework, **so that** I can start work in a ready project.
|
||||
|
||||
**Acceptance Criteria**
|
||||
|
||||
- Given the repositories exist, when the script finishes, then the project directory has an `origin` remote and, if GitHub was chosen, a `github` remote, neither containing a credential.
|
||||
- Given the project directory, when the script finishes, then the framework, its skills and git hooks (and the plan gate if chosen) and the copied templates are in place.
|
||||
- Given a directory or file already exists, when the script would replace it, then it asks first.
|
||||
|
||||
| Traces to | Size | INVEST exceptions |
|
||||
| --- | --- | --- |
|
||||
| [UC-001] steps 8 to 10, [MIL-003] | fits one phase | Independent: needs the repositories of US-001.02 |
|
||||
|
||||
## INVEST Check
|
||||
|
||||
Independent, Negotiable, Valuable, Estimable and Testable hold. Small does not: this is an epic-sized story, delivered through the tasks of the three milestones, with an exception recorded above.
|
||||
Valuable, Negotiable, Estimable, Small and Testable hold for each story. Independent holds only in part: the stories are ordered, each using what the one before it delivers, which follows the milestone order in [PP-001]. This is flagged as an exception on US-001.02 and US-001.03.
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ./business-case.md
|
||||
[UCD-001]: ./use-case-diagram.md
|
||||
[UC-001]: ./uc-001/uc.md
|
||||
[MIL-001]: ./milestones/mil-001-foundation.md
|
||||
[MIL-002]: ./milestones/mil-002-repositories-and-mirror.md
|
||||
[MIL-003]: ./milestones/mil-003-scaffold-and-release.md
|
||||
[PP-001]: ./project-plan.md
|
||||
[424f14f]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/424f14f4f5577bb47fea41c8f3a655dca953e6d8
|
||||
[02875ae]: https://git.tirsystem.com/TirSystem-BashScript/repo_foundry/commit/02875aee5f2953473924074eea0056eb31af6b7a
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-project.sh - set up a new project on Gitea (and optionally GitHub).
|
||||
#
|
||||
# Purpose
|
||||
# RepoFoundry creates a Gitea repository, optionally an empty GitHub
|
||||
# repository with a Gitea -> GitHub push mirror, and a local project with
|
||||
# the SQA-QC-Framework. This version (MIL-002) validates the configuration
|
||||
# and credentials, asks for the project details, checks both hosts with
|
||||
# read-only requests (tokens, owners, names, licence, SSH) and, with
|
||||
# --apply, creates the repositories and the mirror. Choosing GitHub also
|
||||
# applies the AGPL-3.0 license to the Gitea repository. The local project
|
||||
# is not created yet.
|
||||
#
|
||||
# Dry run by default
|
||||
# Without --apply the script only reads from GitHub and Gitea (GET
|
||||
# requests) and prints what it would create. With --apply it prints the plan
|
||||
# and asks for a final yes before it creates anything. Nothing is ever
|
||||
# deleted: if a step fails, the script reports what exists and how to
|
||||
# continue, and a repeated run offers to reuse the empty repositories.
|
||||
#
|
||||
# Usage
|
||||
# create-project.sh [--apply] [--config FILE] [--env FILE]
|
||||
# create-project.sh --help | --version
|
||||
#
|
||||
# Options
|
||||
# --apply create the repositories and the mirror (after a final yes)
|
||||
# --config FILE service addresses (default: config.env in the project root)
|
||||
# --env FILE credentials (default: .env in the project root)
|
||||
# -h, --help show this help
|
||||
# --version show the version
|
||||
#
|
||||
# Files (parsed, never sourced)
|
||||
# config.env GITHUB_API_URL, GITHUB_WEB_URL, GITEA_URL, GITEA_API_URL and
|
||||
# the optional GITEA_SSH_PORT (default 10022) and
|
||||
# MIRROR_INTERVAL (default 10m0s)
|
||||
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN
|
||||
#
|
||||
# Environment
|
||||
# REPOFOUNDRY_NAME project name used in messages (default: RepoFoundry)
|
||||
# REPOFOUNDRY_SYNC_WAIT seconds to wait before reading the first mirror
|
||||
# sync result (default: 3)
|
||||
# TMPDIR where the private temporary directory is created
|
||||
#
|
||||
# Requires
|
||||
# bash 4.4 or later, git, curl, mktemp; jq and ssh are optional (jq is used
|
||||
# for JSON when present; ssh is used for the Gitea SSH test).
|
||||
# Also the base tools sed, grep, head, tr, sleep, rm, rmdir and uname, and
|
||||
# stat (GNU "stat -c" or BSD "stat -f"; only used outside Windows).
|
||||
#
|
||||
# Implements
|
||||
# MIL-001 tasks 1 to 6 and MIL-002 tasks 1 to 5 (issues #3 to #13), user
|
||||
# stories US-001.01 and US-001.02, UC-001 steps 1 to 7; see docs/. Deviation
|
||||
# from the request: its second GITEA_URL key is named GITEA_API_URL.
|
||||
#
|
||||
# Tracing
|
||||
# set -x is switched off while the script runs, because a trace would print
|
||||
# every secret the script handles.
|
||||
#
|
||||
# Structure
|
||||
# This file is the entry point. The work is split by responsibility into
|
||||
# the files in lib/ next to it (one job per file, see the first lines of
|
||||
# each file): constants, output, temp, util, validate, config, tools, json,
|
||||
# http, api, prompts, project, hosts, preflight, steps, plan, repositories,
|
||||
# mirror, apply and cli. The files are loaded from this directory only.
|
||||
#
|
||||
# Exit codes
|
||||
# 0 success (or a dry run, or a "no" at the final question), 1 a failed
|
||||
# check, bad input or a failed step, 2 a usage error.
|
||||
set -Eeuo pipefail
|
||||
|
||||
if [[ $- == *x* ]]; then
|
||||
set +x
|
||||
printf 'warning: tracing (set -x) is disabled because it would print secrets\n' >&2
|
||||
fi
|
||||
|
||||
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4))); then
|
||||
printf 'error: bash 4.4 or later is required (found %s)\n' "$BASH_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Where this script lives; the library files and the project root are found
|
||||
# from here, never from the current directory.
|
||||
readonly SCRIPT_FILE="${BASH_SOURCE[0]}"
|
||||
case "${BASH_SOURCE[0]}" in
|
||||
*/*) script_path_dir="${BASH_SOURCE[0]%/*}" ;;
|
||||
*) script_path_dir="." ;;
|
||||
esac
|
||||
SCRIPT_DIR="$(cd "$script_path_dir" && pwd)"
|
||||
readonly SCRIPT_DIR
|
||||
unset script_path_dir
|
||||
# The script lives in src/; the configuration files live one level up, in
|
||||
# the project root, next to config.env.example and .env.example.
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
readonly PROJECT_ROOT
|
||||
|
||||
# shellcheck source=lib/constants.sh
|
||||
source "$SCRIPT_DIR/lib/constants.sh"
|
||||
# shellcheck source=lib/output.sh
|
||||
source "$SCRIPT_DIR/lib/output.sh"
|
||||
# shellcheck source=lib/temp.sh
|
||||
source "$SCRIPT_DIR/lib/temp.sh"
|
||||
# shellcheck source=lib/util.sh
|
||||
source "$SCRIPT_DIR/lib/util.sh"
|
||||
# shellcheck source=lib/validate.sh
|
||||
source "$SCRIPT_DIR/lib/validate.sh"
|
||||
# shellcheck source=lib/config.sh
|
||||
source "$SCRIPT_DIR/lib/config.sh"
|
||||
# shellcheck source=lib/tools.sh
|
||||
source "$SCRIPT_DIR/lib/tools.sh"
|
||||
# shellcheck source=lib/json.sh
|
||||
source "$SCRIPT_DIR/lib/json.sh"
|
||||
# shellcheck source=lib/http.sh
|
||||
source "$SCRIPT_DIR/lib/http.sh"
|
||||
# shellcheck source=lib/api.sh
|
||||
source "$SCRIPT_DIR/lib/api.sh"
|
||||
# shellcheck source=lib/prompts.sh
|
||||
source "$SCRIPT_DIR/lib/prompts.sh"
|
||||
# shellcheck source=lib/project.sh
|
||||
source "$SCRIPT_DIR/lib/project.sh"
|
||||
# shellcheck source=lib/hosts.sh
|
||||
source "$SCRIPT_DIR/lib/hosts.sh"
|
||||
# shellcheck source=lib/preflight.sh
|
||||
source "$SCRIPT_DIR/lib/preflight.sh"
|
||||
# shellcheck source=lib/steps.sh
|
||||
source "$SCRIPT_DIR/lib/steps.sh"
|
||||
# shellcheck source=lib/plan.sh
|
||||
source "$SCRIPT_DIR/lib/plan.sh"
|
||||
# shellcheck source=lib/repositories.sh
|
||||
source "$SCRIPT_DIR/lib/repositories.sh"
|
||||
# shellcheck source=lib/mirror.sh
|
||||
source "$SCRIPT_DIR/lib/mirror.sh"
|
||||
# shellcheck source=lib/apply.sh
|
||||
source "$SCRIPT_DIR/lib/apply.sh"
|
||||
# shellcheck source=lib/cli.sh
|
||||
source "$SCRIPT_DIR/lib/cli.sh"
|
||||
|
||||
# finish runs on every exit: it reports what a run that started creating
|
||||
# things did or did not do, then removes the temporary files. It keeps the
|
||||
# exit status of the run.
|
||||
finish() {
|
||||
local code=$?
|
||||
if ((IS_CREATION_STARTED)); then
|
||||
report_outcome "$code"
|
||||
fi
|
||||
cleanup
|
||||
}
|
||||
|
||||
main() {
|
||||
trap 'on_error "$LINENO"' ERR
|
||||
trap finish EXIT
|
||||
is_valid_repo_name "$PROJECT_NAME" ||
|
||||
die "REPOFOUNDRY_NAME is not a valid project name"
|
||||
parse_args "$@"
|
||||
check_tools
|
||||
setup_temp_dir
|
||||
load_configuration
|
||||
collect_project_details
|
||||
if ((PROJECT[has_github])); then
|
||||
require_github_credentials
|
||||
fi
|
||||
init_steps
|
||||
print_summary
|
||||
run_preflight
|
||||
print_plan
|
||||
if ((IS_APPLY)); then
|
||||
apply_plan
|
||||
else
|
||||
say ""
|
||||
say "Dry run: nothing was created. Run again with --apply to create it."
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
@@ -0,0 +1,61 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# api.sh - Calls to the GitHub and Gitea APIs and the reporting of a refused call.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: host_label, api_call, server_message, fail_request, expect_status
|
||||
|
||||
host_label() {
|
||||
case "$1" in
|
||||
gitea) printf 'Gitea' ;;
|
||||
github) printf 'GitHub' ;;
|
||||
*) die "internal error: unknown host" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# api_call HOST METHOD PATH [BODY]: HOST is gitea or github. A network
|
||||
# failure ends the run; the HTTP status is left in HTTP_STATUS.
|
||||
api_call() {
|
||||
local host="$1" method="$2" path="$3" body="${4:-}"
|
||||
case "$host" in
|
||||
gitea)
|
||||
http_request "$method" "${CONFIG[GITEA_API_URL]}$path" token \
|
||||
"${CREDENTIALS[GITEA_TOKEN]}" "$body" || die "$HTTP_ERROR"
|
||||
;;
|
||||
github)
|
||||
http_request "$method" "${CONFIG[GITHUB_API_URL]}$path" bearer \
|
||||
"${CREDENTIALS[GITHUB_PAT]}" "$body" || die "$HTTP_ERROR"
|
||||
;;
|
||||
*) die "internal error: unknown host" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# server_message: the "message" of the last response, cleaned and shortened.
|
||||
server_message() {
|
||||
local message
|
||||
# A response that is not JSON must not stop the error report.
|
||||
message="$(json_get "$HTTP_BODY_FILE" message 2>/dev/null || true)"
|
||||
message="${message//[[:cntrl:]]/ }"
|
||||
printf '%s' "${message:0:160}"
|
||||
}
|
||||
|
||||
fail_request() {
|
||||
local detail message
|
||||
detail="$(describe_http_status "$HTTP_STATUS")"
|
||||
message="$(server_message)"
|
||||
die "$1: $detail${message:+ (the server says: $message)}"
|
||||
}
|
||||
|
||||
# expect_status CONTEXT CODE...: go on if the last status is one of CODE,
|
||||
# otherwise stop with CONTEXT and the server's own words.
|
||||
expect_status() {
|
||||
local context="$1" code
|
||||
shift
|
||||
for code in "$@"; do
|
||||
if [[ $HTTP_STATUS == "$code" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fail_request "$context"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# apply.sh - The only code that changes anything: confirmations and the apply flow.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: create_all, confirm_reuse, apply_plan
|
||||
|
||||
create_all() {
|
||||
IS_CREATION_STARTED=1
|
||||
if ((PROJECT[has_github])); then
|
||||
create_repository github
|
||||
fi
|
||||
create_repository gitea
|
||||
if ((PROJECT[has_github])); then
|
||||
configure_mirror
|
||||
fi
|
||||
}
|
||||
|
||||
confirm_reuse() {
|
||||
local host
|
||||
for host in github gitea; do
|
||||
if ! is_reused "$host"; then
|
||||
continue
|
||||
fi
|
||||
prompt_yes_no "The $(host_label "$host") repository $(repo_url "$host") already exists and has no real content. Reuse it" n
|
||||
if ! ((REPLY)); then
|
||||
die "stopped: choose another name or remove the existing repository"
|
||||
fi
|
||||
done
|
||||
if ((${STATE[reuse_gitea]:-0})) && ((PROJECT[has_github])) &&
|
||||
[[ ${STATE[gitea_repo]} == empty ]]; then
|
||||
warn "the empty Gitea repository is reused as it is: the $AGPL_LICENSE_KEY license is not added to it"
|
||||
fi
|
||||
}
|
||||
|
||||
# apply_plan: the only place that changes anything on GitHub or Gitea.
|
||||
apply_plan() {
|
||||
prompt_yes_no "Create these now" n
|
||||
if ! ((REPLY)); then
|
||||
say "Nothing was created."
|
||||
return 0
|
||||
fi
|
||||
confirm_reuse
|
||||
create_all
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# cli.sh - The command line: usage text and option parsing.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: usage, usage_error, parse_args
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ${0##*/} [--apply] [--config FILE] [--env FILE]
|
||||
${0##*/} --help | --version
|
||||
EOF
|
||||
}
|
||||
|
||||
usage_error() {
|
||||
printf 'error: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit "$EXIT_USAGE"
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--apply)
|
||||
IS_APPLY=1
|
||||
shift
|
||||
;;
|
||||
--config)
|
||||
(($# >= 2)) || usage_error "--config needs a file"
|
||||
CONFIG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--env)
|
||||
(($# >= 2)) || usage_error "--env needs a file"
|
||||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
sed -n '2,/^set -Eeuo/p' "$SCRIPT_FILE" | sed -e '$d' -e 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
--version)
|
||||
say "$PROJECT_NAME $VERSION"
|
||||
exit 0
|
||||
;;
|
||||
*) usage_error "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# config.sh - Reading and checking config.env and .env (parsed, never sourced).
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: unquote_value, parse_env_file, parse_env_entry, validate_config, validate_credentials, require_github_credentials, warn_if_env_unsafe, load_configuration
|
||||
|
||||
# unquote_value RAW: strip matching quotes (or a trailing " # comment" on an
|
||||
# unquoted value) and return the value in REPLY. Fails on unbalanced quotes.
|
||||
unquote_value() {
|
||||
local raw quote
|
||||
raw="$(trim "$1")"
|
||||
quote="${raw:0:1}"
|
||||
if [[ $quote == '"' || $quote == "'" ]]; then
|
||||
[[ ${#raw} -ge 2 && ${raw: -1} == "$quote" ]] || return 1
|
||||
raw="${raw:1:${#raw}-2}"
|
||||
[[ $raw != *"$quote"* ]] || return 1
|
||||
else
|
||||
raw="${raw%%[[:space:]]#*}"
|
||||
raw="$(trim "$raw")"
|
||||
[[ $raw != *'"'* && $raw != *"'"* ]] || return 1
|
||||
fi
|
||||
REPLY="$raw"
|
||||
}
|
||||
|
||||
# parse_env_file FILE ALLOWED_ARRAY TARGET_ARRAY
|
||||
# Read KEY=VALUE lines without source or eval. Only keys named in
|
||||
# ALLOWED_ARRAY are accepted; they are stored in the associative array
|
||||
# TARGET_ARRAY. Messages name the key and the line, never the value.
|
||||
parse_env_file() {
|
||||
local file="$1" line key line_number=0
|
||||
local pattern='^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$'
|
||||
[[ -f $file && -r $file ]] || die "cannot read '$file'"
|
||||
# shellcheck disable=SC2094 # the loop body only uses $file in messages
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
line_number=$((line_number + 1))
|
||||
if ((line_number == 1)); then
|
||||
line="${line#$'\xEF\xBB\xBF'}" # byte order mark from some Windows editors
|
||||
fi
|
||||
line="$(trim "${line%$'\r'}")"
|
||||
if [[ -z $line || $line == \#* ]]; then
|
||||
continue
|
||||
fi
|
||||
[[ $line =~ $pattern ]] ||
|
||||
die "$file line $line_number: expected KEY=VALUE"
|
||||
key="${BASH_REMATCH[1]}"
|
||||
parse_env_entry "$file" "$line_number" "$key" "${BASH_REMATCH[2]}" \
|
||||
"$2" "$3"
|
||||
done <"$file"
|
||||
}
|
||||
|
||||
# parse_env_entry FILE LINE KEY RAW_VALUE ALLOWED_ARRAY TARGET_ARRAY
|
||||
parse_env_entry() {
|
||||
local file="$1" line_number="$2" key="$3" raw="$4"
|
||||
local -n allowed_keys="$5"
|
||||
local -n target_map="$6"
|
||||
local value
|
||||
if ! in_list "$key" "${allowed_keys[@]}"; then
|
||||
if in_list "$key" "${CREDENTIAL_KEYS[@]}"; then
|
||||
die "$file line $line_number: '$key' is a credential; keep it in the .env file only"
|
||||
fi
|
||||
die "$file line $line_number: unknown key '$key'"
|
||||
fi
|
||||
if [[ -n ${target_map[$key]+set} ]]; then
|
||||
die "$file line $line_number: '$key' is set twice"
|
||||
fi
|
||||
unquote_value "$raw" ||
|
||||
die "$file line $line_number: unbalanced or misplaced quotes"
|
||||
value="$REPLY"
|
||||
if has_control_character "$value"; then
|
||||
die "$file line $line_number: '$key' contains a control character"
|
||||
fi
|
||||
if ((${#value} > MAX_VALUE_LENGTH)); then
|
||||
die "$file line $line_number: '$key' is too long"
|
||||
fi
|
||||
# shellcheck disable=SC2004 # target_map is an associative array: $key is a string
|
||||
target_map[$key]="$value"
|
||||
}
|
||||
|
||||
validate_config() {
|
||||
local key url
|
||||
if [[ -z ${CONFIG[GITEA_URL]:-} ]]; then
|
||||
die "GITEA_URL is missing in $CONFIG_FILE (see config.env.example)"
|
||||
fi
|
||||
CONFIG[GITHUB_API_URL]="${CONFIG[GITHUB_API_URL]:-https://api.github.com}"
|
||||
CONFIG[GITHUB_WEB_URL]="${CONFIG[GITHUB_WEB_URL]:-https://github.com}"
|
||||
for key in GITHUB_API_URL GITHUB_WEB_URL GITEA_URL; do
|
||||
url="$(normalize_url "${CONFIG[$key]}")"
|
||||
is_valid_base_url "$url" ||
|
||||
die "$key in $CONFIG_FILE must be an https URL without credentials, query or fragment"
|
||||
CONFIG[$key]="$url"
|
||||
done
|
||||
CONFIG[GITEA_API_URL]="$(normalize_url "${CONFIG[GITEA_API_URL]:-${CONFIG[GITEA_URL]}/api/v1}")"
|
||||
is_valid_base_url "${CONFIG[GITEA_API_URL]}" ||
|
||||
die "GITEA_API_URL in $CONFIG_FILE must be an https URL without credentials, query or fragment"
|
||||
CONFIG[GITEA_SSH_PORT]="${CONFIG[GITEA_SSH_PORT]:-$DEFAULT_SSH_PORT}"
|
||||
is_valid_port "${CONFIG[GITEA_SSH_PORT]}" ||
|
||||
die "GITEA_SSH_PORT in $CONFIG_FILE must be a port number from 1 to 65535"
|
||||
CONFIG[MIRROR_INTERVAL]="${CONFIG[MIRROR_INTERVAL]:-$DEFAULT_MIRROR_INTERVAL}"
|
||||
is_valid_interval "${CONFIG[MIRROR_INTERVAL]}" ||
|
||||
die "MIRROR_INTERVAL in $CONFIG_FILE must look like 10m0s or 8h0m0s"
|
||||
}
|
||||
|
||||
validate_credentials() {
|
||||
if [[ -z ${CREDENTIALS[GITEA_TOKEN]:-} ]]; then
|
||||
die "GITEA_TOKEN is missing in $ENV_FILE (see .env.example)"
|
||||
fi
|
||||
# Register secrets first so that no later message can show them.
|
||||
SECRET_VALUES+=("${CREDENTIALS[GITEA_TOKEN]}")
|
||||
if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]]; then
|
||||
SECRET_VALUES+=("${CREDENTIALS[GITHUB_PAT]}")
|
||||
fi
|
||||
is_valid_token "${CREDENTIALS[GITEA_TOKEN]}" ||
|
||||
die "GITEA_TOKEN in $ENV_FILE is not a valid token (8 to 255 letters, digits or _ . ~ + / = -)"
|
||||
if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]] &&
|
||||
! is_valid_token "${CREDENTIALS[GITHUB_PAT]}"; then
|
||||
die "GITHUB_PAT in $ENV_FILE is not a valid token (8 to 255 letters, digits or _ . ~ + / = -)"
|
||||
fi
|
||||
if [[ -n ${CREDENTIALS[GITHUB_USER]:-} ]] &&
|
||||
! is_valid_github_owner "${CREDENTIALS[GITHUB_USER]}"; then
|
||||
die "GITHUB_USER in $ENV_FILE is not a valid GitHub account name"
|
||||
fi
|
||||
}
|
||||
|
||||
# GitHub credentials are only needed when the Maintainer chose GitHub.
|
||||
require_github_credentials() {
|
||||
local key
|
||||
for key in GITHUB_PAT GITHUB_USER; do
|
||||
if [[ -z ${CREDENTIALS[$key]:-} ]]; then
|
||||
die "GitHub was chosen but $key is missing in $ENV_FILE (see .env.example)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
warn_if_env_unsafe() {
|
||||
local file="$1" dir mode
|
||||
case "$(uname -s 2>/dev/null || true)" in
|
||||
MINGW* | MSYS* | CYGWIN*) ;;
|
||||
*)
|
||||
mode="$(stat -c '%a' -- "$file" 2>/dev/null ||
|
||||
stat -f '%Lp' -- "$file" 2>/dev/null || true)"
|
||||
if [[ -n $mode ]] && (((8#$mode & 8#077) != 0)); then
|
||||
warn "$file is readable by other users (mode $mode); run: chmod 600 $file"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
dir="."
|
||||
if [[ $file == */* ]]; then
|
||||
dir="${file%/*}"
|
||||
fi
|
||||
if git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
|
||||
! git -C "$dir" check-ignore -q -- "$file"; then
|
||||
warn "$file is not ignored by git; add it to .gitignore before committing"
|
||||
fi
|
||||
}
|
||||
|
||||
load_configuration() {
|
||||
parse_env_file "$CONFIG_FILE" CONFIG_KEYS CONFIG
|
||||
validate_config
|
||||
parse_env_file "$ENV_FILE" CREDENTIAL_KEYS CREDENTIALS
|
||||
validate_credentials
|
||||
warn_if_env_unsafe "$ENV_FILE"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# shellcheck shell=bash
|
||||
# constants.sh - Constants and the shared state of a run.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# The state variables are read and written by the other library files; this
|
||||
# is the one place that declares them.
|
||||
# shellcheck disable=SC2034 # read and written by the other library files
|
||||
|
||||
readonly PROJECT_NAME="${REPOFOUNDRY_NAME:-RepoFoundry}"
|
||||
readonly VERSION="0.2.0"
|
||||
readonly EXIT_FAILURE=1
|
||||
readonly EXIT_USAGE=2
|
||||
readonly MAX_VALUE_LENGTH=2048
|
||||
readonly MAX_DESCRIPTION_LENGTH=350
|
||||
readonly HTTP_TIMEOUT_SECONDS=30
|
||||
readonly DEFAULT_MIRROR_INTERVAL="10m0s"
|
||||
readonly DEFAULT_SSH_PORT=10022
|
||||
readonly AGPL_LICENSE_KEY="AGPL-3.0"
|
||||
readonly DEFAULT_BRANCH="main"
|
||||
readonly PLAN_STEPS=("GitHub repository" "Gitea repository" "Push mirror")
|
||||
# shellcheck disable=SC2034 # read through namerefs (parse_env_file)
|
||||
readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL
|
||||
GITEA_SSH_PORT MIRROR_INTERVAL)
|
||||
readonly CREDENTIAL_KEYS=(GITHUB_PAT GITHUB_USER GITEA_TOKEN)
|
||||
|
||||
CONFIG_FILE="$PROJECT_ROOT/config.env"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
TMP_DIR=""
|
||||
HAS_JQ=0
|
||||
HTTP_STATUS=0
|
||||
HTTP_BODY_FILE=""
|
||||
HTTP_ERROR=""
|
||||
REPLY=""
|
||||
IS_APPLY=0
|
||||
IS_CREATION_STARTED=0
|
||||
SECRET_VALUES=()
|
||||
TEMP_FILES=()
|
||||
declare -A CONFIG=()
|
||||
declare -A CREDENTIALS=()
|
||||
declare -A PROJECT=()
|
||||
# Facts found by the preflight checks (logins, owner kinds, repository state).
|
||||
declare -A STATE=()
|
||||
# Outcome of each step in PLAN_STEPS, for the final report.
|
||||
declare -A STEP_STATUS=()
|
||||
declare -A STEP_DETAIL=()
|
||||
@@ -0,0 +1,28 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# hosts.sh - Names and links of the repositories on each host.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: is_reused, repo_owner, repo_url
|
||||
|
||||
# is_reused HOST: succeed if the existing repository on HOST will be reused.
|
||||
is_reused() {
|
||||
[[ ${STATE[reuse_$1]:-0} == 1 ]]
|
||||
}
|
||||
|
||||
repo_owner() {
|
||||
if [[ $1 == gitea ]]; then
|
||||
printf '%s' "${PROJECT[gitea_owner]}"
|
||||
else
|
||||
printf '%s' "${PROJECT[github_owner]}"
|
||||
fi
|
||||
}
|
||||
|
||||
repo_url() {
|
||||
if [[ $1 == gitea ]]; then
|
||||
printf '%s/%s/%s' "${CONFIG[GITEA_URL]}" "${PROJECT[gitea_owner]}" "${PROJECT[name]}"
|
||||
else
|
||||
printf '%s/%s/%s' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# http.sh - The one safe place that runs curl: tokens stay off the command line.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: describe_http_status, describe_curl_error, http_request
|
||||
|
||||
describe_http_status() {
|
||||
case "$1" in
|
||||
401) printf 'authentication failed: the token is missing, expired or invalid' ;;
|
||||
403) printf 'the token is valid but not allowed to do this (check its scopes)' ;;
|
||||
404) printf 'not found (check the name, the owner and the token access)' ;;
|
||||
409 | 422) printf 'rejected (the name may already exist or be invalid)' ;;
|
||||
429) printf 'rate limited; wait and try again' ;;
|
||||
5??) printf 'the server reported an error; try again later' ;;
|
||||
*) printf 'unexpected HTTP status %s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
describe_curl_error() {
|
||||
case "$1" in
|
||||
6) printf 'could not resolve the host name' ;;
|
||||
7) printf 'could not connect' ;;
|
||||
28) printf 'the request timed out' ;;
|
||||
35 | 51 | 58 | 60) printf 'the TLS connection failed' ;;
|
||||
*) printf 'curl failed with exit code %s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# http_request METHOD URL SCHEME TOKEN [BODY]
|
||||
# SCHEME is "token" (Gitea) or "bearer" (GitHub). The token goes into a
|
||||
# private curl config file, never onto the command line where other users
|
||||
# could see it. Redirects are not followed, so the token is only ever sent
|
||||
# to the host named in URL. On success HTTP_STATUS and HTTP_BODY_FILE are
|
||||
# set; on a network failure the function returns 1 with HTTP_ERROR set.
|
||||
# shellcheck disable=SC2034 # HTTP_* are results read by the callers
|
||||
http_request() {
|
||||
local method="$1" url="$2" scheme="$3" token="$4" body="${5:-}"
|
||||
local header config_file body_file out_file host curl_status=0
|
||||
local data_args=()
|
||||
[[ $method =~ ^(GET|POST|PUT|PATCH|DELETE)$ ]] ||
|
||||
die "internal error: unsupported HTTP method"
|
||||
is_valid_request_url "$url" ||
|
||||
die "refusing to call an invalid or non-https URL"
|
||||
is_valid_token "$token" || die "refusing to send a malformed token"
|
||||
case "$scheme" in
|
||||
token) header="Authorization: token $token" ;;
|
||||
bearer) header="Authorization: Bearer $token" ;;
|
||||
*) die "internal error: unknown authentication scheme" ;;
|
||||
esac
|
||||
make_temp_file
|
||||
config_file="$REPLY"
|
||||
make_temp_file
|
||||
out_file="$REPLY"
|
||||
{
|
||||
printf 'url = "%s"\n' "$url"
|
||||
printf 'request = "%s"\n' "$method"
|
||||
printf 'header = "%s"\n' "$header"
|
||||
printf 'header = "Accept: application/json"\n'
|
||||
printf 'header = "User-Agent: %s/%s"\n' "$PROJECT_NAME" "$VERSION"
|
||||
} >"$config_file"
|
||||
if [[ -n $body ]]; then
|
||||
make_temp_file
|
||||
body_file="$REPLY"
|
||||
printf '%s' "$body" >"$body_file"
|
||||
printf 'header = "Content-Type: application/json"\n' >>"$config_file"
|
||||
data_args=(--data-binary "@$body_file")
|
||||
fi
|
||||
# curl's own error text is dropped: the exit code is mapped to a message
|
||||
# that never contains the request.
|
||||
HTTP_STATUS="$(curl --silent --max-time "$HTTP_TIMEOUT_SECONDS" \
|
||||
--connect-timeout 10 --output "$out_file" --write-out '%{http_code}' \
|
||||
--config "$config_file" "${data_args[@]}" 2>/dev/null)" || curl_status=$?
|
||||
# The configuration file holds the token and the body may hold another one
|
||||
# (the mirror password): remove both now instead of at exit.
|
||||
rm -f -- "$config_file" ${body_file:+"$body_file"}
|
||||
if ((curl_status != 0)); then
|
||||
host="${url#https://}"
|
||||
host="${host%%/*}"
|
||||
HTTP_STATUS=0
|
||||
HTTP_ERROR="could not reach $host: $(describe_curl_error "$curl_status")"
|
||||
return 1
|
||||
fi
|
||||
HTTP_BODY_FILE="$out_file"
|
||||
HTTP_ERROR=""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# json.sh - Reading and writing the small amount of JSON the script needs.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: json_escape, json_get, json_has_value, json_values
|
||||
|
||||
# json_escape TEXT: escape TEXT for use inside a JSON string.
|
||||
json_escape() {
|
||||
local text="$1"
|
||||
text="${text//\\/\\\\}"
|
||||
text="${text//\"/\\\"}"
|
||||
text="${text//$'\n'/\\n}"
|
||||
text="${text//$'\r'/\\r}"
|
||||
text="${text//$'\t'/\\t}"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
# json_get FILE KEY: print the string, number or boolean value of KEY.
|
||||
# With jq only the top-level key is read. Without jq the first occurrence of
|
||||
# the key anywhere in the file is used, which is enough for the flat fields
|
||||
# the GitHub and Gitea APIs return (name, id, html_url, ...).
|
||||
json_get() {
|
||||
local file="$1" key="$2"
|
||||
[[ $key =~ ^[A-Za-z0-9_]+$ ]] || die "internal error: invalid JSON key"
|
||||
if ((HAS_JQ)); then
|
||||
# jq on Windows ends lines with CRLF; strip the CR so values stay clean.
|
||||
jq -r --arg key "$key" \
|
||||
'if has($key) and .[$key] != null then .[$key] | tostring else empty end' \
|
||||
"$file" | tr -d '\r'
|
||||
else
|
||||
# grep exits 1 when the key is absent; that is not an error here.
|
||||
{ grep -o "\"$key\"[[:space:]]*:[[:space:]]*\(\"[^\"]*\"\|[0-9][0-9]*\|true\|false\)" "$file" || true; } |
|
||||
head -n 1 |
|
||||
sed -e 's/^[^:]*:[[:space:]]*//' -e 's/^"\(.*\)"$/\1/'
|
||||
fi
|
||||
}
|
||||
|
||||
# json_has_value FILE KEY VALUE: succeed if the file holds "KEY": "VALUE",
|
||||
# written with or without a space after the colon.
|
||||
json_has_value() {
|
||||
local file="$1" key="$2" value="$3"
|
||||
grep -Fq "\"$key\":\"$value\"" "$file" ||
|
||||
grep -Fq "\"$key\": \"$value\"" "$file"
|
||||
}
|
||||
|
||||
# json_values FILE KEY: print every string value of KEY, one per line.
|
||||
json_values() {
|
||||
local file="$1" key="$2"
|
||||
[[ $key =~ ^[A-Za-z0-9_]+$ ]] || die "internal error: invalid JSON key"
|
||||
# grep exits 1 when the key is absent; that is not an error here.
|
||||
{ grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" || true; } |
|
||||
sed -e 's/^[^:]*:[[:space:]]*//' -e 's/^"\(.*\)"$/\1/'
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# mirror.sh - The Gitea to GitHub push mirror: create, verify, first sync.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: mirror_field, mirror_path, mirror_address, mirror_exists, create_mirror, verify_mirror, request_first_sync, configure_mirror
|
||||
|
||||
# mirror_field FILE ADDRESS FIELD: print FIELD of the push mirror whose
|
||||
# remote_address is ADDRESS. Without jq the first mirror in the list is used,
|
||||
# which is the only one on a repository this script has just created.
|
||||
mirror_field() {
|
||||
local file="$1" address="$2" field="$3"
|
||||
if ((HAS_JQ)); then
|
||||
jq -r --arg address "$address" --arg field "$field" \
|
||||
'[.[] | select(.remote_address == $address)][0]
|
||||
| if . == null or .[$field] == null then empty else .[$field] | tostring end' \
|
||||
"$file" | tr -d '\r'
|
||||
else
|
||||
json_get "$file" "$field"
|
||||
fi
|
||||
}
|
||||
|
||||
mirror_path() {
|
||||
printf '/repos/%s/%s/push_mirrors' "${PROJECT[gitea_owner]}" "${PROJECT[name]}"
|
||||
}
|
||||
|
||||
mirror_address() {
|
||||
printf '%s/%s/%s.git' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
||||
}
|
||||
|
||||
# mirror_exists ADDRESS: succeed if Gitea already pushes to ADDRESS.
|
||||
mirror_exists() {
|
||||
api_call gitea GET "$(mirror_path)"
|
||||
expect_status "cannot list the push mirrors of the Gitea repository" 200
|
||||
json_has_value "$HTTP_BODY_FILE" remote_address "$1"
|
||||
}
|
||||
|
||||
create_mirror() {
|
||||
local address="$1" body
|
||||
# The GitHub token is the password of the mirror; the body file that holds
|
||||
# it is removed as soon as the request has been sent.
|
||||
body="$(printf '{"remote_address":"%s","remote_username":"%s","remote_password":"%s","interval":"%s","sync_on_commit":true}' \
|
||||
"$address" "${STATE[github_login]}" "${CREDENTIALS[GITHUB_PAT]}" \
|
||||
"${CONFIG[MIRROR_INTERVAL]}")"
|
||||
api_call gitea POST "$(mirror_path)" "$body"
|
||||
expect_status "cannot create the push mirror (push mirrors may be switched off on the Gitea server, the interval may be shorter than the server allows, or the GitHub token may not push to the new repository)" 200 201
|
||||
}
|
||||
|
||||
# verify_mirror ADDRESS: read the mirror back and report what Gitea applied.
|
||||
verify_mirror() {
|
||||
local address="$1" on_commit
|
||||
api_call gitea GET "$(mirror_path)"
|
||||
expect_status "cannot read the push mirror back from Gitea" 200
|
||||
json_has_value "$HTTP_BODY_FILE" remote_address "$address" ||
|
||||
die "Gitea did not list the push mirror after creating it"
|
||||
on_commit="$(mirror_field "$HTTP_BODY_FILE" "$address" sync_on_commit)"
|
||||
if [[ $on_commit != true ]]; then
|
||||
warn "Gitea did not apply sync_on_commit (a known server issue): the mirror syncs every ${CONFIG[MIRROR_INTERVAL]}, not on every commit. Switch it on in the repository settings if you need it."
|
||||
STEP_DETAIL["Push mirror"]="(syncs every ${CONFIG[MIRROR_INTERVAL]}, not on every commit)"
|
||||
fi
|
||||
}
|
||||
|
||||
# request_first_sync ADDRESS: ask Gitea for a first push and report an error
|
||||
# it records. A failure here is a warning: the mirror retries by itself.
|
||||
request_first_sync() {
|
||||
local address="$1" last_error
|
||||
api_call gitea POST "$(mirror_path)-sync"
|
||||
if [[ $HTTP_STATUS != 200 && $HTTP_STATUS != 204 ]]; then
|
||||
warn "could not ask Gitea for the first sync (HTTP $HTTP_STATUS); it runs at the next interval"
|
||||
return 0
|
||||
fi
|
||||
sleep "${REPOFOUNDRY_SYNC_WAIT:-3}"
|
||||
api_call gitea GET "$(mirror_path)"
|
||||
if [[ $HTTP_STATUS == 200 ]]; then
|
||||
last_error="$(mirror_field "$HTTP_BODY_FILE" "$address" last_error)"
|
||||
if [[ -n $last_error ]]; then
|
||||
warn "the first mirror sync reported: ${last_error:0:200}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
configure_mirror() {
|
||||
local label="Push mirror" address
|
||||
address="$(mirror_address)"
|
||||
begin_step "$label"
|
||||
if mirror_exists "$address"; then
|
||||
finish_step "$label" "reused" "Gitea -> $address"
|
||||
else
|
||||
create_mirror "$address"
|
||||
finish_step "$label" "created" "Gitea -> $address"
|
||||
verify_mirror "$address"
|
||||
fi
|
||||
request_first_sync "$address"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# output.sh - Messages for the user: output, warnings, errors, and redaction of secrets.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: redact, say, warn, die, on_error
|
||||
|
||||
# redact TEXT: print TEXT with every known secret value replaced.
|
||||
redact() {
|
||||
local text="$1" secret
|
||||
for secret in "${SECRET_VALUES[@]}"; do
|
||||
if [[ -n $secret ]]; then
|
||||
text="${text//"$secret"/[redacted]}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
say() {
|
||||
printf '%s\n' "$(redact "$*")"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf 'warning: %s\n' "$(redact "$*")" >&2
|
||||
}
|
||||
|
||||
# die [--code N] MESSAGE: print "error: MESSAGE" and exit (default code 1).
|
||||
die() {
|
||||
local code=$EXIT_FAILURE
|
||||
if [[ ${1:-} == --code ]]; then
|
||||
code="$2"
|
||||
shift 2
|
||||
fi
|
||||
printf 'error: %s\n' "$(redact "$*")" >&2
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
# on_error LINE: report an unexpected failure without echoing the command,
|
||||
# because a command line could contain a value that must stay private.
|
||||
on_error() {
|
||||
printf 'error: unexpected failure near line %s of %s\n' "$1" "${0##*/}" >&2
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# plan.sh - Printing what the script is about to do.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: print_plan
|
||||
|
||||
print_plan() {
|
||||
local gitea_action github_action origin_note
|
||||
say ""
|
||||
say "Plan:"
|
||||
if ((STATE[reuse_gitea])); then
|
||||
gitea_action="reuse the existing repository (you will be asked to confirm)"
|
||||
elif ((PROJECT[has_github])); then
|
||||
gitea_action="create (${PROJECT[visibility]}) with the $AGPL_LICENSE_KEY license"
|
||||
else
|
||||
gitea_action="create (${PROJECT[visibility]}), empty"
|
||||
fi
|
||||
say "$(printf ' %-18s: %s %s' "Gitea repository" "$gitea_action" "$(repo_url gitea)")"
|
||||
if ((PROJECT[has_github])); then
|
||||
if ((STATE[reuse_github])); then
|
||||
github_action="reuse the existing empty repository (you will be asked to confirm)"
|
||||
else
|
||||
github_action="create (${PROJECT[visibility]}), empty"
|
||||
fi
|
||||
say "$(printf ' %-18s: %s %s' "GitHub repository" "$github_action" "$(repo_url github)")"
|
||||
say "$(printf ' %-18s: %s' "Push mirror" "Gitea -> GitHub every ${CONFIG[MIRROR_INTERVAL]}")"
|
||||
else
|
||||
say "$(printf ' %-18s: %s' "GitHub repository" "not used")"
|
||||
say "$(printf ' %-18s: %s' "Push mirror" "not used")"
|
||||
fi
|
||||
if ((STATE[is_ssh_ok])); then
|
||||
origin_note="SSH (the SSH test passed)"
|
||||
else
|
||||
origin_note="HTTPS (SSH test: ${STATE[ssh_note]})"
|
||||
fi
|
||||
say "$(printf ' %-18s: %s' "Local origin" "will use $origin_note, in a later phase")"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# preflight.sh - Read-only checks of both hosts before anything is created.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: check_gitea_organization, check_gitea_license, inspect_repository, preflight_gitea, check_github_organization, preflight_github, test_gitea_ssh, decide_existing_repositories, run_preflight
|
||||
|
||||
check_gitea_organization() {
|
||||
local org="$1" login="$2"
|
||||
api_call gitea GET "/orgs/$org"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
die "Gitea owner '$org' is neither your account ($login) nor an organization the token can see"
|
||||
fi
|
||||
expect_status "cannot look up the Gitea organization '$org'" 200
|
||||
api_call gitea GET "/users/$login/orgs/$org/permissions"
|
||||
expect_status "cannot read your permissions in the Gitea organization '$org'" 200
|
||||
if [[ $(json_get "$HTTP_BODY_FILE" can_create_repository) != true ]]; then
|
||||
die "you may not create repositories in the Gitea organization '$org'"
|
||||
fi
|
||||
}
|
||||
|
||||
check_gitea_license() {
|
||||
api_call gitea GET /licenses
|
||||
expect_status "cannot list the licenses of the Gitea server" 200
|
||||
json_has_value "$HTTP_BODY_FILE" key "$AGPL_LICENSE_KEY" ||
|
||||
die "the Gitea server does not offer the $AGPL_LICENSE_KEY license"
|
||||
}
|
||||
|
||||
# inspect_repository HOST: record in STATE[HOST_repo] whether the repository
|
||||
# is free (does not exist), empty, license_only or not_empty.
|
||||
inspect_repository() {
|
||||
local host="$1" owner name names
|
||||
owner="$(repo_owner "$host")"
|
||||
name="${PROJECT[name]}"
|
||||
api_call "$host" GET "/repos/$owner/$name"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
STATE[${host}_repo]="free"
|
||||
return 0
|
||||
fi
|
||||
expect_status "cannot look up the $(host_label "$host") repository $owner/$name" 200
|
||||
api_call "$host" GET "/repos/$owner/$name/contents"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
STATE[${host}_repo]="empty"
|
||||
return 0
|
||||
fi
|
||||
expect_status "cannot read the contents of the $(host_label "$host") repository $owner/$name" 200
|
||||
names="$(json_values "$HTTP_BODY_FILE" name)"
|
||||
case "$names" in
|
||||
"") STATE[${host}_repo]="empty" ;;
|
||||
LICENSE) STATE[${host}_repo]="license_only" ;;
|
||||
*) STATE[${host}_repo]="not_empty" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
preflight_gitea() {
|
||||
local owner="${PROJECT[gitea_owner]}" login
|
||||
api_call gitea GET /user
|
||||
expect_status "Gitea rejected the token" 200
|
||||
login="$(json_get "$HTTP_BODY_FILE" login)"
|
||||
[[ -n $login ]] || die "Gitea did not say which account the token belongs to"
|
||||
STATE[gitea_login]="$login"
|
||||
if is_same_name "$owner" "$login"; then
|
||||
STATE[gitea_owner_kind]="user"
|
||||
else
|
||||
check_gitea_organization "$owner" "$login"
|
||||
STATE[gitea_owner_kind]="organization"
|
||||
fi
|
||||
if ((PROJECT[has_github])); then
|
||||
check_gitea_license
|
||||
fi
|
||||
inspect_repository gitea
|
||||
}
|
||||
|
||||
check_github_organization() {
|
||||
local org="$1" login="$2"
|
||||
api_call github GET "/user/memberships/orgs/$org"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
die "GitHub owner '$org' is neither your account ($login) nor an organization you belong to (or the token lacks the read:org scope)"
|
||||
fi
|
||||
expect_status "cannot read your membership of the GitHub organization '$org'" 200
|
||||
if [[ $(json_get "$HTTP_BODY_FILE" state) != active ]]; then
|
||||
die "your membership of the GitHub organization '$org' is not active"
|
||||
fi
|
||||
}
|
||||
|
||||
preflight_github() {
|
||||
local owner="${PROJECT[github_owner]}" configured login
|
||||
configured="${CREDENTIALS[GITHUB_USER]:-}"
|
||||
api_call github GET /user
|
||||
expect_status "GitHub rejected the token" 200
|
||||
login="$(json_get "$HTTP_BODY_FILE" login)"
|
||||
[[ -n $login ]] || die "GitHub did not say which account the token belongs to"
|
||||
STATE[github_login]="$login"
|
||||
if [[ -n $configured ]] && ! is_same_name "$configured" "$login"; then
|
||||
warn "GITHUB_USER is '$configured' but the token belongs to '$login'; the mirror will use '$login'"
|
||||
fi
|
||||
if is_same_name "$owner" "$login"; then
|
||||
STATE[github_owner_kind]="user"
|
||||
else
|
||||
check_github_organization "$owner" "$login"
|
||||
STATE[github_owner_kind]="organization"
|
||||
fi
|
||||
inspect_repository github
|
||||
}
|
||||
|
||||
# The SSH result decides later whether origin uses SSH or HTTPS. Without ssh
|
||||
# or without access it is simply "not passed"; it never stops the run.
|
||||
test_gitea_ssh() {
|
||||
local host port output status=0
|
||||
host="${CONFIG[GITEA_URL]#https://}"
|
||||
host="${host%%/*}"
|
||||
host="${host%%:*}"
|
||||
port="${CONFIG[GITEA_SSH_PORT]}"
|
||||
STATE[is_ssh_ok]=0
|
||||
STATE[ssh_note]="failed (check your SSH key and that $host:$port is reachable)"
|
||||
if ! command -v ssh >/dev/null 2>&1; then
|
||||
STATE[ssh_note]="not tested (ssh is not installed)"
|
||||
return 0
|
||||
fi
|
||||
output="$(ssh -p "$port" -o BatchMode=yes -o ConnectTimeout=5 \
|
||||
-o StrictHostKeyChecking=yes -T "git@$host" 2>&1)" || status=$?
|
||||
if ((status == 0)) || [[ $output == *"successfully authenticated"* ]]; then
|
||||
STATE[is_ssh_ok]=1
|
||||
STATE[ssh_note]="passed"
|
||||
fi
|
||||
}
|
||||
|
||||
# decide_existing_repositories: a repository that already exists may only be
|
||||
# reused when it is empty (Gitea: or holds just the license this script adds).
|
||||
decide_existing_repositories() {
|
||||
local host owner state
|
||||
for host in gitea github; do
|
||||
STATE[reuse_$host]=0
|
||||
if [[ $host == github ]] && ! ((PROJECT[has_github])); then
|
||||
continue
|
||||
fi
|
||||
owner="$(repo_owner "$host")"
|
||||
state="${STATE[${host}_repo]}"
|
||||
case "$state" in
|
||||
free) ;;
|
||||
empty) STATE[reuse_$host]=1 ;;
|
||||
license_only)
|
||||
if [[ $host == gitea ]] && ((PROJECT[has_github])); then
|
||||
STATE[reuse_$host]=1
|
||||
else
|
||||
die "the $(host_label "$host") repository $owner/${PROJECT[name]} already exists and has content; choose another name or remove it first"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
die "the $(host_label "$host") repository $owner/${PROJECT[name]} already exists and has content; choose another name or remove it first"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
run_preflight() {
|
||||
say ""
|
||||
say "Checking the hosts (read-only requests)..."
|
||||
preflight_gitea
|
||||
if ((PROJECT[has_github])); then
|
||||
preflight_github
|
||||
fi
|
||||
test_gitea_ssh
|
||||
decide_existing_repositories
|
||||
say "All checks passed."
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# project.sh - The details of the project being created: asking for them and showing them.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: collect_project_details, yes_no, credential_state, print_summary
|
||||
|
||||
collect_project_details() {
|
||||
prompt_value "Repository name" "" is_valid_repo_name \
|
||||
"use letters, digits, '.', '_' or '-' (at most 100), not ending in .git"
|
||||
PROJECT[name]="$REPLY"
|
||||
prompt_value "Description (optional)" "" is_valid_description \
|
||||
"at most $MAX_DESCRIPTION_LENGTH characters and no control characters"
|
||||
PROJECT[description]="$REPLY"
|
||||
prompt_choice "Visibility" private private public
|
||||
PROJECT[visibility]="$REPLY"
|
||||
prompt_value "Gitea owner (user or organization)" "" is_valid_gitea_owner \
|
||||
"use letters, digits, '.', '_' or '-' (at most 39)"
|
||||
PROJECT[gitea_owner]="$REPLY"
|
||||
prompt_yes_no "Also create a GitHub repository (applies the AGPL license)" y
|
||||
PROJECT[has_github]="$REPLY"
|
||||
PROJECT[github_owner]=""
|
||||
if ((PROJECT[has_github])); then
|
||||
prompt_value "GitHub owner (user or organization)" \
|
||||
"${CREDENTIALS[GITHUB_USER]:-}" is_valid_github_owner \
|
||||
"use letters, digits or '-' (at most 39)"
|
||||
PROJECT[github_owner]="$REPLY"
|
||||
fi
|
||||
prompt_value "Local directory" "./${PROJECT[name]}" is_valid_directory \
|
||||
"must not be empty, start with '-' or contain control characters"
|
||||
PROJECT[directory]="$REPLY"
|
||||
prompt_yes_no "Enable the plan gate" n
|
||||
PROJECT[is_plan_gate_enabled]="$REPLY"
|
||||
}
|
||||
|
||||
yes_no() {
|
||||
if (($1)); then
|
||||
printf 'yes'
|
||||
else
|
||||
printf 'no'
|
||||
fi
|
||||
}
|
||||
|
||||
credential_state() {
|
||||
if [[ -n ${CREDENTIALS[$1]:-} ]]; then
|
||||
printf 'set'
|
||||
else
|
||||
printf 'not set'
|
||||
fi
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
say ""
|
||||
say "$PROJECT_NAME $VERSION"
|
||||
say "Collected details:"
|
||||
say " Repository : ${PROJECT[name]} (${PROJECT[visibility]})"
|
||||
say " Description : ${PROJECT[description]:-(none)}"
|
||||
say " Gitea : ${CONFIG[GITEA_URL]}/${PROJECT[gitea_owner]}/${PROJECT[name]}"
|
||||
if ((PROJECT[has_github])); then
|
||||
say " GitHub : ${CONFIG[GITHUB_WEB_URL]}/${PROJECT[github_owner]}/${PROJECT[name]} (AGPL license applied)"
|
||||
else
|
||||
say " GitHub : not used"
|
||||
fi
|
||||
say " Directory : ${PROJECT[directory]}"
|
||||
say " Plan gate : $(yes_no "${PROJECT[is_plan_gate_enabled]}")"
|
||||
say "Credentials : GITEA_TOKEN $(credential_state GITEA_TOKEN)," \
|
||||
"GITHUB_PAT $(credential_state GITHUB_PAT)"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# prompts.sh - Interactive questions with validation of every answer.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: prompt_value, prompt_choice, prompt_yes_no
|
||||
|
||||
# prompt_value LABEL DEFAULT VALIDATOR HINT: ask until VALIDATOR accepts the
|
||||
# answer; the accepted answer is returned in REPLY.
|
||||
prompt_value() {
|
||||
local label="$1" default="$2" validator="$3" hint="$4" answer
|
||||
while true; do
|
||||
if [[ -n $default ]]; then
|
||||
printf '%s [%s]: ' "$label" "$default" >&2
|
||||
else
|
||||
printf '%s: ' "$label" >&2
|
||||
fi
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
if "$validator" "$answer"; then
|
||||
REPLY="$answer"
|
||||
return 0
|
||||
fi
|
||||
warn "invalid $label: $hint"
|
||||
done
|
||||
}
|
||||
|
||||
# prompt_choice LABEL DEFAULT CHOICE...: the answer is returned in REPLY.
|
||||
prompt_choice() {
|
||||
local label="$1" default="$2" answer
|
||||
shift 2
|
||||
while true; do
|
||||
printf '%s (%s) [%s]: ' "$label" "$(IFS=/ && echo "$*")" "$default" >&2
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
answer="${answer,,}"
|
||||
if in_list "$answer" "$@"; then
|
||||
REPLY="$answer"
|
||||
return 0
|
||||
fi
|
||||
warn "invalid $label: choose one of $*"
|
||||
done
|
||||
}
|
||||
|
||||
# prompt_yes_no LABEL DEFAULT: DEFAULT is y or n; REPLY is 1 (yes) or 0 (no).
|
||||
prompt_yes_no() {
|
||||
local label="$1" default="$2" answer
|
||||
while true; do
|
||||
printf '%s (y/n) [%s]: ' "$label" "$default" >&2
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
case "${answer,,}" in
|
||||
y | yes)
|
||||
REPLY=1
|
||||
return 0
|
||||
;;
|
||||
n | no)
|
||||
REPLY=0
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
warn "invalid $label: answer y or n"
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# repositories.sh - Creating the GitHub and Gitea repositories.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: repo_body, create_repository
|
||||
|
||||
# repo_body HOST: the JSON body that creates the repository on HOST.
|
||||
repo_body() {
|
||||
local host="$1" description private=true extra=""
|
||||
description="$(json_escape "${PROJECT[description]}")"
|
||||
if [[ ${PROJECT[visibility]} != private ]]; then
|
||||
private=false
|
||||
fi
|
||||
if [[ $host == github ]]; then
|
||||
printf '{"name":"%s","description":"%s","private":%s,"auto_init":false}' \
|
||||
"${PROJECT[name]}" "$description" "$private"
|
||||
return 0
|
||||
fi
|
||||
if ((PROJECT[has_github])); then
|
||||
extra=',"auto_init":true,"license":"'"$AGPL_LICENSE_KEY"'"'
|
||||
else
|
||||
extra=',"auto_init":false'
|
||||
fi
|
||||
printf '{"name":"%s","description":"%s","private":%s,"default_branch":"%s"%s}' \
|
||||
"${PROJECT[name]}" "$description" "$private" "$DEFAULT_BRANCH" "$extra"
|
||||
}
|
||||
|
||||
# create_repository HOST: create the repository, or reuse the existing one.
|
||||
create_repository() {
|
||||
local host="$1" label owner path
|
||||
label="$(host_label "$host") repository"
|
||||
owner="$(repo_owner "$host")"
|
||||
if is_reused "$host"; then
|
||||
finish_step "$label" "reused" "$(repo_url "$host")"
|
||||
return 0
|
||||
fi
|
||||
begin_step "$label"
|
||||
path="/user/repos"
|
||||
if [[ ${STATE[${host}_owner_kind]} == organization ]]; then
|
||||
path="/orgs/$owner/repos"
|
||||
fi
|
||||
api_call "$host" POST "$path" "$(repo_body "$host")"
|
||||
expect_status "cannot create the $label" 201
|
||||
finish_step "$label" "created" "$(repo_url "$host")"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# steps.sh - The outcome of each step and the final report of a run.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: init_steps, begin_step, finish_step, report_outcome
|
||||
|
||||
init_steps() {
|
||||
local label
|
||||
for label in "${PLAN_STEPS[@]}"; do
|
||||
STEP_STATUS[$label]="not attempted"
|
||||
STEP_DETAIL[$label]=""
|
||||
done
|
||||
if ! ((PROJECT[has_github])); then
|
||||
STEP_STATUS["GitHub repository"]="not used"
|
||||
STEP_STATUS["Push mirror"]="not used"
|
||||
fi
|
||||
}
|
||||
|
||||
# begin_step LABEL marks the step failed until finish_step says otherwise, so
|
||||
# any stop in the middle of a step is reported as a failure of that step.
|
||||
begin_step() {
|
||||
STEP_STATUS[$1]="FAILED"
|
||||
STEP_DETAIL[$1]=""
|
||||
}
|
||||
|
||||
finish_step() {
|
||||
STEP_STATUS[$1]="$2"
|
||||
STEP_DETAIL[$1]="${3:-}"
|
||||
}
|
||||
|
||||
report_outcome() {
|
||||
local code="$1" label
|
||||
say ""
|
||||
if ((code == 0)); then
|
||||
say "Done. This is what exists now:"
|
||||
else
|
||||
say "The run stopped before it finished. This is what exists now:"
|
||||
fi
|
||||
for label in "${PLAN_STEPS[@]}"; do
|
||||
say "$(printf ' %-18s: %s' "$label" "${STEP_STATUS[$label]}${STEP_DETAIL[$label]:+ ${STEP_DETAIL[$label]}}")"
|
||||
done
|
||||
if ((code == 0)); then
|
||||
say "The local project with the framework is created by a later phase."
|
||||
else
|
||||
say "To continue: fix the problem named above and run the same command again with --apply."
|
||||
say "A repository this run created is still empty (or holds only the license), so the next run offers to reuse it."
|
||||
say "Nothing is deleted automatically. To start over, delete the repositories above in the web interface."
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# temp.sh - Private temporary files and their cleanup (never a recursive delete).
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: cleanup, setup_temp_dir, make_temp_file
|
||||
|
||||
# Files are removed one by one and the directory with rmdir: a recursive
|
||||
# delete is never needed and never used.
|
||||
cleanup() {
|
||||
local file
|
||||
for file in "${TEMP_FILES[@]}"; do
|
||||
rm -f -- "$file"
|
||||
done
|
||||
if [[ -n $TMP_DIR && -d $TMP_DIR ]]; then
|
||||
# rmdir fails only if something unexpected is left inside; leave it
|
||||
# rather than delete files this script did not create.
|
||||
rmdir -- "$TMP_DIR" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
setup_temp_dir() {
|
||||
TMP_DIR="$(umask 077 && mktemp -d "${TMPDIR:-/tmp}/repofoundry.XXXXXX")"
|
||||
}
|
||||
|
||||
# make_temp_file: create a private file in TMP_DIR and return it in REPLY.
|
||||
make_temp_file() {
|
||||
REPLY="$(umask 077 && mktemp "$TMP_DIR/file.XXXXXX")"
|
||||
TEMP_FILES+=("$REPLY")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# tools.sh - Checking that the required tools are installed.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: check_tools
|
||||
|
||||
check_tools() {
|
||||
local tool
|
||||
local missing=()
|
||||
for tool in git curl mktemp; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing+=("$tool")
|
||||
fi
|
||||
done
|
||||
if ((${#missing[@]} > 0)); then
|
||||
die "required tool(s) not found: ${missing[*]}. Install them and try again."
|
||||
fi
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
HAS_JQ=1
|
||||
else
|
||||
HAS_JQ=0
|
||||
warn "jq not found; using the built-in JSON reader (install jq for stricter parsing)"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# util.sh - Small string and list helpers with no knowledge of the project.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: trim, in_list, has_control_character, is_same_name
|
||||
|
||||
trim() {
|
||||
local text="$1"
|
||||
text="${text#"${text%%[![:space:]]*}"}"
|
||||
text="${text%"${text##*[![:space:]]}"}"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
# in_list NEEDLE ITEM...: succeed if NEEDLE equals one of the items.
|
||||
in_list() {
|
||||
local needle="$1" item
|
||||
shift
|
||||
for item in "$@"; do
|
||||
if [[ $item == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_control_character() {
|
||||
[[ $1 == *[[:cntrl:]]* ]]
|
||||
}
|
||||
|
||||
is_same_name() {
|
||||
[[ ${1,,} == "${2,,}" ]]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# validate.sh - Validators for names, URLs, tokens, ports and intervals.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: is_valid_repo_name, is_valid_gitea_owner, is_valid_github_owner, is_valid_description, is_valid_directory, is_valid_base_url, is_valid_request_url, is_valid_token, is_valid_port, is_valid_interval, normalize_url
|
||||
|
||||
is_valid_repo_name() {
|
||||
local name="$1"
|
||||
[[ $name =~ ^[A-Za-z0-9._-]{1,100}$ ]] || return 1
|
||||
[[ $name != . && $name != .. && $name != *.git ]]
|
||||
}
|
||||
|
||||
is_valid_gitea_owner() {
|
||||
[[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ ]]
|
||||
}
|
||||
|
||||
is_valid_github_owner() {
|
||||
[[ $1 =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$ ]]
|
||||
}
|
||||
|
||||
is_valid_description() {
|
||||
((${#1} <= MAX_DESCRIPTION_LENGTH)) && ! has_control_character "$1"
|
||||
}
|
||||
|
||||
is_valid_directory() {
|
||||
local path="$1"
|
||||
[[ -n $path && ${#path} -le 4096 && $path != -* ]] &&
|
||||
! has_control_character "$path"
|
||||
}
|
||||
|
||||
# https URL without user info, query or fragment, so it can never carry a
|
||||
# credential.
|
||||
is_valid_base_url() {
|
||||
local pattern='^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%+/-]*)?$'
|
||||
[[ $1 =~ $pattern ]]
|
||||
}
|
||||
|
||||
# Like is_valid_base_url but a query string is allowed (for API requests).
|
||||
is_valid_request_url() {
|
||||
local pattern='^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%+/-]*)?(\?[A-Za-z0-9._~%+=&,-]*)?$'
|
||||
[[ $1 =~ $pattern ]]
|
||||
}
|
||||
|
||||
# Access tokens: no quotes, backslashes or whitespace, so a token cannot
|
||||
# break out of the curl configuration it is written to.
|
||||
is_valid_token() {
|
||||
[[ $1 =~ ^[A-Za-z0-9_.~+/=-]{8,255}$ ]]
|
||||
}
|
||||
|
||||
is_valid_port() {
|
||||
[[ $1 =~ ^[0-9]{1,5}$ ]] && ((10#$1 >= 1 && 10#$1 <= 65535))
|
||||
}
|
||||
|
||||
# A Go duration such as 10m0s or 8h0m0s, the form Gitea expects.
|
||||
is_valid_interval() {
|
||||
[[ -n $1 && $1 =~ ^([0-9]+h)?([0-9]+m)?([0-9]+s)?$ ]]
|
||||
}
|
||||
|
||||
normalize_url() {
|
||||
local url="$1"
|
||||
while [[ $url == */ ]]; do
|
||||
url="${url%/}"
|
||||
done
|
||||
printf '%s' "$url"
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib.sh - tiny test helpers for the RepoFoundry tests (sourced, not run).
|
||||
#
|
||||
# Each test file defines functions named test_*; run-tests.sh calls them.
|
||||
# The helpers run create-project.sh in separate bash processes with a private
|
||||
# work directory and stub tools on PATH, so a test never touches the network,
|
||||
# the real .env or the repository.
|
||||
#
|
||||
# Requires: bash 4.4 or later.
|
||||
|
||||
# shellcheck disable=SC2016,SC2034 # stub and snippet text is literal on purpose; OUT, ERR and STATUS are read by the test files
|
||||
REPO_ROOT="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
|
||||
readonly REPO_ROOT
|
||||
readonly SRC_DIR="$REPO_ROOT/src"
|
||||
readonly SCRIPT="$SRC_DIR/create-project.sh"
|
||||
|
||||
# Distinctive fake credentials; the tests search all output for them.
|
||||
readonly FAKE_GITEA_TOKEN="giteaFAKEtoken1234567890"
|
||||
readonly FAKE_GITHUB_PAT="ghpFAKEtoken1234567890"
|
||||
|
||||
# Answers to the prompts: name, description, visibility, Gitea owner, GitHub
|
||||
# yes or no, GitHub owner, directory, plan gate.
|
||||
readonly ANSWERS_GITHUB=$'my-app\nA test app\n\nTirSystem\ny\nacme-org\n\nn\n'
|
||||
readonly ANSWERS_GITEA_ONLY=$'my-app\n\n\nTirSystem\nn\n\nn\n'
|
||||
|
||||
TESTS_RUN=0
|
||||
TESTS_FAILED=0
|
||||
CURRENT_TEST=""
|
||||
WORK=""
|
||||
OUT=""
|
||||
ERR=""
|
||||
STATUS=0
|
||||
|
||||
fail() {
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
printf 'FAIL %s: %s\n' "$CURRENT_TEST" "$1"
|
||||
}
|
||||
|
||||
check() {
|
||||
TESTS_RUN=$((TESTS_RUN + 1))
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
check
|
||||
if [[ $2 != "$3" ]]; then
|
||||
fail "$1: expected '$2', got '$3'"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
assert_eq "$1 (exit status)" "$2" "$3"
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
check
|
||||
if [[ $2 != *"$3"* ]]; then
|
||||
fail "$1: output does not contain '$3'"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
check
|
||||
if [[ $2 == *"$3"* ]]; then
|
||||
fail "$1: output contains '$3' but must not"
|
||||
fi
|
||||
}
|
||||
|
||||
# assert_before NAME A B: the line numbers A and B are set and A comes first.
|
||||
assert_before() {
|
||||
check
|
||||
if [[ -z $2 || -z $3 ]] || ((10#$2 >= 10#$3)); then
|
||||
fail "$1: expected the step at line '$2' before the step at line '$3'"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_file_exists() {
|
||||
check
|
||||
if [[ ! -e $2 ]]; then
|
||||
fail "$1: '$2' does not exist"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_file_missing() {
|
||||
check
|
||||
if [[ -e $2 ]]; then
|
||||
fail "$1: '$2' exists but must not"
|
||||
fi
|
||||
}
|
||||
|
||||
# new_workdir: create a private work directory with a stub bin directory.
|
||||
new_workdir() {
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-test.XXXXXX")"
|
||||
mkdir -p "$WORK/bin" "$WORK/tmp"
|
||||
}
|
||||
|
||||
# remove_workdir: delete the work directory without a recursive rm: files
|
||||
# first, then the now empty directories from the bottom up.
|
||||
remove_workdir() {
|
||||
if [[ -n $WORK && -d $WORK ]]; then
|
||||
find "$WORK" \( -type f -o -type p \) -delete
|
||||
find "$WORK" -depth -type d -exec rmdir {} +
|
||||
fi
|
||||
WORK=""
|
||||
}
|
||||
|
||||
# write_fixtures: valid config.env and .env in the work directory.
|
||||
write_fixtures() {
|
||||
cat >"$WORK/config.env" <<EOF
|
||||
# test configuration
|
||||
GITHUB_API_URL=https://api.github.com
|
||||
GITHUB_WEB_URL=https://github.com
|
||||
GITEA_URL=https://git.example.test/
|
||||
GITEA_API_URL=https://git.example.test/api/v1
|
||||
EOF
|
||||
cat >"$WORK/.env" <<EOF
|
||||
GITHUB_PAT=$FAKE_GITHUB_PAT
|
||||
GITHUB_USER=octo-user
|
||||
GITEA_TOKEN=$FAKE_GITEA_TOKEN
|
||||
EOF
|
||||
}
|
||||
|
||||
# write_stub NAME BODY: install an executable stub tool in the work bin.
|
||||
write_stub() {
|
||||
printf '#!/usr/bin/env bash\n%s\n' "$2" >"$WORK/bin/$1"
|
||||
chmod +x "$WORK/bin/$1"
|
||||
}
|
||||
|
||||
# write_curl_stub: a curl that records every call and answers from the
|
||||
# routes file in the work directory (see write_routes). Without a routes file
|
||||
# it answers STUB_CURL_STATUS (default 200) with the body STUB_CURL_BODY.
|
||||
# Records: curl.args (all arguments), curl.config (the private config file),
|
||||
# curl.calls ("METHOD URL" per call) and curl.bodies (each request body).
|
||||
write_curl_stub() {
|
||||
cat >"$WORK/bin/curl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$@" >>"$STUB_DIR/curl.args"
|
||||
out="" cfg="" data=""
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--output) out="$2"; shift 2 ;;
|
||||
--config) cfg="$2"; shift 2 ;;
|
||||
--data-binary) data="${2#@}"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
url="" method=""
|
||||
if [[ -n $cfg ]]; then
|
||||
cat "$cfg" >>"$STUB_DIR/curl.config"
|
||||
url="$(sed -n 's/^url = "\(.*\)"$/\1/p' "$cfg")"
|
||||
method="$(sed -n 's/^request = "\(.*\)"$/\1/p' "$cfg")"
|
||||
fi
|
||||
printf '%s %s\n' "$method" "$url" >>"$STUB_DIR/curl.calls"
|
||||
if [[ -n $data && -f $data ]]; then
|
||||
printf '%s %s\n%s\n' "$method" "$url" "$(cat "$data")" >>"$STUB_DIR/curl.bodies"
|
||||
fi
|
||||
status="${STUB_CURL_STATUS:-200}"
|
||||
body="${STUB_CURL_BODY:-}"
|
||||
if [[ -z $body ]]; then body="{\"ok\":true}"; fi
|
||||
if [[ -f $STUB_DIR/routes ]]; then
|
||||
status=404
|
||||
body="{\"message\":\"Not Found\"}"
|
||||
n=0 first_unused="" last_match=""
|
||||
while IFS='|' read -r m pattern st rest; do
|
||||
n=$((n + 1))
|
||||
if [[ $m == "$method" && $url == *"$pattern" ]]; then
|
||||
last_match=$n
|
||||
if [[ -z $first_unused ]] && ! grep -qx "$n" "$STUB_DIR/routes.used" 2>/dev/null; then
|
||||
first_unused=$n
|
||||
fi
|
||||
fi
|
||||
done <"$STUB_DIR/routes"
|
||||
pick="${first_unused:-$last_match}"
|
||||
if [[ -n $pick ]]; then
|
||||
if [[ -n $first_unused ]]; then printf '%s\n' "$pick" >>"$STUB_DIR/routes.used"; fi
|
||||
IFS='|' read -r _ _ status body <<<"$(sed -n "${pick}p" "$STUB_DIR/routes")"
|
||||
fi
|
||||
fi
|
||||
if [[ $status == exit* ]]; then exit "${status#exit}"; fi
|
||||
if [[ -n $out ]]; then printf '%s' "$body" >"$out"; fi
|
||||
printf '%s' "$status"
|
||||
exit "${STUB_CURL_EXIT:-0}"
|
||||
STUB
|
||||
chmod +x "$WORK/bin/curl"
|
||||
}
|
||||
|
||||
# write_ssh_stub [EXIT]: an ssh that records its arguments and, by default,
|
||||
# answers like a Gitea server that accepted the key.
|
||||
write_ssh_stub() {
|
||||
cat >"$WORK/bin/ssh" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "\$@" >>"\$STUB_DIR/ssh.args"
|
||||
if [[ ${1:-0} == 0 ]]; then
|
||||
echo "Hi there, gitea-user! You've successfully authenticated, but Gitea does not provide shell access."
|
||||
else
|
||||
echo "Permission denied (publickey)."
|
||||
fi
|
||||
exit ${1:-0}
|
||||
STUB
|
||||
chmod +x "$WORK/bin/ssh"
|
||||
}
|
||||
|
||||
# write_routes: read the routes for the stub curl from stdin. One route per
|
||||
# line: METHOD|URL-SUFFIX|STATUS|BODY. A request takes the first matching
|
||||
# route that was not used yet, so repeated calls can get different answers;
|
||||
# when all are used, the last match answers again. STATUS "exitN" makes curl
|
||||
# fail with exit code N.
|
||||
write_routes() {
|
||||
cat >"$WORK/routes"
|
||||
: >"$WORK/routes.used"
|
||||
}
|
||||
|
||||
# prepend_route LINE: answer a request before the routes already written.
|
||||
prepend_route() {
|
||||
local rest
|
||||
rest="$(cat "$WORK/routes")"
|
||||
printf '%s\n%s\n' "$1" "$rest" >"$WORK/routes"
|
||||
# The lines moved, so the record of used routes no longer applies.
|
||||
: >"$WORK/routes.used"
|
||||
}
|
||||
|
||||
# write_happy_routes: both hosts accept the tokens; Gitea owner TirSystem and
|
||||
# GitHub owner acme-org are organizations; nothing exists yet.
|
||||
write_happy_routes() {
|
||||
write_routes <<'ROUTES'
|
||||
GET|/api/v1/user|200|{"login":"gitea-user"}
|
||||
GET|/api/v1/orgs/TirSystem|200|{"username":"TirSystem"}
|
||||
GET|/api/v1/users/gitea-user/orgs/TirSystem/permissions|200|{"can_create_repository":true,"is_owner":true}
|
||||
GET|/api/v1/licenses|200|[{"key":"AGPL-3.0","name":"AGPL-3.0"}]
|
||||
GET|/api/v1/repos/TirSystem/my-app|404|{"message":"not found"}
|
||||
GET|api.github.com/user|200|{"login":"octo-user"}
|
||||
GET|api.github.com/user/memberships/orgs/acme-org|200|{"state":"active","role":"member"}
|
||||
GET|api.github.com/repos/acme-org/my-app|404|{"message":"Not Found"}
|
||||
GET|/api/v1/repos/TirSystem/my-app/push_mirrors|200|[]
|
||||
GET|/api/v1/repos/TirSystem/my-app/push_mirrors|200|[{"remote_address":"https://github.com/acme-org/my-app.git","sync_on_commit":true,"interval":"10m0s","last_error":""}]
|
||||
POST|api.github.com/orgs/acme-org/repos|201|{"html_url":"https://github.com/acme-org/my-app"}
|
||||
POST|/api/v1/orgs/TirSystem/repos|201|{"html_url":"https://git.example.test/TirSystem/my-app"}
|
||||
POST|/api/v1/repos/TirSystem/my-app/push_mirrors|200|{"remote_address":"https://github.com/acme-org/my-app.git"}
|
||||
POST|/api/v1/repos/TirSystem/my-app/push_mirrors-sync|200|{}
|
||||
ROUTES
|
||||
}
|
||||
|
||||
# setup_hosts: fixtures, stub curl and ssh, and the happy routes.
|
||||
setup_hosts() {
|
||||
write_fixtures
|
||||
write_curl_stub
|
||||
write_ssh_stub 0
|
||||
write_happy_routes
|
||||
}
|
||||
|
||||
# calls: the "METHOD URL" lines the stub curl received (empty if none).
|
||||
calls() {
|
||||
if [[ -f $WORK/curl.calls ]]; then
|
||||
cat "$WORK/curl.calls"
|
||||
fi
|
||||
}
|
||||
|
||||
# run_cli STDIN ARGS...: run create-project.sh with answers from STDIN (a
|
||||
# string). Sets OUT, ERR and STATUS.
|
||||
run_cli() {
|
||||
local input="$1"
|
||||
shift
|
||||
STATUS=0
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||
REPOFOUNDRY_SYNC_WAIT=0 \
|
||||
"$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
|
||||
STATUS=$?
|
||||
OUT="$(cat "$WORK/out.txt")"
|
||||
ERR="$(cat "$WORK/err.txt")"
|
||||
}
|
||||
|
||||
# run_lib INPUT CODE: source create-project.sh and run CODE in a fresh bash,
|
||||
# so that single functions can be tested. Sets OUT, ERR and STATUS.
|
||||
run_lib() {
|
||||
local input="$1"
|
||||
STATUS=0
|
||||
{
|
||||
printf '#!/usr/bin/env bash\nsource "%s"\n' "$SCRIPT"
|
||||
printf '%s\n' "$2"
|
||||
} >"$WORK/snippet.sh"
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||
REPOFOUNDRY_SYNC_WAIT=0 \
|
||||
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
|
||||
STATUS=$?
|
||||
OUT="$(cat "$WORK/out.txt")"
|
||||
ERR="$(cat "$WORK/err.txt")"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-tests.sh - run the RepoFoundry checks: bash -n, shellcheck, shfmt (when
|
||||
# installed) and every test_* function in tests/test-*.sh.
|
||||
#
|
||||
# Usage
|
||||
# tests/run-tests.sh [NAME-PATTERN] run only tests whose name matches
|
||||
#
|
||||
# Everything runs in private work directories with stub tools: no network,
|
||||
# no real .env and no change to the repository.
|
||||
#
|
||||
# Requires: bash 4.4 or later, git; shellcheck and shfmt are used if present.
|
||||
#
|
||||
# Exit codes: 0 all checks passed, 1 a check failed.
|
||||
set -Eeuo pipefail
|
||||
|
||||
TEST_DIR="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
|
||||
readonly TEST_DIR
|
||||
# shellcheck source=tests/lib.sh
|
||||
source "$TEST_DIR/lib.sh"
|
||||
|
||||
pattern="${1:-}"
|
||||
failed_checks=0
|
||||
|
||||
run_static_checks() {
|
||||
printf '== static checks\n'
|
||||
local file
|
||||
for file in "$SCRIPT" "$SRC_DIR"/lib/*.sh; do
|
||||
bash -n "$file" || failed_checks=$((failed_checks + 1))
|
||||
done
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
# -x follows the source lines; the library files are named as well,
|
||||
# because shellcheck only reports on the files it is given.
|
||||
shellcheck -x "$SCRIPT" "$SRC_DIR"/lib/*.sh "$TEST_DIR"/*.sh ||
|
||||
failed_checks=$((failed_checks + 1))
|
||||
else
|
||||
printf 'skipped: shellcheck is not installed\n'
|
||||
fi
|
||||
if command -v shfmt >/dev/null 2>&1; then
|
||||
shfmt -i 2 -ci -d "$SCRIPT" "$SRC_DIR"/lib/*.sh "$TEST_DIR"/*.sh ||
|
||||
failed_checks=$((failed_checks + 1))
|
||||
else
|
||||
printf 'skipped: shfmt is not installed\n'
|
||||
fi
|
||||
}
|
||||
|
||||
run_test_file() {
|
||||
local file="$1" name
|
||||
# shellcheck source=/dev/null
|
||||
source "$file"
|
||||
while read -r name; do
|
||||
if [[ -n $pattern && $name != *"$pattern"* ]]; then
|
||||
continue
|
||||
fi
|
||||
CURRENT_TEST="$name"
|
||||
new_workdir
|
||||
"$name" || fail "the test stopped with an error"
|
||||
remove_workdir
|
||||
done < <(declare -F | awk '$3 ~ /^test_/ {print $3}')
|
||||
}
|
||||
|
||||
run_static_checks
|
||||
for test_file in "$TEST_DIR"/test-*.sh; do
|
||||
printf '== %s\n' "${test_file##*/}"
|
||||
run_test_file "$test_file"
|
||||
# Forget this file's tests so the next file starts clean.
|
||||
while read -r name; do
|
||||
unset -f "$name"
|
||||
done < <(declare -F | awk '$3 ~ /^test_/ {print $3}')
|
||||
done
|
||||
|
||||
printf '\n%d checks, %d failed, %d static check(s) failed\n' \
|
||||
"$TESTS_RUN" "$TESTS_FAILED" "$failed_checks"
|
||||
if ((TESTS_FAILED > 0 || failed_checks > 0)); then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-config.sh - tests for the config.env and .env parsing and validation
|
||||
# (MIL-001 task: safe parser). Sourced by run-tests.sh.
|
||||
|
||||
# parse_ok FILE-CONTENT: parse a config file and print the CONFIG entries.
|
||||
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||
parse_snippet() {
|
||||
printf 'parse_env_file "%s" CONFIG_KEYS CONFIG\nfor k in "${!CONFIG[@]}"; do printf "%%s=%%s\\n" "$k" "${CONFIG[$k]}"; done | sort\n' "$WORK/c.env"
|
||||
}
|
||||
|
||||
test_parser_accepts_valid_file() {
|
||||
printf '# comment\n\nGITEA_URL="https://a.test/"\r\nGITHUB_WEB_URL='"'"'https://b.test'"'"'\nGITEA_API_URL=https://a.test/api/v1 # inline\n' >"$WORK/c.env"
|
||||
run_lib "" "$(parse_snippet)"
|
||||
assert_status "valid file" 0 "$STATUS"
|
||||
assert_contains "quotes stripped" "$OUT" "GITEA_URL=https://a.test/"
|
||||
assert_contains "single quotes" "$OUT" "GITHUB_WEB_URL=https://b.test"
|
||||
assert_contains "inline comment dropped" "$OUT" "GITEA_API_URL=https://a.test/api/v1"
|
||||
}
|
||||
|
||||
test_parser_rejects_bad_input() {
|
||||
local case_name content expected
|
||||
while IFS='|' read -r case_name content expected; do
|
||||
printf '%b' "$content" >"$WORK/c.env"
|
||||
run_lib "" "$(parse_snippet)"
|
||||
assert_status "$case_name" 1 "$STATUS"
|
||||
assert_contains "$case_name message" "$ERR" "$expected"
|
||||
done <<'EOF'
|
||||
unknown key|OTHER=1\n|unknown key 'OTHER'
|
||||
credential in config|GITEA_TOKEN=abcdefgh12345\n|is a credential
|
||||
not KEY=VALUE|just some text\n|line 1: expected KEY=VALUE
|
||||
lowercase key|gitea_url=https://a.test\n|unknown key 'gitea_url'
|
||||
duplicate key|GITEA_URL=https://a.test\nGITEA_URL=https://b.test\n|set twice
|
||||
unbalanced quote|GITEA_URL="https://a.test\n|unbalanced
|
||||
quote inside|GITEA_URL="https://a"b.test"\n|unbalanced
|
||||
control character|GITEA_URL=https://a\x01b.test\n|control character
|
||||
EOF
|
||||
}
|
||||
|
||||
test_parser_missing_file() {
|
||||
run_lib "" "parse_env_file \"$WORK/none.env\" CONFIG_KEYS CONFIG"
|
||||
assert_status "missing file" 1 "$STATUS"
|
||||
assert_contains "missing file message" "$ERR" "cannot read"
|
||||
}
|
||||
|
||||
test_parser_never_executes_values() {
|
||||
local marker="$WORK/pwned"
|
||||
printf 'GITEA_URL=$(touch %s)\nGITHUB_WEB_URL=`touch %s`\n' "$marker" "$marker" >"$WORK/c.env"
|
||||
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
|
||||
validate_config"
|
||||
assert_file_missing "command substitution not run" "$marker"
|
||||
assert_status "bad value rejected" 1 "$STATUS"
|
||||
printf 'GITEA_TOKEN=$(touch %s)\n' "$marker" >"$WORK/e.env"
|
||||
run_lib "" "parse_env_file \"$WORK/e.env\" CREDENTIAL_KEYS CREDENTIALS
|
||||
validate_credentials"
|
||||
assert_file_missing "token value not run" "$marker"
|
||||
assert_status "bad token rejected" 1 "$STATUS"
|
||||
}
|
||||
|
||||
test_config_validation() {
|
||||
local case_name url expected
|
||||
while IFS='|' read -r case_name url expected; do
|
||||
printf 'GITEA_URL=%s\n' "$url" >"$WORK/c.env"
|
||||
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
|
||||
validate_config"
|
||||
assert_status "$case_name" 1 "$STATUS"
|
||||
assert_contains "$case_name message" "$ERR" "$expected"
|
||||
done <<'EOF'
|
||||
plain http|http://git.example.test|https URL
|
||||
user info|https://user:pw@git.example.test|https URL
|
||||
query string|https://git.example.test/?token=abc|https URL
|
||||
fragment|https://git.example.test/#x|https URL
|
||||
spaces|https://git.example.test/a b|https URL
|
||||
EOF
|
||||
printf '# nothing\n' >"$WORK/c.env"
|
||||
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
|
||||
validate_config"
|
||||
assert_status "GITEA_URL missing" 1 "$STATUS"
|
||||
assert_contains "GITEA_URL missing message" "$ERR" "GITEA_URL is missing"
|
||||
}
|
||||
|
||||
test_config_defaults_and_normalizing() {
|
||||
printf 'GITEA_URL=https://git.example.test///\n' >"$WORK/c.env"
|
||||
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
|
||||
validate_config
|
||||
printf '%s\n' \"\${CONFIG[GITEA_URL]}\" \"\${CONFIG[GITEA_API_URL]}\" \"\${CONFIG[GITHUB_API_URL]}\" \"\${CONFIG[GITHUB_WEB_URL]}\""
|
||||
assert_status "defaults" 0 "$STATUS"
|
||||
assert_eq "trailing slashes removed" $'https://git.example.test\nhttps://git.example.test/api/v1\nhttps://api.github.com\nhttps://github.com' "$OUT"
|
||||
}
|
||||
|
||||
test_credentials_validation() {
|
||||
local case_name content expected
|
||||
while IFS='|' read -r case_name content expected; do
|
||||
printf '%b' "$content" >"$WORK/e.env"
|
||||
run_lib "" "parse_env_file \"$WORK/e.env\" CREDENTIAL_KEYS CREDENTIALS
|
||||
validate_credentials"
|
||||
assert_status "$case_name" 1 "$STATUS"
|
||||
assert_contains "$case_name message" "$ERR" "$expected"
|
||||
done <<'EOF'
|
||||
no Gitea token|GITHUB_USER=octo\n|GITEA_TOKEN is missing
|
||||
token too short|GITEA_TOKEN=short\n|not a valid token
|
||||
token with a backslash|GITEA_TOKEN=abc\\defgh12345\n|not a valid token
|
||||
bad GitHub token|GITEA_TOKEN=abcdefgh12345\nGITHUB_PAT=bad token\n|GITHUB_PAT
|
||||
bad GitHub user|GITEA_TOKEN=abcdefgh12345\nGITHUB_USER=-bad-\n|GITHUB_USER
|
||||
EOF
|
||||
}
|
||||
|
||||
test_github_credentials_required_only_when_chosen() {
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/e.env"
|
||||
run_lib "" "parse_env_file \"$WORK/e.env\" CREDENTIAL_KEYS CREDENTIALS
|
||||
validate_credentials
|
||||
echo no-github-ok
|
||||
ENV_FILE=\"$WORK/e.env\"
|
||||
require_github_credentials"
|
||||
assert_contains "Gitea-only .env is valid" "$OUT" "no-github-ok"
|
||||
assert_status "GitHub credentials missing" 1 "$STATUS"
|
||||
assert_contains "names the missing key" "$ERR" "GITHUB_PAT is missing"
|
||||
}
|
||||
|
||||
test_validators() {
|
||||
local fn value expected
|
||||
while IFS='|' read -r fn value expected; do
|
||||
run_lib "" "if $fn '$value'; then echo yes; else echo no; fi"
|
||||
assert_eq "$fn '$value'" "$expected" "$OUT"
|
||||
done <<'EOF'
|
||||
is_valid_repo_name|RepoFoundry|yes
|
||||
is_valid_repo_name|my.repo_1-x|yes
|
||||
is_valid_repo_name|bad name|no
|
||||
is_valid_repo_name|..|no
|
||||
is_valid_repo_name|x.git|no
|
||||
is_valid_repo_name||no
|
||||
is_valid_gitea_owner|Tirsvad|yes
|
||||
is_valid_gitea_owner|-lead|no
|
||||
is_valid_github_owner|octo-user|yes
|
||||
is_valid_github_owner|octo_user|no
|
||||
is_valid_github_owner|-octo|no
|
||||
is_valid_github_owner|octo-|no
|
||||
is_valid_directory|./my-project|yes
|
||||
is_valid_directory|-rf|no
|
||||
is_valid_directory||no
|
||||
is_valid_token|abcdefgh12345|yes
|
||||
is_valid_token|abc|no
|
||||
is_valid_base_url|https://git.example.test/api/v1|yes
|
||||
is_valid_base_url|http://git.example.test|no
|
||||
is_valid_request_url|https://api.github.com/user?per_page=5|yes
|
||||
is_valid_request_url|https://u:p@api.github.com/user|no
|
||||
EOF
|
||||
}
|
||||
|
||||
test_example_files_hold_placeholders_only() {
|
||||
local file line value
|
||||
for file in "$REPO_ROOT/.env.example" "$REPO_ROOT/config.env.example"; do
|
||||
assert_file_exists "example file" "$file"
|
||||
done
|
||||
while IFS= read -r line; do
|
||||
value="${line#*=}"
|
||||
check
|
||||
if [[ -n $value ]]; then
|
||||
fail ".env.example has a value for ${line%%=*}; it must be empty"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$REPO_ROOT/.env.example")
|
||||
run_lib "" "parse_env_file \"$REPO_ROOT/config.env.example\" CONFIG_KEYS CONFIG
|
||||
validate_config
|
||||
echo parsed"
|
||||
assert_contains "config.env.example is valid" "$OUT" "parsed"
|
||||
run_lib "" "parse_env_file \"$REPO_ROOT/.env.example\" CREDENTIAL_KEYS CREDENTIALS
|
||||
echo parsed"
|
||||
assert_contains ".env.example parses" "$OUT" "parsed"
|
||||
}
|
||||
|
||||
test_env_is_ignored_by_git() {
|
||||
check
|
||||
if ! git -C "$REPO_ROOT" check-ignore -q .env; then
|
||||
fail ".env is not ignored by git"
|
||||
fi
|
||||
check
|
||||
if git -C "$REPO_ROOT" check-ignore -q .env.example; then
|
||||
fail ".env.example must not be ignored"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-hosts.sh - tests for the GitHub and Gitea steps (MIL-002): preflight
|
||||
# checks, the dry run, creating the repositories and the push mirror, reusing
|
||||
# existing repositories and reporting a partial failure. A stub curl answers
|
||||
# from a routes file and records every call; no real host is contacted.
|
||||
# Sourced by run-tests.sh.
|
||||
|
||||
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||
|
||||
run_dry() {
|
||||
run_cli "$1" --config "$WORK/config.env" --env "$WORK/.env"
|
||||
}
|
||||
|
||||
run_apply() {
|
||||
run_cli "$1" --apply --config "$WORK/config.env" --env "$WORK/.env"
|
||||
}
|
||||
|
||||
# position LINE: the number of the first recorded call equal to LINE.
|
||||
position() {
|
||||
calls | grep -n -x -F "$1" | head -n 1 | cut -d: -f1
|
||||
}
|
||||
|
||||
readonly GITHUB_REPO_CALL="POST https://api.github.com/orgs/acme-org/repos"
|
||||
readonly GITEA_REPO_CALL="POST https://git.example.test/api/v1/orgs/TirSystem/repos"
|
||||
readonly MIRROR_CALL="POST https://git.example.test/api/v1/repos/TirSystem/my-app/push_mirrors"
|
||||
readonly SYNC_CALL="POST https://git.example.test/api/v1/repos/TirSystem/my-app/push_mirrors-sync"
|
||||
|
||||
# ------------------------------------------------------------------ dry run
|
||||
|
||||
test_dry_run_prints_the_plan_and_only_reads() {
|
||||
setup_hosts
|
||||
run_dry "$ANSWERS_GITHUB"
|
||||
assert_status "dry run" 0 "$STATUS"
|
||||
assert_contains "Gitea plan" "$OUT" "Gitea repository : create (private) with the AGPL-3.0 license https://git.example.test/TirSystem/my-app"
|
||||
assert_contains "GitHub plan" "$OUT" "GitHub repository : create (private), empty https://github.com/acme-org/my-app"
|
||||
assert_contains "mirror plan" "$OUT" "Push mirror : Gitea -> GitHub every 10m0s"
|
||||
assert_contains "origin plan" "$OUT" "Local origin : will use SSH (the SSH test passed)"
|
||||
assert_contains "says it is a dry run" "$OUT" "Dry run: nothing was created. Run again with --apply"
|
||||
assert_not_contains "no creation report" "$OUT" "This is what exists now"
|
||||
assert_not_contains "only reads" "$(calls)" "POST"
|
||||
assert_not_contains "never deletes" "$(calls)" "DELETE"
|
||||
assert_not_contains "no PUT or PATCH" "$(calls)" "PATCH"
|
||||
}
|
||||
|
||||
test_dry_run_does_not_ask_to_confirm() {
|
||||
setup_hosts
|
||||
run_dry "$ANSWERS_GITHUB"
|
||||
assert_not_contains "no final question" "$ERR" "Create these now"
|
||||
}
|
||||
|
||||
test_ssh_failure_means_https() {
|
||||
setup_hosts
|
||||
write_ssh_stub 255
|
||||
run_dry "$ANSWERS_GITHUB"
|
||||
assert_status "ssh failure is not fatal" 0 "$STATUS"
|
||||
assert_contains "origin falls back to HTTPS" "$OUT" "will use HTTPS (SSH test: failed"
|
||||
}
|
||||
|
||||
test_ssh_without_the_ssh_tool_is_not_fatal() {
|
||||
setup_hosts
|
||||
# A PATH that has the stubs and the basic tools but no ssh.
|
||||
rm -f "$WORK/bin/ssh"
|
||||
run_lib "" 'command() { if [[ $1 == -v && $2 == ssh ]]; then return 1; fi; builtin command "$@"; }
|
||||
CONFIG[GITEA_URL]=https://git.example.test CONFIG[GITEA_SSH_PORT]=10022
|
||||
test_gitea_ssh
|
||||
echo "${STATE[is_ssh_ok]}|${STATE[ssh_note]}"'
|
||||
assert_eq "no ssh installed" "0|not tested (ssh is not installed)" "$OUT"
|
||||
}
|
||||
|
||||
test_ssh_uses_the_configured_port() {
|
||||
setup_hosts
|
||||
printf 'GITEA_SSH_PORT=2222\n' >>"$WORK/config.env"
|
||||
run_dry "$ANSWERS_GITHUB"
|
||||
assert_status "custom port" 0 "$STATUS"
|
||||
assert_contains "port passed to ssh" "$(cat "$WORK/ssh.args")" "2222"
|
||||
assert_contains "Gitea host" "$(cat "$WORK/ssh.args")" "git@git.example.test"
|
||||
assert_contains "batch mode, no prompts" "$(cat "$WORK/ssh.args")" "BatchMode=yes"
|
||||
assert_contains "unknown host keys are refused" "$(cat "$WORK/ssh.args")" "StrictHostKeyChecking=yes"
|
||||
}
|
||||
|
||||
test_config_validates_ssh_port_and_interval() {
|
||||
local key value expected
|
||||
while IFS='|' read -r key value expected; do
|
||||
printf 'GITEA_URL=https://git.example.test\n%s=%s\n' "$key" "$value" >"$WORK/c.env"
|
||||
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
|
||||
validate_config"
|
||||
assert_status "$key=$value" 1 "$STATUS"
|
||||
assert_contains "$key=$value message" "$ERR" "$expected"
|
||||
done <<'EOF'
|
||||
GITEA_SSH_PORT|abc|port number
|
||||
GITEA_SSH_PORT|0|port number
|
||||
GITEA_SSH_PORT|70000|port number
|
||||
MIRROR_INTERVAL|soon|10m0s or 8h0m0s
|
||||
MIRROR_INTERVAL|10|10m0s or 8h0m0s
|
||||
MIRROR_INTERVAL|10 m|10m0s or 8h0m0s
|
||||
EOF
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------- preflight
|
||||
|
||||
test_gitea_token_rejected() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/user|401|{"message":"token is required"}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "bad Gitea token" 1 "$STATUS"
|
||||
assert_contains "says what failed" "$ERR" "Gitea rejected the token: authentication failed"
|
||||
assert_contains "shows the server's words" "$ERR" "token is required"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
assert_not_contains "no creation report before anything was created" "$OUT" "This is what exists now"
|
||||
assert_not_contains "GitHub not contacted first" "$(calls)" "api.github.com"
|
||||
}
|
||||
|
||||
test_github_token_rejected() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/user|401|{"message":"Bad credentials"}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "bad GitHub token" 1 "$STATUS"
|
||||
assert_contains "says what failed" "$ERR" "GitHub rejected the token: authentication failed"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
}
|
||||
|
||||
test_gitea_owner_must_exist() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/orgs/TirSystem|404|{"message":"not found"}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "unknown Gitea owner" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "neither your account (gitea-user) nor an organization"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
}
|
||||
|
||||
test_gitea_organization_needs_create_permission() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/users/gitea-user/orgs/TirSystem/permissions|200|{"can_create_repository":false}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "no permission" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "you may not create repositories in the Gitea organization 'TirSystem'"
|
||||
}
|
||||
|
||||
test_github_owner_must_be_a_member() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/user/memberships/orgs/acme-org|404|{"message":"Not Found"}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "not a member" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "neither your account (octo-user) nor an organization you belong to"
|
||||
prepend_route 'GET|api.github.com/user/memberships/orgs/acme-org|200|{"state":"pending"}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "pending membership" 1 "$STATUS"
|
||||
assert_contains "pending message" "$ERR" "membership of the GitHub organization 'acme-org' is not active"
|
||||
}
|
||||
|
||||
test_license_must_be_offered_when_github_is_chosen() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/licenses|200|[{"key":"MIT","name":"MIT"}]'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "no AGPL" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "does not offer the AGPL-3.0 license"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
# Without GitHub no license is needed, so the same server is fine.
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/licenses|200|[{"key":"MIT","name":"MIT"}]'
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
run_dry "$ANSWERS_GITEA_ONLY"
|
||||
assert_status "Gitea only" 0 "$STATUS"
|
||||
}
|
||||
|
||||
test_existing_repository_with_content_stops_the_run() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/contents|200|[{"name":"README.md","type":"file"}]'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "repository with content" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "the Gitea repository TirSystem/my-app already exists and has content"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
}
|
||||
|
||||
test_network_failure_stops_before_creating() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/user|exit7|'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "no connection" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "could not reach git.example.test: could not connect"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
}
|
||||
|
||||
test_token_for_another_github_account_is_reported() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/user|200|{"login":"someone-else"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "run continues" 0 "$STATUS"
|
||||
assert_contains "warns" "$ERR" "GITHUB_USER is 'octo-user' but the token belongs to 'someone-else'"
|
||||
assert_contains "mirror uses the account the token belongs to" "$(cat "$WORK/curl.bodies")" '"remote_username":"someone-else"'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ apply: success
|
||||
|
||||
test_apply_creates_repositories_and_mirror_in_order() {
|
||||
setup_hosts
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_before "GitHub before Gitea" "$(position "$GITHUB_REPO_CALL")" "$(position "$GITEA_REPO_CALL")"
|
||||
assert_before "Gitea before the mirror" "$(position "$GITEA_REPO_CALL")" "$(position "$MIRROR_CALL")"
|
||||
assert_before "mirror before the sync" "$(position "$MIRROR_CALL")" "$(position "$SYNC_CALL")"
|
||||
assert_contains "final report" "$OUT" "Done. This is what exists now:"
|
||||
assert_contains "GitHub created" "$OUT" "GitHub repository : created https://github.com/acme-org/my-app"
|
||||
assert_contains "Gitea created" "$OUT" "Gitea repository : created https://git.example.test/TirSystem/my-app"
|
||||
assert_contains "mirror created" "$OUT" "Push mirror : created Gitea -> https://github.com/acme-org/my-app.git"
|
||||
assert_not_contains "never deletes" "$(calls)" "DELETE"
|
||||
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
}
|
||||
|
||||
test_apply_sends_the_right_request_bodies() {
|
||||
setup_hosts
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
local bodies
|
||||
bodies="$(cat "$WORK/curl.bodies")"
|
||||
assert_contains "GitHub name" "$bodies" '"name":"my-app"'
|
||||
assert_contains "GitHub is created empty" "$bodies" '"private":true,"auto_init":false}'
|
||||
assert_contains "Gitea gets the license" "$bodies" '"license":"AGPL-3.0"'
|
||||
assert_contains "Gitea is initialised with it" "$bodies" '"auto_init":true'
|
||||
assert_contains "default branch" "$bodies" '"default_branch":"main"'
|
||||
assert_contains "description" "$bodies" '"description":"A test app"'
|
||||
assert_contains "mirror target without credentials" "$bodies" '"remote_address":"https://github.com/acme-org/my-app.git"'
|
||||
assert_not_contains "no credentials in the address" "$bodies" 'https://octo-user:'
|
||||
assert_not_contains "no credentials in the address (at sign)" "$bodies" '@github.com'
|
||||
assert_contains "mirror account" "$bodies" '"remote_username":"octo-user"'
|
||||
assert_contains "the token is the mirror password" "$bodies" "\"remote_password\":\"$FAKE_GITHUB_PAT\""
|
||||
assert_contains "mirror interval" "$bodies" '"interval":"10m0s"'
|
||||
assert_contains "push on commit asked for" "$bodies" '"sync_on_commit":true'
|
||||
}
|
||||
|
||||
test_apply_never_prints_or_passes_a_token() {
|
||||
setup_hosts
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_not_contains "no Gitea token in output" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
|
||||
assert_not_contains "no GitHub token in output" "$OUT$ERR" "$FAKE_GITHUB_PAT"
|
||||
assert_not_contains "no token on a command line" "$(cat "$WORK/curl.args")" "$FAKE_GITEA_TOKEN"
|
||||
assert_not_contains "no GitHub token on a command line" "$(cat "$WORK/curl.args")" "$FAKE_GITHUB_PAT"
|
||||
assert_contains "Gitea token in the private config" "$(cat "$WORK/curl.config")" "Authorization: token $FAKE_GITEA_TOKEN"
|
||||
assert_contains "GitHub token in the private config" "$(cat "$WORK/curl.config")" "Authorization: Bearer $FAKE_GITHUB_PAT"
|
||||
}
|
||||
|
||||
test_apply_with_gitea_only() {
|
||||
setup_hosts
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
run_apply "$ANSWERS_GITEA_ONLY"$'y\n'
|
||||
assert_status "Gitea only" 0 "$STATUS"
|
||||
assert_contains "Gitea repository created" "$(calls)" "$GITEA_REPO_CALL"
|
||||
assert_contains "created empty" "$(cat "$WORK/curl.bodies")" '"auto_init":false'
|
||||
assert_not_contains "no license" "$(cat "$WORK/curl.bodies")" "license"
|
||||
assert_not_contains "no GitHub call" "$(calls)" "api.github.com"
|
||||
assert_not_contains "no mirror call" "$(calls)" "push_mirrors"
|
||||
assert_contains "GitHub not used" "$OUT" "GitHub repository : not used"
|
||||
assert_contains "mirror not used" "$OUT" "Push mirror : not used"
|
||||
}
|
||||
|
||||
test_apply_declined_creates_nothing() {
|
||||
setup_hosts
|
||||
run_apply "$ANSWERS_GITHUB"$'n\n'
|
||||
assert_status "answered no" 0 "$STATUS"
|
||||
assert_contains "says so" "$OUT" "Nothing was created."
|
||||
assert_not_contains "no POST" "$(calls)" "POST"
|
||||
assert_not_contains "no report" "$OUT" "This is what exists now"
|
||||
}
|
||||
|
||||
test_apply_for_user_owners_uses_the_user_endpoints() {
|
||||
setup_hosts
|
||||
write_routes <<'ROUTES'
|
||||
GET|/api/v1/user|200|{"login":"gitea-user"}
|
||||
GET|/api/v1/licenses|200|[{"key":"AGPL-3.0"}]
|
||||
GET|/api/v1/repos/gitea-user/my-app|404|{"message":"not found"}
|
||||
GET|api.github.com/user|200|{"login":"octo-user"}
|
||||
GET|api.github.com/repos/octo-user/my-app|404|{"message":"Not Found"}
|
||||
GET|/api/v1/repos/gitea-user/my-app/push_mirrors|200|[]
|
||||
GET|/api/v1/repos/gitea-user/my-app/push_mirrors|200|[{"remote_address":"https://github.com/octo-user/my-app.git","sync_on_commit":true}]
|
||||
POST|api.github.com/user/repos|201|{}
|
||||
POST|/api/v1/user/repos|201|{}
|
||||
POST|/api/v1/repos/gitea-user/my-app/push_mirrors|200|{}
|
||||
POST|/api/v1/repos/gitea-user/my-app/push_mirrors-sync|200|{}
|
||||
ROUTES
|
||||
run_apply $'my-app\n\n\ngitea-user\ny\n\n\n\ny\n'
|
||||
assert_status "user owners" 0 "$STATUS"
|
||||
assert_contains "GitHub user endpoint" "$(calls)" "POST https://api.github.com/user/repos"
|
||||
assert_contains "Gitea user endpoint" "$(calls)" "POST https://git.example.test/api/v1/user/repos"
|
||||
assert_contains "mirror target of the GitHub account" "$(cat "$WORK/curl.bodies")" '"remote_address":"https://github.com/octo-user/my-app.git"'
|
||||
assert_not_contains "no organization endpoint" "$(calls)" "/orgs/"
|
||||
}
|
||||
|
||||
test_public_visibility_and_special_characters_in_the_description() {
|
||||
setup_hosts
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
run_apply $'my-app\nSay "hi" \\ there\npublic\nTirSystem\nn\n\nn\ny\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_contains "public" "$(cat "$WORK/curl.bodies")" '"private":false'
|
||||
assert_contains "description escaped" "$(cat "$WORK/curl.bodies")" '"description":"Say \"hi\" \\ there"'
|
||||
}
|
||||
|
||||
test_mirror_interval_comes_from_the_configuration() {
|
||||
setup_hosts
|
||||
printf 'MIRROR_INTERVAL=1h0m0s\n' >>"$WORK/config.env"
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_contains "interval sent" "$(cat "$WORK/curl.bodies")" '"interval":"1h0m0s"'
|
||||
assert_contains "interval planned" "$OUT" "every 1h0m0s"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- reuse
|
||||
|
||||
test_empty_github_repository_can_be_reused() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/repos/acme-org/my-app|200|{"name":"my-app"}'
|
||||
run_dry "$ANSWERS_GITHUB"
|
||||
assert_contains "plan offers reuse" "$OUT" "reuse the existing empty repository (you will be asked to confirm)"
|
||||
assert_not_contains "dry run does not ask" "$ERR" "Reuse it"
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/repos/acme-org/my-app|200|{"name":"my-app"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
|
||||
assert_status "reuse" 0 "$STATUS"
|
||||
assert_contains "asked" "$ERR" "GitHub repository https://github.com/acme-org/my-app already exists"
|
||||
assert_not_contains "GitHub repository not created again" "$(calls)" "$GITHUB_REPO_CALL"
|
||||
assert_contains "reported as reused" "$OUT" "GitHub repository : reused https://github.com/acme-org/my-app"
|
||||
assert_contains "the rest is created" "$(calls)" "$GITEA_REPO_CALL"
|
||||
}
|
||||
|
||||
test_declining_the_reuse_stops_the_run() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/repos/acme-org/my-app|200|{"name":"my-app"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\nn\n'
|
||||
assert_status "reuse declined" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "stopped: choose another name or remove the existing repository"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
}
|
||||
|
||||
test_gitea_repository_with_only_the_license_can_be_reused() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/contents|200|[{"name":"LICENSE","type":"file","path":"LICENSE"}]'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
|
||||
assert_status "license only" 0 "$STATUS"
|
||||
assert_not_contains "Gitea repository not created again" "$(calls)" "$GITEA_REPO_CALL"
|
||||
assert_eq "mirror created once" "1" "$(calls | grep -c -x -F "$MIRROR_CALL")"
|
||||
assert_contains "reported" "$OUT" "Gitea repository : reused https://git.example.test/TirSystem/my-app"
|
||||
# Without GitHub the license is not expected, so the repository has content.
|
||||
setup_hosts
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/contents|200|[{"name":"LICENSE","type":"file"}]'
|
||||
run_dry "$ANSWERS_GITEA_ONLY"
|
||||
assert_status "license without GitHub" 1 "$STATUS"
|
||||
assert_contains "has content" "$ERR" "already exists and has content"
|
||||
}
|
||||
|
||||
test_reusing_an_empty_gitea_repository_warns_about_the_license() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":true}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
|
||||
assert_status "reuse empty Gitea repository" 0 "$STATUS"
|
||||
assert_contains "warning" "$ERR" "the AGPL-3.0 license is not added to it"
|
||||
}
|
||||
|
||||
test_an_existing_mirror_is_reused() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/push_mirrors|200|[{"remote_address":"https://github.com/acme-org/my-app.git","sync_on_commit":true,"last_error":""}]'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "mirror exists" 0 "$STATUS"
|
||||
assert_eq "mirror not created twice" "" "$(position "$MIRROR_CALL")"
|
||||
assert_contains "first sync still requested" "$(calls)" "$SYNC_CALL"
|
||||
assert_contains "reported" "$OUT" "Push mirror : reused Gitea -> https://github.com/acme-org/my-app.git"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------- failures
|
||||
|
||||
test_partial_failure_reports_what_exists() {
|
||||
setup_hosts
|
||||
prepend_route 'POST|/api/v1/orgs/TirSystem/repos|422|{"message":"repository already exists"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "Gitea creation fails" 1 "$STATUS"
|
||||
assert_contains "reason" "$ERR" "cannot create the Gitea repository: rejected"
|
||||
assert_contains "server's words" "$ERR" "repository already exists"
|
||||
assert_contains "report header" "$OUT" "The run stopped before it finished. This is what exists now:"
|
||||
assert_contains "GitHub was created" "$OUT" "GitHub repository : created https://github.com/acme-org/my-app"
|
||||
assert_contains "Gitea failed" "$OUT" "Gitea repository : FAILED"
|
||||
assert_contains "mirror not attempted" "$OUT" "Push mirror : not attempted"
|
||||
assert_contains "how to continue" "$OUT" "To continue: fix the problem named above and run the same command again with --apply."
|
||||
assert_contains "nothing deleted" "$OUT" "Nothing is deleted automatically."
|
||||
assert_not_contains "never deletes" "$(calls)" "DELETE"
|
||||
assert_not_contains "no mirror call" "$(calls)" "push_mirrors"
|
||||
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
}
|
||||
|
||||
test_failure_of_the_first_step_leaves_the_rest_not_attempted() {
|
||||
setup_hosts
|
||||
prepend_route 'POST|api.github.com/orgs/acme-org/repos|403|{"message":"Resource not accessible by personal access token"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "GitHub creation fails" 1 "$STATUS"
|
||||
assert_contains "reason" "$ERR" "cannot create the GitHub repository: the token is valid but not allowed to do this"
|
||||
assert_contains "GitHub failed" "$OUT" "GitHub repository : FAILED"
|
||||
assert_contains "Gitea not attempted" "$OUT" "Gitea repository : not attempted"
|
||||
assert_not_contains "no Gitea call" "$(calls)" "$GITEA_REPO_CALL"
|
||||
}
|
||||
|
||||
test_mirror_failure_keeps_the_repositories_and_says_so() {
|
||||
setup_hosts
|
||||
prepend_route 'POST|/api/v1/repos/TirSystem/my-app/push_mirrors|403|{"message":"push mirrors are disabled"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "mirror fails" 1 "$STATUS"
|
||||
assert_contains "reason" "$ERR" "cannot create the push mirror"
|
||||
assert_contains "server's words" "$ERR" "push mirrors are disabled"
|
||||
assert_contains "GitHub kept" "$OUT" "GitHub repository : created"
|
||||
assert_contains "Gitea kept" "$OUT" "Gitea repository : created"
|
||||
assert_contains "mirror failed" "$OUT" "Push mirror : FAILED"
|
||||
}
|
||||
|
||||
test_sync_on_commit_not_applied_is_reported() {
|
||||
setup_hosts
|
||||
sed -i 's/"sync_on_commit":true,"interval"/"sync_on_commit":false,"interval"/' "$WORK/routes"
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "still succeeds" 0 "$STATUS"
|
||||
assert_contains "warning" "$ERR" "Gitea did not apply sync_on_commit (a known server issue)"
|
||||
assert_contains "report mentions the interval" "$OUT" "(syncs every 10m0s, not on every commit)"
|
||||
}
|
||||
|
||||
test_first_sync_error_is_reported() {
|
||||
setup_hosts
|
||||
sed -i 's/"last_error":""/"last_error":"push failed: authentication required"/' "$WORK/routes"
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "still succeeds" 0 "$STATUS"
|
||||
assert_contains "warning" "$ERR" "the first mirror sync reported: push failed: authentication required"
|
||||
}
|
||||
|
||||
test_first_sync_request_failure_is_only_a_warning() {
|
||||
setup_hosts
|
||||
prepend_route 'POST|/api/v1/repos/TirSystem/my-app/push_mirrors-sync|500|{"message":"boom"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "still succeeds" 0 "$STATUS"
|
||||
assert_contains "warning" "$ERR" "could not ask Gitea for the first sync (HTTP 500)"
|
||||
}
|
||||
|
||||
test_a_token_in_a_server_message_is_redacted() {
|
||||
setup_hosts
|
||||
prepend_route "POST|/api/v1/orgs/TirSystem/repos|422|{\"message\":\"bad credentials $FAKE_GITHUB_PAT given\"}"
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "Gitea creation fails" 1 "$STATUS"
|
||||
assert_not_contains "token redacted" "$OUT$ERR" "$FAKE_GITHUB_PAT"
|
||||
assert_contains "redaction visible" "$ERR" "[redacted]"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ JSON helpers
|
||||
|
||||
test_json_has_value_and_values() {
|
||||
printf '[{"key": "AGPL-3.0","name":"a"},{"key":"MIT","name":"b"}]\n' >"$WORK/l.json"
|
||||
run_lib "" "json_has_value '$WORK/l.json' key AGPL-3.0 && echo yes1
|
||||
json_has_value '$WORK/l.json' key MIT && echo yes2
|
||||
json_has_value '$WORK/l.json' key GPL-3.0 || echo no3
|
||||
json_values '$WORK/l.json' name"
|
||||
assert_eq "pairs and values" $'yes1\nyes2\nno3\na\nb' "$OUT"
|
||||
}
|
||||
|
||||
test_mirror_field_with_and_without_jq() {
|
||||
local mode
|
||||
printf '[{"remote_address":"https://github.com/o/a.git","sync_on_commit":false,"last_error":"x"},{"remote_address":"https://github.com/o/b.git","sync_on_commit":true,"last_error":""}]\n' >"$WORK/m.json"
|
||||
for mode in 0 1; do
|
||||
if ((mode)) && ! command -v jq >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
run_lib "" "HAS_JQ=$mode
|
||||
mirror_field '$WORK/m.json' https://github.com/o/a.git sync_on_commit
|
||||
mirror_field '$WORK/m.json' https://github.com/o/a.git last_error"
|
||||
assert_eq "first mirror (HAS_JQ=$mode)" $'false\nx' "$OUT"
|
||||
done
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
run_lib "" "HAS_JQ=1
|
||||
mirror_field '$WORK/m.json' https://github.com/o/b.git sync_on_commit"
|
||||
assert_eq "the right mirror is chosen with jq" "true" "$OUT"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-http.sh - tests for the HTTP helper, the tool check and the JSON
|
||||
# helpers (MIL-001 task: tool check and HTTP helper). A stub curl stands in
|
||||
# for the network. Sourced by run-tests.sh.
|
||||
|
||||
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||
test_http_request_keeps_token_off_the_command_line() {
|
||||
write_curl_stub
|
||||
run_lib "" "setup_temp_dir
|
||||
http_request GET https://git.example.test/api/v1/user token '$FAKE_GITEA_TOKEN'
|
||||
echo \"status=\$HTTP_STATUS\"
|
||||
cleanup"
|
||||
assert_status "request succeeds" 0 "$STATUS"
|
||||
assert_contains "status captured" "$OUT" "status=200"
|
||||
assert_file_exists "stub saw the call" "$WORK/curl.args"
|
||||
assert_not_contains "token not in arguments" "$(cat "$WORK/curl.args")" "$FAKE_GITEA_TOKEN"
|
||||
assert_contains "token in private config" "$(cat "$WORK/curl.config")" "Authorization: token $FAKE_GITEA_TOKEN"
|
||||
assert_contains "redirects are not followed" "$(cat "$WORK/curl.args")" "--silent"
|
||||
assert_not_contains "no redirect flag" "$(cat "$WORK/curl.args")" "--location"
|
||||
assert_not_contains "no -L flag" "$(cat "$WORK/curl.args")" $'\n-L\n'
|
||||
assert_not_contains "token not printed" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
|
||||
}
|
||||
|
||||
test_http_request_cleans_up_its_files() {
|
||||
write_curl_stub
|
||||
run_lib "" "setup_temp_dir
|
||||
http_request GET https://git.example.test/api/v1/user token '$FAKE_GITEA_TOKEN'
|
||||
cleanup"
|
||||
assert_eq "no temp files left" "" "$(find "$WORK/tmp" -mindepth 1 2>/dev/null)"
|
||||
}
|
||||
|
||||
test_http_request_bearer_scheme_and_body() {
|
||||
write_curl_stub
|
||||
run_lib "" "setup_temp_dir
|
||||
http_request POST https://api.github.com/user/repos bearer '$FAKE_GITHUB_PAT' '{\"name\":\"x\"}'
|
||||
cleanup"
|
||||
assert_status "POST succeeds" 0 "$STATUS"
|
||||
assert_contains "bearer header" "$(cat "$WORK/curl.config")" "Authorization: Bearer $FAKE_GITHUB_PAT"
|
||||
assert_contains "content type" "$(cat "$WORK/curl.config")" "Content-Type: application/json"
|
||||
assert_contains "body sent from a file" "$(cat "$WORK/curl.args")" "--data-binary"
|
||||
assert_not_contains "token not in arguments" "$(cat "$WORK/curl.args")" "$FAKE_GITHUB_PAT"
|
||||
}
|
||||
|
||||
test_http_status_messages() {
|
||||
local code expected
|
||||
while IFS='|' read -r code expected; do
|
||||
run_lib "" "describe_http_status $code"
|
||||
assert_contains "HTTP $code" "$OUT" "$expected"
|
||||
done <<'EOF'
|
||||
401|authentication failed
|
||||
403|scopes
|
||||
404|not found
|
||||
422|already exist
|
||||
429|rate limited
|
||||
503|server reported an error
|
||||
418|unexpected HTTP status 418
|
||||
EOF
|
||||
}
|
||||
|
||||
test_http_network_failure() {
|
||||
write_curl_stub
|
||||
run_lib "" "setup_temp_dir
|
||||
STUB_CURL_EXIT=7 http_request GET https://git.example.test/api/v1/user token '$FAKE_GITEA_TOKEN' || echo \"failed: \$HTTP_ERROR\"
|
||||
cleanup"
|
||||
assert_contains "network error reported" "$OUT" "failed: could not reach git.example.test: could not connect"
|
||||
assert_not_contains "token not in the error" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
|
||||
}
|
||||
|
||||
test_http_request_refuses_unsafe_input() {
|
||||
local case_name args expected
|
||||
write_curl_stub
|
||||
while IFS='|' read -r case_name args expected; do
|
||||
run_lib "" "setup_temp_dir
|
||||
http_request $args
|
||||
cleanup"
|
||||
assert_status "$case_name" 1 "$STATUS"
|
||||
assert_contains "$case_name message" "$ERR" "$expected"
|
||||
done <<'EOF'
|
||||
http URL|GET http://git.example.test/x token abcdefgh12345|invalid or non-https URL
|
||||
user info URL|GET https://u:p@git.example.test/x token abcdefgh12345|invalid or non-https URL
|
||||
bad method|TRACE https://git.example.test/x token abcdefgh12345|unsupported HTTP method
|
||||
bad token|GET https://git.example.test/x token 'bad token'|malformed token
|
||||
bad scheme|GET https://git.example.test/x basic abcdefgh12345|unknown authentication scheme
|
||||
EOF
|
||||
assert_file_missing "curl never called" "$WORK/curl.args"
|
||||
}
|
||||
|
||||
test_check_tools_stops_before_any_change() {
|
||||
# An empty PATH: no git, curl or mktemp, so the check must fail first and
|
||||
# the script must not create anything, not even a temporary directory.
|
||||
write_fixtures
|
||||
mkdir -p "$WORK/emptybin"
|
||||
STATUS=0
|
||||
PATH="$WORK/emptybin" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
|
||||
--config "$WORK/config.env" --env "$WORK/.env" </dev/null \
|
||||
>"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
|
||||
assert_status "missing tools" 1 "$STATUS"
|
||||
assert_contains "names the tools" "$(cat "$WORK/err.txt")" "required tool(s) not found: git curl mktemp"
|
||||
assert_eq "nothing created" "" "$(find "$WORK/tmp" -mindepth 1 2>/dev/null)"
|
||||
}
|
||||
|
||||
test_missing_jq_is_only_a_warning() {
|
||||
run_lib "" "command() { if [[ \$1 == -v && \$2 == jq ]]; then return 1; fi; builtin command \"\$@\"; }
|
||||
check_tools
|
||||
echo \"HAS_JQ=\$HAS_JQ\""
|
||||
assert_status "jq optional" 0 "$STATUS"
|
||||
assert_contains "jq flag cleared" "$OUT" "HAS_JQ=0"
|
||||
assert_contains "warning shown" "$ERR" "jq not found"
|
||||
}
|
||||
|
||||
test_json_escape() {
|
||||
run_lib "" 'json_escape "say \"hi\" \\ back"; echo; json_escape $'"'"'a\nb\tc'"'"'; echo'
|
||||
assert_eq "escaped" $'say \\"hi\\" \\\\ back\na\\nb\\tc' "$OUT"
|
||||
}
|
||||
|
||||
test_json_get_with_and_without_jq() {
|
||||
local mode
|
||||
printf '{"id": 42, "name": "RepoFoundry", "private": false, "other": {"name": "x"}}\n' >"$WORK/r.json"
|
||||
for mode in 0 1; do
|
||||
if ((mode)) && ! command -v jq >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
run_lib "" "HAS_JQ=$mode
|
||||
json_get '$WORK/r.json' id
|
||||
json_get '$WORK/r.json' name
|
||||
json_get '$WORK/r.json' private
|
||||
json_get '$WORK/r.json' missing"
|
||||
assert_eq "json_get with HAS_JQ=$mode" $'42\nRepoFoundry\nfalse' "$OUT"
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-prompts.sh - tests for the interactive prompts and their validation
|
||||
# (MIL-001 task: prompts). Answers are piped to stdin. Sourced by
|
||||
# run-tests.sh.
|
||||
|
||||
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||
test_prompt_value_asks_again_until_valid() {
|
||||
run_lib $'bad name\n..\nok-name\n' \
|
||||
'prompt_value "Repository name" "" is_valid_repo_name "use letters"; echo "[$REPLY]"'
|
||||
assert_status "retries" 0 "$STATUS"
|
||||
assert_eq "valid answer returned" "[ok-name]" "$OUT"
|
||||
assert_contains "told why" "$ERR" "invalid Repository name: use letters"
|
||||
}
|
||||
|
||||
test_prompt_value_uses_the_default() {
|
||||
run_lib $'\n' \
|
||||
'prompt_value "Local directory" "./proj" is_valid_directory "x"; echo "[$REPLY]"'
|
||||
assert_eq "default taken" "[./proj]" "$OUT"
|
||||
}
|
||||
|
||||
test_prompt_stops_when_input_ends() {
|
||||
run_lib "" 'prompt_value "Repository name" "" is_valid_repo_name "x" </dev/null'
|
||||
assert_status "end of input" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "no input available for 'Repository name'"
|
||||
}
|
||||
|
||||
test_prompt_choice() {
|
||||
run_lib $'PUBLIC\n' 'prompt_choice "Visibility" private private public; echo "[$REPLY]"'
|
||||
assert_eq "case-insensitive choice" "[public]" "$OUT"
|
||||
run_lib $'\n' 'prompt_choice "Visibility" private private public; echo "[$REPLY]"'
|
||||
assert_eq "default choice" "[private]" "$OUT"
|
||||
run_lib $'secret\nprivate\n' 'prompt_choice "Visibility" private private public; echo "[$REPLY]"'
|
||||
assert_eq "invalid then valid" "[private]" "$OUT"
|
||||
assert_contains "told the options" "$ERR" "choose one of private public"
|
||||
}
|
||||
|
||||
test_prompt_yes_no() {
|
||||
local answer expected
|
||||
while IFS='|' read -r answer expected; do
|
||||
run_lib "$answer"$'\n' 'prompt_yes_no "Use GitHub" y; echo "[$REPLY]"'
|
||||
assert_eq "answer '$answer'" "[$expected]" "$OUT"
|
||||
done <<'EOF'
|
||||
|1
|
||||
y|1
|
||||
YES|1
|
||||
n|0
|
||||
No|0
|
||||
EOF
|
||||
run_lib $'maybe\nn\n' 'prompt_yes_no "Use GitHub" y; echo "[$REPLY]"'
|
||||
assert_eq "invalid then valid" "[0]" "$OUT"
|
||||
assert_contains "told what to answer" "$ERR" "answer y or n"
|
||||
}
|
||||
|
||||
test_collect_details_with_github() {
|
||||
run_lib $'my-app\nA test app\npublic\nTirSystem\ny\nmy-org\n\ny\n' \
|
||||
'collect_project_details
|
||||
for k in name description visibility gitea_owner has_github github_owner directory is_plan_gate_enabled; do
|
||||
printf "%s=%s\n" "$k" "${PROJECT[$k]}"
|
||||
done'
|
||||
assert_status "details collected" 0 "$STATUS"
|
||||
assert_eq "details" $'name=my-app\ndescription=A test app\nvisibility=public\ngitea_owner=TirSystem\nhas_github=1\ngithub_owner=my-org\ndirectory=./my-app\nis_plan_gate_enabled=1' "$OUT"
|
||||
}
|
||||
|
||||
test_collect_details_without_github() {
|
||||
run_lib $'my-app\n\n\nTirSystem\nn\n\nn\n' \
|
||||
'collect_project_details
|
||||
printf "%s|%s|%s|%s\n" "${PROJECT[visibility]}" "${PROJECT[has_github]}" "[${PROJECT[github_owner]}]" "${PROJECT[is_plan_gate_enabled]}"'
|
||||
assert_status "GitHub skipped" 0 "$STATUS"
|
||||
assert_eq "defaults and no GitHub owner" "private|0|[]|0" "$OUT"
|
||||
assert_not_contains "no GitHub owner prompt" "$ERR" "GitHub owner"
|
||||
}
|
||||
|
||||
test_github_user_is_a_default_not_the_owner() {
|
||||
# GITHUB_USER only pre-fills the prompt; the Maintainer can pick an
|
||||
# organization instead.
|
||||
run_lib $'my-app\n\n\nTirSystem\ny\n\n\nn\n' \
|
||||
'CREDENTIALS[GITHUB_USER]=octo-user
|
||||
collect_project_details
|
||||
echo "${PROJECT[github_owner]}"'
|
||||
assert_eq "default is the account" "octo-user" "$OUT"
|
||||
run_lib $'my-app\n\n\nTirSystem\ny\nacme-org\n\nn\n' \
|
||||
'CREDENTIALS[GITHUB_USER]=octo-user
|
||||
collect_project_details
|
||||
echo "${PROJECT[github_owner]}"'
|
||||
assert_eq "organization chosen" "acme-org" "$OUT"
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-security.sh - end-to-end tests: no token in any output, no network
|
||||
# call, no change on disk, clean temporary files, safe failure paths
|
||||
# (MIL-001 Go/No-Go criteria 2 to 4). Sourced by run-tests.sh.
|
||||
|
||||
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||
|
||||
# listing: all files under the work directory except the test's own captures.
|
||||
listing() {
|
||||
find "$WORK" -type f ! -name out.txt ! -name err.txt ! -name snippet.sh \
|
||||
! -name 'curl.*' ! -name 'routes*' ! -name 'ssh.args' | sort
|
||||
}
|
||||
|
||||
test_full_run_with_github() {
|
||||
setup_hosts
|
||||
local before after
|
||||
before="$(listing)"
|
||||
run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env"
|
||||
after="$(listing)"
|
||||
assert_status "full run" 0 "$STATUS"
|
||||
assert_contains "dry run" "$OUT" "Dry run: nothing was created"
|
||||
assert_contains "plan" "$OUT" "create (private) with the AGPL-3.0 license"
|
||||
assert_contains "Gitea link derived from config" "$OUT" "https://git.example.test/TirSystem/my-app"
|
||||
assert_contains "GitHub link uses the chosen organization" "$OUT" "https://github.com/acme-org/my-app"
|
||||
assert_contains "AGPL noted" "$OUT" "AGPL license applied"
|
||||
assert_contains "credential state" "$OUT" "GITEA_TOKEN set, GITHUB_PAT set"
|
||||
assert_not_contains "no Gitea token in output" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
|
||||
assert_not_contains "no GitHub token in output" "$OUT$ERR" "$FAKE_GITHUB_PAT"
|
||||
assert_contains "reads from Gitea" "$(calls)" "GET https://git.example.test/api/v1/user"
|
||||
assert_contains "reads from GitHub" "$(calls)" "GET https://api.github.com/user"
|
||||
assert_not_contains "a dry run only reads" "$(calls)" "POST"
|
||||
assert_not_contains "a dry run never deletes" "$(calls)" "DELETE"
|
||||
assert_eq "no file created or changed" "$before" "$after"
|
||||
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
}
|
||||
|
||||
test_full_run_without_github() {
|
||||
setup_hosts
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
run_cli "$ANSWERS_GITEA_ONLY" --config "$WORK/config.env" --env "$WORK/.env"
|
||||
assert_status "Gitea-only run needs no GitHub credentials" 0 "$STATUS"
|
||||
assert_contains "GitHub not used" "$OUT" "GitHub : not used"
|
||||
assert_not_contains "no AGPL line" "$OUT" "AGPL"
|
||||
assert_contains "GITHUB_PAT not set" "$OUT" "GITHUB_PAT not set"
|
||||
assert_not_contains "no call to GitHub" "$(calls)" "api.github.com"
|
||||
assert_not_contains "no license lookup without GitHub" "$(calls)" "/licenses"
|
||||
assert_contains "plan says GitHub is not used" "$OUT" "GitHub repository : not used"
|
||||
}
|
||||
|
||||
test_github_chosen_without_credentials_fails() {
|
||||
write_fixtures
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env"
|
||||
assert_status "missing GitHub credentials" 1 "$STATUS"
|
||||
assert_contains "names the key" "$ERR" "GITHUB_PAT is missing"
|
||||
assert_not_contains "no token in the error" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
|
||||
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
}
|
||||
|
||||
test_error_messages_never_contain_the_value() {
|
||||
write_fixtures
|
||||
printf 'GITEA_TOKEN=S3CR3T\nGITHUB_PAT=%s\n' "$FAKE_GITHUB_PAT" >"$WORK/.env"
|
||||
run_cli "" --config "$WORK/config.env" --env "$WORK/.env"
|
||||
assert_status "invalid token" 1 "$STATUS"
|
||||
assert_contains "says what is wrong" "$ERR" "GITEA_TOKEN"
|
||||
assert_not_contains "invalid value not echoed" "$OUT$ERR" "S3CR3T"
|
||||
assert_not_contains "valid PAT not echoed" "$OUT$ERR" "$FAKE_GITHUB_PAT"
|
||||
printf 'this line holds %s as text\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
run_cli "" --config "$WORK/config.env" --env "$WORK/.env"
|
||||
assert_status "malformed line" 1 "$STATUS"
|
||||
assert_not_contains "malformed line not echoed" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
|
||||
}
|
||||
|
||||
test_unknown_credential_key_in_config_is_rejected() {
|
||||
write_fixtures
|
||||
printf 'GITEA_URL=https://git.example.test\nGITHUB_PAT=%s\n' "$FAKE_GITHUB_PAT" >"$WORK/config.env"
|
||||
run_cli "" --config "$WORK/config.env" --env "$WORK/.env"
|
||||
assert_status "credential in config.env" 1 "$STATUS"
|
||||
assert_contains "explains" "$ERR" "keep it in the .env file only"
|
||||
assert_not_contains "token not echoed" "$OUT$ERR" "$FAKE_GITHUB_PAT"
|
||||
}
|
||||
|
||||
test_redaction() {
|
||||
run_lib "" "SECRET_VALUES=('$FAKE_GITEA_TOKEN')
|
||||
say 'token is $FAKE_GITEA_TOKEN here'
|
||||
warn 'also $FAKE_GITEA_TOKEN'
|
||||
die 'and $FAKE_GITEA_TOKEN'"
|
||||
assert_status "die exits 1" 1 "$STATUS"
|
||||
assert_eq "say redacts" "token is [redacted] here" "$OUT"
|
||||
assert_not_contains "stderr redacted" "$ERR" "$FAKE_GITEA_TOKEN"
|
||||
assert_contains "die message" "$ERR" "error: and [redacted]"
|
||||
}
|
||||
|
||||
test_usage_errors() {
|
||||
run_cli "" --bogus
|
||||
assert_status "unknown option" 2 "$STATUS"
|
||||
assert_contains "says so" "$ERR" "unknown option: --bogus"
|
||||
run_cli "" --config
|
||||
assert_status "option without value" 2 "$STATUS"
|
||||
run_cli "" --help
|
||||
assert_status "help" 0 "$STATUS"
|
||||
assert_contains "help shows usage" "$OUT" "Usage"
|
||||
assert_contains "help shows exit codes" "$OUT" "Exit codes"
|
||||
run_cli "" --version
|
||||
assert_status "version" 0 "$STATUS"
|
||||
assert_contains "version output" "$OUT" "RepoFoundry 0.2.0"
|
||||
}
|
||||
|
||||
test_warns_when_env_is_not_ignored_by_git() {
|
||||
setup_hosts
|
||||
git init -q "$WORK/repo"
|
||||
cp "$WORK/.env" "$WORK/repo/.env"
|
||||
cp "$WORK/config.env" "$WORK/repo/config.env"
|
||||
run_cli "$ANSWERS_GITEA_ONLY" --config "$WORK/repo/config.env" --env "$WORK/repo/.env"
|
||||
assert_status "still runs" 0 "$STATUS"
|
||||
assert_contains "warns" "$ERR" "is not ignored by git"
|
||||
assert_not_contains "no token in the warning" "$ERR" "$FAKE_GITEA_TOKEN"
|
||||
find "$WORK/repo" -type f -delete
|
||||
find "$WORK/repo" -depth -type d -exec rmdir {} +
|
||||
}
|
||||
|
||||
test_script_uses_no_unsafe_constructs() {
|
||||
local code
|
||||
code="$(cat "$SCRIPT" "$SRC_DIR"/lib/*.sh | grep -vE '^[[:space:]]*#')"
|
||||
assert_not_contains "no rm -rf" "$code" "rm -rf"
|
||||
assert_not_contains "no rm -r" "$code" "rm -r "
|
||||
assert_not_contains "no eval" "$code" "eval "
|
||||
assert_not_contains "no dot-source" "$code" $'\n. '
|
||||
# Only the script's own library files are sourced, by a fixed path; a
|
||||
# configuration file never is.
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
check
|
||||
if [[ $line != 'source "$SCRIPT_DIR/lib/'*'.sh"' ]]; then
|
||||
fail "unexpected source line: $line"
|
||||
fi
|
||||
done < <(grep -hE '(^|[[:space:];])source ' <<<"$code")
|
||||
check
|
||||
if grep -Eq '^[[:space:]]*set -[A-Za-z]*x' <<<"$code"; then
|
||||
fail "the script turns tracing on"
|
||||
fi
|
||||
assert_not_contains "no fixed /tmp file" "$code" "/tmp/file"
|
||||
}
|
||||
|
||||
test_tracing_does_not_leak_secrets() {
|
||||
# bash -x would print every assignment and command, secrets included, so
|
||||
# the script switches tracing off and says so.
|
||||
setup_hosts
|
||||
STATUS=0
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \
|
||||
--config "$WORK/config.env" --env "$WORK/.env" <<<"$ANSWERS_GITHUB" \
|
||||
>"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
|
||||
assert_status "run under bash -x" 0 "$STATUS"
|
||||
assert_contains "tracing disabled" "$(cat "$WORK/err.txt")" "tracing (set -x) is disabled"
|
||||
assert_not_contains "no Gitea token in the trace" "$(cat "$WORK/err.txt" "$WORK/out.txt")" "$FAKE_GITEA_TOKEN"
|
||||
assert_not_contains "no GitHub token in the trace" "$(cat "$WORK/err.txt" "$WORK/out.txt")" "$FAKE_GITHUB_PAT"
|
||||
}
|
||||
|
||||
test_byte_order_mark_is_accepted() {
|
||||
printf '\xef\xbb\xbfGITEA_URL=https://a.test\nGITHUB_WEB_URL=https://b.test\n' >"$WORK/c.env"
|
||||
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
|
||||
echo \"\${CONFIG[GITEA_URL]}\""
|
||||
assert_status "BOM on the first line" 0 "$STATUS"
|
||||
assert_eq "first key read" "https://a.test" "$OUT"
|
||||
}
|
||||
|
||||
test_termination_removes_temp_files() {
|
||||
write_fixtures
|
||||
if ! mkfifo "$WORK/in" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
|
||||
--config "$WORK/config.env" --env "$WORK/.env" <"$WORK/in" \
|
||||
>/dev/null 2>&1 &
|
||||
local pid=$! tries=0
|
||||
# Keep the pipe open so the script waits at its first prompt.
|
||||
exec 7>"$WORK/in"
|
||||
while [[ -z "$(find "$WORK/tmp" -mindepth 1)" ]] && ((tries < 50)); do
|
||||
sleep 0.1
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
assert_eq "temp directory exists while running" 1 "$(find "$WORK/tmp" -mindepth 1 | wc -l | tr -d ' ')"
|
||||
kill -TERM "$pid"
|
||||
# wait returns the signal status (143); only the cleanup matters here.
|
||||
wait "$pid" 2>/dev/null || true
|
||||
exec 7>&-
|
||||
assert_eq "temp directory removed after SIGTERM" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
}
|
||||
|
||||
test_script_lives_in_src() {
|
||||
assert_file_exists "script in src/" "$REPO_ROOT/src/create-project.sh"
|
||||
assert_file_missing "no copy in the project root" "$REPO_ROOT/create-project.sh"
|
||||
}
|
||||
|
||||
test_default_files_are_in_the_project_root() {
|
||||
# A project copy: script in src/, config.env and .env one level up.
|
||||
setup_hosts
|
||||
mkdir -p "$WORK/project/src"
|
||||
cp "$SCRIPT" "$WORK/project/src/create-project.sh"
|
||||
cp -R "$SRC_DIR/lib" "$WORK/project/src/lib"
|
||||
cp "$WORK/config.env" "$WORK/project/config.env"
|
||||
cp "$WORK/.env" "$WORK/project/.env"
|
||||
STATUS=0
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
|
||||
<<<"$ANSWERS_GITHUB" >"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
|
||||
assert_status "run with the default files" 0 "$STATUS"
|
||||
assert_contains "found config.env in the project root" "$(cat "$WORK/out.txt")" "https://git.example.test/TirSystem/my-app"
|
||||
# Run from another directory: the defaults follow the script, not the cwd.
|
||||
STATUS=0
|
||||
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
|
||||
<<<"$ANSWERS_GITEA_ONLY" >"$WORK/out.txt" 2>"$WORK/err.txt") || STATUS=$?
|
||||
assert_status "run from another directory" 0 "$STATUS"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-structure.sh - guards for the split of create-project.sh into files
|
||||
# with one responsibility each. Sourced by run-tests.sh.
|
||||
|
||||
# shellcheck disable=SC2016 # snippet and pattern text is literal on purpose
|
||||
|
||||
# lib_names: the names of the files in src/lib, one per line.
|
||||
lib_names() {
|
||||
local file
|
||||
for file in "$SRC_DIR"/lib/*.sh; do
|
||||
file="${file##*/}"
|
||||
printf '%s\n' "${file%.sh}"
|
||||
done
|
||||
}
|
||||
|
||||
test_every_library_file_is_loaded_and_nothing_else() {
|
||||
local loaded on_disk
|
||||
loaded="$(grep -E '^source "\$SCRIPT_DIR/lib/' "$SCRIPT" | sed -e 's|.*/lib/||' -e 's|\.sh"$||' | sort)"
|
||||
on_disk="$(lib_names | sort)"
|
||||
assert_eq "the files that are loaded are the files in src/lib" "$on_disk" "$loaded"
|
||||
}
|
||||
|
||||
test_every_library_file_states_one_responsibility() {
|
||||
local name line
|
||||
for name in $(lib_names); do
|
||||
line="$(head -n 4 "$SRC_DIR/lib/$name.sh" | grep -m 1 "^# $name.sh - " || true)"
|
||||
check
|
||||
if [[ -z $line ]]; then
|
||||
fail "src/lib/$name.sh must name its responsibility in its first lines ('# $name.sh - ...')"
|
||||
fi
|
||||
check
|
||||
if ! grep -q '^# Provides: ' "$SRC_DIR/lib/$name.sh" && [[ $name != constants ]]; then
|
||||
fail "src/lib/$name.sh does not list what it provides"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
test_no_function_is_defined_twice() {
|
||||
local duplicates
|
||||
duplicates="$(cat "$SCRIPT" "$SRC_DIR"/lib/*.sh | grep -oE '^[a-z_]+\(\) \{' | sort | uniq -d)"
|
||||
assert_eq "each function is defined in exactly one file" "" "$duplicates"
|
||||
}
|
||||
|
||||
test_the_listed_functions_exist_in_their_file() {
|
||||
local name list fn
|
||||
for name in $(lib_names); do
|
||||
list="$(sed -n 's/^# Provides: //p' "$SRC_DIR/lib/$name.sh" | tr -d ',')"
|
||||
for fn in $list; do
|
||||
check
|
||||
if ! grep -q "^$fn() {" "$SRC_DIR/lib/$name.sh"; then
|
||||
fail "src/lib/$name.sh says it provides $fn but does not define it"
|
||||
fi
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
test_library_files_have_no_side_effects_when_sourced_alone() {
|
||||
# A library file only defines functions and constants: running it by itself
|
||||
# (after the constants) prints nothing and makes no request.
|
||||
local name
|
||||
write_curl_stub
|
||||
for name in $(lib_names); do
|
||||
if [[ $name == constants ]]; then
|
||||
continue
|
||||
fi
|
||||
STATUS=0
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" "$BASH" -c \
|
||||
'SCRIPT_DIR="$1"; PROJECT_ROOT="$2"; source "$1/lib/constants.sh"; source "$1/lib/$3.sh"' \
|
||||
_ "$SRC_DIR" "$REPO_ROOT" "$name" >"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
|
||||
assert_status "sourcing lib/$name.sh" 0 "$STATUS"
|
||||
assert_eq "lib/$name.sh prints nothing" "" "$(cat "$WORK/out.txt" "$WORK/err.txt")"
|
||||
done
|
||||
assert_eq "no request made by sourcing" "" "$(calls)"
|
||||
}
|
||||
|
||||
test_the_entry_point_is_small_and_holds_no_helpers() {
|
||||
local helpers
|
||||
helpers="$(grep -oE '^[a-z_]+\(\) \{' "$SCRIPT" | tr -d '(){ ' | sort | tr '\n' ' ')"
|
||||
assert_eq "only finish and main live in the entry point" "finish main " "$helpers"
|
||||
}
|
||||
|
||||
test_no_library_file_is_ignored_by_git() {
|
||||
# The Python template in .gitignore ignores any folder named lib/; a file
|
||||
# that git ignores would silently be left out of every commit.
|
||||
local name
|
||||
for name in $(lib_names); do
|
||||
check
|
||||
if git -C "$REPO_ROOT" check-ignore -q --no-index "src/lib/$name.sh"; then
|
||||
fail "src/lib/$name.sh is ignored by git; check .gitignore (!src/lib)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
Reference in New Issue
Block a user